Configuracao completa MicrotecFlix: dominios estudo e admin-estudo, postgres dedicado, docker-stack para Portainer e Traefik

This commit is contained in:
Sidney Gomes 2026-07-20 18:27:24 -03:00
commit 26622a8905
35 changed files with 7040 additions and 0 deletions

18
.env.example Normal file
View File

@ -0,0 +1,18 @@
# 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"
# JWT Secret Key for token generation
JWT_SECRET="YOUR_SUPER_SECRET_JWT_KEY"
# Asaas API Configuration (Sandbox or Production)
ASAAS_API_KEY="YOUR_ASAAS_API_KEY"
ASAAS_WEBHOOK_TOKEN="YOUR_ASAAS_WEBHOOK_TOKEN"
ASAAS_ENV="sandbox" # Set to 'production' for live payments

View File

@ -0,0 +1,29 @@
name: MicrotecFlix CI-CD Pipeline
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Build Docker Image
run: |
docker build -t microtecflix-app:local .
- name: Deploy / Update Docker Swarm Stack
run: |
if [ -n "${{ secrets.PORTAINER_WEBHOOK_URL }}" ]; then
curl -X POST "${{ secrets.PORTAINER_WEBHOOK_URL }}"
echo "Webhook do Portainer acionado com sucesso!"
else
echo "Nenhum webhook do Portainer configurado. A stack docker-stack.yml pode ser implantada via Portainer UI ou docker stack deploy."
fi

8
.gitignore vendored Normal file
View File

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

26
Dockerfile Normal file
View File

@ -0,0 +1,26 @@
# --- Stage 1: Build ---
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* bun.lock* ./
RUN npm install
COPY . .
RUN npm run build
# --- Stage 2: Runner ---
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
COPY package.json package-lock.json* bun.lock* ./
RUN npm install --only=production
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/database.json ./database.json
EXPOSE 3000
CMD ["node", "dist/server.cjs"]

20
README.md Normal file
View File

@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://ai.google.dev/static/site-assets/images/share-ais-513315318.png" />
</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/68bbe346-a08e-4443-82c7-167aa29b34a5
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

686
bun.lock Normal file
View File

@ -0,0 +1,686 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "react-example",
"dependencies": {
"@google/genai": "^2.4.0",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"bcryptjs": "^3.0.3",
"dotenv": "^17.2.3",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.3",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"vite": "^6.2.3",
},
"devDependencies": {
"@types/bcryptjs": "^3.0.0",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"esbuild": "^0.25.0",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.3",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
"@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
"@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
"@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
"@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
"@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="],
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="],
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
"@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="],
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@google/genai": ["@google/genai@2.12.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-LUr972DZosqPUhf9Mb3CIVu/B99woD3QW6ZJV1T9aNgxaoimAZARmo+IyyDsxIL+zouFiYSdA4hzfEWXc9oNIQ=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="],
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="],
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="],
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.2", "", { "os": "none", "cpu": "arm64" }, "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="],
"@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/bcryptjs": ["@types/bcryptjs@3.0.0", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="],
"@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/express": ["@types/express@4.17.25", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", "@types/qs": "*", "@types/serve-static": "^1" } }, "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw=="],
"@types/express-serve-static-core": ["@types/express-serve-static-core@4.19.9", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg=="],
"@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="],
"@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.10", "", { "dependencies": { "@types/ms": "*", "@types/node": "*" } }, "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA=="],
"@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="],
"@types/qs": ["@types/qs@6.15.1", "", {}, "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="],
"@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
"@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="],
"@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="],
"@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="],
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="],
"autoprefixer": ["autoprefixer@10.5.4", "", { "dependencies": { "browserslist": "^4.28.6", "caniuse-lite": "^1.0.30001806", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.44", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-T3ghW+sl/ZJ8w1v/yQx3qvJ9040DWoLBz8JT/CILbAKcFyG9b2MRe75v6W5uXjv6uH1lumK2Kv46y2zSkcej0Q=="],
"bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="],
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
"body-parser": ["body-parser@1.20.6", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g=="],
"browserslist": ["browserslist@4.28.6", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001803", "electron-to-chromium": "^1.5.389", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw=="],
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="],
"content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="],
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"electron-to-chromium": ["electron-to-chromium@1.5.393", "", {}, "sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"enhanced-resolve": ["enhanced-resolve@5.24.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"express": ["express@4.22.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
"finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="],
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
"framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="],
"fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"gaxios": ["gaxios@7.2.0", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-CUVb4wcYe+771XevyH6HtGmXFAGGKkIC3kswAP8Z1JCe0j80JMaTPZH930DWFrvo0atjh18Arc0pEyUCWa5bfg=="],
"gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"google-auth-library": ["google-auth-library@10.9.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg=="],
"google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="],
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
"lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="],
"lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="],
"lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="],
"lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="],
"lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="],
"lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="],
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@0.546.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
"merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="],
"methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="],
"mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"motion": ["motion@12.42.2", "", { "dependencies": { "framer-motion": "^12.42.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q=="],
"motion-dom": ["motion-dom@12.42.2", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA=="],
"motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
"negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
"postcss": ["postcss@8.5.20", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"protobufjs": ["protobufjs@7.6.5", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="],
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="],
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
"retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
"rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="],
"serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="],
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsx": ["tsx@4.23.1", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ=="],
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"@babel/core/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@types/serve-static/@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="],
"debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"https-proxy-agent/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"tsx/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
"tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
"tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
"tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
"tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
"tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
"tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
"tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
"tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
"tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
"tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
"tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
"tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
"tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
"tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
"tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
"tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
"tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
"tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
"tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
"tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
"tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
"tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
"tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
"tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
}
}

77
docker-stack.yml Normal file
View File

@ -0,0 +1,77 @@
version: '3.8'
services:
# =============================================================
# POSTGRESQL — Banco de Dados do MicrotecFlix (Dedicado)
# =============================================================
postgres-microtecflix:
image: postgres:16-alpine
restart: always
environment:
POSTGRES_DB: microtecflix
POSTGRES_USER: microtecflix_user
POSTGRES_PASSWORD: MicrotecFlixSecurePass2026!
ports:
- "5435:5432" # Porta 5435 para evitar conflitos com outros Postgres (ex: AgendaPRO 5432)
volumes:
- pgdata-microtecflix:/var/lib/postgresql/data
networks:
- microtecflix-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U microtecflix_user -d microtecflix"]
interval: 10s
timeout: 5s
retries: 5
deploy:
replicas: 1
restart_policy:
condition: on-failure
# =============================================================
# MICROTECFLIX WEB APP — Plataforma de Cursos (Aluno + Admin)
# =============================================================
microtecflix:
image: microtecflix-app:local
restart: always
depends_on:
- postgres-microtecflix
environment:
- NODE_ENV=production
- PORT=3000
- DATABASE_URL=postgresql://microtecflix_user:MicrotecFlixSecurePass2026!@postgres-microtecflix:5432/microtecflix
- JWT_SECRET=microtecflix_jwt_secret_key_2026_super_secure
- ASAAS_ENVIRONMENT=sandbox
networks:
- microtecflix-net
- network_public
deploy:
replicas: 1
update_config:
parallelism: 1
delay: 10s
order: start-first
restart_policy:
condition: on-failure
max_attempts: 3
labels:
- "traefik.enable=true"
# Painel do Aluno
- "traefik.http.routers.microtecflix-student.rule=Host(`estudo.microtecinformaticacurso.com.br`)"
- "traefik.http.routers.microtecflix-student.entrypoints=websecure"
- "traefik.http.routers.microtecflix-student.tls.certresolver=leresolver"
- "traefik.http.services.microtecflix-student.loadbalancer.server.port=3000"
# Painel Admin
- "traefik.http.routers.microtecflix-admin.rule=Host(`admin-estudo.microtecinformaticacurso.com.br`)"
- "traefik.http.routers.microtecflix-admin.entrypoints=websecure"
- "traefik.http.routers.microtecflix-admin.tls.certresolver=leresolver"
- "traefik.http.services.microtecflix-admin.loadbalancer.server.port=3000"
- "traefik.docker.network=network_public"
volumes:
pgdata-microtecflix:
networks:
microtecflix-net:
driver: overlay
network_public:
external: true

110
gemini.md Normal file
View File

@ -0,0 +1,110 @@
# MicrotecFlix - Registro de Modificações e Guia de Deploy
Este arquivo documenta a estrutura completa, configurações de domínio, banco de dados PostgreSQL e procedimento de deploy do projeto **MicrotecFlix** (Plataforma de Cursos e Assinaturas estilo Netflix).
---
## 1. Mapeamento de Domínios Oficiais
* **Painel do Aluno**: `estudo.microtecinformaticacurso.com.br`
* **Painel do Administrador**: `admin-estudo.microtecinformaticacurso.com.br`
O sistema realiza o roteamento automático do frontend via `src/Root.tsx`:
* Quando a URL acessada contém a palavra `admin` (`admin-estudo.microtecinformaticacurso.com.br`), o sistema renderiza o `AdminApp.tsx`.
* Para as demais URLs (`estudo.microtecinformaticacurso.com.br`), o sistema renderiza o `App.tsx` (Painel do Aluno).
---
## 2. Arquitetura do Banco de Dados PostgreSQL (Dedicado)
Para evitar conflitos com outros bancos de dados ativos na VPS (como o PostgreSQL do AgendaPRO na porta `5432`), o MicrotecFlix utiliza uma instância isolada e dedicada de PostgreSQL:
* **Container**: `postgres-microtecflix`
* **Imagem**: `postgres:16-alpine`
* **Porta Host**: `5435:5432` (evita colisão com a porta 5432 original)
* **Database**: `microtecflix`
* **Usuário**: `microtecflix_user`
* **Senha**: `MicrotecFlixSecurePass2026!`
* **Volume**: `pgdata-microtecflix`
---
## 3. Usuários de Teste Mantidos (Seed Data)
Os dados de acesso para ambiente de testes foram preservados:
| Papel | E-mail | Senha | Descrição |
| :--- | :--- | :--- | :--- |
| **Admin** | `sidney.gomes1989@gmail.com` | `admin123` | Acesso total ao painel administrativo |
| **Aluno** | `aluno@microtecflix.com` | `aluno123` | Aluno ativo em ambiente de testes |
| **Aluno** | `joao@microtecflix.com` | `aluno123` | Aluno com curso de Excel liberado |
| **Aluno** | `maria@microtecflix.com` | `aluno123` | Aluna ativa |
| **Aluno** | `pedro@microtecflix.com` | `aluno123` | Aluno com mensalidade em atraso (Overdue) |
| **Aluno** | `ana@microtecflix.com` | `aluno123` | Aluna com assinatura cancelada |
---
## 4. Estrutura de Docker & Deployment no Portainer
O arquivo `docker-stack.yml` está pré-configurado para implantação no **Docker Swarm com Portainer e Traefik**:
```yaml
version: '3.8'
services:
postgres-microtecflix:
image: postgres:16-alpine
restart: always
environment:
POSTGRES_DB: microtecflix
POSTGRES_USER: microtecflix_user
POSTGRES_PASSWORD: MicrotecFlixSecurePass2026!
ports:
- "5435:5432"
volumes:
- pgdata-microtecflix:/var/lib/postgresql/data
networks:
- microtecflix-net
microtecflix:
image: microtecflix-app:local
restart: always
depends_on:
- postgres-microtecflix
environment:
- NODE_ENV=production
- PORT=3000
- DATABASE_URL=postgresql://microtecflix_user:MicrotecFlixSecurePass2026!@postgres-microtecflix:5432/microtecflix
- JWT_SECRET=microtecflix_jwt_secret_key_2026_super_secure
- ASAAS_ENVIRONMENT=sandbox
networks:
- microtecflix-net
- network_public
deploy:
replicas: 1
labels:
- "traefik.enable=true"
- "traefik.http.routers.microtecflix-student.rule=Host(`estudo.microtecinformaticacurso.com.br`)"
- "traefik.http.routers.microtecflix-student.entrypoints=websecure"
- "traefik.http.routers.microtecflix-student.tls.certresolver=leresolver"
- "traefik.http.services.microtecflix-student.loadbalancer.server.port=3000"
- "traefik.http.routers.microtecflix-admin.rule=Host(`admin-estudo.microtecinformaticacurso.com.br`)"
- "traefik.http.routers.microtecflix-admin.entrypoints=websecure"
- "traefik.http.routers.microtecflix-admin.tls.certresolver=leresolver"
- "traefik.http.services.microtecflix-admin.loadbalancer.server.port=3000"
- "traefik.docker.network=network_public"
```
---
## 5. Instruções para envio ao Gitea
Repositório no Gitea: `https://gitea.microtecinformaticacurso.com.br/sidney/microtecflix.git`
Passos executados:
1. `git init`
2. `git checkout -b main`
3. `git remote add origin https://gitea.microtecinformaticacurso.com.br/sidney/microtecflix.git`
4. `git add .`
5. `git commit -m "Configuracao completa MicrotecFlix para deploy no Docker Swarm via Portainer"`
6. `git push -u origin main`

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>

56
memory.md Normal file
View File

@ -0,0 +1,56 @@
# Contexto de Memória e Arquitetura - MicrotecFlix
Este arquivo registra o contexto de desenvolvimento, arquitetura, esquema de dados e regras de negócio da plataforma **MicrotecFlix**.
---
## 1. Visão Geral do Sistema
O **MicrotecFlix** é uma plataforma EAD / Streaming de cursos de informática e negócios baseada em assinaturas recorrentes com integração ao gateway de pagamentos Asaas.
### Funcionalidades do Aluno (`App.tsx`):
* Catálogo de Cursos (Windows 11, Word, Excel, PowerPoint).
* Player de Vídeo com aulas em streaming (YouTube ou links diretos MP4).
* Progresso de aula (porcentagem assistida e status de conclusão).
* Bloco de anotações por aula.
* Assinatura e checkout integrado via Pix, Boleto e Cartão de Crédito.
### Funcionalidades do Administrador (`AdminApp.tsx`):
* Dashboard com métricas financeiras (MRR, Receita Total, Taxa de Inadimplência, Alunos Ativos).
* CRUD de Cursos, Módulos e Aulas.
* Gestão de Alunos e alteração manual de status de assinatura (`ACTIVE`, `OVERDUE`, `CANCELED`, `TRIAL`).
* Visualização do histórico de Webhooks do Asaas.
---
## 2. Esquema de Entidades (`prisma/schema.prisma` / PostgreSQL)
1. **`User`**: Usuários da plataforma (`admin` ou `student`) com status de assinatura (`ACTIVE`, `OVERDUE`, `CANCELED`, `TRIAL`).
2. **`Course`**: Cursos cadastrados com título, descrição, thumbnail, categoria e preço.
3. **`Module`**: Módulos organizados sequencialmente por curso.
4. **`Lesson`**: Aulas vinculadas ao módulo com URL de vídeo (`youtube` ou `direct`) e conteúdo complementar.
5. **`Note`**: Anotações pessoais feitas pelos alunos por aula.
6. **`Progress`**: Histórico de progresso do aluno por aula (% assistida e `completed`).
7. **`Payment`**: Histórico de faturas e cobranças registradas no gateway de pagamentos.
8. **`WebhookLog`**: Registro auditável de webhooks recebidos do Asaas.
---
## 3. Infraestrutura & Portainer / Swarm
* **Rede Traefik Public**: `network_public`
* **Rede Interna App-DB**: `microtecflix-net` (Overlay)
* **Containers na Stack**:
* `postgres-microtecflix` (Porta externa `5435:5432`)
* `microtecflix` (Porta interna `3000`, exposto via Traefik HTTPS)
* **Domínios Mapeados**:
* `estudo.microtecinformaticacurso.com.br` -> Aluno
* `admin-estudo.microtecinformaticacurso.com.br` -> Admin
---
## 4. Diretrizes para Futuras Edições
1. **Preservação da Autenticação JWT**: O token JWT é armazenado em `localStorage` sob a chave `microtecflix_token`. Qualquer nova rota de API restrita deve utilizar o middleware `authenticateToken`.
2. **Isolamento de Banco de Dados**: O container `postgres-microtecflix` roda na porta 5435 do host para garantir que não haja conflitos de porta com o banco PostgreSQL de outros projetos (ex: AgendaPRO na porta 5432).
3. **Deploy**: O arquivo `docker-stack.yml` deve ser atualizado sempre que novas variáveis de ambiente ou volumes forem necessários para a stack do Portainer.

6
metadata.json Normal file
View File

@ -0,0 +1,6 @@
{
"name": "MicrotecFlix",
"description": "Uma plataforma completa de cursos profissionalizantes de informática por assinatura estilo Netflix, com Windows, pacote Office (Word, Excel, PowerPoint) e controle administrativo de bloqueios.",
"requestFramePermissions": [],
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
}

39
package.json Normal file
View File

@ -0,0 +1,39 @@
{
"name": "react-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "tsx server.ts",
"build": "vite build && esbuild server.ts --bundle --platform=node --format=cjs --packages=external --sourcemap --outfile=dist/server.cjs",
"start": "node dist/server.cjs",
"clean": "rm -rf dist database.json",
"lint": "tsc --noEmit"
},
"dependencies": {
"@google/genai": "^2.4.0",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"bcryptjs": "^3.0.3",
"dotenv": "^17.2.3",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.3",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"vite": "^6.2.3"
},
"devDependencies": {
"@types/bcryptjs": "^3.0.0",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"esbuild": "^0.25.0",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.3"
}
}

155
prisma/schema.prisma Normal file
View File

