From d8e94fd985454e322da9ce95b4b17b451387b76a Mon Sep 17 00:00:00 2001 From: Michal Vrba Date: Mon, 16 Mar 2026 07:36:13 +0100 Subject: [PATCH] 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. --- .gitignore | 3 +- QRPay/Layout/EmptyLayout.razor | 3 + QRPay/Models/PaymentRequest.cs | 89 ++++++++++++++++++++++ QRPay/Pages/Home.razor | 100 +++++++------------------ QRPay/Pages/Qr.razor | 78 +++++++++++++++++++ QRPay/Pages/Qr.razor.css | 46 ++++++++++++ QRPay/Services/PaymentQrCodeService.cs | 33 ++++++++ QRPay/_Imports.razor | 4 +- 8 files changed, 279 insertions(+), 77 deletions(-) create mode 100644 QRPay/Layout/EmptyLayout.razor create mode 100644 QRPay/Models/PaymentRequest.cs create mode 100644 QRPay/Pages/Qr.razor create mode 100644 QRPay/Pages/Qr.razor.css create mode 100644 QRPay/Services/PaymentQrCodeService.cs diff --git a/.gitignore b/.gitignore index 9491a2f..62dee30 100644 --- a/.gitignore +++ b/.gitignore @@ -360,4 +360,5 @@ MigrationBackup/ .ionide/ # Fody - auto-generated XML schema -FodyWeavers.xsd \ No newline at end of file +FodyWeavers.xsd +/.dotnet diff --git a/QRPay/Layout/EmptyLayout.razor b/QRPay/Layout/EmptyLayout.razor new file mode 100644 index 0000000..4f3f76d --- /dev/null +++ b/QRPay/Layout/EmptyLayout.razor @@ -0,0 +1,3 @@ +@inherits LayoutComponentBase + +@Body diff --git a/QRPay/Models/PaymentRequest.cs b/QRPay/Models/PaymentRequest.cs new file mode 100644 index 0000000..562cfdb --- /dev/null +++ b/QRPay/Models/PaymentRequest.cs @@ -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 ValidateForQrRoute() + { + var errors = new List(); + 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 Validate(ValidationContext validationContext) + { + if (!string.IsNullOrWhiteSpace(IBAN) && !IbanRegex.IsMatch(NormalizedIBAN)) + { + yield return new ValidationResult( + "Please enter a valid IBAN", + [nameof(IBAN)]); + } + } +} diff --git a/QRPay/Pages/Home.razor b/QRPay/Pages/Home.razor index 81cee84..9606d91 100644 --- a/QRPay/Pages/Home.razor +++ b/QRPay/Pages/Home.razor @@ -1,16 +1,15 @@ -@page "/" -@using QRCoder -@using SixLabors.ImageSharp.Formats -@using System.ComponentModel.DataAnnotations +@page "/" @inject IJSRuntime JSRuntime +@using QRPay.Models +@using QRPay.Services +@implements IDisposable + QRPay - QR Payment Generator
@if (!_showQRCode) { -
-
- - +
@@ -65,17 +63,15 @@ } else { -

Payment QR Code

Scan this code with your banking app

- +
Payment QR Code - - +
- +
To: - @paymentModel.Name + @(string.IsNullOrWhiteSpace(paymentModel.Name) ? "Not provided" : paymentModel.Name)
Amount: - €@paymentModel.Amount.ToString("F2") + €@paymentModel.Amount?.ToString("F2")
@if (!string.IsNullOrWhiteSpace(paymentModel.Remark)) { @@ -110,7 +106,7 @@
}
- + @@ -119,71 +115,28 @@
@code { - private PaymentModel paymentModel = new(); + private PaymentRequest paymentModel = new(); private string QRCodeStr = string.Empty; - private bool _showQRCode = false; - private bool _isGenerating = false; - private bool _isCopying = false; - private bool _copySuccess = false; + private bool _showQRCode; + private bool _isGenerating; + private bool _isCopying; + private bool _copySuccess; 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() { _isGenerating = true; StateHasChanged(); - - // Small delay to show loading state + await Task.Delay(500); - + try { QRCodeStr = string.Empty; if (!string.IsNullOrWhiteSpace(paymentModel.IBAN)) { - using (var ms = new MemoryStream()) - { - 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)}"; - } - + QRCodeStr = PaymentQrCodeService.GenerateDataUri(paymentModel); _showQRCode = true; } } @@ -202,17 +155,15 @@ _copySuccess = false; StateHasChanged(); - // Use JavaScript to copy the QR code image to clipboard with smaller size var success = await JSRuntime.InvokeAsync("copyImageToClipboardSmall", "qrcode-image"); - + _isCopying = false; - + if (success) { _copySuccess = true; StateHasChanged(); - - // Reset the success state after 3 seconds + _copySuccessTimer?.Dispose(); _copySuccessTimer = new System.Threading.Timer(_ => { @@ -226,7 +177,6 @@ else { StateHasChanged(); - // Could show an error message here if needed } } catch (Exception) @@ -240,7 +190,7 @@ { _showQRCode = false; QRCodeStr = string.Empty; - paymentModel = new PaymentModel(); // Reset the form + paymentModel = new PaymentRequest(); _copySuccess = false; _copySuccessTimer?.Dispose(); StateHasChanged(); @@ -250,4 +200,4 @@ { _copySuccessTimer?.Dispose(); } -} \ No newline at end of file +} diff --git a/QRPay/Pages/Qr.razor b/QRPay/Pages/Qr.razor new file mode 100644 index 0000000..1c9254f --- /dev/null +++ b/QRPay/Pages/Qr.razor @@ -0,0 +1,78 @@ +@page "/qr" +@layout EmptyLayout +@using Microsoft.AspNetCore.Components +@using QRPay.Models +@using QRPay.Services + +QR Preview + +
+

QR payment preview

+ + @if (_errors.Count > 0) + { + + } + else if (!string.IsNullOrEmpty(_qrCodeDataUri)) + { + Payment QR code + } +
+ +@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 _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); + } + } +} diff --git a/QRPay/Pages/Qr.razor.css b/QRPay/Pages/Qr.razor.css new file mode 100644 index 0000000..c9f0305 --- /dev/null +++ b/QRPay/Pages/Qr.razor.css @@ -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; +} diff --git a/QRPay/Services/PaymentQrCodeService.cs b/QRPay/Services/PaymentQrCodeService.cs new file mode 100644 index 0000000..1680eb7 --- /dev/null +++ b/QRPay/Services/PaymentQrCodeService.cs @@ -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)}"; + } +} diff --git a/QRPay/_Imports.razor b/QRPay/_Imports.razor index 377467a..7db2ee3 100644 --- a/QRPay/_Imports.razor +++ b/QRPay/_Imports.razor @@ -1,5 +1,6 @@ -@using System.Net.Http +@using System.Net.Http @using System.Net.Http.Json +@using System.ComponentModel.DataAnnotations @using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Web @@ -8,3 +9,4 @@ @using Microsoft.JSInterop @using QRPay @using QRPay.Layout +@using QRPay.Models