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