feature: implement payment method selector and checkout confirmation page

This commit is contained in:
kusowl 2026-03-24 18:52:06 +05:30
parent 4546d309b8
commit 85b0fbf499
21 changed files with 344 additions and 17 deletions

View File

@ -0,0 +1 @@
<svg height="1524" viewBox="55.2 38.3 464.5 287.8" width="2500" xmlns="http://www.w3.org/2000/svg"><path d="m519.7 182.2c0 79.5-64.3 143.9-143.6 143.9s-143.6-64.4-143.6-143.9 64.2-143.9 143.5-143.9 143.7 64.4 143.7 143.9z" fill="#f79f1a"/><path d="m342.4 182.2c0 79.5-64.3 143.9-143.6 143.9s-143.6-64.4-143.6-143.9 64.3-143.9 143.6-143.9 143.6 64.4 143.6 143.9z" fill="#ea001b"/><path d="m287.4 68.9c-33.5 26.3-55 67.3-55 113.3s21.5 87 55 113.3c33.5-26.3 55-67.3 55-113.3s-21.5-86.9-55-113.3z" fill="#ff5f01"/></svg>

After

Width:  |  Height:  |  Size: 516 B

View File

@ -0,0 +1 @@
<svg width="2500" height="1045" viewBox="0 0 512 214" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid"><path d="M35.982 83.484c0-5.546 4.551-7.68 12.09-7.68 10.808 0 24.461 3.272 35.27 9.103V51.484c-11.804-4.693-23.466-6.542-35.27-6.542C19.2 44.942 0 60.018 0 85.192c0 39.252 54.044 32.995 54.044 49.92 0 6.541-5.688 8.675-13.653 8.675-11.804 0-26.88-4.836-38.827-11.378v33.849c13.227 5.689 26.596 8.106 38.827 8.106 29.582 0 49.92-14.648 49.92-40.106-.142-42.382-54.329-34.845-54.329-50.774zm96.142-66.986l-34.702 7.395-.142 113.92c0 21.05 15.787 36.551 36.836 36.551 11.662 0 20.195-2.133 24.888-4.693V140.8c-4.55 1.849-27.022 8.391-27.022-12.658V77.653h27.022V47.36h-27.022l.142-30.862zm71.112 41.386L200.96 47.36h-30.72v124.444h35.556V87.467c8.39-10.951 22.613-8.96 27.022-7.396V47.36c-4.551-1.707-21.191-4.836-29.582 10.524zm38.257-10.524h35.698v124.444h-35.698V47.36zm0-10.809l35.698-7.68V0l-35.698 7.538V36.55zm109.938 8.391c-13.938 0-22.898 6.542-27.875 11.094l-1.85-8.818h-31.288v165.83l35.555-7.537.143-40.249c5.12 3.698 12.657 8.96 25.173 8.96 25.458 0 48.64-20.48 48.64-65.564-.142-41.245-23.609-63.716-48.498-63.716zm-8.533 97.991c-8.391 0-13.37-2.986-16.782-6.684l-.143-52.765c3.698-4.124 8.818-6.968 16.925-6.968 12.942 0 21.902 14.506 21.902 33.137 0 19.058-8.818 33.28-21.902 33.28zM512 110.08c0-36.409-17.636-65.138-51.342-65.138-33.85 0-54.33 28.73-54.33 64.854 0 42.808 24.179 64.426 58.88 64.426 16.925 0 29.725-3.84 39.396-9.244v-28.445c-9.67 4.836-20.764 7.823-34.844 7.823-13.796 0-26.027-4.836-27.591-21.618h69.547c0-1.85.284-9.245.284-12.658zm-70.258-13.511c0-16.071 9.814-22.756 18.774-22.756 8.675 0 17.92 6.685 17.92 22.756h-36.694z" fill="#6772E5"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1 @@
<svg height="812" viewBox="0.5 0.5 999 323.684" width="2500" xmlns="http://www.w3.org/2000/svg"><path d="M651.185.5c-70.933 0-134.322 36.766-134.322 104.694 0 77.9 112.423 83.28 112.423 122.415 0 16.478-18.884 31.229-51.137 31.229-45.773 0-79.984-20.611-79.984-20.611l-14.638 68.547s39.41 17.41 91.734 17.41c77.552 0 138.576-38.572 138.576-107.66 0-82.316-112.89-87.537-112.89-123.86 0-12.91 15.501-27.053 47.662-27.053 36.286 0 65.892 14.99 65.892 14.99l14.326-66.204S696.614.5 651.185.5zM2.218 5.497L.5 15.49s29.842 5.461 56.719 16.356c34.606 12.492 37.072 19.765 42.9 42.353l63.51 244.832h85.138L379.927 5.497h-84.942L210.707 218.67l-34.39-180.696c-3.154-20.68-19.13-32.477-38.685-32.477H2.218zm411.865 0L347.449 319.03h80.999l66.4-313.534h-80.765zm451.759 0c-19.532 0-29.88 10.457-37.474 28.73L709.699 319.03h84.942l16.434-47.468h103.483l9.994 47.468H999.5L934.115 5.497h-68.273zm11.047 84.707l25.178 117.653h-67.454z" fill="#1434cb"/></svg>