@ -0,0 +1,155 @@
// Prisma Schema for DevFlix / InfoFlix Subscription Platform
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
enum Role {
admin
student
}
enum SubscriptionStatus {
ACTIVE
OVERDUE
CANCELED
TRIAL
}
enum VideoType {
youtube
direct
}
enum PaymentStatus {
PENDING
RECEIVED
CONFIRMED
OVERDUE
REFUNDED
}
enum BillingType {
PIX
CREDIT_CARD
BOLETO
}
model User {
id String @id @default(uuid())
name String
email String @unique
password String
role Role @default(student)
subscriptionStatus SubscriptionStatus @default(TRIAL) @map("subscription_status")
asaasCustomerId String? @map("asaas_customer_id")
createdAt DateTime @default(now()) @map("created_at")
notes Note[]
progress Progress[]
payments Payment[]
@@map("users")
}
model Course {
id String @id @default(uuid())
title String
description String @db.Text
thumbnail String
category String
createdAt DateTime @default(now()) @map("created_at")
modules Module[]
@@map("courses")
}
model Module {
id String @id @default(uuid())
courseId String @map("course_id")
title String
order Int
createdAt DateTime @default(now()) @map("created_at")
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
lessons Lesson[]
@@map("modules")
}
model Lesson {
id String @id @default(uuid())
moduleId String @map("module_id")
title String
videoUrl String @map("video_url") @db.Text
videoType VideoType @map("video_type")
order Int
content String? @db.Text
createdAt DateTime @default(now()) @map("created_at")
module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade)
notes Note[]
progress Progress[]
@@map("lessons")
}
model Note {
id String @id @default(uuid())
userId String @map("user_id")
lessonId String @map("lesson_id")
content String @db.Text
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
@@unique([userId, lessonId])
@@map("notes")
}
model Progress {
id String @id @default(uuid())
userId String @map("user_id")
lessonId String @map("lesson_id")
watchedPercentage Int @default(0) @map("watched_percentage")
completed Boolean @default(false)
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
@@unique([userId, lessonId])
@@map("progress")
}
model Payment {
id String @id
userId String @map("user_id")
value Decimal @db.Decimal(10, 2)
status PaymentStatus
billingType BillingType @map("billing_type")
invoiceUrl String @map("invoice_url") @db.Text
dueDate DateTime @map("due_date") @db.Date
paidAt DateTime? @map("paid_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("payments")
}
model WebhookLog {
id String @id @default(uuid())
event String
payload String @db.Text
receivedAt DateTime @default(now()) @map("received_at")
@@map("webhook_logs")
}

961
server.ts Normal file
View File

@ -0,0 +1,961 @@
import express, { Request, Response, NextFunction } from 'express';
import path from 'path';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
import { createServer as createViteServer } from 'vite';
import { loadDb, saveDb } from './src/db/db';
import { AsaasService } from './src/services/asaas';
import { User, Course, Module, Lesson, Note, LessonProgress, WebhookLog, SubscriptionPayment, SubscriptionStatus } from './src/types';
// JWT Secret Key
const JWT_SECRET = process.env.JWT_SECRET || 'devflix_secret_token_key_13579';
const app = express();
const PORT = 3000;
app.use(express.json());
// --- JWT AUTH MIDDLEWARE ---
interface AuthenticatedRequest extends Request {
user?: {
id: string;
email: string;
role: 'admin' | 'student';
subscriptionStatus: SubscriptionStatus;
};
}
function authenticateToken(req: AuthenticatedRequest, res: Response, next: NextFunction): void {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
res.status(401).json({ error: 'Token de autenticação não fornecido' });
return;
}
jwt.verify(token, JWT_SECRET, (err: any, decoded: any) => {
if (err) {
res.status(403).json({ error: 'Token inválido ou expirado' });
return;
}
req.user = decoded;
next();
});
}
function requireAdmin(req: AuthenticatedRequest, res: Response, next: NextFunction): void {
if (!req.user || req.user.role !== 'admin') {
res.status(403).json({ error: 'Acesso restrito para administradores' });
return;
}
next();
}
// --- API ROUTES ---
// 1. Auth: Register
app.post('/api/auth/register', async (req: Request, res: Response) => {
const { name, email, password } = req.body;
if (!name || !email || !password) {
res.status(400).json({ error: 'Preencha todos os campos obrigatórios' });
return;
}
const db = loadDb();
const userExists = db.users.find(u => u.email.toLowerCase() === email.toLowerCase());
if (userExists) {
res.status(400).json({ error: 'E-mail já cadastrado na plataforma' });
return;
}
try {
// Generate Asaas Customer ID (Real call if configured, or simulated)
const asaasCustomerId = await AsaasService.createCustomer(name, email);
const salt = bcrypt.genSaltSync(10);
const passwordHash = bcrypt.hashSync(password, salt);
const newUser: User = {
id: `usr_${Math.random().toString(36).substring(2, 9)}`,
name,
email: email.toLowerCase(),
password: passwordHash,
role: 'student',
subscriptionStatus: 'TRIAL', // Defaults to trial
asaasCustomerId,
createdAt: new Date().toISOString()
};
db.users.push(newUser);
saveDb(db);
// Generate JWT
const token = jwt.sign(
{ id: newUser.id, email: newUser.email, role: newUser.role, subscriptionStatus: newUser.subscriptionStatus },
JWT_SECRET,
{ expiresIn: '7d' }
);
res.status(201).json({
token,
user: {
id: newUser.id,
name: newUser.name,
email: newUser.email,
role: newUser.role,
subscriptionStatus: newUser.subscriptionStatus
}
});
} catch (err: any) {
res.status(500).json({ error: err.message || 'Erro ao registrar usuário' });
}
});
// 2. Auth: Login
app.post('/api/auth/login', (req: Request, res: Response) => {
const { email, password } = req.body;
if (!email || !password) {
res.status(400).json({ error: 'Informe e-mail e senha' });
return;
}
const db = loadDb();
const user = db.users.find(u => u.email.toLowerCase() === email.toLowerCase());
if (!user || !user.password) {
res.status(401).json({ error: 'Credenciais inválidas' });
return;
}
const passwordValid = bcrypt.compareSync(password, user.password);
if (!passwordValid) {
res.status(401).json({ error: 'Credenciais inválidas' });
return;
}
const token = jwt.sign(
{ id: user.id, email: user.email, role: user.role, subscriptionStatus: user.subscriptionStatus },
JWT_SECRET,
{ expiresIn: '7d' }
);
res.json({
token,
user: {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
subscriptionStatus: user.subscriptionStatus,
unlockedCourses: user.unlockedCourses || []
}
});
});
// 3. Auth: Me
app.get('/api/auth/me', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
if (!req.user) {
res.status(401).json({ error: 'Não autorizado' });
return;
}
const db = loadDb();
const user = db.users.find(u => u.id === req.user?.id);
if (!user) {
res.status(404).json({ error: 'Usuário não encontrado' });
return;
}
res.json({
id: user.id,
name: user.name,
email: user.email,
role: user.role,
subscriptionStatus: user.subscriptionStatus,
unlockedCourses: user.unlockedCourses || []
});
});
// 4. Courses List (With Progress)
app.get('/api/courses', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
const db = loadDb();
const userId = req.user!.id;
const user = db.users.find(u => u.id === userId);
const unlockedCourses = user?.unlockedCourses || [];
const isAdmin = user?.role === 'admin';
// Enhance courses with overall user progress
const coursesWithProgress = db.courses.map(course => {
// Find all lessons in this course
const courseModules = db.modules.filter(m => m.courseId === course.id);
const courseModuleIds = courseModules.map(m => m.id);
const courseLessons = db.lessons.filter(l => courseModuleIds.includes(l.moduleId));
const totalLessons = courseLessons.length;
let completedLessons = 0;
if (totalLessons > 0) {
const userProgress = db.progress.filter(p => p.userId === userId && p.completed);
const userProgressLessonIds = userProgress.map(p => p.lessonId);
completedLessons = courseLessons.filter(l => userProgressLessonIds.includes(l.id)).length;
}
const progressPercentage = totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0;
return {
...course,
totalLessons,
completedLessons,
progress: progressPercentage,
isUnlocked: !course.isLocked || unlockedCourses.includes(course.id) || isAdmin
};
});
res.json(coursesWithProgress);
});
// 5. Course Details (with Modules, Lessons, Progress & Notes)
app.get('/api/courses/:id', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
const db = loadDb();
const userId = req.user!.id;
const courseId = req.params.id;
const course = db.courses.find(c => c.id === courseId);
if (!course) {
res.status(404).json({ error: 'Curso não encontrado' });
return;
}
const user = db.users.find(u => u.id === userId);
const unlockedCourses = user?.unlockedCourses || [];
const isAdmin = user?.role === 'admin';
const isUnlocked = !course.isLocked || unlockedCourses.includes(course.id) || isAdmin;
// Get modules
const courseModules = db.modules
.filter(m => m.courseId === courseId)
.sort((a, b) => a.order - b.order);
// Get lessons and enhance with progress
const modulesWithLessons = courseModules.map(mod => {
const lessons = db.lessons
.filter(l => l.moduleId === mod.id)
.sort((a, b) => a.order - b.order)
.map(lesson => {
const prog = db.progress.find(p => p.userId === userId && p.lessonId === lesson.id);
const note = db.notes.find(n => n.userId === userId && n.lessonId === lesson.id);
return {
...lesson,
progress: prog ? {
watchedPercentage: prog.watchedPercentage,
completed: prog.completed
} : { watchedPercentage: 0, completed: false },
note: note ? note.content : ''
};
});
return {
...mod,
lessons
};
});
res.json({
course: {
...course,
isUnlocked
},
modules: modulesWithLessons
});
});
// 5.5 Unlock Course
app.post('/api/courses/:id/unlock', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
const courseId = req.params.id;
const userId = req.user!.id;
const db = loadDb();
const user = db.users.find(u => u.id === userId);
const course = db.courses.find(c => c.id === courseId);
if (!user) {
res.status(404).json({ error: 'Usuário não encontrado' });
return;
}
if (!course) {
res.status(404).json({ error: 'Curso não encontrado' });
return;
}
if (!user.unlockedCourses) {
user.unlockedCourses = [];
}
if (!user.unlockedCourses.includes(courseId)) {
user.unlockedCourses.push(courseId);
}
// Record an Asaas invoice/payment for this curso unlock
const newPayment: SubscriptionPayment = {
id: `pay_crs_${Math.random().toString(36).substring(2, 9)}`,
userId,
value: course.price || 49.90,
status: 'CONFIRMED',
billingType: 'PIX',
invoiceUrl: `https://www.asaas.com/i/unlock_${courseId}`,
dueDate: new Date().toISOString().split('T')[0],
paidAt: new Date().toISOString(),
createdAt: new Date().toISOString()
};
db.payments.push(newPayment);
saveDb(db);
res.json({ success: true, unlockedCourses: user.unlockedCourses });
});
// 6. Lesson Progress tracking
app.post('/api/lessons/:id/progress', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
const { watchedPercentage } = req.body;
const lessonId = req.params.id;
const userId = req.user!.id;
if (watchedPercentage === undefined) {
res.status(400).json({ error: 'watchedPercentage é obrigatório' });
return;
}
const db = loadDb();
const lesson = db.lessons.find(l => l.id === lessonId);
if (!lesson) {
res.status(404).json({ error: 'Aula não encontrada' });
return;
}
let progIdx = db.progress.findIndex(p => p.userId === userId && p.lessonId === lessonId);
const isCompleted = watchedPercentage >= 90;
if (progIdx >= 0) {
db.progress[progIdx].watchedPercentage = Math.max(db.progress[progIdx].watchedPercentage, watchedPercentage);
if (isCompleted) {
db.progress[progIdx].completed = true;
}
db.progress[progIdx].updatedAt = new Date().toISOString();
} else {
db.progress.push({
id: `prog_${Math.random().toString(36).substring(2, 9)}`,
userId,
lessonId,
watchedPercentage,
completed: isCompleted,
updatedAt: new Date().toISOString()
});
}
saveDb(db);
res.json({ success: true, completed: isCompleted });
});
// 7. Lesson Notes and Doubts
app.get('/api/lessons/:id/notes', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
const lessonId = req.params.id;
const userId = req.user!.id;
const db = loadDb();
const note = db.notes.find(n => n.userId === userId && n.lessonId === lessonId);
res.json({ content: note ? note.content : '' });
});
app.post('/api/lessons/:id/notes', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
const { content } = req.body;
const lessonId = req.params.id;
const userId = req.user!.id;
const db = loadDb();
let noteIdx = db.notes.findIndex(n => n.userId === userId && n.lessonId === lessonId);
if (noteIdx >= 0) {
db.notes[noteIdx].content = content;
db.notes[noteIdx].createdAt = new Date().toISOString();
} else {
db.notes.push({
id: `note_${Math.random().toString(36).substring(2, 9)}`,
userId,
lessonId,
content,
createdAt: new Date().toISOString()
});
}
saveDb(db);
res.json({ success: true });
});
// 8. Subscription status & Invoice history
app.get('/api/subscription/status', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
const db = loadDb();
const userId = req.user!.id;
const user = db.users.find(u => u.id === userId);
if (!user) {
res.status(404).json({ error: 'Usuário não encontrado' });
return;
}
const payments = db.payments
.filter(p => p.userId === userId)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
res.json({
status: user.subscriptionStatus,
asaasCustomerId: user.asaasCustomerId,
payments
});
});
// 9. Subscribe to plan
app.post('/api/subscription/subscribe', authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const { billingType, creditCardInfo } = req.body;
const userId = req.user!.id;
if (!billingType) {
res.status(400).json({ error: 'Tipo de cobrança é obrigatório (PIX, CREDIT_CARD ou BOLETO)' });
return;
}
const db = loadDb();
const user = db.users.find(u => u.id === userId);
if (!user) {
res.status(404).json({ error: 'Usuário não encontrado' });
return;
}
try {
let customerId = user.asaasCustomerId;
if (!customerId) {
customerId = await AsaasService.createCustomer(user.name, user.email);
user.asaasCustomerId = customerId;
}
// Assinatura de R$ 49.90/mês
const { subscriptionId, payment } = await AsaasService.createSubscription(
customerId,
49.90,
billingType,
creditCardInfo
);
// Add subscription payment to database
const newPayment: SubscriptionPayment = {
id: payment.id || `pay_${Math.random().toString(36).substring(2, 9)}`,
userId,
value: 49.90,
status: billingType === 'CREDIT_CARD' ? 'CONFIRMED' : 'PENDING',
billingType,
invoiceUrl: payment.invoiceUrl || `https://sandbox.asaas.com/i/${subscriptionId}`,
dueDate: payment.dueDate || new Date(Date.now() + 3 * 24 * 3600 * 1000).toISOString().split('T')[0],
paidAt: billingType === 'CREDIT_CARD' ? new Date().toISOString() : null,
createdAt: new Date().toISOString()
};
db.payments.push(newPayment);
// Update user subscription status
if (billingType === 'CREDIT_CARD') {
user.subscriptionStatus = 'ACTIVE';
} else {
user.subscriptionStatus = 'TRIAL'; // Give them trial until Pix/Boleto clears, or keep Trial
}
saveDb(db);
res.json({
success: true,
subscriptionId,
payment: newPayment
});
} catch (err: any) {
res.status(500).json({ error: err.message || 'Erro ao processar assinatura' });
}
});
// --- ADMIN API ENDPOINTS ---
// 1. Dashboard statistics
app.get('/api/admin/dashboard', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
const db = loadDb();
const totalActive = db.users.filter(u => u.role === 'student' && u.subscriptionStatus === 'ACTIVE').length;
const totalOverdue = db.users.filter(u => u.role === 'student' && u.subscriptionStatus === 'OVERDUE').length;
const totalCanceled = db.users.filter(u => u.role === 'student' && u.subscriptionStatus === 'CANCELED').length;
const totalTrial = db.users.filter(u => u.role === 'student' && u.subscriptionStatus === 'TRIAL').length;
// MRR = Active Subscribers * 49.90
const mrr = totalActive * 49.90;
// Let's format recent invoice history for logs/charts
const recentPayments = db.payments
.map(p => {
const student = db.users.find(u => u.id === p.userId);
return {
...p,
studentName: student ? student.name : 'Aluno Removido',
studentEmail: student ? student.email : ''
};
})
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 15);
const webhookLogs = db.webhookLogs
.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime())
.slice(0, 15);
res.json({
stats: {
mrr,
activeSubscribers: totalActive,
trialSubscribers: totalTrial,
overdueSubscribers: totalOverdue,
canceledSubscribers: totalCanceled
},
recentPayments,
webhookLogs
});
});
// 2. Students & status management
app.get('/api/admin/students', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
const db = loadDb();
const students = db.users
.filter(u => u.role === 'student')
.map(u => {
const studentPayments = db.payments.filter(p => p.userId === u.id);
return {
id: u.id,
name: u.name,
email: u.email,
subscriptionStatus: u.subscriptionStatus,
asaasCustomerId: u.asaasCustomerId,
createdAt: u.createdAt,
totalPaid: studentPayments
.filter(p => p.status === 'CONFIRMED' || p.status === 'RECEIVED')
.reduce((acc, curr) => acc + curr.value, 0),
unlockedCourses: u.unlockedCourses || []
};
});
res.json(students);
});
// Admin change student status manually
app.post('/api/admin/students/:id/status', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
const { status } = req.body;
const studentId = req.params.id;
const allowedStatuses: SubscriptionStatus[] = ['ACTIVE', 'OVERDUE', 'CANCELED', 'TRIAL'];
if (!status || !allowedStatuses.includes(status)) {
res.status(400).json({ error: 'Status de assinatura inválido' });
return;
}
const db = loadDb();
const student = db.users.find(u => u.id === studentId && u.role === 'student');
if (!student) {
res.status(404).json({ error: 'Estudante não encontrado' });
return;
}
student.subscriptionStatus = status;
saveDb(db);
res.json({ success: true, status: student.subscriptionStatus });
});
// Admin manually unlock or lock a course for a student
app.post('/api/admin/students/:id/unlock-course', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
const { courseId, unlocked } = req.body;
const studentId = req.params.id;
if (!courseId || unlocked === undefined) {
res.status(400).json({ error: 'Parâmetros courseId e unlocked são obrigatórios' });
return;
}
const db = loadDb();
const student = db.users.find(u => u.id === studentId && u.role === 'student');
if (!student) {
res.status(404).json({ error: 'Estudante não encontrado' });
return;
}
if (!student.unlockedCourses) {
student.unlockedCourses = [];
}
if (unlocked) {
if (!student.unlockedCourses.includes(courseId)) {
student.unlockedCourses.push(courseId);
}
} else {
student.unlockedCourses = student.unlockedCourses.filter(id => id !== courseId);
}
saveDb(db);
res.json({ success: true, unlockedCourses: student.unlockedCourses });
});
// 3. CRUD Courses
app.post('/api/admin/courses', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
const { title, description, thumbnail, category, isLocked, price } = req.body;
if (!title || !description || !thumbnail || !category) {
res.status(400).json({ error: 'Campos obrigatórios faltando' });
return;
}
const db = loadDb();
const newCourse: Course = {
id: `crs_${Math.random().toString(36).substring(2, 9)}`,
title,
description,
thumbnail,
category,
isLocked: isLocked === true || isLocked === 'true',
price: price ? Number(price) : 0,
createdAt: new Date().toISOString()
};
db.courses.push(newCourse);
saveDb(db);
res.status(201).json(newCourse);
});
app.put('/api/admin/courses/:id', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
const { title, description, thumbnail, category, isLocked, price } = req.body;
const courseId = req.params.id;
const db = loadDb();
const idx = db.courses.findIndex(c => c.id === courseId);
if (idx < 0) {
res.status(404).json({ error: 'Curso não encontrado' });
return;
}
db.courses[idx] = {
...db.courses[idx],
title: title || db.courses[idx].title,
description: description || db.courses[idx].description,
thumbnail: thumbnail || db.courses[idx].thumbnail,
category: category || db.courses[idx].category,
isLocked: isLocked !== undefined ? (isLocked === true || isLocked === 'true') : db.courses[idx].isLocked,
price: price !== undefined ? Number(price) : db.courses[idx].price
};
saveDb(db);
res.json(db.courses[idx]);
});
app.delete('/api/admin/courses/:id', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
const courseId = req.params.id;
const db = loadDb();
const idx = db.courses.findIndex(c => c.id === courseId);
if (idx < 0) {
res.status(404).json({ error: 'Curso não encontrado' });
return;
}
// Delete modules, lessons, and course
db.courses.splice(idx, 1);
const affectedModules = db.modules.filter(m => m.courseId === courseId);
const affectedModuleIds = affectedModules.map(m => m.id);
db.modules = db.modules.filter(m => m.courseId !== courseId);
db.lessons = db.lessons.filter(l => !affectedModuleIds.includes(l.moduleId));
saveDb(db);
res.json({ success: true });
});
// 4. CRUD Modules
app.post('/api/admin/modules', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
const { courseId, title, order } = req.body;
if (!courseId || !title) {
res.status(400).json({ error: 'Curso e título são obrigatórios' });
return;
}
const db = loadDb();
const newModule: Module = {
id: `mdl_${Math.random().toString(36).substring(2, 9)}`,
courseId,
title,
order: order !== undefined ? Number(order) : db.modules.filter(m => m.courseId === courseId).length + 1,
createdAt: new Date().toISOString()
};
db.modules.push(newModule);
saveDb(db);
res.status(201).json(newModule);
});
app.put('/api/admin/modules/:id', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
const { title, order } = req.body;
const moduleId = req.params.id;
const db = loadDb();
const idx = db.modules.findIndex(m => m.id === moduleId);
if (idx < 0) {
res.status(404).json({ error: 'Módulo não encontrado' });
return;
}
db.modules[idx].title = title || db.modules[idx].title;
if (order !== undefined) {
db.modules[idx].order = Number(order);
}
saveDb(db);
res.json(db.modules[idx]);
});
app.delete('/api/admin/modules/:id', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
const moduleId = req.params.id;
const db = loadDb();
const idx = db.modules.findIndex(m => m.id === moduleId);
if (idx < 0) {
res.status(404).json({ error: 'Módulo não encontrado' });
return;
}
db.modules.splice(idx, 1);
db.lessons = db.lessons.filter(l => l.moduleId !== moduleId);
saveDb(db);
res.json({ success: true });
});
// 5. CRUD Lessons
app.post('/api/admin/lessons', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
const { moduleId, title, videoUrl, videoType, order, content } = req.body;
if (!moduleId || !title || !videoUrl || !videoType) {
res.status(400).json({ error: 'Campos obrigatórios faltando' });
return;
}
const db = loadDb();
const newLesson: Lesson = {
id: `lsn_${Math.random().toString(36).substring(2, 9)}`,
moduleId,
title,
videoUrl,
videoType,
order: order !== undefined ? Number(order) : db.lessons.filter(l => l.moduleId === moduleId).length + 1,
content: content || '',
createdAt: new Date().toISOString()
};
db.lessons.push(newLesson);
saveDb(db);
res.status(201).json(newLesson);
});
app.put('/api/admin/lessons/:id', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
const { title, videoUrl, videoType, order, content } = req.body;
const lessonId = req.params.id;
const db = loadDb();
const idx = db.lessons.findIndex(l => l.id === lessonId);
if (idx < 0) {
res.status(404).json({ error: 'Aula não encontrada' });
return;
}
db.lessons[idx] = {
...db.lessons[idx],
title: title || db.lessons[idx].title,
videoUrl: videoUrl || db.lessons[idx].videoUrl,
videoType: videoType || db.lessons[idx].videoType,
content: content !== undefined ? content : db.lessons[idx].content
};
if (order !== undefined) {
db.lessons[idx].order = Number(order);
}
saveDb(db);
res.json(db.lessons[idx]);
});
app.delete('/api/admin/lessons/:id', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
const lessonId = req.params.id;
const db = loadDb();
const idx = db.lessons.findIndex(l => l.id === lessonId);
if (idx < 0) {
res.status(404).json({ error: 'Aula não encontrada' });
return;
}
db.lessons.splice(idx, 1);
// Also clean up notes and progress
db.notes = db.notes.filter(n => n.lessonId !== lessonId);
db.progress = db.progress.filter(p => p.lessonId !== lessonId);
saveDb(db);
res.json({ success: true });
});
// Reorder modules or lessons (Drag-and-drop or batch reorder helper)
app.post('/api/admin/reorder', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => {
const { type, items } = req.body; // type: 'modules' | 'lessons', items: Array of { id, order }
if (!type || !items || !Array.isArray(items)) {
res.status(400).json({ error: 'Parâmetros inválidos' });
return;
}
const db = loadDb();
if (type === 'modules') {
items.forEach((item: { id: string; order: number }) => {
const idx = db.modules.findIndex(m => m.id === item.id);
if (idx >= 0) {
db.modules[idx].order = Number(item.order);
}
});
} else if (type === 'lessons') {
items.forEach((item: { id: string; order: number }) => {
const idx = db.lessons.findIndex(l => l.id === item.id);
if (idx >= 0) {
db.lessons[idx].order = Number(item.order);
}
});
}
saveDb(db);
res.json({ success: true });
});
// --- ASAAS WEBHOOK HANDLER ---
// Standard webhook validation token, can be verified in Production
const ASAAS_WEBHOOK_TOKEN = process.env.ASAAS_WEBHOOK_TOKEN || 'devflix_webhook_secret_key';
app.post('/api/webhooks/asaas', (req: Request, res: Response) => {
// Optional token verification
const token = req.headers['asaas-token'] || req.headers['authorization'];
if (process.env.ASAAS_WEBHOOK_TOKEN && token !== ASAAS_WEBHOOK_TOKEN) {
res.status(401).json({ error: 'Não autorizado - Token de Webhook incorreto' });
return;
}
const { event, payment } = req.body;
if (!event || !payment) {
res.status(400).json({ error: 'Formato do Webhook inválido' });
return;
}
const db = loadDb();
// Log the webhook event
const logId = `wh_log_${Math.random().toString(36).substring(2, 9)}`;
db.webhookLogs.push({
id: logId,
event,
payload: JSON.stringify(req.body),
receivedAt: new Date().toISOString()
});
// Find user by Asaas Customer ID
const customerId = payment.customer;
const user = db.users.find(u => u.asaasCustomerId === customerId);
if (user) {
// Process payment updates
let userPaymentIdx = db.payments.findIndex(p => p.id === payment.id || (p.userId === user.id && p.status === 'PENDING'));
// If we have a matching or pending payment, let's update or insert
if (userPaymentIdx >= 0) {
db.payments[userPaymentIdx].status =
event === 'PAYMENT_RECEIVED' || event === 'PAYMENT_CONFIRMED' ? 'CONFIRMED' :
event === 'PAYMENT_OVERDUE' ? 'OVERDUE' : db.payments[userPaymentIdx].status;
if (event === 'PAYMENT_RECEIVED' || event === 'PAYMENT_CONFIRMED') {
db.payments[userPaymentIdx].paidAt = new Date().toISOString();
}
} else {
// Register a new payment log
db.payments.push({
id: payment.id || `pay_wh_${Math.random().toString(36).substring(2, 9)}`,
userId: user.id,
value: payment.value || 49.90,
status: (event === 'PAYMENT_RECEIVED' || event === 'PAYMENT_CONFIRMED') ? 'CONFIRMED' :
event === 'PAYMENT_OVERDUE' ? 'OVERDUE' : 'PENDING',
billingType: payment.billingType || 'PIX',
invoiceUrl: payment.invoiceUrl || `https://sandbox.asaas.com/i/${payment.id}`,
dueDate: payment.dueDate || new Date().toISOString().split('T')[0],
paidAt: (event === 'PAYMENT_RECEIVED' || event === 'PAYMENT_CONFIRMED') ? new Date().toISOString() : null,
createdAt: new Date().toISOString()
});
}
// Handle subscription access control
if (event === 'PAYMENT_RECEIVED' || event === 'PAYMENT_CONFIRMED') {
// Liberar / manter acesso
user.subscriptionStatus = 'ACTIVE';
console.log(`Webhook: Acesso LIBERADO para usuário ${user.email} (${user.name})`);
} else if (event === 'PAYMENT_OVERDUE' || event === 'SUBSCRIPTION_DELETED') {
// Bloquear acesso automaticamente
user.subscriptionStatus = event === 'PAYMENT_OVERDUE' ? 'OVERDUE' : 'CANCELED';
console.log(`Webhook: Acesso BLOQUEADO para usuário ${user.email} (${user.name}) - Motivo: ${event}`);
}
} else {
console.warn(`Webhook: Cliente Asaas ${customerId} não encontrado no banco local.`);
}
saveDb(db);
res.status(200).json({ received: true });
});
// --- VITE MIDDLEWARE / SPA FALLBACK ---
async function startServer() {
if (process.env.NODE_ENV !== 'production') {
const vite = await createViteServer({
server: { middlewareMode: true },
appType: 'spa',
});
app.use(vite.middlewares);
} else {
const distPath = path.join(process.cwd(), 'dist');
app.use(express.static(distPath));
app.get('*', (req: Request, res: Response) => {
res.sendFile(path.join(distPath, 'index.html'));
});
}
app.listen(PORT, '0.0.0.0', () => {
console.log(`DevFlix Server running on port ${PORT}`);
});
}
startServer();

279
src/AdminApp.tsx Normal file
View File

