diff --git a/package.json b/package.json index bb945b2..fd7292a 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,8 @@ "stylelint-selector-bem-pattern": "^4.0.1", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.16", - "tslib": "^2.8.1" + "tslib": "^2.8.1", + "angularx-qrcode": "^20.0.0" }, "devDependencies": { "@angular-eslint/builder": "20.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 060e83f..8d2d7f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@tailwindcss/postcss': specifier: ^4.1.16 version: 4.1.16 + angularx-qrcode: + specifier: ^20.0.0 + version: 20.0.0(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)) chart.js: specifier: ^4.5.1 version: 4.5.1 @@ -1917,6 +1920,11 @@ packages: typescript: '*' typescript-eslint: ^8.0.0 + angularx-qrcode@20.0.0: + resolution: {integrity: sha512-WZolRZztQsQxOXqodNSDicxPWNO79t/AT4wts+DxwYdtdXb1RELfZjtax9oGMQQ6mEZ6bwk5GqBGEDB3Y+cSqw==} + peerDependencies: + '@angular/core': ^20.0.0 + ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} @@ -2078,6 +2086,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001748: resolution: {integrity: sha512-5P5UgAr0+aBmNiplks08JLw+AW/XG/SurlgZLgB1dDLfAw7EfRGxIwzPHxdSCGY/BTKDqIVyJL87cCN6s0ZR0w==} @@ -2140,6 +2152,9 @@ packages: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} @@ -2359,6 +2374,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -2393,6 +2412,9 @@ packages: di@0.0.1: resolution: {integrity: sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA==} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -3924,6 +3946,10 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + postcss-angular@2.0.0: resolution: {integrity: sha512-e+hszGoYAq7D8wuDPwVFIcF3iutrfZcdD3mIoes6arYCQ8DuYHdHru9EGU+bCYctoxfiV0s75TwzLyHBTKAKgA==} engines: {node: ^12 || >=14} @@ -4019,6 +4045,11 @@ packages: resolution: {integrity: sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==} engines: {node: '>=0.9'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + qs@6.13.0: resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} engines: {node: '>=0.6'} @@ -4065,6 +4096,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} @@ -4163,6 +4197,9 @@ packages: resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==} engines: {node: '>= 18'} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -4677,6 +4714,9 @@ packages: whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which@1.3.1: resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} hasBin: true @@ -4738,6 +4778,9 @@ packages: utf-8-validate: optional: true + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -4757,6 +4800,10 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@20.2.9: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} engines: {node: '>=10'} @@ -4769,6 +4816,10 @@ packages: resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@16.2.0: resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} engines: {node: '>=10'} @@ -6641,6 +6692,12 @@ snapshots: - chokidar - supports-color + angularx-qrcode@20.0.0(@angular/core@20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2)): + dependencies: + '@angular/core': 20.3.7(@angular/compiler@20.3.7)(rxjs@7.8.2) + qrcode: 1.5.4 + tslib: 2.8.1 + ansi-escapes@4.3.2: dependencies: type-fest: 0.21.3 @@ -6832,6 +6889,8 @@ snapshots: callsites@3.1.0: {} + camelcase@5.3.1: {} + caniuse-lite@1.0.30001748: {} chalk@4.1.2: @@ -6891,6 +6950,12 @@ snapshots: cli-width@4.1.0: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@7.0.4: dependencies: string-width: 4.2.3 @@ -7082,6 +7147,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + deep-is@0.1.4: {} defaults@1.0.4: @@ -7107,6 +7174,8 @@ snapshots: di@0.0.1: {} + dijkstrajs@1.0.3: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -8785,6 +8854,8 @@ snapshots: dependencies: find-up: 4.1.0 + pngjs@5.0.0: {} + postcss-angular@2.0.0(typescript@5.9.3): dependencies: '@phenomnomnominal/tsquery': 5.0.1(typescript@5.9.3) @@ -8881,6 +8952,12 @@ snapshots: qjobs@1.2.0: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + qs@6.13.0: dependencies: side-channel: 1.1.0 @@ -8925,6 +9002,8 @@ snapshots: require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + requires-port@1.0.0: {} resolve-from@4.0.0: {} @@ -9058,6 +9137,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-blocking@2.0.0: {} + setprototypeof@1.2.0: {} shebang-command@2.0.0: @@ -9599,6 +9680,8 @@ snapshots: tr46: 0.0.3 webidl-conversions: 3.0.1 + which-module@2.0.1: {} + which@1.3.1: dependencies: isexe: 2.0.0 @@ -9666,6 +9749,8 @@ snapshots: ws@8.17.1: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} @@ -9676,12 +9761,31 @@ snapshots: yaml@2.8.1: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@20.2.9: {} yargs-parser@21.1.1: {} yargs-parser@22.0.0: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@16.2.0: dependencies: cliui: 7.0.4 diff --git a/src/app/core-logic/auth/auth.service.ts b/src/app/core-logic/auth/auth.service.ts index 17dc14c..5249c20 100644 --- a/src/app/core-logic/auth/auth.service.ts +++ b/src/app/core-logic/auth/auth.service.ts @@ -119,6 +119,7 @@ export class AuthService { this.isAuthenticated = false; // Clear the user role from the user service this._userService.userRole = 'renter'; + this._userService.renterId = undefined; // Return the observable return of({ data: null, @@ -241,6 +242,7 @@ export class AuthService { private _applyRoleFromToken(accessToken: string): void { if (!accessToken) { this._userService.userRole = 'renter'; + this._userService.renterId = undefined; return; } @@ -253,8 +255,10 @@ export class AuthService { } else if (decodedToken.Renter) { this._userService.userRole = 'renter'; } + this._userService.renterId = decodedToken.RenterId?.toString() || undefined; } catch { this._userService.userRole = 'renter'; + this._userService.renterId = undefined; } } @@ -278,5 +282,6 @@ export class AuthService { this._tokenService.clearAllTokens(); this._userService.user = undefined; this._userService.userRole = 'renter'; + this._userService.renterId = undefined; } } diff --git a/src/app/core-logic/bookings/bookings.service.ts b/src/app/core-logic/bookings/bookings.service.ts index b58f736..4f9a9f8 100644 --- a/src/app/core-logic/bookings/bookings.service.ts +++ b/src/app/core-logic/bookings/bookings.service.ts @@ -1,4 +1,14 @@ import { inject, Injectable, signal } from '@angular/core'; +import { catchError, finalize, forkJoin, map, Observable, of, switchMap, tap } from 'rxjs'; +import type { + ApiResponse, + BookingStatus, + BookingVerificationStatus, + CancelCheckinRequest, + CreateBookingRequest, + DepositFeeDtoApiResponse, + RenterProfileDtoApiResponse, +} from '../../../contract'; import { BookingDetailsDto, BookingDetailsDtoListApiResponse, @@ -6,14 +16,13 @@ import { BookingStatus as BookingStatusEnum, RentalDetailsDto, RentalDetailsDtoListApiResponse, + RentalService, RenterProfileDto, RenterProfileDtoListApiResponse, StaffService, VehicleDetailsDto, VehicleDetailsDtoApiResponse, } from '../../../contract'; -import type { BookingStatus, BookingVerificationStatus } from '../../../contract'; -import { catchError, finalize, forkJoin, map, Observable, of, switchMap, tap } from 'rxjs'; export interface StaffBookingRecord { readonly bookingId: string; @@ -38,7 +47,7 @@ type VehicleDetailsMap = Map; export class BookingsService { private readonly _bookingService = inject(BookingService); private readonly _staffService = inject(StaffService); - + private readonly _rentalService = inject(RentalService); private readonly _staffBookings = signal([]); private readonly _loading = signal(false); private readonly _error = signal(null); @@ -47,6 +56,24 @@ export class BookingsService { readonly staffBookingsLoading = this._loading.asReadonly(); readonly staffBookingsError = this._error.asReadonly(); + cancelCheckinBooking( + cancelCheckinRequest: CancelCheckinRequest, + ): Observable { + return this._bookingService.apiBookingCancelPost(cancelCheckinRequest); + } + + createBooking(booking: CreateBookingRequest): Observable { + return this._bookingService.apiBookingPost(booking); + } + + getRenterProfile(renterId: string): Observable { + return this._bookingService + .apiBookingRenterProfileGet(renterId) + .pipe( + map((response: RenterProfileDtoApiResponse) => response.data ?? ({} as RenterProfileDto)), + ); + } + loadStaffBookings(): Observable { this._loading.set(true); this._error.set(null); @@ -94,6 +121,20 @@ export class BookingsService { ); } + /** + * Get bookings for a renter (customer view) + */ + getBookings( + renterId: string, + pageNumber?: number, + pageSize?: number, + ): Observable { + return this._bookingService.apiBookingGet(renterId, pageNumber, pageSize).pipe( + map((response) => response.data?.items ?? []), + catchError(() => of([])), + ); + } + private _fetchRentalsWithVehicleDetails( bookingDtos: readonly BookingDetailsDto[], renterDtos: readonly RenterProfileDto[], diff --git a/src/app/core-logic/payments/payments.service.ts b/src/app/core-logic/payments/payments.service.ts new file mode 100644 index 0000000..0db838c --- /dev/null +++ b/src/app/core-logic/payments/payments.service.ts @@ -0,0 +1,44 @@ +import { inject, Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { + PaymentService, + VnPayPaymentRequestDto, + VnPayPaymentResponseDtoApiResponse, + VnPayReturnDtoApiResponse, +} from '../../../contract'; + +@Injectable({ providedIn: 'root' }) +export class PaymentsService { + private readonly _paymentService = inject(PaymentService); + + /** + * Create a payment for a booking + * @param bookingId The booking ID + * @param amount The payment amount + * @param vehicleName The vehicle name for order description + * @returns Observable of payment response with payment URL + */ + createPayment( + bookingId: string, + amount: number, + vehicleName: string, + ): Observable { + const paymentRequest: VnPayPaymentRequestDto = { + orderId: bookingId, + amount: amount, + orderDescription: `Thanh toán đặt xe ${vehicleName}`, + locale: 'vn', + orderType: 'other', + }; + + return this._paymentService.apiPaymentCreatePost(paymentRequest); + } + + /** + * Get payment return result from VnPay + * @returns Observable of payment return response + */ + getPaymentReturn(): Observable { + return this._paymentService.apiPaymentReturnGet(); + } +} diff --git a/src/app/core-logic/rentals/rentals.service.ts b/src/app/core-logic/rentals/rentals.service.ts index 1397bf4..318b058 100644 --- a/src/app/core-logic/rentals/rentals.service.ts +++ b/src/app/core-logic/rentals/rentals.service.ts @@ -6,6 +6,7 @@ import { ContractDto, RentalDetailsDto, RentalDetailsDtoListApiResponse, + RentalService, RenterProfileDto, RenterProfileDtoListApiResponse, StaffService, @@ -13,7 +14,12 @@ import { VehicleDetailsDtoListApiResponse, VehicleDto, } from '../../../contract'; -import type { BookingStatus, BookingVerificationStatus, RentalStatus } from '../../../contract'; +import type { + BookingStatus, + BookingVerificationStatus, + RentalDetailsDtoApiResponse, + RentalStatus, +} from '../../../contract'; import { catchError, finalize, forkJoin, map, Observable, of, tap } from 'rxjs'; export interface StaffRentalRecord { @@ -46,7 +52,7 @@ type VehicleDetailsMap = Map; @Injectable({ providedIn: 'root' }) export class RentalsService { private readonly _staffService = inject(StaffService); - + private readonly _rentalService = inject(RentalService); private readonly _staffRentals = signal([]); private readonly _loading = signal(false); private readonly _error = signal(null); @@ -55,6 +61,14 @@ export class RentalsService { readonly staffRentalsLoading = this._loading.asReadonly(); readonly staffRentalsError = this._error.asReadonly(); + getRental(rentalId: string): Observable { + return this._rentalService + .apiRentalRentalIdGet(rentalId) + .pipe( + map((response: RentalDetailsDtoApiResponse) => response.data ?? ({} as RentalDetailsDto)), + ); + } + loadStaffRentals(): Observable { this._loading.set(true); this._error.set(null); diff --git a/src/app/core-logic/storage/session.service.ts b/src/app/core-logic/storage/session.service.ts new file mode 100644 index 0000000..31282c1 --- /dev/null +++ b/src/app/core-logic/storage/session.service.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ providedIn: 'root' }) +export class SessionService { + /** + * Store a plain string value + */ + setItem(key: string, value: string): void { + const storage = this.resolveStorage(); + if (!storage) return; + try { + storage.setItem(key, value); + } catch { + // ignore storage errors + } + } + + /** + * Get a plain string value + */ + getItem(key: string): string | null { + const storage = this.resolveStorage(); + if (!storage) return null; + try { + return storage.getItem(key); + } catch { + return null; + } + } + + /** + * Remove an item + */ + removeItem(key: string): void { + const storage = this.resolveStorage(); + if (!storage) return; + try { + storage.removeItem(key); + } catch { + // ignore + } + } + + /** + * Store a JSON-serializable value + */ + setJson(key: string, value: T): void { + try { + const serialized = JSON.stringify(value); + this.setItem(key, serialized); + } catch { + // ignore serialization errors + } + } + + /** + * Retrieve a JSON value, or null if missing/invalid + */ + getJson(key: string): T | null { + const raw = this.getItem(key); + if (!raw) return null; + try { + return JSON.parse(raw) as T; + } catch { + return null; + } + } + + private resolveStorage(): Storage | null { + try { + return typeof sessionStorage === 'undefined' ? null : sessionStorage; + } catch { + return null; + } + } +} diff --git a/src/app/core-logic/token/token.type.ts b/src/app/core-logic/token/token.type.ts index 9f498e6..fd96b56 100644 --- a/src/app/core-logic/token/token.type.ts +++ b/src/app/core-logic/token/token.type.ts @@ -11,4 +11,5 @@ export interface TokenInfo { Renter?: boolean; Admin?: boolean; Staff?: boolean; + RenterId?: string; } diff --git a/src/app/core-logic/user/user.service.ts b/src/app/core-logic/user/user.service.ts index 3c8d04a..3f34112 100644 --- a/src/app/core-logic/user/user.service.ts +++ b/src/app/core-logic/user/user.service.ts @@ -8,6 +8,7 @@ export class UserService { private _user = signal(undefined); private _userRole = signal('renter'); private _accountService = inject(AccountService); + private _renterId = signal(undefined); // ----------------------------------------------------------------------------------------------------- // @ Accessors @@ -32,6 +33,16 @@ export class UserService { set userRole(role: UserRole) { this._userRole.set(role); } + + /** + * Getter & Setter for renter id + */ + get renterId(): string | undefined { + return this._renterId(); + } + set renterId(id: string | undefined) { + this._renterId.set(id); + } // ----------------------------------------------------------------------------------------------------- // @ Public methods // ----------------------------------------------------------------------------------------------------- diff --git a/src/app/features/customer/booking/booking.html b/src/app/features/customer/booking/booking.html new file mode 100644 index 0000000..3b1894d --- /dev/null +++ b/src/app/features/customer/booking/booking.html @@ -0,0 +1,241 @@ +
+
+
+