After

Width:  |  Height:  |  Size: 945 B

View File

@ -0,0 +1,37 @@
import { Injectable } from "@angular/core";
@Injectable({
providedIn: "root",
})
export class SessionStorageService {
setItem<T>(key: string, value: T) {
try {
const item = JSON.stringify(value);
sessionStorage.setItem(key, item);
} catch (e) {
console.error("Could not set item", e);
}
}
getItem<T>(key: string): T | null {
try {
const item = sessionStorage.getItem(key);
return item ? (JSON.parse(item) as T) : null;
} catch (e) {
console.error("Could not get item", e);
return null;
}
}
/**
* @throws Error if key is not found.
* @param key
*/
removeItem(key: string): void {
sessionStorage.removeItem(key);
}
clear(): void {
sessionStorage.clear();
}
}

View File

@ -0,0 +1,16 @@
import { TestBed } from "@angular/core/testing";
import { SessionStorage } from "./session-storage.service";
describe("SessionStorage", () => {
let service: SessionStorage;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(SessionStorage);
});
it("should be created", () => {
expect(service).toBeTruthy();
});
});

View File

@ -3,7 +3,7 @@
<app-stepper [currentStep]="currentStepNumber()" [steps]="steps" />
</div>
<section class="mt-10">
@if (currentStepNumber() > 0) {
@if (currentStepNumber() > 0 && currentStepNumber() < 3) {
<app-go-back
[route]="steps[currentStepNumber() - 1].route"
[text]="steps[currentStepNumber() - 1 ].label"
@ -11,10 +11,11 @@
/>
}
<div class="grid md:grid-cols-6 gap-10">
<div [class.md:grid-cols-6]="currentStepNumber() < 3" class="grid gap-10">
<div class="md:col-span-4">
<router-outlet />
</div>
@if (currentStepNumber() < 3) {
<div class="md:col-span-2">
<app-order-summery class="hidden md:block" />
<div class="card mt-4">
@ -29,7 +30,11 @@
<div class="text-red-500 text-sm p-4 mt-4 rounded-xl bg-red-50">
Please select an address
</div>
}
} @if (paymentMethodControl.invalid && paymentMethodControl.touched) {
<div class="text-red-500 text-sm p-4 mt-4 rounded-xl bg-red-50">
Please select an payment checkout
</div>
} @if (currentStepNumber() === 1) {
<button
(click)="proceedToPayment()"
[disabled]="addressIdControl.invalid && addressIdControl.touched || orderCreationLoading()"
@ -39,8 +44,20 @@
Proceed to payment
</app-loading-spinner>
</button>
} @if (currentStepNumber() === 2) {
<button
(click)="proceedToCheckout()"
[disabled]="paymentMethodControl.invalid && paymentMethodControl.touched || proceedToPaymentLoading()"
class="btn btn-primary w-full mt-4"
>
<app-loading-spinner [isLoading]="proceedToPaymentLoading()">
Proceed to checkout
</app-loading-spinner>
</button>
}
</div>
</div>
}
</div>
</section>
</section>

