diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts index 847164551..e7126bc8e 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts @@ -68,16 +68,44 @@ export class FareEngineService { let ratePerKmMinor: number; let fareSource: string; - if (fareRule) { - // Flat fare from FareRule — distance is informational only + // 1. Segment override: exact origin→destination stop pair on this route + const segmentOverride = await this.prisma.segmentFareRule.findFirst({ + where: { + routeId: route.id, + seatClassId: dto.seatClassId, + originStopSequence: originStop.sequence, + destinationStopSequence: destStop.sequence, + validFrom: { lte: now }, + OR: [{ validUntil: null }, { validUntil: { gte: now } }], + nationality: dto.nationality ?? null, + }, + }) ?? await this.prisma.segmentFareRule.findFirst({ + where: { + routeId: route.id, + seatClassId: dto.seatClassId, + originStopSequence: originStop.sequence, + destinationStopSequence: destStop.sequence, + validFrom: { lte: now }, + OR: [{ validUntil: null }, { validUntil: { gte: now } }], + nationality: null, + }, + }); + + if (segmentOverride) { + // Flat override for this exact segment — baseFareMinor is the total base, not a per-km rate + baseFarePerPassengerMinor = segmentOverride.baseFareMinor; + ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0; + fareSource = 'SEGMENT_FARE_RULE'; + } else if (fareRule?.tripId) { + // Schedule-scoped flat override baseFarePerPassengerMinor = fareRule.baseFareMinor; ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0; - fareSource = fareRule.tripId ? 'SCHEDULE_FARE_RULE' : 'ROUTE_FARE_RULE'; + fareSource = 'SCHEDULE_FARE_RULE'; } else { - // Distance × rate fallback + // Default: distance-based using live SeatClass rate ratePerKmMinor = seatClass.baseFareMinor; - baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor; - fareSource = 'DISTANCE_RATE'; + baseFarePerPassengerMinor = Math.round(ratePerKmMinor * totalDistanceKm); + fareSource = 'SEAT_CLASS_BASE_FARE'; } // Premium and insurance fees applied per passenger @@ -146,6 +174,7 @@ export class FareEngineService { routeCode: route.code, originName: originStation?.name ?? dto.originStationId, destinationName: destStation?.name ?? dto.destinationStationId, + seatClassId: seatClass.id, seatClassName: seatClass.name, totalDistanceKm, ratePerKmMinor, diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts index 2f2a76f48..f7527020b 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts @@ -79,11 +79,10 @@ export class UpdateClassDto { @ApiPropertyOptional({ example: 'coach-type-uuid' }) @IsOptional() @IsString() coachTypeId?: string; @ApiPropertyOptional({ example: 'Economy' }) @IsOptional() @IsString() name?: string; @ApiPropertyOptional() @IsOptional() @IsString() description?: string; - @ApiPropertyOptional({ example: 500 }) @IsOptional() @IsInt() baseFareMinor?: number; - @ApiPropertyOptional({ example: true }) - @IsOptional() - @IsBoolean() - isActive?: boolean; + @ApiPropertyOptional({ example: 50 }) @IsOptional() @IsInt() baseFareMinor?: number; + @ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() premiumMinor?: number; + @ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() insuranceFeeMinor?: number; + @ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() isActive?: boolean; } export class GenerateSeatMapDto { diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts index ebec606c6..a725b9cd4 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts @@ -258,6 +258,8 @@ export class FleetService { name: dto.name, description: dto.description, baseFareMinor: dto.baseFareMinor, + premiumMinor: dto.premiumMinor, + insuranceFeeMinor: dto.insuranceFeeMinor, }; if (dto.isActive !== undefined) { diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts index 6d1091719..1a5547d48 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post, Query, ParseIntPipe, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseIntPipe, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { SchedulesService } from './schedules.service'; @@ -132,6 +132,23 @@ export class SchedulesController { @Body() dto: UpdateStopTimeDto, ) { return this.service.updateStop(id, sequence, dto); } + @Put(':scheduleId/fares/:seatClassId') + @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: 'Override fare for a specific seat class on a schedule', + description: 'Upserts a schedule-scoped FareRule. Expires any existing active rule for the same schedule+seatClass and creates a new one.', + }) + @ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' }) + @ApiParam({ name: 'seatClassId', description: 'SeatClass UUID' }) + @ApiResponse({ status: 200, description: 'Fare rule upserted' }) + upsertScheduleFare( + @Param('scheduleId') scheduleId: string, + @Param('seatClassId') seatClassId: string, + @Body() dto: { baseFareMinor: number; validFrom?: string; validUntil?: string }, + ) { + return this.service.upsertScheduleFare(scheduleId, seatClassId, dto); + } + @Get(':scheduleId/fares/stored') @ApiOperation({ summary: 'Get stored fare rules for a schedule' }) @ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' }) diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index 66e43d6e9..567b9400a 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -402,6 +402,34 @@ export class SchedulesService { }); } + async upsertScheduleFare( + scheduleId: string, + seatClassId: string, + dto: { baseFareMinor: number; validFrom?: string; validUntil?: string }, + ) { + const [schedule, seatClass] = await Promise.all([ + this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } }), + this.prisma.seatClass.findUnique({ where: { id: seatClassId } }), + ]); + if (!schedule) throw new NotFoundException('Schedule not found'); + if (!seatClass) throw new NotFoundException('Seat class not found'); + + const now = new Date(); + const validFrom = dto.validFrom ? parseEthiopianTime(dto.validFrom) : now; + const validUntil = dto.validUntil ? parseEthiopianTime(dto.validUntil) : null; + + return this.prisma.$transaction(async (tx) => { + await tx.fareRule.updateMany({ + where: { tripId: scheduleId, seatClassId, validUntil: null }, + data: { validUntil: now }, + }); + return tx.fareRule.create({ + data: { tripId: scheduleId, seatClassId, baseFareMinor: dto.baseFareMinor, currency: 'ETB', validFrom, validUntil }, + include: { seatClass: true }, + }); + }); + } + createFareRule(dto: CreateFareRuleDto) { const { validFrom, validUntil, scheduleId, nationality, passengerCategory, ...rest } = dto; return this.prisma.fareRule.create({ diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 08687d291..0440e697e 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -418,6 +418,7 @@ export class SearchService { }, }); if (!schedule) throw new NotFoundException('Schedule not found'); + if (!schedule.routeId) throw new NotFoundException('Schedule has no route configured for fare calculation'); const originStop = schedule.stopTimes.find((s: any) => s.stationId === dto.originStationId); const destStop = schedule.stopTimes.find((s: any) => s.stationId === dto.destinationStationId); @@ -426,56 +427,25 @@ export class SearchService { } const seatClass = await this.prisma.seatClass.findFirst({ where: { name: dto.seatClassName } }); + if (!seatClass) throw new NotFoundException(`Seat class '${dto.seatClassName}' not found`); - const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; - const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`; - const now = new Date(); - const nationality = dto.nationality; - - const candidates = await this.prisma.fareRule.findMany({ - where: { - seatClassId: seatClass?.id, - validFrom: { lte: now }, - OR: [ - { validUntil: null }, - { validUntil: { gte: now } }, - ], - }, + const fare = await this.fareEngine.calculate({ + routeId: schedule.routeId, + originStationId: dto.originStationId, + destinationStationId: dto.destinationStationId, + seatClassId: seatClass.id, + nationality: dto.nationality, + scheduleId: dto.scheduleId, + adultCount: dto.adultCount, + childCount: dto.childCount ?? 0, + promoCode: dto.promoCode, }); - const bestMatch = this.selectBestFareRule( - candidates, - dto.scheduleId, - segmentRoute, - fullRoute, - nationality, - ); - - const baseFareMinor = bestMatch?.baseFareMinor - ?? await this.resolveScheduleFare(dto.scheduleId, seatClass?.id, dto.seatClassName); - - const adultCount = dto.adultCount; - const childCount = dto.childCount ?? 0; - const adultFareMinor = baseFareMinor * adultCount; - const paidChildrenCount = Math.max(0, childCount - 1); - const childFareMinor = baseFareMinor * paidChildrenCount; - const totalBaseFareMinor = adultFareMinor + childFareMinor; - - let discountMinor = 0; - if (dto.promoCode) { - const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } }); - if (promo?.active && promo.validUntil > now) { - discountMinor = promo.percentOff - ? Math.round(totalBaseFareMinor * promo.percentOff / 100) - : (promo.amountOffMinor ?? 0); - } - } - const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * POINTS_TO_MINOR; - const taxesMinor = 0; - const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor); + const totalMinor = Math.max(0, fare.totalMinor - loyaltyMinor); - const displayCurrency = dto.displayCurrency ?? resolveCurrencyFromNationality(dto.nationality); + const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; + const displayCurrency = dto.displayCurrency ?? (fare.billingCurrency as Currency); const displayTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) : totalMinor; @@ -487,13 +457,23 @@ export class SearchService { segmentRoute, seatClassName: dto.seatClassName, nationality: dto.nationality, - adultCount, childCount, - baseFareMinor, adultFareMinor, childFareMinor, - freeChildrenCount: Math.min(childCount, 1), - paidChildrenCount, totalBaseFareMinor, - discountMinor, loyaltyRedemptionMinor: loyaltyMinor, - taxesFeesMinor: taxesMinor, totalMinor, - currency: 'ETB', displayCurrency, displayTotalMinor, + adultCount: fare.adultCount, + childCount: fare.childCount, + baseFareMinor: fare.baseFarePerPassengerMinor, + adultFareMinor: fare.adultCount * fare.farePerPassengerMinor, + childFareMinor: fare.paidChildrenCount * fare.farePerPassengerMinor, + freeChildrenCount: fare.freeChildrenCount, + paidChildrenCount: fare.paidChildrenCount, + premiumMinor: fare.premiumPerPassenger, + insuranceFeeMinor: fare.insurancePerPassenger, + totalBaseFareMinor: fare.subtotalMinor, + discountMinor: fare.discountMinor, + taxesFeesMinor: fare.taxMinor, + loyaltyRedemptionMinor: loyaltyMinor, + totalMinor, + currency: 'ETB', + displayCurrency, + displayTotalMinor, }; } @@ -505,15 +485,19 @@ export class SearchService { ): Promise> { const displayCurrency = resolveCurrencyFromNationality(nationality); - // Use seat class data already loaded in the schedule include — avoids an extra seatClass.findMany - const seatClassMap = new Map(); + // Collect seat class IDs from the schedule include for the ID set, + // but fetch fresh records from DB so updated baseFareMinor is always current + const seatClassIdSet = new Set(); for (const a of schedule.coachAssignments) { for (const sc of (a.coach.coachType?.seatClasses ?? [])) { - if (sc.isActive && !seatClassMap.has(sc.id)) seatClassMap.set(sc.id, sc); + if (sc.isActive) seatClassIdSet.add(sc.id); } } - const seatClasses = Array.from(seatClassMap.values()) - .sort((a: any, b: any) => a.baseFareMinor - b.baseFareMinor); + const freshSeatClasses = await this.prisma.seatClass.findMany({ + where: { id: { in: Array.from(seatClassIdSet) }, isActive: true }, + }); + const seatClassMap = new Map(freshSeatClasses.map(sc => [sc.id, sc])); + const seatClasses = freshSeatClasses.sort((a, b) => a.baseFareMinor - b.baseFareMinor); if (seatClasses.length === 0) return []; @@ -531,9 +515,9 @@ export class SearchService { }); return { seatClassName: fare.seatClassName, - baseFareMinor: fare.baseFarePerPassengerMinor, + baseFareMinor: fare.totalMinor, displayCurrency: fare.billingCurrency as Currency, - displayAmountMinor: Math.round(fare.baseFarePerPassengerMinor * fare.exchangeRate), + displayAmountMinor: fare.totalInBillingCurrency, }; } catch { return null; @@ -568,12 +552,16 @@ export class SearchService { if (fareRules.length > 0) { const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, displayCurrency); - return fareRules.map(rule => ({ - seatClassName: seatClassMap.get(rule.seatClassId)?.name ?? 'Unknown', - baseFareMinor: rule.baseFareMinor, - displayCurrency, - displayAmountMinor: Math.round(rule.baseFareMinor * exchangeRate), - })); + const TAX_RATE = 0.05; + return fareRules.map(rule => { + const totalMinor = rule.baseFareMinor + Math.round(rule.baseFareMinor * TAX_RATE); + return { + seatClassName: seatClassMap.get(rule.seatClassId)?.name ?? 'Unknown', + baseFareMinor: totalMinor, + displayCurrency, + displayAmountMinor: Math.round(totalMinor * exchangeRate), + }; + }); } } @@ -644,54 +632,4 @@ export class SearchService { }); } - private async resolveScheduleFare(scheduleId: string, seatClassId?: string, seatClassName?: string): Promise { - if (!seatClassId) throw new NotFoundException(`Seat class '${seatClassName}' not found`); - const schedule = await this.prisma.trainSchedule.findUnique({ - where: { id: scheduleId }, - select: { routeId: true, originStationId: true, destinationStationId: true }, - }); - if (!schedule?.routeId) throw new NotFoundException('Schedule has no route configured for fare calculation'); - const fare = await this.fareEngine.calculate({ - routeId: schedule.routeId, - originStationId: schedule.originStationId, - destinationStationId: schedule.destinationStationId, - seatClassId, - }); - return fare.baseFarePerPassengerMinor; - } - - private selectBestFareRule( - candidates: any[], - scheduleId: string, - segmentRoute: string, - fullRoute: string, - nationality?: string, - ): any | null { - const priorities = [ - { tripId: scheduleId, route: segmentRoute, nationality }, - { tripId: scheduleId, route: segmentRoute, nationality: null }, - { tripId: scheduleId, route: fullRoute, nationality }, - { tripId: scheduleId, route: fullRoute, nationality: null }, - { tripId: scheduleId, route: null, nationality }, - { tripId: scheduleId, route: null, nationality: null }, - { tripId: null, route: segmentRoute, nationality }, - { tripId: null, route: segmentRoute, nationality: null }, - { tripId: null, route: fullRoute, nationality }, - { tripId: null, route: fullRoute, nationality: null }, - { tripId: null, route: null, nationality }, - { tripId: null, route: null, nationality: null }, - ]; - - for (const priority of priorities) { - const match = candidates.find( - (c) => - c.tripId === priority.tripId && - c.route === priority.route && - c.nationality === priority.nationality, - ); - if (match) return match; - } - - return null; - } } diff --git a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts index 10fcfe0cf..79151bdc9 100644 --- a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts +++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts @@ -19,21 +19,31 @@ export class SeatClassesService { return sc; } + async updateSeatClass(id: string, dto: any) { + const sc = await this.prisma.seatClass.findUnique({ where: { id } }); + if (!sc) throw new NotFoundException('SeatClass not found'); + const { basePrice, ...rest } = dto; + const data = { + ...rest, + ...(basePrice !== undefined && { baseFareMinor: basePrice }), + }; + return this.prisma.seatClass.update({ where: { id }, data }); + } + async createSeatClass(dto: any) { try { - return await this.prisma.seatClass.create({ data: dto }); + const { basePrice, ...rest } = dto; + const data = { + ...rest, + ...(basePrice !== undefined && { baseFareMinor: basePrice }), + }; + return await this.prisma.seatClass.create({ data }); } catch (e: any) { if (e.code === 'P2002') throw new ConflictException(`Seat class "${dto.name}" already exists`); throw e; } } - async updateSeatClass(id: string, dto: any) { - const sc = await this.prisma.seatClass.findUnique({ where: { id } }); - if (!sc) throw new NotFoundException('SeatClass not found'); - return this.prisma.seatClass.update({ where: { id }, data: dto }); - } - async deleteSeatClass(id: string) { const sc = await this.prisma.seatClass.findUnique({ where: { id } }); if (!sc) throw new NotFoundException('SeatClass not found'); diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index 797adbb55..e482189d2 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -159,6 +159,8 @@ export class TicketsService { } : null, status: t.status, validatedAt: t.validatedAt, + boardedAt: t.validatedAt, + qrCode: t.qrPayload ?? null, createdAt: t.issuedAt, }; }), @@ -247,9 +249,12 @@ export class TicketsService { // Use first seat for primary data const primarySeat = passengerSeats[0]; - // Build passenger QR data with all legs included - const qrData = JSON.stringify({ + const barcodePayload = `${booking.bookingRef}${primarySeat.seatId.substring(0, 8).toUpperCase()}`; + + // Re-encode QR with ticketNumber included + const qrDataWithTicket = JSON.stringify({ ref: booking.bookingRef, + ticketNumber: barcodePayload, type: booking.bookingType, passenger: passengerName, seats: passengerSeats.map(ps => ({ @@ -259,8 +264,7 @@ export class TicketsService { scheduleId: ps.scheduleId || booking.scheduleId, })), }); - const qrPayload = await QRCode.toDataURL(qrData); - const barcodePayload = `${booking.bookingRef}${primarySeat.seatId.substring(0, 8).toUpperCase()}`; + const qrPayloadFinal = await QRCode.toDataURL(qrDataWithTicket); const ticket = await this.prisma.ticket.create({ data: { @@ -270,7 +274,7 @@ export class TicketsService { seatId: primarySeat.seatId, leg: primarySeat.leg || 1, scheduleId: primarySeat.scheduleId || booking.scheduleId, - qrPayload, + qrPayload: qrPayloadFinal, barcodePayload, } as any, }); @@ -371,15 +375,13 @@ export class TicketsService { let bookingRef = qrCodeOrRef; try { const qrData = JSON.parse(qrCodeOrRef); - if (qrData.ref) { - bookingRef = qrData.ref; - } + if (qrData.ref) bookingRef = qrData.ref; } catch { - // Not JSON, treat as booking reference + // Not JSON, treat as booking reference or ticket number } // Get booking and ticket info - const booking = await this.prisma.booking.findUnique({ + let booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, @@ -389,6 +391,23 @@ export class TicketsService { }, }); + if (!booking) { + // Input may be a ticket number (barcodePayload) — look it up + const ticket = await this.prisma.ticket.findFirst({ where: { barcodePayload: bookingRef } }); + if (ticket) { + booking = await this.prisma.booking.findUnique({ + where: { bookingRef: ticket.bookingRef }, + include: { + schedule: { include: { originStation: true, destinationStation: true, train: true } }, + returnSchedule: { include: { originStation: true, destinationStation: true } }, + tickets: true, + seats: { include: { seat: { include: { coach: true } } } }, + }, + }); + if (booking) bookingRef = (booking as any).bookingRef; + } + } + if (!booking) { throw new NotFoundException('Ticket not found'); } @@ -443,6 +462,7 @@ export class TicketsService { message: `Passenger boarded successfully (${result.leg || 'OUTBOUND'} leg)`, boarding: { ticketId: ticket.id, + ticketNumber: ticket.barcodePayload, bookingRef: booking.bookingRef, passengerName: seatInfo?.passengerName || ticket.passengerName || 'N/A', route: `${(booking as any).schedule?.originStation?.name || 'N/A'} → ${(booking as any).schedule?.destinationStation?.name || 'N/A'}`, diff --git a/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx b/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx index db5f362dc..75f85cc90 100644 --- a/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx @@ -15,7 +15,7 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro const canvasRef = useRef(null); const [isScanning, setIsScanning] = useState(false); const [isInitializing, setIsInitializing] = useState(false); - const [stream, setStream] = useState(null); + const streamRef = useRef(null); const [cameraError, setCameraError] = useState(null); const scanIntervalRef = useRef(null); @@ -34,9 +34,9 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro } // First, stop any existing stream - if (stream) { - stream.getTracks().forEach(track => track.stop()); - setStream(null); + if (streamRef.current) { + streamRef.current.getTracks().forEach(track => track.stop()); + streamRef.current = null; } // Request camera access with simpler fallback @@ -120,7 +120,7 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro } // Set state to show video - setStream(mediaStream); + streamRef.current = mediaStream; setIsScanning(true); setIsInitializing(false); @@ -144,9 +144,9 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro onError(errorMsg); // Clean up on error - if (stream) { - stream.getTracks().forEach(track => track.stop()); - setStream(null); + if (streamRef.current) { + streamRef.current.getTracks().forEach(track => track.stop()); + streamRef.current = null; } setIsScanning(false); setIsInitializing(false); @@ -158,16 +158,16 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro clearInterval(scanIntervalRef.current); scanIntervalRef.current = null; } - if (stream) { - stream.getTracks().forEach(track => track.stop()); - setStream(null); + if (streamRef.current) { + streamRef.current.getTracks().forEach(track => track.stop()); + streamRef.current = null; } if (videoRef.current) { videoRef.current.srcObject = null; } setIsScanning(false); setCameraError(null); - }, [stream]); + }, []); // QR code scanning with jsqr const scanFrame = useCallback(() => { @@ -201,15 +201,19 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro useEffect(() => { if (isScanning) { - scanIntervalRef.current = window.setInterval(scanFrame, 100); // Scan every 100ms - } - return () => { + scanIntervalRef.current = window.setInterval(scanFrame, 100); + } else { if (scanIntervalRef.current) { clearInterval(scanIntervalRef.current); + scanIntervalRef.current = null; } - stopCamera(); - }; - }, [isScanning, scanFrame, stopCamera]); + } + }, [isScanning, scanFrame]); + + // Cleanup on unmount only + useEffect(() => { + return () => stopCamera(); + }, [stopCamera]); // Load jsqr from CDN useEffect(() => { @@ -294,9 +298,9 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro