diff --git a/.github/workflows/dev-0-ci-cd.yaml b/.github/workflows/dev-0-ci-cd.yaml new file mode 100644 index 0000000..26a62b7 --- /dev/null +++ b/.github/workflows/dev-0-ci-cd.yaml @@ -0,0 +1,32 @@ +name: Dev CI/CD +on: + push: + branches: + - dev +jobs: + build: + if: github.ref == 'refs/heads/dev' + uses: ./.github/workflows/dev-1-ci.yaml + with: + environment: dev + image_repository: ${{ vars.DOCKER_IMAGE }} + push_image: ${{ github.event_name == 'push' }} + secrets: + docker_hub_username: ${{ secrets.DOCKER_HUB_USERNAME }} + docker_hub_access_token: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + deploy: + if: github.ref == 'refs/heads/dev' + uses: ./.github/workflows/dev-2-cd.yaml + needs: build + with: + environment: dev + image_repository: ${{ vars.DOCKER_IMAGE }} + image_tag: ${{ needs.build.outputs.image_tag }} + deployment_repository: ${{ vars.ARGOCD_REPOSITORY }} + deployment_ref: ${{ vars.ARGOCD_REF || 'main' }} + image_repository_path: ${{ vars.ARGOCD_IMAGE_REPOSITORY_PATH || 'control-front.image.repository' }} + image_tag_path: ${{ vars.ARGOCD_IMAGE_TAG_PATH || 'control-front.image.tag' }} + argocd_values_file: ${{ vars.ARGOCD_VALUES_FILE }} + secrets: + token: ${{ secrets.ARGOCD_GH_TOKEN }} diff --git a/.github/workflows/dev-1-ci.yaml b/.github/workflows/dev-1-ci.yaml new file mode 100644 index 0000000..34de046 --- /dev/null +++ b/.github/workflows/dev-1-ci.yaml @@ -0,0 +1,74 @@ +name: Dev CI - Build and Push Image + +on: + workflow_call: + inputs: + environment: + description: GitHub Environment used for this build + required: true + type: string + image_repository: + description: Docker Hub image repository for the dev environment + required: true + type: string + push_image: + description: Whether to push the image to Docker Hub + required: false + default: false + type: boolean + secrets: + docker_hub_username: + required: false + docker_hub_access_token: + required: false + outputs: + image_tag: + description: Tag of the built dev image + value: ${{ jobs.build.outputs.image_tag }} + +jobs: + build: + name: Build dev image + runs-on: ubuntu-latest + environment: ${{ inputs.environment }} + outputs: + image_tag: ${{ steps.image.outputs.tag }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate image repository + env: + IMAGE_REPOSITORY: ${{ inputs.image_repository }} + run: test -n "$IMAGE_REPOSITORY" + + - name: Set image tag + id: image + run: echo "tag=dev-${GITHUB_SHA}" >> "$GITHUB_OUTPUT" + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + if: inputs.push_image + uses: docker/login-action@v4 + with: + username: ${{ secrets.docker_hub_username }} + password: ${{ secrets.docker_hub_access_token }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: ${{ inputs.push_image }} + tags: | + ${{ inputs.image_repository }}:${{ steps.image.outputs.tag }} + ${{ inputs.image_repository }}:dev-latest + build-args: | + NEXT_PUBLIC_API_URL=${{ vars.NEXT_PUBLIC_API_URL }} + cache-from: type=gha,scope=dev + cache-to: type=gha,mode=max,scope=dev \ No newline at end of file diff --git a/.github/workflows/dev-2-cd.yaml b/.github/workflows/dev-2-cd.yaml new file mode 100644 index 0000000..9d844a9 --- /dev/null +++ b/.github/workflows/dev-2-cd.yaml @@ -0,0 +1,75 @@ +name: Dev CD - Update Image on Deployment Repository + +on: + workflow_call: + inputs: + environment: + description: GitHub Environment used for this deployment + required: true + type: string + image_repository: + required: true + type: string + image_tag: + required: true + type: string + deployment_repository: + required: true + type: string + deployment_ref: + required: false + default: main + type: string + image_repository_path: + required: false + default: .image.repository + type: string + image_tag_path: + required: false + default: .image.tag + type: string + argocd_values_file: + required: true + type: string + secrets: + token: + required: true + +jobs: + update-image: + name: Update dev image in GitOps repository + runs-on: ubuntu-latest + environment: ${{ inputs.environment }} + steps: + - name: Checkout deployment repository + uses: actions/checkout@v4 + with: + repository: ${{ inputs.deployment_repository }} + ref: ${{ inputs.deployment_ref }} + token: ${{ secrets.token }} + path: deployment-config + + - name: Set up yq + uses: shiipou/setup-yq-action@v2.2.0 + + - name: Update image repository and tag + env: + VALUES_FILE: ${{ vars.ARGOCD_VALUES_FILE }} + IMAGE_REPOSITORY: ${{ inputs.image_repository }} + IMAGE_TAG: ${{ inputs.image_tag }} + IMAGE_REPOSITORY_PATH: ${{ inputs.image_repository_path }} + IMAGE_TAG_PATH: ${{ inputs.image_tag_path }} + run: | + test -n "$VALUES_FILE" + test -f "deployment-config/$VALUES_FILE" + yq -i 'eval(strenv(IMAGE_REPOSITORY_PATH)) = strenv(IMAGE_REPOSITORY) | eval(strenv(IMAGE_TAG_PATH)) = strenv(IMAGE_TAG)' "deployment-config/$VALUES_FILE" + + git -C deployment-config config user.email "github-actions[bot]@users.noreply.github.com" + git -C deployment-config config user.name "github-actions[bot]" + git -C deployment-config add "$VALUES_FILE" + if git -C deployment-config diff --cached --quiet; then + echo "Image values are already up to date." + else + git -C deployment-config commit -m "Deploy dev image $IMAGE_TAG" + git -C deployment-config push + fi \ No newline at end of file diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml deleted file mode 100644 index baec71a..0000000 --- a/.github/workflows/docker-image.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Docker Image CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - -env: - IMAGE_TAG: ${{ github.sha }} - DOCKER_IMAGE: ${{ vars.DOCKER_IMAGE }} -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Set up QEMU - uses: docker/setup-qemu-action@v2 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - - name: Login to GCR - uses: docker/login-action@v2 - with: - registry: gcr.io - username: _json_key - password: ${{ secrets.GCP_SA_KEY }} - - - name: Build and push - uses: docker/build-push-action@v3 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{env.DOCKER_IMAGE}}:${{env.IMAGE_TAG}},${{env.DOCKER_IMAGE}}:latest - build-args: NEXT_PUBLIC_API_URL=${{vars.NEXT_PUBLIC_API_URL}} diff --git a/.github/workflows/prod-0-ci-cd.yaml b/.github/workflows/prod-0-ci-cd.yaml new file mode 100644 index 0000000..46ffcae --- /dev/null +++ b/.github/workflows/prod-0-ci-cd.yaml @@ -0,0 +1,34 @@ +name: Prod CI/CD + +on: + push: + branches: + - main + +jobs: + build: + if: github.ref == 'refs/heads/main' + uses: ./.github/workflows/prod-1-ci.yaml + with: + environment: prod + image_repository: ${{ vars.DOCKER_IMAGE }} + push_image: ${{ github.event_name == 'push' }} + secrets: + docker_hub_username: ${{ secrets.DOCKER_HUB_USERNAME }} + docker_hub_access_token: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + deploy: + if: github.ref == 'refs/heads/main' + uses: ./.github/workflows/prod-2-cd.yaml + needs: build + with: + environment: prod + image_repository: ${{ vars.DOCKER_IMAGE }} + image_tag: ${{ needs.build.outputs.image_tag }} + deployment_repository: ${{ vars.ARGOCD_REPOSITORY }} + deployment_ref: ${{ vars.ARGOCD_REF || 'main' }} + image_repository_path: ${{ vars.ARGOCD_IMAGE_REPOSITORY_PATH || 'control-front.image.repository' }} + image_tag_path: ${{ vars.ARGOCD_IMAGE_TAG_PATH || 'control-front.image.tag' }} + argocd_values_file: ${{ vars.ARGOCD_VALUES_FILE }} + secrets: + token: ${{ secrets.ARGOCD_GH_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/prod-1-ci.yaml b/.github/workflows/prod-1-ci.yaml new file mode 100644 index 0000000..0a5aaac --- /dev/null +++ b/.github/workflows/prod-1-ci.yaml @@ -0,0 +1,74 @@ +name: Prod CI - Build and Push Image + +on: + workflow_call: + inputs: + environment: + description: GitHub Environment used for this build + required: true + type: string + image_repository: + description: Docker Hub image repository + required: true + type: string + push_image: + description: Whether to push the image to Docker Hub + required: false + default: false + type: boolean + secrets: + docker_hub_username: + required: false + docker_hub_access_token: + required: false + outputs: + image_tag: + description: Tag of the built production image + value: ${{ jobs.build.outputs.image_tag }} + +jobs: + build: + name: Build production image + runs-on: ubuntu-latest + environment: ${{ inputs.environment }} + outputs: + image_tag: ${{ steps.image.outputs.tag }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate image repository + env: + IMAGE_REPOSITORY: ${{ inputs.image_repository }} + run: test -n "$IMAGE_REPOSITORY" + + - name: Set image tag + id: image + run: echo "tag=${GITHUB_SHA}" >> "$GITHUB_OUTPUT" + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + if: inputs.push_image + uses: docker/login-action@v4 + with: + username: ${{ secrets.docker_hub_username }} + password: ${{ secrets.docker_hub_access_token }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: ${{ inputs.push_image }} + tags: | + ${{ inputs.image_repository }}:${{ steps.image.outputs.tag }} + ${{ inputs.image_repository }}:latest + build-args: | + NEXT_PUBLIC_API_URL=${{ vars.NEXT_PUBLIC_API_URL }} + cache-from: type=gha,scope=prod + cache-to: type=gha,mode=max,scope=prod \ No newline at end of file diff --git a/.github/workflows/prod-2-cd.yaml b/.github/workflows/prod-2-cd.yaml new file mode 100644 index 0000000..b90c6eb --- /dev/null +++ b/.github/workflows/prod-2-cd.yaml @@ -0,0 +1,73 @@ +name: Prod CD - Update Image on Deployment Repository + +on: + workflow_call: + inputs: + environment: + description: GitHub Environment used for this deployment + required: true + type: string + image_repository: + required: true + type: string + image_tag: + required: true + type: string + deployment_repository: + required: true + type: string + deployment_ref: + required: false + default: main + type: string + image_repository_path: + required: false + default: .image.repository + type: string + image_tag_path: + required: false + default: .image.tag + type: string + argocd_values_file: + required: true + type: string + secrets: + token: + required: true + +jobs: + update-image: + name: Update production image in GitOps repository + runs-on: ubuntu-latest + environment: ${{ inputs.environment }} + steps: + - name: Checkout deployment repository + uses: actions/checkout@v4 + with: + repository: ${{ inputs.deployment_repository }} + ref: ${{ inputs.deployment_ref }} + token: ${{ secrets.token }} + path: deployment-config + + - name: Set up yq + uses: shiipou/setup-yq-action@v2.2.0 + + - name: Update image repository and tag + env: + VALUES_FILE: ${{ vars.argocd_values_file }} + IMAGE_REPOSITORY: ${{ inputs.image_repository }} + IMAGE_TAG: ${{ inputs.image_tag }} + IMAGE_REPOSITORY_PATH: ${{ inputs.image_repository_path }} + IMAGE_TAG_PATH: ${{ inputs.image_tag_path }} + run: | + yq -i 'eval(strenv(IMAGE_REPOSITORY_PATH)) = strenv(IMAGE_REPOSITORY) | eval(strenv(IMAGE_TAG_PATH)) = strenv(IMAGE_TAG)' "deployment-config/$VALUES_FILE" + + git -C deployment-config config user.email "github-actions[bot]@users.noreply.github.com" + git -C deployment-config config user.name "github-actions[bot]" + git -C deployment-config add "$VALUES_FILE" + if git -C deployment-config diff --cached --quiet; then + echo "Image values are already up to date." + else + git -C deployment-config commit -m "Deploy dev image $IMAGE_TAG" + git -C deployment-config push + fi \ No newline at end of file diff --git a/components/layout.tsx b/components/layout.tsx index 85a0304..4bd935b 100644 --- a/components/layout.tsx +++ b/components/layout.tsx @@ -1,19 +1,23 @@ import { AppBar, Box, IconButton, Paper, Toolbar, Typography } from "@mui/material"; import LoginButton from "./login-btn"; import Link from "next/link"; +import { useRouter } from "next/router"; export default function BasicGrid(props: { children?: any }) { + const { pathname } = useRouter(); + const isSelfService = pathname === "/self" || pathname.startsWith("/self/"); + return ( <> - - {"PyDay La Paz 2025"} + + {"PyDay La Paz"} - + diff --git a/components/login-btn.tsx b/components/login-btn.tsx index 9a71b83..a158d41 100644 --- a/components/login-btn.tsx +++ b/components/login-btn.tsx @@ -6,13 +6,15 @@ import { signIn, signOut, useSession } from "next-auth/react"; import Link from "next/link"; import React, { useEffect } from "react"; import { useRecoilState } from "recoil"; +import ParticipationDialog from "./participation-dialog"; -export default function LoginButton() { +export default function LoginButton({ isSelfService = false }: { isSelfService?: boolean }) { const menuStyle = { textDecoration: "none", color: "inherit" } const [token, setAccessTokenState] = useRecoilState(accessTokenState); const [roles, setRoles] = useRecoilState(rolesState); const { data: session, status } = useSession() const [anchorEl, setAnchorEl] = React.useState(null); + const [participationOpen, setParticipationOpen] = React.useState(false); const handleMenu = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); }; @@ -34,7 +36,7 @@ export default function LoginButton() { } // eslint-disable-next-line react-hooks/exhaustive-deps }, [session, status]); - if (session) { + if (session && !isSelfService) { return ( <> + + + + + ); +} \ No newline at end of file diff --git a/const/index.ts b/const/index.ts index d883330..04bb293 100644 --- a/const/index.ts +++ b/const/index.ts @@ -58,4 +58,40 @@ export type ParticipantRedeemCombosProps = { participant_control_code: string; combo: string; products: string[]; -} \ No newline at end of file +} + +export type SelfServiceRegisterRequest = { + ci: string; + birthday: string; +} + +export type SelfServiceParticipant = { + _id: string; + control_code: string; + qr_code: string; +} + +export type SelfServiceResult = { + control_code: string; + message: string; + points: number; +} + +export type SelfServiceGift = { + _id: string; + name: string; + description: string; + kind: string; + points: number; + multiple: boolean; + stock: number | null; +} + +export type SelfServiceRedeem = { + control_code: string; + message: string; + points: number; + product_name: string; + discount_code: string; + download_url: string; +} diff --git a/lib/http_self_service.ts b/lib/http_self_service.ts new file mode 100644 index 0000000..f5c8e91 --- /dev/null +++ b/lib/http_self_service.ts @@ -0,0 +1,107 @@ +import { SelfServiceGift, SelfServiceParticipant, SelfServiceRedeem, SelfServiceRegisterRequest, SelfServiceResult } from "@/const"; + +export async function postSelfServiceRegistration(register: SelfServiceRegisterRequest): Promise<{ data: SelfServiceParticipant, message?: string, error: boolean }> { + const res = await fetch(`/api/v1/self-service/registration`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(register), + }); + if (res.status !== 200) { + const error = await res.text(); + return { data: {} as SelfServiceParticipant, message: `${res.status} - ${error}`, error: true } + } + const data = await res.json(); + const participant: SelfServiceParticipant = data; + + return { data: participant, error: false, message: "Registro exitoso" } +} + +export async function getSelfServiceCheck(code: string): Promise<{ error: boolean, status: number, message?: string }> { + const res = await fetch(`/api/v1/self-service/check/${encodeURIComponent(code)}`, { + method: 'GET', + cache: 'no-store', + }); + if (res.status !== 200) { + const error = await res.text(); + return { error: true, status: res.status, message: `${res.status} - ${error}` }; + } + return { error: false, status: res.status }; +} + +export async function postSelfServiceCheck(code: string, controlCode: string): Promise<{ data: SelfServiceResult, error: boolean, message?: string }> { + const res = await fetch(`/api/v1/self-service/check/${encodeURIComponent(code)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ control_code: controlCode }), + }); + if (res.status !== 200) { + const error = await res.text(); + return { data: {} as SelfServiceResult, error: true, message: `${res.status} - ${error}` }; + } + const data: SelfServiceResult = await res.json(); + return { data, error: false, message: data.message }; +} + +export async function postSelfServicePoints(controlCode: string): Promise<{ data: SelfServiceResult, error: boolean, message?: string }> { + const res = await fetch('/api/v1/self-service/points', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ control_code: controlCode }), + }); + if (res.status !== 200) { + const error = await res.text(); + return { data: {} as SelfServiceResult, error: true, message: `${res.status} - ${error}` }; + } + const data: SelfServiceResult = await res.json(); + return { data, error: false, message: data.message }; +} + +export async function getSelfServiceRewards(): Promise<{ data: SelfServiceGift[], error: boolean, message?: string }> { + const res = await fetch('/api/v1/self-service/rewards', { method: 'GET', cache: 'no-store' }); + if (res.status !== 200) { + const error = await res.text(); + return { data: [], error: true, message: `${res.status} - ${error}` }; + } + const data: SelfServiceGift[] = await res.json(); + return { data, error: false }; +} + +export async function postSelfServiceRedeemReward(reward: { control_code: string, ci: string, gift: string }): Promise<{ data: SelfServiceRedeem, error: boolean, message?: string }> { + const res = await fetch('/api/v1/self-service/redeem/reward', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(reward), + }); + if (res.status !== 200) { + const error = await res.text(); + return { data: {} as SelfServiceRedeem, error: true, message: `${res.status} - ${error}` }; + } + const data: SelfServiceRedeem = await res.json(); + return { data, error: false, message: data.message }; +} + +export async function getSelfServiceGift(): Promise<{ data: SelfServiceResult, error: boolean, message?: string }> { + const res = await fetch('/api/v1/self-service/redeem/gift', { method: 'GET', cache: 'no-store' }); + if (res.status !== 200) { + const error = await res.text(); + return { data: {} as SelfServiceResult, error: true, message: `${res.status} - ${error}` }; + } + const data: SelfServiceResult = await res.json(); + return { data, error: false, message: data.message }; +} + +export async function postSelfServiceRedeemGift(gift: { control_code: string, ci: string }): Promise<{ data: SelfServiceRedeem, error: boolean, message?: string }> { + const res = await fetch('/api/v1/self-service/redeem/gift', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ control_code: gift.control_code, ci: gift.ci, gift: null }), + }); + if (res.status !== 200) { + const error = await res.text(); + return { data: {} as SelfServiceRedeem, error: true, message: `${res.status} - ${error}` }; + } + const data: SelfServiceRedeem = await res.json(); + return { data, error: false, message: data.message }; +} \ No newline at end of file diff --git a/lib/qr.ts b/lib/qr.ts new file mode 100644 index 0000000..c424163 --- /dev/null +++ b/lib/qr.ts @@ -0,0 +1,42 @@ +export async function readQrControlCode(file: File): Promise { + const { default: jsQR } = await import("jsqr"); + const url = URL.createObjectURL(file); + let text = ""; + try { + const image = new Image(); + image.src = url; + await image.decode(); + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (!context) throw new Error("No se pudo procesar la imagen."); + const maxDimension = Math.max(image.naturalWidth, image.naturalHeight); + if (!maxDimension) throw new Error("La imagen está vacía."); + const sizes = new Set([Math.min(maxDimension, 2048), Math.min(maxDimension, 1024), Math.min(maxDimension, 512)]); + for (const size of Array.from(sizes)) { + const scale = size / maxDimension; + canvas.width = Math.max(1, Math.round(image.naturalWidth * scale)); + canvas.height = Math.max(1, Math.round(image.naturalHeight * scale)); + context.imageSmoothingEnabled = false; + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, canvas.width, canvas.height); + context.drawImage(image, 0, 0, canvas.width, canvas.height); + const pixels = context.getImageData(0, 0, canvas.width, canvas.height); + const decoded = jsQR(pixels.data, pixels.width, pixels.height, { inversionAttempts: "attemptBoth" }); + if (decoded?.data.trim()) { + text = decoded.data.trim(); + break; + } + } + if (!text) throw new Error("No se detectó un QR legible. Prueba con la imagen original de tu credencial, sin recortes ni capturas borrosas."); + } finally { + URL.revokeObjectURL(url); + } + if (text.startsWith("{")) { + const data: unknown = JSON.parse(text); + if (!data || typeof data !== "object" || !("control_code" in data) || typeof data.control_code !== "string" || !data.control_code.trim()) { + throw new Error("El QR no contiene un código de control válido."); + } + return data.control_code.trim(); + } + return text; +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index c489481..1164bca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,9 @@ "autoprefixer": "10.4.14", "eslint": "8.42.0", "eslint-config-next": "13.4.4", + "html-to-image": "^1.11.13", "html5-qrcode": "^2.3.8", + "jsqr": "^1.4.0", "next": "13.4.4", "next-auth": "^4.22.1", "notistack": "^3.0.1", @@ -28,7 +30,8 @@ "react-dom": "18.2.0", "recoil": "^0.7.7", "tailwindcss": "3.3.2", - "typescript": "5.1.3" + "typescript": "5.1.3", + "vanilla-tilt": "^1.8.1" } }, "node_modules/@alloc/quick-lru": { @@ -2825,6 +2828,12 @@ "react-is": "^16.7.0" } }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "license": "MIT" + }, "node_modules/html5-qrcode": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz", @@ -3316,6 +3325,12 @@ "json5": "lib/cli.js" } }, + "node_modules/jsqr": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz", + "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==", + "license": "Apache-2.0" + }, "node_modules/jsx-ast-utils": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.3.tgz", @@ -5048,6 +5063,12 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/vanilla-tilt": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/vanilla-tilt/-/vanilla-tilt-1.8.1.tgz", + "integrity": "sha512-hPB1XUsnh+SIeVSW2beb5RnuFxz4ZNgxjGD78o52F49gS4xaoLeEMh9qrQnJrnEn/vjjBI7IlxrrXmz4tGV0Kw==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7076,6 +7097,11 @@ "react-is": "^16.7.0" } }, + "html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==" + }, "html5-qrcode": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz", @@ -7401,6 +7427,11 @@ "minimist": "^1.2.0" } }, + "jsqr": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz", + "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==" + }, "jsx-ast-utils": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.3.tgz", @@ -8522,6 +8553,11 @@ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" }, + "vanilla-tilt": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/vanilla-tilt/-/vanilla-tilt-1.8.1.tgz", + "integrity": "sha512-hPB1XUsnh+SIeVSW2beb5RnuFxz4ZNgxjGD78o52F49gS4xaoLeEMh9qrQnJrnEn/vjjBI7IlxrrXmz4tGV0Kw==" + }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index f75ed7a..9cd62b7 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,9 @@ "autoprefixer": "10.4.14", "eslint": "8.42.0", "eslint-config-next": "13.4.4", + "html-to-image": "^1.11.13", "html5-qrcode": "^2.3.8", + "jsqr": "^1.4.0", "next": "13.4.4", "next-auth": "^4.22.1", "notistack": "^3.0.1", @@ -29,6 +31,7 @@ "react-dom": "18.2.0", "recoil": "^0.7.7", "tailwindcss": "3.3.2", - "typescript": "5.1.3" + "typescript": "5.1.3", + "vanilla-tilt": "^1.8.1" } } diff --git a/pages/points/participation.tsx b/pages/points/participation.tsx index 66fd151..c2db4d1 100644 --- a/pages/points/participation.tsx +++ b/pages/points/participation.tsx @@ -1,8 +1,8 @@ import { accessTokenState } from "@/atoms"; import { PresentationProps } from "@/const"; import { getPresentationsTalks, postObtainPointsParticipation } from "@/lib/http"; -import { Box, Button, Container, FormControl, InputLabel, MenuItem, Select, SelectChangeEvent, Typography } from "@mui/material"; -import { Html5QrcodeScanner } from "html5-qrcode"; +import { Box, Button, Container, FormControl, InputLabel, MenuItem, Select, SelectChangeEvent, TextField, Typography } from "@mui/material"; +import { Html5QrcodeScanner, Html5QrcodeScannerState } from "html5-qrcode"; import { useSession } from "next-auth/react"; import { useRouter } from 'next/router'; import { useSnackbar } from "notistack"; @@ -39,7 +39,7 @@ export default function ObtainPoints() { }); if (!response.error) { handleClean(); - } else { + } else if (html5QrcodeScanner.getState() === Html5QrcodeScannerState.PAUSED) { html5QrcodeScanner.resume(); } } @@ -47,7 +47,9 @@ export default function ObtainPoints() { setQR("") try { console.log(html5QrcodeScanner); - html5QrcodeScanner.resume() + if (html5QrcodeScanner.getState() === Html5QrcodeScannerState.PAUSED) { + html5QrcodeScanner.resume() + } } catch (e) { console.log(e) } @@ -119,6 +121,15 @@ export default function ObtainPoints() {

+ setQR(event.target.value)} + /> +
+
{qr !== "" && <> diff --git a/pages/points/special.tsx b/pages/points/special.tsx index 4a7ae31..b797e2d 100644 --- a/pages/points/special.tsx +++ b/pages/points/special.tsx @@ -1,8 +1,8 @@ import { accessTokenState } from "@/atoms"; import { SpecialPointProps } from "@/const"; import { getSpecialPoints, postObtainSpecialPoints } from "@/lib/http"; -import { Box, Button, Container, FormControl, InputLabel, MenuItem, Select, SelectChangeEvent, Typography } from "@mui/material"; -import { Html5QrcodeScanner } from "html5-qrcode"; +import { Box, Button, Container, FormControl, InputLabel, MenuItem, Select, SelectChangeEvent, TextField, Typography } from "@mui/material"; +import { Html5QrcodeScanner, Html5QrcodeScannerState } from "html5-qrcode"; import { useSession } from "next-auth/react"; import { useRouter } from 'next/router'; import { useSnackbar } from "notistack"; @@ -39,7 +39,7 @@ export default function ObtainPoints() { }); if (!response.error) { handleClean(); - } else { + } else if (html5QrcodeScanner.getState() === Html5QrcodeScannerState.PAUSED) { html5QrcodeScanner.resume(); } } @@ -47,7 +47,9 @@ export default function ObtainPoints() { setQR("") try { console.log(html5QrcodeScanner); - html5QrcodeScanner.resume() + if (html5QrcodeScanner.getState() === Html5QrcodeScannerState.PAUSED) { + html5QrcodeScanner.resume() + } } catch (e) { console.log(e) } @@ -119,6 +121,15 @@ export default function ObtainPoints() {

+ setQR(event.target.value)} + /> +
+
{qr !== "" && <> diff --git a/pages/self/acreditation.tsx b/pages/self/acreditation.tsx new file mode 100644 index 0000000..597363e --- /dev/null +++ b/pages/self/acreditation.tsx @@ -0,0 +1,171 @@ +import { SelfServiceParticipant } from "@/const"; +import { postSelfServiceRegistration } from "@/lib/http_self_service"; +import styles from "@/styles/hologram.module.css"; +import { Box, Button, Container, TextField, Typography } from "@mui/material"; +import DownloadIcon from "@mui/icons-material/Download"; +import { useSnackbar } from "notistack"; +import { useEffect, useRef, useState } from "react"; +import VanillaTilt, { type HTMLVanillaTiltElement, type TiltValues } from "vanilla-tilt"; + +export default function SelfRegistration() { + const [participant, setParticipant] = useState({} as SelfServiceParticipant) + const { enqueueSnackbar } = useSnackbar(); + const [ci, setCI] = useState("") + const [birthday, setBirthday] = useState(new Date().toISOString().split('T')[0]) + const cardRef = useRef>(null) + const [registered, setRegistered] = useState(false) + const [downloaded, setDownloaded] = useState(false) + const [downloading, setDownloading] = useState(false) + const [loading, setLoading] = useState(false) + const registrationPendingRef = useRef(false) + + const showCredential = participant.control_code !== undefined && !!participant.qr_code + + useEffect(() => { + const card = cardRef.current + if (!showCredential || !card) return + + const onTiltChange = (event: Event) => { + const { tiltX, tiltY } = (event as CustomEvent).detail + card.style.setProperty("--angle", `${Number(tiltX) + Number(tiltY)}deg`) + } + card.addEventListener("tiltChange", onTiltChange) + + VanillaTilt.init(card, { + reverse: true, + max: 20, + scale: 1.01, + speed: 400, + perspective: 1000, + gyroscope: true, + "full-page-listening": true, + glare: true, + "max-glare": 0.2, + + }) + + return () => { + card.removeEventListener("tiltChange", onTiltChange) + card.vanillaTilt?.destroy() + card.style.removeProperty("--angle") + } + }, [showCredential]) + + const handleDownloadQR = async () => { + const card = cardRef.current + if (!card || downloading) return + const fileName = `credencial-${participant.control_code}.png` + setDownloading(true) + try { + const { toPng } = await import("html-to-image") + const url = await toPng(card, { + backgroundColor: "#ffffff", + pixelRatio: 3, + skipFonts: true, + style: { + transform: "none", + transition: "none", + boxShadow: "none", + }, + }) + const link = document.createElement("a") + link.href = url + link.download = fileName + document.body.appendChild(link) + link.click() + link.remove() + setDownloaded(true) + } catch { + enqueueSnackbar("No se pudo descargar el QR", { variant: "error" }) + setDownloaded(false) + } finally { + setDownloading(false) + } + } + + + const handleSendRegister = async () => { + if (registrationPendingRef.current || registered) return + registrationPendingRef.current = true + setLoading(true) + try { + const response = await postSelfServiceRegistration({ ci: ci, birthday: birthday }); + enqueueSnackbar(response.message, { + variant: response.error ? "error" : "success", + }); + if (!response.error) { + setParticipant(response.data) + setRegistered(true) + setDownloaded(false) + } + } catch { + enqueueSnackbar("No se pudo realizar la acreditacion", { variant: "error" }) + } finally { + registrationPendingRef.current = false + setLoading(false) + } + } + const handleCleanRegister = (data: SelfServiceParticipant) => { + setCI("") + setBirthday(new Date().toISOString().split('T')[0]) + setParticipant(data) + setRegistered(false) + setDownloaded(false) + } + const handleCIChange = (event: React.ChangeEvent) => { + setCI(event.target.value); + }; + + return ( + + + Acreditación + +
+ + + +
+ + setBirthday(event.target.value)} + fullWidth + /> + +
+ +
+
+ + {showCredential && + <> +
+
+ Su código de control es: {participant.control_code} +
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {participant.control_code} +
+
+
+ + + } +
+ ) +} \ No newline at end of file diff --git a/pages/self/check/[code].tsx b/pages/self/check/[code].tsx new file mode 100644 index 0000000..9cb97f0 --- /dev/null +++ b/pages/self/check/[code].tsx @@ -0,0 +1,171 @@ +import { SelfServiceResult } from "@/const"; +import { getSelfServiceCheck, postSelfServiceCheck } from "@/lib/http_self_service"; +import { readQrControlCode } from "@/lib/qr"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline"; +import UploadFileIcon from "@mui/icons-material/UploadFile"; +import { + Alert, + Box, + Button, + CircularProgress, + Container, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Stack, + Typography, +} from "@mui/material"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { useSnackbar } from "notistack"; +import { type ChangeEvent, useEffect, useRef, useState } from "react"; + +export default function SelfParticipationCheck() { + const router = useRouter(); + const code = typeof router.query.code === "string" ? router.query.code : ""; + const { enqueueSnackbar } = useSnackbar(); + const [validating, setValidating] = useState(true); + const [validatedCode, setValidatedCode] = useState(null); + const [validationError, setValidationError] = useState<{ message: string, status?: number } | null>(null); + const [reading, setReading] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [fileName, setFileName] = useState(""); + const [controlCode, setControlCode] = useState(""); + const [result, setResult] = useState(null); + const pendingRef = useRef(false); + const currentCodeRef = useRef(code); + currentCodeRef.current = code; + + const valid = code !== "" && validatedCode === code && !validationError; + + useEffect(() => { + if (!router.isReady) return; + let active = true; + setValidating(true); + setValidatedCode(null); + setValidationError(null); + setControlCode(""); + setFileName(""); + setResult(null); + + const validate = async () => { + try { + if (!code) { + setValidationError({ message: "El código no existe o no está disponible.", status: 400 }); + return; + } + const response = await getSelfServiceCheck(code); + if (!active) return; + if (response.error) { + setValidationError({ message: response.message || "No se pudo validar el código.", status: response.status }); + } else { + setValidatedCode(code); + } + } catch { + if (active) setValidationError({ message: "No se pudo conectar con el servidor. Comprueba tu conexión e inténtalo de nuevo." }); + } finally { + if (active) setValidating(false); + } + }; + void validate(); + return () => { active = false; }; + }, [router.isReady, code]); + + const handleFileSelection = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file || !valid || pendingRef.current) return; + pendingRef.current = true; + setReading(true); + setControlCode(""); + setResult(null); + setFileName(file.name); + try { + const decoded = await readQrControlCode(file); + if (currentCodeRef.current !== code) return; + setControlCode(decoded); + } catch (error) { + if (currentCodeRef.current === code) { + setControlCode(""); + enqueueSnackbar(error instanceof Error ? error.message : "No se pudo leer la imagen QR.", { variant: "error" }); + } + } finally { + pendingRef.current = false; + setReading(false); + } + }; + + const handleRegister = async () => { + if (!valid || !controlCode || result || pendingRef.current) return; + pendingRef.current = true; + setSubmitting(true); + try { + const response = await postSelfServiceCheck(code, controlCode); + if (currentCodeRef.current !== code) return; + if (response.error) { + enqueueSnackbar(response.message || "No se pudo registrar la participación.", { variant: "error" }); + } else { + setResult(response.data); + } + } catch { + if (currentCodeRef.current === code) { + enqueueSnackbar("No se pudo conectar con el servidor. Puedes volver a intentarlo.", { variant: "error" }); + } + } finally { + pendingRef.current = false; + setSubmitting(false); + } + }; + + const handleCloseError = () => { void router.replace("/self"); }; + + return ( + + + Registrar participación + {validating && + + + Validando código... + + } + {valid && <> + Código de participación válido + + {reading && Leyendo QR...} + {fileName && {fileName}} + {controlCode && Código de control: {controlCode}} + + {result && + + {result.message || "Participación registrada"} + Código de control: {result.control_code} + Puntos: {result.points} + + } + } + + + + No se puede validar el código + + {validationError?.status === 400 && + El código no existe o no está disponible. + } + {validationError?.message} + + + + + + + ); +} diff --git a/pages/self/index.tsx b/pages/self/index.tsx new file mode 100644 index 0000000..e57beb1 --- /dev/null +++ b/pages/self/index.tsx @@ -0,0 +1,147 @@ +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + ButtonBase, + Container, + Typography, +} from "@mui/material"; +import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import HelpOutlineIcon from "@mui/icons-material/HelpOutline"; +import HowToRegIcon from "@mui/icons-material/HowToReg"; +import EventAvailableIcon from "@mui/icons-material/EventAvailable"; +import RedeemIcon from "@mui/icons-material/Redeem"; +import PointOfSaleIcon from '@mui/icons-material/PointOfSale'; +import Head from "next/head"; +import Link from "next/link"; +import { useState } from "react"; +import ParticipationDialog from "@/components/participation-dialog"; + +const frequentlyAskedQuestions = [ + { + question: "¿Qué necesito para acreditarme?", + answer: "Tu carnet de identidad y tu fecha de nacimiento. Ingresa ambos datos en el formulario de acreditación.", + }, + { + question: "¿Cómo obtengo mi credencial QR?", + answer: "Al completar la acreditación, aparecerán tu código de control y tu credencial QR. Puedes guardarla con el botón Descargar Credencial QR.", + }, + { + question: "¿Cómo obtengo los códigos o enlaces de participación?", + answer: "Los códigos y enlaces de participación se proporcionan durante las charlas y talleres. Usa el enlace recibido o ingresa el código en Participación y sube tu credencial QR para registrar tu participación y ganar puntos.", + }, + { + question: "¿Dónde puedo canjear mis puntos?", + answer: "En Canje de puntos puedes subir tu credencial QR, consultar tu saldo y seleccionar un producto disponible. Ingresa tu carnet de identidad para completar el canje. Debes tener los puntos necesarios para el producto elegido.", + }, + { + question: "¿Puedo canjear un regalo especial?", + answer: "Sí, durante el evento puedes canjear un regalo especial desde Canje de regalos, sujeto a disponibilidad. Sube tu credencial QR e ingresa tu carnet de identidad. El regalo especial está limitado a uno por persona.", + }, + { + question: "¿Qué hago si la acreditación falla?", + answer: "Comprueba tu carnet de identidad, tu fecha de nacimiento y tu conexión antes de volver a intentarlo. Si el problema continúa, escribe a hello@pylapaz.org.", + }, + { + question: "¿Dónde puedo pedir ayuda si tengo algún problema?", + answer: "Escribe a hello@pylapaz.org si tienes problemas con la acreditación, el registro de participación o el canje de productos y regalos.", + }, +]; + +const accessOptions = [ + { title: "Acreditación", description: "Tu credencial de acceso al evento para participar y ganar puntos", icon: HowToRegIcon, color: "#176b5b", background: "#eaf6f1", href: "/self/acreditation" }, + { title: "Participación", description: "Registra tu participación con el código de la actividad", icon: EventAvailableIcon, color: "#215b8c", background: "#eaf2fa", href: undefined }, + + { title: "Canje de puntos", description: "Canjea tus puntos por premios", icon: PointOfSaleIcon, color: "#9a4b15", background: "#fff3e2", href: "/self/redeem/reward" }, + { title: "Canje de regalos", description: "Canjea tu regalo especial", icon: RedeemIcon, color: "#6b3a9a", background: "#f3e8ff", href: "/self/redeem/gift" } +]; + +export default function SelfHome() { + const [participationOpen, setParticipationOpen] = useState(false); + + const handleOpenParticipation = () => { + setParticipationOpen(true); + }; + + return ( + <> + + PyDay La Paz | Autoservicio + + + + + + PyDay La Paz + + + Autoservicio para participantes + + + + + + {accessOptions.map(({ title, description, icon: Icon, color, background, href }) => ( + + + + + + {title} + {description} + + + + ))} + + + + + Preguntas frecuentes + + {frequentlyAskedQuestions.map(({ question, answer }, index) => ( + + } id={`help-question-${index}`} aria-controls={`help-answer-${index}`} sx={{ px: 0, minHeight: 60 }}> + {question} + + + {answer} + + + ))} + + + + setParticipationOpen(false)} /> + + ); +} diff --git a/pages/self/redeem/gift.tsx b/pages/self/redeem/gift.tsx new file mode 100644 index 0000000..4dd4bd9 --- /dev/null +++ b/pages/self/redeem/gift.tsx @@ -0,0 +1,157 @@ +import { SelfServiceRedeem } from "@/const"; +import { getSelfServiceGift, postSelfServiceRedeemGift } from "@/lib/http_self_service"; +import { readQrControlCode } from "@/lib/qr"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import DownloadIcon from "@mui/icons-material/Download"; +import RedeemIcon from "@mui/icons-material/Redeem"; +import UploadFileIcon from "@mui/icons-material/UploadFile"; +import { + Alert, + Box, + Button, + CircularProgress, + Container, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Stack, + TextField, + Typography, +} from "@mui/material"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { useSnackbar } from "notistack"; +import { type ChangeEvent, useEffect, useRef, useState } from "react"; + +function safeDownloadUrl(value: string): string | undefined { + if (!value?.trim()) return; + try { + const url = new URL(value.trim(), "https://localhost"); + if (url.protocol === "https:" || url.protocol === "http:") return value.trim(); + } catch {} +} + +export default function SelfGiftRedeem() { + const router = useRouter(); + const { enqueueSnackbar } = useSnackbar(); + const [checking, setChecking] = useState(true); + const [available, setAvailable] = useState(false); + const [availabilityError, setAvailabilityError] = useState(null); + const [reading, setReading] = useState(false); + const [redeeming, setRedeeming] = useState(false); + const [fileName, setFileName] = useState(""); + const [controlCode, setControlCode] = useState(""); + const [ci, setCI] = useState(""); + const [result, setResult] = useState(null); + const pendingRef = useRef(false); + const busy = checking || reading || redeeming; + const canRedeem = available && !busy && !!controlCode && !!ci.trim() && !result; + const downloadUrl = result ? safeDownloadUrl(result.download_url) : undefined; + + useEffect(() => { + let active = true; + getSelfServiceGift().then(response => { + if (!active) return; + if (response.error) { + setAvailabilityError(response.message || "No hay un regalo disponible."); + } else { + setAvailable(true); + } + }).catch(() => { + if (active) setAvailabilityError("No se pudo conectar con el servidor para comprobar la disponibilidad."); + }).finally(() => { + if (active) setChecking(false); + }); + return () => { active = false; }; + }, []); + + const handleFileSelection = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file || !available || busy || result || pendingRef.current) return; + pendingRef.current = true; + setReading(true); + setControlCode(""); + setCI(""); + setFileName(file.name); + try { + setControlCode(await readQrControlCode(file)); + } catch (error) { + enqueueSnackbar(error instanceof Error ? error.message : "No se pudo leer la imagen QR.", { variant: "error" }); + } finally { + pendingRef.current = false; + setReading(false); + } + }; + + const handleRedeem = async () => { + if (!canRedeem || pendingRef.current) return; + pendingRef.current = true; + setRedeeming(true); + try { + const response = await postSelfServiceRedeemGift({ control_code: controlCode, ci: ci.trim() }); + if (response.error) { + enqueueSnackbar(response.message || "No se pudo canjear el regalo.", { variant: "error" }); + } else { + setResult(response.data); + } + } catch { + enqueueSnackbar("No se pudo confirmar el canje. Verifica con el equipo del evento antes de volver a intentarlo.", { variant: "error" }); + } finally { + pendingRef.current = false; + setRedeeming(false); + } + }; + + const handleCloseError = () => { void router.replace("/self"); }; + + return ( + + + Canje de regalo + {checking && + + + Comprobando disponibilidad... + + } + {available && <> + {!result && Hay un regalo disponible} + + {fileName && {fileName}} + {controlCode && Código de control: {controlCode}} + setCI(event.target.value)} disabled={busy || !controlCode || !!result} required fullWidth /> + + {result && + + + {result.message || "Regalo canjeado"} + {result.product_name || "Tu regalo"} + {result.discount_code && } + {downloadUrl && } + + + } + } + + + + Regalo no disponible + + No se pudo comprobar la disponibilidad del regalo. Si está agotado o no existe, no se puede realizar el canje. + {availabilityError} + + + + + + + ); +} diff --git a/pages/self/redeem/reward.tsx b/pages/self/redeem/reward.tsx new file mode 100644 index 0000000..b85207d --- /dev/null +++ b/pages/self/redeem/reward.tsx @@ -0,0 +1,228 @@ +import { SelfServiceGift, SelfServiceRedeem } from "@/const"; +import { getSelfServiceRewards, postSelfServicePoints, postSelfServiceRedeemReward } from "@/lib/http_self_service"; +import { readQrControlCode } from "@/lib/qr"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import DownloadIcon from "@mui/icons-material/Download"; +import RedeemIcon from "@mui/icons-material/Redeem"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import UploadFileIcon from "@mui/icons-material/UploadFile"; +import { + Alert, + Box, + Button, + CircularProgress, + Container, + FormControl, + InputLabel, + MenuItem, + Select, + Stack, + TextField, + Typography, +} from "@mui/material"; +import Link from "next/link"; +import { useSnackbar } from "notistack"; +import { type ChangeEvent, useEffect, useRef, useState } from "react"; + +function safeDownloadUrl(value: string): string | undefined { + if (!value?.trim()) return; + try { + const url = new URL(value.trim(), "https://localhost"); + if (url.protocol === "https:" || url.protocol === "http:") return value.trim(); + } catch {} +} + +export default function SelfRewardRedeem() { + const { enqueueSnackbar } = useSnackbar(); + const [rewards, setRewards] = useState([]); + const [loadingRewards, setLoadingRewards] = useState(true); + const [catalogError, setCatalogError] = useState(""); + const [reading, setReading] = useState(false); + const [redeeming, setRedeeming] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [fileName, setFileName] = useState(""); + const [controlCode, setControlCode] = useState(""); + const [points, setPoints] = useState(null); + const [ci, setCI] = useState(""); + const [giftId, setGiftId] = useState(""); + const [redemptions, setRedemptions] = useState([]); + const [redeemedGiftIds, setRedeemedGiftIds] = useState([]); + const pendingRef = useRef(false); + const availableRewards = rewards.filter(reward => reward.stock === null || reward.stock > 0); + const selectedReward = availableRewards.find(reward => reward._id === giftId); + const busy = loadingRewards || reading || redeeming || refreshing; + const alreadyRedeemed = !!selectedReward && !selectedReward.multiple && redeemedGiftIds.includes(selectedReward._id); + const insufficientPoints = !!selectedReward && points !== null && points < selectedReward.points; + const canRedeem = !busy && !!controlCode && !!ci.trim() && points !== null && !!selectedReward && !insufficientPoints && !alreadyRedeemed; + + useEffect(() => { + let active = true; + getSelfServiceRewards().then(response => { + if (!active) return; + if (response.error) { + setCatalogError(response.message || "No se pudieron cargar los premios."); + } else { + setRewards(response.data); + } + }).catch(() => { + if (active) setCatalogError("No se pudo conectar con el servidor para cargar los premios."); + }).finally(() => { + if (active) setLoadingRewards(false); + }); + return () => { active = false; }; + }, []); + + const refreshData = async (participantCode: string) => { + setPoints(null); + setCatalogError(""); + try { + const [balanceResponse, rewardsResponse] = await Promise.all([ + participantCode ? postSelfServicePoints(participantCode) : Promise.resolve(null), + getSelfServiceRewards(), + ]); + if (balanceResponse?.error) { + enqueueSnackbar(balanceResponse.message || "No se pudo actualizar el saldo.", { variant: "error" }); + } else if (balanceResponse) { + setPoints(balanceResponse.data.points); + } + if (rewardsResponse.error) { + setRewards([]); + setCatalogError(rewardsResponse.message || "No se pudieron actualizar los premios."); + } else { + setRewards(rewardsResponse.data); + } + } catch { + setPoints(null); + setRewards([]); + setCatalogError("No se pudieron actualizar el saldo y los premios. Actualiza los datos antes de volver a canjear."); + } + }; + + const handleFileSelection = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file || pendingRef.current || busy) return; + pendingRef.current = true; + setReading(true); + setControlCode(""); + setPoints(null); + setCI(""); + setGiftId(""); + setRedemptions([]); + setRedeemedGiftIds([]); + setFileName(file.name); + try { + const decoded = await readQrControlCode(file); + const response = await postSelfServicePoints(decoded); + if (response.error) { + enqueueSnackbar(response.message || "No se pudieron consultar los puntos.", { variant: "error" }); + return; + } + setControlCode(decoded); + setPoints(response.data.points); + } catch (error) { + enqueueSnackbar(error instanceof Error ? error.message : "No se pudo leer el QR ni consultar los puntos.", { variant: "error" }); + } finally { + pendingRef.current = false; + setReading(false); + } + }; + + const handleRefresh = async () => { + if (pendingRef.current || busy) return; + pendingRef.current = true; + setRefreshing(true); + try { + await refreshData(controlCode); + } finally { + pendingRef.current = false; + setRefreshing(false); + } + }; + + const handleRedeem = async () => { + if (!canRedeem || !selectedReward || pendingRef.current) return; + pendingRef.current = true; + setRedeeming(true); + try { + const response = await postSelfServiceRedeemReward({ control_code: controlCode, ci: ci.trim(), gift: selectedReward._id }); + if (response.error) { + enqueueSnackbar(response.message || "No se pudo canjear el premio.", { variant: "error" }); + await refreshData(controlCode); + return; + } + setRedemptions(previous => [response.data, ...previous]); + setRedeemedGiftIds(previous => [...previous, selectedReward._id]); + setGiftId(""); + enqueueSnackbar(response.message || "Premio canjeado", { variant: "success" }); + await refreshData(controlCode); + } catch { + setPoints(null); + enqueueSnackbar("No se pudo confirmar el canje. Verifica con el equipo del evento antes de volver a intentarlo.", { variant: "error" }); + } finally { + pendingRef.current = false; + setRedeeming(false); + } + }; + + return ( + + + Canje de premios + + {fileName && {fileName}} + {controlCode && + + Código de control: {controlCode} + Puntos disponibles + {points === null ? "Pendiente de actualizar" : points} + + } + {loadingRewards && Cargando premios...} + {catalogError && {catalogError}} + {!busy && !catalogError && availableRewards.length === 0 && No hay premios disponibles en stock.} + + Premio + + + {selectedReward && + {selectedReward.description && {selectedReward.description}} + Stock: {selectedReward.stock === null ? "ilimitado" : selectedReward.stock} | {selectedReward.multiple ? "Canje múltiple" : "Un canje por participante"} + } + {insufficientPoints && selectedReward && points !== null && Te faltan {selectedReward.points - points} puntos para este premio.} + {alreadyRedeemed && Ya canjeaste este premio.} + setCI(event.target.value)} disabled={busy || !controlCode} fullWidth required /> + + + {redemptions.length > 0 && + + Premios canjeados + {redemptions.map((redemption, index) => { + const downloadUrl = safeDownloadUrl(redemption.download_url); + return + {redemption.product_name} + {redemption.message && {redemption.message}} + {redemption.discount_code && } + {downloadUrl && } + ; + })} + + } + + + + ); +} diff --git a/public/banner.png b/public/banner.png new file mode 100644 index 0000000..3df83fd Binary files /dev/null and b/public/banner.png differ diff --git a/styles/hologram.module.css b/styles/hologram.module.css new file mode 100644 index 0000000..c2568a1 --- /dev/null +++ b/styles/hologram.module.css @@ -0,0 +1,123 @@ +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + grid-gap: 4rem; + max-width: 360px; + margin: 1rem auto; +} + +.card { + --angle: 0deg; + position: relative; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + aspect-ratio: 1 / 1; + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.2); + transform: scale(1); + border-radius: 0.25rem; + overflow: hidden; + transition: box-shadow 0.3s ease-in-out; + /* glow effect */ + background: conic-gradient( + from var(--angle), + rgba(255, 255, 255, 1), + rgba(255, 255, 255, 0), + rgba(255, 255, 255, 1), + rgba(255, 255, 255, 0), + rgba(255, 255, 255, 1), + rgba(255, 255, 255, 0), + rgba(255, 255, 255, 1), + rgba(255, 255, 255, 0), + rgba(255, 255, 255, 1), + rgba(255, 255, 255, 1), + rgba(255, 255, 255, 0), + rgba(255, 255, 255, 1), + rgba(255, 255, 255, 0), + rgba(255, 255, 255, 1), + rgba(255, 255, 255, 0), + rgba(255, 255, 255, 1), + rgba(255, 255, 255, 0), + rgba(255, 255, 255, 1) + ); +} + +.card:hover { + box-shadow: 0 0.25rem 2rem rgba(0, 0, 0, 0.2); +} + +.card::after { + content: ""; + display: block; + height: 100%; + position: absolute; + top: 0; + left: 0; + width: 100%; + pointer-events: none; + /* texture and color effect */ + background: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="100" height="18" viewBox="0 0 100 18"%3E%3Cpath fill="%239C92AC" fill-opacity="0.4" d="M61.82 18c3.47-1.45 6.86-3.78 11.3-7.34C78 6.76 80.34 5.1 83.87 3.42 88.56 1.16 93.75 0 100 0v6.16C98.76 6.05 97.43 6 96 6c-9.59 0-14.23 2.23-23.13 9.34-1.28 1.03-2.39 1.9-3.4 2.66h-7.65zm-23.64 0H22.52c-1-.76-2.1-1.63-3.4-2.66C11.57 9.3 7.08 6.78 0 6.16V0c6.25 0 11.44 1.16 16.14 3.42 3.53 1.7 5.87 3.35 10.73 7.24 4.45 3.56 7.84 5.9 11.31 7.34zM61.82 0h7.66a39.57 39.57 0 0 1-7.34 4.58C57.44 6.84 52.25 8 46 8S34.56 6.84 29.86 4.58A39.57 39.57 0 0 1 22.52 0h15.66C41.65 1.44 45.21 2 50 2c4.8 0 8.35-.56 11.82-2z"%3E%3C/path%3E%3C/svg%3E'), + conic-gradient( + from var(--angle), + hsl(0, 100%, 50%), + hsl(10, 100%, 50%), + hsl(20, 100%, 50%), + hsl(30, 100%, 50%), + hsl(40, 100%, 50%), + hsl(50, 100%, 50%), + hsl(60, 100%, 50%), + hsl(70, 100%, 50%), + hsl(80, 100%, 50%), + hsl(90, 100%, 50%), + hsl(100, 100%, 50%), + hsl(110, 100%, 50%), + hsl(120, 100%, 50%), + hsl(130, 100%, 50%), + hsl(140, 100%, 50%), + hsl(150, 100%, 50%), + hsl(160, 100%, 50%), + hsl(170, 100%, 50%), + hsl(180, 100%, 50%), + hsl(190, 100%, 50%), + hsl(200, 100%, 50%), + hsl(210, 100%, 50%), + hsl(220, 100%, 50%), + hsl(230, 100%, 50%), + hsl(240, 100%, 50%), + hsl(250, 100%, 50%), + hsl(260, 100%, 50%), + hsl(270, 100%, 50%), + hsl(280, 100%, 50%), + hsl(290, 100%, 50%), + hsl(300, 100%, 50%), + hsl(310, 100%, 50%), + hsl(320, 100%, 50%), + hsl(330, 100%, 50%), + hsl(340, 100%, 50%), + hsl(350, 100%, 50%), + hsl(360, 100%, 50%) + ); + opacity: 0.05; + mix-blend-mode: exclusion; +} + +.cardContent { + text-align: center; + padding: 1rem; + height: 100%; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + /* realistic appearance for content */ + filter: grayscale(1); + opacity: 0.95; + mix-blend-mode: hard-light; +} + +.cardContent img { + height: 100%; + width: 100%; + object-fit: contain; +}