Add /qr route and refactor payment QR code generation

Introduce a dedicated /qr route for QR code preview via query params.
Add PaymentRequest model for payment data and validation, replacing
the old inline model. Extract QR code generation to PaymentQrCodeService
for reuse. Refactor Home.razor to use new model/service and improve
validation/UI. Add EmptyLayout for minimal layout needs. Update
.gitignore and _Imports.razor for new structure.
This commit is contained in:
Michal Vrba
2026-03-16 07:36:13 +01:00
parent 373ca0cf9f
commit d8e94fd985
8 changed files with 279 additions and 77 deletions
+2 -1
View File
@@ -360,4 +360,5 @@ MigrationBackup/
.ionide/ .ionide/
# Fody - auto-generated XML schema # Fody - auto-generated XML schema
FodyWeavers.xsd FodyWeavers.xsd
/.dotnet
+3
View File
@@ -0,0 +1,3 @@
@inherits LayoutComponentBase
@Body
+89
View File
@@ -0,0 +1,89 @@
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Text.RegularExpressions;
namespace QRPay.Models;
public class PaymentRequest : IValidatableObject
{
private static readonly Regex IbanRegex = new(
"^[A-Z]{2}[0-9]{2}[A-Z0-9]{4}[0-9]{7}([A-Z0-9]?){0,16}$",
RegexOptions.Compiled);
[StringLength(70, ErrorMessage = "Name cannot exceed 70 characters")]
public string Name { get; set; } = string.Empty;
[Required(ErrorMessage = "IBAN is required")]
public string IBAN { get; set; } = string.Empty;
[Required(ErrorMessage = "Amount is required")]
[Range(typeof(decimal), "0.01", "999999.99", ErrorMessage = "Amount must be between €0.01 and €999,999.99")]
public decimal? Amount { get; set; }
[StringLength(140, ErrorMessage = "Remark cannot exceed 140 characters")]
public string Remark { get; set; } = string.Empty;
public string NormalizedIBAN => NormalizeIban(IBAN);
public static string NormalizeIban(string? iban)
{
return string.IsNullOrWhiteSpace(iban)
? string.Empty
: iban.Replace(" ", string.Empty, StringComparison.Ordinal).ToUpperInvariant();
}
public IReadOnlyList<string> ValidateForQrRoute()
{
var errors = new List<string>();
var normalizedIban = NormalizedIBAN;
if (string.IsNullOrWhiteSpace(normalizedIban))
{
errors.Add("Missing required query parameter: iban.");
}
else if (!IbanRegex.IsMatch(normalizedIban))
{
errors.Add("Invalid IBAN format.");
}
if (Amount is null)
{
errors.Add("Missing required query parameter: amount.");
}
else if (Amount < 0.01m || Amount > 999999.99m)
{
errors.Add("Amount must be between 0.01 and 999999.99 EUR.");
}
if (!string.IsNullOrEmpty(Name) && Name.Length > 70)
{
errors.Add("Name cannot exceed 70 characters.");
}
if (!string.IsNullOrEmpty(Remark) && Remark.Length > 140)
{
errors.Add("Remark cannot exceed 140 characters.");
}
return errors;
}
public static bool TryParseAmount(string? value, out decimal amount)
{
return decimal.TryParse(
value,
NumberStyles.Number,
CultureInfo.InvariantCulture,
out amount);
}
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (!string.IsNullOrWhiteSpace(IBAN) && !IbanRegex.IsMatch(NormalizedIBAN))
{
yield return new ValidationResult(
"Please enter a valid IBAN",
[nameof(IBAN)]);
}
}
}
+25 -75
View File
@@ -1,16 +1,15 @@
@page "/" @page "/"
@using QRCoder
@using SixLabors.ImageSharp.Formats
@using System.ComponentModel.DataAnnotations
@inject IJSRuntime JSRuntime @inject IJSRuntime JSRuntime
@using QRPay.Models
@using QRPay.Services
@implements IDisposable
<PageTitle>QRPay - QR Payment Generator</PageTitle> <PageTitle>QRPay - QR Payment Generator</PageTitle>
<div class="qrpay-container"> <div class="qrpay-container">
@if (!_showQRCode) @if (!_showQRCode)
{ {
<!-- Form View -->
<div class="qrpay-card"> <div class="qrpay-card">
<!-- Logo and Header -->
<div class="qrpay-header"> <div class="qrpay-header">
<div class="qrpay-logo"> <div class="qrpay-logo">
<img src="logo.svg" alt="QRPay Logo" /> <img src="logo.svg" alt="QRPay Logo" />
@@ -19,10 +18,9 @@
<p class="qrpay-subtitle">Generate QR payment codes instantly</p> <p class="qrpay-subtitle">Generate QR payment codes instantly</p>
</div> </div>
<!-- Form -->
<EditForm Model="@paymentModel" OnValidSubmit="@GenerateQRCode"> <EditForm Model="@paymentModel" OnValidSubmit="@GenerateQRCode">
<DataAnnotationsValidator /> <DataAnnotationsValidator />
<div class="qrpay-form"> <div class="qrpay-form">
<div class="form-group"> <div class="form-group">
<label for="name" class="form-label">Beneficiary Name</label> <label for="name" class="form-label">Beneficiary Name</label>
@@ -65,17 +63,15 @@
} }
else else
{ {
<!-- QR Code View -->
<div class="qrcode-container"> <div class="qrcode-container">
<div class="qrcode-header"> <div class="qrcode-header">
<h2>Payment QR Code</h2> <h2>Payment QR Code</h2>
<p>Scan this code with your banking app</p> <p>Scan this code with your banking app</p>
</div> </div>
<div class="qrcode-display"> <div class="qrcode-display">
<img id="qrcode-image" src="@QRCodeStr" alt="Payment QR Code" /> <img id="qrcode-image" src="@QRCodeStr" alt="Payment QR Code" />
<!-- Copy Button -->
<button class="btn-copy @(_isCopying ? "copying" : "")" @onclick="@CopyQRCodeToClipboard" disabled="@_isCopying"> <button class="btn-copy @(_isCopying ? "copying" : "")" @onclick="@CopyQRCodeToClipboard" disabled="@_isCopying">
@if (_isCopying) @if (_isCopying)
{ {
@@ -92,15 +88,15 @@
} }
</button> </button>
</div> </div>
<div class="payment-details"> <div class="payment-details">
<div class="detail-row"> <div class="detail-row">
<span class="detail-label">To:</span> <span class="detail-label">To:</span>
<span class="detail-value">@paymentModel.Name</span> <span class="detail-value">@(string.IsNullOrWhiteSpace(paymentModel.Name) ? "Not provided" : paymentModel.Name)</span>
</div> </div>
<div class="detail-row"> <div class="detail-row">
<span class="detail-label">Amount:</span> <span class="detail-label">Amount:</span>
<span class="detail-value">€@paymentModel.Amount.ToString("F2")</span> <span class="detail-value">€@paymentModel.Amount?.ToString("F2")</span>
</div> </div>
@if (!string.IsNullOrWhiteSpace(paymentModel.Remark)) @if (!string.IsNullOrWhiteSpace(paymentModel.Remark))
{ {
@@ -110,7 +106,7 @@
</div> </div>
} }
</div> </div>
<button class="btn-base btn-back" @onclick="@GoBack"> <button class="btn-base btn-back" @onclick="@GoBack">
<span>← Create New Payment</span> <span>← Create New Payment</span>
</button> </button>
@@ -119,71 +115,28 @@
</div> </div>
@code { @code {
private PaymentModel paymentModel = new(); private PaymentRequest paymentModel = new();
private string QRCodeStr = string.Empty; private string QRCodeStr = string.Empty;
private bool _showQRCode = false; private bool _showQRCode;
private bool _isGenerating = false; private bool _isGenerating;
private bool _isCopying = false; private bool _isCopying;
private bool _copySuccess = false; private bool _copySuccess;
private System.Threading.Timer? _copySuccessTimer; private System.Threading.Timer? _copySuccessTimer;
public class PaymentModel
{
[Required(ErrorMessage = "Beneficiary name is required")]
[StringLength(70, ErrorMessage = "Name cannot exceed 70 characters")]
public string Name { get; set; } = string.Empty;
[Required(ErrorMessage = "IBAN is required")]
[RegularExpression(@"^[A-Z]{2}[0-9]{2}[A-Z0-9]{4}[0-9]{7}([A-Z0-9]?){0,16}$", ErrorMessage = "Please enter a valid IBAN")]
public string IBAN { get; set; } = string.Empty;
[Required(ErrorMessage = "Amount is required")]
[Range(0.01, 999999.99, ErrorMessage = "Amount must be between €0.01 and €999,999.99")]
public decimal Amount { get; set; } = 0;
[StringLength(140, ErrorMessage = "Remark cannot exceed 140 characters")]
public string? Remark { get; set; } = string.Empty;
}
public async Task GenerateQRCode() public async Task GenerateQRCode()
{ {
_isGenerating = true; _isGenerating = true;
StateHasChanged(); StateHasChanged();
// Small delay to show loading state
await Task.Delay(500); await Task.Delay(500);
try try
{ {
QRCodeStr = string.Empty; QRCodeStr = string.Empty;
if (!string.IsNullOrWhiteSpace(paymentModel.IBAN)) if (!string.IsNullOrWhiteSpace(paymentModel.IBAN))
{ {
using (var ms = new MemoryStream()) QRCodeStr = PaymentQrCodeService.GenerateDataUri(paymentModel);
{
QRCodeGenerator qRCodeGenerator = new QRCodeGenerator();
var payment = new PayBySquareTextGenerator.Payment(
paymentModel.IBAN.Replace(" ", ""),
paymentModel.Amount,
"EUR",
"1",
paymentModel.Remark ?? string.Empty);
payment.ConstantSymbol = "0308";
payment.BeneficiaryName = paymentModel.Name;
var payBySquare = new PayBySquareTextGenerator.PayBySquareOverkill(payment);
var stringData = payBySquare.GeneratePayBySquareOverkillString();
// Generate QR code data - smaller size for better clipboard handling
using var qrCodeData = QRCodeGenerator.GenerateQrCode(stringData, QRCodeGenerator.ECCLevel.Q);
using var qrCode = new PngByteQRCode(qrCodeData);
// Reduced from 20 to 10 for smaller clipboard image
byte[] qrCodeImage = qrCode.GetGraphic(10);
QRCodeStr = $"data:image/png;base64,{Convert.ToBase64String(qrCodeImage)}";
}
_showQRCode = true; _showQRCode = true;
} }
} }
@@ -202,17 +155,15 @@
_copySuccess = false; _copySuccess = false;
StateHasChanged(); StateHasChanged();
// Use JavaScript to copy the QR code image to clipboard with smaller size
var success = await JSRuntime.InvokeAsync<bool>("copyImageToClipboardSmall", "qrcode-image"); var success = await JSRuntime.InvokeAsync<bool>("copyImageToClipboardSmall", "qrcode-image");
_isCopying = false; _isCopying = false;
if (success) if (success)
{ {
_copySuccess = true; _copySuccess = true;
StateHasChanged(); StateHasChanged();
// Reset the success state after 3 seconds
_copySuccessTimer?.Dispose(); _copySuccessTimer?.Dispose();
_copySuccessTimer = new System.Threading.Timer(_ => _copySuccessTimer = new System.Threading.Timer(_ =>
{ {
@@ -226,7 +177,6 @@
else else
{ {
StateHasChanged(); StateHasChanged();
// Could show an error message here if needed
} }
} }
catch (Exception) catch (Exception)
@@ -240,7 +190,7 @@
{ {
_showQRCode = false; _showQRCode = false;
QRCodeStr = string.Empty; QRCodeStr = string.Empty;
paymentModel = new PaymentModel(); // Reset the form paymentModel = new PaymentRequest();
_copySuccess = false; _copySuccess = false;
_copySuccessTimer?.Dispose(); _copySuccessTimer?.Dispose();
StateHasChanged(); StateHasChanged();
@@ -250,4 +200,4 @@
{ {
_copySuccessTimer?.Dispose(); _copySuccessTimer?.Dispose();
} }
} }
+78
View File
@@ -0,0 +1,78 @@
@page "/qr"
@layout EmptyLayout
@using Microsoft.AspNetCore.Components
@using QRPay.Models
@using QRPay.Services
<PageTitle>QR Preview</PageTitle>
<div class="qr-route-page">
<h1 class="visually-hidden">QR payment preview</h1>
@if (_errors.Count > 0)
{
<div class="qr-route-error" role="alert">
<p>Unable to generate QR code.</p>
@foreach (var error in _errors)
{
<p>@error</p>
}
</div>
}
else if (!string.IsNullOrEmpty(_qrCodeDataUri))
{
<img class="qr-route-image" src="@_qrCodeDataUri" alt="Payment QR code" />
}
</div>
@code {
[SupplyParameterFromQuery(Name = "amount")]
public string? Amount { get; set; }
[SupplyParameterFromQuery(Name = "iban")]
public string? Iban { get; set; }
[SupplyParameterFromQuery(Name = "name")]
public string? Name { get; set; }
[SupplyParameterFromQuery(Name = "remark")]
public string? Remark { get; set; }
private readonly List<string> _errors = [];
private string _qrCodeDataUri = string.Empty;
protected override void OnParametersSet()
{
_errors.Clear();
_qrCodeDataUri = string.Empty;
decimal? parsedAmount = null;
if (!string.IsNullOrWhiteSpace(Amount))
{
if (PaymentRequest.TryParseAmount(Amount, out var amount))
{
parsedAmount = amount;
}
else
{
_errors.Add("Invalid amount format. Use a dot as the decimal separator, for example 12.50.");
}
}
var paymentRequest = new PaymentRequest
{
Amount = parsedAmount,
IBAN = Iban ?? string.Empty,
Name = Name ?? string.Empty,
Remark = Remark ?? string.Empty
};
_errors.AddRange(paymentRequest.ValidateForQrRoute());
if (_errors.Count == 0)
{
_qrCodeDataUri = PaymentQrCodeService.GenerateDataUri(paymentRequest);
}
}
}
+46
View File
@@ -0,0 +1,46 @@
.qr-route-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: #ffffff;
}
.qr-route-image {
display: block;
width: min(92vw, 420px);
height: auto;
aspect-ratio: 1;
}
.qr-route-error {
max-width: 420px;
padding: 24px;
border: 1px solid #fecaca;
border-radius: 16px;
background: #fef2f2;
color: #991b1b;
text-align: left;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.08);
}
.qr-route-error p {
margin: 0 0 8px;
}
.qr-route-error p:last-child {
margin-bottom: 0;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
+33
View File
@@ -0,0 +1,33 @@
using QRCoder;
using QRPay.Models;
namespace QRPay.Services;
public static class PaymentQrCodeService
{
public static string GenerateDataUri(PaymentRequest paymentRequest)
{
var payment = new PayBySquareTextGenerator.Payment(
paymentRequest.NormalizedIBAN,
paymentRequest.Amount ?? 0m,
"EUR",
"1",
paymentRequest.Remark ?? string.Empty);
payment.ConstantSymbol = "0308";
if (!string.IsNullOrWhiteSpace(paymentRequest.Name))
{
payment.BeneficiaryName = paymentRequest.Name;
}
var payBySquare = new PayBySquareTextGenerator.PayBySquareOverkill(payment);
var stringData = payBySquare.GeneratePayBySquareOverkillString();
using var qrCodeData = QRCodeGenerator.GenerateQrCode(stringData, QRCodeGenerator.ECCLevel.Q);
using var qrCode = new PngByteQRCode(qrCodeData);
var qrCodeImage = qrCode.GetGraphic(10);
return $"data:image/png;base64,{Convert.ToBase64String(qrCodeImage)}";
}
}
+3 -1
View File
@@ -1,5 +1,6 @@
@using System.Net.Http @using System.Net.Http
@using System.Net.Http.Json @using System.Net.Http.Json
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web @using Microsoft.AspNetCore.Components.Web
@@ -8,3 +9,4 @@
@using Microsoft.JSInterop @using Microsoft.JSInterop
@using QRPay @using QRPay
@using QRPay.Layout @using QRPay.Layout
@using QRPay.Models