diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/kooltab_backoffice.iml b/.idea/kooltab_backoffice.iml new file mode 100644 index 0000000..c956989 --- /dev/null +++ b/.idea/kooltab_backoffice.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..d4c3a9d --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index e3a5c1f..5614c2a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,8 +37,9 @@ COPY --from=builder /app/packages/shared/package.json ./packages/shared/ COPY --from=builder /app/packages/shared/dist ./packages/shared/dist/ RUN pnpm install --prod +ENV NODE_ENV=production EXPOSE 3001 -CMD ["sh", "-c", "npx prisma db push --accept-data-loss && node dist/main.js"] +CMD ["sh", "-c", "node prisma/deploy.js && node dist/main.js"] # ---- Production Dashboard image ---- FROM node:20-alpine AS dashboard diff --git a/apps/api/prisma/deploy.js b/apps/api/prisma/deploy.js new file mode 100644 index 0000000..5b496bc --- /dev/null +++ b/apps/api/prisma/deploy.js @@ -0,0 +1,55 @@ +/** + * Applies database migrations on container start. + * + * Databases created before migrations were used (via `prisma db push`) have no + * `_prisma_migrations` table. Those are brought up to date with a non-destructive + * `db push` (which refuses to drop data) and then baselined, so every later deploy + * can use plain `prisma migrate deploy`. + */ +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const { PrismaClient } = require('@prisma/client'); + +function run(command) { + console.log(`[deploy] ${command}`); + execSync(command, { stdio: 'inherit' }); +} + +async function main() { + const prisma = new PrismaClient(); + let hasMigrationsTable; + let hasTables; + try { + const [{ migrations, tables }] = await prisma.$queryRaw` + SELECT + to_regclass('public._prisma_migrations') IS NOT NULL AS migrations, + EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public') AS tables`; + hasMigrationsTable = migrations; + hasTables = tables; + } finally { + await prisma.$disconnect(); + } + + if (!hasMigrationsTable && hasTables) { + console.log('[deploy] Existing database without migration history, baselining'); + run('npx prisma db push --skip-generate'); + + const migrationsDir = path.join(__dirname, 'migrations'); + const migrations = fs + .readdirSync(migrationsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + for (const name of migrations) { + run(`npx prisma migrate resolve --applied ${name}`); + } + } + + run('npx prisma migrate deploy'); +} + +main().catch((error) => { + console.error('[deploy] Migration failed:', error); + process.exit(1); +}); diff --git a/apps/api/prisma/migrations/20260926201526_add_reminders/migration.sql b/apps/api/prisma/migrations/20260926201526_add_reminders/migration.sql new file mode 100644 index 0000000..53d3a15 --- /dev/null +++ b/apps/api/prisma/migrations/20260926201526_add_reminders/migration.sql @@ -0,0 +1,25 @@ +-- AlterTable +ALTER TABLE "PlannedTab" ADD COLUMN "email" TEXT; + +-- CreateTable +CREATE TABLE "TabReminder" ( + "id" TEXT NOT NULL, + "venueId" TEXT NOT NULL, + "tabId" TEXT NOT NULL, + "customerName" TEXT NOT NULL, + "email" TEXT NOT NULL, + "amountInCents" INTEGER NOT NULL, + "sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "status" TEXT NOT NULL DEFAULT 'sent', + + CONSTRAINT "TabReminder_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "TabReminder_venueId_sentAt_idx" ON "TabReminder"("venueId", "sentAt"); + +-- CreateIndex +CREATE INDEX "TabReminder_venueId_tabId_idx" ON "TabReminder"("venueId", "tabId"); + +-- AddForeignKey +ALTER TABLE "TabReminder" ADD CONSTRAINT "TabReminder_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 38febf0..ce44e27 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -20,6 +20,7 @@ model Venue { auditLogs AuditLog[] regCodes RegistrationCode[] plannedTabs PlannedTab[] + tabReminders TabReminder[] } model Device { @@ -148,6 +149,7 @@ model PlannedTab { id String @id @default(uuid()) venueId String customerName String + email String? position Int @default(0) createdAt DateTime @default(now()) @@ -155,4 +157,20 @@ model PlannedTab { @@unique([venueId, customerName]) @@index([venueId]) +} + +model TabReminder { + id String @id @default(uuid()) + venueId String + tabId String + customerName String + email String + amountInCents Int + sentAt DateTime @default(now()) + status String @default("sent") + + venue Venue @relation(fields: [venueId], references: [id]) + + @@index([venueId, sentAt]) + @@index([venueId, tabId]) } \ No newline at end of file diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 343547e..504088f 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -11,6 +11,7 @@ import { CommandsModule } from './commands/commands.module'; import { AuditModule } from './audit/audit.module'; import { JobsModule } from './jobs/jobs.module'; import { PlannedTabsModule } from './planned-tabs/planned-tabs.module'; +import { RemindersModule } from './reminders/reminders.module'; import { HealthController } from './health.controller'; @Module({ @@ -26,6 +27,7 @@ import { HealthController } from './health.controller'; AuditModule, JobsModule, PlannedTabsModule, + RemindersModule, ], controllers: [HealthController], providers: [ diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 38c878f..49fc645 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -1,7 +1,9 @@ import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { AuthService } from './auth.service'; import { LoginDto } from './dto/login.dto'; +import { RefreshTokenDto } from './dto/refresh-token.dto'; @ApiTags('Authentication') @Controller('auth') @@ -9,6 +11,7 @@ export class AuthController { constructor(private authService: AuthService) {} @Post('login') + @Throttle({ default: { limit: 10, ttl: 60000 } }) @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Login with email and password' }) async login(@Body() dto: LoginDto) { @@ -18,15 +21,15 @@ export class AuthController { @Post('refresh') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Refresh access token' }) - async refresh(@Body('refreshToken') refreshToken: string) { - return this.authService.refresh(refreshToken); + async refresh(@Body() dto: RefreshTokenDto) { + return this.authService.refresh(dto.refreshToken); } @Post('logout') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Logout and revoke refresh token' }) - async logout(@Body('refreshToken') refreshToken: string) { - await this.authService.logout(refreshToken); + async logout(@Body() dto: RefreshTokenDto) { + await this.authService.logout(dto.refreshToken); return { ok: true }; } } \ No newline at end of file diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index 5d33280..ced1668 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -5,12 +5,13 @@ import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { JwtStrategy } from './strategies/jwt.strategy'; import { DeviceStrategy } from './strategies/device.strategy'; +import { getJwtSecret } from './jwt-secret'; @Module({ imports: [ PassportModule.register({ defaultStrategy: 'jwt' }), JwtModule.register({ - secret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production', + secret: getJwtSecret(), signOptions: { expiresIn: '24h' }, }), ], diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 2189145..2126b22 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -129,33 +129,46 @@ export class AuthService { throw new UnauthorizedException({ code: 'venue_mismatch', message: 'Registration code does not match venue' }); } - const { rawToken, hash, prefix, expiresAt } = await this.generateDeviceToken(); - const deviceId = uuidv4(); - - // One device per venue — revoke any existing device - const existing = await this.prisma.device.findFirst({ where: { venueId, revokedAt: null } }); - if (existing) { - await this.prisma.device.update({ - where: { id: existing.id }, - data: { revokedAt: new Date() }, - }); - } - - await this.prisma.device.create({ - data: { - id: deviceId, - venueId, - name, - tokenHash: hash, - tokenPrefix: prefix, - tokenExpiresAt: expiresAt, - }, - }); - - await this.prisma.registrationCode.update({ - where: { id: code.id }, + // Claim the code atomically so it can only ever be used once + const claimed = await this.prisma.registrationCode.updateMany({ + where: { id: code.id, usedAt: null }, data: { usedAt: new Date() }, }); + if (claimed.count === 0) { + throw new UnauthorizedException({ code: 'invalid_registration_code', message: 'Invalid or expired registration code' }); + } + + const { rawToken, hash, prefix, expiresAt } = await this.generateDeviceToken(); + let deviceId: string; + + // One device per venue — update existing or create new + const existing = await this.prisma.device.findFirst({ where: { venueId } }); + if (existing) { + deviceId = existing.id; + await this.prisma.device.update({ + where: { id: existing.id }, + data: { + name, + tokenHash: hash, + tokenPrefix: prefix, + tokenExpiresAt: expiresAt, + revokedAt: null, + lastSeenAt: new Date(), + }, + }); + } else { + deviceId = uuidv4(); + await this.prisma.device.create({ + data: { + id: deviceId, + venueId, + name, + tokenHash: hash, + tokenPrefix: prefix, + tokenExpiresAt: expiresAt, + }, + }); + } await this.auditService.log(venueId, 'device', deviceId, 'device_registered', { deviceName: name }); diff --git a/apps/api/src/auth/dto/refresh-token.dto.ts b/apps/api/src/auth/dto/refresh-token.dto.ts new file mode 100644 index 0000000..dd80ded --- /dev/null +++ b/apps/api/src/auth/dto/refresh-token.dto.ts @@ -0,0 +1,8 @@ +import { IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class RefreshTokenDto { + @ApiProperty({ example: 'refresh-token-value' }) + @IsString() + refreshToken: string; +} \ No newline at end of file diff --git a/apps/api/src/auth/jwt-secret.ts b/apps/api/src/auth/jwt-secret.ts new file mode 100644 index 0000000..dde0186 --- /dev/null +++ b/apps/api/src/auth/jwt-secret.ts @@ -0,0 +1,20 @@ +import { Logger } from '@nestjs/common'; + +const DEV_FALLBACK_SECRET = 'dev-jwt-secret-change-in-production'; + +let warned = false; + +export function getJwtSecret(): string { + const secret = process.env.JWT_SECRET; + if (secret) { + return secret; + } + if (process.env.NODE_ENV === 'production') { + throw new Error('JWT_SECRET must be set in production'); + } + if (!warned) { + new Logger('Auth').warn('JWT_SECRET is not set — using an insecure development secret'); + warned = true; + } + return DEV_FALLBACK_SECRET; +} diff --git a/apps/api/src/auth/strategies/jwt.strategy.ts b/apps/api/src/auth/strategies/jwt.strategy.ts index d1c98c0..b87b712 100644 --- a/apps/api/src/auth/strategies/jwt.strategy.ts +++ b/apps/api/src/auth/strategies/jwt.strategy.ts @@ -1,6 +1,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; +import { getJwtSecret } from '../jwt-secret'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { @@ -8,7 +9,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, - secretOrKey: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production', + secretOrKey: getJwtSecret(), }); } diff --git a/apps/api/src/commands/commands.controller.ts b/apps/api/src/commands/commands.controller.ts index f93a9d0..2d3c5ac 100644 --- a/apps/api/src/commands/commands.controller.ts +++ b/apps/api/src/commands/commands.controller.ts @@ -10,17 +10,18 @@ import { HttpStatus, Req, } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiResponse } from '@nestjs/swagger'; import { CommandsService } from './commands.service'; import { DevicesService } from '../devices/devices.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../common/guards/roles.guard'; import { Roles } from '../common/decorators/roles.decorator'; +import { VenueAccessGuard } from '../common/guards/venue-access.guard'; import { CreateCommandDto } from './dto/create-command.dto'; @ApiTags('Commands') @ApiBearerAuth() -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, VenueAccessGuard) @Controller('venues/:venueId/commands') export class CommandsController { constructor( @@ -51,15 +52,16 @@ export class CommandsController { ) { return this.commandsService.findAll(venueId, { status, - limit: limit ? parseInt(limit, 10) : 50, - offset: offset ? parseInt(offset, 10) : 0, + limit: Math.min(Math.max(parseInt(limit ?? '', 10) || 50, 1), 200), + offset: Math.max(parseInt(offset ?? '', 10) || 0, 0), }); } @Post('devices/:deviceId/revoke') @UseGuards(RolesGuard) @Roles('admin') - @ApiOperation({ summary: 'Revoke a device' }) + @ApiOperation({ summary: 'Revoke a device (admin only)' }) + @ApiResponse({ status: 200, description: 'Device revoked' }) async revoke( @Param('venueId') venueId: string, @Param('deviceId') deviceId: string, diff --git a/apps/api/src/commands/commands.service.ts b/apps/api/src/commands/commands.service.ts index 24863cc..dc60d37 100644 --- a/apps/api/src/commands/commands.service.ts +++ b/apps/api/src/commands/commands.service.ts @@ -2,6 +2,7 @@ import { Injectable, NotFoundException, BadRequestException, + ConflictException, } from '@nestjs/common'; import { v4 as uuidv4 } from 'uuid'; import { PrismaService } from '../prisma/prisma.service'; @@ -54,6 +55,33 @@ export class CommandsService { }); } + if (device.revokedAt) { + throw new ConflictException({ + code: 'device_revoked', + message: 'The device owning this tab has been revoked', + }); + } + + const snapshot = await tx.openTabSnapshot.findUnique({ + where: { deviceId_tabId: { deviceId: device.id, tabId: dto.tabId } }, + }); + if (snapshot && snapshot.version !== dto.expectedVersion) { + throw new ConflictException({ + code: 'version_mismatch', + message: 'The tab has changed since it was loaded. Refresh and try again.', + }); + } + + const alreadyPending = await tx.remoteCommand.findFirst({ + where: { venueId, targetTabId: dto.tabId, status: 'pending' }, + }); + if (alreadyPending) { + throw new ConflictException({ + code: 'command_already_pending', + message: 'A command for this tab is already pending', + }); + } + const now = new Date(); const expiresAt = new Date(now.getTime() + COMMAND_EXPIRY_HOURS * 60 * 60 * 1000); diff --git a/apps/api/src/common/filters/http-exception.filter.ts b/apps/api/src/common/filters/http-exception.filter.ts index b7ac63d..c85d5e1 100644 --- a/apps/api/src/common/filters/http-exception.filter.ts +++ b/apps/api/src/common/filters/http-exception.filter.ts @@ -1,8 +1,10 @@ -import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common'; +import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger } from '@nestjs/common'; import { Response } from 'express'; @Catch() export class HttpExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(HttpExceptionFilter.name); + catch(exception: unknown, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); @@ -31,8 +33,13 @@ export class HttpExceptionFilter implements ExceptionFilter { code = resp.code || this.statusToCode(status); } } - } else if (exception instanceof Error) { - message = exception.message; + } else { + // Never leak internal error details (e.g. database errors) to clients + const request = ctx.getRequest(); + this.logger.error( + `Unhandled error on ${request?.method} ${request?.url}`, + exception instanceof Error ? exception.stack : String(exception), + ); } response.status(status).json({ diff --git a/apps/api/src/common/guards/venue-access.guard.spec.ts b/apps/api/src/common/guards/venue-access.guard.spec.ts new file mode 100644 index 0000000..cbebe01 --- /dev/null +++ b/apps/api/src/common/guards/venue-access.guard.spec.ts @@ -0,0 +1,28 @@ +import { ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { VenueAccessGuard } from './venue-access.guard'; + +function contextFor(params: Record, user?: { venueId: string }): ExecutionContext { + return { + switchToHttp: () => ({ getRequest: () => ({ params, user }) }), + } as unknown as ExecutionContext; +} + +describe('VenueAccessGuard', () => { + const guard = new VenueAccessGuard(); + + it('allows routes without a venueId param', () => { + expect(guard.canActivate(contextFor({}, { venueId: 'a' }))).toBe(true); + }); + + it('allows access to the user\'s own venue', () => { + expect(guard.canActivate(contextFor({ venueId: 'a' }, { venueId: 'a' }))).toBe(true); + }); + + it('rejects access to another venue', () => { + expect(() => guard.canActivate(contextFor({ venueId: 'b' }, { venueId: 'a' }))).toThrow(ForbiddenException); + }); + + it('rejects when no user is attached', () => { + expect(() => guard.canActivate(contextFor({ venueId: 'a' }))).toThrow(ForbiddenException); + }); +}); diff --git a/apps/api/src/common/guards/venue-access.guard.ts b/apps/api/src/common/guards/venue-access.guard.ts new file mode 100644 index 0000000..ad87d9f --- /dev/null +++ b/apps/api/src/common/guards/venue-access.guard.ts @@ -0,0 +1,23 @@ +import { Injectable, ExecutionContext, CanActivate, ForbiddenException } from '@nestjs/common'; + +/** + * Ensures the authenticated user can only access the venue they belong to. + * Must run after JwtAuthGuard so that `request.user` is populated. + */ +@Injectable() +export class VenueAccessGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const venueId = request.params?.venueId; + + if (!venueId) { + return true; + } + + if (!request.user || request.user.venueId !== venueId) { + throw new ForbiddenException({ code: 'venue_forbidden', message: 'You do not have access to this venue' }); + } + + return true; + } +} diff --git a/apps/api/src/devices/devices.controller.ts b/apps/api/src/devices/devices.controller.ts index 36a977f..7a12264 100644 --- a/apps/api/src/devices/devices.controller.ts +++ b/apps/api/src/devices/devices.controller.ts @@ -9,7 +9,8 @@ import { HttpStatus, Req, } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; import { DevicesService } from './devices.service'; import { AuthService } from '../auth/auth.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @@ -29,7 +30,9 @@ export class DevicesController { ) {} @Post('register') + @Throttle({ default: { limit: 10, ttl: 60000 } }) @ApiOperation({ summary: 'Register a new device' }) + @ApiResponse({ status: 201, description: 'Device registered successfully' }) async register(@Body() dto: RegisterDeviceDto) { return this.authService.registerDevice(dto.venueId, dto.name, dto.registrationCode); } @@ -37,13 +40,15 @@ export class DevicesController { @Post(':deviceId/sync') @UseGuards(DeviceAuthGuard) @ApiOperation({ summary: 'Sync device state with server' }) + @ApiResponse({ status: 200, description: 'Sync successful, returns pending commands' }) async sync(@Param('deviceId') deviceId: string, @Body() dto: SyncDto, @Req() req: any) { return this.devicesService.sync(req.user, dto); } @Post(':deviceId/commands/:commandId/ack') @UseGuards(DeviceAuthGuard) - @ApiOperation({ summary: 'Acknowledge a command' }) + @ApiOperation({ summary: 'Acknowledge a command result' }) + @ApiResponse({ status: 200, description: 'Command acknowledged' }) async ackCommand( @Param('deviceId') deviceId: string, @Param('commandId') commandId: string, @@ -54,9 +59,11 @@ export class DevicesController { } @Get('generation-code') - @UseGuards(JwtAuthGuard) - @ApiOperation({ summary: 'Generate a registration code' }) + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin') @ApiBearerAuth() + @ApiOperation({ summary: 'Generate a registration code for pairing' }) + @ApiResponse({ status: 200, description: 'Registration code generated' }) async generateCode(@Req() req: any) { const venueId = req.user.venueId; return this.authService.generateRegistrationCode(venueId); diff --git a/apps/api/src/devices/devices.service.ts b/apps/api/src/devices/devices.service.ts index 7dcecc9..6bcb1ad 100644 --- a/apps/api/src/devices/devices.service.ts +++ b/apps/api/src/devices/devices.service.ts @@ -26,68 +26,75 @@ export class DevicesService { } const now = new Date(); + const openTabs = dto.openTabs || []; + const openTabIds = openTabs.map((t) => t.id); - // Upsert snapshots and ownership - for (const tab of dto.openTabs || []) { - await this.prisma.openTabSnapshot.upsert({ - where: { - deviceId_tabId: { deviceId: device.id, tabId: tab.id }, - }, - create: { - deviceId: device.id, - tabId: tab.id, - customerName: tab.customerName, - version: tab.version, - totalInCents: tab.totalInCents, - itemCount: tab.itemCount, - reportedAt: now, - }, - update: { - customerName: tab.customerName, - version: tab.version, - totalInCents: tab.totalInCents, - itemCount: tab.itemCount, - reportedAt: now, - }, - }); - - // Check if tab was owned by another device - const existingOwnership = await this.prisma.tabOwnership.findUnique({ - where: { venueId_tabId: { venueId: device.venueId, tabId: tab.id } }, - }); - - if (existingOwnership && existingOwnership.deviceId !== device.id) { - if (existingOwnership.lastReportedAt < now) { - await this.auditService.log( - device.venueId, - 'device', - device.id, - 'snapshot_conflict', - { tabId: tab.id, previousDevice: existingOwnership.deviceId, newDevice: device.id }, - ); - } - } - - await this.prisma.tabOwnership.upsert({ - where: { venueId_tabId: { venueId: device.venueId, tabId: tab.id } }, - create: { - tabId: tab.id, - venueId: device.venueId, - deviceId: device.id, - lastReportedAt: now, - }, - update: { - deviceId: device.id, - lastReportedAt: now, - }, - }); + // Ownership changes are detected up front so audit logging stays outside the transaction + const takenOver = await this.prisma.tabOwnership.findMany({ + where: { venueId: device.venueId, tabId: { in: openTabIds }, deviceId: { not: device.id } }, + }); + for (const ownership of takenOver) { + await this.auditService.log( + device.venueId, + 'device', + device.id, + 'snapshot_conflict', + { tabId: ownership.tabId, previousDevice: ownership.deviceId, newDevice: device.id }, + ); } - // Update lastSeenAt - await this.prisma.device.update({ - where: { id: device.id }, - data: { lastSeenAt: now }, - }); + await this.prisma.$transaction(async (tx) => { + // The device reports its full set of open tabs: anything it no longer reports has been closed + await tx.tabOwnership.deleteMany({ + where: { deviceId: device.id, tabId: { notIn: openTabIds } }, + }); + await tx.openTabSnapshot.deleteMany({ + where: { deviceId: device.id, tabId: { notIn: openTabIds } }, + }); + + for (const tab of openTabs) { + await tx.openTabSnapshot.upsert({ + where: { + deviceId_tabId: { deviceId: device.id, tabId: tab.id }, + }, + create: { + deviceId: device.id, + tabId: tab.id, + customerName: tab.customerName, + version: tab.version, + totalInCents: tab.totalInCents, + itemCount: tab.itemCount, + reportedAt: now, + }, + update: { + customerName: tab.customerName, + version: tab.version, + totalInCents: tab.totalInCents, + itemCount: tab.itemCount, + reportedAt: now, + }, + }); + + await tx.tabOwnership.upsert({ + where: { venueId_tabId: { venueId: device.venueId, tabId: tab.id } }, + create: { + tabId: tab.id, + venueId: device.venueId, + deviceId: device.id, + lastReportedAt: now, + }, + update: { + deviceId: device.id, + lastReportedAt: now, + }, + }); + } + + await tx.device.update({ + where: { id: device.id }, + data: { lastSeenAt: now }, + }); + }, { timeout: 15000 }); // Expire old commands await this.prisma.remoteCommand.updateMany({ @@ -99,33 +106,15 @@ export class DevicesService { data: { status: 'expired' }, }); - // Build cursor from timestamp - const cursorTimestamp = now.toISOString(); - const tieBreaker = uuidv4().substring(0, 8); - const cursor = Buffer.from(`${cursorTimestamp}:${tieBreaker}`).toString('base64url'); - - // Build the Prisma where clause - const commandWhere: any = { + // The cursor is kept for client compatibility but no longer filters commands: every pending + // command is returned on each sync, so a command committed while a sync was in flight can never + // be skipped. Devices dedupe by command id and re-ack commands they already processed. + const cursor = Buffer.from(`${now.toISOString()}:${uuidv4().substring(0, 8)}`).toString('base64url'); + const commandWhere = { deviceId: device.id, status: 'pending', }; - // Parse cursor from request if provided — skip filter if cursor is missing or invalid - if (dto.cursor) { - try { - const decoded = Buffer.from(dto.cursor, 'base64url').toString('utf-8'); - const parts = decoded.split(':'); - if (parts.length >= 1) { - const parsed = new Date(parts[0]); - if (!isNaN(parsed.getTime())) { - commandWhere.createdAt = { gt: parsed }; - } - } - } catch { - // Invalid cursor — ignore filter, return all pending commands - } - } - // Find pending commands for this device const commands = await this.prisma.remoteCommand.findMany({ where: commandWhere, @@ -182,13 +171,13 @@ export class DevicesService { throw new ForbiddenException({ code: 'venue_mismatch', message: 'Command belongs to a different venue' }); } - // Idempotent: if already acknowledged with same status, just return ok - if (command.status === dto.status && command.acknowledgedAt) { - return { ok: true }; + // Idempotent: the first acknowledgement wins, retries and late duplicates are no-ops + if (command.acknowledgedAt) { + return { ok: true, status: command.status }; } - await this.prisma.remoteCommand.update({ - where: { id: commandId }, + const updated = await this.prisma.remoteCommand.updateMany({ + where: { id: commandId, acknowledgedAt: null }, data: { status: dto.status, result: dto.result || null, @@ -196,6 +185,11 @@ export class DevicesService { }, }); + if (updated.count === 0) { + // A concurrent ack got there first + return { ok: true }; + } + await this.auditService.log( device.venueId, 'device', diff --git a/apps/api/src/devices/dto/sync.dto.ts b/apps/api/src/devices/dto/sync.dto.ts index 55a00a3..9cd3921 100644 --- a/apps/api/src/devices/dto/sync.dto.ts +++ b/apps/api/src/devices/dto/sync.dto.ts @@ -1,9 +1,11 @@ -import { IsString, IsOptional, IsArray, IsInt, Min, IsUUID } from 'class-validator'; +import { IsString, IsOptional, IsArray, IsInt, Min, IsUUID, ValidateNested, ArrayMaxSize, IsNotEmpty } from 'class-validator'; +import { Type } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; class OpenTabDto { @ApiProperty({ example: 'local-tab-uuid' }) @IsString() + @IsNotEmpty() id: string; @ApiProperty({ example: 'Jan' }) @@ -38,5 +40,8 @@ export class SyncDto { @ApiProperty({ type: [OpenTabDto] }) @IsArray() + @ArrayMaxSize(1000) + @ValidateNested({ each: true }) + @Type(() => OpenTabDto) openTabs: OpenTabDto[]; } \ No newline at end of file diff --git a/apps/api/src/health.controller.ts b/apps/api/src/health.controller.ts index 1103bda..a3ec417 100644 --- a/apps/api/src/health.controller.ts +++ b/apps/api/src/health.controller.ts @@ -1,26 +1,32 @@ -import { Controller, Get } from '@nestjs/common'; -import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { Controller, Get, HttpStatus, Res } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { SkipThrottle } from '@nestjs/throttler'; +import { Response } from 'express'; import { PrismaService } from './prisma/prisma.service'; @ApiTags('Health') +@SkipThrottle() @Controller('health') export class HealthController { constructor(private prisma: PrismaService) {} @Get() @ApiOperation({ summary: 'Health check' }) - async check() { + @ApiResponse({ status: 200, description: 'Server is healthy' }) + @ApiResponse({ status: 503, description: 'Database is unreachable' }) + async check(@Res({ passthrough: true }) res: Response) { let dbStatus = 'ok'; try { await this.prisma.$queryRaw`SELECT 1`; } catch { dbStatus = 'error'; + res.status(HttpStatus.SERVICE_UNAVAILABLE); } return { - status: 'ok', + status: dbStatus === 'ok' ? 'ok' : 'degraded', database: dbStatus, timestamp: new Date().toISOString(), }; } -} \ No newline at end of file +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 0551fe2..3ae6e88 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -2,15 +2,25 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import helmet from 'helmet'; +import { NestExpressApplication } from '@nestjs/platform-express'; import { AppModule } from './app.module'; import { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { LoggingInterceptor } from './common/interceptors/logging.interceptor'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule); + + // Bulk customer imports can exceed the 100kb default + app.useBodyParser('json', { limit: '1mb' }); app.setGlobalPrefix('api/v1'); + // Behind a reverse proxy, trust X-Forwarded-For so rate limiting is per client instead of per proxy. + if (process.env.TRUST_PROXY) { + const value = process.env.TRUST_PROXY; + app.set('trust proxy', /^\d+$/.test(value) ? Number(value) : value === 'true' ? true : value); + } + app.use(helmet()); app.enableCors({ diff --git a/apps/api/src/planned-tabs/dto/create-planned-tab.dto.ts b/apps/api/src/planned-tabs/dto/create-planned-tab.dto.ts new file mode 100644 index 0000000..1535e07 --- /dev/null +++ b/apps/api/src/planned-tabs/dto/create-planned-tab.dto.ts @@ -0,0 +1,17 @@ +import { IsString, IsOptional, IsNotEmpty, IsEmail, MaxLength } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreatePlannedTabDto { + @ApiProperty({ example: 'Jan' }) + @IsString() + @IsNotEmpty() + @MaxLength(100) + customerName: string; + + @ApiPropertyOptional({ example: 'jan@example.com' }) + @IsOptional() + @Transform(({ value }) => (typeof value === 'string' ? value.trim() || undefined : value)) + @IsEmail() + email?: string; +} \ No newline at end of file diff --git a/apps/api/src/planned-tabs/dto/import-planned-tabs.dto.ts b/apps/api/src/planned-tabs/dto/import-planned-tabs.dto.ts new file mode 100644 index 0000000..e0d7526 --- /dev/null +++ b/apps/api/src/planned-tabs/dto/import-planned-tabs.dto.ts @@ -0,0 +1,33 @@ +import { IsString, IsOptional, IsNotEmpty, IsEmail, MaxLength, IsArray, ArrayMaxSize, ValidateNested, IsIn } from 'class-validator'; +import { Transform, Type } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class ImportPlannedTabEntryDto { + @ApiProperty({ example: 'Jan Peeters' }) + @IsString() + @IsNotEmpty() + @MaxLength(100) + customerName: string; + + @ApiPropertyOptional({ example: 'jan@example.com' }) + @IsOptional() + @Transform(({ value }) => (typeof value === 'string' ? value.trim() || undefined : value)) + @IsEmail() + email?: string; +} + +export class ImportPlannedTabsDto { + @ApiProperty({ type: [ImportPlannedTabEntryDto] }) + @IsArray() + @ArrayMaxSize(5000) + @ValidateNested({ each: true }) + @Type(() => ImportPlannedTabEntryDto) + entries: ImportPlannedTabEntryDto[]; + + @ApiProperty({ + enum: ['merge', 'replace'], + description: 'merge adds new names and updates emails; replace also removes names that are not in the import', + }) + @IsIn(['merge', 'replace']) + mode: 'merge' | 'replace'; +} diff --git a/apps/api/src/planned-tabs/dto/update-planned-tab.dto.ts b/apps/api/src/planned-tabs/dto/update-planned-tab.dto.ts new file mode 100644 index 0000000..b1a41ab --- /dev/null +++ b/apps/api/src/planned-tabs/dto/update-planned-tab.dto.ts @@ -0,0 +1,17 @@ +import { IsString, IsOptional, IsNotEmpty, IsEmail, MaxLength } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpdatePlannedTabDto { + @ApiProperty({ example: 'Jan' }) + @IsString() + @IsNotEmpty() + @MaxLength(100) + customerName: string; + + @ApiPropertyOptional({ example: 'jan@example.com' }) + @IsOptional() + @Transform(({ value }) => (typeof value === 'string' ? value.trim() || undefined : value)) + @IsEmail() + email?: string; +} \ No newline at end of file diff --git a/apps/api/src/planned-tabs/planned-tabs.controller.ts b/apps/api/src/planned-tabs/planned-tabs.controller.ts index 0a6a1c2..44e7db8 100644 --- a/apps/api/src/planned-tabs/planned-tabs.controller.ts +++ b/apps/api/src/planned-tabs/planned-tabs.controller.ts @@ -1,11 +1,17 @@ -import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards } from '@nestjs/common'; +import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards, HttpCode, HttpStatus } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { VenueAccessGuard } from '../common/guards/venue-access.guard'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; import { PlannedTabsService } from './planned-tabs.service'; +import { CreatePlannedTabDto } from './dto/create-planned-tab.dto'; +import { UpdatePlannedTabDto } from './dto/update-planned-tab.dto'; +import { ImportPlannedTabsDto } from './dto/import-planned-tabs.dto'; @ApiTags('Planned Tabs') @ApiBearerAuth() -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, VenueAccessGuard, RolesGuard) @Controller('venues/:venueId/planned-tabs') export class PlannedTabsController { constructor(private service: PlannedTabsService) {} @@ -17,18 +23,29 @@ export class PlannedTabsController { } @Post() + @Roles('admin') @ApiOperation({ summary: 'Add a planned tab name' }) - async create(@Param('venueId') venueId: string, @Body() dto: { customerName: string }) { - return this.service.create(venueId, dto.customerName); + async create(@Param('venueId') venueId: string, @Body() dto: CreatePlannedTabDto) { + return this.service.create(venueId, dto.customerName, dto.email); + } + + @Post('import') + @Roles('admin') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Bulk import planned tab names (e.g. from an Excel file)' }) + async import(@Param('venueId') venueId: string, @Body() dto: ImportPlannedTabsDto) { + return this.service.import(venueId, dto.entries, dto.mode); } @Put(':id') + @Roles('admin') @ApiOperation({ summary: 'Rename a planned tab' }) - async update(@Param('venueId') venueId: string, @Param('id') id: string, @Body() dto: { customerName: string }) { - return this.service.update(venueId, id, dto.customerName); + async update(@Param('venueId') venueId: string, @Param('id') id: string, @Body() dto: UpdatePlannedTabDto) { + return this.service.update(venueId, id, dto.customerName, dto.email); } @Delete(':id') + @Roles('admin') @ApiOperation({ summary: 'Remove a planned tab' }) async remove(@Param('venueId') venueId: string, @Param('id') id: string) { return this.service.remove(venueId, id); diff --git a/apps/api/src/planned-tabs/planned-tabs.service.spec.ts b/apps/api/src/planned-tabs/planned-tabs.service.spec.ts new file mode 100644 index 0000000..8415829 --- /dev/null +++ b/apps/api/src/planned-tabs/planned-tabs.service.spec.ts @@ -0,0 +1,77 @@ +import { PlannedTabsService } from './planned-tabs.service'; + +type Row = { id: string; venueId: string; customerName: string; email: string | null; position: number }; + +function createService(initial: Omit[]) { + let rows: Row[] = initial.map((r) => ({ ...r, venueId: 'v1' })); + const tx = { + plannedTab: { + findMany: jest.fn(async () => [...rows].sort((a, b) => a.position - b.position)), + create: jest.fn(async ({ data }: { data: Row }) => { + rows.push(data); + return data; + }), + update: jest.fn(async ({ where, data }: { where: { id: string }; data: Partial }) => { + const row = rows.find((r) => r.id === where.id)!; + Object.assign(row, data); + return row; + }), + deleteMany: jest.fn(async ({ where }: { where: { id: { in: string[] } } }) => { + const before = rows.length; + rows = rows.filter((r) => !where.id.in.includes(r.id)); + return { count: before - rows.length }; + }), + }, + }; + const prisma = { $transaction: (fn: (t: typeof tx) => unknown) => fn(tx) }; + const service = new PlannedTabsService(prisma as any); + return { service, rows: () => rows }; +} + +describe('PlannedTabsService.import', () => { + it('adds new names, keeps existing spelling and only fills in emails', async () => { + const { service, rows } = createService([ + { id: 'a', customerName: 'Jan Peeters', email: null, position: 0 }, + { id: 'b', customerName: 'Els', email: 'els@example.com', position: 1 }, + ]); + + const result = await service.import('v1', [ + { customerName: ' jan peeters ', email: 'jan@example.com' }, + { customerName: 'Els' }, + { customerName: 'Piet' }, + ], 'merge'); + + expect(result).toEqual({ created: 1, updated: 1, unchanged: 1, removed: 0 }); + expect(rows().find((r) => r.id === 'a')).toMatchObject({ customerName: 'Jan Peeters', email: 'jan@example.com' }); + expect(rows().find((r) => r.id === 'b')?.email).toBe('els@example.com'); + expect(rows().find((r) => r.customerName === 'Piet')?.position).toBe(2); + }); + + it('collapses duplicate names within the import', async () => { + const { service, rows } = createService([]); + + const result = await service.import('v1', [ + { customerName: 'Piet', email: 'piet@example.com' }, + { customerName: 'PIET' }, + ], 'merge'); + + expect(result.created).toBe(1); + expect(rows()).toHaveLength(1); + expect(rows()[0].email).toBe('piet@example.com'); + }); + + it('removes names missing from the import only in replace mode', async () => { + const initial = [ + { id: 'a', customerName: 'Jan', email: null, position: 0 }, + { id: 'b', customerName: 'Els', email: null, position: 1 }, + ]; + + const merge = createService(initial); + expect((await merge.service.import('v1', [{ customerName: 'Jan' }], 'merge')).removed).toBe(0); + expect(merge.rows()).toHaveLength(2); + + const replace = createService(initial); + expect((await replace.service.import('v1', [{ customerName: 'jan' }], 'replace')).removed).toBe(1); + expect(replace.rows().map((r) => r.customerName)).toEqual(['Jan']); + }); +}); diff --git a/apps/api/src/planned-tabs/planned-tabs.service.ts b/apps/api/src/planned-tabs/planned-tabs.service.ts index 78d4570..3875b0f 100644 --- a/apps/api/src/planned-tabs/planned-tabs.service.ts +++ b/apps/api/src/planned-tabs/planned-tabs.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { v4 as uuidv4 } from 'uuid'; @@ -10,14 +10,14 @@ export class PlannedTabsService { const tabs = await this.prisma.plannedTab.findMany({ where: { venueId }, orderBy: { position: 'asc' }, - select: { id: true, customerName: true, position: true, createdAt: true }, + select: { id: true, customerName: true, email: true, position: true, createdAt: true }, }); return { plannedTabs: tabs }; } - async create(venueId: string, customerName: string) { + async create(venueId: string, customerName: string, email?: string) { const name = customerName.trim(); - if (!name) throw new NotFoundException({ code: 'name_required', message: 'Customer name is required' }); + if (!name) throw new BadRequestException({ code: 'name_required', message: 'Customer name is required' }); // Check for duplicate const existing = await this.prisma.plannedTab.findUnique({ @@ -38,30 +38,90 @@ export class PlannedTabsService { id: uuidv4(), venueId, customerName: name, + email: email?.trim() || null, position: (last?.position ?? -1) + 1, }, - select: { id: true, customerName: true, position: true, createdAt: true }, + select: { id: true, customerName: true, email: true, position: true, createdAt: true }, }); return tab; } - async update(venueId: string, id: string, customerName: string) { + async update(venueId: string, id: string, customerName: string, email?: string) { const name = customerName.trim(); - if (!name) throw new NotFoundException({ code: 'name_required', message: 'Customer name is required' }); + if (!name) throw new BadRequestException({ code: 'name_required', message: 'Customer name is required' }); const tab = await this.prisma.plannedTab.findFirst({ where: { id, venueId } }); if (!tab) throw new NotFoundException({ code: 'not_found', message: 'Planned tab not found' }); + const duplicate = await this.prisma.plannedTab.findUnique({ + where: { venueId_customerName: { venueId, customerName: name } }, + }); + if (duplicate && duplicate.id !== id) { + throw new ConflictException({ code: 'name_exists', message: 'This customer name already exists' }); + } + const result = await this.prisma.plannedTab.update({ where: { id }, - data: { customerName: name }, - select: { id: true, customerName: true, position: true, createdAt: true }, + data: { customerName: name, email: email?.trim() || null }, + select: { id: true, customerName: true, email: true, position: true, createdAt: true }, }); return result; } + /** + * Bulk import of the customer list (e.g. from the client's Excel file). + * Names match case-insensitively and existing spelling is kept, because the terminal matches + * planned names to tabs exactly and a respelled name would open a second tab. + * A missing email in the import never clears an existing one. + */ + async import(venueId: string, entries: { customerName: string; email?: string }[], mode: 'merge' | 'replace') { + const incoming = new Map(); + for (const entry of entries) { + const customerName = entry.customerName.trim().replace(/\s+/g, ' '); + if (!customerName) continue; + const key = customerName.toLowerCase(); + const previous = incoming.get(key); + incoming.set(key, { customerName, email: entry.email || previous?.email }); + } + + return this.prisma.$transaction(async (tx) => { + const existing = await tx.plannedTab.findMany({ where: { venueId }, orderBy: { position: 'asc' } }); + const existingByKey = new Map(existing.map((t) => [t.customerName.toLowerCase(), t])); + let nextPosition = (existing[existing.length - 1]?.position ?? -1) + 1; + + let created = 0; + let updated = 0; + let unchanged = 0; + let removed = 0; + + for (const [key, entry] of incoming) { + const current = existingByKey.get(key); + if (!current) { + await tx.plannedTab.create({ + data: { id: uuidv4(), venueId, customerName: entry.customerName, email: entry.email ?? null, position: nextPosition++ }, + }); + created++; + } else if (entry.email && entry.email !== current.email) { + await tx.plannedTab.update({ where: { id: current.id }, data: { email: entry.email } }); + updated++; + } else { + unchanged++; + } + } + + if (mode === 'replace') { + const toRemove = existing.filter((t) => !incoming.has(t.customerName.toLowerCase())).map((t) => t.id); + if (toRemove.length > 0) { + removed = (await tx.plannedTab.deleteMany({ where: { id: { in: toRemove } } })).count; + } + } + + return { created, updated, unchanged, removed }; + }, { timeout: 30000 }); + } + async remove(venueId: string, id: string) { const tab = await this.prisma.plannedTab.findFirst({ where: { id, venueId } }); if (!tab) throw new NotFoundException({ code: 'not_found', message: 'Planned tab not found' }); diff --git a/apps/api/src/reminders/reminders.controller.ts b/apps/api/src/reminders/reminders.controller.ts new file mode 100644 index 0000000..5906831 --- /dev/null +++ b/apps/api/src/reminders/reminders.controller.ts @@ -0,0 +1,51 @@ +import { Controller, Get, Post, Param, UseGuards, Req } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; +import { VenueAccessGuard } from '../common/guards/venue-access.guard'; +import { RemindersService } from './reminders.service'; + +@ApiTags('Reminders') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, VenueAccessGuard) +@Controller('venues/:venueId/reminders') +export class RemindersController { + constructor(private service: RemindersService) {} + + @Get('candidates') + @ApiOperation({ summary: 'List tabs eligible for payment reminders' }) + @ApiResponse({ status: 200, description: 'List of eligible tabs with customer email and amount' }) + async listCandidates(@Param('venueId') venueId: string) { + return this.service.listCandidates(venueId); + } + + @Post('send') + @ApiOperation({ summary: 'Send payment reminders via Resend' }) + @ApiResponse({ status: 201, description: 'Reminders sent' }) + @ApiResponse({ status: 400, description: 'Resend not configured' }) + async send(@Param('venueId') venueId: string, @Req() req: any) { + return this.service.sendReminders(venueId, req.user?.email || 'unknown'); + } + + @Get() + @ApiOperation({ summary: 'Get reminder history' }) + @ApiResponse({ status: 200, description: 'List of previously sent reminders' }) + async history(@Param('venueId') venueId: string) { + return this.service.history(venueId); + } + + @Post('test/:plannedTabId') + @UseGuards(RolesGuard) + @Roles('admin') + @ApiOperation({ summary: 'Send a test email to a planned customer (admin only)' }) + @ApiResponse({ status: 201, description: 'Test email sent' }) + @ApiResponse({ status: 400, description: 'Resend not configured or customer has no email' }) + @ApiResponse({ status: 404, description: 'Planned customer not found' }) + async sendTest( + @Param('venueId') venueId: string, + @Param('plannedTabId') plannedTabId: string, + ) { + return this.service.sendTestEmail(venueId, plannedTabId); + } +} \ No newline at end of file diff --git a/apps/api/src/reminders/reminders.module.ts b/apps/api/src/reminders/reminders.module.ts new file mode 100644 index 0000000..90d4af0 --- /dev/null +++ b/apps/api/src/reminders/reminders.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { RemindersController } from './reminders.controller'; +import { RemindersService } from './reminders.service'; + +@Module({ + controllers: [RemindersController], + providers: [RemindersService], +}) +export class RemindersModule {} \ No newline at end of file diff --git a/apps/api/src/reminders/reminders.service.ts b/apps/api/src/reminders/reminders.service.ts new file mode 100644 index 0000000..9eea528 --- /dev/null +++ b/apps/api/src/reminders/reminders.service.ts @@ -0,0 +1,204 @@ +import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +// Tabs stay open per customer (closing only settles them), so a customer can be reminded again +// once this many days have passed since their last reminder. +const REMINDER_COOLDOWN_DAYS = 7; + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +@Injectable() +export class RemindersService { + private resendApiKey: string; + private fromEmail: string; + + constructor(private prisma: PrismaService) { + this.resendApiKey = process.env.RESEND_API_KEY || ''; + this.fromEmail = process.env.RESEND_FROM_EMAIL || 'KoolTab '; + } + + async listCandidates(venueId: string) { + const ownerships = await this.prisma.tabOwnership.findMany({ + where: { venueId }, + }); + + // Only the owning device's snapshot is authoritative for a tab + const snapshots = ownerships.length === 0 ? [] : await this.prisma.openTabSnapshot.findMany({ + where: { + OR: ownerships.map((o) => ({ deviceId: o.deviceId, tabId: o.tabId })), + }, + }); + const latestByTab = new Map(snapshots.map((s) => [s.tabId, s])); + + const emailMap = new Map(); + try { + const plannedTabs = await this.prisma.plannedTab.findMany({ + where: { venueId, email: { not: null } }, + select: { customerName: true, email: true }, + }); + for (const pt of plannedTabs) { + if (pt.email) emailMap.set(pt.customerName.toLowerCase(), pt.email); + } + } catch { + // email column may not exist yet if migration hasn't been applied + } + + let sentTabIds = new Set(); + try { + const alreadySent = await this.prisma.tabReminder.findMany({ + where: { venueId, sentAt: { gt: new Date(Date.now() - REMINDER_COOLDOWN_DAYS * 24 * 60 * 60 * 1000) } }, + select: { tabId: true }, + }); + sentTabIds = new Set(alreadySent.map((r) => r.tabId)); + } catch { + // Table may not exist yet if migration hasn't been applied + } + + const candidates = []; + for (const snap of latestByTab.values()) { + if (snap.totalInCents <= 0) continue; + if (sentTabIds.has(snap.tabId)) continue; + const email = emailMap.get(snap.customerName.toLowerCase()); + if (!email) continue; + candidates.push({ + tabId: snap.tabId, + customerName: snap.customerName, + email, + amountInCents: snap.totalInCents, + itemCount: snap.itemCount, + }); + } + + return { candidates }; + } + + async sendReminders(venueId: string, requestorEmail: string) { + const { candidates } = await this.listCandidates(venueId); + + if (candidates.length === 0) { + return { sent: 0, message: 'No candidates to remind' }; + } + + if (!this.resendApiKey) { + throw new BadRequestException({ + code: 'resend_not_configured', + message: 'Resend API key is not configured. Set RESEND_API_KEY environment variable.', + }); + } + + let sent = 0; + const errors: { customerName: string; error: string }[] = []; + + for (const candidate of candidates) { + try { + const res = await fetch('https://api.resend.com/emails', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.resendApiKey}`, + }, + body: JSON.stringify({ + from: this.fromEmail, + to: candidate.email, + subject: `Payment reminder for your tab at the bar`, + html: `