Đơn đặt xe của bạn

+

+ Theo dõi trạng thái các đơn hiện tại, đơn chờ và lịch sử đặt xe. +

+
+ +
+ + @if (isLoading()) { +
+ +

Đang tải danh sách đơn đặt xe...

+
+ } @else if (error(); as errorMessage) { + + } @else { +
+
+

Đơn đang có

+ @if (activeBookings().length > 0) { +
+ @for (booking of activeBookings(); track trackByBookingId($index, booking)) { +
+
+

+ Đơn #{{ booking.bookingId ?? 'Không xác định' }} +

+ + {{ getStatusLabel(booking.status) }} + +
+ +
+ @if (booking.startTime) { +
+ Thời gian nhận xe + {{ formatDateTime(booking.startTime) }} +
+ } + @if (booking.endTime) { +
+ Thời gian trả xe + {{ formatDateTime(booking.endTime) }} +
+ } + @if (booking.bookingCreatedAt) { +
+ Ngày tạo đơn + {{ formatDateTime(booking.bookingCreatedAt) }} +
+ } +
+ +
+ @if (booking.bookingId) { + + } +
+
+ } +
+ } @else { +

Hiện bạn chưa có đơn nào đang được xử lý.

+ } +
+ +
+

Chờ hoàn tất

+ @if (pendingBookings().length > 0) { +
+ @for (booking of pendingBookings(); track trackByBookingId($index, booking)) { +
+
+

+ Đơn #{{ booking.bookingId ?? 'Không xác định' }} +

+ + {{ getStatusLabel(booking.status) }} + +
+ +
+ @if (booking.startTime) { +
+ Thời gian dự kiến + {{ formatDateTime(booking.startTime) }} +
+ } + @if (booking.bookingCreatedAt) { +
+ Ngày tạo đơn + {{ formatDateTime(booking.bookingCreatedAt) }} +
+ } +
+ +
+ @if (booking.bookingId) { + + } +
+
+ } +
+ } @else { +

Không có đơn nào đang chờ hoàn tất.

+ } +
+ +
+

Lịch sử

+ @if (historyBookings().length > 0) { +
+ @for (booking of historyBookings(); track trackByBookingId($index, booking)) { +
+
+

+ Đơn #{{ booking.bookingId ?? 'Không xác định' }} +

+ + {{ getStatusLabel(booking.status) }} + +
+ +
+ @if (booking.startTime) { +
+ Bắt đầu thuê + {{ formatDateTime(booking.startTime) }} +
+ } + @if (booking.endTime) { +
+ Kết thúc thuê + {{ formatDateTime(booking.endTime) }} +
+ } +
+ +
+ @if (booking.bookingId) { + + } +
+
+ } +
+ } @else { +

Chưa có lịch sử đặt xe nào.

+ } +
+ +
+

Đơn hủy

+ @if (cancelledBookings().length > 0) { +
+ @for (booking of cancelledBookings(); track trackByBookingId($index, booking)) { +
+
+

+ Đơn #{{ booking.bookingId ?? 'Không xác định' }} +

+ + {{ getStatusLabel(booking.status) }} + +
+ +
+ @if (booking.bookingCreatedAt) { +
+ Ngày tạo đơn + {{ formatDateTime(booking.bookingCreatedAt) }} +
+ } + @if (booking.cancelReason) { +
+ Lý do hủy + {{ booking.cancelReason }} +
+ } +
+ +
+ @if (booking.bookingId) { + + } +
+
+ } +
+ } @else { +

Không có đơn nào bị hủy.

+ } +
+
+ } +
diff --git a/src/app/features/customer/booking/booking.routes.ts b/src/app/features/customer/booking/booking.routes.ts index 974044b..31965e2 100644 --- a/src/app/features/customer/booking/booking.routes.ts +++ b/src/app/features/customer/booking/booking.routes.ts @@ -1,24 +1,29 @@ import { Routes } from '@angular/router'; +import { BookingDetail } from './pages/booking-detail/booking-detail'; import { CarList } from './pages/car-list/car-list'; -import { CarDetail } from './pages/car-detail/car-detail'; -import { Checkout } from './pages/checkout/checkout'; -import { Deposit } from './pages/deposit/deposit'; +import { CarsPage } from './pages/cars-page/cars-page'; +import { Payment } from './pages/payment/payment'; +import { BookingsPage } from './booking'; export default [ + { + path: 'cars/:id', + component: CarsPage, + }, { path: 'cars', component: CarList, }, { - path: 'cars/:id', - component: CarDetail, + path: 'bookings', + component: BookingsPage, }, { - path: 'checkout', - component: Checkout, + path: 'bookings/:id', + component: BookingDetail, }, { - path: 'deposit', - component: Deposit, + path: 'payment', + component: Payment, }, ] as Routes; diff --git a/src/app/features/customer/booking/booking.scss b/src/app/features/customer/booking/booking.scss new file mode 100644 index 0000000..f80bdd1 --- /dev/null +++ b/src/app/features/customer/booking/booking.scss @@ -0,0 +1,183 @@ +:host { + display: block; + background: var(--mat-sys-surface); + color: var(--mat-sys-on-surface); + min-height: 100%; +} + +.bookings-container { + display: flex; + flex-direction: column; + gap: 2rem; + padding: 2rem 1rem 3rem; +} + +.bookings-header { + display: flex; + flex-direction: column; + gap: 1rem; + + @media (min-width: 768px) { + align-items: center; + flex-direction: row; + justify-content: space-between; + } +} + +.bookings-header__text { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.bookings-header__title { + font: var(--mat-sys-headline-medium); + margin: 0; +} + +.bookings-header__subtitle { + font: var(--mat-sys-body-medium); + color: var(--mat-sys-on-surface-variant); + margin: 0; +} + +.state { + align-items: center; + border-radius: var(--mat-sys-corner-medium); + border: 1px dashed var(--mat-sys-outline-variant); + display: flex; + flex-direction: column; + gap: 0.75rem; + justify-content: center; + padding: 2rem; + text-align: center; + + mat-icon { + font-size: 2.5rem; + height: 2.5rem; + width: 2.5rem; + } +} + +.state--loading { + color: var(--mat-sys-primary); +} + +.state--error { + color: var(--mat-sys-error); + border-style: solid; +} + +.bookings-sections { + display: flex; + flex-direction: column; + gap: 2.5rem; +} + +.booking-section { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.booking-section__title { + font: var(--mat-sys-title-large); + margin: 0; +} + +.booking-empty { + border-radius: var(--mat-sys-corner-medium); + border: 1px dashed var(--mat-sys-outline-variant); + color: var(--mat-sys-on-surface-variant); + padding: 1.5rem; + text-align: center; +} + +.booking-grid { + display: grid; + gap: 1.25rem; + + @media (min-width: 600px) { + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + } +} + +.booking-card { + background: var(--mat-sys-surface-container-low); + border: 1px solid var(--mat-sys-outline-variant); + border-radius: var(--mat-sys-corner-large); + box-shadow: var(--mat-sys-level1); + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1.5rem; +} + +.booking-card__header { + align-items: flex-start; + display: flex; + gap: 1rem; + justify-content: space-between; +} + +.booking-card__title { + font: var(--mat-sys-title-medium); + margin: 0; + word-break: break-word; +} + +.status-badge { + align-items: center; + border-radius: 999px; + display: inline-flex; + font: var(--mat-sys-label-large); + gap: 0.5rem; + padding: 0.25rem 0.75rem; + text-transform: capitalize; +} + +.status-badge[data-status='pending_Verification'] { + background: var(--mat-sys-secondary-container); + color: var(--mat-sys-on-secondary-container); +} + +.status-badge[data-status='verified'] { + background: var(--mat-sys-primary-container); + color: var(--mat-sys-on-primary-container); +} + +.status-badge[data-status='rental_Created'] { + background: var(--mat-sys-tertiary-container); + color: var(--mat-sys-on-tertiary-container); +} + +.status-badge[data-status='cancelled'] { + background: var(--mat-sys-error-container); + color: var(--mat-sys-on-error-container); +} + +.booking-meta { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.booking-meta__item { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.booking-meta__label { + color: var(--mat-sys-on-surface-variant); + font: var(--mat-sys-label-large); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.booking-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + diff --git a/src/app/features/customer/booking/booking.ts b/src/app/features/customer/booking/booking.ts new file mode 100644 index 0000000..f7a8b9c --- /dev/null +++ b/src/app/features/customer/booking/booking.ts @@ -0,0 +1,184 @@ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + computed, + inject, + signal, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterLink } from '@angular/router'; +import { MatButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { catchError, of, take } from 'rxjs'; +import { BookingDetailsDto, BookingStatus } from '../../../../contract'; +import { UserService } from '../../../core-logic/user/user.service'; +import { BookingsService } from '../../../core-logic/bookings/bookings.service'; + +@Component({ + selector: 'app-bookings-page', + standalone: true, + imports: [CommonModule, RouterLink, MatButton, MatIcon], + templateUrl: './booking.html', + styleUrl: './booking.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'booking-page block', + }, +}) +export class BookingsPage { + private readonly bookingsService = inject(BookingsService); + private readonly userService = inject(UserService); + private readonly destroyRef = inject(DestroyRef); + + private currentRenterId: string | null = null; + + readonly isLoading = signal(true); + readonly error = signal(null); + private readonly allBookings = signal([]); + + readonly activeBookings = computed(() => this.filterBookings([BookingStatus.Verified])); + readonly pendingBookings = computed(() => + this.filterBookings([BookingStatus.PendingVerification]), + ); + readonly historyBookings = computed(() => this.filterBookings([BookingStatus.RentalCreated])); + readonly cancelledBookings = computed(() => this.filterBookings([BookingStatus.Cancelled])); + + constructor() { + const renterId = this.userService.renterId; + if (renterId) { + this.loadBookings(renterId); + return; + } + + this.userService + .getUser() + .pipe(takeUntilDestroyed(this.destroyRef), take(1)) + .subscribe({ + next: () => { + const resolvedRenterId = this.userService.renterId ?? null; + if (!resolvedRenterId) { + this.error.set('Không tìm thấy mã người thuê.'); + this.isLoading.set(false); + return; + } + this.loadBookings(resolvedRenterId); + }, + error: () => { + this.error.set('Không thể tải thông tin người thuê.'); + this.isLoading.set(false); + }, + }); + } + + refresh(): void { + if (!this.currentRenterId) { + this.error.set('Không tìm thấy mã người thuê để tải lại.'); + return; + } + this.loadBookings(this.currentRenterId); + } + + trackByBookingId(_: number, booking: BookingDetailsDto): string { + return booking.bookingId ?? `${_}`; + } + + getStatusLabel(status?: string | null): string { + const normalized = this.normalizeStatus(status); + if (!normalized) { + return 'Không xác định'; + } + return { + [BookingStatus.PendingVerification]: 'Chờ xác minh', + [BookingStatus.Verified]: 'Đã xác minh', + [BookingStatus.RentalCreated]: 'Đã hoàn tất', + [BookingStatus.Cancelled]: 'Đã hủy', + }[normalized]; + } + + statusKey(status?: string | null): string { + return this.normalizeStatus(status) ?? 'unknown'; + } + + formatDateTime(value?: string | null): string { + if (!value) { + return 'Không xác định'; + } + + const parsed = new Date(value); + if (Number.isNaN(parsed.valueOf())) { + return value; + } + + return new Intl.DateTimeFormat('vi-VN', { + dateStyle: 'medium', + timeStyle: 'short', + }).format(parsed); + } + + private loadBookings(renterId: string): void { + this.currentRenterId = renterId; + this.isLoading.set(true); + this.error.set(null); + + this.bookingsService + .getBookings(renterId, undefined, 100) + .pipe( + takeUntilDestroyed(this.destroyRef), + take(1), + catchError(() => { + this.error.set('Không thể tải danh sách đặt xe. Vui lòng thử lại.'); + this.isLoading.set(false); + return of(null); + }), + ) + .subscribe((items) => { + if (!items) { + return; + } + + this.allBookings.set(this.sortBookings(items)); + this.isLoading.set(false); + }); + } + + private filterBookings(statuses: readonly BookingStatus[]): BookingDetailsDto[] { + const allowed = new Set(statuses); + return this.allBookings().filter((booking) => { + const normalized = this.normalizeStatus(booking.status); + if (!normalized) { + return false; + } + return allowed.has(normalized); + }); + } + + private sortBookings(bookings: readonly BookingDetailsDto[]): BookingDetailsDto[] { + return [...(bookings ?? [])].sort( + (first, second) => this.toTime(second.bookingCreatedAt) - this.toTime(first.bookingCreatedAt), + ); + } + + private normalizeStatus(status?: string | null): BookingStatus | null { + if (!status) { + return null; + } + if (this.isKnownStatus(status)) { + return status; + } + return null; + } + + private isKnownStatus(status: string): status is BookingStatus { + return Object.values(BookingStatus).includes(status as BookingStatus); + } + + private toTime(value?: string | null): number { + if (!value) { + return 0; + } + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? 0 : parsed; + } +} diff --git a/src/app/features/customer/booking/pages/booking-detail/booking-detail.html b/src/app/features/customer/booking/pages/booking-detail/booking-detail.html new file mode 100644 index 0000000..fbc3b35 --- /dev/null +++ b/src/app/features/customer/booking/pages/booking-detail/booking-detail.html @@ -0,0 +1,129 @@ +
+
+
+

Đặt cọc hoàn tất

+

Cảm ơn bạn đã đặt xe. Thông tin đặt xe của bạn:

+
+ + @if (isLoading()) { +
+

Đang tải thông tin đặt xe...

+
+ } @else if (error(); as errorMessage) { +
+

{{ errorMessage }}

+
+ } @else if (bookingData(); as data) { +
+
+

+ confirmation_number + Mã đặt xe +

+
+ @if (hasBookingId()) { +
+ +

+ Quét mã QR này khi nhận xe để xác minh đơn đặt xe của bạn. +

+
+ } +
+
+ + +
+

+ directions_car + Thông tin xe +

+
+
+ Tên xe: + {{ vehicleName() }} +
+ @if (data.vehicle.make) { +
+ Hãng: + {{ data.vehicle.make }} +
+ } + @if (data.vehicle.model) { +
+ Model: + {{ data.vehicle.model }} +
+ } + @if (data.vehicle.modelYear) { +
+ Năm sản xuất: + {{ data.vehicle.modelYear }} +
+ } + @if (data.vehicle.rangeKm) { +
+ Phạm vi: + {{ data.vehicle.rangeKm }} km +
+ } +
+
+ + +
+

+ location_on + Thông tin trạm +

+
+
+ Tên trạm: + {{ data.station.name }} +
+
+ Địa chỉ: + {{ data.station.address }} +
+
+
+ + +
+

+ schedule + Lịch trình thuê +

+
+
+ Ngày nhận: + {{ formatDate(data.startDate) }} - {{ data.startTime }} +
+
+ Ngày trả: + {{ formatDate(data.endDate) }} - {{ data.endTime }} +
+
+
+
+ } @else { +
+

Không có thông tin đặt xe.

+
+ } +
+
diff --git a/src/app/features/customer/booking/pages/booking-detail/booking-detail.scss b/src/app/features/customer/booking/pages/booking-detail/booking-detail.scss new file mode 100644 index 0000000..2201209 --- /dev/null +++ b/src/app/features/customer/booking/pages/booking-detail/booking-detail.scss @@ -0,0 +1,79 @@ +.deposit-page { + min-height: 100vh; +} + +.deposit-container { + max-width: 100%; +} + +.deposit-header { + margin-bottom: 1rem; +} + +.deposit-title { + font-size: 1.875rem; + font-weight: 700; + line-height: 2.25rem; + color: var(--mat-sys-on-surface); + margin-bottom: 0.5rem; +} + +.deposit-subtitle { + font-size: 1rem; + color: var(--mat-sys-on-surface-variant); +} + +.deposit-content { + grid-template-columns: 1fr; +} + +.deposit-card { + background: var(--mat-sys-surface); + border-color: var(--mat-sys-outline-variant); +} + +.deposit-card__title { + font-size: 1.25rem; + font-weight: 600; + color: var(--mat-sys-on-surface); + margin: 0; +} + +.deposit-card__content { + color: var(--mat-sys-on-surface-variant); +} + +.deposit-info-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 0; + border-bottom: 1px solid var(--mat-sys-outline-variant); + + &:last-child { + border-bottom: none; + } +} + +.deposit-info-label { + font-weight: 500; + color: var(--mat-sys-on-surface-variant); +} + +.deposit-info-value { + font-weight: 600; + color: var(--mat-sys-on-surface); + text-align: right; +} + +.deposit-empty { + padding: 3rem 1rem; + color: var(--mat-sys-on-surface-variant); +} + +@media (min-width: 768px) { + .deposit-content { + grid-template-columns: repeat(2, 1fr); + } +} + diff --git a/src/app/features/customer/booking/pages/booking-detail/booking-detail.ts b/src/app/features/customer/booking/pages/booking-detail/booking-detail.ts new file mode 100644 index 0000000..fe96c82 --- /dev/null +++ b/src/app/features/customer/booking/pages/booking-detail/booking-detail.ts @@ -0,0 +1,93 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + signal, +} from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { MatIcon } from '@angular/material/icon'; +import { QRCodeComponent } from 'angularx-qrcode'; +import { ActivatedRoute } from '@angular/router'; +import { BookingData } from '../checkout/checkout'; +import { BookingStorageService } from '../../services/booking-storage.service'; +import { map } from 'rxjs'; + +@Component({ + selector: 'app-booking-detail', + standalone: true, + imports: [MatIcon, QRCodeComponent], + templateUrl: './booking-detail.html', + styleUrl: './booking-detail.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BookingDetail { + private readonly route = inject(ActivatedRoute); + private readonly bookingStorage = inject(BookingStorageService); + + private readonly routeBookingId = toSignal( + this.route.paramMap.pipe(map((params) => params.get('id')?.trim() ?? '')), + { + initialValue: '', + }, + ); + + readonly bookingId = computed(() => this.routeBookingId()); + readonly bookingData = signal(null); + readonly isLoading = signal(true); + readonly error = signal(null); + readonly qrLogoUrl = 'hoang-logo-text-dark.webp'; + readonly hasBookingId = computed(() => !!this.bookingId()); + + constructor() { + effect(() => { + const id = this.routeBookingId(); + + if (!id) { + this.bookingData.set(null); + this.error.set(null); + this.isLoading.set(true); + return; + } + + this.isLoading.set(true); + this.error.set(null); + + const draft = this.bookingStorage.loadDraft(id); + if (draft) { + this.bookingData.set(draft); + this.isLoading.set(false); + return; + } + + this.bookingData.set(null); + this.isLoading.set(false); + this.error.set('Không tìm thấy thông tin đặt xe cho mã đã cung cấp.'); + }); + } + + readonly vehicleName = computed(() => { + const vehicle = this.bookingData()?.vehicle; + if (!vehicle) return ''; + + const parts: string[] = []; + if (vehicle.make) parts.push(vehicle.make); + if (vehicle.model) parts.push(vehicle.model); + return parts.length ? parts.join(' ') : 'Xe chưa xác định'; + }); + + readonly formatDate = (date: Date | string | null | undefined): string => { + const normalized = date instanceof Date ? date : date ? new Date(date) : new Date(Number.NaN); + + if (Number.isNaN(normalized.valueOf())) { + return ''; + } + + return new Intl.DateTimeFormat('vi-VN', { + year: 'numeric', + month: 'long', + day: 'numeric', + }).format(normalized); + }; +} diff --git a/src/app/features/customer/booking/pages/car-detail/car-detail.html b/src/app/features/customer/booking/pages/car-detail/car-detail.html index bd24ecd..094e109 100644 --- a/src/app/features/customer/booking/pages/car-detail/car-detail.html +++ b/src/app/features/customer/booking/pages/car-detail/car-detail.html @@ -22,7 +22,7 @@

{{ vehicleName() }}

@for (metric of vehicleMetrics(); track metric.label) {
Chọn lịch trình formGroupName="range" [rangePicker]="bookingRangePicker" [dateFilter]="isDateAvailable" + [min]="today" #rangeInput="matDateRangeInput" (dateChange)="onRangeChange(rangeInput.value, bookingRangePicker, rangeInput)" > @@ -89,11 +90,17 @@

Chọn lịch trình

Chọn giờ @for (hour of hourOptions; track hour) { - {{ hour }} + + {{ hour }} + } @if (form.controls.startTime.invalid && form.controls.startTime.touched) { - Vui lòng chọn giờ nhận. + @if (form.controls.startTime.hasError('invalidPastTime')) { + Giờ nhận đã qua. Vui lòng chọn khung giờ khác. + } @else { + Vui lòng chọn giờ nhận. + } } @@ -102,11 +109,19 @@

Chọn lịch trình

Chọn giờ @for (hour of hourOptions; track hour) { - {{ hour }} + + {{ hour }} + } @if (form.controls.endTime.invalid && form.controls.endTime.touched) { - Vui lòng chọn giờ trả. + @if (form.controls.endTime.hasError('invalidPastTime')) { + Giờ trả không thể ở trong quá khứ. + } @else if (form.controls.endTime.hasError('beforeStartTime')) { + Giờ trả phải sau giờ nhận. + } @else { + Vui lòng chọn giờ trả. + } }
@@ -135,6 +150,12 @@

Chọn lịch trình

Tổng cộng {{ estimate.total | number: '1.0-0' }} ₫
+ @if (depositPrice(); as deposit) { +
+ Tiền đặt cọc + {{ deposit }} +
+ }
} diff --git a/src/app/features/customer/booking/pages/car-detail/car-detail.ts b/src/app/features/customer/booking/pages/car-detail/car-detail.ts index 8363d5a..ec799db 100644 --- a/src/app/features/customer/booking/pages/car-detail/car-detail.ts +++ b/src/app/features/customer/booking/pages/car-detail/car-detail.ts @@ -5,10 +5,13 @@ import { Component, DestroyRef, computed, + effect, inject, + input, + output, signal, } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatNativeDateModule } from '@angular/material/core'; @@ -24,18 +27,8 @@ import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; import { ActivatedRoute } from '@angular/router'; -import { - Observable, - catchError, - distinctUntilChanged, - filter, - map, - of, - switchMap, - tap, -} from 'rxjs'; +import { distinctUntilChanged, filter, map } from 'rxjs'; import { BookingBriefDto, VehicleDetailsDto } from '../../../../../../contract'; -import { StationService } from '../../../../../core-logic/station/station.service'; import { Station } from '../../../../../core-logic/station/station.type'; const HOUR_OPTIONS = Array.from( @@ -71,16 +64,40 @@ const TIME_ORDER_ERROR = 'Giờ trả phải sau giờ nhận.'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class CarDetail { + readonly stations = input.required(); + readonly stepIndex = input(0); + readonly stepKey = input<'details' | 'checkout' | 'payment' | 'deposit'>('details'); + + readonly nextStep = output(); + readonly previousStep = output(); + readonly bookingDataReady = output<{ + vehicle: VehicleDetailsDto; + station: Station; + bookingEstimate: { + days: number; + dayTotal: number; + hours: number; + hourTotal: number; + total: number; + }; + depositPrice: number | null; + startDate: Date; + endDate: Date; + startTime: string; + endTime: string; + }>(); + private readonly cdr = inject(ChangeDetectorRef); private readonly route = inject(ActivatedRoute); - private readonly stationService = inject(StationService); private readonly destroyRef = inject(DestroyRef); readonly hourOptions = HOUR_OPTIONS; + readonly today = this.stripTime(new Date()); readonly rangeError = signal(''); readonly selectedRange = signal | null>(null); readonly vehicle = signal(undefined); readonly stationInfo = signal<{ name: string; address: string } | null>(null); + private _currentStation: Station | null = null; readonly isLoading = signal(true); readonly loadError = signal(null); readonly bookingEstimate = signal<{ @@ -125,6 +142,10 @@ export class CarDetail { this.formatCurrency(this.vehicle()?.rentalPricePerDay ?? undefined), ); + readonly depositPrice = computed(() => + this.formatCurrency(this.vehicle()?.depositPrice ?? undefined), + ); + readonly vehicleMetrics = computed(() => { const details = this.vehicle(); const station = this.stationInfo(); @@ -191,10 +212,16 @@ export class CarDetail { formValid: this.form.valid, hasEstimate: !!this.bookingEstimate(), hasAvailability: this.hasAvailability(), + noRangeError: !this.rangeError(), })); readonly canSubmit = computed(() => { const readiness = this.submitReadiness(); - return readiness.formValid && readiness.hasEstimate && readiness.hasAvailability; + return ( + readiness.formValid && + readiness.hasEstimate && + readiness.hasAvailability && + readiness.noRangeError + ); }); readonly dateClass: MatCalendarCellClassFunction = (cellDate) => @@ -205,59 +232,124 @@ export class CarDetail { return true; } + if (this.isDateBeforeToday(date)) { + return false; + } + return !this.bookedDateKeys().has(this.formatDateKey(date)); }; + readonly isStartHourDisabled = (hour: string): boolean => { + const range = this.form.controls.range.value; + const startDate = range?.start ?? null; + if (!startDate) { + return false; + } + + return this.isDateTimeInPast(startDate, hour); + }; + + readonly getEndHourDisableReason = (hour: string): 'past' | 'beforeStart' | null => { + const range = this.form.controls.range.value; + const endDate = range?.end ?? null; + if (!endDate) { + return null; + } + + const endDateTime = this.combineDateAndTime(endDate, hour); + if (!endDateTime) { + return null; + } + + const now = new Date(); + const endDay = this.stripTime(endDate); + const today = this.stripTime(now); + if (endDay.getTime() < today.getTime()) { + return 'past'; + } + if (endDay.getTime() === today.getTime() && endDateTime.getTime() <= now.getTime()) { + return 'past'; + } + + const startDate = range?.start ?? null; + const startTime = this.form.controls.startTime.value ?? ''; + if (startDate && startTime) { + const startDateTime = this.combineDateAndTime(startDate, startTime); + if (startDateTime && endDateTime.getTime() <= startDateTime.getTime()) { + return 'beforeStart'; + } + } + + return null; + }; + + readonly isEndHourDisabled = (hour: string): boolean => + this.getEndHourDisableReason(hour) !== null; + + private readonly vehicleId = toSignal( + this.route.paramMap.pipe( + map((params) => params.get('id')?.trim() ?? ''), + filter((id) => id.length > 0), + distinctUntilChanged(), + ), + { initialValue: '' }, + ); + constructor() { - this.route.paramMap - .pipe( - map((params) => params.get('id')?.trim() ?? ''), - filter((id) => id.length > 0), - distinctUntilChanged(), - tap(() => { + // Effect to load vehicle when vehicleId or stations change + effect(() => { + const vehicleId = this.vehicleId(); + const stations = this.stations(); + + // Only proceed if we have both vehicleId and stations + if (!vehicleId || stations.length === 0) { + // If we have vehicleId but no stations yet, show loading + if (vehicleId && stations.length === 0) { this.isLoading.set(true); this.loadError.set(null); - }), - switchMap((vehicleId) => - this._loadStationsIfNeeded().pipe( - map((stations) => this._findVehicleInStations(stations, vehicleId)), - ), - ), - takeUntilDestroyed(), - ) - .subscribe((result) => { - this.isLoading.set(false); - - if (!result) { - this.vehicle.set(undefined); - this.stationInfo.set(null); - if (!this.loadError()) { - this.loadError.set('Không tìm thấy thông tin xe.'); - } - this.cdr.markForCheck(); - return; } + this.cdr.markForCheck(); + return; + } - this.vehicle.set(result.vehicle); - this.stationInfo.set({ - name: result.station.name, - address: result.station.address, - }); - this.loadError.set(null); + // Both vehicleId and stations are available, try to find vehicle + this.isLoading.set(true); + this.loadError.set(null); - const available = this.vehicleHasFullDayAvailability(result.vehicle); - if (!available) { - this.loadError.set('Xe hiện không còn ngày trống tối thiểu 1 ngày.'); - } else { - this.loadError.set(null); - if (available) { - this.resetFormState(); - this.updateBookingEstimate(); - } - } + const result = this._findVehicleInStations(stations, vehicleId); + this.isLoading.set(false); + + if (!result) { + this.vehicle.set(undefined); + this.stationInfo.set(null); + this.loadError.set('Không tìm thấy thông tin xe.'); this.cdr.markForCheck(); + return; + } + + this.vehicle.set(result.vehicle); + this.stationInfo.set({ + name: result.station.name, + address: result.station.address, }); + // Store full station object for later use + this._currentStation = result.station; + this.loadError.set(null); + + const available = this.vehicleHasFullDayAvailability(result.vehicle); + if (!available) { + this.loadError.set('Xe hiện không còn ngày trống tối thiểu 1 ngày.'); + } else { + this.loadError.set(null); + if (available) { + this.resetFormState(); + this.updateBookingEstimate(); + } + } + + this.cdr.markForCheck(); + }); const rangeGroup = this.form.controls.range; const startCtrl = rangeGroup.controls.start; @@ -267,9 +359,52 @@ export class CarDetail { const start = val?.start ?? null; const end = val?.end ?? null; - startCtrl.setErrors(null); - endCtrl.setErrors(null); - this.rangeError.set(''); + // Clear only range conflict errors, preserve TIME_ORDER_ERROR if exists + const currentError = this.rangeError(); + const isTimeOrderError = currentError === TIME_ORDER_ERROR; + + // Clear control errors related to range conflict + const startErrors = startCtrl.errors; + const endErrors = endCtrl.errors; + if (startErrors?.['rangeConflict']) { + const restStartErrors = { ...startErrors }; + delete restStartErrors['rangeConflict']; + startCtrl.setErrors(Object.keys(restStartErrors).length > 0 ? restStartErrors : null); + } + if (endErrors?.['rangeConflict']) { + const restEndErrors = { ...endErrors }; + delete restEndErrors['rangeConflict']; + endCtrl.setErrors(Object.keys(restEndErrors).length > 0 ? restEndErrors : null); + } + + // Clear range error only if it's not TIME_ORDER_ERROR + if (!isTimeOrderError) { + this.rangeError.set(''); + } + + if (start && this.isDateBeforeToday(start)) { + const errKey = 'pastDate'; + this.rangeError.set('Ngày nhận không thể ở trong quá khứ.'); + startCtrl.setErrors({ ...(startCtrl.errors ?? {}), [errKey]: true }); + startCtrl.markAsTouched(); + startCtrl.markAsDirty(); + } else if (startCtrl.errors?.['pastDate']) { + const restStartErrors = { ...startCtrl.errors }; + delete restStartErrors['pastDate']; + startCtrl.setErrors(Object.keys(restStartErrors).length > 0 ? restStartErrors : null); + } + + if (end && this.isDateBeforeToday(end)) { + const errKey = 'pastDate'; + this.rangeError.set('Ngày trả không thể ở trong quá khứ.'); + endCtrl.setErrors({ ...(endCtrl.errors ?? {}), [errKey]: true }); + endCtrl.markAsTouched(); + endCtrl.markAsDirty(); + } else if (endCtrl.errors?.['pastDate']) { + const restEndErrors = { ...endCtrl.errors }; + delete restEndErrors['pastDate']; + endCtrl.setErrors(Object.keys(restEndErrors).length > 0 ? restEndErrors : null); + } if (start && end && this.rangeContainsBooked(start, end)) { const errKey = 'rangeConflict'; @@ -290,16 +425,34 @@ export class CarDetail { this.selectedRange.set(null); } + if ( + this.rangeError() && + this.rangeError() !== TIME_ORDER_ERROR && + !startCtrl.errors && + !endCtrl.errors + ) { + this.rangeError.set(''); + } + + this.enforceStartTimeValidity(); + this.enforceEndTimeValidity(); this.updateBookingEstimate(); this.cdr.markForCheck(); }); this.form.controls.startTime.valueChanges .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => this.updateBookingEstimate()); + .subscribe(() => { + this.enforceStartTimeValidity(); + this.enforceEndTimeValidity(); + this.updateBookingEstimate(); + }); this.form.controls.endTime.valueChanges .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => this.updateBookingEstimate()); + .subscribe(() => { + this.enforceEndTimeValidity(); + this.updateBookingEstimate(); + }); } onRangeChange( @@ -326,6 +479,22 @@ export class CarDetail { return; } + if (this.isDateBeforeToday(start)) { + this.rangeError.set('Ngày nhận không thể ở trong quá khứ.'); + this.markRangeControlInvalid(input, 'pastDate'); + this.selectedRange.set(range); + picker.close(); + return; + } + + if (this.isDateBeforeToday(end)) { + this.rangeError.set('Ngày trả không thể ở trong quá khứ.'); + this.markRangeControlInvalid(input, 'pastDate'); + this.selectedRange.set(range); + picker.close(); + return; + } + this.rangeError.set(''); this.selectedRange.set(range); this.clearRangeControlState(input); @@ -340,11 +509,43 @@ export class CarDetail { submitBooking(): void { this.form.markAllAsTouched(); - if (this.form.invalid) { + if (this.form.invalid || !this.canSubmit()) { + return; + } + + const vehicle = this.vehicle(); + const station = this.stationInfo(); + const estimate = this.bookingEstimate(); + const formValue = this.form.value; + + if (!vehicle || !station || !estimate || !formValue.range?.start || !formValue.range?.end) { return; } - // Placeholder for future booking submission logic. + // Find station object from stations input + let stationObj = this.stations().find((s) => s.name === station.name); + if (!stationObj && this._currentStation) { + // Fallback: try to get from stored station + stationObj = this.stations().find((s) => s.id === this._currentStation?.id); + } + if (!stationObj) { + return; + } + + // Emit booking data to parent + this.bookingDataReady.emit({ + vehicle, + station: stationObj, + bookingEstimate: estimate, + depositPrice: vehicle.depositPrice ?? null, + startDate: formValue.range.start, + endDate: formValue.range.end, + startTime: formValue.startTime ?? '', + endTime: formValue.endTime ?? '', + }); + + // After successful validation, move to next step + this.nextStep.emit(); } private resetFormState(): void { @@ -358,6 +559,8 @@ export class CarDetail { }); this.rangeError.set(''); this.selectedRange.set(null); + this.form.controls.startTime.setErrors(null); + this.form.controls.endTime.setErrors(null); } private updateBookingEstimate(): void { @@ -506,12 +709,93 @@ export class CarDetail { return combined; } - private markRangeControlInvalid(input: MatDateRangeInput): void { + private isDateTimeInPast(date: Date, time: string): boolean { + const dateTime = this.combineDateAndTime(date, time); + if (!dateTime) { + return false; + } + return dateTime.getTime() <= Date.now(); + } + + private isDateBeforeToday(date: Date): boolean { + const today = this.stripTime(new Date()); + return this.stripTime(date).getTime() < today.getTime(); + } + + private enforceStartTimeValidity(): void { + const control = this.form.controls.startTime; + const value = control.value ?? ''; + if (!value) { + return; + } + + if (this.isStartHourDisabled(value)) { + this.invalidateTimeControl(control, 'invalidPastTime'); + return; + } + + this.clearControlError(control, 'invalidPastTime'); + } + + private enforceEndTimeValidity(): void { + const control = this.form.controls.endTime; + const value = control.value ?? ''; + if (!value) { + return; + } + + const reason = this.getEndHourDisableReason(value); + if (!reason) { + this.clearControlError(control, 'invalidPastTime'); + this.clearControlError(control, 'beforeStartTime'); + return; + } + + if (reason === 'past') { + this.invalidateTimeControl(control, 'invalidPastTime'); + this.clearControlError(control, 'beforeStartTime'); + return; + } + + this.invalidateTimeControl(control, 'beforeStartTime'); + this.clearControlError(control, 'invalidPastTime'); + } + + private invalidateTimeControl(control: FormControl, errorKey: string): void { + control.setValue('', { emitEvent: false }); + this.setControlError(control, errorKey); + control.markAsTouched(); + control.markAsDirty(); + } + + private setControlError(control: FormControl, errorKey: string): void { + const errors = control.errors ?? {}; + if (errors[errorKey]) { + return; + } + control.setErrors({ ...errors, [errorKey]: true }); + } + + private clearControlError(control: FormControl, errorKey: string): void { + const errors = control.errors; + if (!errors?.[errorKey]) { + return; + } + + const updated = { ...errors }; + delete updated[errorKey]; + control.setErrors(Object.keys(updated).length ? updated : null); + } + + private markRangeControlInvalid( + input: MatDateRangeInput, + errorKey = 'rangeConflict', + ): void { const control = input.ngControl?.control; if (!control) { return; } - control.setErrors({ rangeConflict: true }); + control.setErrors({ ...(control.errors ?? {}), [errorKey]: true }); control.markAsTouched(); control.markAsDirty(); } @@ -548,20 +832,6 @@ export class CarDetail { return keys; } - private _loadStationsIfNeeded(): Observable { - const existing = this.stationService.stations; - if (existing.length > 0) { - return of(existing); - } - - return this.stationService.getStations().pipe( - catchError(() => { - this.loadError.set('Không thể tải danh sách trạm. Vui lòng thử lại sau.'); - return of([]); - }), - ); - } - private _findVehicleInStations( stations: Station[], identifier: string, diff --git a/src/app/features/customer/booking/pages/car-list/car-list.html b/src/app/features/customer/booking/pages/car-list/car-list.html index accab8a..a18b231 100644 --- a/src/app/features/customer/booking/pages/car-list/car-list.html +++ b/src/app/features/customer/booking/pages/car-list/car-list.html @@ -5,7 +5,7 @@ Trạm Tất cả trạm - @for (station of stations(); track station.id) { + @for (station of stationsForFilter(); track station.id) { {{ station.name }} } diff --git a/src/app/features/customer/booking/pages/car-list/car-list.ts b/src/app/features/customer/booking/pages/car-list/car-list.ts index 3a15d50..e21d763 100644 --- a/src/app/features/customer/booking/pages/car-list/car-list.ts +++ b/src/app/features/customer/booking/pages/car-list/car-list.ts @@ -2,7 +2,11 @@ import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { CarCard } from '../../components/car-card/car-card'; -import { BookingBriefDto, VehicleDetailsDto } from '../../../../../../contract'; +import { + BookingBriefDto, + VehicleDetailsDto, + VehicleAtStationStatus, +} from '../../../../../../contract'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSelectModule } from '@angular/material/select'; import { MatDatepickerModule } from '@angular/material/datepicker'; @@ -13,6 +17,7 @@ import { StationService } from '../../../../../core-logic/station/station.servic @Component({ selector: 'app-car-list', + standalone: true, imports: [ CarCard, ReactiveFormsModule, @@ -45,8 +50,7 @@ export class CarList { }); constructor() { - // Stations are typically loaded by AuthService during login/session restore. - // This is a fallback in case stations haven't been loaded yet (e.g., race condition, direct navigation). + // Load stations if not already loaded if (this._stationService.stations.length === 0) { this._stationService.getStations().subscribe({ next: () => { @@ -104,7 +108,7 @@ export class CarList { }); // Get stations with id and name for filter dropdown - readonly stations = computed(() => { + readonly stationsForFilter = computed(() => { const stationIds = new Set(); // Collect unique station IDs from carsWithStation @@ -137,6 +141,11 @@ export class CarList { return this.carsWithStation().filter((item) => { const { car, stationId: carStationId } = item; + // Show only vehicles that are currently available + if (car.status !== VehicleAtStationStatus.Available) { + return false; + } + // Filter by station ID if (stationId !== 'all' && carStationId !== stationId) { return false; diff --git a/src/app/features/customer/booking/pages/cars-page/cars-page.html b/src/app/features/customer/booking/pages/cars-page/cars-page.html new file mode 100644 index 0000000..46f3f22 --- /dev/null +++ b/src/app/features/customer/booking/pages/cars-page/cars-page.html @@ -0,0 +1,58 @@ +
+ + + @if (!isFirstStep()) { + Thông tin xe + } @else { +
+ +
+ } +
+ + + Xác nhận thông tin +
+ +
+
+ + + Thanh toán +
+
+

Đang chuyển hướng đến trang thanh toán...

+
+
+
+ + + Đặt cọc hoàn tất +
+
+

Đang chuyển hướng tới trang chi tiết đặt xe của bạn...

+
+
+
+
+
diff --git a/src/app/features/customer/booking/pages/cars-page/cars-page.scss b/src/app/features/customer/booking/pages/cars-page/cars-page.scss new file mode 100644 index 0000000..2333576 --- /dev/null +++ b/src/app/features/customer/booking/pages/cars-page/cars-page.scss @@ -0,0 +1,16 @@ +:host { + .booking-stepper__hidden-header { + ::ng-deep .mat-step-header { + display: none !important; + } + + ::ng-deep .mat-stepper-horizontal-line { + display: none !important; + } + + ::ng-deep .mat-stepper-header-container { + display: none !important; + } + } +} + diff --git a/src/app/features/customer/booking/pages/cars-page/cars-page.ts b/src/app/features/customer/booking/pages/cars-page/cars-page.ts new file mode 100644 index 0000000..159b648 --- /dev/null +++ b/src/app/features/customer/booking/pages/cars-page/cars-page.ts @@ -0,0 +1,240 @@ +import { + ChangeDetectionStrategy, + Component, + ViewChild, + computed, + DestroyRef, + inject, + signal, + afterNextRender, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, Router } from '@angular/router'; +import { StepperSelectionEvent } from '@angular/cdk/stepper'; +import { MatStep, MatStepLabel, MatStepper } from '@angular/material/stepper'; +import { catchError, of } from 'rxjs'; +import { Checkout, BookingData } from '../checkout/checkout'; +import { CarDetail } from '../car-detail/car-detail'; +import { StationService } from '../../../../../core-logic/station/station.service'; +import { PaymentsService } from '../../../../../core-logic/payments/payments.service'; +import { Station } from '../../../../../core-logic/station/station.type'; + +interface BookingStep { + readonly label: string; + readonly key: 'details' | 'checkout' | 'payment' | 'deposit'; +} + +type StepKey = BookingStep['key']; + +@Component({ + selector: 'app-cars-page', + standalone: true, + imports: [MatStepper, MatStep, MatStepLabel, CarDetail, Checkout], + templateUrl: './cars-page.html', + styleUrl: './cars-page.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'booking-page block', + }, +}) +export class CarsPage { + @ViewChild(MatStepper) private matStepper?: MatStepper; + + private readonly stationService = inject(StationService); + private readonly paymentsService = inject(PaymentsService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + + readonly steps: BookingStep[] = [ + { key: 'details', label: 'Thông tin xe' }, + { key: 'checkout', label: 'Xác nhận thông tin' }, + { key: 'payment', label: 'Thanh toán' }, + { key: 'deposit', label: 'Đặt cọc hoàn tất' }, + ]; + + readonly activeStep = signal(0); + readonly stations = signal([]); + readonly bookingData = signal(null); + readonly paymentStarted = signal(false); + + readonly isFirstStep = computed(() => this.activeStep() === 0); + readonly detailsCompleted = computed(() => !!this.bookingData()); + + constructor() { + // Load stations if not already loaded (for car-detail) + if (this.stationService.stations.length === 0) { + this.stationService.getStations().subscribe({ + next: (stations: Station[]) => { + this.stations.set(stations); + }, + error: () => { + // Silently ignore errors. They can be retried later. + }, + }); + } else { + this.stations.set(this.stationService.stations); + } + + // Handle payment return only; ignore external step changes via URL + this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => { + // Check if this is a payment return (VnPay usually returns with vnp_* params) + const hasPaymentParams = Object.keys(params).some((key) => key.startsWith('vnp_')); + if (hasPaymentParams) { + this.handlePaymentReturn(); + } + }); + + this.route.fragment.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((fragment) => { + const normalized = fragment?.trim().toLowerCase(); + if (!normalized) { + return; + } + + const targetIndex = this.steps.findIndex((step) => step.key === normalized); + if (targetIndex < 0 || targetIndex === this.activeStep()) { + return; + } + + this.setActiveStep(targetIndex, { updateFragment: false }); + }); + } + + private handlePaymentReturn(): void { + this.paymentsService + .getPaymentReturn() + .pipe( + catchError(() => { + // On error, go back to checkout step + this.setActiveStep(1); + // Clear query params + this.router.navigate([], { + relativeTo: this.route, + queryParams: {}, + replaceUrl: true, + }); + return of(null); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((response) => { + if (!response) { + return; + } + + // Clear query params + this.router.navigate([], { + relativeTo: this.route, + queryParams: {}, + replaceUrl: true, + }); + + if (response.isSuccess && response.data?.isSuccess) { + const bookingId = response.data.orderId?.trim(); + if (bookingId) { + this.setActiveStep(3); + this.router.navigate(['/bookings', bookingId]); + return; + } + + // Missing booking id - fall back to checkout step + this.setActiveStep(1); + return; + } else { + // Payment failed - go back to checkout step + this.setActiveStep(1); + } + }); + } + + onStepSelectionChange(event: StepperSelectionEvent): void { + const nextIndex = event.selectedIndex; + if (nextIndex !== this.activeStep()) { + this.activeStep.set(nextIndex); + } + } + + goToNextStep(): void { + const currentIndex = this.activeStep(); + // Gate transitions by step + if (currentIndex === 0 && !this.detailsCompleted()) { + return; + } + if (currentIndex === 1 && !this.paymentStarted()) { + return; + } + + if (currentIndex >= this.steps.length - 1) { + return; + } + + this.setActiveStep(currentIndex + 1); + } + + goToPreviousStep(): void { + const currentIndex = this.activeStep(); + if (currentIndex <= 0) { + return; + } + this.setActiveStep(currentIndex - 1); + } + + onBookingDataReady(data: BookingData): void { + this.bookingData.set(data); + } + + // Called from checkout when payment url is ready (via nextStep emission) + // We mark that payment has begun so step 1 can be considered completed. + // The actual navigation to the payment provider is handled in checkout. + markPaymentStarted(): void { + this.paymentStarted.set(true); + } + + private setActiveStep(nextIndex: number, options: { updateFragment?: boolean } = {}): void { + const previousIndex = this.activeStep(); + const boundedIndex = Math.max(0, Math.min(this.steps.length - 1, nextIndex)); + const shouldUpdateFragment = options?.updateFragment !== false; + + this.activeStep.set(boundedIndex); + + if (boundedIndex === 0) { + this.paymentStarted.set(false); + } + if (boundedIndex < previousIndex && boundedIndex <= 1) { + this.paymentStarted.set(false); + } + + const renderRef = afterNextRender({ + read: () => { + if (!this.matStepper) { + return; + } + + const desiredIndex = this.activeStep(); + this.matStepper.selectedIndex = desiredIndex; + + const actualIndex = this.matStepper.selectedIndex; + if (shouldUpdateFragment) { + const key = this.steps[actualIndex]?.key; + if (key) { + this.updateFragment(key); + } + } + + if (actualIndex !== desiredIndex) { + this.activeStep.set(actualIndex); + } + }, + }); + this.destroyRef.onDestroy(() => renderRef.destroy()); + } + + private updateFragment(step: StepKey): void { + this.router.navigate([], { + relativeTo: this.route, + fragment: step, + queryParamsHandling: 'preserve', + replaceUrl: true, + }); + } +} diff --git a/src/app/features/customer/booking/pages/checkout/checkout.html b/src/app/features/customer/booking/pages/checkout/checkout.html index b886b7d..90052ca 100644 --- a/src/app/features/customer/booking/pages/checkout/checkout.html +++ b/src/app/features/customer/booking/pages/checkout/checkout.html @@ -1 +1,196 @@ -

checkout works!

+
+
+
+

Xác nhận thông tin đặt xe

+

Vui lòng kiểm tra lại thông tin trước khi xác nhận

+
+ + @if (bookingData(); as data) { +
+ +
+

+ person + Thông tin khách hàng +

+
+
+ Họ và tên: + {{ userName() || 'Chưa cập nhật' }} +
+
+ Số điện thoại: + {{ phoneNumber() || 'Chưa cập nhật' }} +
+
+
+ + +
+

+ directions_car + Thông tin xe +

+
+
+ Tên xe: + {{ vehicleName() }} +
+ @if (data.vehicle.make) { +
+ Hãng: + {{ data.vehicle.make }} +
+ } + @if (data.vehicle.model) { +
+ Model: + {{ data.vehicle.model }} +
+ } + @if (data.vehicle.modelYear) { +
+ Năm sản xuất: + {{ data.vehicle.modelYear }} +
+ } + @if (data.vehicle.rangeKm) { +
+ Phạm vi: + {{ data.vehicle.rangeKm }} km +
+ } +
+
+ + +
+

+ location_on + Thông tin trạm +

+
+
+ Tên trạm: + {{ data.station.name }} +
+
+ Địa chỉ: + {{ data.station.address }} +
+
+
+ + +
+

+ schedule + Lịch trình thuê +

+
+
+ Ngày nhận: + {{ formatDate(data.startDate) }} - {{ data.startTime }} +
+
+ Ngày trả: + {{ formatDate(data.endDate) }} - {{ data.endTime }} +
+
+ Số ngày thuê: + {{ data.bookingEstimate.days }} ngày +
+
+ Số giờ thuê: + {{ data.bookingEstimate.hours }} giờ +
+
+
+ + +
+

+ payments + Tổng thanh toán +

+
+
+ Tiền ngày ({{ data.bookingEstimate.days }} ngày): + {{ + formatCurrency(data.bookingEstimate.dayTotal) + }} +
+
+ Tiền giờ ({{ data.bookingEstimate.hours }} giờ): + {{ + formatCurrency(data.bookingEstimate.hourTotal) + }} +
+ @if (data.depositPrice) { +
+ Tiền đặt cọc: + {{ formatCurrency(data.depositPrice) }} +
+ } +
+
+ Tổng cộng: + {{ + formatCurrency(data.bookingEstimate.total) + }} +
+
+
+
+ + + @if (error()) { +
+

{{ error() }}

+
+ } + + +
+ + +
+ } @else { +
+

Không có thông tin đặt xe. Vui lòng quay lại bước trước.

+ +
+ } +
+
diff --git a/src/app/features/customer/booking/pages/checkout/checkout.scss b/src/app/features/customer/booking/pages/checkout/checkout.scss index e69de29..5daf7ff 100644 --- a/src/app/features/customer/booking/pages/checkout/checkout.scss +++ b/src/app/features/customer/booking/pages/checkout/checkout.scss @@ -0,0 +1,109 @@ +.checkout-page { + min-height: 100vh; +} + +.checkout-container { + max-width: 100%; +} + +.checkout-header { + margin-bottom: 1rem; +} + +.checkout-title { + font-size: 1.875rem; + font-weight: 700; + line-height: 2.25rem; + color: var(--mat-sys-on-surface); + margin-bottom: 0.5rem; +} + +.checkout-subtitle { + font-size: 1rem; + color: var(--mat-sys-on-surface-variant); +} + +.checkout-content { + grid-template-columns: 1fr; +} + +.checkout-card { + background: var(--mat-sys-surface); + border-color: var(--mat-sys-outline-variant); + + &--total { + background: var(--mat-sys-primary-container); + border-color: var(--mat-sys-primary); + } +} + +.checkout-card__title { + font-size: 1.25rem; + font-weight: 600; + color: var(--mat-sys-on-surface); + margin: 0; +} + +.checkout-card__content { + color: var(--mat-sys-on-surface-variant); +} + +.checkout-info-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 0; + border-bottom: 1px solid var(--mat-sys-outline-variant); + + &:last-child { + border-bottom: none; + } + + &--total { + font-weight: 700; + font-size: 1.125rem; + padding-top: 1rem; + margin-top: 0.5rem; + border-top: 2px solid var(--mat-sys-outline); + border-bottom: none; + } +} + +.checkout-info-label { + font-weight: 500; + color: var(--mat-sys-on-surface-variant); +} + +.checkout-info-value { + font-weight: 600; + color: var(--mat-sys-on-surface); + text-align: right; +} + +.checkout-divider { + height: 1px; + background: var(--mat-sys-outline-variant); + margin: 0.5rem 0; +} + +.checkout-actions { + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid var(--mat-sys-outline-variant); +} + +.checkout-empty { + padding: 3rem 1rem; + color: var(--mat-sys-on-surface-variant); +} + +@media (min-width: 768px) { + .checkout-content { + grid-template-columns: repeat(2, 1fr); + } + + .checkout-card--total { + grid-column: 1 / -1; + } +} + diff --git a/src/app/features/customer/booking/pages/checkout/checkout.ts b/src/app/features/customer/booking/pages/checkout/checkout.ts index 448b96e..03cd670 100644 --- a/src/app/features/customer/booking/pages/checkout/checkout.ts +++ b/src/app/features/customer/booking/pages/checkout/checkout.ts @@ -1,9 +1,235 @@ -import { Component } from '@angular/core'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + computed, + inject, + input, + output, + signal, +} from '@angular/core'; +import { MatButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { UserService } from '../../../../../core-logic/user/user.service'; +import { BookingsService } from '../../../../../core-logic/bookings/bookings.service'; +import { PaymentsService } from '../../../../../core-logic/payments/payments.service'; +import { Currency, CreateBookingRequest } from '../../../../../../contract'; +import { VehicleDetailsDto } from '../../../../../../contract'; +import { Station } from '../../../../../core-logic/station/station.type'; +import { catchError, of, switchMap } from 'rxjs'; +import { BookingStorageService } from '../../services/booking-storage.service'; + +export interface BookingData { + vehicle: VehicleDetailsDto; + station: Station; + bookingEstimate: { + days: number; + dayTotal: number; + hours: number; + hourTotal: number; + total: number; + }; + depositPrice: number | null; + startDate: Date; + endDate: Date; + startTime: string; + endTime: string; +} @Component({ selector: 'app-checkout', - imports: [], + standalone: true, + imports: [MatButton, MatIcon], templateUrl: './checkout.html', styleUrl: './checkout.scss', + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class Checkout {} +export class Checkout { + private readonly userService = inject(UserService); + private readonly bookingsService = inject(BookingsService); + private readonly paymentsService = inject(PaymentsService); + private readonly cdr = inject(ChangeDetectorRef); + private readonly bookingStorage = inject(BookingStorageService); + + readonly stepIndex = input(0); + readonly stepKey = input<'details' | 'checkout' | 'payment' | 'deposit'>('checkout'); + readonly bookingData = input(null); + + readonly nextStep = output(); + readonly previousStep = output(); + + readonly isLoading = signal(false); + readonly error = signal(null); + + readonly isActiveStep = computed(() => this.stepKey() === 'checkout'); + + readonly user = computed(() => this.userService.user); + readonly userName = computed(() => this.user()?.userName ?? ''); + readonly phoneNumber = computed(() => this.user()?.phoneNumber ?? ''); + + readonly vehicleName = computed(() => { + const vehicle = this.bookingData()?.vehicle; + if (!vehicle) return ''; + + const parts: string[] = []; + if (vehicle.make) parts.push(vehicle.make); + if (vehicle.model) parts.push(vehicle.model); + return parts.length ? parts.join(' ') : 'Xe chưa xác định'; + }); + + readonly formatCurrency = (value: number | null | undefined): string => { + if (value === null || value === undefined) return '0 ₫'; + return new Intl.NumberFormat('vi-VN', { + style: 'currency', + currency: 'VND', + maximumFractionDigits: 0, + }).format(value); + }; + + readonly formatDate = (date: Date): string => { + return new Intl.DateTimeFormat('vi-VN', { + year: 'numeric', + month: 'long', + day: 'numeric', + }).format(date); + }; + + private combineDateAndTime(date: Date, time: string): Date | null { + if (!time) { + return null; + } + + const [hoursStr, minutesStr = '0'] = time.split(':'); + const hours = Number.parseInt(hoursStr ?? '', 10); + const minutes = Number.parseInt(minutesStr ?? '', 10); + + if (Number.isNaN(hours) || Number.isNaN(minutes)) { + return null; + } + + const combined = new Date(date); + combined.setHours(hours, minutes, 0, 0); + return combined; + } + + private formatDateTimeISO(date: Date | null): string | undefined { + if (!date) return undefined; + return date.toISOString(); + } + + confirmBooking(): void { + const data = this.bookingData(); + const renterId = this.userService.renterId; + + if (!data || !renterId) { + this.error.set('Thiếu thông tin đặt xe hoặc mã người thuê.'); + return; + } + + const startDateTime = this.combineDateAndTime(data.startDate, data.startTime); + const endDateTime = this.combineDateAndTime(data.endDate, data.endTime); + + if (!startDateTime || !endDateTime) { + this.error.set('Thông tin thời gian không hợp lệ.'); + return; + } + + const request: CreateBookingRequest = { + renterId, + vehicleAtStationId: data.vehicle.vehicleAtStationId ?? undefined, + startTime: this.formatDateTimeISO(startDateTime), + endTime: this.formatDateTimeISO(endDateTime), + depositAmount: data.depositPrice ?? undefined, + depositCurrency: data.depositPrice ? Currency.Vnd : undefined, + depositDescription: data.depositPrice + ? `Đặt cọc cho thuê xe ${this.vehicleName()}` + : undefined, + // Payment fields will be handled in deposit step + paymentMethod: undefined, + amountPaid: undefined, + paidAt: undefined, + providerReference: undefined, + }; + + this.isLoading.set(true); + this.error.set(null); + this.cdr.markForCheck(); + + this.bookingsService + .createBooking(request) + .pipe( + switchMap((bookingResponse) => { + if (!bookingResponse?.isSuccess) { + throw new Error(bookingResponse?.message ?? 'Không thể tạo đặt xe.'); + } + + // Extract bookingId from response + // The data field might be a string (bookingId) or an object with bookingId + let bookingId: string | undefined; + if (typeof bookingResponse.data === 'string') { + bookingId = bookingResponse.data; + } else if (bookingResponse.data && typeof bookingResponse.data === 'object') { + bookingId = (bookingResponse.data as { bookingId?: string }).bookingId; + } + + if (!bookingId) { + throw new Error('Không thể lấy mã đặt xe từ phản hồi.'); + } + + this.bookingStorage.saveDraft(bookingId, data); + + // Create payment using PaymentsService + return this.paymentsService.createPayment( + bookingId, + data.bookingEstimate.total, + this.vehicleName(), + ); + }), + catchError((error: unknown) => { + const errorMessage = + error instanceof Error ? error.message : 'Không thể tạo thanh toán. Vui lòng thử lại.'; + this.error.set(errorMessage); + this.isLoading.set(false); + this.cdr.markForCheck(); + return of(null); + }), + ) + .subscribe({ + next: (paymentResponse) => { + if (!paymentResponse) { + return; + } + + if (paymentResponse.isSuccess && paymentResponse.data?.paymentUrl) { + // Emit nextStep to notify parent to move to payment step + // Use setTimeout to ensure parent processes the event before redirect + this.nextStep.emit(); + const paymentUrl = paymentResponse.data.paymentUrl.trim(); + if (paymentUrl) { + // Small delay to allow parent to update step before redirect + setTimeout(() => { + window.location.href = paymentUrl; + }, 100); + } else { + this.error.set('URL thanh toán không hợp lệ.'); + this.isLoading.set(false); + this.cdr.markForCheck(); + } + } else { + this.error.set( + paymentResponse.message ?? 'Không thể tạo liên kết thanh toán. Vui lòng thử lại.', + ); + this.isLoading.set(false); + this.cdr.markForCheck(); + } + }, + error: (error: unknown) => { + const errorMessage = + error instanceof Error ? error.message : 'Đã xảy ra lỗi khi tạo thanh toán.'; + this.error.set(errorMessage); + this.isLoading.set(false); + this.cdr.markForCheck(); + }, + }); + } +} diff --git a/src/app/features/customer/booking/pages/deposit/deposit.html b/src/app/features/customer/booking/pages/deposit/deposit.html deleted file mode 100644 index c98b60a..0000000 --- a/src/app/features/customer/booking/pages/deposit/deposit.html +++ /dev/null @@ -1 +0,0 @@ -

deposit works!

diff --git a/src/app/features/customer/booking/pages/deposit/deposit.spec.ts b/src/app/features/customer/booking/pages/deposit/deposit.spec.ts deleted file mode 100644 index 6f859c6..0000000 --- a/src/app/features/customer/booking/pages/deposit/deposit.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { Deposit } from './deposit'; - -describe('Deposit', () => { - let component: Deposit; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [Deposit], - }).compileComponents(); - - fixture = TestBed.createComponent(Deposit); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/features/customer/booking/pages/deposit/deposit.ts b/src/app/features/customer/booking/pages/deposit/deposit.ts deleted file mode 100644 index ef5f13a..0000000 --- a/src/app/features/customer/booking/pages/deposit/deposit.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Component } from '@angular/core'; - -@Component({ - selector: 'app-deposit', - imports: [], - templateUrl: './deposit.html', - styleUrl: './deposit.scss', -}) -export class Deposit {} diff --git a/src/app/features/customer/booking/pages/payment/payment.html b/src/app/features/customer/booking/pages/payment/payment.html new file mode 100644 index 0000000..1d55c4e --- /dev/null +++ b/src/app/features/customer/booking/pages/payment/payment.html @@ -0,0 +1,33 @@ +
+
+ @if (isSuccess()) { +
+ +

Thanh toán thành công

+
+

+ Mã đặt xe của bạn: {{ bookingId() }} +

+ + } @else { +
+ +

Thanh toán thất bại

+
+

+ Mã lỗi: {{ responseCode() || 'N/A' }} +

+ @if (bookingId()) { +

+ Mã đặt xe: {{ bookingId() }} +

+ } + } +
+
diff --git a/src/app/features/customer/booking/pages/deposit/deposit.scss b/src/app/features/customer/booking/pages/payment/payment.scss similarity index 100% rename from src/app/features/customer/booking/pages/deposit/deposit.scss rename to src/app/features/customer/booking/pages/payment/payment.scss diff --git a/src/app/features/customer/booking/pages/payment/payment.ts b/src/app/features/customer/booking/pages/payment/payment.ts new file mode 100644 index 0000000..9d892df --- /dev/null +++ b/src/app/features/customer/booking/pages/payment/payment.ts @@ -0,0 +1,80 @@ +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { MatButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { ActivatedRoute, Router } from '@angular/router'; +import { map } from 'rxjs'; + +@Component({ + selector: 'app-payment', + standalone: true, + imports: [MatIcon, MatButton], + templateUrl: './payment.html', + styleUrl: './payment.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class Payment { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + + // Read raw query params once and expose via signals + private readonly qp = toSignal( + this.route.queryParamMap.pipe( + map((pm) => { + const get = (k: string) => pm.get(k) ?? ''; + return { + amount: get('vnp_Amount'), + bankCode: get('vnp_BankCode'), + bankTranNo: get('vnp_BankTranNo'), + cardType: get('vnp_CardType'), + orderInfo: get('vnp_OrderInfo'), + payDate: get('vnp_PayDate'), + responseCode: get('vnp_ResponseCode'), + tmnCode: get('vnp_TmnCode'), + transactionNo: get('vnp_TransactionNo'), + transactionStatus: get('vnp_TransactionStatus'), + txnRef: get('vnp_TxnRef'), // bookingId + secureHash: get('vnp_SecureHash'), + }; + }), + ), + { + initialValue: { + amount: '', + bankCode: '', + bankTranNo: '', + cardType: '', + orderInfo: '', + payDate: '', + responseCode: '', + tmnCode: '', + transactionNo: '', + transactionStatus: '', + txnRef: '', + secureHash: '', + }, + }, + ); + + readonly bookingId = computed(() => this.qp().txnRef?.trim() ?? ''); + readonly responseCode = computed(() => this.qp().responseCode?.trim() ?? ''); + readonly isSuccess = computed(() => this.responseCode() === '00'); + + constructor() { + // Cache the bookingId for later access + const id = this.bookingId(); + if (id) { + try { + sessionStorage.setItem('lastBookingId', id); + } catch { + // ignore storage errors + } + } + } + + goToBookingDetail(): void { + const id = this.bookingId(); + if (!id) return; + this.router.navigate(['/bookings', id]); + } +} diff --git a/src/app/features/customer/booking/services/booking-storage.service.ts b/src/app/features/customer/booking/services/booking-storage.service.ts new file mode 100644 index 0000000..c0381c6 --- /dev/null +++ b/src/app/features/customer/booking/services/booking-storage.service.ts @@ -0,0 +1,51 @@ +import { Injectable, inject } from '@angular/core'; +import { SessionService } from '../../../../core-logic/storage/session.service'; +import { BookingData } from '../pages/checkout/checkout'; + +type StoredBookingDraft = Omit & { + readonly startDate: string; + readonly endDate: string; +}; + +@Injectable({ providedIn: 'root' }) +export class BookingStorageService { + private readonly session = inject(SessionService); + private readonly storageKeyPrefix = 'booking-draft:'; + + saveDraft(bookingId: string, data: BookingData): void { + const payload: StoredBookingDraft = { + ...data, + startDate: data.startDate.toISOString(), + endDate: data.endDate.toISOString(), + }; + this.session.setJson(this.composeKey(bookingId), payload); + } + + loadDraft(bookingId: string): BookingData | null { + const parsed = this.session.getJson(this.composeKey(bookingId)); + if (!parsed) { + return null; + } + + const startDate = new Date(parsed.startDate); + const endDate = new Date(parsed.endDate); + + if (Number.isNaN(startDate.valueOf()) || Number.isNaN(endDate.valueOf())) { + return null; + } + + return { + ...parsed, + startDate, + endDate, + }; + } + + removeDraft(bookingId: string): void { + this.session.removeItem(this.composeKey(bookingId)); + } + + private composeKey(bookingId: string): string { + return `${this.storageKeyPrefix}${bookingId}`; + } +} diff --git a/src/app/layout/layout.ts b/src/app/layout/layout.ts index b06410d..58e4bb4 100644 --- a/src/app/layout/layout.ts +++ b/src/app/layout/layout.ts @@ -80,8 +80,8 @@ export class LayoutComponent { // Navigation data cho từng role private navigationData = { renter: [ - { name: 'Thuê xe', href: '/booking', current: false, icon: 'directions_car' }, - { name: 'Đơn thuê của tôi', href: '/orders', current: false, icon: 'list_alt' }, + { name: 'Thuê xe', href: '/cars', current: false, icon: 'directions_car' }, + { name: 'Đơn thuê của tôi', href: '/bookings', current: false, icon: 'list_alt' }, { name: 'Báo cáo', href: '/reports', current: false, icon: 'analytics' }, ], staff: [