View File

@ -2,6 +2,7 @@ import { Routes } from "@angular/router";
import { Address } from "./address/address";
import { Checkout } from "./checkout";
import { Payment } from "@app/features/checkout/payment/payment";
import Confirmation from "@app/features/checkout/confirmation/confirmation";
export const checkoutRoutes: Routes = [
{
@ -16,6 +17,10 @@ export const checkoutRoutes: Routes = [
path: "payment",
component: Payment,
},
{
path: "confirmation",
component: Confirmation,
},
],
},
];

View File

@ -5,7 +5,7 @@ import { OrderSummery } from "@app/features/checkout/components/order-summery/or
import { GoBack } from "@shared/components/go-back/go-back";
import { AddressService } from "@app/features/checkout/services/address-service";
import { LoadingSpinner } from "@shared/components/loading-spinner/loading-spinner";
import { OrderService } from "@app/features/checkout/services/order-service";
import { CheckoutResponse, OrderService } from "@app/features/checkout/services/order-service";
import { CartService } from "@core/services/cart-service";
import { CartModel } from "@core/models/cart.model";
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
@ -23,17 +23,21 @@ export class Checkout implements OnInit {
{ label: "Cart", route: "" },
{ label: "Address", route: "/checkout/address" },
{ label: "Payment", route: "/checkout/payment" },
{ label: "Confirm", route: "/checkout/address" },
{ label: "Confirmation", route: "/checkout/confirmation" },
];
destroyRef = inject(DestroyRef);
orderCreationLoading = signal(false);
proceedToPaymentLoading = signal(false);
private addressService = inject(AddressService);
addressIdControl = this.addressService.addressIdControl;
private orderService = inject(OrderService);
paymentMethodControl = this.orderService.paymentMethodForm;
private cartService = inject(CartService);
private cart: CartModel | undefined;
private router = inject(Router);
protected currentStepNumber = toSignal(
this.router.events.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd), // Added TS type guard
@ -74,4 +78,32 @@ export class Checkout implements OnInit {
await this.router.navigate(["/checkout/payment"]);
});
}
protected proceedToCheckout() {
if (this.paymentMethodControl.invalid) {
this.paymentMethodControl.markAsTouched();
return;
}
this.proceedToPaymentLoading.set(true);
this.orderService
.checkout(this.paymentMethodControl.value!)
.pipe(
takeUntilDestroyed(this.destroyRef),
finalize(() => this.proceedToPaymentLoading.set(false)),
)
.subscribe((response) => this.handleCheckout(response));
}
private handleCheckout(data: CheckoutResponse): void {
if (data.success) {
if (data.method === "stripeCheckout") {
this.handleStripeCheckout(data);
}
}
}
private handleStripeCheckout(data: CheckoutResponse) {
console.log(data);
if (data.redirectUrl) {
window.location.href = data.redirectUrl;
}
}
}

View File

@ -0,0 +1,3 @@
<div class="card min-h-20">
<ng-content />
</div>

View File