Hi ${escapeHtml(candidate.customerName)},

+

This is a friendly reminder that you have an open tab of €${(candidate.amountInCents / 100).toFixed(2)} (${candidate.itemCount} item${candidate.itemCount !== 1 ? 's' : ''}).

+

Please settle your tab at the bar at your earliest convenience.

+

Thank you!

`, + }), + }); + + if (!res.ok) { + const errBody = await res.text(); + errors.push({ customerName: candidate.customerName, error: errBody }); + continue; + } + + await this.prisma.tabReminder.create({ + data: { + venueId, + tabId: candidate.tabId, + customerName: candidate.customerName, + email: candidate.email, + amountInCents: candidate.amountInCents, + }, + }); + + sent++; + } catch (err: any) { + errors.push({ customerName: candidate.customerName, error: err.message }); + } + } + + return { sent, total: candidates.length, errors: errors.length > 0 ? errors : undefined }; + } + + async history(venueId: string) { + try { + const reminders = await this.prisma.tabReminder.findMany({ + where: { venueId }, + orderBy: { sentAt: 'desc' }, + take: 100, + }); + return { reminders }; + } catch { + return { reminders: [] }; + } + } + + async sendTestEmail(venueId: string, plannedTabId: string) { + if (!this.resendApiKey) { + throw new BadRequestException({ + code: 'resend_not_configured', + message: 'Resend API key is not configured. Set RESEND_API_KEY environment variable.', + }); + } + + const tab = await this.prisma.plannedTab.findFirst({ + where: { id: plannedTabId, venueId }, + }); + + if (!tab) { + throw new NotFoundException({ code: 'not_found', message: 'Planned customer not found' }); + } + + if (!tab.email) { + throw new BadRequestException({ code: 'no_email', message: 'This customer has no email address set' }); + } + + const res = await fetch('https://api.resend.com/emails', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.resendApiKey}`, + }, + body: JSON.stringify({ + from: this.fromEmail, + to: tab.email, + subject: `Test email from KoolTab`, + html: `

Hi ${escapeHtml(tab.customerName)},

+

This is a test email from KoolTab to verify that email reminders are working correctly.

+

If you received this, the email configuration is working!

+
+

This is a test — no action needed.

`, + }), + }); + + if (!res.ok) { + const errBody = await res.text(); + throw new BadRequestException({ + code: 'email_failed', + message: `Failed to send test email: ${errBody}`, + }); + } + + return { ok: true, email: tab.email, customerName: tab.customerName }; + } +} \ No newline at end of file diff --git a/apps/api/src/tabs/tabs.controller.ts b/apps/api/src/tabs/tabs.controller.ts index e8a81aa..58ecc82 100644 --- a/apps/api/src/tabs/tabs.controller.ts +++ b/apps/api/src/tabs/tabs.controller.ts @@ -2,10 +2,11 @@ import { Controller, Get, Param, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { TabsService } from './tabs.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { VenueAccessGuard } from '../common/guards/venue-access.guard'; @ApiTags('Tabs') @ApiBearerAuth() -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, VenueAccessGuard) @Controller('venues/:venueId/tabs') export class TabsController { constructor(private tabsService: TabsService) {} diff --git a/apps/api/src/tabs/tabs.service.ts b/apps/api/src/tabs/tabs.service.ts index 46e19d5..8ecacc6 100644 --- a/apps/api/src/tabs/tabs.service.ts +++ b/apps/api/src/tabs/tabs.service.ts @@ -15,12 +15,16 @@ export class TabsService { }, }); + const snapshots = await this.prisma.openTabSnapshot.findMany({ + where: { + OR: ownerships.map((o) => ({ deviceId: o.deviceId, tabId: o.tabId })), + }, + }); + const snapshotByKey = new Map(snapshots.map((s) => [`${s.deviceId}:${s.tabId}`, s])); + const tabs = []; for (const ownership of ownerships) { - const snapshot = await this.prisma.openTabSnapshot.findFirst({ - where: { tabId: ownership.tabId, deviceId: ownership.deviceId }, - orderBy: { reportedAt: 'desc' }, - }); + const snapshot = snapshotByKey.get(`${ownership.deviceId}:${ownership.tabId}`); if (snapshot) { tabs.push({ diff --git a/apps/api/src/venues/dto/create-user.dto.ts b/apps/api/src/venues/dto/create-user.dto.ts new file mode 100644 index 0000000..a1008bb --- /dev/null +++ b/apps/api/src/venues/dto/create-user.dto.ts @@ -0,0 +1,23 @@ +import { IsString, IsEmail, IsIn, MinLength, IsNotEmpty } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateUserDto { + @ApiProperty({ example: 'user@example.com' }) + @IsEmail() + email: string; + + @ApiProperty({ example: 'SecurePass123!' }) + @IsString() + @MinLength(6) + password: string; + + @ApiProperty({ example: 'John Doe' }) + @IsString() + @IsNotEmpty() + name: string; + + @ApiProperty({ example: 'viewer', enum: ['admin', 'viewer'] }) + @IsString() + @IsIn(['admin', 'viewer']) + role: string; +} \ No newline at end of file diff --git a/apps/api/src/venues/venues.controller.ts b/apps/api/src/venues/venues.controller.ts index 0d79303..98dc1e4 100644 --- a/apps/api/src/venues/venues.controller.ts +++ b/apps/api/src/venues/venues.controller.ts @@ -4,8 +4,10 @@ import { VenuesService } from './venues.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../common/guards/roles.guard'; import { Roles } from '../common/decorators/roles.decorator'; +import { VenueAccessGuard } from '../common/guards/venue-access.guard'; import { AuthService } from '../auth/auth.service'; import { PrismaService } from '../prisma/prisma.service'; +import { CreateUserDto } from './dto/create-user.dto'; @ApiTags('Venues') @Controller('venues') @@ -24,7 +26,7 @@ export class VenuesController { @Get(':venueId') @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(JwtAuthGuard, VenueAccessGuard) @ApiOperation({ summary: 'Get venue by ID' }) async findOne(@Param('venueId') venueId: string) { return this.venuesService.findOne(venueId); @@ -32,7 +34,7 @@ export class VenuesController { @Get(':venueId/users') @ApiBearerAuth() - @UseGuards(JwtAuthGuard, RolesGuard) + @UseGuards(JwtAuthGuard, VenueAccessGuard, RolesGuard) @Roles('admin') @ApiOperation({ summary: 'List users for a venue' }) async findUsers(@Param('venueId') venueId: string) { @@ -46,19 +48,19 @@ export class VenuesController { @Post(':venueId/users') @ApiBearerAuth() - @UseGuards(JwtAuthGuard, RolesGuard) + @UseGuards(JwtAuthGuard, VenueAccessGuard, RolesGuard) @Roles('admin') @ApiOperation({ summary: 'Create a new user for the venue' }) async createUser( @Param('venueId') venueId: string, - @Body() dto: { email: string; password: string; name: string; role: string }, + @Body() dto: CreateUserDto, ) { return this.authService.createUser(venueId, dto.email, dto.password, dto.name, dto.role); } @Get(':venueId/devices') @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(JwtAuthGuard, VenueAccessGuard) @ApiOperation({ summary: 'Get venue device info' }) async findDevices(@Param('venueId') venueId: string) { const device = await this.prisma.device.findFirst({ diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 9fab7a9..2895780 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -32,6 +32,7 @@ "react-dom": "^18.2.0", "react-hook-form": "^7.49.0", "tailwind-merge": "^2.2.1", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "zod": "^3.22.4" }, "devDependencies": { diff --git a/apps/dashboard/src/app/dashboard/layout.tsx b/apps/dashboard/src/app/dashboard/layout.tsx index 76a0e83..815b740 100644 --- a/apps/dashboard/src/app/dashboard/layout.tsx +++ b/apps/dashboard/src/app/dashboard/layout.tsx @@ -12,18 +12,22 @@ import { LogOut, Menu, X, + Bell, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/cn'; +import { useLang } from '@/lib/i18n'; +import { LanguageSelector } from '@/components/language-selector'; import { authApi, api } from '@/lib/api/client'; import { toast } from '@/components/ui/toast'; const navItems = [ - { href: '/dashboard', label: 'Overview', icon: LayoutDashboard, neededRole: 'viewer' }, - { href: '/dashboard/tabs', label: 'Open Tabs', icon: ClipboardList, neededRole: 'viewer' }, - { href: '/dashboard/users', label: 'Users', icon: Monitor, neededRole: 'admin' }, - { href: '/dashboard/commands', label: 'Command History', icon: Terminal, neededRole: 'viewer' }, - { href: '/dashboard/settings', label: 'Settings', icon: Settings, neededRole: 'viewer' }, + { href: '/dashboard', key: 'nav.overview', icon: LayoutDashboard, adminOnly: false }, + { href: '/dashboard/tabs', key: 'nav.tabs', icon: ClipboardList, adminOnly: false }, + { href: '/dashboard/reminders', key: 'nav.reminders', icon: Bell, adminOnly: false }, + { href: '/dashboard/users', key: 'nav.users', icon: Monitor, adminOnly: true }, + { href: '/dashboard/commands', key: 'nav.commands', icon: Terminal, adminOnly: false }, + { href: '/dashboard/settings', key: 'nav.settings', icon: Settings, adminOnly: false }, ]; export default function DashboardLayout({ @@ -36,6 +40,7 @@ export default function DashboardLayout({ const [sidebarOpen, setSidebarOpen] = useState(false); const [user, setUser] = useState<{ name: string; email: string; role: string; venueId: string } | null>(null); const [venue, setVenue] = useState<{ name: string } | null>(null); + const { t } = useLang(); useEffect(() => { // Check auth @@ -111,7 +116,7 @@ export default function DashboardLayout({ const Icon = item.icon; const active = pathname === item.href; - if (item.neededRole === 'admin' && user?.role !== 'admin') { + if (item.adminOnly && user?.role !== 'admin') { return null; } @@ -128,13 +133,14 @@ export default function DashboardLayout({ )} > - {item.label} + {t(item.key)} ); })}
+ {user && (

{user.name}

@@ -147,7 +153,7 @@ export default function DashboardLayout({ onClick={handleLogout} > - Logout + {t('nav.logout')}
diff --git a/apps/dashboard/src/app/dashboard/reminders/page.tsx b/apps/dashboard/src/app/dashboard/reminders/page.tsx new file mode 100644 index 0000000..36438bd --- /dev/null +++ b/apps/dashboard/src/app/dashboard/reminders/page.tsx @@ -0,0 +1,216 @@ +'use client'; + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { remindersApi } from '@/lib/api/client'; +import { PageHeader } from '@/components/page-header'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; +import { EmptyState } from '@/components/empty-state'; +import { LoadingState } from '@/components/loading-state'; +import { ErrorState } from '@/components/error-state'; +import { toast } from '@/components/ui/toast'; +import { formatCents, formatRelativeTime } from '@/lib/utils'; +import { useLang } from '@/lib/i18n'; +import { Send, History } from 'lucide-react'; + +export default function RemindersPage() { + const router = useRouter(); + const queryClient = useQueryClient(); + const { t } = useLang(); + const [venueId, setVenueId] = useState(null); + const [sendDialogOpen, setSendDialogOpen] = useState(false); + const [showHistory, setShowHistory] = useState(false); + + useEffect(() => { + const cookie = document.cookie.split('; ').find(row => row.startsWith('access_token=')); + if (!cookie) { router.push('/login'); return; } + const payload = JSON.parse(atob(cookie.split('.')[1])); + setVenueId(payload.venueId); + }, [router]); + + const candidatesQuery = useQuery({ + queryKey: ['reminder-candidates', venueId], + queryFn: () => remindersApi.candidates(venueId!), + enabled: !!venueId, + }); + + const historyQuery = useQuery({ + queryKey: ['reminder-history', venueId], + queryFn: () => remindersApi.history(venueId!), + enabled: !!venueId && showHistory, + }); + + const sendMutation = useMutation({ + mutationFn: () => remindersApi.send(venueId!), + onSuccess: (result: any) => { + setSendDialogOpen(false); + if (result.errors && result.errors.length > 0) { + const firstError = result.errors[0]; + toast({ title: `${result.sent}/${result.total} sent`, description: `Error: ${firstError.error}`, variant: 'destructive' }); + } else if (result.sent > 0) { + toast({ title: t('reminders.sent_title'), description: t('reminders.success', { sent: result.sent }), variant: 'success' }); + } else { + toast({ title: t('reminders.sent_title'), description: t('reminders.zero_sent'), variant: 'destructive' }); + } + queryClient.invalidateQueries({ queryKey: ['reminder-candidates'] }); + queryClient.invalidateQueries({ queryKey: ['reminder-history'] }); + }, + onError: (err: any) => { + toast({ title: 'Failed', description: err.message || 'An error occurred', variant: 'destructive' }); + }, + }); + + if (!venueId) return ; + + const candidates = candidatesQuery.data?.candidates || []; + const reminders = historyQuery.data?.reminders || []; + + return ( +
+ + +
+ } + /> + + {showHistory ? ( + + + {t('reminders.history')} + + + {reminders.length === 0 ? ( +

{t('reminders.history_empty')}

+ ) : ( +
+ + + + {t('reminders.customer')} + {t('reminders.email')} + {t('reminders.amount')} + {t('reminders.sent_at')} + {t('reminders.status')} + + + + {reminders.map((r: any) => ( + + {r.customerName} + {r.email} + {formatCents(r.amountInCents)} + {formatRelativeTime(r.sentAt)} + {r.status} + + ))} + +
+
+ )} +
+
+ ) : ( + + + + + {t('reminders.candidates')} + + + +

+ {t('reminders.candidates_desc')} +

+ + {candidatesQuery.isLoading ? ( +

{t('common.loading')}

+ ) : candidatesQuery.error ? ( +

{t('common.error')}

+ ) : candidates.length === 0 ? ( + + ) : ( + <> +
+ + + + {t('reminders.customer')} + {t('reminders.email')} + {t('reminders.amount')} + {t('tabs.items')} + + + + {candidates.map((c: any) => ( + + {c.customerName} + {c.email} + {formatCents(c.amountInCents)} + {c.itemCount} + + ))} + +
+
+ + + + )} +
+
+ )} + + + + + {t('reminders.send')} + + {t('reminders.result', { sent: 0, total: candidates.length })} + + +
+

+ This will send payment reminder emails to {candidates.length} customer(s) with open tabs. +

+
    + {candidates.map((c: any) => ( +
  • + {c.customerName} + ({c.email}) + {formatCents(c.amountInCents)} +
  • + ))} +
+
+ + + + +
+
+ + ); +} \ No newline at end of file diff --git a/apps/dashboard/src/app/dashboard/tabs/page.tsx b/apps/dashboard/src/app/dashboard/tabs/page.tsx index cde375e..a01efaf 100644 --- a/apps/dashboard/src/app/dashboard/tabs/page.tsx +++ b/apps/dashboard/src/app/dashboard/tabs/page.tsx @@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { api, commandsApi, plannedTabsApi } from '@/lib/api/client'; +import { api, commandsApi, plannedTabsApi, remindersApi } from '@/lib/api/client'; import { PageHeader } from '@/components/page-header'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; @@ -17,7 +17,9 @@ import { LoadingState } from '@/components/loading-state'; import { ErrorState } from '@/components/error-state'; import { toast } from '@/components/ui/toast'; import { formatCents, formatRelativeTime } from '@/lib/utils'; -import { RefreshCw, Plus, X, Users } from 'lucide-react'; +import { RefreshCw, Plus, X, Users, Mail, Send, Upload } from 'lucide-react'; +import { ImportCustomersDialog } from '@/components/import-customers-dialog'; +import { useLang } from '@/lib/i18n'; export default function TabsPage() { const router = useRouter(); @@ -28,7 +30,10 @@ export default function TabsPage() { const [creating, setCreating] = useState(false); const [pendingCommandIds, setPendingCommandIds] = useState>(new Set()); const [plannedName, setPlannedName] = useState(''); + const [plannedEmail, setPlannedEmail] = useState(''); const [userRole, setUserRole] = useState(null); + const [importOpen, setImportOpen] = useState(false); + const { t } = useLang(); useEffect(() => { const cookie = document.cookie.split('; ').find(row => row.startsWith('access_token=')); @@ -97,8 +102,9 @@ export default function TabsPage() { async function addPlannedName() { if (!plannedName.trim()) return; try { - await plannedTabsApi.create(venueId!, plannedName.trim()); + await plannedTabsApi.create(venueId!, plannedName.trim(), plannedEmail.trim() || undefined); setPlannedName(''); + setPlannedEmail(''); queryClient.invalidateQueries({ queryKey: ['planned-tabs'] }); toast({ title: 'Added', description: `"${plannedName.trim()}" added to planned tabs` }); } catch (err: any) { @@ -115,6 +121,16 @@ export default function TabsPage() { } } + const sendTestMutation = useMutation({ + mutationFn: (plannedTabId: string) => remindersApi.sendTest(venueId!, plannedTabId), + onSuccess: (result) => { + toast({ title: 'Test email sent', description: `Sent to ${result.email}`, variant: 'success' }); + }, + onError: (err: any) => { + toast({ title: 'Test email failed', description: err.message || 'An error occurred', variant: 'destructive' }); + }, + }); + if (!venueId) return ; if (isLoading) return ; if (error) return ; @@ -132,14 +148,21 @@ export default function TabsPage() { {/* Planned Customers */} - Planned Customers +
+ Planned Customers + {userRole === 'admin' && ( + + )} +

These names are synced to the terminal. It will create tabs for these customers automatically.

{userRole === 'admin' && ( -
+
e.key === 'Enter' && addPlannedName()} className="max-w-xs" /> + setPlannedEmail(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && addPlannedName()} + className="max-w-xs" + /> @@ -159,8 +190,19 @@ export default function TabsPage() { {(plannedQuery.data?.plannedTabs || []).map((pt: any) => (
{pt.customerName} + {pt.email && } + {userRole === 'admin' && pt.email && ( + + )} {userRole === 'admin' && ( - )} @@ -194,7 +236,8 @@ export default function TabsPage() { {tab.version} {formatRelativeTime(tab.lastReportedAt)} - + {/* Closing settles the tab on the terminal; a tab without a balance has nothing to settle */} + ))} @@ -203,6 +246,15 @@ export default function TabsPage() {
)} + {venueId && ( + queryClient.invalidateQueries({ queryKey: ['planned-tabs'] })} + /> + )} + {closeDialog.tab && ( !open && setCloseDialog({ open: false, tab: null })}> diff --git a/apps/dashboard/src/app/providers.tsx b/apps/dashboard/src/app/providers.tsx index 1ad2f86..690da38 100644 --- a/apps/dashboard/src/app/providers.tsx +++ b/apps/dashboard/src/app/providers.tsx @@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { useState } from 'react'; +import { LanguageProvider } from '@/lib/i18n'; export function Providers({ children }: { children: React.ReactNode }) { const [queryClient] = useState( @@ -18,7 +19,7 @@ export function Providers({ children }: { children: React.ReactNode }) { return ( - {children} + {children} ); } \ No newline at end of file diff --git a/apps/dashboard/src/components/import-customers-dialog.tsx b/apps/dashboard/src/components/import-customers-dialog.tsx new file mode 100644 index 0000000..6565db2 --- /dev/null +++ b/apps/dashboard/src/components/import-customers-dialog.tsx @@ -0,0 +1,218 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { Upload } from 'lucide-react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { toast } from '@/components/ui/toast'; +import { plannedTabsApi } from '@/lib/api/client'; +import { useLang } from '@/lib/i18n'; +import { buildPreview, columnLabel, detectMapping, readSheet, type ColumnMapping, type SheetData } from '@/lib/customer-import'; + +const PREVIEW_ROWS = 8; + +const selectClassName = + 'flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring'; + +interface Props { + venueId: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onImported: () => void; +} + +export function ImportCustomersDialog({ venueId, open, onOpenChange, onImported }: Props) { + const { t } = useLang(); + const [fileName, setFileName] = useState(null); + const [sheet, setSheet] = useState(null); + const [mapping, setMapping] = useState(null); + const [mode, setMode] = useState<'merge' | 'replace'>('merge'); + const [submitting, setSubmitting] = useState(false); + + const preview = useMemo(() => (sheet && mapping ? buildPreview(sheet, mapping) : null), [sheet, mapping]); + + function reset() { + setFileName(null); + setSheet(null); + setMapping(null); + setMode('merge'); + } + + function handleOpenChange(next: boolean) { + if (!next) reset(); + onOpenChange(next); + } + + async function handleFile(file: File | undefined) { + if (!file) return; + try { + const data = await readSheet(file); + if (data.rows.length === 0) { + toast({ title: t('import.failed'), description: t('import.empty_file'), variant: 'destructive' }); + return; + } + setFileName(file.name); + setSheet(data); + setMapping(detectMapping(data)); + } catch { + toast({ title: t('import.failed'), description: t('import.unreadable'), variant: 'destructive' }); + } + } + + async function submit() { + if (!preview || preview.entries.length === 0) return; + setSubmitting(true); + try { + const result = await plannedTabsApi.import(venueId, preview.entries, mode); + toast({ title: t('import.done'), description: t('import.result', result), variant: 'success' }); + onImported(); + handleOpenChange(false); + } catch (err: any) { + toast({ title: t('import.failed'), description: err.message, variant: 'destructive' }); + } finally { + setSubmitting(false); + } + } + + const header = sheet && mapping?.hasHeader ? sheet.rows[0] : null; + const columnOptions = sheet + ? Array.from({ length: sheet.columnCount }, (_, i) => ({ + value: i, + label: header?.[i] ? `${columnLabel(i)} – ${header[i]}` : columnLabel(i), + })) + : []; + + function columnSelect(value: number | null, onChange: (value: number | null) => void, optional: boolean) { + return ( + + ); + } + + return ( + + + + {t('import.title')} + {t('import.description')} + + +
+ + + {sheet && mapping && preview && ( + <> +
+
+ + {columnSelect(mapping.nameColumn, (v) => setMapping({ ...mapping, nameColumn: v ?? 0 }), false)} +
+
+ + {columnSelect(mapping.lastNameColumn, (v) => setMapping({ ...mapping, lastNameColumn: v }), true)} +
+
+ + {columnSelect(mapping.emailColumn, (v) => setMapping({ ...mapping, emailColumn: v }), true)} +
+
+ + + +
+

+ {t('import.summary', { + count: preview.entries.length, + emails: preview.entries.filter((e) => e.email).length, + })} +

+ {(preview.skippedRows > 0 || preview.invalidEmails > 0) && ( +

+ {t('import.warnings', { skipped: preview.skippedRows, invalid: preview.invalidEmails })} +

+ )} +
+ + + + {t('tabs.customer')} + {t('import.email')} + + + + {preview.entries.slice(0, PREVIEW_ROWS).map((entry, i) => ( + + {entry.customerName} + {entry.email ?? '—'} + + ))} + +
+
+ {preview.entries.length > PREVIEW_ROWS && ( +

+ {t('import.more', { count: preview.entries.length - PREVIEW_ROWS })} +

+ )} +
+ +
+ + setMode(v as 'merge' | 'replace')}> +
+ + +
+
+ + +
+
+
+ + )} +
+ + + + + +
+
+ ); +} diff --git a/apps/dashboard/src/components/language-selector.tsx b/apps/dashboard/src/components/language-selector.tsx new file mode 100644 index 0000000..d82f280 --- /dev/null +++ b/apps/dashboard/src/components/language-selector.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { useLang } from '@/lib/i18n'; +import { Languages } from 'lucide-react'; + +export function LanguageSelector() { + const { lang, setLang, t } = useLang(); + + return ( +
+ + {t('lang.label')}: + + +
+ ); +} \ No newline at end of file diff --git a/apps/dashboard/src/lib/api/client.ts b/apps/dashboard/src/lib/api/client.ts index 2e4af29..156df78 100644 --- a/apps/dashboard/src/lib/api/client.ts +++ b/apps/dashboard/src/lib/api/client.ts @@ -15,7 +15,9 @@ function getTokens() { } function setTokens(accessToken: string, refreshToken: string) { - document.cookie = `access_token=${accessToken}; path=/; max-age=${24 * 60 * 60}; SameSite=Lax`; + // The access cookie outlives the JWT on purpose: pages decode it for venue/role, and an + // expired JWT triggers a 401 which is transparently refreshed below. + document.cookie = `access_token=${accessToken}; path=/; max-age=${30 * 24 * 60 * 60}; SameSite=Lax`; document.cookie = `refresh_token=${refreshToken}; path=/; max-age=${30 * 24 * 60 * 60}; SameSite=Lax`; } @@ -24,7 +26,19 @@ function clearTokens() { document.cookie = 'refresh_token=; path=/; max-age=0'; } -async function refreshAccessToken(): Promise { +// Refresh tokens rotate on use, so concurrent 401s must share a single refresh request +let refreshInFlight: Promise | null = null; + +function refreshAccessToken(): Promise { + if (!refreshInFlight) { + refreshInFlight = doRefreshAccessToken().finally(() => { + refreshInFlight = null; + }); + } + return refreshInFlight; +} + +async function doRefreshAccessToken(): Promise { const refreshToken = document.cookie .split('; ') .find((row) => row.startsWith('refresh_token=')) @@ -169,12 +183,29 @@ export const venuesApi = { // Planned Tabs API export const plannedTabsApi = { list: (venueId: string) => api<{ plannedTabs: any[] }>(`/venues/${venueId}/planned-tabs`), - create: (venueId: string, customerName: string) => - api(`/venues/${venueId}/planned-tabs`, { method: 'POST', body: JSON.stringify({ customerName }) }), - update: (venueId: string, id: string, customerName: string) => - api(`/venues/${venueId}/planned-tabs/${id}`, { method: 'PUT', body: JSON.stringify({ customerName }) }), + create: (venueId: string, customerName: string, email?: string) => + api(`/venues/${venueId}/planned-tabs`, { method: 'POST', body: JSON.stringify({ customerName, email }) }), + update: (venueId: string, id: string, customerName: string, email?: string) => + api(`/venues/${venueId}/planned-tabs/${id}`, { method: 'PUT', body: JSON.stringify({ customerName, email }) }), remove: (venueId: string, id: string) => api(`/venues/${venueId}/planned-tabs/${id}`, { method: 'DELETE' }), + import: (venueId: string, entries: { customerName: string; email?: string }[], mode: 'merge' | 'replace') => + api<{ created: number; updated: number; unchanged: number; removed: number }>(`/venues/${venueId}/planned-tabs/import`, { + method: 'POST', + body: JSON.stringify({ entries, mode }), + }), +}; + +// Reminders API +export const remindersApi = { + candidates: (venueId: string) => + api<{ candidates: any[] }>(`/venues/${venueId}/reminders/candidates`), + send: (venueId: string) => + api<{ sent: number; total: number; errors?: { customerName: string; error: string }[] }>(`/venues/${venueId}/reminders/send`, { method: 'POST' }), + history: (venueId: string) => + api<{ reminders: any[] }>(`/venues/${venueId}/reminders`), + sendTest: (venueId: string, plannedTabId: string) => + api<{ ok: boolean; email: string; customerName: string }>(`/venues/${venueId}/reminders/test/${plannedTabId}`, { method: 'POST' }), }; export { setTokens, clearTokens }; \ No newline at end of file diff --git a/apps/dashboard/src/lib/customer-import.ts b/apps/dashboard/src/lib/customer-import.ts new file mode 100644 index 0000000..5dcbe9f --- /dev/null +++ b/apps/dashboard/src/lib/customer-import.ts @@ -0,0 +1,122 @@ +export interface SheetData { + rows: string[][]; + columnCount: number; +} + +export interface ColumnMapping { + hasHeader: boolean; + nameColumn: number; + lastNameColumn: number | null; + emailColumn: number | null; +} + +export interface ImportEntry { + customerName: string; + email?: string; +} + +export interface ImportPreview { + entries: ImportEntry[]; + skippedRows: number; + invalidEmails: number; +} + +const NAME_HEADERS = ['naam', 'name', 'volledige naam', 'full name', 'klant', 'klantnaam', 'customer', 'lid', 'member']; +const FIRST_NAME_HEADERS = ['voornaam', 'first name', 'firstname', 'given name']; +const LAST_NAME_HEADERS = ['achternaam', 'familienaam', 'last name', 'lastname', 'surname', 'family name']; + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +function normalizeHeader(value: string) { + return value.toLowerCase().replace(/[_\-.:]/g, ' ').replace(/\s+/g, ' ').trim(); +} + +/** Reads the first sheet of an .xlsx, .xls or .csv file into trimmed string rows. */ +export async function readSheet(file: File): Promise { + // Loaded on demand: the parser is large and only needed when an admin imports a file + const XLSX = await import('xlsx'); + const workbook = XLSX.read(await file.arrayBuffer(), { type: 'array' }); + const sheet = workbook.Sheets[workbook.SheetNames[0]]; + if (!sheet) return { rows: [], columnCount: 0 }; + + const raw = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '', raw: false, blankrows: false }); + const rows = raw + .map((row) => row.map((cell) => String(cell ?? '').trim())) + .filter((row) => row.some((cell) => cell !== '')); + const columnCount = rows.reduce((max, row) => Math.max(max, row.length), 0); + return { rows, columnCount }; +} + +/** Guesses which columns hold the name(s) and email from the first row. */ +export function detectMapping(sheet: SheetData): ColumnMapping { + const header = (sheet.rows[0] ?? []).map(normalizeHeader); + const find = (candidates: string[]) => { + const index = header.findIndex((h) => candidates.includes(h)); + return index === -1 ? null : index; + }; + + const name = find(NAME_HEADERS); + const firstName = find(FIRST_NAME_HEADERS); + const lastName = find(LAST_NAME_HEADERS); + const emailHeaderIndex = header.findIndex((h) => h.includes('mail')); + const hasHeader = name !== null || firstName !== null || lastName !== null || emailHeaderIndex !== -1; + + // Without a recognisable header, fall back to the column whose values look like email addresses + let email: number | null = emailHeaderIndex === -1 ? null : emailHeaderIndex; + if (email === null) { + const sample = sheet.rows.slice(hasHeader ? 1 : 0, 50); + for (let col = 0; col < sheet.columnCount && email === null; col++) { + const filled = sample.map((row) => row[col]).filter(Boolean); + if (filled.length > 0 && filled.filter((v) => EMAIL_PATTERN.test(v)).length / filled.length >= 0.5) { + email = col; + } + } + } + + if (firstName !== null && lastName !== null) { + return { hasHeader, nameColumn: firstName, lastNameColumn: lastName, emailColumn: email }; + } + return { hasHeader, nameColumn: name ?? firstName ?? lastName ?? 0, lastNameColumn: null, emailColumn: email }; +} + +export function buildPreview(sheet: SheetData, mapping: ColumnMapping): ImportPreview { + const dataRows = mapping.hasHeader ? sheet.rows.slice(1) : sheet.rows; + const entries: ImportEntry[] = []; + let skippedRows = 0; + let invalidEmails = 0; + + for (const row of dataRows) { + const parts = [row[mapping.nameColumn]]; + if (mapping.lastNameColumn !== null) parts.push(row[mapping.lastNameColumn]); + const customerName = parts.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim().slice(0, 100); + if (!customerName) { + skippedRows++; + continue; + } + + let email: string | undefined; + if (mapping.emailColumn !== null) { + const value = (row[mapping.emailColumn] ?? '').trim(); + if (value && EMAIL_PATTERN.test(value)) { + email = value; + } else if (value) { + invalidEmails++; + } + } + + entries.push(email ? { customerName, email } : { customerName }); + } + + return { entries, skippedRows, invalidEmails }; +} + +/** Spreadsheet-style column label: 0 -> A, 26 -> AA. */ +export function columnLabel(index: number): string { + let label = ''; + let n = index; + do { + label = String.fromCharCode(65 + (n % 26)) + label; + n = Math.floor(n / 26) - 1; + } while (n >= 0); + return label; +} diff --git a/apps/dashboard/src/lib/i18n.tsx b/apps/dashboard/src/lib/i18n.tsx new file mode 100644 index 0000000..c8ac3c9 --- /dev/null +++ b/apps/dashboard/src/lib/i18n.tsx @@ -0,0 +1,367 @@ +import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; + +type Lang = 'en' | 'nl'; + +const translations: Record> = { + en: { + 'nav.overview': 'Overview', + 'nav.tabs': 'Open Tabs', + 'nav.reminders': 'Reminders', + 'nav.users': 'Users', + 'nav.commands': 'Command History', + 'nav.settings': 'Settings', + 'nav.logout': 'Logout', + 'tabs.title': 'Open Tabs', + 'tabs.count': '{count} open tab', + 'tabs.count_plural': '{count} open tabs', + 'tabs.empty': 'No open tabs', + 'tabs.empty_desc': 'Open tabs will appear here when the terminal syncs.', + 'tabs.customer': 'Customer', + 'tabs.amount': 'Amount', + 'tabs.items': 'Items', + 'tabs.version': 'Version', + 'tabs.reported': 'Last Reported', + 'tabs.close': 'Close', + 'tabs.close_title': "Close {name}'s tab?", + 'tabs.close_desc': 'Send a close request to the terminal.', + 'tabs.payment': 'Payment method', + 'tabs.cash': 'Cash', + 'tabs.payconiq': 'Payconiq', + 'tabs.pending_payment': 'Payment pending', + 'tabs.select_payment': 'Please select a payment method', + 'tabs.send': 'Send close request', + 'tabs.sending': 'Sending...', + 'tabs.cancel': 'Cancel', + 'tabs.planned_title': 'Planned Customers', + 'tabs.planned_desc': 'These names are synced to the terminal. It will create tabs for these customers automatically.', + 'tabs.planned_add': 'Add customer name...', + 'tabs.planned_empty': 'No planned customers. Add names above and they\'ll sync to the terminal.', + 'import.button': 'Import from Excel', + 'import.title': 'Import customers', + 'import.description': 'Upload the customer list. Every name becomes a planned customer and gets a tab on the terminal.', + 'import.choose_file': 'Choose a file', + 'import.empty_file': 'The file contains no rows.', + 'import.unreadable': 'This file could not be read. Use an .xlsx, .xls or .csv file.', + 'import.name_column': 'Name (or first name)', + 'import.last_name_column': 'Last name', + 'import.email_column': 'Email', + 'import.column_none': '— none —', + 'import.has_header': 'First row contains column headers', + 'import.summary': '{count} customers found, {emails} with an email address.', + 'import.warnings': '{skipped} rows without a name skipped, {invalid} invalid email addresses ignored.', + 'import.email': 'Email', + 'import.more': 'and {count} more…', + 'import.mode': 'Existing customers', + 'import.mode_merge': 'Keep customers that are not in the file', + 'import.mode_replace': 'Remove customers that are not in the file (their open tabs on the terminal are kept)', + 'import.submit': 'Import {count} customers', + 'import.importing': 'Importing...', + 'import.done': 'Import complete', + 'import.result': '{created} added, {updated} updated, {unchanged} unchanged, {removed} removed.', + 'import.failed': 'Import failed', + 'users.title': 'Users', + 'users.count': '{count} user', + 'users.count_plural': '{count} users', + 'users.empty': 'No users', + 'users.empty_desc': 'Add users who can manage tabs from this dashboard.', + 'users.add': 'Add User', + 'users.name': 'Name', + 'users.email': 'Email', + 'users.role': 'Role', + 'users.created': 'Created', + 'users.add_title': 'Add User', + 'users.add_desc': 'Create a new user who can access the dashboard.', + 'users.password': 'Password', + 'users.creating': 'Creating...', + 'users.create': 'Create User', + 'commands.title': 'Command History', + 'commands.count': '{count} command', + 'commands.count_plural': '{count} commands', + 'commands.empty': 'No commands found', + 'commands.tab': 'Tab', + 'commands.requested': 'Requested By', + 'commands.payment': 'Payment', + 'commands.created': 'Created', + 'commands.status': 'Status', + 'commands.processed': 'Processed', + 'commands.result': 'Result', + 'commands.all': 'All', + 'commands.previous': 'Previous', + 'commands.next': 'Next', + 'commands.page': 'Page {page} of {total}', + 'settings.title': 'Settings', + 'settings.desc': 'Venue and device management', + 'settings.venue': 'Venue', + 'settings.device': 'Device', + 'settings.paired': 'Paired as "{name}" — last seen {time}', + 'settings.not_paired': 'No device paired yet', + 'settings.connected': 'Connected', + 'settings.since': 'Since {date}', + 'settings.pair_desc': 'Pair a bar terminal to start receiving tabs.', + 'settings.pair': 'Pair Device', + 'settings.repair': 'Re-pair Device', + 'settings.pair_title': 'Pair a Device', + 'settings.pair_instructions': 'Enter this code on the bar terminal to connect it to your venue.', + 'settings.pair_code': 'Registration Code', + 'settings.pair_expires': 'Expires at {time}', + 'settings.pair_step1': 'Open the bar terminal app', + 'settings.pair_step2': 'Select your venue from the list', + 'settings.pair_step3': 'Enter this registration code', + 'settings.pair_step4': 'Give the terminal a name', + 'settings.pair_step5': 'Complete registration', + 'settings.copy': 'Copy Code', + 'settings.close': 'Close', + 'settings.api': 'API', + 'settings.base_url': 'Base URL', + 'settings.swagger': 'Swagger', + 'overview.title': 'Overview', + 'overview.desc': 'Real-time overview of your venue', + 'overview.open_tabs': 'Open Tabs', + 'overview.device': 'Device', + 'overview.pending': 'Pending Commands', + 'overview.recent': 'Recent Commands', + 'overview.no_commands': 'No commands yet', + 'overview.not_connected': 'Not connected', + 'overview.last_seen': 'Last seen {time}', + 'common.refresh': 'Refresh', + 'common.loading': 'Loading...', + 'common.error': 'Something went wrong', + 'common.confirm': 'Confirm', + 'common.cancel': 'Cancel', + 'common.processing': 'Processing...', + 'common.copied': 'Copied', + 'auth.login': 'Sign in', + 'auth.logging_in': 'Signing in...', + 'auth.email': 'Email', + 'auth.password': 'Password', + 'auth.invalid': 'Invalid email or password', + 'lang.en': 'English', + 'lang.nl': 'Nederlands', + 'lang.label': 'Language', + 'reminders.title': 'Payment Reminders', + 'reminders.desc': 'Send payment reminders to customers with open tabs', + 'reminders.candidates': 'Eligible for reminder', + 'reminders.candidates_desc': 'These customers have open tabs and an email address on file. Reminders will be sent via Resend.', + 'reminders.no_candidates': 'No eligible customers', + 'reminders.no_candidates_desc': 'Make sure planned customers have email addresses set.', + 'reminders.send': 'Send Reminders', + 'reminders.sending': 'Sending...', + 'reminders.result': 'Sent {sent} of {total} reminder(s)', + 'reminders.sent_title': 'Reminders Sent', + 'reminders.zero_sent': 'No reminders were sent. Check that Resend is configured.', + 'reminders.success': 'Successfully sent {sent} reminder email(s)', + 'reminders.history': 'Sent Reminders', + 'reminders.history_empty': 'No reminders have been sent yet', + 'reminders.customer': 'Customer', + 'reminders.email': 'Email', + 'reminders.amount': 'Amount', + 'reminders.sent_at': 'Sent At', + 'reminders.status': 'Status', + 'tabs.email': 'Email (optional)', + 'tabs.planned_email_hint': 'Add email to enable payment reminders', + }, + nl: { + 'nav.overview': 'Overzicht', + 'nav.tabs': 'Open Tabs', + 'nav.reminders': 'Herinneringen', + 'nav.users': 'Gebruikers', + 'nav.commands': 'Opdrachten', + 'nav.settings': 'Instellingen', + 'nav.logout': 'Uitloggen', + 'tabs.title': 'Open Tabs', + 'tabs.count': '{count} open tab', + 'tabs.count_plural': '{count} open tabs', + 'tabs.empty': 'Geen open tabs', + 'tabs.empty_desc': 'Open tabs verschijnen hier wanneer de terminal synchroniseert.', + 'tabs.customer': 'Klant', + 'tabs.amount': 'Bedrag', + 'tabs.items': 'Artikelen', + 'tabs.version': 'Versie', + 'tabs.reported': 'Laatst gemeld', + 'tabs.close': 'Sluiten', + 'tabs.close_title': 'Sluit {name}\'s tab?', + 'tabs.close_desc': 'Stuur een sluitverzoek naar de terminal.', + 'tabs.payment': 'Betaalmethode', + 'tabs.cash': 'Contant', + 'tabs.payconiq': 'Payconiq', + 'tabs.pending_payment': 'Betaling in behandeling', + 'tabs.select_payment': 'Selecteer een betaalmethode', + 'tabs.send': 'Sluitverzoek versturen', + 'tabs.sending': 'Bezig met verzenden...', + 'tabs.cancel': 'Annuleren', + 'tabs.planned_title': 'Geplande Klanten', + 'tabs.planned_desc': 'Deze namen worden gesynchroniseerd met de terminal. Deze maakt automatisch tabs aan voor deze klanten.', + 'tabs.planned_add': 'Klantnaam toevoegen...', + 'tabs.planned_empty': 'Geen geplande klanten. Voeg namen toe en ze worden gesynchroniseerd met de terminal.', + 'import.button': 'Importeren uit Excel', + 'import.title': 'Klanten importeren', + 'import.description': 'Upload de klantenlijst. Elke naam wordt een geplande klant en krijgt een rekening op de terminal.', + 'import.choose_file': 'Kies een bestand', + 'import.empty_file': 'Het bestand bevat geen rijen.', + 'import.unreadable': 'Dit bestand kon niet gelezen worden. Gebruik een .xlsx-, .xls- of .csv-bestand.', + 'import.name_column': 'Naam (of voornaam)', + 'import.last_name_column': 'Achternaam', + 'import.email_column': 'E-mail', + 'import.column_none': '— geen —', + 'import.has_header': 'De eerste rij bevat kolomnamen', + 'import.summary': '{count} klanten gevonden, {emails} met een e-mailadres.', + 'import.warnings': '{skipped} rijen zonder naam overgeslagen, {invalid} ongeldige e-mailadressen genegeerd.', + 'import.email': 'E-mail', + 'import.more': 'en nog {count}…', + 'import.mode': 'Bestaande klanten', + 'import.mode_merge': 'Klanten die niet in het bestand staan behouden', + 'import.mode_replace': 'Klanten die niet in het bestand staan verwijderen (hun open rekeningen op de terminal blijven)', + 'import.submit': '{count} klanten importeren', + 'import.importing': 'Bezig met importeren...', + 'import.done': 'Import voltooid', + 'import.result': '{created} toegevoegd, {updated} bijgewerkt, {unchanged} ongewijzigd, {removed} verwijderd.', + 'import.failed': 'Importeren mislukt', + 'users.title': 'Gebruikers', + 'users.count': '{count} gebruiker', + 'users.count_plural': '{count} gebruikers', + 'users.empty': 'Geen gebruikers', + 'users.empty_desc': 'Voeg gebruikers toe die tabs kunnen beheren vanaf dit dashboard.', + 'users.add': 'Gebruiker toevoegen', + 'users.name': 'Naam', + 'users.email': 'E-mail', + 'users.role': 'Rol', + 'users.created': 'Aangemaakt', + 'users.add_title': 'Gebruiker toevoegen', + 'users.add_desc': 'Maak een nieuwe gebruiker aan die toegang heeft tot het dashboard.', + 'users.password': 'Wachtwoord', + 'users.creating': 'Bezig...', + 'users.create': 'Gebruiker aanmaken', + 'commands.title': 'Opdrachtgeschiedenis', + 'commands.count': '{count} opdracht', + 'commands.count_plural': '{count} opdrachten', + 'commands.empty': 'Geen opdrachten gevonden', + 'commands.tab': 'Tab', + 'commands.requested': 'Aangevraagd door', + 'commands.payment': 'Betaling', + 'commands.created': 'Aangemaakt', + 'commands.status': 'Status', + 'commands.processed': 'Verwerkt', + 'commands.result': 'Resultaat', + 'commands.all': 'Alle', + 'commands.previous': 'Vorige', + 'commands.next': 'Volgende', + 'commands.page': 'Pagina {page} van {total}', + 'settings.title': 'Instellingen', + 'settings.desc': 'Locatie- en apparaatbeheer', + 'settings.venue': 'Locatie', + 'settings.device': 'Apparaat', + 'settings.paired': 'Gekoppeld als "{name}" — laatst gezien {time}', + 'settings.not_paired': 'Nog geen apparaat gekoppeld', + 'settings.connected': 'Verbonden', + 'settings.since': 'Sinds {date}', + 'settings.pair_desc': 'Koppel een barterminal om tabs te ontvangen.', + 'settings.pair': 'Apparaat koppelen', + 'settings.repair': 'Opnieuw koppelen', + 'settings.pair_title': 'Apparaat koppelen', + 'settings.pair_instructions': 'Voer deze code in op de barterminal om deze te verbinden met uw locatie.', + 'settings.pair_code': 'Registratiecode', + 'settings.pair_expires': 'Verloopt om {time}', + 'settings.pair_step1': 'Open de barterminal app', + 'settings.pair_step2': 'Selecteer uw locatie uit de lijst', + 'settings.pair_step3': 'Voer deze registratiecode in', + 'settings.pair_step4': 'Geef de terminal een naam', + 'settings.pair_step5': 'Voltooi de registratie', + 'settings.copy': 'Code kopiëren', + 'settings.close': 'Sluiten', + 'settings.api': 'API', + 'settings.base_url': 'Basis-URL', + 'settings.swagger': 'Swagger', + 'overview.title': 'Overzicht', + 'overview.desc': 'Real-time overzicht van uw locatie', + 'overview.open_tabs': 'Open Tabs', + 'overview.device': 'Apparaat', + 'overview.pending': 'Openstaande opdrachten', + 'overview.recent': 'Recente opdrachten', + 'overview.no_commands': 'Nog geen opdrachten', + 'overview.not_connected': 'Niet verbonden', + 'overview.last_seen': 'Laatst gezien {time}', + 'common.refresh': 'Verversen', + 'common.loading': 'Laden...', + 'common.error': 'Er is iets misgegaan', + 'common.confirm': 'Bevestigen', + 'common.cancel': 'Annuleren', + 'common.processing': 'Bezig...', + 'common.copied': 'Gekopieerd', + 'auth.login': 'Inloggen', + 'auth.logging_in': 'Bezig met inloggen...', + 'auth.email': 'E-mail', + 'auth.password': 'Wachtwoord', + 'auth.invalid': 'Ongeldig e-mailadres of wachtwoord', + 'lang.en': 'English', + 'lang.nl': 'Nederlands', + 'lang.label': 'Taal', + 'reminders.title': 'Betalingsherinneringen', + 'reminders.desc': 'Stuur betalingsherinneringen naar klanten met openstaande tabs', + 'reminders.candidates': 'In aanmerking voor herinnering', + 'reminders.candidates_desc': 'Deze klanten hebben openstaande tabs en een e-mailadres. Herinneringen worden verzonden via Resend.', + 'reminders.no_candidates': 'Geen geschikte klanten', + 'reminders.no_candidates_desc': 'Zorg dat geplande klanten een e-mailadres hebben.', + 'reminders.send': 'Verstuur herinneringen', + 'reminders.sending': 'Bezig met versturen...', + 'reminders.result': '{sent} van {total} herinnering(en) verzonden', + 'reminders.sent_title': 'Herinneringen verzonden', + 'reminders.zero_sent': 'Geen herinneringen verzonden. Controleer of Resend is geconfigureerd.', + 'reminders.success': '{sent} herinnering(en) succesvol verzonden', + 'reminders.history': 'Verzonden herinneringen', + 'reminders.history_empty': 'Nog geen herinneringen verzonden', + 'reminders.customer': 'Klant', + 'reminders.email': 'E-mail', + 'reminders.amount': 'Bedrag', + 'reminders.sent_at': 'Verzonden op', + 'reminders.status': 'Status', + 'tabs.email': 'E-mail (optioneel)', + 'tabs.planned_email_hint': 'Voeg e-mail toe om betalingsherinneringen in te schakelen', + }, +}; + +interface LangContextType { + lang: Lang; + setLang: (lang: Lang) => void; + t: (key: string, params?: Record) => string; +} + +const LangContext = createContext({ + lang: 'en', + setLang: () => {}, + t: (key) => key, +}); + +export function LanguageProvider({ children }: { children: ReactNode }) { + const [lang, setLangState] = useState('en'); + + useEffect(() => { + const stored = localStorage.getItem('kooltab-lang') as Lang | null; + if (stored === 'en' || stored === 'nl') setLangState(stored); + }, []); + + function setLang(l: Lang) { + setLangState(l); + localStorage.setItem('kooltab-lang', l); + } + + function t(key: string, params?: Record): string { + let s = translations[lang][key]; + if (!s) s = translations['en'][key] || key; + if (params) { + for (const [k, v] of Object.entries(params)) { + s = s.replace(`{${k}}`, String(v)); + } + } + return s; + } + + return ( + + {children} + + ); +} + +export function useLang() { + return useContext(LangContext); +} \ No newline at end of file diff --git a/apps/dashboard/tsconfig.tsbuildinfo b/apps/dashboard/tsconfig.tsbuildinfo new file mode 100644 index 0000000..984123d --- /dev/null +++ b/apps/dashboard/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"fileNames":["../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es5.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2023.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2024.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.iterable.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.core.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.collection.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.generator.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.iterable.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.proxy.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.reflect.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.array.include.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.date.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.object.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.regexp.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.array.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.object.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.symbol.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.bigint.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.date.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.number.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.weakref.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.array.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.error.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.object.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.regexp.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2023.array.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2023.collection.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2023.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2024.arraybuffer.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2024.collection.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2024.object.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2024.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2024.regexp.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2024.sharedmemory.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2024.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.array.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.collection.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.disposable.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.decorators.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.iterator.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.float16.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.error.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.sharedmemory.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.decorators.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.decorators.legacy.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/styled-jsx/types/css.d.ts","../../node_modules/.pnpm/@types+react@18.3.31/node_modules/@types/react/global.d.ts","../../node_modules/.pnpm/csstype@3.2.3/node_modules/csstype/index.d.ts","../../node_modules/.pnpm/@types+prop-types@15.7.15/node_modules/@types/prop-types/index.d.ts","../../node_modules/.pnpm/@types+react@18.3.31/node_modules/@types/react/index.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/styled-jsx/types/index.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/styled-jsx/types/macro.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/styled-jsx/types/style.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/styled-jsx/types/global.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/shared/lib/amp.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/amp.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/compatibility/disposable.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/compatibility/indexable.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/compatibility/iterators.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/compatibility/index.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/globals.typedarray.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/buffer.buffer.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/globals.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/web-globals/abortcontroller.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/web-globals/domexception.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/web-globals/events.d.ts","../../node_modules/.pnpm/buffer@5.7.1/node_modules/buffer/index.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/header.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/readable.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/file.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/fetch.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/formdata.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/connector.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/client.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/errors.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/dispatcher.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/global-dispatcher.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/global-origin.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/pool-stats.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/pool.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/handlers.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/balanced-pool.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-interceptor.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-client.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-pool.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/mock-errors.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/proxy-agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/env-http-proxy-agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/retry-handler.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/retry-agent.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/api.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/interceptors.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/util.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/cookies.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/patch.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/websocket.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/eventsource.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/filereader.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/diagnostics-channel.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/content-type.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/cache.d.ts","../../node_modules/.pnpm/undici-types@6.21.0/node_modules/undici-types/index.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/web-globals/fetch.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/assert.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/assert/strict.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/async_hooks.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/buffer.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/child_process.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/cluster.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/console.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/constants.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/crypto.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/dgram.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/diagnostics_channel.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/dns.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/dns/promises.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/domain.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/events.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/fs.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/fs/promises.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/http.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/http2.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/https.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/inspector.generated.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/module.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/net.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/os.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/path.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/perf_hooks.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/process.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/punycode.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/querystring.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/readline.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/readline/promises.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/repl.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/sea.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/stream.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/stream/promises.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/stream/consumers.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/stream/web.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/string_decoder.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/test.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/timers.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/timers/promises.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/tls.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/trace_events.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/tty.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/url.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/util.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/v8.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/vm.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/wasi.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/worker_threads.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/zlib.d.ts","../../node_modules/.pnpm/@types+node@20.19.43/node_modules/@types/node/index.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/get-page-files.d.ts","../../node_modules/.pnpm/@types+react@18.3.31/node_modules/@types/react/canary.d.ts","../../node_modules/.pnpm/@types+react@18.3.31/node_modules/@types/react/experimental.d.ts","../../node_modules/.pnpm/@types+react-dom@18.3.7_@types+react@18.3.31/node_modules/@types/react-dom/index.d.ts","../../node_modules/.pnpm/@types+react-dom@18.3.7_@types+react@18.3.31/node_modules/@types/react-dom/canary.d.ts","../../node_modules/.pnpm/@types+react-dom@18.3.7_@types+react@18.3.31/node_modules/@types/react-dom/experimental.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/compiled/webpack/webpack.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/config.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/lib/load-custom-routes.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/shared/lib/image-config.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/build/webpack/plugins/subresource-integrity-plugin.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/body-streams.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/future/route-kind.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/future/route-definitions/route-definition.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/future/route-matches/route-match.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/client/components/app-router-headers.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/request-meta.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/lib/revalidate.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/config-shared.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/base-http/index.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/api-utils/index.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/node-environment.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/require-hook.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/node-polyfill-crypto.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/lib/page-types.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/build/analysis/get-page-static-info.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/build/webpack/loaders/get-module-build-info.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/build/webpack/plugins/middleware-plugin.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/render-result.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/future/helpers/i18n-provider.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/web/next-url.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/compiled/@edge-runtime/cookies/index.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/web/spec-extension/cookies.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/web/spec-extension/request.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/web/spec-extension/fetch-event.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/web/spec-extension/response.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/web/types.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/lib/setup-exception-listeners.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/lib/constants.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/build/index.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/build/webpack/plugins/pages-manifest-plugin.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/shared/lib/router/utils/route-regex.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/shared/lib/router/utils/route-matcher.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/shared/lib/router/utils/parse-url.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/base-http/node.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/font-utils.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/build/webpack/plugins/flight-manifest-plugin.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/future/route-modules/route-module.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/shared/lib/deep-readonly.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/load-components.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/shared/lib/router/utils/middleware-route-matcher.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/build/webpack/plugins/next-font-manifest-plugin.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/future/route-definitions/locale-route-definition.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/future/route-definitions/pages-route-definition.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/shared/lib/mitt.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/client/with-router.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/client/router.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/client/route-loader.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/client/page-loader.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/shared/lib/bloom-filter.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/shared/lib/router/router.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/shared/lib/router-context.shared-runtime.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/shared/lib/loadable-context.shared-runtime.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/shared/lib/loadable.shared-runtime.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/shared/lib/image-config-context.shared-runtime.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/shared/lib/hooks-client-context.shared-runtime.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/shared/lib/head-manager-context.shared-runtime.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/future/route-definitions/app-page-route-definition.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/shared/lib/modern-browserslist-target.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/shared/lib/constants.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/build/webpack/loaders/metadata/types.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/build/page-extensions-type.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/build/webpack/loaders/next-app-loader.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/lib/app-dir-module.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/response-cache/types.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/response-cache/index.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/lib/incremental-cache/index.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/client/components/hooks-server-context.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/app-render/dynamic-rendering.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/client/components/static-generation-async-storage-instance.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/client/components/static-generation-async-storage.external.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/web/spec-extension/adapters/request-cookies.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/async-storage/draft-mode-provider.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/web/spec-extension/adapters/headers.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/client/components/request-async-storage-instance.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/client/components/request-async-storage.external.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/app-render/create-error-handler.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/app-render/app-render.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/shared/lib/server-inserted-html.shared-runtime.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/shared/lib/amp-context.shared-runtime.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/future/route-modules/app-page/vendored/contexts/entrypoints.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/future/route-modules/app-page/module.compiled.d.ts","../../node_modules/.pnpm/@types+react@18.3.31/node_modules/@types/react/jsx-runtime.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/client/components/error-boundary.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/client/components/router-reducer/create-initial-router-state.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/client/components/app-router.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/client/components/layout-router.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/client/components/render-from-template-context.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/client/components/action-async-storage-instance.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/client/components/action-async-storage.external.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/client/components/client-page.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/client/components/search-params.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/client/components/not-found-boundary.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/app-render/rsc/preloads.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/app-render/rsc/postpone.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/app-render/rsc/taint.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/app-render/entry-base.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/build/templates/app-page.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/future/route-modules/app-page/module.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/lib/builtin-request-context.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/app-render/types.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/client/components/router-reducer/fetch-server-response.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/client/components/router-reducer/router-reducer-types.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/shared/lib/app-router-context.shared-runtime.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/future/route-modules/pages/vendored/contexts/entrypoints.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/future/route-modules/pages/module.compiled.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/build/templates/pages.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/future/route-modules/pages/module.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/render.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/future/route-definitions/pages-api-route-definition.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/future/route-matches/pages-api-route-match.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/future/route-matchers/route-matcher.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/future/route-matcher-providers/route-matcher-provider.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/future/route-matcher-managers/route-matcher-manager.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/future/normalizers/normalizer.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/future/normalizers/locale-route-normalizer.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/future/normalizers/request/pathname-normalizer.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/future/normalizers/request/suffix.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/future/normalizers/request/rsc.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/future/normalizers/request/prefix.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/future/normalizers/request/postponed.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/future/normalizers/request/action.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/future/normalizers/request/prefetch-rsc.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/future/normalizers/request/next-data.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/base-server.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/image-optimizer.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/next-server.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/lib/coalesced-function.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/lib/router-utils/types.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/trace/types.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/trace/trace.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/trace/shared.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/trace/index.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/build/load-jsconfig.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/build/webpack-config.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/build/webpack/plugins/define-env-plugin.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/build/swc/index.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/dev/parse-version-info.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/dev/hot-reloader-types.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/telemetry/storage.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/lib/types.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/lib/render-server.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/lib/router-server.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/shared/lib/router/utils/path-match.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/lib/router-utils/filesystem.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/lib/router-utils/setup-dev-bundler.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/lib/dev-bundler-service.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/dev/static-paths-worker.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/dev/next-dev-server.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/next.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/lib/metadata/types/alternative-urls-types.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/lib/metadata/types/extra-types.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/lib/metadata/types/metadata-types.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/lib/metadata/types/manifest-types.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/lib/metadata/types/opengraph-types.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/lib/metadata/types/twitter-types.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/lib/metadata/types/metadata-interface.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/types/index.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/shared/lib/html-context.shared-runtime.d.ts","../../node_modules/.pnpm/@next+env@14.2.35/node_modules/@next/env/dist/index.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/shared/lib/utils.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/pages/_app.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/app.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/web/spec-extension/unstable-cache.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/web/spec-extension/revalidate.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/web/spec-extension/unstable-no-store.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/cache.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/shared/lib/runtime-config.external.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/config.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/pages/_document.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/document.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/shared/lib/dynamic.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dynamic.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/pages/_error.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/error.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/shared/lib/head.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/head.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/client/components/draft-mode.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/client/components/headers.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/headers.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/shared/lib/get-img-props.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/client/image-component.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/shared/lib/image-external.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/image.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/client/link.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/link.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/client/components/redirect-status-code.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/client/components/redirect.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/client/components/not-found.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/client/components/navigation.react-server.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/client/components/navigation.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/navigation.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/router.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/client/script.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/script.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/web/spec-extension/user-agent.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/compiled/@edge-runtime/primitives/url.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/server/web/spec-extension/image-response.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/compiled/@vercel/og/satori/index.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/compiled/@vercel/og/emoji/index.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/compiled/@vercel/og/types.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/server.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/types/global.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/types/compiled.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/index.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/image-types/global.d.ts","./next-env.d.ts","../../node_modules/.pnpm/clsx@2.1.1/node_modules/clsx/clsx.d.mts","../../node_modules/.pnpm/tailwind-merge@2.6.1/node_modules/tailwind-merge/dist/types.d.ts","./src/lib/cn.ts","../../node_modules/.pnpm/xlsx@https+++cdn.sheetjs.com+xlsx-0.20.3+xlsx-0.20.3.tgz/node_modules/xlsx/types/index.d.ts","./src/lib/customer-import.ts","./src/lib/device-status.ts","./src/lib/utils.ts","./src/lib/api/client.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/compiled/@next/font/dist/types.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/dist/compiled/@next/font/dist/google/index.d.ts","../../node_modules/.pnpm/next@14.2.35_@babel+core@7._0fc1ba9056757c635d009ffbbc95e3b9/node_modules/next/font/google/index.d.ts","../../node_modules/.pnpm/@tanstack+query-core@5.103.1/node_modules/@tanstack/query-core/build/modern/environmentmanager.d.ts","../../node_modules/.pnpm/@tanstack+query-core@5.103.1/node_modules/@tanstack/query-core/build/modern/subscribable-cbifvtkz.d.ts","../../node_modules/.pnpm/@tanstack+query-core@5.103.1/node_modules/@tanstack/query-core/build/modern/focusmanager.d.ts","../../node_modules/.pnpm/@tanstack+query-core@5.103.1/node_modules/@tanstack/query-core/build/modern/removable-demjhw5m.d.ts","../../node_modules/.pnpm/@tanstack+query-core@5.103.1/node_modules/@tanstack/query-core/build/modern/hydration-cq7qyazb.d.ts","../../node_modules/.pnpm/@tanstack+query-core@5.103.1/node_modules/@tanstack/query-core/build/modern/infinitequeryobserver.d.ts","../../node_modules/.pnpm/@tanstack+query-core@5.103.1/node_modules/@tanstack/query-core/build/modern/notifymanager.d.ts","../../node_modules/.pnpm/@tanstack+query-core@5.103.1/node_modules/@tanstack/query-core/build/modern/onlinemanager.d.ts","../../node_modules/.pnpm/@tanstack+query-core@5.103.1/node_modules/@tanstack/query-core/build/modern/queriesobserver.d.ts","../../node_modules/.pnpm/@tanstack+query-core@5.103.1/node_modules/@tanstack/query-core/build/modern/timeoutmanager.d.ts","../../node_modules/.pnpm/@tanstack+query-core@5.103.1/node_modules/@tanstack/query-core/build/modern/streamedquery.d.ts","../../node_modules/.pnpm/@tanstack+query-core@5.103.1/node_modules/@tanstack/query-core/build/modern/index.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/hydrationboundary.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/isrestoringprovider.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/queryclientprovider.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/queryerrorresetboundary.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/types.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/usequeries.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/queryoptions.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/usequery.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/usesuspensequery.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/usesuspenseinfinitequery.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/usesuspensequeries.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/useprefetchquery.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/useprefetchinfinitequery.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/infinitequeryoptions.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/useisfetching.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/usemutationstate.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/usemutation.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/mutationoptions.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/useinfinitequery.d.ts","../../node_modules/.pnpm/@tanstack+react-query@5.103.1_react@18.3.1/node_modules/@tanstack/react-query/build/modern/index.d.ts","./src/lib/i18n.tsx","./src/app/providers.tsx","./src/app/layout.tsx","./src/app/page.tsx","../../node_modules/.pnpm/lucide-react@0.309.0_react@18.3.1/node_modules/lucide-react/dist/lucide-react.d.ts","../../node_modules/.pnpm/@radix-ui+react-slot@1.3.3_@types+react@18.3.31_react@18.3.1/node_modules/@radix-ui/react-slot/dist/index.d.mts","../../node_modules/.pnpm/class-variance-authority@0.7.1/node_modules/class-variance-authority/dist/types.d.ts","../../node_modules/.pnpm/class-variance-authority@0.7.1/node_modules/class-variance-authority/dist/index.d.ts","./src/components/ui/button.tsx","./src/components/language-selector.tsx","../../node_modules/.pnpm/@radix-ui+react-context@1.2_7e5a4e8f36c561d39949339c7011401d/node_modules/@radix-ui/react-context/dist/index.d.mts","../../node_modules/.pnpm/@radix-ui+react-primitive@2_dba614f83980a1ab9805a3f12c0d2bc2/node_modules/@radix-ui/react-primitive/dist/index.d.mts","../../node_modules/.pnpm/@radix-ui+react-dismissable_4f2024a88a120e800aa2287adeb24710/node_modules/@radix-ui/react-dismissable-layer/dist/index.d.mts","../../node_modules/.pnpm/@radix-ui+react-toast@1.2.2_b178c2c4a82f5fa77750a59f509799ce/node_modules/@radix-ui/react-toast/dist/index.d.mts","./src/components/ui/toast.tsx","./src/app/dashboard/layout.tsx","./src/components/page-header.tsx","./src/components/ui/card.tsx","./src/components/ui/skeleton.tsx","./src/components/loading-state.tsx","./src/components/ui/badge.tsx","./src/components/command-status-badge.tsx","./src/app/dashboard/page.tsx","./src/components/ui/table.tsx","./src/components/payment-method-badge.tsx","./src/components/empty-state.tsx","./src/components/error-state.tsx","./src/app/dashboard/commands/page.tsx","../../node_modules/.pnpm/@radix-ui+react-focus-scope_161760c0d51338f080e5b66a9ea35f08/node_modules/@radix-ui/react-focus-scope/dist/index.d.mts","../../node_modules/.pnpm/@radix-ui+react-portal@1.1._35c248e3d41b4a33144a8517e72175a4/node_modules/@radix-ui/react-portal/dist/index.d.mts","../../node_modules/.pnpm/@radix-ui+react-dialog@1.1._89c2a39b94a39c3c4bfcbd95c00244cd/node_modules/@radix-ui/react-dialog/dist/index.d.mts","./src/components/ui/dialog.tsx","./src/app/dashboard/reminders/page.tsx","./src/app/dashboard/settings/page.tsx","./src/components/ui/input.tsx","../../node_modules/.pnpm/@radix-ui+react-label@2.1.1_da1c8461c5aa9a0df92eac209c865a77/node_modules/@radix-ui/react-label/dist/index.d.mts","./src/components/ui/label.tsx","../../node_modules/.pnpm/@radix-ui+react-roving-focu_d5b0f6117fe35925e77b051a5d0d3813/node_modules/@radix-ui/react-roving-focus/dist/index.d.mts","../../node_modules/.pnpm/@radix-ui+react-radio-group_329e69a3bae96a8187bc0f963334324d/node_modules/@radix-ui/react-radio-group/dist/index.d.mts","./src/components/ui/radio-group.tsx","./src/components/import-customers-dialog.tsx","./src/app/dashboard/tabs/page.tsx","../../node_modules/.pnpm/@radix-ui+react-arrow@1.1.1_4adf567bd0450ecb7dbf3f22f0548dcb/node_modules/@radix-ui/react-arrow/dist/index.d.mts","../../node_modules/.pnpm/@radix-ui+rect@1.1.3/node_modules/@radix-ui/rect/dist/index.d.mts","../../node_modules/.pnpm/@radix-ui+react-popper@1.3._eb61390ffeb98719c4501fa966467838/node_modules/@radix-ui/react-popper/dist/index.d.mts","../../node_modules/.pnpm/@radix-ui+react-select@2.3._17810f902be7f268a61046ec24c8ec35/node_modules/@radix-ui/react-select/dist/index.d.mts","./src/components/ui/select.tsx","./src/app/dashboard/users/page.tsx","./src/app/login/page.tsx","./src/components/confirm-dialog.tsx","./src/components/device-status-badge.tsx","./.next/types/app/page.ts","./.next/types/app/dashboard/page.ts","./.next/types/app/dashboard/commands/page.ts","./.next/types/app/dashboard/reminders/page.ts","./.next/types/app/dashboard/settings/page.ts","./.next/types/app/dashboard/tabs/page.ts","./.next/types/app/dashboard/users/page.ts","./.next/types/app/login/page.ts"],"fileIdsList":[[99,146,361,482],[99,146,361,477],[99,146,361,487],[99,146,361,488],[99,146,361,496],[99,146,361,502],[99,146,361,503],[99,146,361,458],[99,146,409,410],[87,99,146,396,418,419,454,459,463,471,474,475,476,478,479,480,481],[87,99,146,390,396,414,419,455,459,463,464,469],[87,99,146,396,418,419,454,459,471,472,473,474,476],[87,99,146,396,418,419,454,455,459,463,469,471,472,474,475,478,480,481,486],[87,99,146,396,418,419,454,459,463,469,471,472,475,486],[87,99,146,396,418,419,454,455,459,463,469,471,472,474,478,480,481,486,489,491,494,495],[87,99,146,396,418,419,454,459,463,469,471,474,475,478,480,481,486,489,491,501],[99,146,409,422,456],[87,99,146,396,419,463,472,489,491],[87,99,146,396,419],[87,99,146,454,455],[99,146,475],[99,146,463,486],[99,146,417,475],[99,146,472],[87,99,146,416,419,455,459,463,469,478,486,491,494],[99,146,455,459],[99,146,472,473],[87,99,146],[87,99,146,414,462],[87,99,146,414,460,462],[87,99,146,414],[87,99,146,414,459,485],[87,99,146,414,462,490],[87,99,146,414,459,493],[87,99,146,414,459,500],[87,99,146,414,459,462,468],[99,146],[99,146,412,413],[99,146,415],[99,146,414],[87,99,146,466],[87,99,146,465,466,467,483,484],[87,99,146,465,466,497,498],[87,99,146,287,465,466,492],[87,99,146,465,466],[87,99,146,287,465,466,467,483,484,499],[87,99,146,465,466,467],[99,146,424],[99,146,424,426],[99,146,423,425,427,428,429,430,431,432,433],[99,146,424,427],[99,146,427],[87,99,146,434],[99,146,434,435,436,437,438,439,440,441,442,443,444,445,446,447,448,449,450,451,452,453],[99,146,434,439],[87,99,146,287],[99,146,434],[99,146,434,439,448],[99,146,434,439,441],[99,143,146],[99,145,146],[146],[99,146,151,179],[99,146,147,152,157,165,176,187],[99,146,147,148,157,165],[94,95,96,99,146],[99,146,149,188],[99,146,150,151,158,166],[99,146,151,176,184],[99,146,152,154,157,165],[99,145,146,153],[99,146,154,155],[99,146,156,157],[99,145,146,157],[99,146,157,158,159,176,187],[99,146,157,158,159,172,176,179],[99,146,154,157,160,165,176,187],[99,146,157,158,160,161,165,176,184,187],[99,146,160,162,176,184,187],[97,98,99,100,101,102,103,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193],[99,146,157,163],[99,146,164,187,192],[99,146,154,157,165,176],[99,146,166],[99,146,167],[99,145,146,168],[99,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193],[99,146,170],[99,146,171],[99,146,157,172,173],[99,146,172,174,188,190],[99,146,157,176,177,179],[99,146,178,179],[99,146,176,177],[99,146,179],[99,146,180],[99,143,146,176,181],[99,146,157,182,183],[99,146,182,183],[99,146,151,165,176,184],[99,146,185],[99,146,165,186],[99,146,160,171,187],[99,146,151,188],[99,146,176,189],[99,146,164,190],[99,146,191],[99,141,146],[99,141,146,157,159,168,176,179,187,190,192],[99,146,176,193],[87,99,146,198,199,200],[87,99,146,198,199],[87,91,99,146,197,362,405],[87,91,99,146,196,362,405],[84,85,86,99,146],[99,146,412,461],[99,146,412],[92,99,146],[99,146,366],[99,146,368,369,370],[99,146,372],[99,146,203,213,219,221,362],[99,146,203,210,212,215,233],[99,146,213],[99,146,213,215,340],[99,146,268,286,301,408],[99,146,310],[99,146,203,213,220,254,264,337,338,408],[99,146,220,408],[99,146,213,264,265,266,408],[99,146,213,220,254,408],[99,146,408],[99,146,203,220,221,408],[99,146,294],[99,145,146,194,293],[87,99,146,287,288,289,307,308],[99,146,277],[99,146,276,278,382],[87,99,146,287,288,305],[99,146,283,308,394],[99,146,392,393],[99,146,227,391],[99,146,280],[99,145,146,194,227,243,276,277,278,279],[87,99,146,305,307,308],[99,146,305,307],[99,146,305,306,308],[99,146,171,194],[99,146,275],[99,145,146,194,212,214,271,272,273,274],[87,99,146,204,385],[87,99,146,187,194],[87,99,146,220,252],[87,99,146,220],[99,146,250,255],[87,99,146,251,365],[99,146,420],[87,91,99,146,160,194,196,197,362,403,404],[99,146,362],[99,146,202],[99,146,355,356,357,358,359,360],[99,146,357],[87,99,146,251,287,365],[87,99,146,287,363,365],[87,99,146,287,365],[99,146,160,194,214,365],[99,146,160,194,211,212,223,241,243,275,280,281,303,305],[99,146,272,275,280,288,290,291,292,294,295,296,297,298,299,300,408],[99,146,273],[87,99,146,171,194,212,213,241,243,244,246,271,303,304,308,362,408],[99,146,160,194,214,215,227,228,276],[99,146,160,194,213,215],[99,146,160,176,194,211,214,215],[99,146,160,171,187,194,211,212,213,214,215,220,223,224,234,235,237,240,241,243,244,245,246,270,271,304,305,313,315,318,320,323,325,326,327,328],[99,146,160,176,194],[99,146,203,204,205,211,212,362,365,408],[99,146,160,176,187,194,208,339,341,342,408],[99,146,171,187,194,208,211,214,231,235,237,238,239,244,271,318,329,331,337,351,352],[99,146,213,217,271],[99,146,211,213],[99,146,224,319],[99,146,321,322],[99,146,321],[99,146,319],[99,146,321,324],[99,146,207,208],[99,146,207,247],[99,146,207],[99,146,209,224,317],[99,146,316],[99,146,208,209],[99,146,209,314],[99,146,208],[99,146,303],[99,146,160,194,211,223,242,262,268,282,285,302,305],[99,146,256,257,258,259,260,261,283,284,308,363],[99,146,312],[99,146,160,194,211,223,242,248,309,311,313,362,365],[99,146,160,187,194,204,211,213,270],[99,146,267],[99,146,160,194,345,350],[99,146,234,243,270,365],[99,146,333,337,351,354],[99,146,160,217,337,345,346,354],[99,146,203,213,234,245,348],[99,146,160,194,213,220,245,332,333,343,344,347,349],[99,146,195,241,242,243,362,365],[99,146,160,171,187,194,209,211,212,214,217,222,223,231,234,235,237,238,239,240,244,246,270,271,315,329,330,365],[99,146,160,194,211,213,217,331,353],[99,146,160,194,212,214],[87,99,146,160,171,194,202,204,211,212,215,223,240,241,243,244,246,312,362,365],[99,146,160,171,187,194,206,209,210,214],[99,146,207,269],[99,146,160,194,207,212,223],[99,146,160,194,213,224],[99,146,160,194],[99,146,227],[99,146,226],[99,146,228],[99,146,213,225,227,231],[99,146,213,225,227],[99,146,160,194,206,213,214,220,228,229,230],[87,99,146,305,306,307],[99,146,263],[87,99,146,204],[87,99,146,237],[87,99,146,195,240,243,246,362,365],[99,146,204,385,386],[87,99,146,255],[87,99,146,171,187,194,202,249,251,253,254,365],[99,146,214,220,237],[99,146,236],[87,99,146,158,160,171,194,202,255,264,362,363,364],[83,87,88,89,90,99,146,196,197,362,405],[99,146,151],[99,146,334,335,336],[99,146,334],[99,146,374],[99,146,376],[99,146,378],[99,146,421],[99,146,380],[99,146,383],[99,146,387],[91,93,99,146,362,367,371,373,375,377,379,381,384,388,390,396,397,399,406,407,408],[99,146,389],[99,146,395],[99,146,251],[99,146,398],[99,145,146,228,229,230,231,400,401,402,405],[99,146,194],[87,91,99,146,160,162,171,194,196,197,198,200,202,215,354,361,365,405],[99,113,117,146,187],[99,113,146,176,187],[99,108,146],[99,110,113,146,184,187],[99,146,165,184],[99,108,146,194],[99,110,113,146,165,187],[99,105,106,109,112,146,157,176,187],[99,113,120,146],[99,105,111,146],[99,113,134,135,146],[99,109,113,146,179,187,194],[99,134,146,194],[99,107,108,146,194],[99,113,146],[99,107,108,109,110,111,112,113,114,115,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,135,136,137,138,139,140,146],[99,113,128,146],[99,113,120,121,146],[99,111,113,121,122,146],[99,112,146],[99,105,108,113,146],[99,113,117,121,122,146],[99,117,146],[99,111,113,116,146,187],[99,105,110,113,120,146],[99,146,176],[99,108,113,134,146,192,194]],"fileInfos":[{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"feecb1be483ed332fad555aff858affd90a48ab19ba7272ee084704eb7167569","impliedFormat":1},{"version":"ee7bad0c15b58988daa84371e0b89d313b762ab83cb5b31b8a2d1162e8eb41c2","impliedFormat":1},{"version":"27bdc30a0e32783366a5abeda841bc22757c1797de8681bbe81fbc735eeb1c10","impliedFormat":1},{"version":"8fd575e12870e9944c7e1d62e1f5a73fcf23dd8d3a321f2a2c74c20d022283fe","impliedFormat":1},{"version":"2ab096661c711e4a81cc464fa1e6feb929a54f5340b46b0a07ac6bbf857471f0","impliedFormat":1},{"version":"080941d9f9ff9307f7e27a83bcd888b7c8270716c39af943532438932ec1d0b9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2e80ee7a49e8ac312cc11b77f1475804bee36b3b2bc896bead8b6e1266befb43","affectsGlobalScope":true,"impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"a680117f487a4d2f30ea46f1b4b7f58bef1480456e18ba53ee85c2746eeca012","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"af3dd424cf267428f30ccfc376f47a2c0114546b55c44d8c0f1d57d841e28d74","affectsGlobalScope":true,"impliedFormat":1},{"version":"995c005ab91a498455ea8dfb63aa9f83fa2ea793c3d8aa344be4a1678d06d399","affectsGlobalScope":true,"impliedFormat":1},{"version":"959d36cddf5e7d572a65045b876f2956c973a586da58e5d26cde519184fd9b8a","affectsGlobalScope":true,"impliedFormat":1},{"version":"965f36eae237dd74e6cca203a43e9ca801ce38824ead814728a2807b1910117d","affectsGlobalScope":true,"impliedFormat":1},{"version":"3925a6c820dcb1a06506c90b1577db1fdbf7705d65b62b99dce4be75c637e26b","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a3d63ef2b853447ec4f749d3f368ce642264246e02911fcb1590d8c161b8005","affectsGlobalScope":true,"impliedFormat":1},{"version":"8cdf8847677ac7d20486e54dd3fcf09eda95812ac8ace44b4418da1bbbab6eb8","affectsGlobalScope":true,"impliedFormat":1},{"version":"8444af78980e3b20b49324f4a16ba35024fef3ee069a0eb67616ea6ca821c47a","affectsGlobalScope":true,"impliedFormat":1},{"version":"3287d9d085fbd618c3971944b65b4be57859f5415f495b33a6adc994edd2f004","affectsGlobalScope":true,"impliedFormat":1},{"version":"b4b67b1a91182421f5df999988c690f14d813b9850b40acd06ed44691f6727ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"df83c2a6c73228b625b0beb6669c7ee2a09c914637e2d35170723ad49c0f5cd4","affectsGlobalScope":true,"impliedFormat":1},{"version":"436aaf437562f276ec2ddbee2f2cdedac7664c1e4c1d2c36839ddd582eeb3d0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e3c06ea092138bf9fa5e874a1fdbc9d54805d074bee1de31b99a11e2fec239d","affectsGlobalScope":true,"impliedFormat":1},{"version":"87dc0f382502f5bbce5129bdc0aea21e19a3abbc19259e0b43ae038a9fc4e326","affectsGlobalScope":true,"impliedFormat":1},{"version":"b1cb28af0c891c8c96b2d6b7be76bd394fddcfdb4709a20ba05a7c1605eea0f9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2fef54945a13095fdb9b84f705f2b5994597640c46afeb2ce78352fab4cb3279","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac77cb3e8c6d3565793eb90a8373ee8033146315a3dbead3bde8db5eaf5e5ec6","affectsGlobalScope":true,"impliedFormat":1},{"version":"56e4ed5aab5f5920980066a9409bfaf53e6d21d3f8d020c17e4de584d29600ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ece9f17b3866cc077099c73f4983bddbcb1dc7ddb943227f1ec070f529dedd1","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a6282c8827e4b9a95f4bf4f5c205673ada31b982f50572d27103df8ceb8013c","affectsGlobalScope":true,"impliedFormat":1},{"version":"1c9319a09485199c1f7b0498f2988d6d2249793ef67edda49d1e584746be9032","affectsGlobalScope":true,"impliedFormat":1},{"version":"e3a2a0cee0f03ffdde24d89660eba2685bfbdeae955a6c67e8c4c9fd28928eeb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811c71eee4aa0ac5f7adf713323a5c41b0cf6c4e17367a34fbce379e12bbf0a4","affectsGlobalScope":true,"impliedFormat":1},{"version":"51ad4c928303041605b4d7ae32e0c1ee387d43a24cd6f1ebf4a2699e1076d4fa","affectsGlobalScope":true,"impliedFormat":1},{"version":"60037901da1a425516449b9a20073aa03386cce92f7a1fd902d7602be3a7c2e9","affectsGlobalScope":true,"impliedFormat":1},{"version":"d4b1d2c51d058fc21ec2629fff7a76249dec2e36e12960ea056e3ef89174080f","affectsGlobalScope":true,"impliedFormat":1},{"version":"22adec94ef7047a6c9d1af3cb96be87a335908bf9ef386ae9fd50eeb37f44c47","affectsGlobalScope":true,"impliedFormat":1},{"version":"196cb558a13d4533a5163286f30b0509ce0210e4b316c56c38d4c0fd2fb38405","affectsGlobalScope":true,"impliedFormat":1},{"version":"73f78680d4c08509933daf80947902f6ff41b6230f94dd002ae372620adb0f60","affectsGlobalScope":true,"impliedFormat":1},{"version":"c5239f5c01bcfa9cd32f37c496cf19c61d69d37e48be9de612b541aac915805b","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"0990a7576222f248f0a3b888adcb7389f957928ce2afb1cd5128169086ff4d29","impliedFormat":1},{"version":"eb5b19b86227ace1d29ea4cf81387279d04bb34051e944bc53df69f58914b788","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac51dd7d31333793807a6abaa5ae168512b6131bd41d9c5b98477fc3b7800f9f","impliedFormat":1},{"version":"87d9d29dbc745f182683f63187bf3d53fd8673e5fca38ad5eaab69798ed29fbc","impliedFormat":1},{"version":"09ddcfcfbe77a8232d155ca1030005106b1328f6210df43629d0be750da07c16","affectsGlobalScope":true,"impliedFormat":1},{"version":"cc69795d9954ee4ad57545b10c7bf1a7260d990231b1685c147ea71a6faa265c","impliedFormat":1},{"version":"8bc6c94ff4f2af1f4023b7bb2379b08d3d7dd80c698c9f0b07431ea16101f05f","impliedFormat":1},{"version":"1b61d259de5350f8b1e5db06290d31eaebebc6baafd5f79d314b5af9256d7153","impliedFormat":1},{"version":"57194e1f007f3f2cbef26fa299d4c6b21f4623a2eddc63dfeef79e38e187a36e","impliedFormat":1},{"version":"0f6666b58e9276ac3a38fdc80993d19208442d6027ab885580d93aec76b4ef00","impliedFormat":1},{"version":"05fd364b8ef02fb1e174fbac8b825bdb1e5a36a016997c8e421f5fab0a6da0a0","impliedFormat":1},{"version":"70521b6ab0dcba37539e5303104f29b721bfb2940b2776da4cc818c07e1fefc1","affectsGlobalScope":true,"impliedFormat":1},{"version":"ab41ef1f2cdafb8df48be20cd969d875602483859dc194e9c97c8a576892c052","affectsGlobalScope":true,"impliedFormat":1},{"version":"d153a11543fd884b596587ccd97aebbeed950b26933ee000f94009f1ab142848","affectsGlobalScope":true,"impliedFormat":1},{"version":"21d819c173c0cf7cc3ce57c3276e77fd9a8a01d35a06ad87158781515c9a438a","impliedFormat":1},{"version":"98cffbf06d6bab333473c70a893770dbe990783904002c4f1a960447b4b53dca","affectsGlobalScope":true,"impliedFormat":1},{"version":"ba481bca06f37d3f2c137ce343c7d5937029b2468f8e26111f3c9d9963d6568d","affectsGlobalScope":true,"impliedFormat":1},{"version":"6d9ef24f9a22a88e3e9b3b3d8c40ab1ddb0853f1bfbd5c843c37800138437b61","affectsGlobalScope":true,"impliedFormat":1},{"version":"1db0b7dca579049ca4193d034d835f6bfe73096c73663e5ef9a0b5779939f3d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"9798340ffb0d067d69b1ae5b32faa17ab31b82466a3fc00d8f2f2df0c8554aaa","affectsGlobalScope":true,"impliedFormat":1},{"version":"f26b11d8d8e4b8028f1c7d618b22274c892e4b0ef5b3678a8ccbad85419aef43","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e9c23ba78aabc2e0a27033f18737a6df754067731e69dc5f52823957d60a4b6","impliedFormat":1},{"version":"5929864ce17fba74232584d90cb721a89b7ad277220627cc97054ba15a98ea8f","impliedFormat":1},{"version":"763fe0f42b3d79b440a9b6e51e9ba3f3f91352469c1e4b3b67bfa4ff6352f3f4","impliedFormat":1},{"version":"25c8056edf4314820382a5fdb4bb7816999acdcb929c8f75e3f39473b87e85bc","impliedFormat":1},{"version":"c464d66b20788266e5353b48dc4aa6bc0dc4a707276df1e7152ab0c9ae21fad8","impliedFormat":1},{"version":"78d0d27c130d35c60b5e5566c9f1e5be77caf39804636bc1a40133919a949f21","impliedFormat":1},{"version":"c6fd2c5a395f2432786c9cb8deb870b9b0e8ff7e22c029954fabdd692bff6195","impliedFormat":1},{"version":"1d6e127068ea8e104a912e42fc0a110e2aa5a66a356a917a163e8cf9a65e4a75","impliedFormat":1},{"version":"5ded6427296cdf3b9542de4471d2aa8d3983671d4cac0f4bf9c637208d1ced43","impliedFormat":1},{"version":"7f182617db458e98fc18dfb272d40aa2fff3a353c44a89b2c0ccb3937709bfb5","impliedFormat":1},{"version":"cadc8aced301244057c4e7e73fbcae534b0f5b12a37b150d80e5a45aa4bebcbd","impliedFormat":1},{"version":"385aab901643aa54e1c36f5ef3107913b10d1b5bb8cbcd933d4263b80a0d7f20","impliedFormat":1},{"version":"9670d44354bab9d9982eca21945686b5c24a3f893db73c0dae0fd74217a4c219","impliedFormat":1},{"version":"0b8a9268adaf4da35e7fa830c8981cfa22adbbe5b3f6f5ab91f6658899e657a7","impliedFormat":1},{"version":"11396ed8a44c02ab9798b7dca436009f866e8dae3c9c25e8c1fbc396880bf1bb","impliedFormat":1},{"version":"ba7bc87d01492633cb5a0e5da8a4a42a1c86270e7b3d2dea5d156828a84e4882","impliedFormat":1},{"version":"4893a895ea92c85345017a04ed427cbd6a1710453338df26881a6019432febdd","impliedFormat":1},{"version":"c21dc52e277bcfc75fac0436ccb75c204f9e1b3fa5e12729670910639f27343e","impliedFormat":1},{"version":"13f6f39e12b1518c6650bbb220c8985999020fe0f21d818e28f512b7771d00f9","impliedFormat":1},{"version":"9b5369969f6e7175740bf51223112ff209f94ba43ecd3bb09eefff9fd675624a","impliedFormat":1},{"version":"4fe9e626e7164748e8769bbf74b538e09607f07ed17c2f20af8d680ee49fc1da","impliedFormat":1},{"version":"24515859bc0b836719105bb6cc3d68255042a9f02a6022b3187948b204946bd2","impliedFormat":1},{"version":"ea0148f897b45a76544ae179784c95af1bd6721b8610af9ffa467a518a086a43","impliedFormat":1},{"version":"24c6a117721e606c9984335f71711877293a9651e44f59f3d21c1ea0856f9cc9","impliedFormat":1},{"version":"dd3273ead9fbde62a72949c97dbec2247ea08e0c6952e701a483d74ef92d6a17","impliedFormat":1},{"version":"405822be75ad3e4d162e07439bac80c6bcc6dbae1929e179cf467ec0b9ee4e2e","impliedFormat":1},{"version":"0db18c6e78ea846316c012478888f33c11ffadab9efd1cc8bcc12daded7a60b6","impliedFormat":1},{"version":"e61be3f894b41b7baa1fbd6a66893f2579bfad01d208b4ff61daef21493ef0a8","impliedFormat":1},{"version":"bd0532fd6556073727d28da0edfd1736417a3f9f394877b6d5ef6ad88fba1d1a","impliedFormat":1},{"version":"89167d696a849fce5ca508032aabfe901c0868f833a8625d5a9c6e861ef935d2","impliedFormat":1},{"version":"615ba88d0128ed16bf83ef8ccbb6aff05c3ee2db1cc0f89ab50a4939bfc1943f","impliedFormat":1},{"version":"a4d551dbf8746780194d550c88f26cf937caf8d56f102969a110cfaed4b06656","impliedFormat":1},{"version":"8bd86b8e8f6a6aa6c49b71e14c4ffe1211a0e97c80f08d2c8cc98838006e4b88","impliedFormat":1},{"version":"317e63deeb21ac07f3992f5b50cdca8338f10acd4fbb7257ebf56735bf52ab00","impliedFormat":1},{"version":"4732aec92b20fb28c5fe9ad99521fb59974289ed1e45aecb282616202184064f","impliedFormat":1},{"version":"2e85db9e6fd73cfa3d7f28e0ab6b55417ea18931423bd47b409a96e4a169e8e6","impliedFormat":1},{"version":"c46e079fe54c76f95c67fb89081b3e399da2c7d109e7dca8e4b58d83e332e605","impliedFormat":1},{"version":"bf67d53d168abc1298888693338cb82854bdb2e69ef83f8a0092093c2d562107","impliedFormat":1},{"version":"b52476feb4a0cbcb25e5931b930fc73cb6643fb1a5060bf8a3dda0eeae5b4b68","affectsGlobalScope":true,"impliedFormat":1},{"version":"e2677634fe27e87348825bb041651e22d50a613e2fdf6a4a3ade971d71bac37e","impliedFormat":1},{"version":"7394959e5a741b185456e1ef5d64599c36c60a323207450991e7a42e08911419","impliedFormat":1},{"version":"8c0bcd6c6b67b4b503c11e91a1fb91522ed585900eab2ab1f61bba7d7caa9d6f","impliedFormat":1},{"version":"8cd19276b6590b3ebbeeb030ac271871b9ed0afc3074ac88a94ed2449174b776","affectsGlobalScope":true,"impliedFormat":1},{"version":"696eb8d28f5949b87d894b26dc97318ef944c794a9a4e4f62360cd1d1958014b","impliedFormat":1},{"version":"3f8fa3061bd7402970b399300880d55257953ee6d3cd408722cb9ac20126460c","impliedFormat":1},{"version":"35ec8b6760fd7138bbf5809b84551e31028fb2ba7b6dc91d95d098bf212ca8b4","affectsGlobalScope":true,"impliedFormat":1},{"version":"5524481e56c48ff486f42926778c0a3cce1cc85dc46683b92b1271865bcf015a","impliedFormat":1},{"version":"68bd56c92c2bd7d2339457eb84d63e7de3bd56a69b25f3576e1568d21a162398","affectsGlobalScope":true,"impliedFormat":1},{"version":"3e93b123f7c2944969d291b35fed2af79a6e9e27fdd5faa99748a51c07c02d28","impliedFormat":1},{"version":"9d19808c8c291a9010a6c788e8532a2da70f811adb431c97520803e0ec649991","impliedFormat":1},{"version":"87aad3dd9752067dc875cfaa466fc44246451c0c560b820796bdd528e29bef40","impliedFormat":1},{"version":"4aacb0dd020eeaef65426153686cc639a78ec2885dc72ad220be1d25f1a439df","impliedFormat":1},{"version":"f0bd7e6d931657b59605c44112eaf8b980ba7f957a5051ed21cb93d978cf2f45","impliedFormat":1},{"version":"8db0ae9cb14d9955b14c214f34dae1b9ef2baee2fe4ce794a4cd3ac2531e3255","affectsGlobalScope":true,"impliedFormat":1},{"version":"15fc6f7512c86810273af28f224251a5a879e4261b4d4c7e532abfbfc3983134","impliedFormat":1},{"version":"58adba1a8ab2d10b54dc1dced4e41f4e7c9772cbbac40939c0dc8ce2cdb1d442","impliedFormat":1},{"version":"641942a78f9063caa5d6b777c99304b7d1dc7328076038c6d94d8a0b81fc95c1","impliedFormat":1},{"version":"1123a83f35cf56c97de746f0a7250012153c61a167e4a61668bf50e558162d14","impliedFormat":1},{"version":"855cd5f7eb396f5f1ab1bc0f8580339bff77b68a770f84c6b254e319bbfd1ac7","impliedFormat":1},{"version":"5650cf3dace09e7c25d384e3e6b818b938f68f4e8de96f52d9c5a1b3db068e86","impliedFormat":1},{"version":"1354ca5c38bd3fd3836a68e0f7c9f91f172582ba30ab15bb8c075891b91502b7","affectsGlobalScope":true,"impliedFormat":1},{"version":"7e20d899c28ca26a2a7afc98beaa69e63ff7fba0a8bc47b4e3bf3ede5e09e424","impliedFormat":1},{"version":"2d2fcaab481b31a5882065c7951255703ddbe1c0e507af56ea42d79ac3911201","impliedFormat":1},{"version":"a192fe8ec33f75edbc8d8f3ed79f768dfae11ff5735e7fe52bfa69956e46d78d","impliedFormat":1},{"version":"ca867399f7db82df981d6915bcbb2d81131d7d1ef683bc782b59f71dda59bc85","affectsGlobalScope":true,"impliedFormat":1},{"version":"372413016d17d804e1d139418aca0c68e47a83fb6669490857f4b318de8cccb3","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e043a1bc8fbf2a255bccf9bf27e0f1caf916c3b0518ea34aa72357c0afd42ec","impliedFormat":1},{"version":"b4f70ec656a11d570e1a9edce07d118cd58d9760239e2ece99306ee9dfe61d02","impliedFormat":1},{"version":"3bc2f1e2c95c04048212c569ed38e338873f6a8593930cf5a7ef24ffb38fc3b6","impliedFormat":1},{"version":"6e70e9570e98aae2b825b533aa6292b6abd542e8d9f6e9475e88e1d7ba17c866","impliedFormat":1},{"version":"f9d9d753d430ed050dc1bf2667a1bab711ccbb1c1507183d794cc195a5b085cc","impliedFormat":1},{"version":"9eece5e586312581ccd106d4853e861aaaa1a39f8e3ea672b8c3847eedd12f6e","impliedFormat":1},{"version":"085f552d005479e2e6a7311cdbbe5d8c55c497b4d19274285df161ee9684cd9c","impliedFormat":1},{"version":"37ba7b45141a45ce6e80e66f2a96c8a5ab1bcef0fc2d0f56bb58df96ec67e972","impliedFormat":1},{"version":"45650f47bfb376c8a8ed39d4bcda5902ab899a3150029684ee4c10676d9fbaee","impliedFormat":1},{"version":"007faacc9268357caa21d24169f3f3f2497af3e9241308df2d89f6e6d9bb3f2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"74cf591a0f63db318651e0e04cb55f8791385f86e987a67fd4d2eaab8191f730","impliedFormat":1},{"version":"5eab9b3dc9b34f185417342436ec3f106898da5f4801992d8ff38ab3aff346b5","impliedFormat":1},{"version":"12ed4559eba17cd977aa0db658d25c4047067444b51acfdcbf38470630642b23","affectsGlobalScope":true,"impliedFormat":1},{"version":"f3ffabc95802521e1e4bcba4c88d8615176dc6e09111d920c7a213bdda6e1d65","impliedFormat":1},{"version":"809821b8a065e3234a55b3a9d7846231ed18d66dd749f2494c66288d890daf7f","impliedFormat":1},{"version":"ae56f65caf3be91108707bd8dfbccc2a57a91feb5daabf7165a06a945545ed26","impliedFormat":1},{"version":"a136d5de521da20f31631a0a96bf712370779d1c05b7015d7019a9b2a0446ca9","impliedFormat":1},{"version":"c3b41e74b9a84b88b1dca61ec39eee25c0dbc8e7d519ba11bb070918cfacf656","affectsGlobalScope":true,"impliedFormat":1},{"version":"4737a9dc24d0e68b734e6cfbcea0c15a2cfafeb493485e27905f7856988c6b29","affectsGlobalScope":true,"impliedFormat":1},{"version":"36d8d3e7506b631c9582c251a2c0b8a28855af3f76719b12b534c6edf952748d","impliedFormat":1},{"version":"1ca69210cc42729e7ca97d3a9ad48f2e9cb0042bada4075b588ae5387debd318","impliedFormat":1},{"version":"f5ebe66baaf7c552cfa59d75f2bfba679f329204847db3cec385acda245e574e","impliedFormat":1},{"version":"ed59add13139f84da271cafd32e2171876b0a0af2f798d0c663e8eeb867732cf","affectsGlobalScope":true,"impliedFormat":1},{"version":"b7c5e2ea4a9749097c347454805e933844ed207b6eefec6b7cfd418b5f5f7b28","impliedFormat":1},{"version":"b1810689b76fd473bd12cc9ee219f8e62f54a7d08019a235d07424afbf074d25","impliedFormat":1},{"version":"8caa5c86be1b793cd5f599e27ecb34252c41e011980f7d61ae4989a149ff6ccc","impliedFormat":1},{"version":"f9fd93190acb1ffe0bc0fb395df979452f8d625071e9ffc8636e4dfb86ab2508","impliedFormat":1},{"version":"5f41fd8732a89e940c58ce22206e3df85745feb8983e2b4c6257fb8cbb118493","impliedFormat":1},{"version":"17ed71200119e86ccef2d96b73b02ce8854b76ad6bd21b5021d4269bec527b5f","impliedFormat":1},{"version":"1cfa8647d7d71cb03847d616bd79320abfc01ddea082a49569fda71ac5ece66b","impliedFormat":1},{"version":"bb7a61dd55dc4b9422d13da3a6bb9cc5e89be888ef23bbcf6558aa9726b89a1c","impliedFormat":1},{"version":"db6d2d9daad8a6d83f281af12ce4355a20b9a3e71b82b9f57cddcca0a8964a96","impliedFormat":1},{"version":"cfe4ef4710c3786b6e23dae7c086c70b4f4835a2e4d77b75d39f9046106e83d3","impliedFormat":1},{"version":"cbea99888785d49bb630dcbb1613c73727f2b5a2cf02e1abcaab7bcf8d6bf3c5","impliedFormat":1},{"version":"3a8bddb66b659f6bd2ff641fc71df8a8165bafe0f4b799cc298be5cd3755bb20","impliedFormat":1},{"version":"a86f82d646a739041d6702101afa82dcb935c416dd93cbca7fd754fd0282ce1f","impliedFormat":1},{"version":"2dad084c67e649f0f354739ec7df7c7df0779a28a4f55c97c6b6883ae850d1ce","impliedFormat":1},{"version":"fa5bbc7ab4130dd8cdc55ea294ec39f76f2bc507a0f75f4f873e38631a836ca7","impliedFormat":1},{"version":"df45ca1176e6ac211eae7ddf51336dc075c5314bc5c253651bae639defd5eec5","impliedFormat":1},{"version":"cf86de1054b843e484a3c9300d62fbc8c97e77f168bbffb131d560ca0474d4a8","impliedFormat":1},{"version":"196c960b12253fde69b204aa4fbf69470b26daf7a430855d7f94107a16495ab0","impliedFormat":1},{"version":"ee15ea5dd7a9fc9f5013832e5843031817a880bf0f24f37a29fd8337981aae07","impliedFormat":1},{"version":"bf24f6d35f7318e246010ffe9924395893c4e96d34324cde77151a73f078b9ad","impliedFormat":1},{"version":"ea53732769832d0f127ae16620bd5345991d26bf0b74e85e41b61b27d74ea90f","impliedFormat":1},{"version":"10595c7ff5094dd5b6a959ccb1c00e6a06441b4e10a87bc09c15f23755d34439","impliedFormat":1},{"version":"9620c1ff645afb4a9ab4044c85c26676f0a93e8c0e4b593aea03a89ccb47b6d0","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"a9af0e608929aaf9ce96bd7a7b99c9360636c31d73670e4af09a09950df97841","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"c86fe861cf1b4c46a0fb7d74dffe596cf679a2e5e8b1456881313170f092e3fa","impliedFormat":1},{"version":"08ed0b3f0166787f84a6606f80aa3b1388c7518d78912571b203817406e471da","impliedFormat":1},{"version":"47e5af2a841356a961f815e7c55d72554db0c11b4cba4d0caab91f8717846a94","impliedFormat":1},{"version":"65f43099ded6073336e697512d9b80f2d4fec3182b7b2316abf712e84104db00","impliedFormat":1},{"version":"f5f541902bf7ae0512a177295de9b6bcd6809ea38307a2c0a18bfca72212f368","impliedFormat":1},{"version":"b0decf4b6da3ebc52ea0c96095bdfaa8503acc4ac8e9081c5f2b0824835dd3bd","impliedFormat":1},{"version":"ca1b882a105a1972f82cc58e3be491e7d750a1eb074ffd13b198269f57ed9e1b","impliedFormat":1},{"version":"fc3e1c87b39e5ba1142f27ec089d1966da168c04a859a4f6aab64dceae162c2b","impliedFormat":1},{"version":"3b414b99a73171e1c4b7b7714e26b87d6c5cb03d200352da5342ab4088a54c85","impliedFormat":1},{"version":"61888522cec948102eba94d831c873200aa97d00d8989fdfd2a3e0ee75ec65a2","impliedFormat":1},{"version":"4e10622f89fea7b05dd9b52fb65e1e2b5cbd96d4cca3d9e1a60bb7f8a9cb86a1","impliedFormat":1},{"version":"74b2a5e5197bd0f2e0077a1ea7c07455bbea67b87b0869d9786d55104006784f","impliedFormat":1},{"version":"59bf32919de37809e101acffc120596a9e45fdbab1a99de5087f31fdc36e2f11","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"faa03dffb64286e8304a2ca96dd1317a77db6bfc7b3fb385163648f67e535d77","impliedFormat":1},{"version":"c40c848daad198266370c1c72a7a8c3d18d2f50727c7859fcfefd3ff69a7f288","impliedFormat":1},{"version":"ac60bbee0d4235643cc52b57768b22de8c257c12bd8c2039860540cab1fa1d82","impliedFormat":1},{"version":"6428e6edd944ce6789afdf43f9376c1f2e4957eea34166177625aaff4c0da1a0","impliedFormat":1},{"version":"ada39cbb2748ab2873b7835c90c8d4620723aedf323550e8489f08220e477c7f","impliedFormat":1},{"version":"6e5f5cee603d67ee1ba6120815497909b73399842254fc1e77a0d5cdc51d8c9c","impliedFormat":1},{"version":"8dba67056cbb27628e9b9a1cba8e57036d359dceded0725c72a3abe4b6c79cd4","impliedFormat":1},{"version":"70f3814c457f54a7efe2d9ce9d2686de9250bb42eb7f4c539bd2280a42e52d33","impliedFormat":1},{"version":"154dd2e22e1e94d5bc4ff7726706bc0483760bae40506bdce780734f11f7ec47","impliedFormat":1},{"version":"ef61792acbfa8c27c9bd113f02731e66229f7d3a169e3c1993b508134f1a58e0","impliedFormat":1},{"version":"9c82171d836c47486074e4ca8e059735bf97b205e70b196535b5efd40cbe1bc5","impliedFormat":1},{"version":"0131e203d8560edb39678abe10db42564a068f98c4ebd1ed9ffe7279c78b3c81","impliedFormat":1},{"version":"f6404e7837b96da3ea4d38c4f1a3812c96c9dcdf264e93d5bdb199f983a3ef4b","impliedFormat":1},{"version":"c5426dbfc1cf90532f66965a7aa8c1136a78d4d0f96d8180ecbfc11d7722f1a5","impliedFormat":1},{"version":"65a15fc47900787c0bd18b603afb98d33ede930bed1798fc984d5ebb78b26cf9","impliedFormat":1},{"version":"9d202701f6e0744adb6314d03d2eb8fc994798fc83d91b691b75b07626a69801","impliedFormat":1},{"version":"de9d2df7663e64e3a91bf495f315a7577e23ba088f2949d5ce9ec96f44fba37d","impliedFormat":1},{"version":"c7af78a2ea7cb1cd009cfb5bdb48cd0b03dad3b54f6da7aab615c2e9e9d570c5","impliedFormat":1},{"version":"1ee45496b5f8bdee6f7abc233355898e5bf9bd51255db65f5ff7ede617ca0027","impliedFormat":1},{"version":"8b8f00491431fe82f060dfe8c7f2180a9fb239f3d851527db909b83230e75882","affectsGlobalScope":true,"impliedFormat":1},{"version":"db01d18853469bcb5601b9fc9826931cc84cc1a1944b33cad76fd6f1e3d8c544","affectsGlobalScope":true,"impliedFormat":1},{"version":"dba114fb6a32b355a9cfc26ca2276834d72fe0e94cd2c3494005547025015369","impliedFormat":1},{"version":"903e299a28282fa7b714586e28409ed73c3b63f5365519776bf78e8cf173db36","affectsGlobalScope":true,"impliedFormat":1},{"version":"fa6c12a7c0f6b84d512f200690bfc74819e99efae69e4c95c4cd30f6884c526e","impliedFormat":1},{"version":"f1c32f9ce9c497da4dc215c3bc84b722ea02497d35f9134db3bb40a8d918b92b","impliedFormat":1},{"version":"b73c319af2cc3ef8f6421308a250f328836531ea3761823b4cabbd133047aefa","affectsGlobalScope":true,"impliedFormat":1},{"version":"e433b0337b8106909e7953015e8fa3f2d30797cea27141d1c5b135365bb975a6","impliedFormat":1},{"version":"dd3900b24a6a8745efeb7ad27629c0f8a626470ac229c1d73f1fe29d67e44dca","impliedFormat":1},{"version":"ddff7fc6edbdc5163a09e22bf8df7bef75f75369ebd7ecea95ba55c4386e2441","impliedFormat":1},{"version":"106c6025f1d99fd468fd8bf6e5bda724e11e5905a4076c5d29790b6c3745e50c","impliedFormat":1},{"version":"ec29be0737d39268696edcec4f5e97ce26f449fa9b7afc2f0f99a86def34a418","impliedFormat":1},{"version":"aeab39e8e0b1a3b250434c3b2bb8f4d17bbec2a9dbce5f77e8a83569d3d2cbc2","impliedFormat":1},{"version":"ec6cba1c02c675e4dd173251b156792e8d3b0c816af6d6ad93f1a55d674591aa","impliedFormat":1},{"version":"b620391fe8060cf9bedc176a4d01366e6574d7a71e0ac0ab344a4e76576fcbb8","impliedFormat":1},{"version":"d729408dfde75b451530bcae944cf89ee8277e2a9df04d1f62f2abfd8b03c1e1","impliedFormat":1},{"version":"e15d3c84d5077bb4a3adee4c791022967b764dc41cb8fa3cfa44d4379b2c95f5","impliedFormat":1},{"version":"5f58e28cd22e8fc1ac1b3bc6b431869f1e7d0b39e2c21fbf79b9fa5195a85980","impliedFormat":1},{"version":"e1fc1a1045db5aa09366be2b330e4ce391550041fc3e925f60998ca0b647aa97","impliedFormat":1},{"version":"63533978dcda286422670f6e184ac516805a365fb37a086eeff4309e812f1402","impliedFormat":1},{"version":"43ba4f2fa8c698f5c304d21a3ef596741e8e85a810b7c1f9b692653791d8d97a","impliedFormat":1},{"version":"31fb49ef3aa3d76f0beb644984e01eab0ea222372ea9b49bb6533be5722d756c","impliedFormat":1},{"version":"33cd131e1461157e3e06b06916b5176e7a8ec3fce15a5cfe145e56de744e07d2","impliedFormat":1},{"version":"889ef863f90f4917221703781d9723278db4122d75596b01c429f7c363562b86","impliedFormat":1},{"version":"3556cfbab7b43da96d15a442ddbb970e1f2fc97876d055b6555d86d7ac57dae5","impliedFormat":1},{"version":"437751e0352c6e924ddf30e90849f1d9eb00ca78c94d58d6a37202ec84eb8393","impliedFormat":1},{"version":"48e8af7fdb2677a44522fd185d8c87deff4d36ee701ea003c6c780b1407a1397","impliedFormat":1},{"version":"d11308de5a36c7015bb73adb5ad1c1bdaac2baede4cc831a05cf85efa3cc7f2f","impliedFormat":1},{"version":"38e4684c22ed9319beda6765bab332c724103d3a966c2e5e1c5a49cf7007845f","impliedFormat":1},{"version":"f9812cfc220ecf7557183379531fa409acd249b9e5b9a145d0d52b76c20862de","affectsGlobalScope":true,"impliedFormat":1},{"version":"e650298721abc4f6ae851e60ae93ee8199791ceec4b544c3379862f81f43178c","impliedFormat":1},{"version":"2e4f37ffe8862b14d8e24ae8763daaa8340c0df0b859d9a9733def0eee7562d9","impliedFormat":1},{"version":"13283350547389802aa35d9f2188effaeac805499169a06ef5cd77ce2a0bd63f","impliedFormat":1},{"version":"680793958f6a70a44c8d9ae7d46b7a385361c69ac29dcab3ed761edce1c14ab8","impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","impliedFormat":1},{"version":"b838d4c72740eb0afd284bf7575b74c624b105eff2e8c7b4aeead57e7ac320ff","impliedFormat":1},{"version":"913ddbba170240070bd5921b8f33ea780021bdf42fbdfcd4fcb2691b1884ddde","impliedFormat":1},{"version":"b4e6d416466999ff40d3fe5ceb95f7a8bfb7ac2262580287ac1a8391e5362431","impliedFormat":1},{"version":"5fe23bd829e6be57d41929ac374ee9551ccc3c44cee893167b7b5b77be708014","impliedFormat":1},{"version":"0a626484617019fcfbfc3c1bc1f9e84e2913f1adb73692aa9075817404fb41a1","impliedFormat":1},{"version":"438c7513b1df91dcef49b13cd7a1c4720f91a36e88c1df731661608b7c055f10","impliedFormat":1},{"version":"cf185cc4a9a6d397f416dd28cca95c227b29f0f27b160060a95c0e5e36cda865","impliedFormat":1},{"version":"0086f3e4ad898fd7ca56bb223098acfacf3fa065595182aaf0f6c4a6a95e6fbd","impliedFormat":1},{"version":"efaa078e392f9abda3ee8ade3f3762ab77f9c50b184e6883063a911742a4c96a","impliedFormat":1},{"version":"54a8bb487e1dc04591a280e7a673cdfb272c83f61e28d8a64cf1ac2e63c35c51","impliedFormat":1},{"version":"021a9498000497497fd693dd315325484c58a71b5929e2bbb91f419b04b24cea","impliedFormat":1},{"version":"9385cdc09850950bc9b59cca445a3ceb6fcca32b54e7b626e746912e489e535e","impliedFormat":1},{"version":"2894c56cad581928bb37607810af011764a2f511f575d28c9f4af0f2ef02d1ab","impliedFormat":1},{"version":"0a72186f94215d020cb386f7dca81d7495ab6c17066eb07d0f44a5bf33c1b21a","impliedFormat":1},{"version":"84124384abae2f6f66b7fbfc03862d0c2c0b71b826f7dbf42c8085d31f1d3f95","impliedFormat":1},{"version":"63a8e96f65a22604eae82737e409d1536e69a467bb738bec505f4f97cce9d878","impliedFormat":1},{"version":"3fd78152a7031315478f159c6a5872c712ece6f01212c78ea82aef21cb0726e2","impliedFormat":1},{"version":"b01bd582a6e41457bc56e6f0f9de4cb17f33f5f3843a7cf8210ac9c18472fb0f","impliedFormat":1},{"version":"58b49e5c1def740360b5ae22ae2405cfac295fee74abd88d74ac4ea42502dc03","impliedFormat":1},{"version":"512fc15cca3a35b8dbbf6e23fe9d07e6f87ad03c895acffd3087ce09f352aad0","impliedFormat":1},{"version":"9a0946d15a005832e432ea0cd4da71b57797efb25b755cc07f32274296d62355","impliedFormat":1},{"version":"a52ff6c0a149e9f370372fc3c715d7f2beee1f3bab7980e271a7ab7d313ec677","impliedFormat":1},{"version":"fd933f824347f9edd919618a76cdb6a0c0085c538115d9a287fa0c7f59957ab3","impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","impliedFormat":1},{"version":"6a1aa3e55bdc50503956c5cd09ae4cd72e3072692d742816f65c66ca14f4dfdd","impliedFormat":1},{"version":"ab75cfd9c4f93ffd601f7ca1753d6a9d953bbedfbd7a5b3f0436ac8a1de60dfa","impliedFormat":1},{"version":"f95180f03d827525ca4f990f49e17ec67198c316dd000afbe564655141f725cd","impliedFormat":1},{"version":"b73cbf0a72c8800cf8f96a9acfe94f3ad32ca71342a8908b8ae484d61113f647","impliedFormat":1},{"version":"bae6dd176832f6423966647382c0d7ba9e63f8c167522f09a982f086cd4e8b23","impliedFormat":1},{"version":"1364f64d2fb03bbb514edc42224abd576c064f89be6a990136774ecdd881a1da","impliedFormat":1},{"version":"c9958eb32126a3843deedda8c22fb97024aa5d6dd588b90af2d7f2bfac540f23","impliedFormat":1},{"version":"950fb67a59be4c2dbe69a5786292e60a5cb0e8612e0e223537784c731af55db1","impliedFormat":1},{"version":"e927c2c13c4eaf0a7f17e6022eee8519eb29ef42c4c13a31e81a611ab8c95577","impliedFormat":1},{"version":"07ca44e8d8288e69afdec7a31fa408ce6ab90d4f3d620006701d5544646da6aa","impliedFormat":1},{"version":"70246ad95ad8a22bdfe806cb5d383a26c0c6e58e7207ab9c431f1cb175aca657","impliedFormat":1},{"version":"f00f3aa5d64ff46e600648b55a79dcd1333458f7a10da2ed594d9f0a44b76d0b","impliedFormat":1},{"version":"772d8d5eb158b6c92412c03228bd9902ccb1457d7a705b8129814a5d1a6308fc","impliedFormat":1},{"version":"4e4475fba4ed93a72f167b061cd94a2e171b82695c56de9899275e880e06ba41","impliedFormat":1},{"version":"97c5f5d580ab2e4decd0a3135204050f9b97cd7908c5a8fbc041eadede79b2fa","impliedFormat":1},{"version":"c99a3a5f2215d5b9d735aa04cec6e61ed079d8c0263248e298ffe4604d4d0624","impliedFormat":1},{"version":"49b2375c586882c3ac7f57eba86680ff9742a8d8cb2fe25fe54d1b9673690d41","impliedFormat":1},{"version":"802e797bcab5663b2c9f63f51bdf67eff7c41bc64c0fd65e6da3e7941359e2f7","impliedFormat":1},{"version":"847e160d709c74cc714fbe1f99c41d3425b74cd47b1be133df1623cd87014089","impliedFormat":1},{"version":"9fee04f1e1afa50524862289b9f0b0fdc3735b80e2a0d684cec3b9ff3d94cecc","impliedFormat":1},{"version":"5cdc27fbc5c166fc5c763a30ac21cbac9859dc5ba795d3230db6d4e52a1965bb","impliedFormat":1},{"version":"6459054aabb306821a043e02b89d54da508e3a6966601a41e71c166e4ea1474f","impliedFormat":1},{"version":"f416c9c3eee9d47ff49132c34f96b9180e50485d435d5748f0e8b72521d28d2e","impliedFormat":1},{"version":"05c97cddbaf99978f83d96de2d8af86aded9332592f08ce4a284d72d0952c391","impliedFormat":1},{"version":"14e5cdec6f8ae82dfd0694e64903a0a54abdfe37e1d966de3d4128362acbf35f","impliedFormat":1},{"version":"bbc183d2d69f4b59fd4dd8799ffdf4eb91173d1c4ad71cce91a3811c021bf80c","impliedFormat":1},{"version":"7b6ff760c8a240b40dab6e4419b989f06a5b782f4710d2967e67c695ef3e93c4","impliedFormat":1},{"version":"8dbc4134a4b3623fc476be5f36de35c40f2768e2e3d9ed437e0d5f1c4cd850f6","impliedFormat":1},{"version":"4e06330a84dec7287f7ebdd64978f41a9f70a668d3b5edc69d5d4a50b9b376bb","impliedFormat":1},{"version":"65bfa72967fbe9fc33353e1ac03f0480aa2e2ea346d61ff3ea997dfd850f641a","impliedFormat":1},{"version":"c06f0bb92d1a1a5a6c6e4b5389a5664d96d09c31673296cb7da5fe945d54d786","impliedFormat":1},{"version":"f974e4a06953682a2c15d5bd5114c0284d5abf8bc0fe4da25cb9159427b70072","impliedFormat":1},{"version":"872caaa31423f4345983d643e4649fb30f548e9883a334d6d1c5fff68ede22d4","impliedFormat":1},{"version":"94404c4a878fe291e7578a2a80264c6f18e9f1933fbb57e48f0eb368672e389c","impliedFormat":1},{"version":"5c1b7f03aa88be854bc15810bfd5bd5a1943c5a7620e1c53eddd2a013996343e","impliedFormat":1},{"version":"09dfc64fcd6a2785867f2368419859a6cc5a8d4e73cbe2538f205b1642eb0f51","impliedFormat":1},{"version":"bcf6f0a323653e72199105a9316d91463ad4744c546d1271310818b8cef7c608","impliedFormat":1},{"version":"01aa917531e116485beca44a14970834687b857757159769c16b228eb1e49c5f","impliedFormat":1},{"version":"351475f9c874c62f9b45b1f0dc7e2704e80dfd5f1af83a3a9f841f9dfe5b2912","impliedFormat":1},{"version":"ac457ad39e531b7649e7b40ee5847606eac64e236efd76c5d12db95bf4eacd17","impliedFormat":1},{"version":"187a6fdbdecb972510b7555f3caacb44b58415da8d5825d03a583c4b73fde4cf","impliedFormat":1},{"version":"d4c3250105a612202289b3a266bb7e323db144f6b9414f9dea85c531c098b811","impliedFormat":1},{"version":"95b444b8c311f2084f0fb51c616163f950fb2e35f4eaa07878f313a2d36c98a4","impliedFormat":1},{"version":"741067675daa6d4334a2dc80a4452ca3850e89d5852e330db7cb2b5f867173b1","impliedFormat":1},{"version":"f8acecec1114f11690956e007d920044799aefeb3cece9e7f4b1f8a1d542b2c9","impliedFormat":1},{"version":"178071ccd043967a58c5d1a032db0ddf9bd139e7920766b537d9783e88eb615e","impliedFormat":1},{"version":"3a17f09634c50cce884721f54fd9e7b98e03ac505889c560876291fcf8a09e90","impliedFormat":1},{"version":"32531dfbb0cdc4525296648f53b2b5c39b64282791e2a8c765712e49e6461046","impliedFormat":1},{"version":"0ce1b2237c1c3df49748d61568160d780d7b26693bd9feb3acb0744a152cd86d","impliedFormat":1},{"version":"e489985388e2c71d3542612685b4a7db326922b57ac880f299da7026a4e8a117","impliedFormat":1},{"version":"5cad4158616d7793296dd41e22e1257440910ea8d01c7b75045d4dfb20c5a41a","impliedFormat":1},{"version":"04d3aad777b6af5bd000bfc409907a159fe77e190b9d368da4ba649cdc28d39e","affectsGlobalScope":true,"impliedFormat":1},{"version":"74efc1d6523bd57eb159c18d805db4ead810626bc5bc7002a2c7f483044b2e0f","impliedFormat":1},{"version":"19252079538942a69be1645e153f7dbbc1ef56b4f983c633bf31fe26aeac32cd","impliedFormat":1},{"version":"bc11f3ac00ac060462597add171220aed628c393f2782ac75dd29ff1e0db871c","impliedFormat":1},{"version":"616775f16134fa9d01fc677ad3f76e68c051a056c22ab552c64cc281a9686790","impliedFormat":1},{"version":"65c24a8baa2cca1de069a0ba9fba82a173690f52d7e2d0f1f7542d59d5eb4db0","impliedFormat":1},{"version":"f9fe6af238339a0e5f7563acee3178f51db37f32a2e7c09f85273098cee7ec49","impliedFormat":1},{"version":"3b0b1d352b8d2e47f1c4df4fb0678702aee071155b12ef0185fce9eb4fa4af1e","impliedFormat":1},{"version":"77e71242e71ebf8528c5802993697878f0533db8f2299b4d36aa015bae08a79c","impliedFormat":1},{"version":"a344403e7a7384e0e7093942533d309194ad0a53eca2a3100c0b0ab4d3932773","impliedFormat":1},{"version":"b7fff2d004c5879cae335db8f954eb1d61242d9f2d28515e67902032723caeab","impliedFormat":1},{"version":"5f3dc10ae646f375776b4e028d2bed039a93eebbba105694d8b910feebbe8b9c","impliedFormat":1},{"version":"bb18bf4a61a17b4a6199eb3938ecfa4a59eb7c40843ad4a82b975ab6f7e3d925","impliedFormat":1},{"version":"4545c1a1ceca170d5d83452dd7c4994644c35cf676a671412601689d9a62da35","impliedFormat":1},{"version":"e9b6fc05f536dfddcdc65dbcf04e09391b1c968ab967382e48924f5cb90d88e1","impliedFormat":1},{"version":"a2d648d333cf67b9aeac5d81a1a379d563a8ffa91ddd61c6179f68de724260ff","impliedFormat":1},{"version":"2b664c3cc544d0e35276e1fb2d4989f7d4b4027ffc64da34ec83a6ccf2e5c528","impliedFormat":1},{"version":"a3f41ed1b4f2fc3049394b945a68ae4fdefd49fa1739c32f149d32c0545d67f5","impliedFormat":1},{"version":"3cd8f0464e0939b47bfccbb9bb474a6d87d57210e304029cd8eb59c63a81935d","impliedFormat":1},{"version":"47699512e6d8bebf7be488182427189f999affe3addc1c87c882d36b7f2d0b0e","impliedFormat":1},{"version":"3026abd48e5e312f2328629ede6e0f770d21c3cd32cee705c450e589d015ee09","impliedFormat":1},{"version":"8b140b398a6afbd17cc97c38aea5274b2f7f39b1ae5b62952cfe65bf493e3e75","impliedFormat":1},{"version":"7663d2c19ce5ef8288c790edba3d45af54e58c84f1b37b1249f6d49d962f3d91","impliedFormat":1},{"version":"5cce3b975cdb72b57ae7de745b3c5de5790781ee88bcb41ba142f07c0fa02e97","impliedFormat":1},{"version":"00bd6ebe607246b45296aa2b805bd6a58c859acecda154bfa91f5334d7c175c6","impliedFormat":1},{"version":"ad036a85efcd9e5b4f7dd5c1a7362c8478f9a3b6c3554654ca24a29aa850a9c5","impliedFormat":1},{"version":"fedebeae32c5cdd1a85b4e0504a01996e4a8adf3dfa72876920d3dd6e42978e7","impliedFormat":1},{"version":"0d28b974a7605c4eda20c943b3fa9ae16cb452c1666fc9b8c341b879992c7612","impliedFormat":1},{"version":"cdf21eee8007e339b1b9945abf4a7b44930b1d695cc528459e68a3adc39a622e","impliedFormat":1},{"version":"db036c56f79186da50af66511d37d9fe77fa6793381927292d17f81f787bb195","impliedFormat":1},{"version":"87ac2fb61e629e777f4d161dff534c2023ee15afd9cb3b1589b9b1f014e75c58","impliedFormat":1},{"version":"13c8b4348db91e2f7d694adc17e7438e6776bc506d5c8f5de9ad9989707fa3fe","impliedFormat":1},{"version":"3c1051617aa50b38e9efaabce25e10a5dd9b1f42e372ef0e8a674076a68742ed","impliedFormat":1},{"version":"07a3e20cdcb0f1182f452c0410606711fbea922ca76929a41aacb01104bc0d27","impliedFormat":1},{"version":"1de80059b8078ea5749941c9f863aa970b4735bdbb003be4925c853a8b6b4450","impliedFormat":1},{"version":"1d079c37fa53e3c21ed3fa214a27507bda9991f2a41458705b19ed8c2b61173d","impliedFormat":1},{"version":"4cd4b6b1279e9d744a3825cbd7757bbefe7f0708f3f1069179ad535f19e8ed2c","impliedFormat":1},{"version":"5835a6e0d7cd2738e56b671af0e561e7c1b4fb77751383672f4b009f4e161d70","impliedFormat":1},{"version":"c0eeaaa67c85c3bb6c52b629ebbfd3b2292dc67e8c0ffda2fc6cd2f78dc471e6","impliedFormat":1},{"version":"4b7f74b772140395e7af67c4841be1ab867c11b3b82a51b1aeb692822b76c872","impliedFormat":1},{"version":"27be6622e2922a1b412eb057faa854831b95db9db5035c3f6d4b677b902ab3b7","impliedFormat":1},{"version":"b95a6f019095dd1d48fd04965b50dfd63e5743a6e75478343c46d2582a5132bf","impliedFormat":99},{"version":"c2008605e78208cfa9cd70bd29856b72dda7ad89df5dc895920f8e10bcb9cd0a","impliedFormat":99},{"version":"b97cb5616d2ab82a98ec9ada7b9e9cabb1f5da880ec50ea2b8dc5baa4cbf3c16","impliedFormat":99},{"version":"d23df9ff06ae8bf1dcb7cc933e97ae7da418ac77749fecee758bb43a8d69f840","affectsGlobalScope":true,"impliedFormat":1},{"version":"040c71dde2c406f869ad2f41e8d4ce579cc60c8dbe5aa0dd8962ac943b846572","affectsGlobalScope":true,"impliedFormat":1},{"version":"3586f5ea3cc27083a17bd5c9059ede9421d587286d5a47f4341a4c2d00e4fa91","impliedFormat":1},{"version":"a6df929821e62f4719551f7955b9f42c0cd53c1370aec2dd322e24196a7dfe33","impliedFormat":1},{"version":"b789bf89eb19c777ed1e956dbad0925ca795701552d22e68fd130a032008b9f9","impliedFormat":1},"e462a655754db9df18b4a657454a7b6a88717ffded4e89403b2b3a47c6603fc3",{"version":"c57b441e0c0a9cbdfa7d850dae1f8a387d6f81cbffbc3cd0465d530084c2417d","impliedFormat":99},{"version":"26c57c9f839e6d2048d6c25e81f805ba0ca32a28fd4d824399fd5456c9b0575b","impliedFormat":1},"339d9e082c4c255ac7573a8903d667d3c742affe2c8af479d776e3a47430cd80",{"version":"191e4e6aceae3602aa3a1e9a6bc0e98821d6d5fb787e2bc16e250439482bddb6","impliedFormat":1},{"version":"86f1b8f950d7a94469735cfdb9f4042ab449f576ed88611251ec81c35a91955b","signature":"081a855326ae9420dcd04b8b4a6a350d819447986540aeecca33ce50e4a0202f"},"23b006c1c80b4eae319c6fe48768073be2b4a646824df567b42d85a3a8ff3534","0b0d9d4bd0de4517333d00a21dd1fc754561465458dae62a2a528165c748fb90",{"version":"eab22bb6fad904e2e9782889a840a6b710a16bf8919c4a7c43617b992d178631","signature":"32110c0810ec38908e2ba23ab07041246d3e3342f7abd6620ddd18dd1d21e99b"},{"version":"fe93c474ab38ac02e30e3af073412b4f92b740152cf3a751fdaee8cbea982341","impliedFormat":1},{"version":"aa4feed67c9af19fa98fe02a12f424def3cdc41146fb87b8d8dab077ad9ceb3c","impliedFormat":1},{"version":"1e00b8bf9e3766c958218cd6144ffe08418286f89ff44ba5a2cc830c03dd22c7","impliedFormat":1},{"version":"671202b3d2e4c00e740305f7a320b3964f5645896e14550b1069c5254bce3267","impliedFormat":99},{"version":"246ad924fb690f3af3aaead6d056184f74cfd51749773fd20523f9ce389e5e20","impliedFormat":99},{"version":"251000fa47da0b0ea2758b44df7311c4e83fdcde2c67e3f6631bd9635832ba5c","impliedFormat":99},{"version":"8a66a185e78f615f2a8abfda16d7eb4b490a965f34efe611ae2186d9dd21361d","impliedFormat":99},{"version":"dcc5f1a498cdd4fa010772391ce3f71a609f5c50f249f6dda98a00e32bf8adbe","impliedFormat":99},{"version":"53b63b74cba2195039551edaeecd9e0083067e20ef6e1b95e3b0fda92493b44a","impliedFormat":99},{"version":"3d7c038bd6350184b6c093676573c13971489a14a9d5db7bb4c9a15c1fa2ba91","impliedFormat":99},{"version":"b19b6148f916f015348018f2f4eac4fbf28acbb1883665c9034ee8b8aaa4f7dd","impliedFormat":99},{"version":"f4f83d57a328688e00077e95ad50c0ed9d57747f54aa88aee7003f8c66e69661","impliedFormat":99},{"version":"0a9b96b5b16c3355f56d66068f6415811100456479071e0a227c0fe9fcb0d0c2","impliedFormat":99},{"version":"364c3283f107e59ef3f7a0bd93a94c9e6a97f562c8d9120cb3c3669ce19c2bfb","impliedFormat":99},{"version":"9a58286fc310d364f7ddf0781d906be0f0c3e314f4ba9925f8cad7f6b343635a","impliedFormat":99},{"version":"51fe876dd04e220fad80f64edfef75730308ebc0f6e0e3d3899c976f97a548ee","impliedFormat":99},{"version":"b77e0039b0eb170bbf64adbd1b6d7caceed08f2e95abec56a3c7f789a5b94702","impliedFormat":99},{"version":"ee641e3f9520cc445e653f033fc76a9d3bfb26cac8929b4ba4fbc2e40eeed65a","impliedFormat":99},{"version":"1c6ff7168bdc65e80cd3308b448037e0756c2049c1b4f7fee9b906db2b4eb4cc","impliedFormat":99},{"version":"2703ba30a3330f2dd4866c57f5ab4deaf79ffe5473fb53f6f9fdf3355ab80134","impliedFormat":99},{"version":"b457f37de4cd510a57b2c799b03b2e25ddc0d127a9cdd38d16929abd8b9ac079","impliedFormat":99},{"version":"361e5122779ff07d22373e6effa87335656043e74b2d23f65ef1ab95ce112f53","impliedFormat":99},{"version":"d71557daa064d0509f8a03c76b0e96c99c40222ec7a4010acd8d0157c078caa7","impliedFormat":99},{"version":"f83dc87bb5ad87bbc13eaf38d1122a4b6ea08f9df7cf977551a207cdd1d9c9ad","impliedFormat":99},{"version":"7f9c87ef7acce4bc3e8743922e457402555f8e77d7a585b46fd06cb50534df1d","impliedFormat":99},{"version":"f5758605565692fa08ac8176c8395a733cd885ffd77a2cdc7ea89c06a1b13a73","impliedFormat":99},{"version":"1daf419444f812ffe29154b06b9846b81a509890dd5d4a01c12f592152e6736b","impliedFormat":99},{"version":"07a2dcd4b438a7d389253cf0276c65b6484ff1802af3bbae64da89840c6d1e60","impliedFormat":99},{"version":"f8d073c81afe390c2c66391d8e0d3ecc4769dd1678c44143949f6f61671e5d00","impliedFormat":99},{"version":"c94a0b2162979ff2cda3e1706a0eac4a7e13b1dbeb138b6a331c0ac8cded80e7","impliedFormat":99},{"version":"efafda8ab711a6b463c815357d008953816db3964bb68f1972964563a013ade6","impliedFormat":99},{"version":"b7f3d3b0248617ce229e02b5f5ed640ffa59dc33cfbf0607127254ecad40b214","impliedFormat":99},{"version":"44f18a86f2695f62e207ea094b75d2ea060931cf467415c246bedbe404c99976","impliedFormat":99},{"version":"56c69663e1246d1b57374436efd8ee5d4f8894466e53f25931eb05b108f00f1f","impliedFormat":99},{"version":"333a8d500cfc9ba377c7ea02a250bcf2f9cbc56bdcbb9fd9b0402e89807d085f","impliedFormat":99},{"version":"db1ddc3b0e9dcd8dd43244179adac61050d7e6656f3b90e2d01c34a305ab1484","signature":"e18e929c22062883e0427db9599eb9c63bbd712bee2ef30078cca2fb2096e77d"},"222016cc9d4c7c69d8405d9447b873f5726775a3bd0633626f003ff0694aa167","8dff6f7214177717675af837d3d533c15b93fb3af9c3d519627e45a01207e11e","faa529629378235710aa9a405fba4aca6a7600dba334f553ecdd28dda432ce5d",{"version":"a90a5d2535f1de61b824dc8bf1928f68b20df3e323ba924f682d5c1f18b88d4d","impliedFormat":1},{"version":"fbc6095032e74690254e6591fad71b2a424cc063d21af3160879bb3a92d8beac","impliedFormat":99},{"version":"2fbe402f0ee5aa8ab55367f88030f79d46211c0a0f342becaa9f648bf8534e9d","impliedFormat":1},{"version":"b94258ef37e67474ac5522e9c519489a55dcb3d4a8f645e335fc68ea2215fe88","impliedFormat":1},"f658196a843a2d2586e31f6419ccbe6148a21d8b24e7acff21f500a704a1ca9a","f5ec5a715b9371b88f1587a26edff8d5d54f78a4191b78175d06ae53bb1385f1",{"version":"64e4057dd49916d73d3c577e0751a6c8f22c6152b295ea019161966051951a74","impliedFormat":99},{"version":"caf4af98bf464ad3e10c46cf7d340556f89197aab0f87f032c7b84eb8ddb24d9","impliedFormat":99},{"version":"0a60daa545b41003e922202c01e47d0cea514b09ba0af1e4684222f5b332841e","impliedFormat":99},{"version":"85142ec7bf3ef1cbfd7c004092c64abe9eb0a4ed9325604ad14a922591c37025","impliedFormat":99},"8bfb5c47f3e98050a3cb2e0e076bd43c5c0c5da188478316e5f70c4474638366","ec69ccca19c44d9a3cd5f0bb5dc53d73f3df30041d6c95e6b64bf6f51201865b","4d4ddd36b11fec7ac07b52803e633934e229b4a1dd14cdc738c2fe30a2c84347","1a5c0f8ce3e62b7bcaaed871d23d4004eca041149fa555c1f13828799ae75480","77ca5266064ef67a2e3200a445de86a671d2d58a5896a7ec49838274eb212ceb","29adeb7739fa900bbfd3e063c9f9aa1eaf9f499a8bdddb3dbabbaf9c12c7dbd4","8323b9c1be8bcc3292f5b238f061ec2d6e5052ae393c0b6ea02a39ef4325f314","8aa7d9709ce949920a4da27277dcfa28ef7392dd7f97e454d42b84203d024237","817f79333eee21bc27794dace7fd50b89d3a32b6d8ec47995410fce0508f06a8","097c250129fc05fe863b4a465d18801e737c54ef12737c6cf975abc79d9d5812","f3a365442598ce21f5573cf6636ceeeccee999cf3348edda8918b84f92f7e549","d9467ba0e3d6cbb4f21b72dd8f881cb07760d5806bc00bb8be4a1bbc5ad236be","dd20f5338725ba1c935413dcc8d5463ffad1510e1fe89f2c38e363115ad39485","6cf50a8fa134464f5f02b34c7e3306e7e72c9576c641a81f6ee0f21a8833f4d8",{"version":"8dd450de6d756cee0761f277c6dc58b0b5a66b8c274b980949318b8cad26d712","impliedFormat":99},{"version":"904d6ad970b6bd825449480488a73d9b98432357ab38cf8d31ffd651ae376ff5","impliedFormat":99},{"version":"2059b04988eece7f6e992a5c218e9c5361b8fed24cb04caddb7e1cb4339e236b","impliedFormat":99},"da55d83c1e799e52a50c2398def6ec2febdab9a74c919a82242faf3de907eda5","cd01e620fa829e9338ea3c182b092722447329714d8786e5b1cdfbb5c794e3d1","0532b51895d5ef5ea84fdb142c91534678d43e25fc0c206f1bd44141d9082207","98a3b313e1555befc3b988bf0cabaad1a6230bc5be28ee0a5355f602a354ae17",{"version":"71acd198e19fa38447a3cbc5c33f2f5a719d933fccf314aaff0e8b0593271324","impliedFormat":99},"2524ec7d048ec1cbbd6208aeff79c4c593472807c35bd0fd4871ca1fedd0a6b3",{"version":"68b6a7501a56babd7bcd840e0d638ee7ec582f1e70b3c36ebf32e5e5836913c8","impliedFormat":99},{"version":"0fa6e03fb138801f6af97b485127a9717867061d4d3016bf0db21bf7b32f5d5a","impliedFormat":99},"7b0f856869194f2e7783b5bfa71c805358d2de31d743750f16217719ac2ab40b",{"version":"ab64bfe758627c82789984f0769e8522d3f37300c891ecfda11c4973a0d50377","signature":"1a5acd03815d5b083b687112583a472483d184a0cb7d67f32c43731bcdb62b9c"},{"version":"9d6715a355aeef27fcc7b19e73e5278554c5c717653dc33d7def8ea54b7f3e5a","signature":"7621c7540a26fd665f67bcbd3b139af6a1e352276b7aed8b48f401e2c7a3a65b"},{"version":"6b5f886fe41e2e767168e491fe6048398ed6439d44e006d9f51cc31265f08978","impliedFormat":99},{"version":"56a87e37f91f5625eb7d5f8394904f3f1e2a90fb08f347161dc94f1ae586bdd0","impliedFormat":99},{"version":"f42faa092a65655c60ad6f92215e33a68926d231f8dbcd9aca378c10b9ce0f26","impliedFormat":99},{"version":"a0fa43b27364a340ea7639ff5fdffbcd375ecf49e6ed5df3eec73b3d74a84d58","impliedFormat":99},"0d00499220a62bfadef0307ae41537f2e801fd27e1324e5e03e4812e15725fe0","94aa739eb0189b5af934a2f1523add4e488fc605aaf6e777ad0843f5b29e4318","987c1aeaa36cd1c1354cbfeab20daf29eb126be44fd93852c4bb971733cb9034","be49c57a31ed4a44c9f1eb8b8c10826a4dd5693e303f91f4dc6c39cf4bbbe84b","0fa2547b0ea1d486d5b66074a49b3ab18ec42e44c77209fe1f2e38e875278830",{"version":"d370d544e2f5b253ffe09d0c80a7c9281ea10789a26efd15ab69e5efa110ff40","signature":"2cc743b624d6891f9275f11f76fedfe235af04641c806e7dc65e55740db4dd29"},"e0902e08411183d05f6b984fe28e4a00c6df02532ef8ee8ee72c91e6ba897484","e783f37fc1b553d3bc0a1a71d1ff74aa596b321f93e86b968083e5b23239c29a","a30d71fd36c3b108ac7232a17f74125a5b449e2df32318a2340d031520d5390a","c83d44ecf35c1ca4cbd081bc488221a58692c5ce12d55cd8baaaad43fc9d527b","30608cdcc5e352a0bfc49b39dd099c05488c636506039001590f5ce6f7bcb412","e98ef31631c8f0792c91db8a1153dd57e10786b98163f0d7691808adb03b2982","a1e3be755cce01c7990d56199786aa6c80538a283586dbf633d37b9f75761a43"],"root":[411,414,[416,419],[455,458],463,464,[469,482],[486,489],491,[494,496],[501,513]],"options":{"allowJs":true,"declaration":true,"declarationMap":true,"esModuleInterop":true,"jsx":1,"module":99,"skipLibCheck":true,"sourceMap":true,"strict":true,"target":4},"referencedMap":[[508,1],[507,2],[509,3],[510,4],[511,5],[512,6],[513,7],[506,8],[411,9],[482,10],[470,11],[477,12],[487,13],[488,14],[496,15],[502,16],[457,17],[503,18],[458,19],[456,20],[476,21],[504,22],[505,23],[480,24],[481,24],[495,25],[464,26],[474,27],[471,28],[479,21],[475,29],[463,30],[472,31],[486,32],[489,31],[491,33],[494,34],[501,35],[473,31],[478,31],[469,36],[419,37],[414,38],[416,39],[417,37],[455,28],[418,40],[364,37],[497,41],[465,28],[485,42],[467,41],[483,41],[490,41],[499,43],[484,41],[466,28],[493,44],[492,45],[500,46],[460,28],[468,47],[498,37],[423,37],[425,48],[427,49],[434,50],[428,51],[429,37],[430,48],[431,51],[426,37],[433,52],[424,37],[432,37],[435,53],[454,54],[448,55],[436,28],[452,55],[437,53],[438,56],[441,55],[439,57],[453,58],[449,57],[451,55],[450,57],[447,55],[446,55],[440,55],[442,59],[444,55],[445,55],[443,55],[143,60],[144,60],[145,61],[99,62],[146,63],[147,64],[148,65],[94,37],[97,66],[95,37],[96,37],[149,67],[150,68],[151,69],[152,70],[153,71],[154,72],[155,72],[156,73],[157,74],[158,75],[159,76],[100,37],[98,37],[160,77],[161,78],[162,79],[194,80],[163,81],[164,82],[165,83],[166,84],[167,85],[168,86],[169,87],[170,88],[171,89],[172,90],[173,90],[174,91],[175,37],[176,92],[178,93],[177,94],[179,95],[180,96],[181,97],[182,98],[183,99],[184,100],[185,101],[186,102],[187,103],[188,104],[189,105],[190,106],[191,107],[101,37],[102,37],[103,37],[142,108],[192,109],[193,110],[86,37],[199,111],[200,112],[198,28],[196,113],[197,114],[84,37],[87,115],[287,28],[104,37],[462,116],[461,117],[412,37],[85,37],[459,28],[93,118],[367,119],[371,120],[373,121],[220,122],[234,123],[338,124],[266,37],[341,125],[302,126],[311,127],[339,128],[221,129],[265,37],[267,130],[340,131],[241,132],[222,133],[246,132],[235,132],[205,132],[293,134],[294,135],[210,37],[290,136],[295,56],[382,137],[288,56],[383,138],[272,37],[291,139],[395,140],[394,141],[297,56],[393,37],[391,37],[392,142],[292,28],[279,143],[280,144],[289,145],[306,146],[307,147],[296,148],[274,149],[275,150],[386,151],[389,152],[253,153],[252,154],[251,155],[398,28],[250,156],[226,37],[401,37],[421,157],[420,37],[404,37],[403,28],[405,158],[201,37],[332,37],[233,159],[203,160],[355,37],[356,37],[358,37],[361,161],[357,37],[359,162],[360,162],[219,37],[232,37],[366,163],[374,164],[378,165],[215,166],[282,167],[281,37],[273,149],[301,168],[299,169],[298,37],[300,37],[305,170],[277,171],[214,172],[239,173],[329,174],[206,175],[213,176],[202,124],[343,177],[353,178],[342,37],[352,179],[240,37],[224,180],[320,181],[319,37],[326,182],[328,183],[321,184],[325,185],[327,182],[324,184],[323,182],[322,184],[262,186],[247,186],[314,187],[248,187],[208,188],[207,37],[318,189],[317,190],[316,191],[315,192],[209,193],[286,194],[303,195],[285,196],[310,197],[312,198],[309,196],[242,193],[195,37],[330,199],[268,200],[304,37],[351,201],[271,202],[346,203],[212,37],[347,204],[349,205],[350,206],[333,37],[345,175],[244,207],[331,208],[354,209],[216,37],[218,37],[223,210],[313,211],[211,212],[217,37],[270,213],[269,214],[225,215],[278,216],[276,217],[227,218],[229,219],[402,37],[228,220],[230,221],[369,37],[368,37],[370,37],[400,37],[231,222],[284,28],[92,37],[308,223],[254,37],[264,224],[243,37],[376,28],[385,225],[261,28],[380,56],[260,226],[363,227],[259,225],[204,37],[387,228],[257,28],[258,28],[249,37],[263,37],[256,229],[255,230],[245,231],[238,148],[348,37],[237,232],[236,37],[372,37],[283,28],[365,233],[83,37],[91,234],[88,28],[89,37],[90,37],[344,235],[337,236],[336,37],[335,237],[334,37],[375,238],[377,239],[379,240],[422,241],[381,242],[384,243],[410,244],[388,244],[409,245],[390,246],[396,247],[397,248],[399,249],[406,250],[408,37],[407,251],[362,252],[413,37],[81,37],[82,37],[13,37],[14,37],[16,37],[15,37],[2,37],[17,37],[18,37],[19,37],[20,37],[21,37],[22,37],[23,37],[24,37],[3,37],[25,37],[26,37],[4,37],[27,37],[31,37],[28,37],[29,37],[30,37],[32,37],[33,37],[34,37],[5,37],[35,37],[36,37],[37,37],[38,37],[6,37],[42,37],[39,37],[40,37],[41,37],[43,37],[7,37],[44,37],[49,37],[50,37],[45,37],[46,37],[47,37],[48,37],[8,37],[54,37],[51,37],[52,37],[53,37],[55,37],[9,37],[56,37],[57,37],[58,37],[60,37],[59,37],[61,37],[62,37],[10,37],[63,37],[64,37],[65,37],[11,37],[66,37],[67,37],[68,37],[69,37],[70,37],[1,37],[71,37],[72,37],[12,37],[76,37],[74,37],[79,37],[78,37],[73,37],[77,37],[75,37],[80,37],[120,253],[130,254],[119,253],[140,255],[111,256],[110,257],[139,251],[133,258],[138,259],[113,260],[127,261],[112,262],[136,263],[108,264],[107,251],[137,265],[109,266],[114,267],[115,37],[118,267],[105,37],[141,268],[131,269],[122,270],[123,271],[125,272],[121,273],[124,274],[134,251],[116,275],[117,276],[126,277],[106,278],[129,269],[128,267],[132,37],[135,279],[415,37]],"affectedFilesPendingEmit":[[508,51],[507,51],[509,51],[510,51],[511,51],[512,51],[513,51],[506,51],[482,51],[470,51],[477,51],[487,51],[488,51],[496,51],[502,51],[457,51],[503,51],[458,51],[456,51],[476,51],[504,51],[505,51],[480,51],[481,51],[495,51],[464,51],[474,51],[471,51],[479,51],[475,51],[463,51],[472,51],[486,51],[489,51],[491,51],[494,51],[501,51],[473,51],[478,51],[469,51],[419,51],[414,51],[416,51],[417,51],[455,51],[418,51]],"version":"5.9.3"} \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 9c03aee..805743e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -30,6 +30,10 @@ services: JWT_REFRESH_SECRET: "${JWT_REFRESH_SECRET}" PORT: 3001 CORS_ORIGIN: "${DASHBOARD_URL:-http://localhost:3000}" + RESEND_API_KEY: "${RESEND_API_KEY:-}" + RESEND_FROM_EMAIL: "${RESEND_FROM_EMAIL:-KoolTab }" + # The API sits behind a reverse proxy: trust one hop of X-Forwarded-For for per-client rate limiting + TRUST_PROXY: "${TRUST_PROXY:-1}" depends_on: postgres: condition: service_healthy diff --git a/docs/api.md b/docs/api.md index 98b681b..8ae948f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -18,7 +18,7 @@ Swagger UI: `http://localhost:3001/api/docs` ## Endpoints ### Health -`GET /health` - Server health check +`GET /health` - Server health check (503 when the database is unreachable) ### Auth `POST /auth/login` - Login with email/password @@ -36,13 +36,37 @@ Swagger UI: `http://localhost:3001/api/docs` `POST /devices/:deviceId/commands/:commandId/ack` - Acknowledge command (device auth) `GET /devices/generation-code` - Generate registration code (admin) +Sync semantics: +- `openTabs` must be the device's **complete** list of open tabs. Tabs it no longer reports + (e.g. deleted on the terminal) are removed from the dashboard. Settling a tab on the terminal + keeps the tab open under the same name with a zero balance, so the name stays listed. +- Every sync returns **all** pending commands for the device; `cursor` is still returned but no longer + filters. Devices dedupe by command id and re-send the ack for commands they already processed. +- The first ack for a command wins; later acks are accepted as no-ops. + ### Tabs `GET /venues/:venueId/tabs` - List open tabs +### Planned Tabs (customer list) +`GET /venues/:venueId/planned-tabs` - List planned customers +`POST /venues/:venueId/planned-tabs` - Add a customer (admin) +`POST /venues/:venueId/planned-tabs/import` - Bulk import `{ entries: [{ customerName, email? }], mode: 'merge' | 'replace' }` (admin). +Names match case-insensitively and keep their existing spelling; `replace` also removes customers missing from the import. +`PUT /venues/:venueId/planned-tabs/:id` - Rename a customer / set email (admin) +`DELETE /venues/:venueId/planned-tabs/:id` - Remove a customer (admin) + +### Reminders +`GET /venues/:venueId/reminders/candidates` - Customers with an unpaid balance and email, not reminded in the last 7 days +`POST /venues/:venueId/reminders/send` - Send payment reminders +`GET /venues/:venueId/reminders` - Reminder history +`POST /venues/:venueId/reminders/test/:plannedTabId` - Send a test email (admin) + ### Commands `POST /venues/:venueId/commands` - Create a command `GET /venues/:venueId/commands` - List command history -`POST /venues/:venueId/devices/:deviceId/revoke` - Revoke device (admin) +`POST /venues/:venueId/commands/devices/:deviceId/revoke` - Revoke device (admin) + +All `/venues/:venueId/...` routes return `403 venue_forbidden` unless the user belongs to that venue. ## Error Format diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f636f8d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,534 @@ +{ + "name": "kooltab", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kooltab", + "devDependencies": { + "tsx": "^4.23.15" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + } + } +} diff --git a/package.json b/package.json index 2c69f0a..617e886 100644 --- a/package.json +++ b/package.json @@ -18,5 +18,8 @@ }, "engines": { "node": ">=18.0.0" + }, + "devDependencies": { + "tsx": "^4.23.15" } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42fb9ee..a47416f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,11 @@ settings: importers: - .: {} + .: + devDependencies: + tsx: + specifier: ^4.23.15 + version: 4.23.15 apps/api: dependencies: @@ -82,7 +86,7 @@ importers: devDependencies: '@nestjs/cli': specifier: ^10.3.0 - version: 10.4.9(postcss@8.5.28)(uglify-js@3.19.3) + version: 10.4.9(esbuild@0.28.2)(postcss@8.5.28)(uglify-js@3.19.3) '@nestjs/schematics': specifier: ^10.1.0 version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3) @@ -118,10 +122,10 @@ importers: version: 6.3.4(supports-color@8.1.1) ts-jest: specifier: ^29.1.2 - version: 29.4.12(@babel/core@7.29.7(supports-color@8.1.1))(@jest/transform@29.7.0(supports-color@8.1.1))(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.43)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.12(@babel/core@7.29.7(supports-color@8.1.1))(@jest/transform@29.7.0(supports-color@8.1.1))(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(esbuild@0.28.2)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.43)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)))(typescript@5.9.3) ts-loader: specifier: ^9.5.1 - version: 9.6.2(typescript@5.9.3)(webpack@5.97.1(postcss@8.5.28)(uglify-js@3.19.3)) + version: 9.6.2(typescript@5.9.3)(webpack@5.97.1(esbuild@0.28.2)(postcss@8.5.28)(uglify-js@3.19.3)) apps/dashboard: dependencies: @@ -188,6 +192,9 @@ importers: tailwind-merge: specifier: ^2.2.1 version: 2.6.1 + xlsx: + specifier: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz + version: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz zod: specifier: ^3.22.4 version: 3.25.76 @@ -209,7 +216,7 @@ importers: version: 8.5.28 tailwindcss: specifier: ^3.4.1 - version: 3.4.19 + version: 3.4.19(tsx@4.23.15) typescript: specifier: ^5.4.0 version: 5.9.3 @@ -420,6 +427,162 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@floating-ui/core@1.8.0': resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} @@ -1998,6 +2161,11 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -3620,6 +3788,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.23.15: + resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==} + engines: {node: '>=18.0.0'} + hasBin: true + type-detect@4.0.8: resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} engines: {node: '>=4'} @@ -3797,6 +3970,12 @@ packages: resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz: + resolution: {integrity: sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==, tarball: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz} + version: 0.20.3 + engines: {node: '>=0.8'} + hasBin: true + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -4064,6 +4243,84 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + '@floating-ui/core@1.8.0': dependencies: '@floating-ui/utils': 0.2.12 @@ -4314,7 +4571,7 @@ snapshots: '@microsoft/tsdoc@0.15.1': {} - '@nestjs/cli@10.4.9(postcss@8.5.28)(uglify-js@3.19.3)': + '@nestjs/cli@10.4.9(esbuild@0.28.2)(postcss@8.5.28)(uglify-js@3.19.3)': dependencies: '@angular-devkit/core': 17.3.11(chokidar@3.6.0) '@angular-devkit/schematics': 17.3.11(chokidar@3.6.0) @@ -4324,7 +4581,7 @@ snapshots: chokidar: 3.6.0 cli-table3: 0.6.5 commander: 4.1.1 - fork-ts-checker-webpack-plugin: 9.0.2(typescript@5.7.2)(webpack@5.97.1(postcss@8.5.28)(uglify-js@3.19.3)) + fork-ts-checker-webpack-plugin: 9.0.2(typescript@5.7.2)(webpack@5.97.1(esbuild@0.28.2)(postcss@8.5.28)(uglify-js@3.19.3)) glob: 10.4.5 inquirer: 8.2.6 node-emoji: 1.11.0 @@ -4333,7 +4590,7 @@ snapshots: tsconfig-paths: 4.2.0 tsconfig-paths-webpack-plugin: 4.2.0 typescript: 5.7.2 - webpack: 5.97.1(postcss@8.5.28)(uglify-js@3.19.3) + webpack: 5.97.1(esbuild@0.28.2)(postcss@8.5.28)(uglify-js@3.19.3) webpack-node-externals: 3.0.0 transitivePeerDependencies: - '@minify-html/node' @@ -5825,6 +6082,35 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.4 + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -5985,7 +6271,7 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - fork-ts-checker-webpack-plugin@9.0.2(typescript@5.7.2)(webpack@5.97.1(postcss@8.5.28)(uglify-js@3.19.3)): + fork-ts-checker-webpack-plugin@9.0.2(typescript@5.7.2)(webpack@5.97.1(esbuild@0.28.2)(postcss@8.5.28)(uglify-js@3.19.3)): dependencies: '@babel/code-frame': 7.29.7 chalk: 4.1.2 @@ -6000,7 +6286,7 @@ snapshots: semver: 7.8.5 tapable: 2.3.3 typescript: 5.7.2 - webpack: 5.97.1(postcss@8.5.28)(uglify-js@3.19.3) + webpack: 5.97.1(esbuild@0.28.2)(postcss@8.5.28)(uglify-js@3.19.3) form-data@4.0.6: dependencies: @@ -7071,12 +7357,13 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.28 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.28): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.28)(tsx@4.23.15): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 postcss: 8.5.28 + tsx: 4.23.15 postcss-nested@6.2.0(postcss@8.5.28): dependencies: @@ -7490,7 +7777,7 @@ snapshots: tailwind-merge@2.6.1: {} - tailwindcss@3.4.19: + tailwindcss@3.4.19(tsx@4.23.15): dependencies: '@alloc/quick-lru': 5.3.0 arg: 5.0.2 @@ -7509,7 +7796,7 @@ snapshots: postcss: 8.5.28 postcss-import: 15.1.0(postcss@8.5.28) postcss-js: 4.1.0(postcss@8.5.28) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.28) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.28)(tsx@4.23.15) postcss-nested: 6.2.0(postcss@8.5.28) postcss-selector-parser: 6.1.4 resolve: 1.22.12 @@ -7529,14 +7816,15 @@ snapshots: mkdirp: 1.0.4 yallist: 4.0.0 - terser-webpack-plugin@5.6.1(postcss@8.5.28)(uglify-js@3.19.3)(webpack@5.97.1(postcss@8.5.28)(uglify-js@3.19.3)): + terser-webpack-plugin@5.6.1(esbuild@0.28.2)(postcss@8.5.28)(uglify-js@3.19.3)(webpack@5.97.1(esbuild@0.28.2)(postcss@8.5.28)(uglify-js@3.19.3)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.5.0 terser: 5.51.2 - webpack: 5.97.1(postcss@8.5.28)(uglify-js@3.19.3) + webpack: 5.97.1(esbuild@0.28.2)(postcss@8.5.28)(uglify-js@3.19.3) optionalDependencies: + esbuild: 0.28.2 postcss: 8.5.28 uglify-js: 3.19.3 @@ -7592,7 +7880,7 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-jest@29.4.12(@babel/core@7.29.7(supports-color@8.1.1))(@jest/transform@29.7.0(supports-color@8.1.1))(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.43)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)))(typescript@5.9.3): + ts-jest@29.4.12(@babel/core@7.29.7(supports-color@8.1.1))(@jest/transform@29.7.0(supports-color@8.1.1))(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(esbuild@0.28.2)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.43)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 @@ -7610,15 +7898,16 @@ snapshots: '@jest/transform': 29.7.0(supports-color@8.1.1) '@jest/types': 29.6.3 babel-jest: 29.7.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + esbuild: 0.28.2 jest-util: 29.7.0 - ts-loader@9.6.2(typescript@5.9.3)(webpack@5.97.1(postcss@8.5.28)(uglify-js@3.19.3)): + ts-loader@9.6.2(typescript@5.9.3)(webpack@5.97.1(esbuild@0.28.2)(postcss@8.5.28)(uglify-js@3.19.3)): dependencies: chalk: 4.1.2 picomatch: 4.0.7 source-map: 0.7.6 typescript: 5.9.3 - webpack: 5.97.1(postcss@8.5.28)(uglify-js@3.19.3) + webpack: 5.97.1(esbuild@0.28.2)(postcss@8.5.28)(uglify-js@3.19.3) ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3): dependencies: @@ -7653,6 +7942,12 @@ snapshots: tslib@2.8.1: {} + tsx@4.23.15: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + type-detect@4.0.8: {} type-fest@0.21.3: {} @@ -7748,7 +8043,7 @@ snapshots: webpack-sources@3.5.1: {} - webpack@5.97.1(postcss@8.5.28)(uglify-js@3.19.3): + webpack@5.97.1(esbuild@0.28.2)(postcss@8.5.28)(uglify-js@3.19.3): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.9 @@ -7770,7 +8065,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 3.3.0 tapable: 2.3.3 - terser-webpack-plugin: 5.6.1(postcss@8.5.28)(uglify-js@3.19.3)(webpack@5.97.1(postcss@8.5.28)(uglify-js@3.19.3)) + terser-webpack-plugin: 5.6.1(esbuild@0.28.2)(postcss@8.5.28)(uglify-js@3.19.3)(webpack@5.97.1(esbuild@0.28.2)(postcss@8.5.28)(uglify-js@3.19.3)) watchpack: 2.5.2 webpack-sources: 3.5.1 transitivePeerDependencies: @@ -7827,6 +8122,8 @@ snapshots: imurmurhash: 0.1.4 signal-exit: 3.0.7 + xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz: {} + xtend@4.0.2: {} y18n@5.0.8: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fb68f71..874618e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,4 +6,5 @@ allowBuilds: '@prisma/client': true '@prisma/engines': true bcrypt: true + esbuild: true prisma: true diff --git a/scripts/device-sync.ts b/scripts/device-sync.ts index 6bea7f2..fcf5057 100644 --- a/scripts/device-sync.ts +++ b/scripts/device-sync.ts @@ -1,206 +1,289 @@ /** - * KoolTab Device Simulator + * KoolTab Interactive Device Simulator + * + * An interactive CLI that simulates a Flutter bar terminal device. + * Keeps running until you press Q. * - * A CLI tool that simulates a Flutter bar terminal device. * Usage: npx ts-node scripts/device-sync.ts - * - * Steps: - * 1. Register a device (or paste an existing token) - * 2. Sync open tabs - * 3. Receive pending commands - * 4. Acknowledge commands */ +import * as readline from 'readline'; + const API_URL = process.env.API_URL || 'http://localhost:3001/api/v1'; -const args = process.argv.slice(2); -const command = args[0] || 'help'; - -interface SyncResult { - cursor: string; - commands: Array<{ - id: string; - type: string; - tabId: string; - expectedVersion: number; - paymentMethod: string; - requestedBy: string; - createdAt: string; - }>; -} +// ── State ──────────────────────────────────────────────── +let deviceId = ''; +let token = ''; +let cursor: string | null = null; +let venueId = ''; +let registrationCode = ''; +const openTabs: Map = new Map(); +let pendingCommands: Array<{ id: string; type: string; tabId: string; expectedVersion: number; paymentMethod: string }> = []; +let tabCounter = 0; +let plannedNames: string[] = []; +let plannedIndex = 0; +// ── API helpers ────────────────────────────────────────── async function api(endpoint: string, options: RequestInit = {}): Promise { + const { headers: customHeaders, ...rest } = options; const res = await fetch(`${API_URL}${endpoint}`, { - headers: { 'Content-Type': 'application/json', ...(options.headers as Record) }, - ...options, + ...rest, + headers: { 'Content-Type': 'application/json', ...(customHeaders as Record) }, }); - if (!res.ok) { const body = await res.json().catch(() => null); - const msg = body?.error?.message || `HTTP ${res.status}`; - throw new Error(msg); + throw new Error(body?.error?.message || `HTTP ${res.status}`); } - return res.json(); } -async function registerDevice() { - const venueId = args[1]; - const name = args[2] || 'Simulated Terminal'; - const code = args[3]; +// ── Actions ────────────────────────────────────────────── - if (!venueId || !code) { - console.error('Usage: npx ts-node scripts/device-sync.ts register '); - process.exit(1); - } - - console.log(`Registering device "${name}" in venue ${venueId}...`); - const result = await api('/devices/register', { - method: 'POST', - body: JSON.stringify({ venueId, name, registrationCode: code }), - }); - - console.log('Device registered!'); - console.log(` Device ID: ${result.deviceId}`); - console.log(` Token: ${result.token}`); - console.log(` Expires: ${result.tokenExpiresAt}`); - console.log('\nSave this token for future sync operations.'); - return result; +// The server treats openTabs as the complete set: tabs left out are considered closed +function buildTabsPayload() { + return Array.from(openTabs.entries()).map(([id, t]) => ({ + id, customerName: t.customerName, version: t.version, totalInCents: t.totalInCents, itemCount: t.itemCount, + })); } -async function syncDevice() { - const deviceId = args[1]; - const token = args[2]; - +async function fetchPlannedCustomers() { + // Planned names come from the sync response — do a quick sync to refresh them if (!deviceId || !token) { - console.error('Usage: npx ts-node scripts/device-sync.ts sync [cursor]'); - process.exit(1); + console.log('\n ❌ Not registered yet.'); + return; } - - const cursor = args[3]; - const tabs = [ - { id: 'tab-' + Date.now(), customerName: 'Simulated Customer', version: 1, totalInCents: 1500, itemCount: 2 }, - ]; - - console.log(`Syncing device ${deviceId}...`); - const result = await api(`/devices/${deviceId}/sync`, { - method: 'POST', - headers: { Authorization: `Bearer ${token}` }, - body: JSON.stringify({ deviceId, cursor: cursor || null, openTabs: tabs }), - }); - - console.log('Sync complete!'); - console.log(` New cursor: ${result.cursor}`); - console.log(` Commands received: ${result.commands.length}`); - - for (const cmd of result.commands) { - console.log(` Command: ${cmd.id} - ${cmd.type} (tab: ${cmd.tabId}, payment: ${cmd.paymentMethod})`); - } - - return result; -} - -async function ackCommand() { - const deviceId = args[1]; - const token = args[2]; - const commandId = args[3]; - const status = args[4] || 'completed'; - const result = args[5] || 'Tab closed successfully'; - - if (!deviceId || !token || !commandId) { - console.error('Usage: npx ts-node scripts/device-sync.ts ack [status] [result]'); - process.exit(1); - } - - console.log(`Acknowledging command ${commandId} as ${status}...`); - const res = await api(`/devices/${deviceId}/commands/${commandId}/ack`, { - method: 'POST', - headers: { Authorization: `Bearer ${token}` }, - body: JSON.stringify({ commandId, status, result }), - }); - - console.log('Ack complete!'); - console.log(` Result: ${JSON.stringify(res)}`); -} - -// Interactive demo sequence -async function demo() { - const venueId = args[1]; - const code = args[2]; - - if (!venueId || !code) { - console.error('Usage: npx ts-node scripts/device-sync.ts demo '); - console.error('First, generate a registration code from the dashboard (Devices > Add Device).'); - process.exit(1); - } - try { - // 1. Register - const reg = await registerDevice(); - const deviceId = reg.deviceId; - const token = reg.token; + const result = await api<{ plannedTabNames?: string[] }>(`/devices/${deviceId}/sync`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify({ deviceId, cursor, openTabs: buildTabsPayload() }), + }); + if (result.plannedTabNames && result.plannedTabNames.length > 0) { + plannedNames = result.plannedTabNames; + plannedIndex = 0; + } + console.log(`\n 📋 Loaded ${plannedNames.length} planned customer(s) from server`); + if (plannedNames.length > 0) { + console.log(` ${plannedNames.join(', ')}`); + } + } catch { + console.log('\n ⚠️ Could not fetch planned customers (using fallback names)'); + } +} - // 2. Sync with tabs - console.log('\n--- Sync 1: Upload tabs ---'); - const sync1 = await syncDevice(); +async function registerDevice() { + const name = 'Simulated Terminal'; + if (!registrationCode) { + console.log('\n ❌ No registration code set. Use P to pair first.'); + return; + } + try { + const result = await api('/devices/register', { + method: 'POST', + body: JSON.stringify({ venueId, name, registrationCode }), + }); + deviceId = result.deviceId; + token = result.token; + console.log(`\n ✅ Registered as "${name}"`); + console.log(` 🆔 Device ID: ${deviceId}`); + console.log(` 🔑 Token: ${token.substring(0, 20)}...`); + await fetchPlannedCustomers(); + } catch (err: any) { + console.log(`\n ❌ Registration failed: ${err.message}`); + } +} - if (sync1.commands.length > 0) { - // 3. Ack commands - for (const cmd of sync1.commands) { - console.log(`\n--- Ack: ${cmd.id} ---`); - await ackCommand(deviceId, token, cmd.id, 'completed', `Tab ${cmd.tabId} closed successfully`); - } +function addTab() { + tabCounter++; + const tabId = `tab-${Date.now()}-${tabCounter}`; + const fallbackNames = ['Jan', 'Piet', 'Kees', 'Anna', 'Maria', 'Lisa', 'Tom', 'Emma', 'Noah', 'Sophie']; + const names = plannedNames.length > 0 ? plannedNames : fallbackNames; + const name = names[plannedIndex % names.length]; + plannedIndex++; + const items = Math.floor(Math.random() * 5) + 1; + const total = items * (Math.floor(Math.random() * 10) + 5) * 100; + openTabs.set(tabId, { customerName: name, version: 1, totalInCents: total, itemCount: items }); + console.log(` 📋 Tab added: ${name} — €${(total / 100).toFixed(2)} (${items} items) [${tabId.substring(0, 8)}]`); +} - // 4. Sync again to see no pending commands - console.log('\n--- Sync 2: Verify no pending commands ---'); - await syncDevice(); +async function sync() { + if (!deviceId || !token) { + console.log('\n ❌ Not registered. Press P to pair first.'); + return; + } + const tabsPayload = buildTabsPayload(); + try { + const result = await api<{ cursor: string; commands: any[]; plannedTabNames?: string[] }>(`/devices/${deviceId}/sync`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify({ deviceId, cursor, openTabs: tabsPayload }), + }); + cursor = result.cursor; + pendingCommands = result.commands; + + // Update planned customers from sync response + if (result.plannedTabNames && result.plannedTabNames.length > 0) { + plannedNames = result.plannedTabNames; + plannedIndex = 0; } - console.log('\n✅ Demo complete! Your device is fully functional.'); + console.log(`\n ✅ Synced — ${openTabs.size} tab(s), ${pendingCommands.length} command(s) pending, ${plannedNames.length} planned`); + for (const cmd of pendingCommands) { + console.log(` 📦 ${cmd.type} on ${cmd.tabId.substring(0, 8)} (${cmd.paymentMethod})`); + } } catch (err: any) { - console.error('❌ Demo failed:', err.message); + console.log(`\n ❌ Sync failed: ${err.message}`); } } +async function ackAll() { + if (pendingCommands.length === 0) { + console.log('\n ℹ️ No pending commands to acknowledge.'); + return; + } + for (const cmd of pendingCommands) { + try { + const status = Math.random() > 0.2 ? 'completed' : 'failed'; + await api(`/devices/${deviceId}/commands/${cmd.id}/ack`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify({ commandId: cmd.id, status, result: `Tab closed via simulator (${status})` }), + }); + console.log(` ✅ Acked ${cmd.id.substring(0, 8)} as ${status}`); + + // If completed, remove the tab from local state + if (status === 'completed') { + openTabs.delete(cmd.tabId); + console.log(` Removed tab ${cmd.tabId.substring(0, 8)} from local state`); + } else { + // Bump version on failed close + const tab = openTabs.get(cmd.tabId); + if (tab) { + tab.version++; + console.log(` Bumped version to ${tab.version}`); + } + } + } catch (err: any) { + console.log(` ❌ Ack failed for ${cmd.id.substring(0, 8)}: ${err.message}`); + } + } + pendingCommands = []; +} + +function listTabs() { + if (openTabs.size === 0) { + console.log('\n ℹ️ No tabs. Press T to add one.'); + return; + } + console.log(`\n 📋 Open Tabs (${openTabs.size}):`); + Array.from(openTabs.entries()).forEach(([id, t]) => { + console.log(` ${id.substring(0, 8)} — ${t.customerName} — €${(t.totalInCents / 100).toFixed(2)} (v${t.version})`); + }); +} + +function showStatus() { + console.log(`\n ── Status ──────────────────────`); + console.log(` Venue ID: ${venueId || '—'}`); + console.log(` Device ID: ${deviceId || '—'}`); + console.log(` Token: ${token ? token.substring(0, 20) + '...' : '—'}`); + console.log(` Cursor: ${cursor || '—'}`); + console.log(` Planned: ${plannedNames.length} customer(s)`); + console.log(` Tabs: ${openTabs.size}`); + console.log(` Pending: ${pendingCommands.length}`); + console.log(` ────────────────────────────────`); +} + +async function setVenueAndCode() { + // Temporarily disable raw mode so readline works + if ((process.stdin as any).isTTY) (process.stdin as any).setRawMode(false); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true }); + const v = await new Promise((resolve) => rl.question(' Venue ID: ', resolve)); + venueId = v.trim(); + const c = await new Promise((resolve) => rl.question(' Registration Code: ', resolve)); + registrationCode = c.trim(); + rl.close(); + // Re-enable raw mode for keypress handling + if ((process.stdin as any).isTTY) (process.stdin as any).setRawMode(true); + if (venueId && c) { + await registerDevice(); + } +} + +// ── Interactive loop ───────────────────────────────────── + +function printHelp() { + console.log(` +╔══════════════════════════════════════════╗ +║ KoolTab Device Simulator ║ +╠══════════════════════════════════════════╣ +║ P — Pair / register device ║ +║ T — Add tab (uses planned customers) ║ +║ S — Sync with server ║ +║ A — Acknowledge all pending commands ║ +║ R — Refresh planned customers ║ +║ L — List open tabs ║ +║ H — Show this help ║ +║ Q — Quit ║ +╚══════════════════════════════════════════╝`); +} + async function main() { - try { - switch (command) { - case 'register': - await registerDevice(); - break; - case 'sync': - await syncDevice(); - break; - case 'ack': - await ackCommand(); - break; - case 'demo': - await demo(); - break; - default: - console.log(` -KoolTab Device Simulator -======================== + console.clear(); + console.log('╔══════════════════════════════════════╗'); + console.log('║ KoolTab Device Simulator ║'); + console.log('╚══════════════════════════════════════╝'); -Commands: - register Register a new device - sync [cursor] Sync device state - ack [status] Acknowledge a command - demo Run full demo sequence + // Prompt for venue / code on first run + await setVenueAndCode(); -Examples: - npx ts-node scripts/device-sync.ts register "Bar Terminal 1" ABC123 - npx ts-node scripts/device-sync.ts sync - npx ts-node scripts/device-sync.ts ack completed - npx ts-node scripts/device-sync.ts demo ABC123 + printHelp(); + showStatus(); -Status values: completed, failed, conflict, expired - `); + readline.emitKeypressEvents(process.stdin); + if ((process.stdin as any).isTTY) (process.stdin as any).setRawMode(true); + process.stdin.resume(); + + process.stdin.on('keypress', async (_str: string, key: any) => { + if (key.ctrl && key.name === 'c') { + console.log('\n👋 Goodbye!'); + process.exit(0); } - } catch (err: any) { - console.error('Error:', err.message); - process.exit(1); - } + switch (key.name?.toLowerCase()) { + case 'p': + await setVenueAndCode(); + showStatus(); + break; + case 't': + addTab(); + break; + case 'r': + await fetchPlannedCustomers(); + showStatus(); + break; + case 's': + await sync(); + showStatus(); + break; + case 'a': + await ackAll(); + showStatus(); + break; + case 'l': + listTabs(); + break; + case 'h': + printHelp(); + break; + case 'q': + console.log('\n👋 Goodbye!'); + process.exit(0); + } + }); + + console.log('\nPress H for help, or any key above to get started.'); } -main(); \ No newline at end of file +main().catch((err) => { + console.error('Fatal:', err.message); + process.exit(1); +}); \ No newline at end of file