From 1e6071a5831caeba5abb0db8e483d7bfef695b74 Mon Sep 17 00:00:00 2001 From: twinik Date: Sun, 2 Aug 2026 15:58:30 -0300 Subject: [PATCH 01/12] update pnpm and added tests --- __tests__/api/routes.test.ts | 121 +++++ __tests__/lib/api.test.ts | 81 +++ __tests__/lib/utils.test.ts | 51 ++ __tests__/proxy.test.ts | 56 +++ __tests__/schemas/validation.test.ts | 111 ++++ package.json | 7 +- pnpm-lock.yaml | 727 +++++++++++++++++++++++++-- vitest.config.mts | 12 + 8 files changed, 1136 insertions(+), 30 deletions(-) create mode 100644 __tests__/api/routes.test.ts create mode 100644 __tests__/lib/api.test.ts create mode 100644 __tests__/lib/utils.test.ts create mode 100644 __tests__/proxy.test.ts create mode 100644 __tests__/schemas/validation.test.ts create mode 100644 vitest.config.mts diff --git a/__tests__/api/routes.test.ts b/__tests__/api/routes.test.ts new file mode 100644 index 0000000..e11c164 --- /dev/null +++ b/__tests__/api/routes.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextRequest } from 'next/server' + +vi.mock('@/lib/auth0', () => ({ + auth0: { + getAccessToken: vi.fn(), + getSession: vi.fn(), + }, +})) + +vi.mock('@/lib/api', () => ({ + fetchBackend: vi.fn(), +})) + +import { GET as getDevToken } from '@/app/api/dev-token/route' +import { GET as getUser } from '@/app/api/user/route' +import { GET as getApplications, POST as createApplication } from '@/app/api/applications/route' +import { auth0 } from '@/lib/auth0' +import { fetchBackend } from '@/lib/api' + +describe('app/api Routes (Endpoints Internos)', () => { + const originalEnv = process.env.VERCEL_ENV + + beforeEach(() => { + vi.resetAllMocks() + delete process.env.VERCEL_ENV + }) + + afterEach(() => { + if (originalEnv !== undefined) { + process.env.VERCEL_ENV = originalEnv + } else { + delete process.env.VERCEL_ENV + } + }) + + describe('GET /api/dev-token', () => { + it('should return 404 if running on Vercel environment', async () => { + process.env.VERCEL_ENV = 'production' + const res = await getDevToken() + expect(res.status).toBe(404) + }) + + it('should return 401 if access token is not available', async () => { + vi.mocked(auth0.getAccessToken).mockResolvedValueOnce(null as any) + const res = await getDevToken() + expect(res.status).toBe(401) + const body = await res.json() + expect(body.error).toBe('No access token available') + }) + + it('should return token if user is authenticated locally', async () => { + vi.mocked(auth0.getAccessToken).mockResolvedValueOnce('secret-dev-token' as any) + const res = await getDevToken() + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toBe('secret-dev-token') + }) + }) + + describe('GET /api/user', () => { + it('should return 401 if no active session', async () => { + vi.mocked(auth0.getSession).mockResolvedValueOnce(null as any) + const res = await getUser() + expect(res.status).toBe(401) + const body = await res.json() + expect(body.error).toBe('Not authenticated') + }) + + it('should return user session data when authenticated', async () => { + const mockUser = { sub: 'auth0|123', name: 'John Doe', email: 'john@example.com' } + vi.mocked(auth0.getSession).mockResolvedValueOnce({ user: mockUser } as any) + + const res = await getUser() + expect(res.status).toBe(200) + const body = await res.json() + expect(body.user).toEqual(mockUser) + }) + }) + + describe('/api/applications', () => { + it('GET should call fetchBackend with pagination params and return backend json', async () => { + const mockBackendResponse = new Response( + JSON.stringify({ content: [{ id: 'app-1', name: 'payment-sync' }] }), + { status: 200 } + ) + vi.mocked(fetchBackend).mockResolvedValueOnce(mockBackendResponse) + + const req = new NextRequest('http://localhost:3000/api/applications?page=0&size=10') + const res = await getApplications(req) + + expect(res.status).toBe(200) + expect(fetchBackend).toHaveBeenCalledWith('/v1/applications?page=0&size=10&sort=name%2Casc') + const body = await res.json() + expect(body.content).toHaveLength(1) + }) + + it('POST should forward payload to backend and return created application', async () => { + const payload = { name: 'new-app', description: 'Test app' } + const mockBackendResponse = new Response( + JSON.stringify({ id: 'app-99', ...payload }), + { status: 201 } + ) + vi.mocked(fetchBackend).mockResolvedValueOnce(mockBackendResponse) + + const req = new NextRequest('http://localhost:3000/api/applications', { + method: 'POST', + body: JSON.stringify(payload), + }) + const res = await createApplication(req) + + expect(res.status).toBe(200) + expect(fetchBackend).toHaveBeenCalledWith('/v1/applications', { + method: 'POST', + body: JSON.stringify(payload), + }) + const body = await res.json() + expect(body.id).toBe('app-99') + }) + }) +}) diff --git a/__tests__/lib/api.test.ts b/__tests__/lib/api.test.ts new file mode 100644 index 0000000..d591e6c --- /dev/null +++ b/__tests__/lib/api.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +vi.mock('@/lib/auth0', () => ({ + auth0: { + getAccessToken: vi.fn(), + }, +})) + +import { getBackendHeaders, fetchBackend } from '@/lib/api' +import { auth0 } from '@/lib/auth0' + +describe('lib/api', () => { + const originalFetch = global.fetch + + beforeEach(() => { + vi.resetAllMocks() + global.fetch = vi.fn() + }) + + afterEach(() => { + global.fetch = originalFetch + }) + + describe('getBackendHeaders', () => { + it('should return headers with Bearer token when token is available', async () => { + vi.mocked(auth0.getAccessToken).mockResolvedValueOnce({ token: 'mock-token-123' } as any) + + const headers = await getBackendHeaders() + expect(headers).toEqual({ + 'Content-Type': 'application/json', + Authorization: 'Bearer mock-token-123', + }) + }) + + it('should throw Unauthorized error when token is missing', async () => { + vi.mocked(auth0.getAccessToken).mockResolvedValueOnce(null as any) + + await expect(getBackendHeaders()).rejects.toThrow('Unauthorized: Could not retrieve access token') + }) + + it('should throw error when getAccessToken rejects', async () => { + vi.mocked(auth0.getAccessToken).mockRejectedValueOnce(new Error('Auth error')) + + await expect(getBackendHeaders()).rejects.toThrow('Unauthorized: Could not retrieve access token') + }) + }) + + describe('fetchBackend', () => { + it('should call fetch with backend URL and authorization headers', async () => { + vi.mocked(auth0.getAccessToken).mockResolvedValueOnce({ token: 'mock-token-abc' } as any) + const mockResponse = new Response(JSON.stringify({ ok: true }), { status: 200 }) + vi.mocked(global.fetch).mockResolvedValueOnce(mockResponse) + + const response = await fetchBackend('/v1/test', { method: 'POST' }) + + expect(global.fetch).toHaveBeenCalledTimes(1) + const [url, init] = vi.mocked(global.fetch).mock.calls[0] + expect(url).toContain('/v1/test') + expect(init?.method).toBe('POST') + + const headers = init?.headers as Headers + expect(headers.get('Authorization')).toBe('Bearer mock-token-abc') + expect(headers.get('Content-Type')).toBe('application/json') + expect(response).toBe(mockResponse) + }) + + it('should merge custom headers properly', async () => { + vi.mocked(auth0.getAccessToken).mockResolvedValueOnce({ token: 'mock-token-xyz' } as any) + vi.mocked(global.fetch).mockResolvedValueOnce(new Response('{}', { status: 200 })) + + await fetchBackend('/v1/custom', { + headers: { 'X-Custom-Header': 'CustomValue' }, + }) + + const [, init] = vi.mocked(global.fetch).mock.calls[0] + const headers = init?.headers as Headers + expect(headers.get('X-Custom-Header')).toBe('CustomValue') + expect(headers.get('Authorization')).toBe('Bearer mock-token-xyz') + }) + }) +}) diff --git a/__tests__/lib/utils.test.ts b/__tests__/lib/utils.test.ts new file mode 100644 index 0000000..0275d97 --- /dev/null +++ b/__tests__/lib/utils.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest' +import { cn, getEnvColors } from '@/lib/utils' + +describe('lib/utils', () => { + describe('cn', () => { + it('should merge class names correctly', () => { + expect(cn('bg-red-500', 'text-white')).toBe('bg-red-500 text-white') + }) + + it('should handle conditional classes', () => { + expect(cn('base-class', true && 'active', false && 'disabled')).toBe('base-class active') + }) + + it('should resolve tailwind conflicts using tailwind-merge', () => { + expect(cn('px-2 py-1', 'p-4')).toBe('p-4') + expect(cn('text-red-500', 'text-blue-500')).toBe('text-blue-500') + }) + }) + + describe('getEnvColors', () => { + it('should return dev colors for dev/development or unknown environment', () => { + const devColors = getEnvColors('dev') + expect(devColors.dot).toBe('bg-blue-500') + expect(devColors.text).toBe('text-blue-400') + + const developmentColors = getEnvColors('DEVELOPMENT') + expect(developmentColors.dot).toBe('bg-blue-500') + + const nullColors = getEnvColors(null) + expect(nullColors.dot).toBe('bg-blue-500') + }) + + it('should return staging colors for staging/stage environment', () => { + const stagingColors = getEnvColors('staging') + expect(stagingColors.dot).toBe('bg-yellow-500') + expect(stagingColors.text).toBe('text-yellow-400') + + const stageColors = getEnvColors('STAGE') + expect(stageColors.dot).toBe('bg-yellow-500') + }) + + it('should return prod colors for prod/production environment', () => { + const prodColors = getEnvColors('prod') + expect(prodColors.dot).toBe('bg-green-500') + expect(prodColors.text).toBe('text-green-400') + + const productionColors = getEnvColors('PRODUCTION') + expect(productionColors.dot).toBe('bg-green-500') + }) + }) +}) diff --git a/__tests__/proxy.test.ts b/__tests__/proxy.test.ts new file mode 100644 index 0000000..23d13da --- /dev/null +++ b/__tests__/proxy.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextRequest, NextResponse } from 'next/server' + +vi.mock('@/lib/auth0', () => ({ + auth0: { + middleware: vi.fn((req: any) => NextResponse.next()), + getSession: vi.fn(), + }, +})) + +import { proxy } from '@/proxy' +import { auth0 } from '@/lib/auth0' + +describe('proxy.ts (Middleware de Red y Seguridad)', () => { + beforeEach(() => { + vi.resetAllMocks() + vi.mocked(auth0.middleware).mockImplementation(() => NextResponse.next()) + }) + + it('should redirect unauthenticated users accessing /dashboard to /auth/login', async () => { + vi.mocked(auth0.getSession).mockResolvedValueOnce(null) + + const req = new NextRequest('http://localhost:3000/dashboard/applications') + const res = await proxy(req) + + expect(res.status).toBe(307) // Next.js NextResponse.redirect default status + expect(res.headers.get('location')).toBe('http://localhost:3000/auth/login') + }) + + it('should allow authenticated users accessing /dashboard', async () => { + vi.mocked(auth0.getSession).mockResolvedValueOnce({ user: { sub: 'user-123' } } as any) + + const req = new NextRequest('http://localhost:3000/dashboard/applications') + const res = await proxy(req) + + expect(res.headers.get('location')).toBeNull() + }) + + it('should redirect unauthenticated users accessing /accept-invite to /auth/login with returnTo', async () => { + vi.mocked(auth0.getSession).mockResolvedValueOnce(null) + + const req = new NextRequest('http://localhost:3000/accept-invite?token=invite-token-abc') + const res = await proxy(req) + + expect(res.status).toBe(307) + const expectedReturnTo = encodeURIComponent('/accept-invite?token=invite-token-abc') + expect(res.headers.get('location')).toBe(`http://localhost:3000/auth/login?returnTo=${expectedReturnTo}`) + }) + + it('should pass through requests for public routes like landing page', async () => { + const req = new NextRequest('http://localhost:3000/') + const res = await proxy(req) + + expect(res.headers.get('location')).toBeNull() + }) +}) diff --git a/__tests__/schemas/validation.test.ts b/__tests__/schemas/validation.test.ts new file mode 100644 index 0000000..3977c81 --- /dev/null +++ b/__tests__/schemas/validation.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from 'vitest' +import * as z from 'zod' + +// Resource form schema rules +const resourceSchema = z.object({ + name: z.string().min(2).regex(/^[a-z0-9_]+$/), + description: z.string().optional(), + mode: z.enum(["unit", "multiple"]), + ttl: z.coerce.number().min(1), + saveMetadata: z.boolean().default(false), + conflictStrategy: z.enum(["fail", "retry", "queue"]), + retryInterval: z.coerce.number().min(1).max(10).optional(), + maxRetries: z.coerce.number().min(1).max(5).optional(), + idempotency: z.boolean().default(false), + notificationWebhookUrl: z.string().max(255).optional().or(z.literal("")), +}) + +// Lock form schema rules +const lockSchema = z.object({ + namespace: z.string().min(2).regex(/^[a-z0-9_]+$/), + description: z.string().optional(), + type: z.enum(["exclusive", "read-write"]), + ttl: z.coerce.number().min(1), + deadlockStrategy: z.enum(["alert", "kill"]), + webhookUrl: z.string().url().optional().or(z.literal('')), + acquisitionStrategy: z.enum(["fail", "retry", "blocking"]), + retryInterval: z.coerce.number().min(10).optional(), + maxRetries: z.coerce.number().min(1).optional(), + requireFencingToken: z.boolean().default(false), +}) + +describe('Validation Schemas (Reglas de Negocio Zod)', () => { + describe('Resource Form Schema', () => { + it('should pass with valid resource payload', () => { + const validPayload = { + name: 'seat_reservation_v1', + mode: 'multiple', + ttl: 300, + conflictStrategy: 'retry', + retryInterval: 2, + maxRetries: 3, + } + const result = resourceSchema.safeParse(validPayload) + expect(result.success).toBe(true) + }) + + it('should reject names with uppercase or special characters', () => { + const invalidPayload = { + name: 'Seat-Reservation!', + mode: 'unit', + ttl: 10, + conflictStrategy: 'fail', + } + const result = resourceSchema.safeParse(invalidPayload) + expect(result.success).toBe(false) + }) + + it('should reject TTL less than 1 second', () => { + const invalidPayload = { + name: 'valid_name', + mode: 'unit', + ttl: 0, + conflictStrategy: 'fail', + } + const result = resourceSchema.safeParse(invalidPayload) + expect(result.success).toBe(false) + }) + }) + + describe('Lock Form Schema', () => { + it('should pass with valid lock payload', () => { + const validPayload = { + namespace: 'payment_sync_lock', + type: 'exclusive', + ttl: 60, + deadlockStrategy: 'alert', + acquisitionStrategy: 'retry', + retryInterval: 15, + maxRetries: 3, + requireFencingToken: true, + } + const result = lockSchema.safeParse(validPayload) + expect(result.success).toBe(true) + }) + + it('should reject invalid lock namespace formats', () => { + const invalidPayload = { + namespace: 'Payment Sync', + type: 'exclusive', + ttl: 60, + deadlockStrategy: 'alert', + acquisitionStrategy: 'fail', + } + const result = lockSchema.safeParse(invalidPayload) + expect(result.success).toBe(false) + }) + + it('should reject invalid webhook URL', () => { + const invalidPayload = { + namespace: 'valid_lock', + type: 'exclusive', + ttl: 60, + deadlockStrategy: 'alert', + acquisitionStrategy: 'fail', + webhookUrl: 'not-a-valid-url', + } + const result = lockSchema.safeParse(invalidPayload) + expect(result.success).toBe(false) + }) + }) +}) diff --git a/package.json b/package.json index ecad831..47f675d 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,14 @@ { "name": "caerus-fe", "version": "0.4.3", + "packageManager": "pnpm@11.18.0", "private": true, "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "eslint .", + "test": "vitest run", "deploy:preview": "vercel", "deploy:prod": "node scripts/deploy-prod.js" }, @@ -40,8 +42,8 @@ "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", - "@vercel/speed-insights": "^2.0.0", "@vercel/analytics": "^2.0.1", + "@vercel/speed-insights": "^2.0.0", "autoprefixer": "^10.4.20", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -71,6 +73,7 @@ "postcss": "^8.5", "tailwindcss": "^4.2.0", "tw-animate-css": "1.3.3", - "typescript": "5.7.3" + "typescript": "5.7.3", + "vitest": "^4.1.10" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 233d470..a9d40b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -186,6 +186,9 @@ importers: typescript: specifier: 5.7.3 version: 5.7.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@24.10.4)(vite@8.2.0(@types/node@24.10.4)(jiti@2.6.1)) packages: @@ -211,9 +214,18 @@ packages: resolution: {integrity: sha512-Sd8LcWpZk/SWEeKGE8LT6gMm5MGfX/wm+GPnh1eBEtCpya3vYqn37wYknwAHw92ONoyyREl1hJwxV/Qx2DWNOg==} engines: {node: '>=16'} + '@emnapi/core@2.0.0-alpha.3': + resolution: {integrity: sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==} + '@emnapi/runtime@1.8.1': resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} + '@emnapi/runtime@2.0.0-alpha.3': + resolution: {integrity: sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==} + + '@emnapi/wasi-threads@2.0.1': + resolution: {integrity: sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==} + '@floating-ui/core@1.7.4': resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} @@ -264,105 +276,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -403,6 +399,13 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@napi-rs/wasm-runtime@1.2.2': + resolution: {integrity: sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 + '@next/env@16.2.6': resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==} @@ -423,28 +426,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-musl@16.2.6': resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-x64-gnu@16.2.6': resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-musl@16.2.6': resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@next/swc-win32-arm64-msvc@16.2.6': resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==} @@ -458,6 +457,9 @@ packages: cpu: [x64] os: [win32] + '@oxc-project/types@0.142.0': + resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} + '@panva/hkdf@1.2.1': resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} @@ -1117,6 +1119,100 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@rolldown/binding-android-arm64@1.2.1': + resolution: {integrity: sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.1': + resolution: {integrity: sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.1': + resolution: {integrity: sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.1': + resolution: {integrity: sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.1': + resolution: {integrity: sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.1': + resolution: {integrity: sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.2.1': + resolution: {integrity: sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.2.1': + resolution: {integrity: sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.2.1': + resolution: {integrity: sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.2.1': + resolution: {integrity: sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.2.1': + resolution: {integrity: sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.2.1': + resolution: {integrity: sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.2.1': + resolution: {integrity: sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + + '@rolldown/binding-win32-arm64-msvc@1.2.1': + resolution: {integrity: sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.1': + resolution: {integrity: sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -1158,28 +1254,24 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.0': resolution: {integrity: sha512-XKcSStleEVnbH6W/9DHzZv1YhjE4eSS6zOu2eRtYAIh7aV4o3vIBs+t/B15xlqoxt6ef/0uiqJVB6hkHjWD/0A==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.0': resolution: {integrity: sha512-/hlXCBqn9K6fi7eAM0RsobHwJYa5V/xzWspVTzxnX+Ft9v6n+30Pz8+RxCn7sQL/vRHHLS30iQPrHQunu6/vJA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.0': resolution: {integrity: sha512-lKUaygq4G7sWkhQbfdRRBkaq4LY39IriqBQ+Gk6l5nKq6Ay2M2ZZb1tlIyRNgZKS8cbErTwuYSor0IIULC0SHw==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.0': resolution: {integrity: sha512-xuDjhAsFdUuFP5W9Ze4k/o4AskUtI8bcAGU4puTYprr89QaYFmhYOPfP+d1pH+k9ets6RoE23BXZM1X1jJqoyw==} @@ -1212,6 +1304,12 @@ packages: '@tailwindcss/postcss@4.2.0': resolution: {integrity: sha512-u6YBacGpOm/ixPfKqfgrJEjMfrYmPD7gEFRoygS/hnQaRtV0VCBdpkx5Ouw9pnaLRwwlgGCuJw8xLpaR0hOrQg==} + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -1239,6 +1337,12 @@ packages: '@types/d3-timer@3.0.2': resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/node@24.10.4': resolution: {integrity: sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==} @@ -1305,10 +1409,43 @@ packages: vue-router: optional: true + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + autoprefixer@10.4.24: resolution: {integrity: sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==} engines: {node: ^10 || ^12 || >=14} @@ -1328,6 +1465,10 @@ packages: caniuse-lite@1.0.30001769: resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -1344,6 +1485,9 @@ packages: react: ^18 || ^19 || ^19.0.0-rc react-dom: ^18 || ^19 || ^19.0.0-rc + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -1434,20 +1578,44 @@ packages: resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} engines: {node: '>=10.13.0'} + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + fast-equals@5.4.0: resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} engines: {node: '>=6.0.0'} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + get-nonce@1.0.1: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} @@ -1481,57 +1649,107 @@ packages: cpu: [arm64] os: [android] + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + lightningcss-darwin-arm64@1.31.1: resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + lightningcss-darwin-x64@1.31.1: resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + lightningcss-freebsd-x64@1.31.1: resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + lightningcss-linux-arm-gnueabihf@1.31.1: resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + lightningcss-linux-arm64-gnu@1.31.1: resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] lightningcss-linux-arm64-musl@1.31.1: resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] lightningcss-linux-x64-gnu@1.31.1: resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] lightningcss-linux-x64-musl@1.31.1: resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] lightningcss-win32-arm64-msvc@1.31.1: resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} @@ -1539,16 +1757,32 @@ packages: cpu: [arm64] os: [win32] + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + lightningcss-win32-x64-msvc@1.31.1: resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + lightningcss@1.31.1: resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} engines: {node: '>= 12.0.0'} + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} @@ -1569,6 +1803,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + next-themes@0.4.6: resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} peerDependencies: @@ -1606,12 +1845,23 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + openid-client@6.8.4: resolution: {integrity: sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} @@ -1619,6 +1869,10 @@ packages: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.25: + resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} + engines: {node: ^10 || ^12 || >=14} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -1711,6 +1965,11 @@ packages: react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + rolldown@1.2.1: + resolution: {integrity: sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -1723,6 +1982,9 @@ packages: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + sonner@1.7.4: resolution: {integrity: sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==} peerDependencies: @@ -1733,6 +1995,12 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -1764,6 +2032,21 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1818,6 +2101,95 @@ packages: victory-vendor@36.9.2: resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + vite@8.2.0: + resolution: {integrity: sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -1843,11 +2215,27 @@ snapshots: '@edge-runtime/cookies@5.0.2': {} + '@emnapi/core@2.0.0-alpha.3': + dependencies: + '@emnapi/wasi-threads': 2.0.1 + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.8.1': dependencies: tslib: 2.8.1 optional: true + '@emnapi/runtime@2.0.0-alpha.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@2.0.1': + dependencies: + tslib: 2.8.1 + optional: true + '@floating-ui/core@1.7.4': dependencies: '@floating-ui/utils': 0.2.10 @@ -1985,6 +2373,13 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)': + dependencies: + '@emnapi/core': 2.0.0-alpha.3 + '@emnapi/runtime': 2.0.0-alpha.3 + '@tybys/wasm-util': 0.10.3 + optional: true + '@next/env@16.2.6': {} '@next/swc-darwin-arm64@16.2.6': @@ -2011,6 +2406,8 @@ snapshots: '@next/swc-win32-x64-msvc@16.2.6': optional: true + '@oxc-project/types@0.142.0': {} + '@panva/hkdf@1.2.1': {} '@radix-ui/number@1.1.1': {} @@ -2708,6 +3105,59 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@rolldown/binding-android-arm64@1.2.1': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.1': + optional: true + + '@rolldown/binding-darwin-x64@1.2.1': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.1': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.1': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.1': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.1': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.1': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.1': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.1': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.1': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.1': + optional: true + + '@rolldown/binding-wasm32-wasi@1.2.1': + dependencies: + '@emnapi/core': 2.0.0-alpha.3 + '@emnapi/runtime': 2.0.0-alpha.3 + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.1': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.1': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -2781,6 +3231,16 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.2.0 + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} '@types/d3-color@3.1.3': {} @@ -2805,6 +3265,10 @@ snapshots: '@types/d3-timer@3.0.2': {} + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + '@types/node@24.10.4': dependencies: undici-types: 7.16.0 @@ -2827,10 +3291,53 @@ snapshots: next: 16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.10(vite@8.2.0(@types/node@24.10.4)(jiti@2.6.1))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.0(@types/node@24.10.4)(jiti@2.6.1) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + aria-hidden@1.2.6: dependencies: tslib: 2.8.1 + assertion-error@2.0.1: {} + autoprefixer@10.4.24(postcss@8.5.6): dependencies: browserslist: 4.28.1 @@ -2852,6 +3359,8 @@ snapshots: caniuse-lite@1.0.30001769: {} + chai@6.2.2: {} + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -2872,6 +3381,8 @@ snapshots: - '@types/react' - '@types/react-dom' + convert-source-map@2.0.0: {} + csstype@3.2.3: {} d3-array@3.2.4: @@ -2948,14 +3459,29 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + es-module-lexer@2.3.1: {} + escalade@3.2.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + eventemitter3@4.0.7: {} + expect-type@1.4.0: {} + fast-equals@5.4.0: {} + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + fraction.js@5.3.4: {} + fsevents@2.3.3: + optional: true + get-nonce@1.0.1: {} graceful-fs@4.2.11: {} @@ -2976,36 +3502,69 @@ snapshots: lightningcss-android-arm64@1.31.1: optional: true + lightningcss-android-arm64@1.33.0: + optional: true + lightningcss-darwin-arm64@1.31.1: optional: true + lightningcss-darwin-arm64@1.33.0: + optional: true + lightningcss-darwin-x64@1.31.1: optional: true + lightningcss-darwin-x64@1.33.0: + optional: true + lightningcss-freebsd-x64@1.31.1: optional: true + lightningcss-freebsd-x64@1.33.0: + optional: true + lightningcss-linux-arm-gnueabihf@1.31.1: optional: true + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + lightningcss-linux-arm64-gnu@1.31.1: optional: true + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + lightningcss-linux-arm64-musl@1.31.1: optional: true + lightningcss-linux-arm64-musl@1.33.0: + optional: true + lightningcss-linux-x64-gnu@1.31.1: optional: true + lightningcss-linux-x64-gnu@1.33.0: + optional: true + lightningcss-linux-x64-musl@1.31.1: optional: true + lightningcss-linux-x64-musl@1.33.0: + optional: true + lightningcss-win32-arm64-msvc@1.31.1: optional: true + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + lightningcss-win32-x64-msvc@1.31.1: optional: true + lightningcss-win32-x64-msvc@1.33.0: + optional: true + lightningcss@1.31.1: dependencies: detect-libc: 2.1.2 @@ -3022,6 +3581,22 @@ snapshots: lightningcss-win32-arm64-msvc: 1.31.1 lightningcss-win32-x64-msvc: 1.31.1 + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + lodash@4.17.23: {} loose-envify@1.4.0: @@ -3038,6 +3613,8 @@ snapshots: nanoid@3.3.11: {} + nanoid@3.3.16: {} + next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: react: 19.2.4 @@ -3073,13 +3650,19 @@ snapshots: object-assign@4.1.1: {} + obug@2.1.4: {} + openid-client@6.8.4: dependencies: jose: 6.2.3 oauth4webapi: 3.8.6 + pathe@2.0.3: {} + picocolors@1.1.1: {} + picomatch@4.0.5: {} + postcss-value-parser@4.2.0: {} postcss@8.4.31: @@ -3088,6 +3671,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.25: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -3188,6 +3777,27 @@ snapshots: tiny-invariant: 1.3.3 victory-vendor: 36.9.2 + rolldown@1.2.1: + dependencies: + '@oxc-project/types': 0.142.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.2.1 + '@rolldown/binding-darwin-arm64': 1.2.1 + '@rolldown/binding-darwin-x64': 1.2.1 + '@rolldown/binding-freebsd-x64': 1.2.1 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.1 + '@rolldown/binding-linux-arm64-gnu': 1.2.1 + '@rolldown/binding-linux-arm64-musl': 1.2.1 + '@rolldown/binding-linux-ppc64-gnu': 1.2.1 + '@rolldown/binding-linux-s390x-gnu': 1.2.1 + '@rolldown/binding-linux-x64-gnu': 1.2.1 + '@rolldown/binding-linux-x64-musl': 1.2.1 + '@rolldown/binding-openharmony-arm64': 1.2.1 + '@rolldown/binding-wasm32-wasi': 1.2.1 + '@rolldown/binding-win32-arm64-msvc': 1.2.1 + '@rolldown/binding-win32-x64-msvc': 1.2.1 + scheduler@0.27.0: {} semver@7.7.4: @@ -3225,6 +3835,8 @@ snapshots: '@img/sharp-win32-x64': 0.34.5 optional: true + siginfo@2.0.0: {} + sonner@1.7.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: react: 19.2.4 @@ -3232,6 +3844,10 @@ snapshots: source-map-js@1.2.1: {} + stackback@0.0.2: {} + + std-env@4.2.0: {} + styled-jsx@5.1.6(react@19.2.4): dependencies: client-only: 0.0.1 @@ -3251,6 +3867,17 @@ snapshots: tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} + + tinyexec@1.3.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.1: {} + tslib@2.8.1: {} tw-animate-css@1.3.3: {} @@ -3310,4 +3937,48 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 + vite@8.2.0(@types/node@24.10.4)(jiti@2.6.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.25 + rolldown: 1.2.1 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.10.4 + fsevents: 2.3.3 + jiti: 2.6.1 + + vitest@4.1.10(@types/node@24.10.4)(vite@8.2.0(@types/node@24.10.4)(jiti@2.6.1)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@24.10.4)(jiti@2.6.1)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.0(@types/node@24.10.4)(jiti@2.6.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.10.4 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + zod@3.25.76: {} diff --git a/vitest.config.mts b/vitest.config.mts new file mode 100644 index 0000000..fa1a0d8 --- /dev/null +++ b/vitest.config.mts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config' +import path from 'path' + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + alias: { + '@': path.resolve(process.cwd(), './'), + }, + }, +}) From 8249c374965aaaf9947c2b630e0dbc7d7bfeb0f7 Mon Sep 17 00:00:00 2001 From: twinik Date: Sun, 2 Aug 2026 16:04:59 -0300 Subject: [PATCH 02/12] test: add Vitest test suite and pnpm v11 setup --- __tests__/api/routes.test.ts | 65 ++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/__tests__/api/routes.test.ts b/__tests__/api/routes.test.ts index e11c164..fa8105f 100644 --- a/__tests__/api/routes.test.ts +++ b/__tests__/api/routes.test.ts @@ -15,6 +15,8 @@ vi.mock('@/lib/api', () => ({ import { GET as getDevToken } from '@/app/api/dev-token/route' import { GET as getUser } from '@/app/api/user/route' import { GET as getApplications, POST as createApplication } from '@/app/api/applications/route' +import { GET as getAppById, PUT as updateAppById, DELETE as deleteAppById } from '@/app/api/applications/[id]/route' +import { POST as acceptInvitation } from '@/app/api/invitations/[token]/accept/route' import { auth0 } from '@/lib/auth0' import { fetchBackend } from '@/lib/api' @@ -118,4 +120,67 @@ describe('app/api Routes (Endpoints Internos)', () => { expect(body.id).toBe('app-99') }) }) + + describe('/api/applications/[id]', () => { + it('GET /api/applications/[id] should fetch specific application', async () => { + const mockBackendResponse = new Response( + JSON.stringify({ id: 'app-123', name: 'my-app' }), + { status: 200 } + ) + vi.mocked(fetchBackend).mockResolvedValueOnce(mockBackendResponse) + + const req = new NextRequest('http://localhost:3000/api/applications/app-123') + const res = await getAppById(req, { params: Promise.resolve({ id: 'app-123' }) }) + + expect(res.status).toBe(200) + expect(fetchBackend).toHaveBeenCalledWith('/v1/applications/app-123') + const body = await res.json() + expect(body.id).toBe('app-123') + }) + + it('PUT /api/applications/[id] should update application', async () => { + const updateData = { name: 'updated-name' } + const mockBackendResponse = new Response( + JSON.stringify({ id: 'app-123', ...updateData }), + { status: 200 } + ) + vi.mocked(fetchBackend).mockResolvedValueOnce(mockBackendResponse) + + const req = new NextRequest('http://localhost:3000/api/applications/app-123', { + method: 'PUT', + body: JSON.stringify(updateData), + }) + const res = await updateAppById(req, { params: Promise.resolve({ id: 'app-123' }) }) + + expect(res.status).toBe(200) + expect(fetchBackend).toHaveBeenCalledWith('/v1/applications/app-123', { + method: 'PUT', + body: JSON.stringify(updateData), + }) + }) + + it('DELETE /api/applications/[id] should delete application and return 204', async () => { + const mockBackendResponse = new Response(null, { status: 204 }) + vi.mocked(fetchBackend).mockResolvedValueOnce(mockBackendResponse) + + const req = new NextRequest('http://localhost:3000/api/applications/app-123', { method: 'DELETE' }) + const res = await deleteAppById(req, { params: Promise.resolve({ id: 'app-123' }) }) + + expect(res.status).toBe(204) + expect(fetchBackend).toHaveBeenCalledWith('/v1/applications/app-123', { method: 'DELETE' }) + }) + }) + + describe('/api/invitations/[token]/accept', () => { + it('POST /api/invitations/[token]/accept should call backend accept endpoint', async () => { + const mockBackendResponse = new Response(null, { status: 200 }) + vi.mocked(fetchBackend).mockResolvedValueOnce(mockBackendResponse) + + const req = new NextRequest('http://localhost:3000/api/invitations/token-abc/accept', { method: 'POST' }) + const res = await acceptInvitation(req, { params: Promise.resolve({ token: 'token-abc' }) }) + + expect(res.status).toBe(200) + expect(fetchBackend).toHaveBeenCalledWith('/v1/invitations/token-abc/accept', { method: 'POST' }) + }) + }) }) From 0490591fbc58d001334bf1a996b8a0139d98b306 Mon Sep 17 00:00:00 2001 From: twinik Date: Sun, 2 Aug 2026 16:15:05 -0300 Subject: [PATCH 03/12] refactor: modularize dashboard components, centralize types and extract mock data --- __tests__/api/routes.test.ts | 2 +- __tests__/proxy.test.ts | 2 +- app/dashboard/applications/[id]/page.tsx | 524 +----------------- app/dashboard/applications/page.tsx | 423 +++++++------- app/dashboard/page.tsx | 108 +--- .../applications/application-card.tsx | 126 +++++ .../applications/tabs/api-keys-tab.tsx | 124 +++++ .../dashboard/applications/tabs/locks-tab.tsx | 142 +++++ .../applications/tabs/resources-tab.tsx | 146 +++++ components/dashboard/shared/env-badge.tsx | 22 + components/dashboard/shared/stat-card.tsx | 30 + components/ui/use-mobile.tsx | 19 - components/ui/use-toast.ts | 191 ------- lib/mocks/applications.ts | 180 ++++++ lib/mocks/dashboard.ts | 62 +++ types/api-key.ts | 9 + types/application.ts | 43 ++ types/billing.ts | 17 + types/index.ts | 5 + types/lock.ts | 17 + types/resource.ts | 17 + 21 files changed, 1175 insertions(+), 1034 deletions(-) create mode 100644 components/dashboard/applications/application-card.tsx create mode 100644 components/dashboard/applications/tabs/api-keys-tab.tsx create mode 100644 components/dashboard/applications/tabs/locks-tab.tsx create mode 100644 components/dashboard/applications/tabs/resources-tab.tsx create mode 100644 components/dashboard/shared/env-badge.tsx create mode 100644 components/dashboard/shared/stat-card.tsx delete mode 100644 components/ui/use-mobile.tsx delete mode 100644 components/ui/use-toast.ts create mode 100644 lib/mocks/applications.ts create mode 100644 lib/mocks/dashboard.ts create mode 100644 types/api-key.ts create mode 100644 types/application.ts create mode 100644 types/billing.ts create mode 100644 types/index.ts create mode 100644 types/lock.ts create mode 100644 types/resource.ts diff --git a/__tests__/api/routes.test.ts b/__tests__/api/routes.test.ts index fa8105f..e1953f9 100644 --- a/__tests__/api/routes.test.ts +++ b/__tests__/api/routes.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { NextRequest } from 'next/server' vi.mock('@/lib/auth0', () => ({ diff --git a/__tests__/proxy.test.ts b/__tests__/proxy.test.ts index 23d13da..ca5059d 100644 --- a/__tests__/proxy.test.ts +++ b/__tests__/proxy.test.ts @@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from 'next/server' vi.mock('@/lib/auth0', () => ({ auth0: { - middleware: vi.fn((req: any) => NextResponse.next()), + middleware: vi.fn((req: any) => Promise.resolve(NextResponse.next())), getSession: vi.fn(), }, })) diff --git a/app/dashboard/applications/[id]/page.tsx b/app/dashboard/applications/[id]/page.tsx index 439550d..f3e9462 100644 --- a/app/dashboard/applications/[id]/page.tsx +++ b/app/dashboard/applications/[id]/page.tsx @@ -10,27 +10,15 @@ import { Settings, Activity, ArrowLeft, - Plus, - MoreVertical, - Play, - Pause, - Trash2, Loader2, Copy, Check, } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Card } from "@/components/ui/card" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { cn, getEnvColors } from "@/lib/utils" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" import { Select, SelectContent, @@ -47,211 +35,10 @@ import { DialogTitle, } from "@/components/ui/dialog" - -interface EnvData { - resources: Array<{ - id: string - name: string - mode: string - status: string - activeReservations: number - }> - locks: Array<{ - id: string - name: string - type: string - status: string - activeLocks: number - }> - apiKeys: Array<{ - id: string - name: string - prefix: string - createdAt: string - lastUsed: string - }> -} - -type AppEnvironmentsData = Record<"dev" | "staging" | "prod", EnvData> - -const appEnvironmentsMock: Record = { - "reserva-engine": { - dev: { - resources: [ - { id: "1-dev", name: "seat_reservation_dev", mode: "multiple", status: "active", activeReservations: 12 }, - { id: "2-dev", name: "test_lounge_dev", mode: "unit", status: "active", activeReservations: 0 }, - ], - locks: [ - { id: "1-dev", name: "payment_mock_lock", type: "exclusive", status: "active", activeLocks: 1 }, - ], - apiKeys: [ - { id: "1-dev", name: "Clave Desarrollo", prefix: "ck_test_", createdAt: "2024-01-15", lastUsed: "Hace 5m" }, - ], - }, - staging: { - resources: [ - { id: "1-stage", name: "seat_reservation_staging", mode: "multiple", status: "active", activeReservations: 5 }, - ], - locks: [ - { id: "1-stage", name: "payment_stage_lock", type: "exclusive", status: "paused", activeLocks: 0 }, - ], - apiKeys: [ - { id: "1-stage", name: "Clave Staging", prefix: "ck_stage_", createdAt: "2024-01-15", lastUsed: "Hace 2h" }, - ], - }, - prod: { - resources: [ - { id: "1-prod", name: "seat_reservation", mode: "multiple", status: "active", activeReservations: 45 }, - { id: "2-prod", name: "vip_lounge", mode: "unit", status: "active", activeReservations: 2 }, - ], - locks: [ - { id: "1-prod", name: "payment_processor", type: "exclusive", status: "active", activeLocks: 3 }, - ], - apiKeys: [ - { id: "1-prod", name: "Production Key", prefix: "ck_live_", createdAt: "2024-01-15", lastUsed: "Hace 2m" }, - ], - }, - }, - "lock-service": { - dev: { - resources: [], - locks: [ - { id: "1-dev", name: "order_processing_dev", type: "exclusive", status: "active", activeLocks: 1 }, - { id: "2-dev", name: "inventory_sync_dev", type: "read-write", status: "active", activeLocks: 2 }, - ], - apiKeys: [ - { id: "1-dev", name: "Development Key", prefix: "ck_test_", createdAt: "2024-02-20", lastUsed: "Hace 5m" }, - ], - }, - staging: { - resources: [], - locks: [ - { id: "1-stage", name: "inventory_sync_staging", type: "read-write", status: "active", activeLocks: 0 }, - ], - apiKeys: [ - { id: "1-stage", name: "Staging Key", prefix: "ck_stage_", createdAt: "2024-02-20", lastUsed: "Hace 1d" }, - ], - }, - prod: { - resources: [], - locks: [ - { id: "1-prod", name: "order_processing", type: "exclusive", status: "active", activeLocks: 1 }, - { id: "2-prod", name: "inventory_sync", type: "read-write", status: "active", activeLocks: 5 }, - ], - apiKeys: [ - { id: "1-prod", name: "Production Key", prefix: "ck_live_", createdAt: "2024-02-20", lastUsed: "Hace 10m" }, - ], - }, - }, - "payment-sync": { - dev: { - resources: [ - { id: "1-dev", name: "transaction_slot_dev", mode: "unit", status: "active", activeReservations: 1 }, - ], - locks: [ - { id: "1-dev", name: "payment_gateway_dev", type: "exclusive", status: "active", activeLocks: 0 }, - ], - apiKeys: [ - { id: "1-dev", name: "Dev Key", prefix: "ck_test_", createdAt: "2024-03-10", lastUsed: "Hace 1h" }, - ], - }, - staging: { - resources: [ - { id: "1-stage", name: "transaction_slot_staging", mode: "unit", status: "active", activeReservations: 2 }, - ], - locks: [ - { id: "1-stage", name: "payment_gateway_staging", type: "exclusive", status: "active", activeLocks: 1 }, - ], - apiKeys: [ - { id: "1-stage", name: "Staging Key", prefix: "ck_stage_", createdAt: "2024-03-10", lastUsed: "Hace 30m" }, - ], - }, - prod: { - resources: [ - { id: "1-prod", name: "transaction_slot", mode: "unit", status: "active", activeReservations: 12 }, - ], - locks: [ - { id: "1-prod", name: "payment_gateway", type: "exclusive", status: "active", activeLocks: 8 }, - ], - apiKeys: [ - { id: "1-prod", name: "Production Key", prefix: "ck_live_", createdAt: "2024-03-10", lastUsed: "Hace 30s" }, - ], - }, - }, -} - -const generateMockDataForApp = (name: string): AppEnvironmentsData => { - return { - dev: { - resources: [ - { id: "custom-1-dev", name: `${name}_resource_dev`, mode: "multiple", status: "active", activeReservations: 1 }, - ], - locks: [ - { id: "custom-lock-1-dev", name: `${name}_lock_dev`, type: "exclusive", status: "active", activeLocks: 0 }, - ], - apiKeys: [ - { id: "custom-key-1-dev", name: "Development Key", prefix: "ck_test_", createdAt: new Date().toISOString().split('T')[0], lastUsed: "Hace 10m" }, - ], - }, - staging: { - resources: [ - { id: "custom-1-stage", name: `${name}_resource_staging`, mode: "multiple", status: "active", activeReservations: 2 }, - ], - locks: [ - { id: "custom-lock-1-stage", name: `${name}_lock_staging`, type: "exclusive", status: "active", activeLocks: 1 }, - ], - apiKeys: [ - { id: "custom-key-1-stage", name: "Staging Key", prefix: "ck_stage_", createdAt: new Date().toISOString().split('T')[0], lastUsed: "Hace 1d" }, - ], - }, - prod: { - resources: [ - { id: "custom-1-prod", name: `${name}_resource`, mode: "multiple", status: "active", activeReservations: 5 }, - ], - locks: [ - { id: "custom-lock-1-prod", name: `${name}_lock`, type: "exclusive", status: "active", activeLocks: 2 }, - ], - apiKeys: [ - { id: "custom-key-1-prod", name: "Production Key", prefix: "ck_live_", createdAt: new Date().toISOString().split('T')[0], lastUsed: "Hace 30s" }, - ], - }, - } -} - -const getMockDataForEnv = (appName: string, envName: string): EnvData => { - const predefined = appEnvironmentsMock[appName]; - if (predefined) { - if (envName === "dev" || envName === "development") return predefined.dev; - if (envName === "stage" || envName === "staging") return predefined.staging; - if (envName === "prod" || envName === "production") return predefined.prod; - } - - return { - resources: [ - { id: `${envName}-res-1`, name: `${appName}_res_${envName}`, mode: "multiple", status: "active", activeReservations: 3 }, - ], - locks: [ - { id: `${envName}-lock-1`, name: `${appName}_lock_${envName}`, type: "exclusive", status: "active", activeLocks: 1 }, - ], - apiKeys: [ - { id: `${envName}-key-1`, name: `Key ${envName}`, prefix: "ck_live_", createdAt: new Date().toISOString().split('T')[0], lastUsed: "Hace 5m" }, - ], - }; -} - -const formatTtl = (ms: any): string => { - const num = Number(ms) - if (isNaN(num)) return String(ms) - if (num < 1000) return `${num} ms` - const seconds = num / 1000 - if (seconds < 60) return `${seconds.toFixed(seconds % 1 === 0 ? 0 : 1)} s` - const minutes = seconds / 60 - if (minutes < 60) return `${minutes.toFixed(minutes % 1 === 0 ? 0 : 1)} min` - const hours = minutes / 60 - if (hours < 24) return `${hours.toFixed(hours % 1 === 0 ? 0 : 1)} h` - const days = hours / 24 - return `${days.toFixed(days % 1 === 0 ? 0 : 1)} d` -} +import { getMockDataForEnv } from "@/lib/mocks/applications" +import { ResourcesTab } from "@/components/dashboard/applications/tabs/resources-tab" +import { LocksTab } from "@/components/dashboard/applications/tabs/locks-tab" +import { ApiKeysTab } from "@/components/dashboard/applications/tabs/api-keys-tab" export default function ApplicationDetailPage({ params, @@ -436,19 +223,6 @@ export default function ApplicationDetailPage({ } } - const getEnvironmentBadgeClass = (env: string) => { - switch (env) { - case "prod": - return "bg-primary/20 text-primary" - case "dev": - return "bg-chart-2/20 text-chart-2" - case "staging": - return "bg-chart-4/20 text-chart-4" - default: - return "bg-secondary text-muted-foreground" - } - } - const getStatusBadgeClass = (status: string) => { switch (status) { case "active": @@ -490,7 +264,6 @@ export default function ApplicationDetailPage({ Volver a Aplicaciones - {/* Row 1: Title and Controls */}
@@ -566,7 +339,6 @@ export default function ApplicationDetailPage({
- {/* Row 2: Description */} {app.description && (

@@ -635,8 +407,6 @@ export default function ApplicationDetailPage({

- {/* Selected environment description — compact but color-coded so it's obvious at a glance which - environment's resources are being shown below (the env name itself is already in the selector above) */} {currentEnvDetails?.description && (
)} - {/* Tabs for Resources, Locks, API Keys */} + {/* Tabs */}
@@ -681,271 +451,35 @@ export default function ApplicationDetailPage({
-
-

- {templates.length === 1 ? "1 recurso configurado" : `${templates.length} recursos configurados`} -

- {app.myRole !== "VIEWER" && ( - - - - )} -
- - {templates.length === 0 ? ( - - - -

Aún no hay recursos configurados

- {app.myRole !== "VIEWER" && ( - - - - )} -
-
- ) : ( -
- {templates.map((template: any) => ( - - -
-
- -
-
-

{template.name}

-
- - Tipo {template.type === "UNITARY" ? "Unitario" : "Múltiple"} - - {template.defaultTtlSec && ( - <> - - - TTL: {formatTtl(template.defaultTtlSec * 1000)} - - - )} - - - Res: {template.conflictResolution} - -
- {template.description && ( -

- {template.description} -

- )} -
-
-
- {app.myRole !== "VIEWER" && ( - - - - - - - - - - Configurar - - - - - handleOpenDeleteTemplate(template)} - > - - Eliminar - - - - )} -
-
-
- ))} -
- )} +
-
-

- {currentEnvData.locks.length === 1 ? "1 lock configurado" : `${currentEnvData.locks.length} locks configurados`} -

- {app.myRole !== "VIEWER" && ( - - - - )} -
- - {currentEnvData.locks.length === 0 ? ( - - - -

Aún no hay configuraciones de locks

- {app.myRole !== "VIEWER" && ( - - - - )} -
-
- ) : ( -
- {currentEnvData.locks.map((lock: any) => ( - - -
-
- -
-
-

{lock.name}

-
- Tipo {lock.type === "exclusive" ? "exclusivo" : "lectura-escritura"} - - {lock.activeLocks} activos -
-
-
-
- - {lock.status} - - {app.myRole !== "VIEWER" && ( - - - - - - - - - Configurar - - - - - Liberar Todos - - - - - Eliminar - - - - )} -
-
-
- ))} -
- )} +
-
-

- {apiKeys.length === 1 ? "1 API Key" : `${apiKeys.length} API Keys`} -

- {app.myRole !== "VIEWER" && ( - - )} -
- - {isApiKeysLoading ? ( -
- -
- ) : apiKeys.length === 0 ? ( - - - -

No hay API Keys configuradas para este ambiente

- {app.myRole !== "VIEWER" && ( - - )} -
-
- ) : ( -
- {apiKeys.map((key: any) => ( - - -
-
- -
-
-
-

{key.keyPrefix}••••••••••••

- - {key.state === "ACTIVE" ? "Activa" : "Revocada"} - -
-

- Creada el: {new Date(key.createdAt).toLocaleString()} - {key.revokedAt && ` • Revocada el: ${new Date(key.revokedAt).toLocaleString()}`} -

-
-
- {key.state === "ACTIVE" && app.myRole !== "VIEWER" && ( -
- -
- )} -
-
- ))} -
- )} +
diff --git a/app/dashboard/applications/page.tsx b/app/dashboard/applications/page.tsx index 75029d2..3117eae 100644 --- a/app/dashboard/applications/page.tsx +++ b/app/dashboard/applications/page.tsx @@ -31,10 +31,10 @@ import { Users, Key, Layers, - Calendar, ExternalLink, Loader2 } from "lucide-react" +import { EnvBadge } from "@/components/dashboard/shared/env-badge" interface Application { id: string @@ -73,39 +73,6 @@ const getRoleBadge = (role: string) => { } }; -const mockApplications: Application[] = [ - { - id: "app_1", - name: "E-Commerce Platform", - description: "Sistema de reservas para inventario de productos", - environments: ["production", "staging", "development"], - collaborators: 4, - apiCalls: 125430, - createdAt: "2024-01-15", - status: "active", - }, - { - id: "app_2", - name: "Cinema Booking", - description: "Reserva de asientos para cadena de cines", - environments: ["production", "development"], - collaborators: 2, - apiCalls: 89210, - createdAt: "2024-02-20", - status: "active", - }, - { - id: "app_3", - name: "Medical Appointments", - description: "Sistema de turnos para clinica medica", - environments: ["staging", "development"], - collaborators: 3, - apiCalls: 45600, - createdAt: "2024-03-10", - status: "active", - }, -] - export default function ApplicationsPage() { const router = useRouter() const [applications, setApplications] = useState([]) @@ -214,216 +181,204 @@ export default function ApplicationsPage() { return (
- {/* Header */} -
-
-

Aplicaciones

-

- Gestiona tus aplicaciones y sus configuraciones -

-
- - - + {/* Header */} +
+
+

Aplicaciones

+

+ Gestiona tus aplicaciones y sus configuraciones +

+ + + +
- {/* Search */} -
- - setSearchQuery(e.target.value)} - className="pl-10" - /> -
+ {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> +
- {/* Applications Grid */} - {isLoading ? ( -
- -
- ) : filteredApps.length === 0 ? ( - - -
- -
-

- No hay aplicaciones -

-

- {searchQuery - ? "No se encontraron aplicaciones con ese criterio" - : "Crea tu primera aplicación para comenzar"} -

- {!searchQuery && ( - - - - )} -
-
- ) : ( -
- {filteredApps.map((app) => ( - { - const target = e.target as HTMLElement; - if (target.closest('[role="menuitem"]') || target.closest('button') || target.closest('[role="button"]')) { - return; - } - router.push(`/dashboard/applications/${app.id}`); - }} - > - -
-
- - {app.name} - - {app.status === "active" ? "Activa" : "Inactiva"} - - {getRoleBadge(app.myRole || "VIEWER")} - - - {app.description || "Sin descripción configurada"} - -
- - - - - + {/* Applications Grid */} + {isLoading ? ( +
+ +
+ ) : filteredApps.length === 0 ? ( + + +
+ +
+

+ No hay aplicaciones +

+

+ {searchQuery + ? "No se encontraron aplicaciones con ese criterio" + : "Crea tu primera aplicación para comenzar"} +

+ {!searchQuery && ( + + + + )} +
+
+ ) : ( +
+ {filteredApps.map((app) => ( + { + const target = e.target as HTMLElement; + if (target.closest('[role="menuitem"]') || target.closest('button') || target.closest('[role="button"]')) { + return; + } + router.push(`/dashboard/applications/${app.id}`); + }} + > + +
+
+ + {app.name} + + {app.status === "active" ? "Activa" : "Inactiva"} + + {getRoleBadge(app.myRole || "VIEWER")} + + + {app.description || "Sin descripción configurada"} + +
+ + + + + + + + + Ver Detalles + + + {app.myRole !== "VIEWER" && ( - - - Ver Detalles + + + Configuracion - {app.myRole !== "VIEWER" && ( - - - - Configuracion - + )} + + + + Colaboradores + + + + + + API Keys + + + {app.myRole === "OWNER" && ( + <> + + handleDeleteClick(app)} + > + + Eliminar - )} - - - - Colaboradores - - - - - - API Keys - - - {app.myRole === "OWNER" && ( - <> - - handleDeleteClick(app)} - > - - Eliminar - - - )} - - + + )} + + +
+
+ + {/* Environments */} +
+ {app.environments.map((env) => ( + + ))} +
+ + {/* Stats */} +
+
+

+ {app.collaborators} +

+

Colaboradores

- - - {/* Environments */} -
- {app.environments.map((env) => ( - - {env} - - ))} +
+

+ {formatNumber(app.apiCalls)} +

+

Llamadas API

- - {/* Stats */} -
-
-

- {app.collaborators} -

-

Colaboradores

-
-
-

- {formatNumber(app.apiCalls)} -

-

Llamadas API

-
-
-

- {formatDate(app.createdAt)} -

-

Creado

-
+
+

+ {formatDate(app.createdAt)} +

+

Creado

- - - ))} -
- )} +
+
+ + ))} +
+ )} - {/* Delete Confirmation Dialog */} - - - - Eliminar Aplicación - - ¿Estás seguro que deseas eliminar la aplicación{" "} - - {appToDelete?.name} - - ? Esta acción no se puede deshacer y eliminará todas las - configuraciones, API keys y datos asociados. - - - - - - - - -
+ {/* Delete Confirmation Dialog */} + + + + Eliminar Aplicación + + ¿Estás seguro que deseas eliminar la aplicación{" "} + + {appToDelete?.name} + + ? Esta acción no se puede deshacer y eliminará todas las + configuraciones, API keys y datos asociados. + + + + + + + + +
) } diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 39de602..3bbdf75 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,83 +1,14 @@ import Link from "next/link" -import { Layers, Activity, Lock, Gauge, AlertTriangle, ArrowUpRight } from "lucide-react" +import { AlertTriangle, ArrowUpRight } from "lucide-react" import { Button } from "@/components/ui/button" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" - -const stats = [ - { - name: "Aplicaciones", - value: "3", - icon: Layers, - }, - { - name: "Llamadas de API (30d)", - value: "84.2k", - icon: Activity, - }, - { - name: "Bloqueos activos", - value: "12", - icon: Lock, - }, - { - name: "Uso del plan", - value: "68%", - icon: Gauge, - }, -] - -const recentActivity = [ - { - id: 1, - event: "lock.acquired", - application: "payment-sync", - environment: "prod", - time: "Hace 2s", - }, - { - id: 2, - event: "reserve.confirmed", - application: "reserva-engine", - environment: "prod", - time: "Hace 15s", - }, - { - id: 3, - event: "lock.released", - application: "lock-service", - environment: "dev", - time: "Hace 32s", - }, - { - id: 4, - event: "reserve.expired", - application: "reserva-engine", - environment: "prod", - time: "Hace 1m", - }, - { - id: 5, - event: "api_key.created", - application: "payment-sync", - environment: "prod", - time: "Hace 5m", - }, -] +import { Card } from "@/components/ui/card" +import { StatCard } from "@/components/dashboard/shared/stat-card" +import { EnvBadge } from "@/components/dashboard/shared/env-badge" +import { dashboardStatsMock, recentActivityMock } from "@/lib/mocks/dashboard" export default function DashboardPage() { const usagePercentage = 68 - const getEnvironmentBadgeClass = (env: string) => { - const lower = env.toLowerCase() - if (lower === "prod" || lower === "production") { - return "bg-primary/20 text-primary" - } else if (lower === "stage" || lower === "staging") { - return "bg-chart-4/20 text-chart-4" - } else { - return "bg-chart-2/20 text-chart-2" - } - } - const getEventColor = (event: string) => { if (event.includes("acquired") || event.includes("confirmed") || event.includes("created")) { return "text-primary" @@ -103,21 +34,14 @@ export default function DashboardPage() { {/* Stats grid */}
- {stats.map((stat) => ( - - - - {stat.name} - - - -
- - {stat.value} - -
-
-
+ {dashboardStatsMock.map((stat) => ( + ))}
@@ -169,7 +93,7 @@ export default function DashboardPage() { - {recentActivity.map((activity) => ( + {recentActivityMock.map((activity) => (
@@ -183,9 +107,7 @@ export default function DashboardPage() { {activity.application} - - {activity.environment} - + {activity.time} diff --git a/components/dashboard/applications/application-card.tsx b/components/dashboard/applications/application-card.tsx new file mode 100644 index 0000000..c6ed189 --- /dev/null +++ b/components/dashboard/applications/application-card.tsx @@ -0,0 +1,126 @@ +import React from 'react' +import Link from 'next/link' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { MoreVertical, Settings, Trash2, Users, Layers, ExternalLink } from 'lucide-react' +import { EnvBadge } from '@/components/dashboard/shared/env-badge' + +interface ApplicationCardProps { + app: { + id: string + name: string + description: string + environments: string[] + collaborators: number + apiCalls: number + createdAt: string + status: 'active' | 'inactive' + myRole?: string + } + onOpenDelete: (app: any) => void +} + +export function ApplicationCard({ app, onOpenDelete }: ApplicationCardProps) { + const getRoleBadge = (role?: string) => { + const normalized = role?.toUpperCase() + switch (normalized) { + case 'OWNER': + return ( + + Propietario + + ) + case 'ADMIN': + return ( + + Administrador + + ) + case 'VIEWER': + default: + return ( + + Visor + + ) + } + } + + return ( + + +
+
+
+ {app.name} + {getRoleBadge(app.myRole)} +
+ {app.description} +
+ {app.myRole === 'OWNER' && ( + + + + + + + + + + Configuración + + + + + onOpenDelete(app)} + > + + Eliminar + + + + )} +
+
+ +
+ {app.environments.map((env) => ( + + ))} +
+ +
+
+ + {app.collaborators} colaboradores +
+
+ + {(app.apiCalls / 1000).toFixed(1)}k llamadas/mes +
+
+ +
+ + + +
+
+
+ ) +} diff --git a/components/dashboard/applications/tabs/api-keys-tab.tsx b/components/dashboard/applications/tabs/api-keys-tab.tsx new file mode 100644 index 0000000..fdaab88 --- /dev/null +++ b/components/dashboard/applications/tabs/api-keys-tab.tsx @@ -0,0 +1,124 @@ +import React from 'react' +import { Key, Plus, Trash2, Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Card, CardContent } from '@/components/ui/card' +import { cn, getEnvColors } from '@/lib/utils' + +interface ApiKeysTabProps { + apiKeys: any[] + isApiKeysLoading: boolean + selectedEnv: string + currentEnvDetails?: any + myRole?: string + onCreateApiKey: () => void + onOpenRevokeKey: (key: any) => void +} + +export function ApiKeysTab({ + apiKeys, + isApiKeysLoading, + selectedEnv, + currentEnvDetails, + myRole, + onCreateApiKey, + onOpenRevokeKey, +}: ApiKeysTabProps) { + const isViewer = myRole === 'VIEWER' + + return ( +
+
+

+ {apiKeys.length === 1 ? '1 API Key' : `${apiKeys.length} API Keys`} +

+ {!isViewer && ( + + )} +
+ + {isApiKeysLoading ? ( +
+ +
+ ) : apiKeys.length === 0 ? ( + + + +

No hay API Keys configuradas para este ambiente

+ {!isViewer && ( + + )} +
+
+ ) : ( +
+ {apiKeys.map((key: any) => ( + + +
+
+ +
+
+
+

{key.keyPrefix}••••••••••••

+ + {key.state === 'ACTIVE' ? 'Activa' : 'Revocada'} + +
+

+ Creada el: {new Date(key.createdAt).toLocaleString()} + {key.revokedAt && ` • Revocada el: ${new Date(key.revokedAt).toLocaleString()}`} +

+
+
+ {key.state === 'ACTIVE' && !isViewer && ( +
+ +
+ )} +
+
+ ))} +
+ )} +
+ ) +} diff --git a/components/dashboard/applications/tabs/locks-tab.tsx b/components/dashboard/applications/tabs/locks-tab.tsx new file mode 100644 index 0000000..0b9043e --- /dev/null +++ b/components/dashboard/applications/tabs/locks-tab.tsx @@ -0,0 +1,142 @@ +import React from 'react' +import Link from 'next/link' +import { Lock, Plus, MoreVertical, Settings, Play, Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Card, CardContent } from '@/components/ui/card' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { cn, getEnvColors } from '@/lib/utils' + +interface LocksTabProps { + appId: string + locks: any[] + selectedEnv: string + myRole?: string +} + +export function LocksTab({ appId, locks, selectedEnv, myRole }: LocksTabProps) { + const isViewer = myRole === 'VIEWER' + + const getStatusBadgeClass = (status: string) => { + switch (status) { + case 'active': + return 'bg-primary/20 text-primary' + case 'paused': + return 'bg-chart-4/20 text-chart-4' + default: + return 'bg-secondary text-muted-foreground' + } + } + + return ( +
+
+

+ {locks.length === 1 ? '1 lock configurado' : `${locks.length} locks configurados`} +

+ {!isViewer && ( + + + + )} +
+ + {locks.length === 0 ? ( + + + +

Aún no hay configuraciones de locks

+ {!isViewer && ( + + + + )} +
+
+ ) : ( +
+ {locks.map((lock: any) => ( + + +
+
+ +
+
+

+ {lock.name} +

+
+ + Tipo {lock.type === 'exclusive' ? 'exclusivo' : 'lectura-escritura'} + + + {lock.activeLocks} activos +
+
+
+
+ + {lock.status} + + {!isViewer && ( + + + + + + + + + Configurar + + + + + Liberar Todos + + + + + Eliminar + + + + )} +
+
+
+ ))} +
+ )} +
+ ) +} diff --git a/components/dashboard/applications/tabs/resources-tab.tsx b/components/dashboard/applications/tabs/resources-tab.tsx new file mode 100644 index 0000000..6705c4e --- /dev/null +++ b/components/dashboard/applications/tabs/resources-tab.tsx @@ -0,0 +1,146 @@ +import React from 'react' +import Link from 'next/link' +import { Box, Plus, MoreVertical, Settings, Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Card, CardContent } from '@/components/ui/card' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { cn, getEnvColors } from '@/lib/utils' +import { formatTtl } from '@/lib/mocks/applications' + +interface ResourcesTabProps { + appId: string + templates: any[] + selectedEnv: string + currentEnvDetails?: any + myRole?: string + onOpenDeleteTemplate: (template: any) => void +} + +export function ResourcesTab({ + appId, + templates, + selectedEnv, + currentEnvDetails, + myRole, + onOpenDeleteTemplate, +}: ResourcesTabProps) { + const isViewer = myRole === 'VIEWER' + + return ( +
+
+

+ {templates.length === 1 ? '1 recurso configurado' : `${templates.length} recursos configurados`} +

+ {!isViewer && ( + + + + )} +
+ + {templates.length === 0 ? ( + + + +

Aún no hay recursos configurados

+ {!isViewer && ( + + + + )} +
+
+ ) : ( +
+ {templates.map((template: any) => ( + + +
+
+ +
+
+

+ {template.name} +

+
+ + Tipo {template.type === 'UNITARY' ? 'Unitario' : 'Múltiple'} + + {template.defaultTtlSec && ( + <> + + TTL: {formatTtl(template.defaultTtlSec * 1000)} + + )} + + Res: {template.conflictResolution} +
+ {template.description && ( +

+ {template.description} +

+ )} +
+
+
+ {!isViewer && ( + + + + + + + + + + Configurar + + + + + onOpenDeleteTemplate(template)} + > + + Eliminar + + + + )} +
+
+
+ ))} +
+ )} +
+ ) +} diff --git a/components/dashboard/shared/env-badge.tsx b/components/dashboard/shared/env-badge.tsx new file mode 100644 index 0000000..234fcf5 --- /dev/null +++ b/components/dashboard/shared/env-badge.tsx @@ -0,0 +1,22 @@ +import React from 'react' +import { getEnvColors } from '@/lib/utils' + +interface EnvBadgeProps { + environment: string + showDot?: boolean + className?: string +} + +export function EnvBadge({ environment, showDot = true, className = '' }: EnvBadgeProps) { + const colors = getEnvColors(environment) + const label = environment.toLowerCase() + + return ( + + {showDot && } + {label} + + ) +} diff --git a/components/dashboard/shared/stat-card.tsx b/components/dashboard/shared/stat-card.tsx new file mode 100644 index 0000000..f936f26 --- /dev/null +++ b/components/dashboard/shared/stat-card.tsx @@ -0,0 +1,30 @@ +import React from 'react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { LucideIcon } from 'lucide-react' + +interface StatCardProps { + title: string + value: string | number + icon?: LucideIcon + valueColor?: string +} + +export function StatCard({ title, value, icon: Icon, valueColor = 'text-primary' }: StatCardProps) { + return ( + + + + {title} + + {Icon && } + + +
+ + {value} + +
+
+
+ ) +} diff --git a/components/ui/use-mobile.tsx b/components/ui/use-mobile.tsx deleted file mode 100644 index 4331d5c..0000000 --- a/components/ui/use-mobile.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import * as React from 'react' - -const MOBILE_BREAKPOINT = 768 - -export function useIsMobile() { - const [isMobile, setIsMobile] = React.useState(undefined) - - React.useEffect(() => { - const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) - const onChange = () => { - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - } - mql.addEventListener('change', onChange) - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - return () => mql.removeEventListener('change', onChange) - }, []) - - return !!isMobile -} diff --git a/components/ui/use-toast.ts b/components/ui/use-toast.ts deleted file mode 100644 index 8932bc5..0000000 --- a/components/ui/use-toast.ts +++ /dev/null @@ -1,191 +0,0 @@ -'use client' - -// Inspired by react-hot-toast library -import * as React from 'react' - -import type { ToastActionElement, ToastProps } from '@/components/ui/toast' - -const TOAST_LIMIT = 1 -const TOAST_REMOVE_DELAY = 1000000 - -type ToasterToast = ToastProps & { - id: string - title?: React.ReactNode - description?: React.ReactNode - action?: ToastActionElement -} - -const actionTypes = { - ADD_TOAST: 'ADD_TOAST', - UPDATE_TOAST: 'UPDATE_TOAST', - DISMISS_TOAST: 'DISMISS_TOAST', - REMOVE_TOAST: 'REMOVE_TOAST', -} as const - -let count = 0 - -function genId() { - count = (count + 1) % Number.MAX_SAFE_INTEGER - return count.toString() -} - -type ActionType = typeof actionTypes - -type Action = - | { - type: ActionType['ADD_TOAST'] - toast: ToasterToast - } - | { - type: ActionType['UPDATE_TOAST'] - toast: Partial - } - | { - type: ActionType['DISMISS_TOAST'] - toastId?: ToasterToast['id'] - } - | { - type: ActionType['REMOVE_TOAST'] - toastId?: ToasterToast['id'] - } - -interface State { - toasts: ToasterToast[] -} - -const toastTimeouts = new Map>() - -const addToRemoveQueue = (toastId: string) => { - if (toastTimeouts.has(toastId)) { - return - } - - const timeout = setTimeout(() => { - toastTimeouts.delete(toastId) - dispatch({ - type: 'REMOVE_TOAST', - toastId: toastId, - }) - }, TOAST_REMOVE_DELAY) - - toastTimeouts.set(toastId, timeout) -} - -export const reducer = (state: State, action: Action): State => { - switch (action.type) { - case 'ADD_TOAST': - return { - ...state, - toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), - } - - case 'UPDATE_TOAST': - return { - ...state, - toasts: state.toasts.map((t) => - t.id === action.toast.id ? { ...t, ...action.toast } : t, - ), - } - - case 'DISMISS_TOAST': { - const { toastId } = action - - // ! Side effects ! - This could be extracted into a dismissToast() action, - // but I'll keep it here for simplicity - if (toastId) { - addToRemoveQueue(toastId) - } else { - state.toasts.forEach((toast) => { - addToRemoveQueue(toast.id) - }) - } - - return { - ...state, - toasts: state.toasts.map((t) => - t.id === toastId || toastId === undefined - ? { - ...t, - open: false, - } - : t, - ), - } - } - case 'REMOVE_TOAST': - if (action.toastId === undefined) { - return { - ...state, - toasts: [], - } - } - return { - ...state, - toasts: state.toasts.filter((t) => t.id !== action.toastId), - } - } -} - -const listeners: Array<(state: State) => void> = [] - -let memoryState: State = { toasts: [] } - -function dispatch(action: Action) { - memoryState = reducer(memoryState, action) - listeners.forEach((listener) => { - listener(memoryState) - }) -} - -type Toast = Omit - -function toast({ ...props }: Toast) { - const id = genId() - - const update = (props: ToasterToast) => - dispatch({ - type: 'UPDATE_TOAST', - toast: { ...props, id }, - }) - const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id }) - - dispatch({ - type: 'ADD_TOAST', - toast: { - ...props, - id, - open: true, - onOpenChange: (open) => { - if (!open) dismiss() - }, - }, - }) - - return { - id: id, - dismiss, - update, - } -} - -function useToast() { - const [state, setState] = React.useState(memoryState) - - React.useEffect(() => { - listeners.push(setState) - return () => { - const index = listeners.indexOf(setState) - if (index > -1) { - listeners.splice(index, 1) - } - } - }, [state]) - - return { - ...state, - toast, - dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }), - } -} - -export { useToast, toast } diff --git a/lib/mocks/applications.ts b/lib/mocks/applications.ts new file mode 100644 index 0000000..5c4d204 --- /dev/null +++ b/lib/mocks/applications.ts @@ -0,0 +1,180 @@ +import { AppEnvironmentsData, EnvData } from '@/types' + +export const appEnvironmentsMock: Record = { + "reserva-engine": { + dev: { + resources: [ + { id: "1-dev", name: "seat_reservation_dev", mode: "multiple", status: "active", activeReservations: 12 }, + { id: "2-dev", name: "test_lounge_dev", mode: "unit", status: "active", activeReservations: 0 }, + ], + locks: [ + { id: "1-dev", name: "payment_mock_lock", type: "exclusive", status: "active", activeLocks: 1 }, + ], + apiKeys: [ + { id: "1-dev", name: "Clave Desarrollo", prefix: "ck_test_", createdAt: "2024-01-15", lastUsed: "Hace 5m" }, + ], + }, + staging: { + resources: [ + { id: "1-stage", name: "seat_reservation_staging", mode: "multiple", status: "active", activeReservations: 5 }, + ], + locks: [ + { id: "1-stage", name: "payment_stage_lock", type: "exclusive", status: "paused", activeLocks: 0 }, + ], + apiKeys: [ + { id: "1-stage", name: "Clave Staging", prefix: "ck_stage_", createdAt: "2024-01-15", lastUsed: "Hace 2h" }, + ], + }, + prod: { + resources: [ + { id: "1-prod", name: "seat_reservation", mode: "multiple", status: "active", activeReservations: 45 }, + { id: "2-prod", name: "vip_lounge", mode: "unit", status: "active", activeReservations: 2 }, + ], + locks: [ + { id: "1-prod", name: "payment_processor", type: "exclusive", status: "active", activeLocks: 3 }, + ], + apiKeys: [ + { id: "1-prod", name: "Production Key", prefix: "ck_live_", createdAt: "2024-01-15", lastUsed: "Hace 2m" }, + ], + }, + }, + "lock-service": { + dev: { + resources: [], + locks: [ + { id: "1-dev", name: "order_processing_dev", type: "exclusive", status: "active", activeLocks: 1 }, + { id: "2-dev", name: "inventory_sync_dev", type: "read-write", status: "active", activeLocks: 2 }, + ], + apiKeys: [ + { id: "1-dev", name: "Development Key", prefix: "ck_test_", createdAt: "2024-02-20", lastUsed: "Hace 5m" }, + ], + }, + staging: { + resources: [], + locks: [ + { id: "1-stage", name: "inventory_sync_staging", type: "read-write", status: "active", activeLocks: 0 }, + ], + apiKeys: [ + { id: "1-stage", name: "Staging Key", prefix: "ck_stage_", createdAt: "2024-02-20", lastUsed: "Hace 1d" }, + ], + }, + prod: { + resources: [], + locks: [ + { id: "1-prod", name: "order_processing", type: "exclusive", status: "active", activeLocks: 1 }, + { id: "2-prod", name: "inventory_sync", type: "read-write", status: "active", activeLocks: 5 }, + ], + apiKeys: [ + { id: "1-prod", name: "Production Key", prefix: "ck_live_", createdAt: "2024-02-20", lastUsed: "Hace 10m" }, + ], + }, + }, + "payment-sync": { + dev: { + resources: [ + { id: "1-dev", name: "transaction_slot_dev", mode: "unit", status: "active", activeReservations: 1 }, + ], + locks: [ + { id: "1-dev", name: "payment_gateway_dev", type: "exclusive", status: "active", activeLocks: 0 }, + ], + apiKeys: [ + { id: "1-dev", name: "Dev Key", prefix: "ck_test_", createdAt: "2024-03-10", lastUsed: "Hace 1h" }, + ], + }, + staging: { + resources: [ + { id: "1-stage", name: "transaction_slot_staging", mode: "unit", status: "active", activeReservations: 2 }, + ], + locks: [ + { id: "1-stage", name: "payment_gateway_staging", type: "exclusive", status: "active", activeLocks: 1 }, + ], + apiKeys: [ + { id: "1-stage", name: "Staging Key", prefix: "ck_stage_", createdAt: "2024-03-10", lastUsed: "Hace 30m" }, + ], + }, + prod: { + resources: [ + { id: "1-prod", name: "transaction_slot", mode: "unit", status: "active", activeReservations: 12 }, + ], + locks: [ + { id: "1-prod", name: "payment_gateway", type: "exclusive", status: "active", activeLocks: 8 }, + ], + apiKeys: [ + { id: "1-prod", name: "Production Key", prefix: "ck_live_", createdAt: "2024-03-10", lastUsed: "Hace 30s" }, + ], + }, + }, +} + +export const generateMockDataForApp = (name: string): AppEnvironmentsData => { + return { + dev: { + resources: [ + { id: "custom-1-dev", name: `${name}_resource_dev`, mode: "multiple", status: "active", activeReservations: 1 }, + ], + locks: [ + { id: "custom-lock-1-dev", name: `${name}_lock_dev`, type: "exclusive", status: "active", activeLocks: 0 }, + ], + apiKeys: [ + { id: "custom-key-1-dev", name: "Development Key", prefix: "ck_test_", createdAt: new Date().toISOString().split('T')[0], lastUsed: "Hace 10m" }, + ], + }, + staging: { + resources: [ + { id: "custom-1-stage", name: `${name}_resource_staging`, mode: "multiple", status: "active", activeReservations: 2 }, + ], + locks: [ + { id: "custom-lock-1-stage", name: `${name}_lock_staging`, type: "exclusive", status: "active", activeLocks: 1 }, + ], + apiKeys: [ + { id: "custom-key-1-stage", name: "Staging Key", prefix: "ck_stage_", createdAt: new Date().toISOString().split('T')[0], lastUsed: "Hace 1d" }, + ], + }, + prod: { + resources: [ + { id: "custom-1-prod", name: `${name}_resource`, mode: "multiple", status: "active", activeReservations: 5 }, + ], + locks: [ + { id: "custom-lock-1-prod", name: `${name}_lock`, type: "exclusive", status: "active", activeLocks: 2 }, + ], + apiKeys: [ + { id: "custom-key-1-prod", name: "Production Key", prefix: "ck_live_", createdAt: new Date().toISOString().split('T')[0], lastUsed: "Hace 30s" }, + ], + }, + } +} + +export const getMockDataForEnv = (appName: string, envName: string): EnvData => { + const predefined = appEnvironmentsMock[appName] + if (predefined) { + if (envName === "dev" || envName === "development") return predefined.dev + if (envName === "stage" || envName === "staging") return predefined.staging + if (envName === "prod" || envName === "production") return predefined.prod + } + + return { + resources: [ + { id: `${envName}-res-1`, name: `${appName}_res_${envName}`, mode: "multiple", status: "active", activeReservations: 3 }, + ], + locks: [ + { id: `${envName}-lock-1`, name: `${appName}_lock_${envName}`, type: "exclusive", status: "active", activeLocks: 1 }, + ], + apiKeys: [ + { id: `${envName}-key-1`, name: `Key ${envName}`, prefix: "ck_live_", createdAt: new Date().toISOString().split('T')[0], lastUsed: "Hace 5m" }, + ], + } +} + +export const formatTtl = (ms: any): string => { + const num = Number(ms) + if (isNaN(num)) return String(ms) + if (num < 1000) return `${num} ms` + const seconds = num / 1000 + if (seconds < 60) return `${seconds.toFixed(seconds % 1 === 0 ? 0 : 1)} s` + const minutes = seconds / 60 + if (minutes < 60) return `${minutes.toFixed(minutes % 1 === 0 ? 0 : 1)} min` + const hours = minutes / 60 + if (hours < 24) return `${hours.toFixed(hours % 1 === 0 ? 0 : 1)} h` + const days = hours / 24 + return `${days.toFixed(days % 1 === 0 ? 0 : 1)} d` +} diff --git a/lib/mocks/dashboard.ts b/lib/mocks/dashboard.ts new file mode 100644 index 0000000..b7b8fe5 --- /dev/null +++ b/lib/mocks/dashboard.ts @@ -0,0 +1,62 @@ +import { Layers, Activity, Lock, Gauge } from "lucide-react" + +export const dashboardStatsMock = [ + { + name: "Aplicaciones", + value: "3", + icon: Layers, + }, + { + name: "Llamadas de API (30d)", + value: "84.2k", + icon: Activity, + }, + { + name: "Bloqueos activos", + value: "12", + icon: Lock, + }, + { + name: "Uso del plan", + value: "68%", + icon: Gauge, + }, +] + +export const recentActivityMock = [ + { + id: 1, + event: "lock.acquired", + application: "payment-sync", + environment: "prod", + time: "Hace 2s", + }, + { + id: 2, + event: "reserve.confirmed", + application: "reserva-engine", + environment: "prod", + time: "Hace 15s", + }, + { + id: 3, + event: "lock.released", + application: "lock-service", + environment: "dev", + time: "Hace 32s", + }, + { + id: 4, + event: "reserve.expired", + application: "reserva-engine", + environment: "prod", + time: "Hace 1m", + }, + { + id: 5, + event: "api_key.created", + application: "payment-sync", + environment: "prod", + time: "Hace 5m", + }, +] diff --git a/types/api-key.ts b/types/api-key.ts new file mode 100644 index 0000000..48a8ce1 --- /dev/null +++ b/types/api-key.ts @@ -0,0 +1,9 @@ +export interface ApiKey { + id: string + name: string + prefix: string + environment: 'dev' | 'staging' | 'prod' + applicationName: string + createdAt: string + lastUsed: string +} diff --git a/types/application.ts b/types/application.ts new file mode 100644 index 0000000..ddb2b88 --- /dev/null +++ b/types/application.ts @@ -0,0 +1,43 @@ +export interface Application { + id: string + name: string + description?: string + environmentsCount: number + resourcesCount: number + locksCount: number + status: 'active' | 'inactive' | 'archived' + createdAt: string + updatedAt: string +} + +export interface ResourceItem { + id: string + name: string + mode: 'unit' | 'multiple' | string + status: 'active' | 'paused' | 'archived' | string + activeReservations: number +} + +export interface LockItem { + id: string + name: string + type: 'exclusive' | 'read-write' | string + status: 'active' | 'paused' | string + activeLocks: number +} + +export interface ApiKeyItem { + id: string + name: string + prefix: string + createdAt: string + lastUsed: string +} + +export interface EnvData { + resources: ResourceItem[] + locks: LockItem[] + apiKeys: ApiKeyItem[] +} + +export type AppEnvironmentsData = Record<'dev' | 'staging' | 'prod', EnvData> diff --git a/types/billing.ts b/types/billing.ts new file mode 100644 index 0000000..8a43a62 --- /dev/null +++ b/types/billing.ts @@ -0,0 +1,17 @@ +export interface Plan { + id: string + name: string + price: string + description: string + features: string[] + isCurrent: boolean + isPopular?: boolean +} + +export interface Invoice { + id: string + date: string + amount: string + status: 'paid' | 'pending' | 'failed' + downloadUrl: string +} diff --git a/types/index.ts b/types/index.ts new file mode 100644 index 0000000..4ee10c5 --- /dev/null +++ b/types/index.ts @@ -0,0 +1,5 @@ +export * from './application' +export * from './resource' +export * from './lock' +export * from './api-key' +export * from './billing' diff --git a/types/lock.ts b/types/lock.ts new file mode 100644 index 0000000..7782799 --- /dev/null +++ b/types/lock.ts @@ -0,0 +1,17 @@ +export interface Lock { + id: string + namespace: string + description?: string + type: 'exclusive' | 'read-write' + ttl: number + deadlockStrategy: 'alert' | 'kill' + webhookUrl?: string + acquisitionStrategy: 'fail' | 'retry' | 'blocking' + retryInterval?: number + maxRetries?: number + requireFencingToken: boolean + status: 'active' | 'paused' + activeLocks: number + createdAt: string + updatedAt: string +} diff --git a/types/resource.ts b/types/resource.ts new file mode 100644 index 0000000..c59b50d --- /dev/null +++ b/types/resource.ts @@ -0,0 +1,17 @@ +export interface Resource { + id: string + name: string + description?: string + mode: 'unit' | 'multiple' + ttl: number + saveMetadata: boolean + conflictStrategy: 'fail' | 'retry' | 'queue' + retryInterval?: number + maxRetries?: number + idempotency: boolean + notificationWebhookUrl?: string + status: 'active' | 'paused' | 'archived' + activeReservations: number + createdAt: string + updatedAt: string +} From 0ce532debbb3882718da6c0aff119ec785c8428f Mon Sep 17 00:00:00 2001 From: twinik Date: Sun, 2 Aug 2026 17:53:54 -0300 Subject: [PATCH 04/12] fix(dashboard): consume real backend data for applications and environment locks --- .gitignore | 1 + app/dashboard/applications/[id]/page.tsx | 26 +++++++------------- app/dashboard/applications/page.tsx | 30 +++++++----------------- next-env.d.ts | 2 +- 4 files changed, 19 insertions(+), 40 deletions(-) diff --git a/.gitignore b/.gitignore index 51e874a..b62c050 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ node_modules .DS_Store *.tsbuildinfo .vercel +.claude/ \ No newline at end of file diff --git a/app/dashboard/applications/[id]/page.tsx b/app/dashboard/applications/[id]/page.tsx index f3e9462..8a97919 100644 --- a/app/dashboard/applications/[id]/page.tsx +++ b/app/dashboard/applications/[id]/page.tsx @@ -35,7 +35,6 @@ import { DialogTitle, } from "@/components/ui/dialog" -import { getMockDataForEnv } from "@/lib/mocks/applications" import { ResourcesTab } from "@/components/dashboard/applications/tabs/resources-tab" import { LocksTab } from "@/components/dashboard/applications/tabs/locks-tab" import { ApiKeysTab } from "@/components/dashboard/applications/tabs/api-keys-tab" @@ -51,6 +50,7 @@ export default function ApplicationDetailPage({ const [selectedEnv, setSelectedEnv] = useState("") const [currentEnvDetails, setCurrentEnvDetails] = useState(null) const [templates, setTemplates] = useState([]) + const [locks, setLocks] = useState([]) const [isTemplatesLoading, setIsTemplatesLoading] = useState(false) const [confirmDeleteTemplateOpen, setConfirmDeleteTemplateOpen] = useState(false) const [templateToDelete, setTemplateToDelete] = useState(null) @@ -63,10 +63,6 @@ export default function ApplicationDetailPage({ const [createdRawKey, setCreatedRawKey] = useState("") const [copiedKey, setCopiedKey] = useState(false) - const currentEnvData = app && selectedEnv - ? getMockDataForEnv(app.name, selectedEnv) - : { resources: [], locks: [], apiKeys: [] } - useEffect(() => { const fetchApp = async () => { try { @@ -83,7 +79,7 @@ export default function ApplicationDetailPage({ envs[0]; initialEnv = pref.name; } - setSelectedEnv(initialEnv) + setSelectedEnv(initialEnv || "dev") setApp({ id: data.id.toString(), @@ -98,7 +94,7 @@ export default function ApplicationDetailPage({ myRole: data.myRole || "VIEWER", }) } else { - console.error("Failed to fetch application details") + console.error("Failed to fetch application details from backend") } } catch (error) { console.error("Error fetching application details:", error) @@ -127,6 +123,7 @@ export default function ApplicationDetailPage({ if (envRes.ok) { const envData = await envRes.json(); setCurrentEnvDetails(envData); + setLocks(envData.locks || []); } if (templatesRes.ok) { @@ -139,7 +136,7 @@ export default function ApplicationDetailPage({ setApiKeys(keysData.content || []); } } catch (error) { - console.error("Error fetching environment details, templates or keys:", error); + console.error("Error fetching environment details from backend:", error); } finally { setIsTemplatesLoading(false); setIsApiKeysLoading(false); @@ -369,7 +366,7 @@ export default function ApplicationDetailPage({
-
{currentEnvData.locks.length}
+
{locks.length}
@@ -394,14 +391,7 @@ export default function ApplicationDetailPage({
- {((((selectedEnv === "prod" || selectedEnv === "production") - ? 145230900 - : (selectedEnv === "stage" || selectedEnv === "staging") - ? 28920400 - : 4520300) + - currentEnvData.resources.reduce((sum: number, r: any) => sum + r.activeReservations, 0) + - currentEnvData.locks.reduce((sum: number, l: any) => sum + l.activeLocks, 0) - ).toLocaleString("es-AR"))} + 0
@@ -464,7 +454,7 @@ export default function ApplicationDetailPage({ diff --git a/app/dashboard/applications/page.tsx b/app/dashboard/applications/page.tsx index 3117eae..9a17d4f 100644 --- a/app/dashboard/applications/page.tsx +++ b/app/dashboard/applications/page.tsx @@ -82,35 +82,22 @@ export default function ApplicationsPage() { const [appToDelete, setAppToDelete] = useState(null) useEffect(() => { + let isMounted = true; const fetchApps = async () => { try { const res = await fetch("/api/applications"); if (res.ok) { const data = await res.json(); - if (data && data.content) { - const mapped = data.content.map((app: any, index: number) => { + if (isMounted && data && Array.isArray(data.content)) { + const mapped = data.content.map((app: any) => { const envs = app.environments ? app.environments.map((env: any) => env.name) : []; - let collaborators = 1; - let apiCalls = 0; - - if (index === 0) { - collaborators = 4; - apiCalls = 125430; - } else if (index === 1) { - collaborators = 2; - apiCalls = 89210; - } else if (index === 2) { - collaborators = 3; - apiCalls = 45600; - } - return { id: app.id.toString(), name: app.name, description: app.description || "", environments: envs, - collaborators, - apiCalls, + collaborators: app.collaboratorsCount || 1, + apiCalls: app.apiCallsCount || 0, createdAt: app.createdAt || new Date().toISOString(), status: "active", myRole: app.myRole || "VIEWER", @@ -124,10 +111,11 @@ export default function ApplicationsPage() { } catch (error) { console.error("Error fetching applications:", error); } finally { - setIsLoading(false); + if (isMounted) setIsLoading(false); } }; fetchApps(); + return () => { isMounted = false; }; }, []); const filteredApps = applications.filter( @@ -150,7 +138,7 @@ export default function ApplicationsPage() { if (response.ok) { setApplications((prev) => prev.filter((app) => app.id !== appToDelete.id)) } else { - console.error("Failed to delete application"); + console.error("Failed to delete application from backend"); } } catch (error) { console.error("Error deleting application:", error); @@ -285,7 +273,7 @@ export default function ApplicationsPage() { - Configuracion + Configuración )} diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From 12030dfceb0b250073642ec524c141b0bc71cfb6 Mon Sep 17 00:00:00 2001 From: twinik Date: Sun, 2 Aug 2026 18:52:27 -0300 Subject: [PATCH 05/12] fix(dashboard): add skeleton loader and deduplicate environment dots --- app/dashboard/applications/[id]/page.tsx | 25 +++---- .../application-detail-skeleton.tsx | 68 +++++++++++++++++++ components/dashboard/sidebar.tsx | 10 +-- lib/utils.ts | 26 +++++++ 4 files changed, 112 insertions(+), 17 deletions(-) create mode 100644 components/dashboard/applications/application-detail-skeleton.tsx diff --git a/app/dashboard/applications/[id]/page.tsx b/app/dashboard/applications/[id]/page.tsx index 8a97919..5c322a2 100644 --- a/app/dashboard/applications/[id]/page.tsx +++ b/app/dashboard/applications/[id]/page.tsx @@ -35,6 +35,8 @@ import { DialogTitle, } from "@/components/ui/dialog" +import { Skeleton } from "@/components/ui/skeleton" +import { ApplicationDetailSkeleton } from "@/components/dashboard/applications/application-detail-skeleton" import { ResourcesTab } from "@/components/dashboard/applications/tabs/resources-tab" import { LocksTab } from "@/components/dashboard/applications/tabs/locks-tab" import { ApiKeysTab } from "@/components/dashboard/applications/tabs/api-keys-tab" @@ -232,11 +234,7 @@ export default function ApplicationDetailPage({ } if (isLoading) { - return ( -
- -
- ) + return } if (!app) { @@ -397,25 +395,28 @@ export default function ApplicationDetailPage({
- {currentEnvDetails?.description && ( + {isTemplatesLoading && !currentEnvDetails ? ( + + ) : currentEnvDetails?.description ? (

{currentEnvDetails.description}

- )} + ) : null} {/* Tabs */} diff --git a/components/dashboard/applications/application-detail-skeleton.tsx b/components/dashboard/applications/application-detail-skeleton.tsx new file mode 100644 index 0000000..b534792 --- /dev/null +++ b/components/dashboard/applications/application-detail-skeleton.tsx @@ -0,0 +1,68 @@ +import { Skeleton } from "@/components/ui/skeleton" +import { Card } from "@/components/ui/card" + +export function ApplicationDetailSkeleton() { + return ( +
+ {/* Header section */} +
+ {/* Back link skeleton */} + + + {/* Title, Badge, Select and Buttons */} +
+
+ + +
+ +
+ +
+ + +
+
+
+ + {/* App description skeleton */} +
+ + +
+
+ + {/* Stats Grid Skeleton */} +
+ {Array.from({ length: 4 }).map((_, i) => ( + +
+ + +
+ +
+ ))} +
+ + {/* Environment Description Banner Skeleton */} + + + {/* Tabs & Content Skeleton */} +
+ + +
+ + +
+
+ + + +
+
+
+
+ ) +} diff --git a/components/dashboard/sidebar.tsx b/components/dashboard/sidebar.tsx index baf9def..940d180 100644 --- a/components/dashboard/sidebar.tsx +++ b/components/dashboard/sidebar.tsx @@ -18,7 +18,7 @@ import { ChevronRight, ChevronDown, } from "lucide-react" -import { cn, getEnvColors } from "@/lib/utils" +import { cn, getEnvColors, getUniqueEnvDots } from "@/lib/utils" import { DropdownMenu, DropdownMenuContent, @@ -268,11 +268,11 @@ export function DashboardSidebar({ isCollapsed = false, setIsCollapsed }: Dashbo
{!isCollapsed && app.environments.length > 0 && ( - {app.environments.map((env: string) => ( + {getUniqueEnvDots(app.environments).map(({ kind, colors }) => ( ))} diff --git a/lib/utils.ts b/lib/utils.ts index 0f7ea44..6a14afd 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -50,3 +50,29 @@ export function getEnvColors(envName?: string | null) { } as const return map[kind] } + +export function getUniqueEnvDots(environments?: (string | { name: string })[] | null) { + if (!environments || environments.length === 0) return [] + + const seenKinds = new Set() + const order: EnvKind[] = ['dev', 'staging', 'prod'] + const result: { kind: EnvKind; colors: ReturnType }[] = [] + + const names = environments.map(e => (typeof e === 'string' ? e : e.name)) + + for (const name of names) { + seenKinds.add(envKind(name)) + } + + for (const kind of order) { + if (seenKinds.has(kind)) { + result.push({ + kind, + colors: getEnvColors(kind), + }) + } + } + + return result +} + From 25eda1cbde51358aaee36f70b4e7aa12f0637089 Mon Sep 17 00:00:00 2001 From: twinik Date: Sun, 2 Aug 2026 19:02:52 -0300 Subject: [PATCH 06/12] feat(environments): support custom color overrides and template duplication --- app/dashboard/applications/[id]/page.tsx | 148 ++++++++++------ .../applications/[id]/settings/page.tsx | 29 ++- .../duplicate-template-dialog.tsx | 167 ++++++++++++++++++ .../applications/tabs/resources-tab.tsx | 19 +- components/dashboard/resource-form.tsx | 9 +- components/dashboard/sidebar.tsx | 29 +-- lib/utils.ts | 99 +++++++++-- 7 files changed, 416 insertions(+), 84 deletions(-) create mode 100644 components/dashboard/applications/duplicate-template-dialog.tsx diff --git a/app/dashboard/applications/[id]/page.tsx b/app/dashboard/applications/[id]/page.tsx index 5c322a2..072efbc 100644 --- a/app/dashboard/applications/[id]/page.tsx +++ b/app/dashboard/applications/[id]/page.tsx @@ -2,6 +2,7 @@ import Link from "next/link" import { use, useState, useEffect } from "react" +import { useSearchParams, useRouter } from "next/navigation" import { Box, Lock, @@ -40,6 +41,7 @@ import { ApplicationDetailSkeleton } from "@/components/dashboard/applications/a import { ResourcesTab } from "@/components/dashboard/applications/tabs/resources-tab" import { LocksTab } from "@/components/dashboard/applications/tabs/locks-tab" import { ApiKeysTab } from "@/components/dashboard/applications/tabs/api-keys-tab" +import { DuplicateTemplateDialog } from "@/components/dashboard/applications/duplicate-template-dialog" export default function ApplicationDetailPage({ params, @@ -47,6 +49,9 @@ export default function ApplicationDetailPage({ params: Promise<{ id: string }> }) { const { id } = use(params) + const searchParams = useSearchParams() + const router = useRouter() + const [app, setApp] = useState(null) const [isLoading, setIsLoading] = useState(true) const [selectedEnv, setSelectedEnv] = useState("") @@ -57,6 +62,9 @@ export default function ApplicationDetailPage({ const [confirmDeleteTemplateOpen, setConfirmDeleteTemplateOpen] = useState(false) const [templateToDelete, setTemplateToDelete] = useState(null) + const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false) + const [templateToDuplicate, setTemplateToDuplicate] = useState(null) + const [apiKeys, setApiKeys] = useState([]) const [isApiKeysLoading, setIsApiKeysLoading] = useState(false) const [confirmRevokeKeyOpen, setConfirmRevokeKeyOpen] = useState(false) @@ -72,15 +80,20 @@ export default function ApplicationDetailPage({ if (res.ok) { const data = await res.json() const envs = data.environments || [] + + const queryEnv = searchParams.get("env") || searchParams.get("envName") + const savedEnv = typeof window !== "undefined" ? localStorage.getItem(`caerus_env_${id}`) : null - let initialEnv = "" - if (envs.length > 0) { + let initialEnv = queryEnv || savedEnv || "" + + if (!initialEnv || !envs.some((e: any) => e.name === initialEnv)) { const pref = envs.find((e: any) => e.name === "dev" || e.name === "development") || envs.find((e: any) => e.name === "stage" || e.name === "staging") || envs.find((e: any) => e.name === "prod" || e.name === "production") || - envs[0]; - initialEnv = pref.name; + envs[0] + initialEnv = pref ? pref.name : "" } + setSelectedEnv(initialEnv || "dev") setApp({ @@ -105,48 +118,56 @@ export default function ApplicationDetailPage({ } } fetchApp() - }, [id]) + }, [id, searchParams]) useEffect(() => { - if (!app || !app.environments || !selectedEnv) return; - const activeEnvObj = app.environments.find((env: any) => env.name === selectedEnv); - if (!activeEnvObj) return; + if (!app || !app.environments || !selectedEnv) return + const activeEnvObj = app.environments.find((env: any) => env.name === selectedEnv) + if (!activeEnvObj) return const fetchEnvDetailsAndTemplates = async () => { - setIsTemplatesLoading(true); - setIsApiKeysLoading(true); + setIsTemplatesLoading(true) + setIsApiKeysLoading(true) try { const [envRes, templatesRes, keysRes] = await Promise.all([ fetch(`/api/applications/${id}/environments/${activeEnvObj.id}`), fetch(`/api/shared-resource-templates?environmentId=${activeEnvObj.id}`), fetch(`/api/environments/${activeEnvObj.id}/api-keys`), - ]); + ]) if (envRes.ok) { - const envData = await envRes.json(); - setCurrentEnvDetails(envData); - setLocks(envData.locks || []); + const envData = await envRes.json() + setCurrentEnvDetails(envData) + setLocks(envData.locks || []) } if (templatesRes.ok) { - const templatesData = await templatesRes.json(); - setTemplates(templatesData.content || []); + const templatesData = await templatesRes.json() + setTemplates(templatesData.content || []) } if (keysRes.ok) { - const keysData = await keysRes.json(); - setApiKeys(keysData.content || []); + const keysData = await keysRes.json() + setApiKeys(keysData.content || []) } } catch (error) { - console.error("Error fetching environment details from backend:", error); + console.error("Error fetching environment details from backend:", error) } finally { - setIsTemplatesLoading(false); - setIsApiKeysLoading(false); + setIsTemplatesLoading(false) + setIsApiKeysLoading(false) } - }; - fetchEnvDetailsAndTemplates(); + } + fetchEnvDetailsAndTemplates() }, [selectedEnv, app]) + const handleEnvChange = (val: string) => { + setSelectedEnv(val) + if (typeof window !== "undefined") { + localStorage.setItem(`caerus_env_${id}`, val) + } + router.replace(`/dashboard/applications/${id}?env=${encodeURIComponent(val)}`, { scroll: false }) + } + const handleCreateApiKey = async () => { if (!currentEnvDetails) return try { @@ -159,7 +180,7 @@ export default function ApplicationDetailPage({ setShowCreatedKeyDialog(true) setApiKeys((prev) => [data, ...prev]) } else { - console.error("Failed to create API key") + console.error("Failed to create API Key") } } catch (error) { console.error("Error creating API key:", error) @@ -172,15 +193,15 @@ export default function ApplicationDetailPage({ } const handleRevokeKeyConfirm = async () => { - if (!keyToRevoke) return + if (!keyToRevoke || !currentEnvDetails) return try { - const res = await fetch(`/api/environments/${currentEnvDetails.id}/api-keys/${keyToRevoke.id}/revoke`, { - method: "POST", - }) + const res = await fetch( + `/api/environments/${currentEnvDetails.id}/api-keys/${keyToRevoke.id}/revoke`, + { method: "POST" } + ) if (res.ok) { - const data = await res.json() setApiKeys((prev) => - prev.map((k) => (k.id === keyToRevoke.id ? data : k)) + prev.map((k) => (k.id === keyToRevoke.id ? { ...k, state: "REVOKED" } : k)) ) setConfirmRevokeKeyOpen(false) setKeyToRevoke(null) @@ -192,11 +213,12 @@ export default function ApplicationDetailPage({ } } - const copyRawKeyToClipboard = async () => { - if (!createdRawKey) return - await navigator.clipboard.writeText(createdRawKey) - setCopiedKey(true) - setTimeout(() => setCopiedKey(false), 2000) + const copyRawKeyToClipboard = () => { + if (createdRawKey) { + navigator.clipboard.writeText(createdRawKey) + setCopiedKey(true) + setTimeout(() => setCopiedKey(false), 2000) + } } const handleOpenDeleteTemplate = (template: any) => { @@ -222,6 +244,22 @@ export default function ApplicationDetailPage({ } } + const handleOpenDuplicateTemplate = (template: any) => { + setTemplateToDuplicate(template) + setDuplicateDialogOpen(true) + } + + const handleDuplicateSuccess = () => { + if (app && selectedEnv) { + const activeEnvObj = app.environments.find((env: any) => env.name === selectedEnv) + if (activeEnvObj) { + fetch(`/api/shared-resource-templates?environmentId=${activeEnvObj.id}`) + .then((res) => res.json()) + .then((data) => setTemplates(data.content || [])) + } + } + } + const getStatusBadgeClass = (status: string) => { switch (status) { case "active": @@ -248,6 +286,8 @@ export default function ApplicationDetailPage({ ) } + const descriptionText = currentEnvDetails?.description || (currentEnvDetails?.name && app?.name ? `Entorno de ${currentEnvDetails.name} para ${app.name}` : null) + return (
{/* Breadcrumb and header */} @@ -260,11 +300,8 @@ export default function ApplicationDetailPage({ Volver a Aplicaciones
-
-
- -
-
+
+

{app.name}

{app.status} @@ -275,7 +312,7 @@ export default function ApplicationDetailPage({