@ -0,0 +1,279 @@
import React, { useState, useEffect } from 'react';
import {
Play, Shield, LogOut, ArrowRight, Lock, Mail, RefreshCw,
Layers, ShieldAlert, Users, GraduationCap, DollarSign, Database
} from 'lucide-react';
import AdminDashboard from './components/AdminDashboard';
import AdminContentManager from './components/AdminContentManager';
export default function AdminApp() {
const [token, setToken] = useState<string | null>(localStorage.getItem('devflix_admin_token'));
const [adminUser, setAdminUser] = useState<{
name: string;
email: string;
role: 'admin';
} | null>(null);
// App routing inside admin panel
const [activeView, setView] = useState<'dashboard' | 'courses'>('dashboard');
// Authentication forms
const [loginEmail, setLoginEmail] = useState('');
const [loginPassword, setLoginPassword] = useState('');
const [authError, setAuthError] = useState<string | null>(null);
const [authLoading, setAuthLoading] = useState(false);
useEffect(() => {
if (token) {
fetchAdminProfile();
}
}, [token]);
const fetchAdminProfile = async () => {
try {
const res = await fetch('/api/auth/me', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (res.ok) {
const data = await res.json();
if (data.role === 'admin') {
setAdminUser(data);
} else {
// If a student tries to use the admin token
setAuthError('Token inválido para acesso administrativo.');
handleLogout();
}
} else {
handleLogout();
}
} catch (err) {
console.error('Failed to load profile:', err);
handleLogout();
}
};
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setAuthError(null);
setAuthLoading(true);
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: loginEmail, password: loginPassword })
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Erro ao efetuar login');
}
if (data.user.role !== 'admin') {
throw new Error('Acesso recusado. Apenas administradores podem acessar esta área.');
}
localStorage.setItem('devflix_admin_token', data.token);
setToken(data.token);
setAdminUser(data.user);
setLoginEmail('');
setLoginPassword('');
} catch (err: any) {
setAuthError(err.message);
} finally {
setAuthLoading(false);
}
};
const handleLogout = () => {
localStorage.removeItem('devflix_admin_token');
setToken(null);
setAdminUser(null);
};
const quickFillAdmin = () => {
setLoginEmail('sidney.gomes1989@gmail.com');
setLoginPassword('admin123');
setAuthError(null);
};
return (
<div className="min-h-screen bg-[#0b0c10] text-zinc-100 flex flex-col selection:bg-[#E50914] selection:text-white font-sans">
{token && adminUser ? (
/* --- AUTHENTICATED ADMIN AREA --- */
<>
{/* Admin Header / Navbar */}
<header className="sticky top-0 z-50 glass-navbar px-4 py-4 md:px-8 flex items-center justify-between border-b border-white/5">
<div className="flex items-center space-x-8">
{/* Logo with Admin Tag */}
<div className="flex items-center space-x-2.5 cursor-pointer select-none">
<div className="bg-[#E50914] text-white p-1.5 rounded flex items-center justify-center">
<Shield className="w-5 h-5" />
</div>
<div className="flex flex-col">
<span className="font-display font-black text-lg md:text-xl leading-none tracking-tighter text-[#E50914]">
MICROTEC<span className="text-white">FLIX</span>
</span>
<span className="text-[9px] text-[#E50914] font-extrabold uppercase tracking-widest mt-0.5 leading-none">
Admin Portal
</span>
</div>
</div>
{/* Navigation Tabs */}
<nav className="hidden md:flex items-center space-x-6 text-sm">
<button
onClick={() => setView('dashboard')}
className={`font-semibold transition-all hover:text-white flex items-center space-x-2 px-3 py-1.5 rounded-lg ${activeView === 'dashboard' ? 'text-white bg-white/5' : 'text-zinc-400'}`}
>
<Database className="w-4 h-4 text-[#E50914]" />
<span>Painel Geral</span>
</button>
<button
onClick={() => setView('courses')}
className={`font-semibold transition-all hover:text-white flex items-center space-x-2 px-3 py-1.5 rounded-lg ${activeView === 'courses' ? 'text-white bg-white/5' : 'text-zinc-400'}`}
>
<GraduationCap className="w-4 h-4 text-[#E50914]" />
<span>Grade de Cursos</span>
</button>
</nav>
</div>
{/* Admin Right Menu */}
<div className="flex items-center space-x-4">
<div className="text-right hidden sm:block border-r border-white/10 pr-4">
<p className="text-xs font-bold text-white">{adminUser.name}</p>
<p className="text-[10px] text-[#E50914] font-black uppercase tracking-wider">{adminUser.email}</p>
</div>
{/* Mobile View Toggle Buttons */}
<div className="flex md:hidden items-center bg-white/5 p-1 rounded-lg">
<button
onClick={() => setView('dashboard')}
className={`text-xs font-bold px-2.5 py-1.5 rounded-md ${activeView === 'dashboard' ? 'bg-[#E50914] text-white' : 'text-zinc-400'}`}
>
Geral
</button>
<button
onClick={() => setView('courses')}
className={`text-xs font-bold px-2.5 py-1.5 rounded-md ${activeView === 'courses' ? 'bg-[#E50914] text-white' : 'text-zinc-400'}`}
>
Grade
</button>
</div>
<button
onClick={handleLogout}
title="Sair do Painel Admin"
className="w-9 h-9 rounded-full bg-white/5 hover:bg-red-950/50 text-zinc-400 hover:text-red-400 flex items-center justify-center transition-all cursor-pointer"
>
<LogOut className="w-4 h-4" />
</button>
</div>
</header>
{/* Main workspace */}
<main className="flex-grow p-4 md:p-8 max-w-7xl mx-auto w-full space-y-8">
{activeView === 'dashboard' ? (
<AdminDashboard token={token} />
) : (
<AdminContentManager token={token} />
)}
</main>
{/* Admin Footer */}
<footer className="border-t border-white/5 bg-[#08090d] py-6 px-4 text-center text-[11px] text-zinc-600">
<p className="font-bold uppercase tracking-wider text-zinc-500">MicrotecFlix Admin Control Center Shared PostgreSQL Engine</p>
<p className="mt-1">Acesso corporativo restrito. Todas as auditorias e alterações de cursos e cobranças Asaas são registradas.</p>
</footer>
</>
) : (
/* --- UNAUTHENTICATED ADMIN ACCESS SCREEN --- */
<div className="flex-grow flex items-center justify-center p-4 md:p-8 relative" style={{ backgroundImage: `linear-gradient(rgba(11,12,16,0.9), rgba(11,12,16,0.9)), url('https://images.unsplash.com/photo-1558494949-ef010cbdcc31?q=80&w=1200&auto=format&fit=crop')`, backgroundSize: 'cover', backgroundPosition: 'center' }}>
<div className="absolute inset-0 bg-black/40 z-0" />
{/* Glass login panel */}
<div className="relative z-10 w-full max-w-md glass-card rounded-2xl p-6 md:p-10 space-y-6 shadow-2xl border border-white/10">
{/* Admin Header Accent */}
<div className="flex flex-col items-center space-y-3 text-center">
<div className="bg-[#E50914] text-white p-3 rounded-xl flex items-center justify-center shadow-lg animate-pulse">
<Shield className="w-7 h-7" />
</div>
<div className="space-y-1">
<h1 className="font-display font-black text-2xl tracking-tighter text-white">
MICROTEC<span className="text-[#E50914]">FLIX</span> <span className="text-zinc-500 font-medium text-lg">ADMIN</span>
</h1>
<p className="text-[11px] text-zinc-400 font-medium">Autenticação obrigatória para gestores, administradores e moderadores.</p>
</div>
</div>
{/* Error notifications */}
{authError && (
<div className="bg-red-950/40 border border-red-900/50 text-red-400 p-3.5 rounded-xl text-xs leading-relaxed text-center flex items-center justify-center space-x-2">
<ShieldAlert className="w-4 h-4 flex-shrink-0 text-red-500" />
<span>{authError}</span>
</div>
)}
{/* Form */}
<form onSubmit={handleLogin} className="space-y-4">
<div className="space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">E-mail Administrativo</label>
<div className="relative">
<Mail className="absolute left-3.5 top-3.5 w-4 h-4 text-zinc-500" />
<input
type="email"
required
value={loginEmail}
onChange={(e) => setLoginEmail(e.target.value)}
placeholder="email.admin@devflix.com"
className="w-full glass-input rounded-xl p-3.5 pl-11 text-xs text-white focus:outline-none"
/>
</div>
</div>
<div className="space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Senha de Segurança</label>
<div className="relative">
<Lock className="absolute left-3.5 top-3.5 w-4 h-4 text-zinc-500" />
<input
type="password"
required
value={loginPassword}
onChange={(e) => setLoginPassword(e.target.value)}
placeholder="Sua senha privada"
className="w-full glass-input rounded-xl p-3.5 pl-11 text-xs text-white focus:outline-none"
/>
</div>
</div>
<button
type="submit"
disabled={authLoading}
className="w-full glass-btn-primary text-white text-xs font-bold py-3.5 px-4 rounded-xl flex items-center justify-center space-x-2 cursor-pointer disabled:opacity-50"
>
<span>{authLoading ? 'Autenticando...' : 'Acessar Painel de Controle'}</span>
<ArrowRight className="w-4 h-4" />
</button>
{/* SANDBOX ACCESS PRESET */}
<div className="border-t border-white/5 pt-4">
<button
type="button"
onClick={quickFillAdmin}
className="w-full bg-white/5 hover:bg-white/10 text-zinc-300 font-bold p-2.5 rounded-lg text-xs transition-colors flex items-center justify-center space-x-1"
>
<Users className="w-4 h-4 text-[#E50914]" />
<span>Entrar com Credencial Admin Demonstrativa</span>
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

441
src/App.tsx Normal file
View File

@ -0,0 +1,441 @@
import React, { useState, useEffect } from 'react';
import {
Play, Shield, User, GraduationCap, ArrowRight, Lock, Mail,
Plus, CheckCircle, ChevronRight, HelpCircle, Film, Laptop
} from 'lucide-react';
import Navbar from './components/Navbar';
import HeroBanner from './components/HeroBanner';
import CourseCarousel from './components/CourseCarousel';
import CourseView from './components/CourseView';
import SubscriptionView from './components/SubscriptionView';
import CourseUnlockModal from './components/CourseUnlockModal';
import { User as UserType, Course as CourseType, SubscriptionStatus } from './types';
export default function App() {
const [token, setToken] = useState<string | null>(localStorage.getItem('devflix_token'));
const [user, setUser] = useState<{
name: string;
email: string;
role: 'admin' | 'student';
subscriptionStatus: SubscriptionStatus;
} | null>(null);
// App routing
const [activeView, setView] = useState<string>('home'); // home, course-view, subscription, admin-dashboard, admin-content
const [selectedCourseId, setSelectedCourseId] = useState<string | null>(null);
const [unlockingCourse, setUnlockingCourse] = useState<CourseType | null>(null);
// Authentication forms
const [isRegister, setIsRegister] = useState(false);
const [loginEmail, setLoginEmail] = useState('');
const [loginPassword, setLoginPassword] = useState('');
const [regName, setRegName] = useState('');
const [regEmail, setRegEmail] = useState('');
const [regPassword, setRegPassword] = useState('');
const [authError, setAuthError] = useState<string | null>(null);
const [authSuccess, setAuthSuccess] = useState<string | null>(null);
const [authLoading, setAuthLoading] = useState(false);
// Course catalogue
const [courses, setCourses] = useState<(CourseType & { progress?: number; totalLessons?: number; completedLessons?: number })[]>([]);
const [isLoadingCourses, setIsLoadingCourses] = useState(false);
useEffect(() => {
if (token) {
fetchUserProfile();
}
}, [token]);
useEffect(() => {
if (user) {
fetchCourses();
}
}, [user]);
const fetchUserProfile = async () => {
try {
const res = await fetch('/api/auth/me', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (res.ok) {
const data = await res.json();
setUser(data);
} else {
// Stale token
handleLogout();
}
} catch (err) {
console.error('Failed to load profile:', err);
}
};
const fetchCourses = async () => {
setIsLoadingCourses(true);
try {
const res = await fetch('/api/courses', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (res.ok) {
const data = await res.json();
setCourses(data);
}
} catch (err) {
console.error('Failed to fetch courses:', err);
} finally {
setIsLoadingCourses(false);
}
};
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setAuthError(null);
setAuthLoading(true);
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: loginEmail, password: loginPassword })
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Erro ao efetuar login');
}
localStorage.setItem('devflix_token', data.token);
setToken(data.token);
setUser(data.user);
setLoginEmail('');
setLoginPassword('');
} catch (err: any) {
setAuthError(err.message);
} finally {
setAuthLoading(false);
}
};
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
setAuthError(null);
setAuthSuccess(null);
setAuthLoading(true);
try {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: regName, email: regEmail, password: regPassword })
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Erro ao efetuar cadastro');
}
localStorage.setItem('devflix_token', data.token);
setToken(data.token);
setUser(data.user);
setRegName('');
setRegEmail('');
setRegPassword('');
} catch (err: any) {
setAuthError(err.message);
} finally {
setAuthLoading(false);
}
};
const handleLogout = () => {
localStorage.removeItem('devflix_token');
setToken(null);
setUser(null);
setView('home');
setSelectedCourseId(null);
};
const handleSelectCourse = (courseId: string) => {
const selected = courses.find(c => c.id === courseId);
if (selected && (selected as any).isUnlocked === false) {
setUnlockingCourse(selected);
return;
}
setSelectedCourseId(courseId);
setView('course-view');
};
// Group courses by Category
const categories = Array.from(new Set(courses.map(c => c.category))) as string[];
// Preset quick-fill logins for easy testing
const quickFill = (email: string, pass: string) => {
setLoginEmail(email);
setLoginPassword(pass);
setAuthError(null);
};
// Check if active student is blocked (overdue or cancelled)
const isBlocked = user?.subscriptionStatus === 'OVERDUE' || user?.subscriptionStatus === 'CANCELED';
return (
<div className="min-h-screen bg-[#141414] text-white flex flex-col selection:bg-[#E50914] selection:text-white">
{token && user ? (
/* --- AUTHENTICATED AREA --- */
<>
<Navbar
user={user}
activeView={activeView}
setView={(v) => { setView(v); setSelectedCourseId(null); }}
onLogout={handleLogout}
/>
<main className="flex-grow p-4 md:p-8 max-w-7xl mx-auto w-full space-y-8">
{/* RENDER VIEW: HOME CATALOGUE */}
{activeView === 'home' && (
<div className="space-y-12">
{/* Hero Banner (Featured last course or first catalogue course) */}
<HeroBanner
course={courses.length > 0 ? courses[0] : null}
onPlay={handleSelectCourse}
subscriptionStatus={user.subscriptionStatus}
setView={setView}
/>
{/* Categories Carousels */}
<div className="space-y-10">
{isLoadingCourses ? (
<div className="flex justify-center py-12">
<div className="w-8 h-8 border-4 border-[#E50914] border-t-transparent rounded-full animate-spin" />
</div>
) : courses.length === 0 ? (
<div className="text-center text-zinc-500 py-12 border border-zinc-800 rounded-xl bg-[#1c1c1c]">
Nenhum curso disponível no momento.
</div>
) : (
categories.map(cat => (
<CourseCarousel
key={cat}
title={cat}
courses={courses.filter(c => c.category === cat)}
onSelectCourse={handleSelectCourse}
onUnlockCourse={(course) => setUnlockingCourse(course)}
subscriptionStatus={user.subscriptionStatus}
/>
))
)}
</div>
</div>
)}
{/* RENDER VIEW: COURSE STUDY ROOM */}
{activeView === 'course-view' && selectedCourseId && (
<CourseView
courseId={selectedCourseId}
token={token}
onBack={() => { setView('home'); setSelectedCourseId(null); fetchCourses(); }}
/>
)}
{/* RENDER VIEW: ACCOUNT & BILLING / SUBSCRIPTION */}
{activeView === 'subscription' && (
<SubscriptionView
user={user}
token={token}
onStatusUpdate={(newStatus) => setUser(prev => prev ? { ...prev, subscriptionStatus: newStatus } : null)}
/>
)}
</main>
{/* Unlock Modal */}
{unlockingCourse && (
<CourseUnlockModal
course={unlockingCourse}
onClose={() => setUnlockingCourse(null)}
onSuccess={() => {
setUnlockingCourse(null);
fetchCourses();
fetchUserProfile();
}}
token={token || ''}
/>
)}
{/* Footer */}
<footer className="border-t border-zinc-900 bg-zinc-950 py-8 px-4 text-center text-xs text-zinc-500 space-y-2 mt-12">
<p className="font-semibold text-zinc-400">MICROTECFLIX - Plataforma de Cursos Profissionalizantes de Informática</p>
<p className="max-w-md mx-auto">Desenvolvido com foco em alta experiência de estudo para Windows, Pacote Office (Word, Excel, PowerPoint) e informática profissionalizante.</p>
<p className="text-zinc-600">© 2026 MicrotecFlix Inc. Todos os direitos reservados. Integrado oficialmente com a API Asaas.</p>
</footer>
</>
) : (
/* --- UNAUTHENTICATED PORTAL (LOGIN / REGISTER) --- */
<div className="flex-grow flex items-center justify-center p-4 md:p-8 bg-cover bg-center bg-no-repeat relative" style={{ backgroundImage: `linear-gradient(rgba(20,20,20,0.85), rgba(20,20,20,0.85)), url('https://images.unsplash.com/photo-1558494949-ef010cbdcc31?q=80&w=1200&auto=format&fit=crop')` }}>
<div className="absolute inset-0 bg-[#141414]/90 z-0" />
{/* Login Container */}
<div className="relative z-10 w-full max-w-md glass-card rounded-2xl p-6 md:p-10 space-y-6 shadow-2xl">
{/* Logo */}
<div className="flex flex-col items-center space-y-2 text-center">
<div className="bg-[#E50914] text-white p-2 rounded flex items-center justify-center shadow-lg">
<Play className="fill-white w-6 h-6" />
</div>
<h1 className="font-display font-black text-2xl tracking-tighter text-[#E50914]">
MICROTEC<span className="text-white">FLIX</span>
</h1>
<p className="text-xs text-zinc-400 font-medium">Acelere seus estudos em informática e pacote Office no estilo Netflix.</p>
</div>
{/* Error indicators */}
{authError && (
<div className="bg-red-950/40 border border-red-900/50 text-red-400 p-3 rounded-lg text-xs leading-relaxed text-center">
{authError}
</div>
)}
{isRegister ? (
/* REGISTER FORM */
<form onSubmit={handleRegister} className="space-y-4">
<div className="space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Seu Nome Completo</label>
<input
type="text"
required
value={regName}
onChange={(e) => setRegName(e.target.value)}
placeholder="Seu nome"
className="w-full glass-input rounded-lg p-3 text-xs text-white focus:outline-none"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">E-mail para Acesso</label>
<input
type="email"
required
value={regEmail}
onChange={(e) => setRegEmail(e.target.value)}
placeholder="email@exemplo.com"
className="w-full glass-input rounded-lg p-3 text-xs text-white focus:outline-none"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Crie uma Senha</label>
<input
type="password"
required
value={regPassword}
onChange={(e) => setRegPassword(e.target.value)}
placeholder="Mínimo 6 caracteres"
className="w-full glass-input rounded-lg p-3 text-xs text-white focus:outline-none"
/>
</div>
<button
type="submit"
disabled={authLoading}
className="w-full glass-btn-primary text-white text-xs font-bold py-3 px-4 rounded-lg flex items-center justify-center space-x-1.5 cursor-pointer disabled:opacity-50"
>
<span>{authLoading ? 'Registrando...' : 'Criar Conta & Iniciar Teste'}</span>
<ArrowRight className="w-4 h-4" />
</button>
<p className="text-center text-xs text-zinc-500">
possui cadastro?{' '}
<button
type="button"
onClick={() => { setIsRegister(false); setAuthError(null); }}
className="text-white hover:underline font-bold"
>
Fazer Login
</button>
</p>
</form>
) : (
/* LOGIN FORM */
<form onSubmit={handleLogin} className="space-y-4">
<div className="space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">E-mail</label>
<div className="relative">
<Mail className="absolute left-3 top-3 w-4 h-4 text-zinc-500" />
<input
type="email"
required
value={loginEmail}
onChange={(e) => setLoginEmail(e.target.value)}
placeholder="email@exemplo.com"
className="w-full glass-input rounded-lg p-3 pl-10 text-xs text-white focus:outline-none"
/>
</div>
</div>
<div className="space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Senha</label>
<div className="relative">
<Lock className="absolute left-3 top-3 w-4 h-4 text-zinc-500" />
<input
type="password"
required
value={loginPassword}
onChange={(e) => setLoginPassword(e.target.value)}
placeholder="Digite sua senha"
className="w-full glass-input rounded-lg p-3 pl-10 text-xs text-white focus:outline-none"
/>
</div>
</div>
<button
type="submit"
disabled={authLoading}
className="w-full glass-btn-primary text-white text-xs font-bold py-3 px-4 rounded-lg flex items-center justify-center space-x-1.5 cursor-pointer disabled:opacity-50"
>
<span>{authLoading ? 'Autenticando...' : 'Entrar na Plataforma'}</span>
<ArrowRight className="w-4 h-4" />
</button>
<p className="text-center text-xs text-zinc-500">
Novo por aqui?{' '}
<button
type="button"
onClick={() => { setIsRegister(true); setAuthError(null); }}
className="text-white hover:underline font-bold"
>
Registre-se gratuitamente
</button>
</p>
{/* QUICK PRESETS FOR SANDBOX DEMO */}
<div className="border-t border-white/10 pt-4 space-y-3">
<p className="text-center text-[10px] text-zinc-500 font-bold uppercase tracking-wider">Acesso de Teste Rápido (Iframe Sandbox)</p>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => quickFill('aluno@microtecflix.com', 'aluno123')}
className="glass-btn-secondary text-zinc-300 font-semibold p-2 rounded text-[10px] transition-colors"
>
Logar como Aluno
</button>
<button
type="button"
onClick={() => quickFill('sidney.gomes1989@gmail.com', 'admin123')}
className="glass-btn-secondary text-zinc-300 font-semibold p-2 rounded text-[10px] transition-colors"
>
Logar como Admin
</button>
</div>
</div>
</form>
)}
</div>
</div>
)}
</div>
);
}

65
src/Root.tsx Normal file
View File