@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { PaymentMethodCard } from "./payment-method-card";
describe("PaymentMethodCard", () => {
let component: PaymentMethodCard;
let fixture: ComponentFixture<PaymentMethodCard>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PaymentMethodCard],
}).compileComponents();
fixture = TestBed.createComponent(PaymentMethodCard);
component = fixture.componentInstance;
await fixture.whenStable();
});
it("should create", () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,9 @@
import { Component } from "@angular/core";
@Component({
selector: "app-payment-method-card",
imports: [],
templateUrl: "./payment-method-card.html",
styleUrl: "./payment-method-card.css",
})
export class PaymentMethodCard {}

View File

@ -0,0 +1,27 @@
<div class="flex flex-col gap-4 items-center justify-center">
<div class="card max-w-md text-center flex items-center flex-col gap-4">
<div class="rounded-full bg-green-100 p-4">
<lucide-angular [img]="BadgeCheck" class="w-6 h-6 text-green-500" />
</div>
<p class="text-xl font-medium text-green-600">Payment Successful</p>
<p class="text-sm text-gray-400">
Your payment processed successfully. You will receive a confirmation email shortly.
</p>
<article class="rounded-xl bg-gray-100 px-3 py-4 text-gray-500 text-sm w-full space-y-4">
<article class="flex justify-between border-b border-b-gray-200 pb-4">
<p class="font-medium">Amount</p>
<p>Rs. 20000</p>
</article>
<article class="flex justify-between">
<p class="font-medium">Transaction ID</p>
<p class="truncate">text_ch_989789y789jhhg8h8</p>
</article>
<article class="flex justify-between">
<p class="font-medium">Payment method</p>
<p>Stripe Checkout</p>
</article>
</article>
</div>
<app-go-back class="w-min-content! flex-nowrap" route="/" text="Continue Shopping" />
</div>

View File

@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import Confirmation from "./confirmation";
describe("Confirmation", () => {
let component: Confirmation;
let fixture: ComponentFixture<Confirmation>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Confirmation],
}).compileComponents();
fixture = TestBed.createComponent(Confirmation);
component = fixture.componentInstance;
await fixture.whenStable();
});
it("should create", () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,24 @@
import { Component, inject } from "@angular/core";
import { BadgeCheck, LucideAngularModule } from "lucide-angular";
import { GoBack } from "@shared/components/go-back/go-back";
import { ActivatedRoute } from "@angular/router";
@Component({
selector: "app-confirmation",
imports: [LucideAngularModule, GoBack],
templateUrl: "./confirmation.html",
styleUrl: "./confirmation.css",
})
class Confirmation {
protected readonly BadgeCheck = BadgeCheck;
private route = inject(ActivatedRoute);
private orderId: string | null = null;
private sessionId: string | null = null;
ngOnInit() {
this.orderId = this.route.snapshot.paramMap.get("order_id");
this.sessionId = this.route.snapshot.paramMap.get("session_id");
}
}
export default Confirmation;

View File

@ -1 +1,51 @@
<p>payment works!</p>
<div class="flex flex-col gap-4">
<app-payment-method-card>
<div class="flex gap-4">
<input
(change)="onPaymentMethodSelected('stripeCheckout')"
[formControl]="paymentMethodForm"
name="payment-method"
type="radio"
value="stripeCheckout"
/>
<div class="flex-1">
<div class="flex justify-between mb-2">
<div class="">
<img alt="" class="w-25" src="/assets/images/stripe-4.svg" />
</div>
<div class="flex gap-2">
<div class="card p-3! flex justify-center items-center">
<img alt="" class="h-3" src="/assets/images/visa-10.svg" />
</div>
<div class="card p-3! flex justify-center items-center">
<img alt="" class="h-5" src="/assets/images/mastercard-modern-design-.svg" />
</div>
</div>
</div>
<p class="text-sm text-gray-600">Pay using card via stripe checkout.</p>
</div>
</div>
</app-payment-method-card>
<app-payment-method-card>
<div class="flex gap-4">
<input
(change)="onPaymentMethodSelected('cashOnDelivery')"
[formControl]="paymentMethodForm"
name="payment-method"
type="radio"
value="cashOnDelivery"
/>
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<div class="text-gray-700">
<lucide-angular [img]="coinsIcon" class="h-10" />
</div>
<div class="">
<p class="text-xl font-medium text-gray-700">Cash on Delivery</p>
</div>
</div>
<p class="text-sm text-gray-600">Additional charges may apply.</p>
</div>
</div>
</app-payment-method-card>
</div>

View File

@ -1,9 +1,28 @@
import { Component } from "@angular/core";
import { Component, inject, OnInit } from "@angular/core";
import { PaymentMethodCard } from "@app/features/checkout/components/payment-method-card/payment-method-card";
import { Coins, LucideAngularModule } from "lucide-angular";
import { ReactiveFormsModule } from "@angular/forms";
import { OrderService } from "@app/features/checkout/services/order-service";
import { ActivatedRoute } from "@angular/router";
@Component({
selector: "app-payment",
imports: [],
imports: [PaymentMethodCard, LucideAngularModule, ReactiveFormsModule],
templateUrl: "./payment.html",
styleUrl: "./payment.css",
})
export class Payment {}
export class Payment implements OnInit {
coinsIcon = Coins;
private orderService = inject(OrderService);
protected paymentMethodForm = this.orderService.paymentMethodForm;
private route = inject(ActivatedRoute);
private orderId: string | null = null;
ngOnInit() {
this.orderId = this.route.snapshot.paramMap.get("order_id");
}
onPaymentMethodSelected(paymentMethod: string) {
this.paymentMethodForm.setValue(paymentMethod);
}
}

View File

@ -2,22 +2,63 @@ import { inject, Injectable } from "@angular/core";
import { API_URL } from "@core/tokens/api-url-tokens";
import { HttpClient } from "@angular/common/http";
import { AuthService } from "@core/services/auth-service";
import { FormControl, Validators } from "@angular/forms";
import { tap } from "rxjs";
import { SessionStorageService } from "@core/services/session-storage.service";
export interface CheckoutResponse {
success: boolean;
amount: number;
currency: string;
method: string;
redirectUrl: string | null;
errorMessage: string | null;
}
export interface OrderRequest {
addressId: number;
cartId: number;
}
export interface OrderResponse {
orderId: number;
message: string;
}
@Injectable({
providedIn: "root",
})
export class OrderService {
http = inject(HttpClient);
apiUrl = inject(API_URL);
public paymentMethodForm = new FormControl<string | null>(null, Validators.required);
private currentOrderId: number | null = null;
private http = inject(HttpClient);
private apiUrl = inject(API_URL);
private authService = inject(AuthService);
private user = this.authService.user;
private sessionStorage = inject(SessionStorageService);
constructor() {
const cachedOrderId = this.sessionStorage.getItem<number>("orderId");
if (cachedOrderId) {
this.currentOrderId = cachedOrderId;
}
}
createOrder(data: OrderRequest) {
return this.http.post(`${this.apiUrl}/users/${this.user()?.id}/orders`, data);
return this.http
.post<OrderResponse>(`${this.apiUrl}/users/${this.user()?.id}/orders`, data)
.pipe(
tap((response) => {
this.currentOrderId = response.orderId;
this.sessionStorage.setItem<number>("orderId", response.orderId);
}),
);
}
checkout(mode: string) {
return this.http.post<CheckoutResponse>(
`${this.apiUrl}/orders/${this.currentOrderId}/payments`,
{
mode: mode,
},
);
}
}

View File

@ -1,4 +1,4 @@
<a [routerLink]="route" class="flex space-x-2 w-min my-4 text-gray-600 hover:text-blue-500 text-sm">
<a [routerLink]="route" class="flex space-x-2 w-max my-4 text-gray-600 hover:text-blue-500 text-sm">
<lucide-angular [img]="MoveLeftIcon" class="w-4" />
<p class="font-medium">{{ text }}</p>
</a>

View File

@ -3,14 +3,14 @@
<li class="flex flex-col items-start">
<div class="flex items-center">
<a
[routerLink]="$index <= currentStep ? step.route : ''"
[class.cursor-not-allowed]="$index > currentStep"
[class.bg-blue-600]="$index <= currentStep"
[class.cursor-not-allowed]="$index > currentStep"
[routerLink]="$index <= currentStep ? step.route : '#'"
class="btn py-0! px-1 rounded-full! w-min"
>
<lucide-angular
[class.text-white]="$index <= currentStep"
[class.text-gray-400]="$index > currentStep"
[class.text-white]="$index <= currentStep"
[img]="$index <= currentStep ? CheckIcon : CirecleIcon"
class="w-4"
/>