@ -0,0 +1,65 @@
import React, { useState, useEffect } from 'react';
import App from './App.tsx';
import AdminApp from './AdminApp.tsx';
import { Globe, Shield, RefreshCw } from 'lucide-react';
export default function Root() {
const [simulatedDomain, setSimulatedDomain] = useState<'student' | 'admin'>(() => {
// 1. Check hostname
if (window.location.hostname.includes('admin')) {
return 'admin';
}
// 2. Check path
if (window.location.pathname.startsWith('/admin')) {
return 'admin';
}
// 3. Fallback to localStorage
const saved = localStorage.getItem('microtecflix_simulated_domain');
return saved === 'admin' ? 'admin' : 'student';
});
const handleToggleDomain = (domain: 'student' | 'admin') => {
localStorage.setItem('microtecflix_simulated_domain', domain);
setSimulatedDomain(domain);
// Reload to clear state and re-route properly
window.location.reload();
};
const isAdmin = simulatedDomain === 'admin';
return (
<div className="relative min-h-screen">
{isAdmin ? <AdminApp /> : <App />}
{/* FLOATING SIMULATED DOMAIN SWITCHER (SANDBOX / DEVELOPMENT ONLY) */}
<div className="fixed bottom-4 right-4 z-[9999] flex flex-col items-end gap-1 font-sans">
<div className="bg-black/80 border border-white/10 backdrop-blur-md rounded-xl p-2.5 shadow-2xl flex items-center space-x-3 text-xs">
<div className="flex flex-col">
<span className="text-[10px] text-zinc-500 font-extrabold uppercase tracking-wider leading-none">
Ambiente de Desenvolvimento
</span>
<span className="text-white font-bold tracking-tight mt-0.5 flex items-center gap-1">
<Globe className="w-3.5 h-3.5 text-[#E50914] animate-spin" style={{ animationDuration: '6s' }} />
{isAdmin ? 'admin-estudo.microtecinformaticacurso.com.br' : 'estudo.microtecinformaticacurso.com.br'}
</span>
</div>
<div className="flex bg-zinc-900 p-0.5 rounded-lg border border-white/5">
<button
onClick={() => handleToggleDomain('student')}
className={`px-2 py-1 rounded-md font-bold text-[10px] uppercase tracking-wider transition-all cursor-pointer ${!isAdmin ? 'bg-[#E50914] text-white' : 'text-zinc-500 hover:text-zinc-300'}`}
>
Aluno
</button>
<button
onClick={() => handleToggleDomain('admin')}
className={`px-2 py-1 rounded-md font-bold text-[10px] uppercase tracking-wider transition-all cursor-pointer ${isAdmin ? 'bg-[#E50914] text-white' : 'text-zinc-500 hover:text-zinc-300'}`}
>
Admin
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,747 @@
import React, { useState, useEffect } from 'react';
import {
GraduationCap, Plus, Edit, Trash2, FolderPlus, FilePlus, ChevronRight,
ArrowLeft, Film, Youtube, RefreshCw, Layers, CheckCircle
} from 'lucide-react';
interface Lesson {
id: string;
moduleId: string;
title: string;
videoUrl: string;
videoType: 'youtube' | 'direct';
order: number;
content: string;
}
interface Module {
id: string;
courseId: string;
title: string;
order: number;
lessons: Lesson[];
}
interface Course {
id: string;
title: string;
description: string;
thumbnail: string;
category: string;
isLocked?: boolean;
price?: number;
}
interface AdminContentManagerProps {
token: string | null;
}
export default function AdminContentManager({ token }: AdminContentManagerProps) {
const [courses, setCourses] = useState<Course[]>([]);
const [selectedCourse, setSelectedCourse] = useState<Course | null>(null);
const [modules, setModules] = useState<Module[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Forms states
const [showCourseForm, setShowCourseForm] = useState(false);
const [editingCourse, setEditingCourse] = useState<Course | null>(null);
const [courseForm, setCourseForm] = useState({
title: '', description: '', thumbnail: '', category: '', isLocked: false, price: 0
});
const [showModuleForm, setShowModuleForm] = useState(false);
const [editingModule, setEditingModule] = useState<Module | null>(null);
const [moduleForm, setModuleForm] = useState({ title: '', order: 1 });
const [showLessonForm, setShowLessonForm] = useState(false);
const [editingLesson, setEditingLesson] = useState<Lesson | null>(null);
const [activeModuleForLesson, setActiveModuleForLesson] = useState<string | null>(null);
const [lessonForm, setLessonForm] = useState({
title: '', videoUrl: '', videoType: 'youtube' as 'youtube' | 'direct', order: 1, content: ''
});
useEffect(() => {
fetchCourses();
}, []);
const fetchCourses = async () => {
setIsLoading(true);
try {
const res = await fetch('/api/courses', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
setCourses(data);
}
} catch (err) {
console.error('Error fetching courses:', err);
} finally {
setIsLoading(false);
}
};
const fetchCourseModulesAndLessons = async (courseId: string) => {
setIsLoading(true);
try {
const res = await fetch(`/api/courses/${courseId}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
setModules(data.modules);
}
} catch (err) {
console.error('Error loading modules:', err);
} finally {
setIsLoading(false);
}
};
const handleSelectCourse = (course: Course) => {
setSelectedCourse(course);
fetchCourseModulesAndLessons(course.id);
};
// --- COURSE CRUD ---
const handleSaveCourse = async (e: React.FormEvent) => {
e.preventDefault();
const method = editingCourse ? 'PUT' : 'POST';
const url = editingCourse ? `/api/admin/courses/${editingCourse.id}` : '/api/admin/courses';
try {
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(courseForm)
});
if (res.ok) {
await fetchCourses();
setShowCourseForm(false);
setEditingCourse(null);
setCourseForm({ title: '', description: '', thumbnail: '', category: '', isLocked: false, price: 0 });
}
} catch (err) {
console.error('Error saving course:', err);
}
};
const handleEditCourse = (course: Course) => {
setEditingCourse(course);
setCourseForm({
title: course.title,
description: course.description,
thumbnail: course.thumbnail,
category: course.category,
isLocked: course.isLocked || false,
price: course.price || 0
});
setShowCourseForm(true);
};
const handleDeleteCourse = async (courseId: string) => {
if (!window.confirm('Tem certeza que deseja excluir este curso e todo o seu conteúdo (módulos, aulas, anotações)?')) return;
try {
const res = await fetch(`/api/admin/courses/${courseId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
fetchCourses();
if (selectedCourse?.id === courseId) {
setSelectedCourse(null);
setModules([]);
}
}
} catch (err) {
console.error('Error deleting course:', err);
}
};
// --- MODULE CRUD ---
const handleSaveModule = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedCourse) return;
const method = editingModule ? 'PUT' : 'POST';
const url = editingModule ? `/api/admin/modules/${editingModule.id}` : '/api/admin/modules';
const payload = editingModule ? { ...moduleForm } : { ...moduleForm, courseId: selectedCourse.id };
try {
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(payload)
});
if (res.ok) {
await fetchCourseModulesAndLessons(selectedCourse.id);
setShowModuleForm(false);
setEditingModule(null);
setModuleForm({ title: '', order: 1 });
}
} catch (err) {
console.error('Error saving module:', err);
}
};
const handleEditModule = (mod: Module) => {
setEditingModule(mod);
setModuleForm({ title: mod.title, order: mod.order });
setShowModuleForm(true);
};
const handleDeleteModule = async (modId: string) => {
if (!window.confirm('Excluir este módulo apagará todas as suas aulas associadas. Continuar?')) return;
if (!selectedCourse) return;
try {
const res = await fetch(`/api/admin/modules/${modId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
fetchCourseModulesAndLessons(selectedCourse.id);
}
} catch (err) {
console.error('Error deleting module:', err);
}
};
// --- LESSON CRUD ---
const handleSaveLesson = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedCourse) return;
const method = editingLesson ? 'PUT' : 'POST';
const url = editingLesson ? `/api/admin/lessons/${editingLesson.id}` : '/api/admin/lessons';
const payload = editingLesson ? { ...lessonForm } : { ...lessonForm, moduleId: activeModuleForLesson };
try {
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(payload)
});
if (res.ok) {
await fetchCourseModulesAndLessons(selectedCourse.id);
setShowLessonForm(false);
setEditingLesson(null);
setActiveModuleForLesson(null);
setLessonForm({ title: '', videoUrl: '', videoType: 'youtube', order: 1, content: '' });
}
} catch (err) {
console.error('Error saving lesson:', err);
}
};
const handleNewLesson = (moduleId: string, lessonsCount: number) => {
setActiveModuleForLesson(moduleId);
setEditingLesson(null);
setLessonForm({
title: '',
videoUrl: '',
videoType: 'youtube',
order: lessonsCount + 1,
content: ''
});
setShowLessonForm(true);
};
const handleEditLesson = (lesson: Lesson) => {
setEditingLesson(lesson);
setLessonForm({
title: lesson.title,
videoUrl: lesson.videoUrl,
videoType: lesson.videoType,
order: lesson.order,
content: lesson.content
});
setShowLessonForm(true);
};
const handleDeleteLesson = async (lessonId: string) => {
if (!window.confirm('Excluir esta aula?')) return;
if (!selectedCourse) return;
try {
const res = await fetch(`/api/admin/lessons/${lessonId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
fetchCourseModulesAndLessons(selectedCourse.id);
}
} catch (err) {
console.error('Error deleting lesson:', err);
}
};
if (isLoading && courses.length === 0) {
return (
<div className="flex flex-col items-center justify-center min-h-[50vh] text-zinc-400 space-y-4">
<RefreshCw className="w-8 h-8 text-[#E50914] animate-spin" />
<p className="text-sm font-semibold">Carregando catálogo de treinamentos...</p>
</div>
);
}
return (
<div className="space-y-8">
{/* Top Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between border-b border-white/10 pb-5 gap-4">
<div>
<h1 className="text-2xl md:text-3xl font-display font-extrabold text-white tracking-tight flex items-center space-x-2">
<GraduationCap className="w-8 h-8 text-[#E50914]" />
<span>Gestão de Cursos, Módulos e Aulas</span>
</h1>
<p className="text-xs text-zinc-500 mt-1">Crie cursos, adicione módulos e configure links de vídeo (YouTube ou Upload Direto).</p>
</div>
{!selectedCourse && !showCourseForm && (
<button
onClick={() => { setEditingCourse(null); setCourseForm({ title: '', description: '', thumbnail: '', category: '' }); setShowCourseForm(true); }}
className="glass-btn-primary text-white font-bold text-xs px-4 py-2.5 rounded-lg flex items-center justify-center space-x-2 cursor-pointer self-start sm:self-auto"
>
<Plus className="w-4 h-4" />
<span>Adicionar Novo Curso</span>
</button>
)}
</div>
{/* --- RENDER COURSE FORM --- */}
{showCourseForm && (
<div className="glass-card p-6 rounded-2xl space-y-6">
<div className="flex items-center justify-between border-b border-white/5 pb-4">
<h3 className="text-lg font-bold text-white">
{editingCourse ? 'Editar Detalhes do Curso' : 'Criar Novo Curso'}
</h3>
<button
onClick={() => setShowCourseForm(false)}
className="text-zinc-500 hover:text-zinc-300 text-xs font-semibold"
>
Cancelar
</button>
</div>
<form onSubmit={handleSaveCourse} className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-xs text-zinc-400 font-bold uppercase tracking-wider">Título do Curso</label>
<input
type="text"
required
value={courseForm.title}
onChange={(e) => setCourseForm({ ...courseForm, title: e.target.value })}
placeholder="Ex: Infraestrutura de Servidores Linux"
className="w-full glass-input rounded-lg p-3 text-sm text-white focus:outline-none"
/>
</div>
<div className="space-y-2">
<label className="text-xs text-zinc-400 font-bold uppercase tracking-wider">Categoria / Trilha</label>
<input
type="text"
required
value={courseForm.category}
onChange={(e) => setCourseForm({ ...courseForm, category: e.target.value })}
placeholder="Ex: Redes e Servidores"
className="w-full glass-input rounded-lg p-3 text-sm text-white focus:outline-none"
/>
</div>
<div className="md:col-span-2 space-y-2">
<label className="text-xs text-zinc-400 font-bold uppercase tracking-wider">Imagem de Capa (Thumbnail URL)</label>
<input
type="url"
required
value={courseForm.thumbnail}
onChange={(e) => setCourseForm({ ...courseForm, thumbnail: e.target.value })}
placeholder="https://images.unsplash.com/... ou link de imagem direta"
className="w-full glass-input rounded-lg p-3 text-sm text-white focus:outline-none"
/>
</div>
<div className="space-y-2">
<label className="text-xs text-zinc-400 font-bold uppercase tracking-wider">Preço do Curso para Desbloqueio (R$)</label>
<input
type="number"
step="0.01"
value={courseForm.price}
onChange={(e) => setCourseForm({ ...courseForm, price: Number(e.target.value) })}
placeholder="Ex: 89.90"
className="w-full glass-input rounded-lg p-3 text-sm text-white focus:outline-none font-mono"
/>
</div>
<div className="space-y-2 flex flex-col justify-center">
<label className="text-xs text-zinc-400 font-bold uppercase tracking-wider mb-2">Status de Bloqueio</label>
<label className="inline-flex items-center space-x-3 cursor-pointer">
<input
type="checkbox"
checked={courseForm.isLocked}
onChange={(e) => setCourseForm({ ...courseForm, isLocked: e.target.checked })}
className="rounded bg-zinc-900 border-white/10 text-[#E50914] focus:ring-[#E50914] focus:ring-offset-0 w-4 h-4"
/>
<span className="text-sm font-semibold text-zinc-300">Curso Trancado (Requer pagamento ou desbloqueio manual)</span>
</label>
</div>
<div className="md:col-span-2 space-y-2">
<label className="text-xs text-zinc-400 font-bold uppercase tracking-wider">Descrição Detalhada</label>
<textarea
required
value={courseForm.description}
onChange={(e) => setCourseForm({ ...courseForm, description: e.target.value })}
placeholder="Fale brevemente sobre o objetivo deste treinamento, o que o aluno vai aprender..."
className="w-full h-24 glass-input rounded-lg p-3 text-sm text-white focus:outline-none resize-none"
/>
</div>
<div className="md:col-span-2 flex justify-end">
<button
type="submit"
className="glass-btn-primary text-white text-xs font-bold px-6 py-3 rounded-lg cursor-pointer"
>
{editingCourse ? 'Salvar Alterações' : 'Criar Curso'}
</button>
</div>
</form>
</div>
)}
{/* --- RENDER COURSE DETAIL / CURRICULUM MANAGER --- */}
{selectedCourse ? (
<div className="space-y-6">
{/* Active Course Breadcrumb */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 glass-card p-4 rounded-xl">
<button
onClick={() => setSelectedCourse(null)}
className="flex items-center space-x-1.5 text-zinc-400 hover:text-white transition-colors text-xs font-bold"
>
<ArrowLeft className="w-4 h-4" />
<span>Voltar para Catálogo Completo</span>
</button>
<div className="flex items-center space-x-3">
<span className="text-zinc-600 text-xs font-bold">Curso Selecionado:</span>
<span className="text-white text-xs font-bold bg-[#E50914] px-2.5 py-1 rounded">
{selectedCourse.title}
</span>
</div>
<div className="flex space-x-2">
<button
onClick={() => { setEditingModule(null); setModuleForm({ title: '', order: modules.length + 1 }); setShowModuleForm(true); }}
className="glass-btn-secondary text-white text-xs font-bold px-3 py-1.5 rounded flex items-center space-x-1"
>
<FolderPlus className="w-3.5 h-3.5 text-zinc-400" />
<span>Criar Módulo</span>
</button>
</div>
</div>
{/* --- MODULE FORM DIALOG --- */}
{showModuleForm && (
<div className="glass-card p-6 rounded-2xl border-[#E50914]/30 space-y-4">
<h4 className="text-sm font-bold text-white uppercase tracking-wider">
{editingModule ? 'Editar Módulo' : 'Adicionar Novo Módulo'}
</h4>
<form onSubmit={handleSaveModule} className="flex flex-col sm:flex-row gap-4 items-end">
<div className="flex-1 space-y-1">
<label className="text-[10px] text-zinc-500 font-bold uppercase">Título do Módulo</label>
<input
type="text"
required
value={moduleForm.title}
onChange={(e) => setModuleForm({ ...moduleForm, title: e.target.value })}
placeholder="Ex: Módulo 1 - Fundamentos"
className="w-full glass-input rounded-lg p-2.5 text-xs text-white focus:outline-none"
/>
</div>
<div className="w-24 space-y-1">
<label className="text-[10px] text-zinc-500 font-bold uppercase">Ordem</label>
<input
type="number"
required
value={moduleForm.order}
onChange={(e) => setModuleForm({ ...moduleForm, order: Number(e.target.value) })}
className="w-full glass-input rounded-lg p-2.5 text-xs text-white focus:outline-none"
/>
</div>
<div className="flex space-x-2">
<button type="submit" className="glass-btn-primary text-white text-xs font-bold px-4 py-2.5 rounded-lg">
Salvar
</button>
<button type="button" onClick={() => setShowModuleForm(false)} className="text-xs text-zinc-500 px-2">
Cancelar
</button>
</div>
</form>
</div>
)}
{/* --- LESSON FORM DIALOG --- */}
{showLessonForm && (
<div className="glass-card p-6 rounded-2xl space-y-6">
<div className="flex items-center justify-between border-b border-white/5 pb-4">
<h4 className="text-sm font-bold text-white uppercase tracking-wider">
{editingLesson ? 'Editar Aula' : 'Adicionar Nova Aula'}
</h4>
<button type="button" onClick={() => setShowLessonForm(false)} className="text-xs text-zinc-500">
Cancelar
</button>
</div>
<form onSubmit={handleSaveLesson} className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="md:col-span-2 space-y-2">
<label className="text-xs text-zinc-400 font-bold uppercase">Título da Aula</label>
<input
type="text"
required
value={lessonForm.title}
onChange={(e) => setLessonForm({ ...lessonForm, title: e.target.value })}
placeholder="Ex: Como configurar SSH no Servidor Linux"
className="w-full glass-input rounded-lg p-3 text-xs text-white focus:outline-none"
/>
</div>
<div className="space-y-2">
<label className="text-xs text-zinc-400 font-bold uppercase">Ordem Numérica</label>
<input
type="number"
required
value={lessonForm.order}
onChange={(e) => setLessonForm({ ...lessonForm, order: Number(e.target.value) })}
className="w-full glass-input rounded-lg p-3 text-xs text-white focus:outline-none"
/>
</div>
<div className="space-y-2">
<label className="text-xs text-zinc-400 font-bold uppercase">Tipo de Vídeo</label>
<select
value={lessonForm.videoType}
onChange={(e) => setLessonForm({ ...lessonForm, videoType: e.target.value as 'youtube' | 'direct' })}
className="w-full glass-input rounded-lg p-3 text-xs text-white focus:outline-none"
>
<option value="youtube">YouTube Embed (Privado/Não Listado)</option>
<option value="direct">Upload Direto / Link HTML5 MP4</option>
</select>
</div>
<div className="md:col-span-2 space-y-2">
<label className="text-xs text-zinc-400 font-bold uppercase">
{lessonForm.videoType === 'youtube'
? 'Link do Vídeo no YouTube (ou ID do vídeo)'
: 'Link URL do arquivo de vídeo direto (.mp4 / HLS)'}
</label>
<input
type="text"
required
value={lessonForm.videoUrl}
onChange={(e) => setLessonForm({ ...lessonForm, videoUrl: e.target.value })}
placeholder={lessonForm.videoType === 'youtube'
? 'Ex: https://www.youtube.com/watch?v=K8w3m_pM6lA'
: 'Ex: https://meuservidor.com/aulas/aula1.mp4'}
className="w-full glass-input rounded-lg p-3 text-xs text-white focus:outline-none"
/>
</div>
<div className="md:col-span-3 space-y-2">
<label className="text-xs text-zinc-400 font-bold uppercase font-sans">Recursos Extras & Material de Apoio (Opcional)</label>
<textarea
value={lessonForm.content}
onChange={(e) => setLessonForm({ ...lessonForm, content: e.target.value })}
placeholder="Cole códigos de exemplo, links de arquivos zip ou notas de apoio aqui..."
className="w-full h-24 glass-input rounded-lg p-3 text-xs text-white focus:outline-none"
/>
</div>
<div className="md:col-span-3 flex justify-end">
<button type="submit" className="glass-btn-primary text-white font-bold text-xs px-6 py-2.5 rounded-lg">
{editingLesson ? 'Salvar Aula' : 'Criar Aula'}
</button>
</div>
</form>
</div>
)}
{/* Curriculum Index (Modules & Lessons list) */}
<div className="space-y-4">
{modules.length === 0 ? (
<div className="p-8 text-center glass-card rounded-xl text-zinc-500">
<Layers className="w-8 h-8 mx-auto text-zinc-600 mb-2" />
<p className="text-sm font-semibold">Nenhum módulo cadastrado neste curso.</p>
<button
onClick={() => { setEditingModule(null); setModuleForm({ title: '', order: 1 }); setShowModuleForm(true); }}
className="mt-3 text-xs text-[#E50914] font-bold"
>
Criar primeiro módulo agora
</button>
</div>
) : (
modules.map((mod, modIdx) => (
<div key={mod.id} className="glass-card rounded-xl overflow-hidden">
{/* Module Header Panel */}
<div className="glass-accordion-header px-4 py-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex items-center space-x-2">
<span className="bg-zinc-800 border border-zinc-700 text-[#E50914] text-[10px] font-extrabold px-1.5 py-0.5 rounded">
M{mod.order}
</span>
<h4 className="font-sans font-extrabold text-sm text-zinc-100 tracking-tight">
{mod.title}
</h4>
</div>
<div className="flex items-center space-x-2">
{/* Add Lesson */}
<button
onClick={() => handleNewLesson(mod.id, mod.lessons.length)}
className="bg-zinc-800 text-zinc-300 hover:text-white hover:bg-zinc-700 text-[10px] font-bold px-2 py-1 rounded flex items-center space-x-1 border border-zinc-700 transition-colors"
>
<FilePlus className="w-3 h-3 text-[#E50914]" />
<span>Nova Aula</span>
</button>
{/* Edit Module */}
<button
onClick={() => handleEditModule(mod)}
className="text-zinc-500 hover:text-zinc-300 p-1"
title="Editar Módulo"
>
<Edit className="w-3.5 h-3.5" />
</button>
{/* Delete Module */}
<button
onClick={() => handleDeleteModule(mod.id)}
className="text-zinc-500 hover:text-red-400 p-1"
title="Excluir Módulo"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Lessons Listing inside Module */}
<div className="divide-y divide-white/5 bg-black/10">
{mod.lessons.length === 0 ? (
<div className="p-4 text-xs text-zinc-600 italic text-center">Nenhuma aula cadastrada neste módulo.</div>
) : (
mod.lessons.map(lesson => (
<div key={lesson.id} className="px-6 py-3 flex items-center justify-between text-xs hover:bg-zinc-900/10 transition-colors">
<div className="flex items-center space-x-3 min-w-0">
<span className="text-zinc-500 font-mono font-bold w-4 text-right">#{lesson.order}</span>
{lesson.videoType === 'youtube' ? (
<Youtube className="w-4 h-4 text-red-500 flex-shrink-0" />
) : (
<Film className="w-4 h-4 text-emerald-500 flex-shrink-0" />
)}
<span className="font-semibold text-zinc-300 truncate max-w-sm sm:max-w-md md:max-w-lg">
{lesson.title}
</span>
</div>
<div className="flex items-center space-x-2 ml-4">
{/* Edit */}
<button
onClick={() => handleEditLesson(lesson)}
className="text-zinc-500 hover:text-white p-1"
title="Editar Aula"
>
<Edit className="w-3.5 h-3.5" />
</button>
{/* Delete */}
<button
onClick={() => handleDeleteLesson(lesson.id)}
className="text-zinc-500 hover:text-red-400 p-1"
title="Excluir Aula"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))
)}
</div>
</div>
))
)}
</div>
</div>
) : (
/* --- LIST ALL COURSES IN CATALOGUE --- */
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{courses.map(course => (
<div
key={course.id}
className="glass-card rounded-2xl overflow-hidden flex flex-col justify-between"
>
<div>
{/* Thumbnail */}
<div className="relative w-full aspect-video">
<img src={course.thumbnail} alt={course.title} className="w-full h-full object-cover" referrerPolicy="no-referrer" />
<span className="absolute top-3 left-3 bg-[#E50914]/85 text-white text-[10px] font-black px-2 py-0.5 rounded uppercase tracking-wider glass-badge">
{course.category}
</span>
</div>
{/* Info */}
<div className="p-5 space-y-2">
<h3 className="font-display font-bold text-base text-white tracking-tight line-clamp-1">{course.title}</h3>
<p className="text-xs text-zinc-500 leading-relaxed line-clamp-3">{course.description}</p>
</div>
</div>
{/* Actions Footer */}
<div className="p-5 border-t border-white/5 bg-white/3 flex items-center justify-between">
<button
onClick={() => handleSelectCourse(course)}
className="glass-btn-secondary text-white text-xs font-bold px-3 py-2 rounded flex items-center space-x-1 cursor-pointer"
>
<span>Grade Curricular</span>
<ChevronRight className="w-3.5 h-3.5" />
</button>
<div className="flex space-x-1.5">
<button
onClick={() => handleEditCourse(course)}
className="p-2 text-zinc-400 hover:text-white rounded bg-zinc-800/30 hover:bg-zinc-800 border border-zinc-850 transition-all"
title="Editar Curso"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleDeleteCourse(course.id)}
className="p-2 text-zinc-400 hover:text-red-400 rounded bg-zinc-800/30 hover:bg-zinc-800 border border-zinc-850 transition-all"
title="Excluir Curso"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,613 @@
import React, { useState, useEffect } from 'react';
import {
TrendingUp, Users, AlertTriangle, XCircle, Search,
Settings, CheckCircle2, RefreshCw, Link as LinkIcon, ShieldCheck, HelpCircle, Eye
} from 'lucide-react';
import { SubscriptionStatus } from '../types';
interface WebhookLog {
id: string;
event: string;
payload: string;
receivedAt: string;
}
interface Student {
id: string;
name: string;
email: string;
subscriptionStatus: SubscriptionStatus;
asaasCustomerId: string | null;
createdAt: string;
totalPaid: number;
unlockedCourses?: string[];
}
interface PaymentLog {
id: string;
userId: string;
studentName: string;
studentEmail: string;
value: number;
status: 'PENDING' | 'RECEIVED' | 'CONFIRMED' | 'OVERDUE' | 'REFUNDED';
billingType: 'PIX' | 'CREDIT_CARD' | 'BOLETO';
invoiceUrl: string;
dueDate: string;
paidAt: string | null;
createdAt: string;
}
interface DashboardStats {
mrr: number;
activeSubscribers: number;
trialSubscribers: number;
overdueSubscribers: number;
canceledSubscribers: number;
}
interface AdminDashboardProps {
token: string | null;
}
export default function AdminDashboard({ token }: AdminDashboardProps) {
const [stats, setStats] = useState<DashboardStats | null>(null);
const [payments, setPayments] = useState<PaymentLog[]>([]);
const [webhookLogs, setWebhookLogs] = useState<WebhookLog[]>([]);
const [students, setStudents] = useState<Student[]>([]);
const [courses, setCourses] = useState<any[]>([]);
const [expandedStudentId, setExpandedStudentId] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [activeTab, setActiveTab] = useState<'finance' | 'students' | 'webhooks'>('finance');
const [isLoading, setIsLoading] = useState(true);
const [actionLoading, setActionLoading] = useState<string | null>(null);
useEffect(() => {
fetchDashboardData();
fetchStudents();
fetchCourses();
}, []);
const fetchCourses = async () => {
try {
const res = await fetch('/api/courses', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
setCourses(data);
}
} catch (err) {
console.error('Error fetching courses:', err);
}
};
const fetchDashboardData = async () => {
setIsLoading(true);
try {
const res = await fetch('/api/admin/dashboard', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
setStats(data.stats);
setPayments(data.recentPayments);
setWebhookLogs(data.webhookLogs);
}
} catch (err) {
console.error('Error fetching dashboard stats:', err);
} finally {
setIsLoading(false);
}
};
const fetchStudents = async () => {
try {
const res = await fetch('/api/admin/students', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
setStudents(data);
}
} catch (err) {
console.error('Error fetching students:', err);
}
};
const handleChangeStudentStatus = async (studentId: string, newStatus: SubscriptionStatus) => {
setActionLoading(studentId);
try {
const res = await fetch(`/api/admin/students/${studentId}/status`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ status: newStatus })
});
if (res.ok) {
// Refresh local student details
await fetchStudents();
await fetchDashboardData();
}
} catch (err) {
console.error('Error changing student status:', err);
} finally {
setActionLoading(null);
}
};
const handleToggleCourseUnlock = async (studentId: string, courseId: string, currentUnlocked: boolean) => {
setActionLoading(`${studentId}_${courseId}`);
try {
const res = await fetch(`/api/admin/students/${studentId}/unlock-course`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ courseId, unlocked: !currentUnlocked })
});
if (res.ok) {
await fetchStudents();
}
} catch (err) {
console.error('Error toggling course unlock:', err);
} finally {
setActionLoading(null);
}
};
const getStatusTag = (status: SubscriptionStatus) => {
switch (status) {
case 'ACTIVE':
return <span className="bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 px-2.5 py-0.5 rounded-full text-xs font-bold tracking-wide">ATIVO</span>;
case 'TRIAL':
return <span className="bg-amber-500/10 text-amber-500 border border-amber-500/20 px-2.5 py-0.5 rounded-full text-xs font-bold tracking-wide">PERÍODO TESTE</span>;
case 'OVERDUE':
return <span className="bg-red-500/10 text-red-500 border border-red-500/20 px-2.5 py-0.5 rounded-full text-xs font-bold tracking-wide">INADIMPLENTE</span>;
case 'CANCELED':
return <span className="bg-zinc-800 text-zinc-400 border border-zinc-700 px-2.5 py-0.5 rounded-full text-xs font-bold tracking-wide">CANCELADO</span>;
default:
return <span className="bg-zinc-700 text-zinc-300 px-2.5 py-0.5 rounded-full text-xs font-bold tracking-wide">{status}</span>;
}
};
const getPaymentStatusTag = (status: string) => {
switch (status) {
case 'CONFIRMED':
case 'RECEIVED':
return <span className="text-emerald-500 font-bold"> Confirmado</span>;
case 'PENDING':
return <span className="text-amber-500 font-bold"> Pendente</span>;
case 'OVERDUE':
return <span className="text-red-500 font-bold"> Vencido</span>;
default:
return <span className="text-zinc-500">{status}</span>;
}
};
const filteredStudents = students.filter(student =>
student.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
student.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
(student.asaasCustomerId && student.asaasCustomerId.toLowerCase().includes(searchQuery.toLowerCase()))
);
if (isLoading || !stats) {
return (
<div className="flex flex-col items-center justify-center min-h-[50vh] text-zinc-400 space-y-4">
<RefreshCw className="w-8 h-8 text-[#E50914] animate-spin" />
<p className="text-sm font-semibold">Atualizando métricas de faturamento...</p>
</div>
);
}
return (
<div className="space-y-8">
{/* Dashboard Top Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between border-b border-white/10 pb-5 gap-4">
<div>
<h1 className="text-2xl md:text-3xl font-display font-extrabold text-white tracking-tight flex items-center space-x-2">
<ShieldCheck className="w-8 h-8 text-[#E50914]" />
<span>Painel de Controle Financeiro & Alunos</span>
</h1>
<p className="text-xs text-zinc-500 mt-1">Gerencie métricas de faturamento, assinaturas recorrentes e controle acessos.</p>
</div>
<button
onClick={() => { fetchDashboardData(); fetchStudents(); }}
className="glass-btn-secondary text-white font-bold text-xs px-4 py-2.5 rounded-lg flex items-center justify-center space-x-2 cursor-pointer self-start sm:self-auto"
>
<RefreshCw className="w-4 h-4" />
<span>Atualizar Dados</span>
</button>
</div>
{/* METRICS ROW (MRR, Actives, Overdue, Cancelled) */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{/* MRR Card */}
<div className="glass-card p-6 rounded-2xl flex items-center justify-between">
<div className="space-y-1">
<p className="text-xs text-zinc-500 font-bold uppercase tracking-wider">MRR (Recorrente Mensal)</p>
<p className="text-2xl md:text-3xl font-display font-black text-[#E50914] tracking-tight">
R$ {stats.mrr.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}
</p>
<p className="text-[10px] text-zinc-400 font-medium">Faturamento estimado ativo</p>
</div>
<div className="bg-[#E50914]/10 text-[#E50914] p-3 rounded-xl border border-[#E50914]/20">
<TrendingUp className="w-6 h-6" />
</div>
</div>
{/* Active Subscribers Card */}
<div className="glass-card p-6 rounded-2xl flex items-center justify-between">
<div className="space-y-1">
<p className="text-xs text-zinc-500 font-bold uppercase tracking-wider">Assinantes Ativos</p>
<p className="text-2xl md:text-3xl font-display font-black text-white tracking-tight">
{stats.activeSubscribers}
</p>
<p className="text-[10px] text-zinc-400 font-medium">({stats.trialSubscribers} em período de teste)</p>
</div>
<div className="bg-emerald-500/10 text-emerald-500 p-3 rounded-xl border border-emerald-500/20">
<Users className="w-6 h-6" />
</div>
</div>
{/* Overdue Subscribers Card */}
<div className="glass-card p-6 rounded-2xl flex items-center justify-between">
<div className="space-y-1">
<p className="text-xs text-zinc-500 font-bold uppercase tracking-wider">Inadimplentes</p>
<p className="text-2xl md:text-3xl font-display font-black text-red-500 tracking-tight">
{stats.overdueSubscribers}
</p>
<p className="text-[10px] text-zinc-400 font-medium">Bloqueados ou com cobrança pendente</p>
</div>
<div className="bg-red-500/10 text-red-500 p-3 rounded-xl border border-red-500/20">
<AlertTriangle className="w-6 h-6" />
</div>
</div>
{/* Cancelled Card */}
<div className="glass-card p-6 rounded-2xl flex items-center justify-between">
<div className="space-y-1">
<p className="text-xs text-zinc-500 font-bold uppercase tracking-wider">Cancelamentos</p>
<p className="text-2xl md:text-3xl font-display font-black text-zinc-400 tracking-tight">
{stats.canceledSubscribers}
</p>
<p className="text-[10px] text-zinc-400 font-medium">Histórico total de contas canceladas</p>
</div>
<div className="bg-zinc-800 text-zinc-400 p-3 rounded-xl border border-zinc-700">
<XCircle className="w-6 h-6" />
</div>
</div>
</div>
{/* TABS CONTAINER */}
<div className="space-y-6">
<div className="flex border-b border-white/10">
<button
onClick={() => setActiveTab('finance')}
className={`px-5 py-3 font-semibold text-sm transition-colors border-b-2 cursor-pointer ${activeTab === 'finance' ? 'border-[#E50914] text-white' : 'border-transparent text-zinc-500 hover:text-zinc-300'}`}
>
Faturamento Recorrente
</button>
<button
onClick={() => setActiveTab('students')}
className={`px-5 py-3 font-semibold text-sm transition-colors border-b-2 cursor-pointer ${activeTab === 'students' ? 'border-[#E50914] text-white' : 'border-transparent text-zinc-500 hover:text-zinc-300'}`}
>
Gestão de Alunos ({students.length})
</button>
<button
onClick={() => setActiveTab('webhooks')}
className={`px-5 py-3 font-semibold text-sm transition-colors border-b-2 cursor-pointer ${activeTab === 'webhooks' ? 'border-[#E50914] text-white' : 'border-transparent text-zinc-500 hover:text-zinc-300'}`}
>
Auditoria de Webhooks Asaas
</button>
</div>
{/* TAB 1: FINANCE / PAYMENTS */}
{activeTab === 'finance' && (
<div className="space-y-6">
<div className="glass-card rounded-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-white/5 flex items-center justify-between">
<h3 className="font-display font-bold text-sm text-white">Transações e Cobranças Recorrentes</h3>
<span className="text-zinc-500 text-xs">Últimos lançamentos</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm divide-y divide-white/5">
<thead className="bg-white/3 text-xs text-zinc-400 font-bold uppercase">
<tr>
<th className="px-6 py-3.5">Aluno</th>
<th className="px-6 py-3.5">Valor</th>
<th className="px-6 py-3.5">Tipo</th>
<th className="px-6 py-3.5">Vencimento</th>
<th className="px-6 py-3.5">Status</th>
<th className="px-6 py-3.5">Comprovante Asaas</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5 bg-transparent">
{payments.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-zinc-500">Nenhum pagamento registrado ainda.</td>
</tr>
) : (
payments.map(pay => (
<tr key={pay.id} className="hover:bg-zinc-900/20 transition-colors">
<td className="px-6 py-4">
<div className="font-semibold text-white">{pay.studentName}</div>
<div className="text-xs text-zinc-500">{pay.studentEmail}</div>
</td>
<td className="px-6 py-4 font-bold text-zinc-200">
R$ {pay.value.toFixed(2)}
</td>
<td className="px-6 py-4">
<span className="text-xs font-mono bg-zinc-800 text-zinc-300 px-2 py-0.5 rounded-md border border-zinc-700">
{pay.billingType}
</span>
</td>
<td className="px-6 py-4 text-xs text-zinc-400">
{new Date(pay.dueDate).toLocaleDateString('pt-BR')}
</td>
<td className="px-6 py-4 text-xs">
{getPaymentStatusTag(pay.status)}
</td>
<td className="px-6 py-4">
<a
href={pay.invoiceUrl}
target="_blank"
rel="noreferrer"
className="text-xs text-zinc-400 hover:text-[#E50914] flex items-center space-x-1 transition-colors"
>
<LinkIcon className="w-3 h-3" />
<span>Segunda Via / Fatura</span>
</a>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* TAB 2: STUDENTS MANAGEMENT */}
{activeTab === 'students' && (
<div className="space-y-6">
{/* Search Bar */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3.5 top-3 w-4 h-4 text-zinc-500" />
<input
type="text"
placeholder="Pesquisar assinante por nome, e-mail ou código Asaas..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full glass-input rounded-lg py-2.5 pl-10 pr-4 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none transition-colors"
/>
</div>
</div>
<div className="glass-card rounded-2xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm divide-y divide-white/5">
<thead className="bg-white/3 text-xs text-zinc-400 font-bold uppercase">
<tr>
<th className="px-6 py-3.5">Nome / E-mail</th>
<th className="px-6 py-3.5">ID Cliente Asaas</th>
<th className="px-6 py-3.5">Data Cadastro</th>
<th className="px-6 py-3.5">Total Pago</th>
<th className="px-6 py-3.5">Status Assinatura</th>
<th className="px-6 py-3.5 text-right">Ação Manual (Alterar Status)</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5 bg-transparent">
{filteredStudents.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-zinc-500">Nenhum assinante correspondente encontrado.</td>
</tr>
) : (
filteredStudents.map(student => (
<React.Fragment key={student.id}>
<tr className="hover:bg-zinc-900/20 transition-colors">
<td className="px-6 py-4">
<div className="font-semibold text-white">{student.name}</div>
<div className="text-xs text-zinc-500">{student.email}</div>
{student.unlockedCourses && student.unlockedCourses.length > 0 && (
<div className="mt-1 flex items-center gap-1">
<span className="bg-[#E50914]/25 text-[#E50914] text-[9px] font-extrabold px-1.5 py-0.5 rounded uppercase tracking-wider">
{student.unlockedCourses.length} {student.unlockedCourses.length === 1 ? 'Curso Avulso' : 'Cursos Avulsos'}
</span>
</div>
)}
</td>
<td className="px-6 py-4 text-xs font-mono text-zinc-400">
{student.asaasCustomerId || <span className="text-zinc-600 italic">Sem vínculo</span>}
</td>
<td className="px-6 py-4 text-xs text-zinc-400">
{new Date(student.createdAt).toLocaleDateString('pt-BR')}
</td>
<td className="px-6 py-4 font-bold text-zinc-200">
R$ {student.totalPaid.toFixed(2)}
</td>
<td className="px-6 py-4">
{getStatusTag(student.subscriptionStatus)}
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end space-x-1">
{/* Manage Individual Courses Access */}
<button
onClick={() => setExpandedStudentId(expandedStudentId === student.id ? null : student.id)}
className={`text-[10px] font-bold px-2 py-1 rounded transition-colors cursor-pointer ${expandedStudentId === student.id ? 'bg-[#E50914] text-white' : 'bg-zinc-850 text-zinc-300 hover:bg-zinc-700'}`}
title="Liberar Acesso Manual a Cursos Trancados"
>
{expandedStudentId === student.id ? 'Ocultar Cursos' : 'Gerenciar Cursos'}
</button>
{/* Activate */}
<button
onClick={() => handleChangeStudentStatus(student.id, 'ACTIVE')}
disabled={actionLoading === student.id}
className="bg-emerald-950/40 border border-emerald-900/50 hover:bg-emerald-900 text-emerald-400 text-[10px] font-bold px-2 py-1 rounded transition-colors"
title="Ativar Acesso"
>
Ativar
</button>
{/* Suspend / Overdue */}
<button
onClick={() => handleChangeStudentStatus(student.id, 'OVERDUE')}
disabled={actionLoading === student.id}
className="bg-amber-950/40 border border-amber-900/50 hover:bg-amber-900 text-amber-400 text-[10px] font-bold px-2 py-1 rounded transition-colors"
title="Suspender (Inadimplente)"
>
Suspender
</button>
{/* Grant Trial */}
<button
onClick={() => handleChangeStudentStatus(student.id, 'TRIAL')}
disabled={actionLoading === student.id}
className="bg-indigo-950/40 border border-indigo-900/50 hover:bg-indigo-900 text-indigo-400 text-[10px] font-bold px-2 py-1 rounded transition-colors"
title="Conceder Período de Testes"
>
Teste
</button>
{/* Cancel */}
<button
onClick={() => handleChangeStudentStatus(student.id, 'CANCELED')}
disabled={actionLoading === student.id}
className="bg-red-950/40 border border-red-900/50 hover:bg-red-900 text-red-400 text-[10px] font-bold px-2 py-1 rounded transition-colors"
title="Cancelar Conta"
>
Cancelar
</button>
</div>
</td>
</tr>
{expandedStudentId === student.id && (
<tr className="bg-black/30">
<td colSpan={6} className="px-6 py-4 border-b border-white/5">
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-xs font-bold text-zinc-300 uppercase tracking-wider flex items-center gap-1.5">
<span>Liberar Acesso Manual a Cursos Trancados para {student.name}</span>
</h4>
<span className="text-[10px] text-zinc-500 italic">O preço de venda é definido no Gerenciador de Cursos</span>
</div>
{courses.filter(c => c.isLocked).length === 0 ? (
<p className="text-xs text-zinc-500 italic">Não cursos trancados cadastrados para desbloqueio individual.</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3">
{courses.filter(c => c.isLocked).map(course => {
const isUnlockedForStudent = student.unlockedCourses?.includes(course.id) || false;
const isPendingAction = actionLoading === `${student.id}_${course.id}`;
return (
<div key={course.id} className="bg-zinc-900/80 border border-white/5 rounded-lg p-3 flex items-center justify-between text-xs">
<div className="min-w-0 pr-2">
<p className="font-bold text-white truncate">{course.title}</p>
<p className="text-[10px] text-[#E50914] font-semibold mt-0.5 font-mono">R$ {course.price?.toFixed(2) || '0.00'}</p>
</div>
<button
disabled={isPendingAction}
onClick={() => handleToggleCourseUnlock(student.id, course.id, isUnlockedForStudent)}
className={`px-2.5 py-1.5 rounded text-[10px] font-bold transition-all cursor-pointer ${
isUnlockedForStudent
? 'bg-emerald-950 text-emerald-400 border border-emerald-900 hover:bg-red-950 hover:text-red-400 hover:border-red-900'
: 'bg-zinc-800 text-zinc-400 hover:bg-emerald-950 hover:text-emerald-400 hover:border-emerald-900 border border-transparent'
}`}
>
{isPendingAction ? 'Aguarde...' : isUnlockedForStudent ? '✓ Liberado (Bloquear)' : 'Bloqueado (Liberar)'}
</button>
</div>
);
})}
</div>
)}
</div>
</td>
</tr>
)}
</React.Fragment>
))
)}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* TAB 3: WEBHOOK AUDIT LOGS */}
{activeTab === 'webhooks' && (
<div className="space-y-6">
<div className="glass-card p-5 rounded-2xl space-y-4">
<div className="flex items-center space-x-2">
<HelpCircle className="w-5 h-5 text-zinc-400" />
<p className="text-xs text-zinc-400">
Os logs de eventos abaixo auditam as chamadas disparadas pela API do Asaas para o endpoint de integração do webhook <code className="bg-zinc-800 text-white px-1.5 py-0.5 rounded text-[10px]">/api/webhooks/asaas</code>.
</p>
</div>
</div>
<div className="glass-card rounded-2xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm divide-y divide-white/5">
<thead className="bg-white/3 text-xs text-zinc-400 font-bold uppercase">
<tr>
<th className="px-6 py-3.5">ID Log</th>
<th className="px-6 py-3.5">Evento Asaas</th>
<th className="px-6 py-3.5">Data Processamento</th>
<th className="px-6 py-3.5">Payload Recebido</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5 bg-transparent">
{webhookLogs.length === 0 ? (
<tr>
<td colSpan={4} className="px-6 py-8 text-center text-zinc-500">Nenhum evento registrado no banco de dados.</td>
</tr>
) : (
webhookLogs.map(log => (
<tr key={log.id} className="hover:bg-zinc-900/10 transition-colors">
<td className="px-6 py-4 text-xs font-mono text-zinc-400">
{log.id}
</td>
<td className="px-6 py-4">
<span className={`text-xs font-bold px-2.5 py-1 rounded border ${
log.event.includes('RECEIVED') || log.event.includes('CONFIRMED')
? 'bg-emerald-950 text-emerald-400 border-emerald-900'
: log.event.includes('OVERDUE')
? 'bg-red-950 text-red-400 border-red-900'
: 'bg-zinc-800 text-zinc-300 border-zinc-700'
}`}>
{log.event}
</span>
</td>
<td className="px-6 py-4 text-xs text-zinc-400">
{new Date(log.receivedAt).toLocaleString('pt-BR')}
</td>
<td className="px-6 py-4">
<pre className="text-[10px] text-zinc-500 font-mono max-w-sm overflow-x-auto truncate bg-zinc-900 p-2 rounded-lg border border-zinc-850">
{log.payload}
</pre>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,120 @@
import React from 'react';
import { Play, BookOpen, CheckCircle2, Lock } from 'lucide-react';
import { Course } from '../types';
interface CourseCardProps {
key?: string;
course: Course & { progress?: number; totalLessons?: number; completedLessons?: number; isUnlocked?: boolean };
onSelect: (courseId: string) => void;
onUnlockRequest?: (course: any) => void;
subscriptionStatus: string;
}
export default function CourseCard({ course, onSelect, onUnlockRequest, subscriptionStatus }: CourseCardProps) {
const isBlocked = subscriptionStatus === 'OVERDUE' || subscriptionStatus === 'CANCELED';
const isUnlocked = course.isUnlocked !== false; // defaults to true if undefined
return (
<div
id={`course-card-${course.id}`}
onClick={() => {
if (!isUnlocked) {
if (onUnlockRequest) onUnlockRequest(course);
} else {
onSelect(course.id);
}
}}
className={`group relative glass-card glass-card-hover rounded-xl overflow-hidden cursor-pointer flex flex-col h-full transition-all duration-300 ${!isUnlocked ? 'filter grayscale brightness-75 hover:brightness-100 hover:grayscale-0' : ''}`}
>
{/* Thumbnail */}
<div className="relative w-full aspect-video overflow-hidden">
<img
src={course.thumbnail}
alt={course.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
referrerPolicy="no-referrer"
/>
{/* Lock Overlay */}
{!isUnlocked && (
<div className="absolute inset-0 bg-black/60 flex flex-col items-center justify-center space-y-1.5 z-10 transition-opacity">
<div className="bg-[#E50914] text-white p-2.5 rounded-full shadow-[0_0_15px_rgba(229,9,20,0.4)] border border-white/10 animate-pulse">
<Lock className="w-5 h-5" />
</div>
<span className="text-[10px] uppercase tracking-widest font-extrabold text-white bg-black/50 px-2.5 py-0.5 rounded-full border border-white/5">
Curso Trancado
</span>
</div>
)}
{/* Play Overlay */}
{isUnlocked && (
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<div className="bg-[#E50914] text-white p-3 rounded-full transform scale-75 group-hover:scale-100 transition-all duration-300 shadow-lg">
<Play className="w-6 h-6 fill-white" />
</div>
</div>
)}
{/* Progress bar at the bottom of the thumbnail */}
{course.progress !== undefined && course.progress > 0 && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-white/10 backdrop-blur-sm">
<div
className="bg-[#E50914] h-full transition-all duration-300 shadow-[0_0_10px_rgba(229,9,20,0.5)]"
style={{ width: `${course.progress}%` }}
/>
</div>
)}
</div>
{/* Course Info */}
<div className="p-4 flex-grow flex flex-col justify-between space-y-3">
<div className="space-y-1">
{/* Category */}
<span className="text-[10px] font-bold text-[#E50914] tracking-wider uppercase flex items-center justify-between">
<span>{course.category}</span>
{!isUnlocked && course.price !== undefined && (
<span className="text-white bg-zinc-800 px-1.5 py-0.5 rounded text-[9px] font-mono border border-white/5">
R$ {course.price.toFixed(2)}
</span>
)}
</span>
{/* Title */}
<h3 className="font-display font-bold text-sm md:text-base text-white tracking-tight leading-snug group-hover:text-[#E50914] transition-colors line-clamp-1">
{course.title}
</h3>
{/* Description */}
<p className="text-xs text-zinc-400 font-normal line-clamp-2 leading-relaxed">
{course.description}
</p>
</div>
{/* Footer Metrics */}
<div className="flex items-center justify-between pt-2 border-t border-white/5 text-[11px] text-zinc-500">
<div className="flex items-center space-x-1 font-medium">
<BookOpen className="w-3.5 h-3.5 text-zinc-400" />
<span>{course.totalLessons || 0} Aulas</span>
</div>
{course.progress !== undefined && course.progress > 0 ? (
<div className="flex items-center space-x-1 text-[#E50914] font-semibold">
{course.progress === 100 ? (
<div className="flex items-center space-x-1 text-emerald-500">
<CheckCircle2 className="w-3.5 h-3.5" />
<span>Concluído</span>
</div>
) : (
<span>{course.progress}% assistido</span>
)}
</div>
) : (
<span className="text-zinc-500 text-[10px] uppercase font-bold flex items-center gap-1">
{!isUnlocked && <Lock className="w-2.5 h-2.5 text-[#E50914]" />}
{!isUnlocked ? 'Bloqueado' : 'Não iniciado'}
</span>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,43 @@
import React from 'react';
import { Course } from '../types';
import CourseCard from './CourseCard';
interface CourseCarouselProps {
key?: string;
title: string;
courses: (Course & { progress?: number; totalLessons?: number; completedLessons?: number; isUnlocked?: boolean })[];
onSelectCourse: (courseId: string) => void;
onUnlockCourse?: (course: any) => void;
subscriptionStatus: string;
}
export default function CourseCarousel({ title, courses, onSelectCourse, onUnlockCourse, subscriptionStatus }: CourseCarouselProps) {
if (courses.length === 0) return null;
return (
<div className="space-y-4 py-4">
{/* Category Title */}
<div className="flex items-center space-x-2 border-l-4 border-[#E50914] pl-3">
<h2 className="font-display font-extrabold text-lg md:text-xl text-white tracking-tight">
{title}
</h2>
<span className="text-zinc-500 text-xs font-bold">
({courses.length} {courses.length === 1 ? 'curso' : 'cursos'})
</span>
</div>
{/* Grid container (Scrollable on smaller viewports, neat grid on larger) */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{courses.map(course => (
<CourseCard
key={course.id}
course={course}
onSelect={onSelectCourse}
onUnlockRequest={onUnlockCourse}
subscriptionStatus={subscriptionStatus}
/>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,338 @@
import React, { useState } from 'react';
import { X, Check, CreditCard, QrCode, FileText, Sparkles, Loader2, Play } from 'lucide-react';
import { Course } from '../types';
interface CourseUnlockModalProps {
course: Course & { price?: number };
onClose: () => void;
onSuccess: () => void;
token: string;
}
export default function CourseUnlockModal({ course, onClose, onSuccess, token }: CourseUnlockModalProps) {
const [paymentMethod, setPaymentMethod] = useState<'PIX' | 'CREDIT_CARD' | 'BOLETO'>('PIX');
const [isPaying, setIsPaying] = useState(false);
const [pixCopied, setPixCopied] = useState(false);
const [boletoCopied, setBoletoCopied] = useState(false);
const [unlocked, setUnlocked] = useState(false);
const [error, setError] = useState<string | null>(null);
// Form states
const [ccNumber, setCcNumber] = useState('');
const [ccName, setCcName] = useState('');
const [ccExpiry, setCcExpiry] = useState('');
const [ccCvv, setCcCvv] = useState('');
const coursePrice = course.price || 49.90;
const handleCopyPix = () => {
navigator.clipboard.writeText(`00020126580014br.gov.bcb.pix0136unlock_course_${course.id}_microtecflix5204000053039865405${coursePrice.toFixed(2)}5802BR5915MicrotecFlix6009Sao Paulo62070503***6304`);
setPixCopied(true);
setTimeout(() => setPixCopied(false), 2000);
};
const handleCopyBoleto = () => {
navigator.clipboard.writeText("34191.79001 01043.513184 91020.150008 7 900200000" + Math.floor(coursePrice * 100));
setBoletoCopied(true);
setTimeout(() => setBoletoCopied(false), 2000);
};
const handleConfirmPayment = async (e: React.FormEvent) => {
e.preventDefault();
setIsPaying(true);
setError(null);
// Simulate network delay for a real feeling
await new Promise(resolve => setTimeout(resolve, 1800));
try {
const res = await fetch(`/api/courses/${course.id}/unlock`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
}
});
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.error || 'Erro ao processar pagamento');
}
setUnlocked(true);
} catch (err: any) {
setError(err.message || 'Erro de conexão.');
setIsPaying(false);
}
};
return (
<div id="unlock-modal-overlay" className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-md animate-fade-in">
<div className="relative w-full max-w-2xl bg-[#141414]/95 border border-white/10 rounded-2xl shadow-2xl overflow-hidden md:flex">
{/* Close Button */}
<button
onClick={onClose}
className="absolute top-4 right-4 z-20 bg-black/40 text-zinc-400 hover:text-white p-2 rounded-full border border-white/5 transition-colors cursor-pointer"
>
<X className="w-4 h-4" />
</button>
{!unlocked ? (
<>
{/* Left: Course Visual Details */}
<div className="w-full md:w-5/12 bg-zinc-950 p-6 flex flex-col justify-between border-r border-white/5">
<div className="space-y-4">
<span className="text-[10px] uppercase tracking-widest font-extrabold text-[#E50914] bg-[#E50914]/10 px-2.5 py-1 rounded-full border border-[#E50914]/20 inline-block">
Adquirir Curso
</span>
<div className="relative aspect-video rounded-lg overflow-hidden border border-white/10">
<img src={course.thumbnail} alt={course.title} className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent" />
</div>
<div className="space-y-2">
<h3 className="font-display font-black text-lg text-white leading-tight">
{course.title}
</h3>
<p className="text-xs text-zinc-400 line-clamp-3">
{course.description}
</p>
</div>
</div>
<div className="mt-6 pt-4 border-t border-white/5">
<p className="text-[10px] text-zinc-500 font-bold uppercase tracking-wider">Acesso Vitalício</p>
<div className="flex items-baseline space-x-1.5 mt-1">
<span className="text-zinc-400 text-xs font-semibold">R$</span>
<span className="text-white text-3xl font-black font-mono tracking-tight">{coursePrice.toFixed(2)}</span>
</div>
<p className="text-[10px] text-zinc-500 mt-1">Taxa única. Sem mensalidades adicionais.</p>
</div>
</div>
{/* Right: Payment Forms */}
<div className="w-full md:w-7/12 p-6 md:p-8 flex flex-col justify-between space-y-6">
<div>
<h4 className="font-display font-bold text-sm text-white uppercase tracking-wider mb-4">Escolha a forma de pagamento</h4>
{/* Tabs */}
<div className="grid grid-cols-3 gap-2 p-1 bg-zinc-900/60 rounded-xl border border-white/5 mb-6">
<button
type="button"
onClick={() => setPaymentMethod('PIX')}
className={`flex flex-col items-center justify-center py-2.5 rounded-lg text-xs font-semibold transition-all cursor-pointer ${paymentMethod === 'PIX' ? 'bg-[#E50914] text-white shadow-lg' : 'text-zinc-400 hover:text-white'}`}
>
<QrCode className="w-4 h-4 mb-1" />
<span>PIX</span>
</button>
<button
type="button"
onClick={() => setPaymentMethod('CREDIT_CARD')}
className={`flex flex-col items-center justify-center py-2.5 rounded-lg text-xs font-semibold transition-all cursor-pointer ${paymentMethod === 'CREDIT_CARD' ? 'bg-[#E50914] text-white shadow-lg' : 'text-zinc-400 hover:text-white'}`}
>
<CreditCard className="w-4 h-4 mb-1" />
<span>Cartão</span>
</button>
<button
type="button"
onClick={() => setPaymentMethod('BOLETO')}
className={`flex flex-col items-center justify-center py-2.5 rounded-lg text-xs font-semibold transition-all cursor-pointer ${paymentMethod === 'BOLETO' ? 'bg-[#E50914] text-white shadow-lg' : 'text-zinc-400 hover:text-white'}`}
>
<FileText className="w-4 h-4 mb-1" />
<span>Boleto</span>
</button>
</div>
{error && (
<div className="bg-red-950/40 border border-red-900/50 text-red-400 p-3 rounded-xl text-xs text-center mb-4">
{error}
</div>
)}
{/* Tab Contents */}
<form onSubmit={handleConfirmPayment} className="space-y-4">
{paymentMethod === 'PIX' && (
<div className="space-y-4 text-center">
<div className="bg-white p-3 rounded-xl inline-block shadow-xl border border-zinc-200">
{/* Beautiful generated mock QR Code */}
<div className="w-36 h-36 bg-zinc-100 flex flex-col items-center justify-center p-2 relative rounded">
<div className="grid grid-cols-5 gap-1.5 w-full h-full opacity-90">
{Array.from({ length: 25 }).map((_, i) => (
<div
key={i}
className={`rounded-sm ${(i * 7 + 11) % 3 === 0 || (i % 6 === 0) || (i < 6 && i !== 2) ? 'bg-black' : 'bg-transparent'}`}
/>
))}
</div>
<div className="absolute inset-0 flex items-center justify-center">
<div className="bg-white p-1 rounded-md border border-zinc-200 shadow-sm">
<div className="bg-[#E50914] text-white p-1 rounded">
<QrCode className="w-5 h-5" />
</div>
</div>
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-xs text-zinc-400 max-w-sm mx-auto">
Escaneie o QR Code acima com o app do seu banco ou copie a chave Pix Copia e Cola abaixo.
</p>
<div className="flex items-center space-x-2 bg-zinc-900 rounded-lg p-2.5 border border-white/5">
<input
type="text"
readOnly
value="00020126580014br.gov.bcb.pix0136..."
className="bg-transparent text-zinc-300 text-xs font-mono select-all focus:outline-none flex-grow"
/>
<button
type="button"
onClick={handleCopyPix}
className="bg-zinc-800 text-xs text-white hover:bg-zinc-700 px-3 py-1.5 rounded-md font-bold transition-all cursor-pointer flex items-center space-x-1"
>
{pixCopied ? <Check className="w-3.5 h-3.5 text-emerald-500" /> : null}
<span>{pixCopied ? 'Copiado!' : 'Copiar'}</span>
</button>
</div>
</div>
</div>
)}
{paymentMethod === 'CREDIT_CARD' && (
<div className="space-y-3.5">
<div className="space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Número do Cartão</label>
<input
type="text"
required
value={ccNumber}
onChange={(e) => setCcNumber(e.target.value.replace(/\D/g, '').replace(/(\d{4})/g, '$1 ').trim().slice(0, 19))}
placeholder="0000 0000 0000 0000"
className="w-full glass-input rounded-lg p-3 text-xs text-white focus:outline-none"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Nome Impresso no Cartão</label>
<input
type="text"
required
value={ccName}
onChange={(e) => setCcName(e.target.value.toUpperCase())}
placeholder="NOME IGUAL DO CARTÃO"
className="w-full glass-input rounded-lg p-3 text-xs text-white focus:outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Validade</label>
<input
type="text"
required
value={ccExpiry}
onChange={(e) => setCcExpiry(e.target.value.replace(/\D/g, '').replace(/(\d{2})/g, '$1/').slice(0, 5))}
placeholder="MM/AA"
className="w-full glass-input rounded-lg p-3 text-xs text-white focus:outline-none"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">CVV</label>
<input
type="password"
required
value={ccCvv}
onChange={(e) => setCcCvv(e.target.value.replace(/\D/g, '').slice(0, 4))}
placeholder="123"
className="w-full glass-input rounded-lg p-3 text-xs text-white focus:outline-none"
/>
</div>
</div>
</div>
)}
{paymentMethod === 'BOLETO' && (
<div className="space-y-4 text-center py-4 bg-zinc-900/40 rounded-xl border border-white/5">
<div className="bg-[#E50914]/10 text-[#E50914] p-3 rounded-full inline-block">
<FileText className="w-8 h-8" />
</div>
<div className="space-y-2">
<p className="text-sm font-bold text-white">Boleto Bancário</p>
<p className="text-xs text-zinc-400 max-w-sm mx-auto">
O boleto será gerado no valor de R$ {coursePrice.toFixed(2)}. O prazo de compensação é de 1 a 2 dias úteis.
</p>
<div className="pt-2">
<button
type="button"
onClick={handleCopyBoleto}
className="glass-btn-secondary text-xs text-white font-semibold py-2 px-4 rounded-lg inline-flex items-center space-x-2"
>
{boletoCopied ? <Check className="w-3.5 h-3.5 text-emerald-500" /> : null}
<span>{boletoCopied ? 'Código do Boleto Copiado!' : 'Copiar Linha Digitável'}</span>
</button>
</div>
</div>
</div>
)}
{/* Submit button */}
<div className="pt-4">
<button
type="submit"
disabled={isPaying}
className="w-full glass-btn-primary text-white text-xs font-bold py-3 px-4 rounded-lg flex items-center justify-center space-x-2 cursor-pointer disabled:opacity-50"
>
{isPaying ? (
<>
<Loader2 className="w-4 h-4 animate-spin text-white" />
<span>Processando Pagamento Asaas...</span>
</>
) : (
<>
<Sparkles className="w-4 h-4 text-yellow-400" />
<span>Confirmar Pagamento (Simulação)</span>
</>
)}
</button>
</div>
</form>
</div>
<div className="text-center text-[10px] text-zinc-500">
Transação 100% segura processada via ambiente de testes do gateway oficial **Asaas**.
</div>
</div>
</>
) : (
/* Success Step */
<div className="w-full p-8 md:p-12 text-center flex flex-col items-center justify-center space-y-6">
<div className="bg-emerald-500 text-white p-4 rounded-full shadow-[0_0_25px_rgba(16,185,129,0.5)] border border-emerald-400 animate-bounce">
<Check className="w-10 h-10 stroke-[3]" />
</div>
<div className="space-y-2 max-w-md">
<h3 className="font-display font-black text-2xl text-white">
Pagamento Confirmado!
</h3>
<p className="text-zinc-400 text-xs leading-relaxed">
Parabéns! O curso **{course.title}** foi desbloqueado com sucesso na sua conta **MicrotecFlix**. O acesso a todas as aulas, módulos e anotações agora é vitalício para você!
</p>
</div>
<button
onClick={onSuccess}
className="glass-btn-primary text-white text-xs font-bold py-3 px-8 rounded-lg flex items-center justify-center space-x-2 shadow-lg transition-transform hover:scale-105 cursor-pointer"
>
<Play className="w-4 h-4 fill-white" />
<span>Começar a Estudar Agora</span>
</button>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,379 @@
import React, { useState, useEffect } from 'react';
import { ArrowLeft, BookOpen, ChevronRight, FileText, CheckCircle2, Circle, MessageSquare, AlertCircle } from 'lucide-react';
import VideoPlayer from './VideoPlayer';
interface LessonProgress {
watchedPercentage: number;
completed: boolean;
}
interface Lesson {
id: string;
moduleId: string;
title: string;
videoUrl: string;
videoType: 'youtube' | 'direct';
order: number;
content: string;
progress: LessonProgress;
note: string;
}
interface Module {
id: string;
courseId: string;
title: string;
order: number;
lessons: Lesson[];
}
interface Course {
id: string;
title: string;
description: string;
thumbnail: string;
category: string;
}
interface CourseViewProps {
courseId: string;
token: string | null;
onBack: () => void;
}
export default function CourseView({ courseId, token, onBack }: CourseViewProps) {
const [course, setCourse] = useState<Course | null>(null);
const [modules, setModules] = useState<Module[]>([]);
const [activeLesson, setActiveLesson] = useState<Lesson | null>(null);
const [noteContent, setNoteContent] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
useEffect(() => {
fetchCourseDetails();
}, [courseId]);
const fetchCourseDetails = async () => {
setIsLoading(true);
setError(null);
try {
const res = await fetch(`/api/courses/${courseId}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!res.ok) {
throw new Error('Falha ao carregar detalhes do curso');
}
const data = await res.json();
setCourse(data.course);
setModules(data.modules);
// Default to the first lesson of the first module
if (data.modules && data.modules.length > 0) {
const firstMod = data.modules[0];
if (firstMod.lessons && firstMod.lessons.length > 0) {
const firstLsn = firstMod.lessons[0];
setActiveLesson(firstLsn);
setNoteContent(firstLsn.note || '');
}
}
} catch (err: any) {
setError(err.message || 'Erro inesperado.');
} finally {
setIsLoading(false);
}
};
const handleSelectLesson = (lesson: Lesson) => {
setActiveLesson(lesson);
setNoteContent(lesson.note || '');
setSaveStatus('idle');
};
const handleProgressUpdate = async (watchedPercentage: number) => {
if (!activeLesson) return;
try {
const res = await fetch(`/api/lessons/${activeLesson.id}/progress`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ watchedPercentage })
});
if (res.ok) {
const data = await res.json();
// Update local state for lesson progress
setModules(prev => prev.map(mod => {
return {
...mod,
lessons: mod.lessons.map(lsn => {
if (lsn.id === activeLesson.id) {
return {
...lsn,
progress: {
watchedPercentage: Math.max(lsn.progress.watchedPercentage, watchedPercentage),
completed: data.completed || lsn.progress.completed
}
};
}
return lsn;
})
};
}));
// Also update the active lesson progress locally
setActiveLesson(prev => {
if (!prev) return null;
return {
...prev,
progress: {
watchedPercentage: Math.max(prev.progress.watchedPercentage, watchedPercentage),
completed: data.completed || prev.progress.completed
}
};
});
}
} catch (err) {
console.error('Error saving progress:', err);
}
};
const handleSaveNote = async () => {
if (!activeLesson) return;
setSaveStatus('saving');
try {
const res = await fetch(`/api/lessons/${activeLesson.id}/notes`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ content: noteContent })
});
if (res.ok) {
setSaveStatus('saved');
// Update local modules array so state matches
setModules(prev => prev.map(mod => {
return {
...mod,
lessons: mod.lessons.map(lsn => {
if (lsn.id === activeLesson.id) {
return { ...lsn, note: noteContent };
}
return lsn;
})
};
}));
setTimeout(() => setSaveStatus('idle'), 3000);
} else {
setSaveStatus('error');
}
} catch (err) {
setSaveStatus('error');
}
};
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-zinc-400 space-y-4">
<div className="w-12 h-12 border-4 border-[#E50914] border-t-transparent rounded-full animate-spin" />
<p className="text-sm font-semibold tracking-wide">Preparando seu ambiente de estudos...</p>
</div>
);
}
if (error || !course) {
return (
<div className="flex flex-col items-center justify-center min-h-[50vh] text-center max-w-md mx-auto p-4 space-y-4">
<AlertCircle className="w-12 h-12 text-[#E50914]" />
<h2 className="text-xl font-bold text-white">Oops! Algo deu errado</h2>
<p className="text-sm text-zinc-400">{error || 'Não foi possível carregar as informações deste curso.'}</p>
<button
onClick={onBack}
className="bg-zinc-800 hover:bg-zinc-700 text-white font-bold px-6 py-2 rounded-lg transition-colors flex items-center space-x-2"
>
<ArrowLeft className="w-4 h-4" />
<span>Voltar para cursos</span>
</button>
</div>
);
}
return (
<div className="space-y-6">
{/* Back Header */}
<div className="flex items-center justify-between pb-4 border-b border-white/10">
<button
onClick={onBack}
className="group flex items-center space-x-2 text-zinc-400 hover:text-white transition-colors font-semibold text-sm cursor-pointer"
>
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" />
<span>Voltar ao Catálogo</span>
</button>
<span className="text-zinc-500 text-xs font-bold uppercase tracking-widest">{course.category}</span>
</div>
{/* Main Grid: Player on left, lessons sidebar on right */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left Hand: Video & Notes */}
<div className="lg:col-span-2 space-y-6">
{activeLesson ? (
<div className="space-y-4">
<h2 className="text-xl md:text-2xl font-sans font-extrabold text-white tracking-tight">
{activeLesson.title}
</h2>
<VideoPlayer
videoUrl={activeLesson.videoUrl}
videoType={activeLesson.videoType}
lessonId={activeLesson.id}
onProgressUpdate={handleProgressUpdate}
initialProgress={activeLesson.progress?.watchedPercentage || 0}
/>
{/* Lesson Description Content */}
{activeLesson.content && (
<div className="glass-card p-5 rounded-xl space-y-2">
<h3 className="text-xs font-bold text-zinc-400 uppercase tracking-widest flex items-center space-x-1.5">
<FileText className="w-3.5 h-3.5 text-[#E50914]" />
<span>Recursos & Descrição da Aula</span>
</h3>
<p className="text-zinc-300 text-sm leading-relaxed whitespace-pre-line">
{activeLesson.content}
</p>
</div>
)}
{/* Notes & Doubts panel */}
<div className="glass-card p-5 rounded-xl space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-xs font-bold text-zinc-400 uppercase tracking-widest flex items-center space-x-1.5">
<MessageSquare className="w-3.5 h-3.5 text-[#E50914]" />
<span>Suas Anotações e Dúvidas</span>
</h3>
{saveStatus === 'saving' && <span className="text-[10px] text-zinc-500">Salvando...</span>}
{saveStatus === 'saved' && <span className="text-[10px] text-emerald-500 font-bold">Salvo com sucesso!</span>}
{saveStatus === 'error' && <span className="text-[10px] text-red-500 font-bold">Erro ao salvar.</span>}
</div>
<textarea
value={noteContent}
onChange={(e) => setNoteContent(e.target.value)}
placeholder="Escreva aqui suas anotações pessoais, dúvidas ou resumos sobre esta aula... Seus apontamentos são salvos automaticamente."
className="w-full h-32 glass-input rounded-lg p-3 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none resize-none"
/>
<div className="flex justify-end">
<button
onClick={handleSaveNote}
disabled={saveStatus === 'saving'}
className="glass-btn-secondary text-white font-bold text-xs px-4 py-2 rounded-lg cursor-pointer"
>
Salvar Anotações
</button>
</div>
</div>
</div>
) : (
<div className="h-[40vh] glass-card rounded-xl flex flex-col items-center justify-center text-zinc-500 text-center p-6">
<BookOpen className="w-12 h-12 text-zinc-700 mb-2" />
<p className="text-sm font-semibold text-zinc-400">Nenhuma aula cadastrada neste curso ainda.</p>
<p className="text-xs text-zinc-600 mt-1">O administrador adicionará conteúdo em breve!</p>
</div>
)}
</div>
{/* Right Hand: Interactive Modules Accordion */}
<div className="space-y-4">
<div className="glass-card p-4 rounded-xl space-y-1">
<h3 className="font-sans font-extrabold text-sm text-white tracking-tight">Conteúdo do Curso</h3>
<p className="text-xs text-zinc-500 font-medium">{course.title}</p>
</div>
<div className="space-y-3">
{modules.map((mod, modIdx) => (
<div
key={mod.id}
className="glass-card rounded-xl overflow-hidden"
>
{/* Module Title bar */}
<div className="glass-accordion-header px-4 py-3 flex items-center justify-between">
<div className="flex items-center space-x-2">
<span className="text-[#E50914] text-xs font-black">M{modIdx + 1}</span>
<h4 className="font-sans font-bold text-xs text-zinc-200 tracking-tight leading-snug line-clamp-1">
{mod.title}
</h4>
</div>
<span className="text-[10px] text-zinc-500 font-bold whitespace-nowrap">
{mod.lessons.length} {mod.lessons.length === 1 ? 'aula' : 'aulas'}
</span>
</div>
{/* Lessons list inside module */}
<div className="divide-y divide-white/5">
{mod.lessons.length === 0 ? (
<div className="p-3 text-xs text-zinc-600 text-center italic">Nenhuma aula neste módulo.</div>
) : (
mod.lessons.map(lesson => {
const isActive = activeLesson?.id === lesson.id;
const isLsnCompleted = lesson.progress?.completed;
return (
<button
key={lesson.id}
onClick={() => handleSelectLesson(lesson)}
className={`w-full px-4 py-3 text-left flex items-start space-x-3 transition-colors ${
isActive
? 'bg-[#E50914]/10 text-white'
: 'hover:bg-zinc-800/40 text-zinc-400 hover:text-zinc-200'
}`}
>
<div className="mt-0.5 flex-shrink-0">
{isLsnCompleted ? (
<CheckCircle2 className="w-4 h-4 text-emerald-500 fill-emerald-500/10" />
) : (
<Circle className="w-4 h-4 text-zinc-600" />
)}
</div>
<div className="space-y-0.5">
<span className={`font-medium text-xs leading-snug line-clamp-2 ${isActive ? 'text-[#E50914] font-bold' : ''}`}>
{lesson.title}
</span>
{lesson.progress?.watchedPercentage > 0 && !isLsnCompleted && (
<div className="flex items-center space-x-1.5 pt-1">
<div className="w-16 bg-zinc-800 h-1 rounded-full overflow-hidden">
<div
className="bg-amber-500 h-full"
style={{ width: `${lesson.progress.watchedPercentage}%` }}
/>
</div>
<span className="text-[9px] text-zinc-500 font-bold">{lesson.progress.watchedPercentage}%</span>
</div>
)}
</div>
</button>
);
})
)}
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,104 @@
import React from 'react';
import { Play, Info, AlertTriangle } from 'lucide-react';
import { Course } from '../types';
interface HeroBannerProps {
course: (Course & { progress?: number; totalLessons?: number }) | null;
onPlay: (courseId: string) => void;
subscriptionStatus: string;
setView: (view: string) => void;
}
export default function HeroBanner({ course, onPlay, subscriptionStatus, setView }: HeroBannerProps) {
if (!course) {
return (
<div className="h-[30vh] md:h-[45vh] w-full bg-zinc-900 flex items-center justify-center text-zinc-500 rounded-lg border border-zinc-800">
Nenhum curso em destaque disponível.
</div>
);
}
const isBlocked = subscriptionStatus === 'OVERDUE' || subscriptionStatus === 'CANCELED';
return (
<div className="relative w-full h-[55vh] md:h-[65vh] flex items-end overflow-hidden rounded-2xl border border-white/10 shadow-[0_12px_40px_rgba(0,0,0,0.5)]">
{/* Background Image / Gradient */}
<div className="absolute inset-0 z-0">
<img
src={course.thumbnail}
alt={course.title}
className="w-full h-full object-cover brightness-[0.40] scale-105 transition-all duration-700"
referrerPolicy="no-referrer"
/>
{/* Gradients */}
<div className="absolute inset-0 bg-gradient-to-t from-[#141414] via-transparent to-transparent" />
<div className="absolute inset-0 bg-gradient-to-r from-[#141414]/90 via-[#141414]/30 to-transparent" />
</div>
{/* Content Container */}
<div className="relative z-10 p-6 md:p-12 max-w-2xl space-y-4">
{/* Category Tag */}
<span className="glass-badge bg-[#E50914]/85 text-white px-3 py-1 rounded-md text-xs font-bold uppercase tracking-wider">
{course.category}
</span>
{/* Title */}
<h1 className="text-3xl md:text-5xl font-display font-black tracking-tight text-white leading-tight">
{course.title}
</h1>
{/* Description */}
<p className="text-sm md:text-base text-zinc-300 font-normal leading-relaxed line-clamp-3">
{course.description}
</p>
{/* Progress Bar (If started) */}
{course.progress !== undefined && course.progress > 0 && (
<div className="space-y-1">
<div className="flex justify-between items-center text-xs text-zinc-400 font-semibold">
<span>Seu Progresso</span>
<span>{course.progress}% concluído</span>
</div>
<div className="w-full bg-white/10 backdrop-blur-sm h-2 rounded-full overflow-hidden">
<div
className="bg-[#E50914] h-full rounded-full transition-all duration-500 shadow-[0_0_10px_rgba(229,9,20,0.5)]"
style={{ width: `${course.progress}%` }}
/>
</div>
</div>
)}
{/* Buttons */}
<div className="flex flex-wrap items-center gap-3 pt-2">
{isBlocked ? (
<button
onClick={() => setView('subscription')}
className="glass-btn-secondary border border-red-500/30 hover:bg-red-950/20 text-red-400 font-bold px-6 py-2.5 rounded-lg flex items-center space-x-2 transition-all cursor-pointer"
>
<AlertTriangle className="w-5 h-5 fill-red-500/10 text-red-500" />
<span>Assinatura Suspensa - Regularizar</span>
</button>
) : (
<button
onClick={() => onPlay(course.id)}
className="bg-white hover:bg-zinc-200 text-black font-bold px-6 py-2.5 rounded-lg flex items-center space-x-2 transition-all shadow-lg hover:scale-[1.02] cursor-pointer"
>
<Play className="w-5 h-5 fill-black text-black" />
<span>
{course.progress && course.progress > 0 ? 'Continuar Assistindo' : 'Iniciar Treinamento'}
</span>
</button>
)}
<button
onClick={() => onPlay(course.id)}
className="glass-btn-secondary text-white font-medium px-5 py-2.5 rounded-lg flex items-center space-x-2 cursor-pointer"
>
<Info className="w-5 h-5 text-zinc-300" />
<span>Ver Detalhes</span>
</button>
</div>
</div>
</div>
);
}

109
src/components/Navbar.tsx Normal file
View File

@ -0,0 +1,109 @@
import React from 'react';
import { Play, LogOut, Shield, User, GraduationCap, DollarSign } from 'lucide-react';
import { SubscriptionStatus } from '../types';
interface NavbarProps {
user: {
name: string;
email: string;
role: 'admin' | 'student';
subscriptionStatus: SubscriptionStatus;
} | null;
activeView: string;
setView: (view: string) => void;
onLogout: () => void;
}
export default function Navbar({ user, activeView, setView, onLogout }: NavbarProps) {
if (!user) return null;
const getStatusColor = (status: SubscriptionStatus) => {
switch (status) {
case 'ACTIVE': return 'bg-emerald-500 text-emerald-950';
case 'TRIAL': return 'bg-amber-500 text-amber-950';
case 'OVERDUE': return 'bg-red-500 text-white';
case 'CANCELED': return 'bg-zinc-600 text-zinc-200';
default: return 'bg-zinc-500 text-white';
}
};
const getStatusLabel = (status: SubscriptionStatus) => {
switch (status) {
case 'ACTIVE': return 'Assinatura Ativa';
case 'TRIAL': return 'Período de Teste';
case 'OVERDUE': return 'Pagamento Pendente';
case 'CANCELED': return 'Assinatura Cancelada';
default: return status;
}
};
return (
<nav className="sticky top-0 z-50 glass-navbar px-4 py-3 md:px-8 flex items-center justify-between">
<div className="flex items-center space-x-8">
{/* LOGO */}
<div
onClick={() => setView('home')}
className="flex items-center space-x-2 cursor-pointer select-none group"
>
<div className="bg-[#E50914] text-white p-1.5 rounded flex items-center justify-center group-hover:scale-105 transition-transform">
<Play className="fill-white w-5 h-5" />
</div>
<span className="font-sans font-black text-xl md:text-2xl tracking-tighter text-[#E50914]">
MICROTEC<span className="text-white">FLIX</span>
</span>
</div>
{/* NAVIGATION LINKS */}
<div className="hidden md:flex items-center space-x-6 text-sm">
<button
onClick={() => setView('home')}
className={`font-medium transition-colors hover:text-[#E50914] ${activeView === 'home' || activeView === 'course-view' ? 'text-white' : 'text-zinc-400'}`}
>
Cursos
</button>
<button
onClick={() => setView('subscription')}
className={`font-medium transition-colors hover:text-[#E50914] ${activeView === 'subscription' ? 'text-white' : 'text-zinc-400'}`}
>
Minha Assinatura
</button>
</div>
</div>
{/* USER ACTIONS & STATUS */}
<div className="flex items-center space-x-4">
{/* Subscription Status Tag */}
<span
onClick={() => setView('subscription')}
className={`hidden sm:inline-block px-2.5 py-0.5 rounded-full text-xs font-bold tracking-wide uppercase cursor-pointer hover:opacity-90 transition-opacity ${getStatusColor(user.subscriptionStatus)}`}
>
{getStatusLabel(user.subscriptionStatus)}
</span>
{/* User Info & Logout */}
<div className="flex items-center space-x-3 border-l border-zinc-800 pl-4">
<div className="text-right hidden lg:block">
<p className="text-xs font-medium text-white max-w-[120px] truncate">{user.name}</p>
<p className="text-[10px] text-zinc-500 uppercase font-semibold">{user.role === 'admin' ? 'Administrador' : 'Aluno'}</p>
</div>
<button
onClick={() => setView('subscription')}
title="Minha Conta"
className="w-8 h-8 rounded-full bg-zinc-800 flex items-center justify-center text-zinc-300 hover:bg-zinc-700 transition-colors"
>
<User className="w-4 h-4" />
</button>
<button
onClick={onLogout}
title="Sair da Conta"
className="w-8 h-8 rounded-full bg-zinc-800/50 flex items-center justify-center text-zinc-400 hover:bg-red-950 hover:text-red-400 transition-all"
>
<LogOut className="w-4 h-4" />
</button>
</div>
</div>
</nav>
);
}

View File

@ -0,0 +1,432 @@
import React, { useState, useEffect } from 'react';
import {
CreditCard, CheckCircle, RefreshCw, AlertTriangle, Play, Calendar, DollarSign,
ShieldCheck, ArrowRight, ExternalLink, QrCode
} from 'lucide-react';
import { SubscriptionStatus, SubscriptionPayment } from '../types';
interface SubscriptionViewProps {
user: {
name: string;
email: string;
subscriptionStatus: SubscriptionStatus;
} | null;
token: string | null;
onStatusUpdate: (newStatus: SubscriptionStatus) => void;
}
export default function SubscriptionView({ user, token, onStatusUpdate }: SubscriptionViewProps) {
const [payments, setPayments] = useState<SubscriptionPayment[]>([]);
const [subStatus, setSubStatus] = useState<SubscriptionStatus | null>(user ? user.subscriptionStatus : null);
const [isLoading, setIsLoading] = useState(true);
// Checkout flow state
const [billingType, setBillingType] = useState<'PIX' | 'CREDIT_CARD' | 'BOLETO'>('PIX');
const [cardName, setCardName] = useState('');
const [cardNumber, setCardNumber] = useState('');
const [cardExpiry, setCardExpiry] = useState('');
const [cardCvv, setCardCvv] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [successPayload, setSuccessPayload] = useState<any>(null);
const [checkoutError, setCheckoutError] = useState<string | null>(null);
useEffect(() => {
fetchSubscriptionDetails();
}, []);
const fetchSubscriptionDetails = async () => {
setIsLoading(true);
try {
const res = await fetch('/api/subscription/status', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (res.ok) {
const data = await res.json();
setSubStatus(data.status);
setPayments(data.payments);
onStatusUpdate(data.status);
}
} catch (err) {
console.error('Error fetching subscription status:', err);
} finally {
setIsLoading(false);
}
};
const handleCheckout = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setCheckoutError(null);
setSuccessPayload(null);
const creditCardInfo = billingType === 'CREDIT_CARD' ? {
creditCard: {
holderName: cardName,
number: cardNumber.replace(/\s+/g, ''),
expiryMonth: cardExpiry.split('/')[0]?.trim(),
expiryYear: '20' + cardExpiry.split('/')[1]?.trim(),
ccv: cardCvv
},
creditCardHolderInfo: {
name: cardName,
email: user?.email,
cpfCnpj: '000.000.000-00',
postalCode: '01001-000',
addressNumber: '123',
phone: '11999999999'
}
} : undefined;
try {
const res = await fetch('/api/subscription/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
billingType,
creditCardInfo
})
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Erro ao processar assinatura');
}
setSuccessPayload(data);
// Wait a moment and fetch updated profile status
await fetchSubscriptionDetails();
} catch (err: any) {
setCheckoutError(err.message || 'Erro de rede. Tente novamente.');
} finally {
setIsSubmitting(false);
}
};
const getStatusColor = (status: SubscriptionStatus) => {
switch (status) {
case 'ACTIVE': return 'text-emerald-500 bg-emerald-500/10 border-emerald-500/20';
case 'TRIAL': return 'text-amber-500 bg-amber-500/10 border-amber-500/20';
case 'OVERDUE': return 'text-red-500 bg-red-500/10 border-red-500/20';
case 'CANCELED': return 'text-zinc-400 bg-zinc-800 border-zinc-700';
default: return 'text-zinc-500 bg-zinc-800 border-zinc-700';
}
};
const getStatusLabel = (status: SubscriptionStatus) => {
switch (status) {
case 'ACTIVE': return 'Assinatura Ativa';
case 'TRIAL': return 'Acesso de Avaliação (Trial)';
case 'OVERDUE': return 'Fatura Vencida / Inadimplente';
case 'CANCELED': return 'Assinatura Cancelada';
default: return status;
}
};
const getPaymentStatusTag = (status: string) => {
switch (status) {
case 'CONFIRMED':
case 'RECEIVED':
return <span className="bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 px-2 py-0.5 rounded text-[10px] font-bold"> PAGO</span>;
case 'PENDING':
return <span className="bg-amber-500/10 text-amber-500 border border-amber-500/20 px-2 py-0.5 rounded text-[10px] font-bold"> AGUARDANDO</span>;
case 'OVERDUE':
return <span className="bg-red-500/10 text-red-500 border border-red-500/20 px-2 py-0.5 rounded text-[10px] font-bold"> VENCIDO</span>;
default:
return <span className="bg-zinc-800 text-zinc-400 px-2 py-0.5 rounded text-[10px]">{status}</span>;
}
};
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center min-h-[50vh] text-zinc-400 space-y-4">
<RefreshCw className="w-8 h-8 text-[#E50914] animate-spin" />
<p className="text-sm font-semibold">Buscando faturas e status financeiro...</p>
</div>
);
}
return (
<div className="space-y-8 max-w-4xl mx-auto">
{/* Top Title */}
<div className="border-b border-white/10 pb-4">
<h1 className="text-2xl md:text-3xl font-display font-extrabold text-white tracking-tight flex items-center space-x-2">
<CreditCard className="w-8 h-8 text-[#E50914]" />
<span>Gestão de Assinatura & Cobranças</span>
</h1>
<p className="text-xs text-zinc-500 mt-1">Veja seus comprovantes, históricos de faturamento e mude seu plano de estudos.</p>
</div>
{/* Main Grid: Status summary left, checkout right */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Left Hand: Current Plan details & invoice history */}
<div className="space-y-6">
<div className="glass-card p-6 rounded-2xl space-y-5">
<h3 className="font-display font-bold text-sm text-zinc-400 uppercase tracking-wider">Sua Conta</h3>
<div className="flex items-center space-x-4">
<div className="w-12 h-12 rounded-full glass-badge flex items-center justify-center text-zinc-300">
<ShieldCheck className="w-6 h-6 text-[#E50914]" />
</div>
<div className="space-y-0.5">
<p className="text-sm font-bold text-white">{user?.name}</p>
<p className="text-xs text-zinc-500">{user?.email}</p>
</div>
</div>
<div className="border-t border-zinc-850 pt-4 space-y-2">
<p className="text-[10px] text-zinc-500 font-bold uppercase tracking-wider">Status Atual do Acesso</p>
{subStatus && (
<div className={`inline-block px-3 py-1.5 rounded-lg border text-sm font-extrabold ${getStatusColor(subStatus)}`}>
{getStatusLabel(subStatus)}
</div>
)}
{subStatus === 'TRIAL' && (
<p className="text-xs text-zinc-400 font-medium pt-1">
Seu período de teste está ativo. Para continuar com acesso ilimitado a todos os cursos, módulos e certificados, ative sua assinatura mensal abaixo.
</p>
)}
{subStatus === 'ACTIVE' && (
<p className="text-xs text-emerald-500/80 font-bold flex items-center space-x-1 pt-1">
<CheckCircle className="w-4 h-4 fill-emerald-500/10" />
<span>Acesso integral liberado a todos os cursos de informática, hardware e servidores!</span>
</p>
)}
{subStatus === 'OVERDUE' && (
<p className="text-xs text-red-500/80 font-semibold flex items-center space-x-1 pt-1">
<AlertTriangle className="w-4 h-4 fill-red-500/10" />
<span>Seu acesso foi bloqueado por falta de pagamento. Regularize via Pix ou Cartão para liberar.</span>
</p>
)}
</div>
</div>
{/* Past Payments History */}
<div className="glass-card p-6 rounded-2xl space-y-4">
<h3 className="font-display font-bold text-sm text-zinc-400 uppercase tracking-wider">Faturas e Segunda Via</h3>
<div className="space-y-3">
{payments.length === 0 ? (
<p className="text-xs text-zinc-500 italic">Nenhum pagamento registrado nesta conta.</p>
) : (
payments.map(pay => (
<div
key={pay.id}
className="p-3.5 glass-badge rounded-xl flex items-center justify-between"
>
<div className="space-y-1">
<div className="flex items-center space-x-2">
<span className="text-white text-xs font-bold">R$ {pay.value.toFixed(2)}</span>
{getPaymentStatusTag(pay.status)}
</div>
<p className="text-[10px] text-zinc-500 flex items-center space-x-1 font-medium">
<Calendar className="w-3 h-3" />
<span>Vence em: {new Date(pay.dueDate).toLocaleDateString('pt-BR')}</span>
</p>
</div>
<a
href={pay.invoiceUrl}
target="_blank"
rel="noreferrer"
className="text-xs text-zinc-400 hover:text-[#E50914] glass-btn-secondary px-3 py-1.5 rounded-lg font-bold transition-all flex items-center space-x-1"
>
<span>Boleto / Pix / Detalhes</span>
<ExternalLink className="w-3 h-3" />
</a>
</div>
))
)}
</div>
</div>
</div>
{/* Right Hand: Checkout Form (Create Subscription) */}
<div className="space-y-6">
<div className="glass-card p-6 rounded-2xl space-y-6">
<div className="space-y-1">
<h3 className="font-display font-extrabold text-lg text-white tracking-tight">Assine o Plano Ilimitado</h3>
<p className="text-xs text-zinc-500 font-semibold flex items-center space-x-1">
<span>Trilha completa DevFlix por apenas</span>
<span className="text-[#E50914] font-black text-sm">R$ 49,90/mês</span>
</p>
</div>
{/* Billing Types Toggle */}
<div className="grid grid-cols-3 gap-2 p-1 glass-badge rounded-lg">
<button
type="button"
onClick={() => { setBillingType('PIX'); setCheckoutError(null); }}
className={`py-2 text-xs font-bold rounded-md transition-all cursor-pointer ${billingType === 'PIX' ? 'bg-[#E50914] text-white' : 'text-zinc-400 hover:text-zinc-200'}`}
>
PIX
</button>
<button
type="button"
onClick={() => { setBillingType('CREDIT_CARD'); setCheckoutError(null); }}
className={`py-2 text-xs font-bold rounded-md transition-all cursor-pointer ${billingType === 'CREDIT_CARD' ? 'bg-[#E50914] text-white' : 'text-zinc-400 hover:text-zinc-200'}`}
>
Cartão de Crédito
</button>
<button
type="button"
onClick={() => { setBillingType('BOLETO'); setCheckoutError(null); }}
className={`py-2 text-xs font-bold rounded-md transition-all cursor-pointer ${billingType === 'BOLETO' ? 'bg-[#E50914] text-white' : 'text-zinc-400 hover:text-zinc-200'}`}
>
Boleto
</button>
</div>
{/* Error alerts */}
{checkoutError && (
<div className="p-3 bg-red-950/40 border border-red-900/50 rounded-lg text-xs text-red-400">
{checkoutError}
</div>
)}
{/* SUCCESS PAYLOAD (PIX QR CODE OR CREDIT CARD CLEAR) */}
{successPayload ? (
<div className="bg-zinc-900/60 border border-emerald-950 p-5 rounded-xl space-y-4 text-center">
<div className="w-10 h-10 bg-emerald-500/10 text-emerald-500 rounded-full flex items-center justify-center mx-auto">
<CheckCircle className="w-6 h-6 fill-emerald-500/10" />
</div>
<div className="space-y-1">
<h4 className="text-sm font-bold text-white">Inscrição criada com sucesso!</h4>
<p className="text-xs text-zinc-400">
Sua assinatura está sendo faturada e integrada com o Asaas.
</p>
</div>
{billingType === 'PIX' && (
<div className="space-y-3 pt-2">
<div className="bg-white p-3 rounded-xl inline-block">
{/* Fake QR representation */}
<QrCode className="w-32 h-32 text-black" />
</div>
<div className="space-y-2">
<p className="text-[10px] text-zinc-500 uppercase font-black">Copia e Cola Pix</p>
<input
readOnly
value="00020101021126580014br.gov.bcb.pix0136devflix-asaas-4990-billing-rec-key"
className="w-full glass-input text-[10px] font-mono text-zinc-400 p-2 rounded text-center focus:outline-none"
/>
</div>
</div>
)}
<div className="pt-2">
<a
href={successPayload.payment?.invoiceUrl}
target="_blank"
rel="noreferrer"
className="w-full bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-bold py-2.5 px-4 rounded-lg transition-colors flex items-center justify-center space-x-1.5"
>
<span>Abrir Fatura Oficial (Asaas)</span>
<ExternalLink className="w-4 h-4" />
</a>
</div>
</div>
) : (
/* ACTIVE CHECKOUT FORM */
<form onSubmit={handleCheckout} className="space-y-4">
{billingType === 'CREDIT_CARD' ? (
<div className="space-y-4">
<div className="space-y-1">
<label className="text-[10px] text-zinc-500 font-bold uppercase">Nome impresso no Cartão</label>
<input
type="text"
required
value={cardName}
onChange={(e) => setCardName(e.target.value)}
placeholder="Ex: SIDNEY GOMES"
className="w-full glass-input rounded-lg p-2.5 text-xs text-white focus:outline-none"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] text-zinc-500 font-bold uppercase">Número do Cartão de Crédito</label>
<input
type="text"
required
maxLength={19}
value={cardNumber}
onChange={(e) => setCardNumber(e.target.value.replace(/\s?/g, '').replace(/(\d{4})/g, '$1 ').trim())}
placeholder="4551 1234 5678 9012"
className="w-full glass-input rounded-lg p-2.5 text-xs text-white focus:outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-[10px] text-zinc-500 font-bold uppercase">Vencimento (MM/AA)</label>
<input
type="text"
required
maxLength={5}
placeholder="12/29"
value={cardExpiry}
onChange={(e) => setCardExpiry(e.target.value)}
className="w-full glass-input rounded-lg p-2.5 text-xs text-white focus:outline-none"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] text-zinc-500 font-bold uppercase">CVV</label>
<input
type="password"
required
maxLength={4}
placeholder="123"
value={cardCvv}
onChange={(e) => setCardCvv(e.target.value)}
className="w-full glass-input rounded-lg p-2.5 text-xs text-white focus:outline-none"
/>
</div>
</div>
</div>
) : (
/* PIX or BOLETO Info */
<div className="p-4 glass-badge rounded-xl text-xs text-zinc-400 space-y-2 leading-relaxed">
<p className="font-semibold text-white">Informações da Assinatura:</p>
<p>
- Faturamento recorrente mensal (R$ 49,90) via {billingType}.
</p>
<p>
- O link para pagamento, Pix Copia e Cola e código de barras serão gerados dinamicamente de forma integrada com a API oficial do Asaas.
</p>
</div>
)}
<button
type="submit"
disabled={isSubmitting}
className="w-full glass-btn-primary text-white text-xs font-bold py-3 px-4 rounded-lg flex items-center justify-center space-x-1.5 cursor-pointer disabled:opacity-50"
>
{isSubmitting ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
<span>Registrando no Asaas...</span>
</>
) : (
<>
<span>Confirmar Assinatura - R$ 49,90/mês</span>
<ArrowRight className="w-4 h-4" />
</>
)}
</button>
</form>
)}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,241 @@
import React, { useRef, useState, useEffect } from 'react';
import { Play, Pause, RotateCcw, Volume2, Maximize, CheckCircle, ExternalLink } from 'lucide-react';
interface VideoPlayerProps {
videoUrl: string;
videoType: 'youtube' | 'direct';
lessonId: string;
onProgressUpdate: (percentage: number) => void;
initialProgress?: number;
}
export default function VideoPlayer({ videoUrl, videoType, lessonId, onProgressUpdate, initialProgress = 0 }: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
const [isCompleted, setIsCompleted] = useState(initialProgress >= 90);
const [manualProgress, setManualProgress] = useState(initialProgress);
// Sync state when lesson changes
useEffect(() => {
setIsCompleted(initialProgress >= 90);
setManualProgress(initialProgress);
setIsPlaying(false);
setCurrentTime(0);
}, [lessonId, initialProgress]);
// For Direct HTML5 Video Player
const handleTimeUpdate = () => {
if (!videoRef.current) return;
const current = videoRef.current.currentTime;
const dur = videoRef.current.duration;
setCurrentTime(current);
if (dur > 0) {
const percentage = Math.round((current / dur) * 100);
setManualProgress(percentage);
if (percentage >= 90 && !isCompleted) {
setIsCompleted(true);
onProgressUpdate(100);
} else {
// Debounce progress updates or update periodically
onProgressUpdate(percentage);
}
}
};
const togglePlay = () => {
if (!videoRef.current) return;
if (isPlaying) {
videoRef.current.pause();
} else {
videoRef.current.play().catch(e => console.log('Autoplay blocked'));
}
setIsPlaying(!isPlaying);
};
const handleLoadedMetadata = () => {
if (!videoRef.current) return;
setDuration(videoRef.current.duration);
};
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newVol = parseFloat(e.target.value);
setVolume(newVol);
if (videoRef.current) {
videoRef.current.volume = newVol;
}
};
const handleProgressSlider = (e: React.ChangeEvent<HTMLInputElement>) => {
const pct = parseInt(e.target.value);
setManualProgress(pct);
onProgressUpdate(pct);
if (pct >= 90) {
setIsCompleted(true);
} else {
setIsCompleted(false);
}
};
const triggerManualCompletion = () => {
setIsCompleted(true);
setManualProgress(100);
onProgressUpdate(100);
};
const formatTime = (timeInSeconds: number) => {
const minutes = Math.floor(timeInSeconds / 60);
const seconds = Math.floor(timeInSeconds % 60);
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`;
};
// YouTube embed parser
const getYoutubeEmbedUrl = (url: string) => {
let videoId = '';
// Handle watch?v= format
if (url.includes('youtube.com/watch?v=')) {
videoId = url.split('v=')[1]?.split('&')[0] || '';
}
// Handle embed format
else if (url.includes('youtube.com/embed/')) {
videoId = url.split('embed/')[1]?.split('?')[0] || '';
}
// Handle share format youtu.be/
else if (url.includes('youtu.be/')) {
videoId = url.split('youtu.be/')[1]?.split('?')[0] || '';
} else {
videoId = url; // assume id was passed directly
}
return `https://www.youtube.com/embed/${videoId}?modestbranding=1&rel=0&showinfo=0&controls=1&iv_load_policy=3&disablekb=1&fs=1`;
};
return (
<div className="space-y-4">
{/* Player Frame Container */}
<div className="relative bg-black aspect-video rounded-xl overflow-hidden border border-white/10 shadow-2xl group/player">
{videoType === 'youtube' ? (
<iframe
key={lessonId}
src={getYoutubeEmbedUrl(videoUrl)}
title="YouTube Player"
className="w-full h-full border-0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
/>
) : (
<div className="relative w-full h-full flex items-center justify-center">
<video
ref={videoRef}
src={videoUrl}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onClick={togglePlay}
className="w-full h-full object-contain"
/>
{/* Custom Direct Controls Overlay (Visible on Hover) */}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/90 to-transparent p-4 flex flex-col space-y-2 opacity-0 group-hover/player:opacity-100 transition-opacity duration-300">
{/* Progress Bar */}
<div className="flex items-center space-x-2">
<input
type="range"
min={0}
max={duration || 100}
value={currentTime}
onChange={(e) => {
const time = parseFloat(e.target.value);
if (videoRef.current) videoRef.current.currentTime = time;
setCurrentTime(time);
}}
className="w-full accent-[#E50914] bg-zinc-800 h-1.5 rounded-full cursor-pointer appearance-none"
/>
</div>
{/* Bottom Controls */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<button onClick={togglePlay} className="text-white hover:text-[#E50914] transition-colors">
{isPlaying ? <Pause className="w-5 h-5 fill-white" /> : <Play className="w-5 h-5 fill-white" />}
</button>
<span className="text-xs text-zinc-300 font-mono">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<Volume2 className="w-4 h-4 text-zinc-400" />
<input
type="range"
min={0}
max={1}
step={0.1}
value={volume}
onChange={handleVolumeChange}
className="w-16 accent-white bg-zinc-850 h-1 rounded-full cursor-pointer"
/>
</div>
<button
onClick={() => videoRef.current?.requestFullscreen()}
className="text-white hover:text-[#E50914] transition-colors"
>
<Maximize className="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
)}
</div>
{/* Lesson Progress Status & Actions bar */}
<div className="glass-card p-4 rounded-xl flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center space-x-3 w-full sm:w-auto">
<div className={`p-2 rounded-lg ${isCompleted ? 'bg-emerald-500/10 text-emerald-500' : 'bg-white/5 text-zinc-500'}`}>
<CheckCircle className="w-5 h-5" />
</div>
<div>
<p className="text-sm font-bold text-white leading-tight">
{isCompleted ? 'Aula Concluída!' : 'Aula em Progresso'}
</p>
<p className="text-xs text-zinc-400 font-medium mt-0.5">
Progresso atual: <span className="text-white font-semibold">{manualProgress}%</span>
</p>
</div>
</div>
{/* Manual Progress Slider for YouTube or easy debugging */}
<div className="flex items-center space-x-4 w-full sm:w-auto flex-1 max-w-xs">
<span className="text-[10px] text-zinc-500 font-bold uppercase whitespace-nowrap">Ajustar Progresso</span>
<input
type="range"
min={0}
max={100}
value={manualProgress}
onChange={handleProgressSlider}
className="w-full accent-[#E50914] bg-white/10 h-1.5 rounded-full cursor-pointer"
/>
</div>
<div className="w-full sm:w-auto flex justify-end">
{!isCompleted ? (
<button
onClick={triggerManualCompletion}
className="w-full sm:w-auto glass-btn-primary text-white text-xs font-bold px-4 py-2 rounded-lg flex items-center justify-center space-x-1.5 cursor-pointer"
>
<span>Concluir Aula</span>
</button>
) : (
<span className="text-xs text-emerald-500 font-bold flex items-center space-x-1">
<span> Salvo na Área do Aluno</span>
</span>
)}
</div>
</div>
</div>
);
}

400
src/db/db.ts Normal file
View File

@ -0,0 +1,400 @@
import fs from 'fs';
import path from 'path';
import bcrypt from 'bcryptjs';
import { User, Course, Module, Lesson, Note, LessonProgress, WebhookLog, SubscriptionPayment } from '../types';
const DB_FILE = path.join(process.cwd(), 'database.json');
export interface DatabaseSchema {
users: User[];
courses: Course[];
modules: Module[];
lessons: Lesson[];
notes: Note[];
progress: LessonProgress[];
webhookLogs: WebhookLog[];
payments: SubscriptionPayment[];
}
const DEFAULT_DB: DatabaseSchema = {
users: [],
courses: [],
modules: [],
lessons: [],
notes: [],
progress: [],
webhookLogs: [],
payments: []
};
// Helper to load db
export function loadDb(): DatabaseSchema {
try {
if (!fs.existsSync(DB_FILE)) {
const db = createInitialDb();
saveDb(db);
return db;
}
const data = fs.readFileSync(DB_FILE, 'utf-8');
return JSON.parse(data);
} catch (err) {
console.error('Error loading database:', err);
return DEFAULT_DB;
}
}
// Helper to save db
export function saveDb(data: DatabaseSchema): void {
try {
fs.writeFileSync(DB_FILE, JSON.stringify(data, null, 2), 'utf-8');
} catch (err) {
console.error('Error saving database:', err);
}
}
function createInitialDb(): DatabaseSchema {
const salt = bcrypt.genSaltSync(10);
const adminPasswordHash = bcrypt.hashSync('admin123', salt);
const studentPasswordHash = bcrypt.hashSync('aluno123', salt);
const now = new Date().toISOString();
const users: User[] = [
{
id: 'usr_admin',
name: 'Sidney Gomes (Admin)',
email: 'sidney.gomes1989@gmail.com',
password: adminPasswordHash,
role: 'admin',
subscriptionStatus: 'ACTIVE',
asaasCustomerId: null,
unlockedCourses: [],
createdAt: now
},
{
id: 'usr_student1',
name: 'João Silva',
email: 'joao@microtecflix.com',
password: studentPasswordHash,
role: 'student',
subscriptionStatus: 'ACTIVE',
asaasCustomerId: 'cus_00001',
unlockedCourses: ['crs_excel'], // Excel starts unlocked for Joao
createdAt: new Date(Date.now() - 30 * 24 * 3600 * 1000).toISOString()
},
{
id: 'usr_student2',
name: 'Maria Santos',
email: 'maria@microtecflix.com',
password: studentPasswordHash,
role: 'student',
subscriptionStatus: 'ACTIVE',
asaasCustomerId: 'cus_00002',
unlockedCourses: [],
createdAt: new Date(Date.now() - 15 * 24 * 3600 * 1000).toISOString()
},
{
id: 'usr_student3',
name: 'Pedro Oliveira',
email: 'pedro@microtecflix.com',
password: studentPasswordHash,
role: 'student',
subscriptionStatus: 'OVERDUE',
asaasCustomerId: 'cus_00003',
unlockedCourses: [],
createdAt: new Date(Date.now() - 45 * 24 * 3600 * 1000).toISOString()
},
{
id: 'usr_student4',
name: 'Ana Costa',
email: 'ana@microtecflix.com',
password: studentPasswordHash,
role: 'student',
subscriptionStatus: 'CANCELED',
asaasCustomerId: 'cus_00004',
unlockedCourses: [],
createdAt: new Date(Date.now() - 60 * 24 * 3600 * 1000).toISOString()
},
{
id: 'usr_student_test',
name: 'Aluno de Teste',
email: 'aluno@microtecflix.com',
password: studentPasswordHash,
role: 'student',
subscriptionStatus: 'ACTIVE',
asaasCustomerId: 'cus_test',
unlockedCourses: [], // Starts with only the free courses unlocked
createdAt: now
}
];
const courses: Course[] = [
{
id: 'crs_windows',
title: 'Sistema Operacional Windows 11 para Escritório',
description: 'Domine o sistema operacional mais utilizado no mundo corporativo. Aprenda a organizar arquivos, configurar o painel de controle, gerenciar usuários e utilizar ferramentas de produtividade para escritório de forma ágil e segura.',
thumbnail: 'https://images.unsplash.com/photo-1624555130581-1d9cca783bc0?q=80&w=600&auto=format&fit=crop',
category: 'Sistemas Operacionais',
isLocked: false,
price: 0,
createdAt: now
},
{
id: 'crs_word',
title: 'Microsoft Word Profissional',
description: 'Crie relatórios, contratos, trabalhos acadêmicos nas normas ABNT e documentos de escritório altamente profissionais. Domine formatação avançada, tabelas, sumários automáticos e malas diretas dinâmicas.',
thumbnail: 'https://images.unsplash.com/photo-1586075010923-2dd4570fb338?q=80&w=600&auto=format&fit=crop',
category: 'Pacote Office',
isLocked: false,
price: 0,
createdAt: now
},
{
id: 'crs_excel',
title: 'Microsoft Excel - Do Básico ao Dashboard',
description: 'O software de planilhas mais exigido pelo mercado de trabalho mundial. Aprenda desde fórmulas fundamentais (SOMA, SE, PROCV, CONT.SE) até tabelas dinâmicas, gráficos executivos, automações e dashboards de impacto profissional.',
thumbnail: 'https://images.unsplash.com/photo-1551288049-bebda4e38f71?q=80&w=600&auto=format&fit=crop',
category: 'Pacote Office',
isLocked: true,
price: 89.90,
createdAt: now
},
{
id: 'crs_powerpoint',
title: 'PowerPoint para Apresentações de Impacto',
description: 'Aprenda a estruturar e desenhar apresentações profissionais e persuasivas para reuniões de negócios, projetos ou aulas. Domine o efeito de transição Transformar (Morph), inserção de mídias inteligentes e animações profissionais.',
thumbnail: 'https://images.unsplash.com/photo-1516321318423-f06f85e504b3?q=80&w=600&auto=format&fit=crop',
category: 'Pacote Office',
isLocked: true,
price: 59.90,
createdAt: now
}
];
const modules: Module[] = [
// Windows 11
{ id: 'mdl_win_1', courseId: 'crs_windows', title: 'Fundamentos e Área de Trabalho', order: 1, createdAt: now },
{ id: 'mdl_win_2', courseId: 'crs_windows', title: 'Configurações e Segurança de Dados', order: 2, createdAt: now },
// Word
{ id: 'mdl_wrd_1', courseId: 'crs_word', title: 'Interface e Formatação de Textos', order: 1, createdAt: now },
{ id: 'mdl_wrd_2', courseId: 'crs_word', title: 'Layouts, Tabelas e Sumários', order: 2, createdAt: now },
// Excel
{ id: 'mdl_exc_1', courseId: 'crs_excel', title: 'Células, Fórmulas e Funções Principais', order: 1, createdAt: now },
{ id: 'mdl_exc_2', courseId: 'crs_excel', title: 'Tabelas Dinâmicas e Dashboards', order: 2, createdAt: now },
// PowerPoint
{ id: 'mdl_ppt_1', courseId: 'crs_powerpoint', title: 'Layout de Slides e Formas', order: 1, createdAt: now },
{ id: 'mdl_ppt_2', courseId: 'crs_powerpoint', title: 'Transições Especiais e Apresentação', order: 2, createdAt: now }
];
const lessons: Lesson[] = [
// Windows - Módulo 1
{
id: 'lsn_win_1_1',
moduleId: 'mdl_win_1',
title: 'Dominando a Nova Área de Trabalho e Barra de Tarefas do Windows 11',
videoUrl: 'https://www.youtube.com/embed/9e9D_bZfT8o',
videoType: 'youtube',
order: 1,
content: 'Nesta aula, você aprenderá a se localizar e customizar a nova barra de tarefas centralizada e a área de trabalho do Windows 11.',
createdAt: now
},
{
id: 'lsn_win_1_2',
moduleId: 'mdl_win_1',
title: 'Gerenciador de Arquivos e Organização Eficiente de Pastas',
videoUrl: 'https://www.youtube.com/embed/Pj1_bAn_m4Y',
videoType: 'youtube',
order: 2,
content: 'Aprenda a criar estruturas de pastas organizadas, atalhos rápidos e a usar o novo explorador de arquivos com abas do Windows.',
createdAt: now
},
// Windows - Módulo 2
{
id: 'lsn_win_2_1',
moduleId: 'mdl_win_2',
title: 'Painel de Configurações e Segurança com Windows Defender',
videoUrl: 'https://www.youtube.com/embed/K8w3m_pM6lA',
videoType: 'youtube',
order: 1,
content: 'Como configurar rede wi-fi, dispositivos bluetooth, impressoras e garantir que seu antivírus integrado esteja sempre atualizado.',
createdAt: now
},
// Word - Módulo 1
{
id: 'lsn_wrd_1_1',
moduleId: 'mdl_wrd_1',
title: 'Conhecendo a Faixa de Opções e Formatando Fontes e Parágrafos',
videoUrl: 'https://www.youtube.com/embed/Epn879GkIis',
videoType: 'youtube',
order: 1,
content: 'Entenda os principais comandos da guia Página Inicial e como aplicar formatações essenciais de texto.',
createdAt: now
},
// Word - Módulo 2
{
id: 'lsn_wrd_2_1',
moduleId: 'mdl_wrd_2',
title: 'Criação de Sumários Automáticos com Estilos de Título',
videoUrl: 'https://www.youtube.com/embed/2_W6C3vjD1s',
videoType: 'youtube',
order: 1,
content: 'Evite o trabalho manual! Aprenda a usar os Estilos (Título 1, Título 2) para que o Microsoft Word gere seu sumário em um clique.',
createdAt: now
},
// Excel - Módulo 1
{
id: 'lsn_exc_1_1',
moduleId: 'mdl_exc_1',
title: 'Dominando Fórmulas Básicas: SOMA, MÉDIA e Função Lógica SE',
videoUrl: 'https://www.youtube.com/embed/3S-F9h-gRjA',
videoType: 'youtube',
order: 1,
content: 'Entenda a sintaxe de fórmulas no Excel, referências relativas/absolutas ($) e como aplicar a famosa função SE.',
createdAt: now
},
// Excel - Módulo 2
{
id: 'lsn_exc_2_1',
moduleId: 'mdl_exc_2',
title: 'Criando sua Primeira Tabela Dinâmica e Gráficos de Vendas',
videoUrl: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4',
videoType: 'direct',
order: 1,
content: 'Aprenda a consolidar milhares de linhas de dados em resumos inteligentes usando tabelas dinâmicas.',
createdAt: now
},
// PowerPoint - Módulo 1
{
id: 'lsn_ppt_1_1',
moduleId: 'mdl_ppt_1',
title: 'Trabalhando com Layouts Próprios e Inserção de Imagens e Ícones',
videoUrl: 'https://www.youtube.com/embed/Y0iI9wzY_L4',
videoType: 'youtube',
order: 1,
content: 'Esqueça os designs prontos e ultrapassados! Aprenda a criar layouts autorais limpos, modernos e profissionais.',
createdAt: now
},
// PowerPoint - Módulo 2
{
id: 'lsn_ppt_2_1',
moduleId: 'mdl_ppt_2',
title: 'O Poder da Transição Transformar (Morph) em Apresentações',
videoUrl: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4',
videoType: 'direct',
order: 1,
content: 'Aprenda a criar animações fluidas e dinâmicas dignas de cinema utilizando o Morph do PowerPoint.',
createdAt: now
}
];
const notes: Note[] = [
{
id: 'note_1',
userId: 'usr_student_test',
lessonId: 'lsn_win_1_1',
content: 'Anotação de Teste: Atalho Windows + D serve para mostrar ou ocultar a Área de Trabalho instantaneamente!',
createdAt: new Date(Date.now() - 1 * 24 * 3600 * 1000).toISOString()
}
];
const progress: LessonProgress[] = [
{
id: 'prog_1',
userId: 'usr_student_test',
lessonId: 'lsn_win_1_1',
watchedPercentage: 95,
completed: true,
updatedAt: new Date(Date.now() - 1 * 24 * 3600 * 1000).toISOString()
},
{
id: 'prog_2',
userId: 'usr_student_test',
lessonId: 'lsn_win_1_2',
watchedPercentage: 45,
completed: false,
updatedAt: now
}
];
const payments: SubscriptionPayment[] = [
{
id: 'pay_1',
userId: 'usr_student1',
value: 49.90,
status: 'CONFIRMED',
billingType: 'PIX',
invoiceUrl: 'https://www.asaas.com/i/fake_invoice_1',
dueDate: new Date(Date.now() - 5 * 24 * 3600 * 1000).toISOString().split('T')[0],
paidAt: new Date(Date.now() - 5 * 24 * 3600 * 1000).toISOString(),
createdAt: new Date(Date.now() - 6 * 24 * 3600 * 1000).toISOString()
},
{
id: 'pay_2',
userId: 'usr_student2',
value: 49.90,
status: 'CONFIRMED',
billingType: 'CREDIT_CARD',
invoiceUrl: 'https://www.asaas.com/i/fake_invoice_2',
dueDate: new Date(Date.now() - 2 * 24 * 3600 * 1000).toISOString().split('T')[0],
paidAt: new Date(Date.now() - 2 * 24 * 3600 * 1000).toISOString(),
createdAt: new Date(Date.now() - 2 * 24 * 3600 * 1000).toISOString()
},
{
id: 'pay_3',
userId: 'usr_student3',
value: 49.90,
status: 'OVERDUE',
billingType: 'BOLETO',
invoiceUrl: 'https://www.asaas.com/i/fake_invoice_3',
dueDate: new Date(Date.now() - 10 * 24 * 3600 * 1000).toISOString().split('T')[0],
paidAt: null,
createdAt: new Date(Date.now() - 10 * 24 * 3600 * 1000).toISOString()
},
{
id: 'pay_test_1',
userId: 'usr_student_test',
value: 49.90,
status: 'CONFIRMED',
billingType: 'PIX',
invoiceUrl: 'https://www.asaas.com/i/fake_invoice_test',
dueDate: new Date(Date.now() - 1 * 24 * 3600 * 1000).toISOString().split('T')[0],
paidAt: new Date(Date.now() - 1 * 24 * 3600 * 1000).toISOString(),
createdAt: new Date(Date.now() - 1 * 24 * 3600 * 1000).toISOString()
}
];
const webhookLogs: WebhookLog[] = [
{
id: 'wh_log_1',
event: 'PAYMENT_RECEIVED',
payload: JSON.stringify({ id: 'pay_1', subscription: 'sub_0001', value: 49.90, customer: 'cus_00001' }),
receivedAt: new Date(Date.now() - 5 * 24 * 3600 * 1000).toISOString()
},
{
id: 'wh_log_2',
event: 'PAYMENT_CONFIRMED',
payload: JSON.stringify({ id: 'pay_2', subscription: 'sub_0002', value: 49.90, customer: 'cus_00002' }),
receivedAt: new Date(Date.now() - 2 * 24 * 3600 * 1000).toISOString()
},
{
id: 'wh_log_3',
event: 'PAYMENT_OVERDUE',
payload: JSON.stringify({ id: 'pay_3', subscription: 'sub_0003', value: 49.90, customer: 'cus_00003' }),
receivedAt: new Date(Date.now() - 10 * 24 * 3600 * 1000).toISOString()
}
];
return {
users,
courses,
modules,
lessons,
notes,
progress,
webhookLogs,
payments
};
}

134
src/db/schema.sql Normal file
View File

@ -0,0 +1,134 @@
-- --------------------------------------------------------
-- DATABASE SCHEMA FOR DEVFLIX / INFOFLIX PLATFORM
-- Target: PostgreSQL v12+
-- Description: Streaming courses subscription platform
-- --------------------------------------------------------
-- 1. Enum Types for Enforcing Integrity
CREATE TYPE user_role AS ENUM ('admin', 'student');
CREATE TYPE subscription_status AS ENUM ('ACTIVE', 'OVERDUE', 'CANCELED', 'TRIAL');
CREATE TYPE video_type AS ENUM ('youtube', 'direct');
CREATE TYPE payment_status AS ENUM ('PENDING', 'RECEIVED', 'CONFIRMED', 'OVERDUE', 'REFUNDED');
CREATE TYPE billing_type AS ENUM ('PIX', 'CREDIT_CARD', 'BOLETO');
-- 2. Users and Students Table
CREATE TABLE users (
id VARCHAR(50) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
role user_role DEFAULT 'student'::user_role,
subscription_status subscription_status DEFAULT 'TRIAL'::subscription_status,
asaas_customer_id VARCHAR(100),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL
);
-- Indexes for performance
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_asaas_customer ON users(asaas_customer_id);
-- 3. Courses Table
CREATE TABLE courses (
id VARCHAR(50) PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
thumbnail TEXT NOT NULL,
category VARCHAR(100) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL
);
-- 4. Modules Table
CREATE TABLE modules (
id VARCHAR(50) PRIMARY KEY,
course_id VARCHAR(50) NOT NULL,
title VARCHAR(255) NOT NULL,
"order" INTEGER NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
CONSTRAINT fk_modules_course FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE
);
-- Indexes
CREATE INDEX idx_modules_course ON modules(course_id);
-- 5. Lessons Table
CREATE TABLE lessons (
id VARCHAR(50) PRIMARY KEY,
module_id VARCHAR(50) NOT NULL,
title VARCHAR(255) NOT NULL,
video_url TEXT NOT NULL,
video_type video_type NOT NULL,
"order" INTEGER NOT NULL,
content TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
CONSTRAINT fk_lessons_module FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE CASCADE
);
-- Indexes
CREATE INDEX idx_lessons_module ON lessons(module_id);
-- 6. Student Notes Table
CREATE TABLE notes (
id VARCHAR(50) PRIMARY KEY,
user_id VARCHAR(50) NOT NULL,
lesson_id VARCHAR(50) NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
CONSTRAINT fk_notes_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_notes_lesson FOREIGN KEY (lesson_id) REFERENCES lessons(id) ON DELETE CASCADE,
CONSTRAINT uq_user_lesson_notes UNIQUE (user_id, lesson_id)
);
-- 7. Student Lesson Progress Tracking Table
CREATE TABLE progress (
id VARCHAR(50) PRIMARY KEY,
user_id VARCHAR(50) NOT NULL,
lesson_id VARCHAR(50) NOT NULL,
watched_percentage INTEGER DEFAULT 0 NOT NULL,
completed BOOLEAN DEFAULT FALSE NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
CONSTRAINT fk_progress_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_progress_lesson FOREIGN KEY (lesson_id) REFERENCES lessons(id) ON DELETE CASCADE,
CONSTRAINT uq_user_lesson_progress UNIQUE (user_id, lesson_id)
);
-- Indexes
CREATE INDEX idx_progress_user ON progress(user_id);
-- 8. Subscription Payments History (Asaas Linked)
CREATE TABLE payments (
id VARCHAR(100) PRIMARY KEY,
user_id VARCHAR(50) NOT NULL,
value NUMERIC(10, 2) NOT NULL,
status payment_status NOT NULL,
billing_type billing_type NOT NULL,
invoice_url TEXT NOT NULL,
due_date DATE NOT NULL,
paid_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
CONSTRAINT fk_payments_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Indexes
CREATE INDEX idx_payments_user ON payments(user_id);
-- 9. Asaas Webhook Logs Audit Table
CREATE TABLE webhook_logs (
id VARCHAR(50) PRIMARY KEY,
event VARCHAR(255) NOT NULL,
payload TEXT NOT NULL,
received_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL
);
-- Trigger to auto-update "updated_at" on lesson progress updates
CREATE OR REPLACE FUNCTION update_progress_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_update_progress_time
BEFORE UPDATE ON progress
FOR EACH ROW
EXECUTE FUNCTION update_progress_timestamp();

97
src/index.css Normal file
View File

@ -0,0 +1,97 @@
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800;900&family=Space+Grotesk:wght@400;500;600;700&family=Fira+Code:wght@400;500&display=swap');
@import "tailwindcss";
@theme {
--font-sans: "Outfit", "Inter", ui-sans-serif, system-ui, sans-serif;
--font-display: "Space Grotesk", sans-serif;
--font-mono: "Fira Code", monospace;
}
/* Base custom styles to enable smooth scrolling and fine layout */
html, body {
scroll-behavior: smooth;
font-family: var(--font-sans);
}
/* Glassmorphism custom classes */
.glass-container {
background: rgba(20, 20, 20, 0.45);
backdrop-filter: blur(24px) saturate(120%);
-webkit-backdrop-filter: blur(24px) saturate(120%);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 12px 40px 0 rgba(0, 0, 0, 0.5);
}
.glass-navbar {
background: rgba(12, 12, 12, 0.5);
backdrop-filter: blur(30px) saturate(130%);
-webkit-backdrop-filter: blur(30px) saturate(130%);
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
}
.glass-card {
background: rgba(30, 30, 30, 0.35);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.06);
box-shadow: 0 8px 24px 0 rgba(0, 0, 0, 0.25);
}
.glass-card-hover {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.glass-card-hover:hover {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(229, 9, 20, 0.4);
box-shadow: 0 12px 36px 0 rgba(229, 9, 20, 0.18);
transform: translateY(-5px);
}
.glass-input {
background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.07);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.glass-input:focus {
background: rgba(255, 255, 255, 0.07);
border-color: rgba(229, 9, 20, 0.6);
box-shadow: 0 0 16px rgba(229, 9, 20, 0.25);
}
.glass-btn-primary {
background: linear-gradient(135deg, rgba(229, 9, 20, 0.85) 0%, rgba(184, 7, 15, 0.95) 100%);
backdrop-filter: blur(6px);
border: 1px solid rgba(255, 255, 255, 0.15);
box-shadow: 0 4px 15px rgba(229, 9, 20, 0.3);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.glass-btn-primary:hover {
background: linear-gradient(135deg, rgba(229, 9, 20, 0.98) 0%, rgba(184, 7, 15, 1) 100%);
box-shadow: 0 6px 20px rgba(229, 9, 20, 0.5);
transform: translateY(-1px);
}
.glass-btn-secondary {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
transition: all 0.2s ease;
}
.glass-btn-secondary:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.2);
}
.glass-accordion-header {
background: rgba(0, 0, 0, 0.25);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.glass-badge {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(6px);
border: 1px solid rgba(255, 255, 255, 0.08);
}

10
src/main.tsx Normal file
View File

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

149
src/services/asaas.ts Normal file
View File

@ -0,0 +1,149 @@
import { SubscriptionPayment } from '../types';
export class AsaasService {
private static getApiKey(): string | null {
return process.env.ASAAS_API_KEY || null;
}
private static getApiUrl(): string {
// If we have an API key, check if it looks like a production or sandbox one.
// Standard sandbox keys often start with '$' or are long hashes, let's allow configuring via env or default to sandbox
const isProd = process.env.ASAAS_ENV === 'production';
return isProd ? 'https://api.asaas.com/v3' : 'https://sandbox.asaas.com/v3';
}
private static getHeaders() {
const key = this.getApiKey();
return {
'Content-Type': 'application/json',
'access_token': key || ''
};
}
/**
* Create a customer in Asaas
*/
public static async createCustomer(name: string, email: string, cpfCnpj?: string): Promise<string> {
const apiKey = this.getApiKey();
if (!apiKey) {
console.warn('ASAAS_API_KEY not configured. Simulating customer creation.');
return `cus_sim_${Math.random().toString(36).substring(2, 9)}`;
}
try {
const response = await fetch(`${this.getApiUrl()}/customers`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify({
name,
email,
cpfCnpj: cpfCnpj || '000.000.000-00', // Default mock CPF if not provided
notificationDisabled: true
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.errors?.[0]?.description || 'Failed to create customer in Asaas');
}
const data = await response.json();
return data.id;
} catch (err: any) {
console.error('Asaas createCustomer error:', err);
// Fallback in case of networking issues, so it doesn't block local experience completely
return `cus_fallback_${Math.random().toString(36).substring(2, 9)}`;
}
}
/**
* Create a recurring subscription in Asaas
*/
public static async createSubscription(
customerId: string,
value: number,
billingType: 'PIX' | 'CREDIT_CARD' | 'BOLETO',
creditCardInfo?: any
): Promise<{ subscriptionId: string; payment: Partial<SubscriptionPayment> }> {
const apiKey = this.getApiKey();
if (!apiKey) {
console.warn('ASAAS_API_KEY not configured. Simulating subscription creation.');
const subId = `sub_sim_${Math.random().toString(36).substring(2, 9)}`;
const invoiceUrl = `https://sandbox.asaas.com/i/simulated_${subId}`;
return {
subscriptionId: subId,
payment: {
id: `pay_sim_${Math.random().toString(36).substring(2, 9)}`,
value,
status: 'PENDING',
billingType,
invoiceUrl,
dueDate: new Date(Date.now() + 3 * 24 * 3600 * 1000).toISOString().split('T')[0],
paidAt: null,
createdAt: new Date().toISOString()
}
};
}
try {
const payload: any = {
customer: customerId,
billingType,
value,
nextDueDate: new Date(Date.now() + 3 * 24 * 3600 * 1000).toISOString().split('T')[0],
cycle: 'MONTHLY',
description: 'Assinatura Mensal DevFlix'
};
if (billingType === 'CREDIT_CARD' && creditCardInfo) {
payload.creditCard = creditCardInfo.creditCard;
payload.creditCardHolderInfo = creditCardInfo.creditCardHolderInfo;
}
const response = await fetch(`${this.getApiUrl()}/subscriptions`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.errors?.[0]?.description || 'Failed to create subscription in Asaas');
}
const data = await response.json();
// Retrieve the pending payment/invoice url if possible, or build simulated invoice link
return {
subscriptionId: data.id,
payment: {
id: `pay_asaas_${data.id}`,
value,
status: 'PENDING',
billingType,
invoiceUrl: data.invoiceUrl || `https://sandbox.asaas.com/i/${data.id}`,
dueDate: data.nextDueDate,
paidAt: null,
createdAt: new Date().toISOString()
}
};
} catch (err: any) {
console.error('Asaas createSubscription error:', err);
// Fallback
const subId = `sub_fb_${Math.random().toString(36).substring(2, 9)}`;
return {
subscriptionId: subId,
payment: {
id: `pay_fb_${Math.random().toString(36).substring(2, 9)}`,
value,
status: 'PENDING',
billingType,
invoiceUrl: `https://sandbox.asaas.com/i/fallback_${subId}`,
dueDate: new Date(Date.now() + 3 * 24 * 3600 * 1000).toISOString().split('T')[0],
paidAt: null,
createdAt: new Date().toISOString()
}
};
}
}
}

87
src/types.ts Normal file
View File

@ -0,0 +1,87 @@
export type UserRole = 'admin' | 'student';
export type SubscriptionStatus = 'ACTIVE' | 'OVERDUE' | 'CANCELED' | 'TRIAL';
export interface User {
id: string;
name: string;
email: string;
password?: string;
role: UserRole;
subscriptionStatus: SubscriptionStatus;
asaasCustomerId: string | null;
unlockedCourses?: string[]; // IDs of courses unlocked individually
createdAt: string;
}
export interface Course {
id: string;
title: string;
description: string;
thumbnail: string;
category: string;
isLocked?: boolean; // If true, requires payment/unlock
price?: number; // Cost to unlock if locked (e.g. 89.90)
createdAt: string;
}
export interface Module {
id: string;
courseId: string;
title: string;
order: number;
createdAt: string;
}
export interface Lesson {
id: string;
moduleId: string;
title: string;
videoUrl: string; // YouTube embed link or direct HTML5 mp4/hls link
videoType: 'youtube' | 'direct';
order: number;
content: string; // Additional lesson resources/description
createdAt: string;
}
export interface Note {
id: string;
userId: string;
lessonId: string;
content: string;
createdAt: string;
}
export interface LessonProgress {
id: string;
userId: string;
lessonId: string;
watchedPercentage: number;
completed: boolean;
updatedAt: string;
}
export interface WebhookLog {
id: string;
event: string;
payload: string;
receivedAt: string;
}
export interface SubscriptionPayment {
id: string;
userId: string;
value: number;
status: 'PENDING' | 'RECEIVED' | 'CONFIRMED' | 'OVERDUE' | 'REFUNDED';
billingType: 'PIX' | 'CREDIT_CARD' | 'BOLETO';
invoiceUrl: string;
dueDate: string;
paidAt: string | null;
createdAt: string;
}
export interface AdminDashboardStats {
mrr: number;
activeSubscribers: number;
overdueSubscribers: number;
canceledSubscribers: number;
}

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
}
}

22
vite.config.ts Normal file
View File

@ -0,0 +1,22 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import {defineConfig} from 'vite';
export default defineConfig(() => {
return {
plugins: [react(), tailwindcss()],
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',
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
watch: process.env.DISABLE_HMR === 'true' ? null : {},
},
};
});