Compare commits

...

5 commits

Author SHA1 Message Date
Kruille
378f7fbf2a översättning 2026-02-23 22:10:01 +01:00
Kruille
0dc3d05c68 Merge branch 'main' of https://git.vbytes.se/PmP/vbytes-lan-registration 2026-02-23 22:09:40 +01:00
Kruille
9596da0983 add a become member button if not member 2026-02-23 21:56:14 +01:00
Kruille
3c036a4891 add validation and remove debug and add errorlogs 2026-02-23 21:21:44 +01:00
Kruille
0f673c6a6e add validation on forms in frontend 2026-02-23 20:08:37 +01:00
18 changed files with 789 additions and 139 deletions

View file

@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using AuthAPI; using AuthAPI;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Logging.SetMinimumLevel(LogLevel.Error);
builder.Services.AddHttpClient(); builder.Services.AddHttpClient();
builder.Services.Configure<AuthSettings>( builder.Services.Configure<AuthSettings>(
builder.Configuration.GetSection("EnvironmentVariables")); builder.Configuration.GetSection("EnvironmentVariables"));
@ -18,7 +19,8 @@ app.MapPost("/validate", async (
[FromBody] Request validationRequest, [FromBody] Request validationRequest,
HttpClient httpClient, HttpClient httpClient,
MemberValidationService memberService, MemberValidationService memberService,
IOptions<AuthSettings> settings) => IOptions<AuthSettings> settings,
ILogger<Program> logger) =>
{ {
var request = memberService.GetRequestWithApiKey(validationRequest); var request = memberService.GetRequestWithApiKey(validationRequest);
if (request.IsFailure) if (request.IsFailure)
@ -29,10 +31,12 @@ app.MapPost("/validate", async (
request.Success); request.Success);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{
logger.LogError("Sverok validation returned non-success status code: {StatusCode}", (int)response.StatusCode);
return Results.StatusCode((int)response.StatusCode); return Results.StatusCode((int)response.StatusCode);
}
var content = await response.Content.ReadAsStringAsync(); var content = await response.Content.ReadAsStringAsync();
Console.WriteLine($"[DEBUG] Sverok response: {content}");
return content.Contains("\"member_found\":true") ? Results.Ok() : Results.NotFound(); return content.Contains("\"member_found\":true") ? Results.Ok() : Results.NotFound();
}) })
.WithName("ValidateMember"); .WithName("ValidateMember");

View file

@ -3,9 +3,9 @@ namespace Registration.API.Configuration;
public class VbytesRelayOptions public class VbytesRelayOptions
{ {
public string BaseUrl { get; set; } = string.Empty; public string BaseUrl { get; set; } = string.Empty;
public string ParticipantRegisterPath { get; set; } = "/api/participant"; public string ParticipantRegisterPath { get; set; } = string.Empty;
public string ApiKeyHeaderName { get; set; } = "X-Api-Key"; public string ApiKeyHeaderName { get; set; } = "X-Api-Key";
public string ApiKey { get; set; } = string.Empty; public string ApiKey { get; set; } = string.Empty;
public string ClientCertificatePfxPath { get; set; } = string.Empty; public string ClientCertificatePfxPath { get; set; } = string.Empty;
public string VolunteerRegisterPath { get; set; } = "/api/volunteer"; public string VolunteerRegisterPath { get; set; } = string.Empty;
} }

View file

@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Registration.API.RequestModels;
using Registration.API.Services; using Registration.API.Services;
using Registration.API.Validation;
using Registration.Domain.Models; using Registration.Domain.Models;
namespace Registration.API.Controllers; namespace Registration.API.Controllers;
@ -11,8 +13,33 @@ public class ParticipantController(IVbytesParticipantRelayService relayService)
private readonly IVbytesParticipantRelayService _relayService = relayService; private readonly IVbytesParticipantRelayService _relayService = relayService;
[HttpPost("register")] [HttpPost("register")]
public async Task<IActionResult> RegisterForLan([FromBody] Participant participant, CancellationToken cancellationToken) public async Task<IActionResult> RegisterForLan([FromBody] ParticipantRegistrationRequest request, CancellationToken cancellationToken)
{ {
var normalizedGuardianPhone = InputNormalization.NormalizeSwedishMobile(request.GuardianPhoneNumber);
string? normalizedParticipantPhone = null;
if (!string.IsNullOrWhiteSpace(request.PhoneNumber))
{
normalizedParticipantPhone = InputNormalization.NormalizeSwedishMobile(request.PhoneNumber);
}
var participant = new Participant
{
IsMember = request.IsMember!.Value,
FirstName = request.FirstName.Trim(),
SurName = request.SurName.Trim(),
Grade = request.Grade.Trim(),
PhoneNumber = normalizedParticipantPhone,
Email = request.Email?.Trim(),
GuardianName = request.GuardianName.Trim(),
GuardianPhoneNumber = normalizedGuardianPhone,
GuardianEmail = request.GuardianEmail.Trim(),
IsVisitor = request.IsVisitor!.Value,
HasApprovedGdpr = request.HasApprovedGdpr!.Value,
Friends = request.Friends?.Trim(),
SpecialDiet = request.SpecialDiet?.Trim(),
};
var result = await _relayService.RegisterParticipantAsync(participant, cancellationToken); var result = await _relayService.RegisterParticipantAsync(participant, cancellationToken);
if (result.StatusCode == 200 && !result.Message.Contains("401")) if (result.StatusCode == 200 && !result.Message.Contains("401"))

View file

@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Registration.API.Services; using Registration.API.Services;
using Registration.API.Validation;
using Registration.Infra.Repositories; using Registration.Infra.Repositories;
namespace Registration.API.Controllers namespace Registration.API.Controllers
@ -14,14 +15,24 @@ namespace Registration.API.Controllers
[HttpGet("register/{ssn}")] [HttpGet("register/{ssn}")]
public async Task<IActionResult> ValidateSsn(string ssn, CancellationToken cancellationToken) public async Task<IActionResult> ValidateSsn(string ssn, CancellationToken cancellationToken)
{ {
var isMember = await _authService.IsMemberAsync(ssn, cancellationToken); if (!TryNormalizeSsn(ssn, out var normalizedSsn, out var errorResult))
{
return errorResult;
}
var isMember = await _authService.IsMemberAsync(normalizedSsn, cancellationToken);
return isMember ? Ok() : NotFound(); return isMember ? Ok() : NotFound();
} }
[HttpPost("register/{ssn}")] [HttpPost("register/{ssn}")]
public async Task<IActionResult> RegisterMember(string ssn) public async Task<IActionResult> RegisterMember(string ssn)
{ {
var added = await _memberRepository.AddRegistration(ssn); if (!TryNormalizeSsn(ssn, out var normalizedSsn, out var errorResult))
{
return errorResult;
}
var added = await _memberRepository.AddRegistration(normalizedSsn);
return added ? Ok() : Conflict(); return added ? Ok() : Conflict();
} }
@ -29,7 +40,12 @@ namespace Registration.API.Controllers
[HttpGet("registered/{ssn}")] [HttpGet("registered/{ssn}")]
public async Task<IActionResult> IsMemberRegistered(string ssn) public async Task<IActionResult> IsMemberRegistered(string ssn)
{ {
var isRegistered = await _memberRepository.GetIsRegistered(ssn); if (!TryNormalizeSsn(ssn, out var normalizedSsn, out var errorResult))
{
return errorResult;
}
var isRegistered = await _memberRepository.GetIsRegistered(normalizedSsn);
return isRegistered ? Conflict() : Ok(); return isRegistered ? Conflict() : Ok();
} }
@ -39,5 +55,25 @@ namespace Registration.API.Controllers
await _memberRepository.ClearRegistrations(); await _memberRepository.ClearRegistrations();
return Ok(new { Message = "All registrations cleared." }); return Ok(new { Message = "All registrations cleared." });
} }
private bool TryNormalizeSsn(string ssn, out string normalizedSsn, out IActionResult errorResult)
{
normalizedSsn = InputNormalization.NormalizeSsn(ssn);
if (InputNormalization.IsValidSsn(normalizedSsn))
{
errorResult = Ok();
return true;
}
errorResult = BadRequest(new ValidationProblemDetails(new Dictionary<string, string[]>
{
["ssn"] = ["SSN must contain 10 or 12 digits."]
})
{
Title = "One or more validation errors occurred.",
Status = StatusCodes.Status400BadRequest
});
return false;
}
} }
} }

View file

@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Registration.API.RequestModels;
using Registration.API.Services; using Registration.API.Services;
using Registration.API.Validation;
using Registration.Domain.Models; using Registration.Domain.Models;
namespace Registration.API.Controllers; namespace Registration.API.Controllers;
@ -11,8 +13,35 @@ public class VolunteerController(IVbytesVolunteerRelayService relayService) : Co
private readonly IVbytesVolunteerRelayService _relayService = relayService; private readonly IVbytesVolunteerRelayService _relayService = relayService;
[HttpPost("register")] [HttpPost("register")]
public async Task<IActionResult> RegisterVolunteer([FromBody] Volunteer volunteer, CancellationToken cancellationToken) public async Task<IActionResult> RegisterVolunteer([FromBody] VolunteerRegistrationRequest request, CancellationToken cancellationToken)
{ {
var normalizedPhone = InputNormalization.NormalizeSwedishMobile(request.PhoneNumber);
var trimmedAreas = request.AreasOfInterest
.Select(area => area.Name.Trim())
.Where(name => !string.IsNullOrWhiteSpace(name))
.ToList();
if (trimmedAreas.Count == 0)
{
ModelState.AddModelError(nameof(request.AreasOfInterest), "At least one valid area of interest is required.");
}
if (!ModelState.IsValid)
{
return ValidationProblem(ModelState);
}
var volunteer = new Volunteer
{
FirstName = request.FirstName.Trim(),
SurName = request.SurName.Trim(),
PhoneNumber = normalizedPhone,
Email = request.Email.Trim(),
HasApprovedGdpr = request.HasApprovedGdpr!.Value,
AreasOfInterest = trimmedAreas.Select(name => new AreasOfInterest { Name = name }).ToList()
};
var result = await _relayService.RegisterVolunteerAsync(volunteer, cancellationToken); var result = await _relayService.RegisterVolunteerAsync(volunteer, cancellationToken);
if (result.StatusCode == 200 && !result.Message.Contains("401")) if (result.StatusCode == 200 && !result.Message.Contains("401"))

View file

@ -4,6 +4,7 @@ using Registration.API.Configuration;
using Registration.API.Services; using Registration.API.Services;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Logging.SetMinimumLevel(LogLevel.Error);
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen();

View file

@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace Registration.API.RequestModels;
public class AreaOfInterestRequest
{
[Required]
public string Name { get; set; } = string.Empty;
}

View file

@ -0,0 +1,43 @@
using System.ComponentModel.DataAnnotations;
namespace Registration.API.RequestModels;
public class ParticipantRegistrationRequest
{
[Required]
public bool? IsMember { get; set; }
[Required]
public string FirstName { get; set; } = string.Empty;
[Required]
public string SurName { get; set; } = string.Empty;
[Required]
public string Grade { get; set; } = string.Empty;
public string? PhoneNumber { get; set; }
[EmailAddress]
public string? Email { get; set; }
[Required]
public string GuardianName { get; set; } = string.Empty;
[Required]
public string GuardianPhoneNumber { get; set; } = string.Empty;
[Required]
[EmailAddress]
public string GuardianEmail { get; set; } = string.Empty;
[Required]
public bool? IsVisitor { get; set; }
[Required]
public bool? HasApprovedGdpr { get; set; }
public string? Friends { get; set; }
public string? SpecialDiet { get; set; }
}

View file

@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations;
namespace Registration.API.RequestModels;
public class VolunteerRegistrationRequest
{
[Required]
public string FirstName { get; set; } = string.Empty;
[Required]
public string SurName { get; set; } = string.Empty;
[Required]
public string PhoneNumber { get; set; } = string.Empty;
[Required]
[EmailAddress]
public string Email { get; set; } = string.Empty;
[Required]
public bool? HasApprovedGdpr { get; set; }
[Required]
[MinLength(1)]
public List<AreaOfInterestRequest> AreasOfInterest { get; set; } = [];
}

View file

@ -9,7 +9,7 @@ public class VbytesParticipantRelayService(
HttpClient httpClient, HttpClient httpClient,
IOptions<VbytesRelayOptions> options, IOptions<VbytesRelayOptions> options,
ILogger<VbytesParticipantRelayService> logger) ILogger<VbytesParticipantRelayService> logger)
: VbytesRelayServiceBase(httpClient, options), IVbytesParticipantRelayService : VbytesRelayServiceBase(httpClient, options, logger), IVbytesParticipantRelayService
{ {
public const string HttpClientName = "VbytesRelay"; public const string HttpClientName = "VbytesRelay";
@ -20,24 +20,22 @@ public class VbytesParticipantRelayService(
var configError = ValidateConfiguration(); var configError = ValidateConfiguration();
if (configError is not null) if (configError is not null)
{ {
_logger.LogWarning("Relay configuration invalid: {Message}", configError.Message); _logger.LogError("Relay configuration invalid: {Message}", configError.Message);
return configError; return configError;
} }
var payload = Map(participant); var payload = Map(participant);
_logger.LogInformation("Relaying participant to {Path}. Payload: {@Payload}", Options.ParticipantRegisterPath, payload);
try try
{ {
var result = await SendAsync(Options.ParticipantRegisterPath, payload, cancellationToken); var result = await SendAsync(Options.ParticipantRegisterPath, payload, cancellationToken);
_logger.LogInformation("Relay response: {StatusCode} - {Message}", result.StatusCode, result.Message);
return result; return result;
} }
catch (TaskCanceledException) catch (TaskCanceledException)
{ {
_logger.LogWarning("Relay timed out calling {Path}", Options.ParticipantRegisterPath); _logger.LogError("Relay timed out calling {Path}", Options.ParticipantRegisterPath);
return new VbytesRelayResult(false, StatusCodes.Status504GatewayTimeout, "Upstream timeout."); return new VbytesRelayResult(false, StatusCodes.Status504GatewayTimeout, "Upstream timeout.");
} }

View file

@ -1,4 +1,3 @@
using System.Text.Json;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Registration.API.Configuration; using Registration.API.Configuration;
@ -6,9 +5,11 @@ namespace Registration.API.Services;
public abstract class VbytesRelayServiceBase( public abstract class VbytesRelayServiceBase(
HttpClient httpClient, HttpClient httpClient,
IOptions<VbytesRelayOptions> options) IOptions<VbytesRelayOptions> options,
ILogger logger)
{ {
private readonly HttpClient _httpClient = httpClient; private readonly HttpClient _httpClient = httpClient;
private readonly ILogger _logger = logger;
protected readonly VbytesRelayOptions Options = options.Value; protected readonly VbytesRelayOptions Options = options.Value;
protected async Task<VbytesRelayResult> SendAsync(string path, object payload, CancellationToken cancellationToken) protected async Task<VbytesRelayResult> SendAsync(string path, object payload, CancellationToken cancellationToken)
@ -22,16 +23,16 @@ public abstract class VbytesRelayServiceBase(
using var response = await _httpClient.SendAsync(request, cancellationToken); using var response = await _httpClient.SendAsync(request, cancellationToken);
var body = await response.Content.ReadAsStringAsync(cancellationToken); var body = await response.Content.ReadAsStringAsync(cancellationToken);
var safeBody = body.Length > 600 ? body[..600] : body;
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
Console.WriteLine($"[DEBUG] Relay success body: {body}");
return new VbytesRelayResult(true, StatusCodes.Status200OK, body); return new VbytesRelayResult(true, StatusCodes.Status200OK, body);
} }
var statusCode = (int)response.StatusCode; var statusCode = (int)response.StatusCode;
Console.WriteLine($"[DEBUG] Relay error body: {body}"); _logger.LogError("Outbound relay response error: {StatusCode}. Body: {Body}", statusCode, safeBody);
var message = body.Length > 600 ? body[..600] : body; var message = safeBody;
return new VbytesRelayResult(false, statusCode, string.IsNullOrWhiteSpace(message) ? "Upstream request failed." : message); return new VbytesRelayResult(false, statusCode, string.IsNullOrWhiteSpace(message) ? "Upstream request failed." : message);
} }
@ -60,17 +61,6 @@ public abstract class VbytesRelayServiceBase(
return path.StartsWith('/') ? path : $"/{path}"; return path.StartsWith('/') ? path : $"/{path}";
} }
private static async Task<string> ReadSafeErrorMessage(HttpResponseMessage response, CancellationToken cancellationToken)
{
var body = await response.Content.ReadAsStringAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(body))
{
return "Upstream request failed.";
}
return body.Length > 600 ? body[..600] : body;
}
protected static bool IsConfigured(string value) protected static bool IsConfigured(string value)
{ {
return !string.IsNullOrWhiteSpace(value) && !value.StartsWith("__SET_", StringComparison.Ordinal); return !string.IsNullOrWhiteSpace(value) && !value.StartsWith("__SET_", StringComparison.Ordinal);

View file

@ -9,7 +9,7 @@ public class VbytesVolunteerRelayService(
HttpClient httpClient, HttpClient httpClient,
IOptions<VbytesRelayOptions> options, IOptions<VbytesRelayOptions> options,
ILogger<VbytesVolunteerRelayService> logger) ILogger<VbytesVolunteerRelayService> logger)
: VbytesRelayServiceBase(httpClient, options), IVbytesVolunteerRelayService : VbytesRelayServiceBase(httpClient, options, logger), IVbytesVolunteerRelayService
{ {
private readonly ILogger<VbytesVolunteerRelayService> _logger = logger; private readonly ILogger<VbytesVolunteerRelayService> _logger = logger;

View file

@ -0,0 +1,31 @@
using System.Text.RegularExpressions;
namespace Registration.API.Validation;
public static partial class InputNormalization
{
public static string NormalizeSsn(string value)
{
var raw = value ?? string.Empty;
return NonDigitsRegex().Replace(raw, string.Empty);
}
public static bool IsValidSsn(string value)
{
return value.Length is 10 or 12;
}
public static string NormalizeSwedishMobile(string value)
{
var digitsOnly = NonDigitsRegex().Replace(value ?? string.Empty, string.Empty);
if (digitsOnly.StartsWith("0046")) return $"0{digitsOnly[4..]}";
if (digitsOnly.StartsWith("46")) return $"0{digitsOnly[2..]}";
if (digitsOnly.StartsWith('7')) return $"0{digitsOnly}";
return digitsOnly;
}
[GeneratedRegex("\\D")]
private static partial Regex NonDigitsRegex();
}

View file

@ -14,8 +14,8 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Vbytes LAN Registration", title: "Vbytes LAN-anmälan",
description: "Register for the upcoming Vbytes LAN event.", description: "Anmäl dig till kommande LAN-evenemang hos Vbytes.",
}; };
export default function RootLayout({ export default function RootLayout({
@ -24,7 +24,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en"> <html lang="sv">
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
> >

View file

@ -40,7 +40,7 @@ export default function LandingPage() {
return ( return (
<div className="min-h-screen bg-white text-gray-900 font-sans"> <div className="min-h-screen bg-white text-gray-900 font-sans">
{/* Hero Section */} {/* Hero Section */}
<header className="relative h-[70vh] flex items-center justify-center bg-black overflow-hidden"> <header className="relative h-[52vh] flex items-center justify-center bg-black overflow-hidden">
<div className="absolute inset-0 opacity-50"> <div className="absolute inset-0 opacity-50">
<Image <Image
src="https://images.unsplash.com/photo-1542751371-adc38448a05e?auto=format&fit=crop&q=80&w=2070" src="https://images.unsplash.com/photo-1542751371-adc38448a05e?auto=format&fit=crop&q=80&w=2070"
@ -60,15 +60,34 @@ export default function LandingPage() {
</div> </div>
</header> </header>
<div className="max-w-4xl mx-auto px-6 pt-6 flex justify-center">
<Image
src="/vBytes_Logotyp.png"
alt="vBytes"
width={320}
height={128}
className="h-auto w-40"
priority
/>
</div>
{/* Info Section */} {/* Info Section */}
<main className="max-w-4xl mx-auto py-16 px-6 space-y-20"> <main className="max-w-4xl mx-auto py-12 px-6 space-y-20">
{(content.eventDate || content.eventTime || content.locationName || content.locationAddress || content.additionalInfo) && ( {(content.eventDate ||
content.eventTime ||
content.locationName ||
content.locationAddress ||
content.additionalInfo) && (
<section className="space-y-6"> <section className="space-y-6">
<h2 className="text-3xl font-bold border-b-2 border-blue-500 pb-2 inline-block">Information</h2> <h2 className="text-3xl font-bold border-b-2 border-blue-500 pb-2 inline-block">
Information
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mt-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-8 mt-6">
{(content.eventDate || content.eventTime) && ( {(content.eventDate || content.eventTime) && (
<div className="space-y-2"> <div className="space-y-2">
<h3 className="font-semibold text-lg text-blue-600">Tid/Datum</h3> <h3 className="font-semibold text-lg text-blue-600">
Tid/Datum
</h3>
{content.eventDate && <p>{content.eventDate}</p>} {content.eventDate && <p>{content.eventDate}</p>}
{content.eventTime && <p>{content.eventTime}</p>} {content.eventTime && <p>{content.eventTime}</p>}
</div> </div>
@ -91,9 +110,14 @@ export default function LandingPage() {
{content.whatToBring && ( {content.whatToBring && (
<section className="space-y-6"> <section className="space-y-6">
<h2 className="text-3xl font-bold border-b-2 border-blue-500 pb-2 inline-block">Ta Med(bokad LAN-plats)</h2> <h2 className="text-3xl font-bold border-b-2 border-blue-500 pb-2 inline-block">
Ta Med(bokad LAN-plats)
</h2>
<ul className="list-disc list-inside space-y-3 text-lg text-gray-700"> <ul className="list-disc list-inside space-y-3 text-lg text-gray-700">
{content.whatToBring.split('\n').filter(line => line.trim()).map((line, i) => ( {content.whatToBring
.split("\n")
.filter((line) => line.trim())
.map((line, i) => (
<li key={i}>{line}</li> <li key={i}>{line}</li>
))} ))}
</ul> </ul>
@ -102,7 +126,9 @@ export default function LandingPage() {
{content.rulesAndGdpr && ( {content.rulesAndGdpr && (
<section className="space-y-6"> <section className="space-y-6">
<h2 className="text-3xl font-bold border-b-2 border-blue-500 pb-2 inline-block">Regler för LAN & GDPR</h2> <h2 className="text-3xl font-bold border-b-2 border-blue-500 pb-2 inline-block">
Regler för LAN & GDPR
</h2>
<p className="text-lg text-gray-700 leading-relaxed whitespace-pre-wrap"> <p className="text-lg text-gray-700 leading-relaxed whitespace-pre-wrap">
{content.rulesAndGdpr} {content.rulesAndGdpr}
</p> </p>
@ -114,7 +140,9 @@ export default function LandingPage() {
<section className="flex flex-col items-center justify-center space-y-8 pt-12 border-t border-gray-100"> <section className="flex flex-col items-center justify-center space-y-8 pt-12 border-t border-gray-100">
<div className="text-center space-y-2"> <div className="text-center space-y-2">
<h2 className="text-4xl font-bold">Vill du delta?</h2> <h2 className="text-4xl font-bold">Vill du delta?</h2>
<p className="text-gray-600 text-lg">Begränsade platser, Säkra din plats idag!</p> <p className="text-gray-600 text-lg">
Begränsade platser, Säkra din plats idag!
</p>
</div> </div>
<Link <Link
href="/register" href="/register"
@ -137,7 +165,7 @@ export default function LandingPage() {
Bli Funktionär Bli Funktionär
</Link> </Link>
</div> </div>
<p>© 2026 Vbytes Gaming. All rights reserved.</p> <p>© 2026 Vbytes Gaming. Alla rättigheter förbehållna.</p>
</footer> </footer>
</div> </div>
); );

View file

@ -2,7 +2,9 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import router from "next/router";
interface EventContent { interface EventContent {
registrationEnabled: boolean; registrationEnabled: boolean;
@ -10,6 +12,22 @@ interface EventContent {
volunteerAreas: string; volunteerAreas: string;
} }
const normalizeSsn = (value: string) =>
value.replace(/[\s\-+]/g, "").replace(/\D/g, "");
const hasValidSsnLength = (value: string) =>
value.length === 10 || value.length === 12;
const isValidEmail = (value: string) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
const normalizeMobileNumber = (value: string) => {
const digitsOnly = value.replace(/\D/g, "");
if (digitsOnly.startsWith("0046")) return `0${digitsOnly.slice(4)}`;
if (digitsOnly.startsWith("46")) return `0${digitsOnly.slice(2)}`;
if (digitsOnly.startsWith("7")) return `0${digitsOnly}`;
return digitsOnly;
};
const isValidMobileNumber = (value: string) =>
/^07\d{8}$/.test(normalizeMobileNumber(value));
export default function RegisterPage() { export default function RegisterPage() {
const [content, setContent] = useState<EventContent | null>(null); const [content, setContent] = useState<EventContent | null>(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@ -30,6 +48,8 @@ export default function RegisterPage() {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [message, setMessage] = useState({ type: "", text: "" }); const [message, setMessage] = useState({ type: "", text: "" });
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [showBecomeMemberCta, setShowBecomeMemberCta] = useState(false);
useEffect(() => { useEffect(() => {
fetch("/api/Content") fetch("/api/Content")
@ -47,53 +67,143 @@ export default function RegisterPage() {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4"> <div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div className="max-w-md w-full bg-white p-8 rounded-xl shadow-md text-center"> <div className="max-w-md w-full bg-white p-8 rounded-xl shadow-md text-center">
<h1 className="text-2xl font-bold text-gray-900 mb-4">Registreringen är stängd</h1> <h1 className="text-2xl font-bold text-gray-900 mb-4">
<p className="text-gray-600 mb-6">Vi tar tyvärr inte emot fler anmälningar just nu.</p> Registreringen är stängd
<Link href="/" className="text-blue-600 hover:underline"> tillbaka till startsidan</Link> </h1>
<p className="text-gray-600 mb-6">
Vi tar tyvärr inte emot fler anmälningar just nu.
</p>
<Link href="/" className="text-blue-600 hover:underline">
tillbaka till startsidan
</Link>
</div> </div>
</div> </div>
); );
} }
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => { const handleInputChange = (
e: React.ChangeEvent<
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
>,
) => {
const { name, value, type } = e.target; const { name, value, type } = e.target;
const val = type === "checkbox" ? (e.target as HTMLInputElement).checked : value; const val =
type === "checkbox" ? (e.target as HTMLInputElement).checked : value;
setFormData((prev) => ({ setFormData((prev) => ({
...prev, ...prev,
[name]: val, [name]: val,
})); }));
setFieldErrors((prev) => ({
...prev,
[name]: "",
}));
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!formData.ssn) { const normalizedSsn = normalizeSsn(formData.ssn);
setMessage({ type: "error", text: "SSN is required for registration." }); const trimmedFirstName = formData.firstName.trim();
const trimmedSurName = formData.surName.trim();
const trimmedGrade = formData.grade.trim();
const trimmedPhoneNumber = formData.phoneNumber.trim();
const trimmedGuardianName = formData.guardianName.trim();
const trimmedGuardianPhone = formData.guardianPhoneNumber.trim();
const trimmedGuardianEmail = formData.guardianEmail.trim();
const normalizedPhoneNumber = normalizeMobileNumber(trimmedPhoneNumber);
const normalizedGuardianPhone = normalizeMobileNumber(trimmedGuardianPhone);
const nextFieldErrors: Record<string, string> = {};
if (!normalizedSsn) {
nextFieldErrors.ssn = "Personnummer krävs för registrering.";
} else if (!hasValidSsnLength(normalizedSsn)) {
nextFieldErrors.ssn = "Personnummer måste vara 10 eller 12 siffror.";
}
if (!trimmedFirstName)
nextFieldErrors.firstName = "Förnamn är obligatoriskt.";
if (!trimmedSurName)
nextFieldErrors.surName = "Efternamn är obligatoriskt.";
if (!trimmedGrade) nextFieldErrors.grade = "Årskurs är obligatorisk.";
if (!trimmedGuardianName)
nextFieldErrors.guardianName = "Vårdnadshavares namn är obligatoriskt.";
if (!trimmedGuardianPhone)
nextFieldErrors.guardianPhoneNumber =
"Vårdnadshavares Mobilnummer är obligatoriskt.";
if (!trimmedGuardianEmail)
nextFieldErrors.guardianEmail = "Vårdnadshavares e-post är obligatorisk.";
if (trimmedPhoneNumber && !isValidMobileNumber(trimmedPhoneNumber)) {
nextFieldErrors.phoneNumber =
"Mobilnummer måste vara ett giltigt svenskt mobilnummer.";
}
if (trimmedGuardianPhone && !isValidMobileNumber(trimmedGuardianPhone)) {
nextFieldErrors.guardianPhoneNumber =
"Vårdnadshavares Mobilnummer måste vara ett giltigt svenskt mobilnummer.";
}
if (trimmedGuardianEmail && !isValidEmail(trimmedGuardianEmail)) {
nextFieldErrors.guardianEmail =
"Vårdnadshavarens e-postadress är ogiltig.";
}
if (formData.email.trim() && !isValidEmail(formData.email.trim())) {
nextFieldErrors.email = "Din e-postadress är ogiltig.";
}
if (Object.keys(nextFieldErrors).length > 0) {
setFieldErrors(nextFieldErrors);
setMessage({
type: "error",
text: "Kontrollera markerade fält och försök igen.",
});
return; return;
} }
setFieldErrors({});
setShowBecomeMemberCta(false);
setIsSubmitting(true); setIsSubmitting(true);
setMessage({ type: "info", text: "Processing your registration..." }); setMessage({ type: "info", text: "Behandlar din anmälan..." });
try { try {
// Returns Ok (200) if NOT registered, Conflict (409) if registered // Returns Ok (200) if NOT registered, Conflict (409) if registered
const checkRes = await fetch(`/api/Registration/registered/${formData.ssn}`); const checkRes = await fetch(
`/api/Registration/registered/${normalizedSsn}`,
);
if (checkRes.status === 409) { if (checkRes.status === 409) {
setMessage({ type: "error", text: "This SSN is already registered for the LAN." }); setMessage({
type: "error",
text: "Detta personnummer är redan registrerat för LAN:et.",
});
setIsSubmitting(false); setIsSubmitting(false);
return; return;
} }
// Returns 200 (Ok) if member, 404 (NotFound) if not // Returns 200 (Ok) if member, 404 (NotFound) if not
const validateRes = await fetch(`/api/Registration/register/${formData.ssn}`); const validateRes = await fetch(
`/api/Registration/register/${normalizedSsn}`,
);
const isMember = validateRes.ok; const isMember = validateRes.ok;
const { ssn, ...participantData } = formData; const { ssn, ...participantData } = formData;
const finalPayload = { const finalPayload = {
...participantData, ...participantData,
isMember: isMember firstName: trimmedFirstName,
surName: trimmedSurName,
grade: trimmedGrade,
phoneNumber: normalizedPhoneNumber,
email: formData.email.trim(),
guardianName: trimmedGuardianName,
guardianPhoneNumber: normalizedGuardianPhone,
guardianEmail: trimmedGuardianEmail,
friends: formData.friends.trim(),
specialDiet: formData.specialDiet.trim(),
isMember: isMember,
}; };
const response = await fetch("/api/Participant/register", { const response = await fetch("/api/Participant/register", {
@ -105,18 +215,28 @@ export default function RegisterPage() {
}); });
if (response.ok) { if (response.ok) {
setMessage({ type: "success", text: "Registrering slutförd!" }); setMessage({
type: "success",
text: "Registreringen är klar! Du är nu anmäld till LAN:et.",
});
setShowBecomeMemberCta(!isMember);
await fetch(`/api/Registration/register/${formData.ssn}`, { await fetch(`/api/Registration/register/${normalizedSsn}`, {
method: "POST", method: "POST",
}); });
} else { } else {
const errorData = await response.json(); const errorData = await response.json();
setMessage({ type: "error", text: errorData.message || "Event registration failed." }); setMessage({
type: "error",
text: errorData.message || "Registreringen misslyckades.",
});
} }
} catch (error) { } catch (error) {
console.error("Registration error:", error); console.error("Registration error:", error);
setMessage({ type: "error", text: "A connection error occurred. Please check your internet and try again." }); setMessage({
type: "error",
text: "Ett anslutningsfel uppstod. Kontrollera internet och försök igen.",
});
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@ -125,146 +245,264 @@ export default function RegisterPage() {
return ( return (
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8"> <div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-2xl mx-auto bg-white p-8 rounded-xl shadow-md"> <div className="max-w-2xl mx-auto bg-white p-8 rounded-xl shadow-md">
<div className="mb-6"> <div className="mb-3">
<Link href="/" className="text-blue-600 hover:text-blue-800 flex items-center gap-1 text-sm font-medium"> <button
Tillbaka till startsidan type="button"
</Link> onClick={() => router.push("/")}
className="inline-flex items-center gap-1 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
<span></span>
<span className="sm:hidden">Tillbaka</span>
<span className="hidden sm:inline">Tillbaka till information</span>
</button>
</div>
<div className="flex justify-center mb-6">
<Image
src="/vBytes_Logotyp.png"
alt="vBytes"
width={320}
height={128}
className="h-auto w-44"
priority
/>
</div> </div>
<div className="text-center mb-10"> <div className="text-center mb-10">
<h1 className="text-3xl font-bold text-gray-900">Lan Registrering</h1> <h1 className="text-3xl font-bold text-gray-900">LAN-registrering</h1>
<p className="mt-2 text-gray-600">Vänligen fyll i detta formulär för att anmäla dig till lanet.</p> <p className="mt-2 text-gray-600">
Vänligen fyll i detta formulär för att anmäla dig till lanet.
</p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-2">
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<label htmlFor="ssn" className="block text-sm font-medium text-gray-700">Personnummer*</label> <label
htmlFor="ssn"
className="block text-sm font-medium text-gray-700"
>
Personnummer*
</label>
<input <input
type="text" type="text"
name="ssn" name="ssn"
id="ssn" id="ssn"
placeholder="YYYYMMDD-XXXX" placeholder="YYYYMMDD-XXXX"
required required
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm p-2 border text-gray-900" className={`mt-1 block w-full rounded-md shadow-sm sm:text-sm p-2 border text-gray-900 ${fieldErrors.ssn ? "border-red-500 focus:border-red-500 focus:ring-red-500" : "border-gray-300 focus:border-blue-500 focus:ring-blue-500"}`}
value={formData.ssn} value={formData.ssn}
onChange={handleInputChange} onChange={handleInputChange}
/> />
<p className="mt-1 text-xs text-gray-500">Används endast för att kolla om du redan är medlem eller inte. Sparas ej.</p> {fieldErrors.ssn && (
<p className="mt-1 text-xs text-red-600">{fieldErrors.ssn}</p>
)}
<p className="mt-1 text-xs text-gray-500">
Används endast för att kolla om du redan är medlem eller inte.
Sparas ej.
</p>
</div> </div>
<div> <div>
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700">Förnamn *</label> <label
htmlFor="firstName"
className="block text-sm font-medium text-gray-700"
>
Förnamn *
</label>
<input <input
type="text" type="text"
name="firstName" name="firstName"
id="firstName" id="firstName"
required required
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm p-2 border text-gray-900" className={`mt-1 block w-full rounded-md shadow-sm sm:text-sm p-2 border text-gray-900 ${fieldErrors.firstName ? "border-red-500 focus:border-red-500 focus:ring-red-500" : "border-gray-300 focus:border-blue-500 focus:ring-blue-500"}`}
value={formData.firstName} value={formData.firstName}
onChange={handleInputChange} onChange={handleInputChange}
/> />
{fieldErrors.firstName && (
<p className="mt-1 text-xs text-red-600">
{fieldErrors.firstName}
</p>
)}
</div> </div>
<div> <div>
<label htmlFor="surName" className="block text-sm font-medium text-gray-700">Efternamn *</label> <label
htmlFor="surName"
className="block text-sm font-medium text-gray-700"
>
Efternamn *
</label>
<input <input
type="text" type="text"
name="surName" name="surName"
id="surName" id="surName"
required required
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm p-2 border text-gray-900" className={`mt-1 block w-full rounded-md shadow-sm sm:text-sm p-2 border text-gray-900 ${fieldErrors.surName ? "border-red-500 focus:border-red-500 focus:ring-red-500" : "border-gray-300 focus:border-blue-500 focus:ring-blue-500"}`}
value={formData.surName} value={formData.surName}
onChange={handleInputChange} onChange={handleInputChange}
/> />
{fieldErrors.surName && (
<p className="mt-1 text-xs text-red-600">
{fieldErrors.surName}
</p>
)}
</div> </div>
<div> <div>
<label htmlFor="grade" className="block text-sm font-medium text-gray-700">Årskurs *</label> <label
htmlFor="grade"
className="block text-sm font-medium text-gray-700"
>
Årskurs *
</label>
<input <input
type="text" type="text"
name="grade" name="grade"
id="grade" id="grade"
required required
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm p-2 border text-gray-900" className={`mt-1 block w-full rounded-md shadow-sm sm:text-sm p-2 border text-gray-900 ${fieldErrors.grade ? "border-red-500 focus:border-red-500 focus:ring-red-500" : "border-gray-300 focus:border-blue-500 focus:ring-blue-500"}`}
value={formData.grade} value={formData.grade}
onChange={handleInputChange} onChange={handleInputChange}
/> />
{fieldErrors.grade && (
<p className="mt-1 text-xs text-red-600">{fieldErrors.grade}</p>
)}
</div> </div>
<div> <div>
<label htmlFor="phoneNumber" className="block text-sm font-medium text-gray-700">Mobilnummer</label> <label
htmlFor="phoneNumber"
className="block text-sm font-medium text-gray-700"
>
Mobilnummer
</label>
<input <input
type="tel" type="tel"
name="phoneNumber" name="phoneNumber"
id="phoneNumber" id="phoneNumber"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm p-2 border text-gray-900" inputMode="tel"
placeholder="07XXXXXXXX"
className={`mt-1 block w-full rounded-md shadow-sm sm:text-sm p-2 border text-gray-900 ${fieldErrors.phoneNumber ? "border-red-500 focus:border-red-500 focus:ring-red-500" : "border-gray-300 focus:border-blue-500 focus:ring-blue-500"}`}
value={formData.phoneNumber} value={formData.phoneNumber}
onChange={handleInputChange} onChange={handleInputChange}
/> />
{fieldErrors.phoneNumber && (
<p className="mt-1 text-xs text-red-600">
{fieldErrors.phoneNumber}
</p>
)}
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<label htmlFor="email" className="block text-sm font-medium text-gray-700">Email</label> <label
htmlFor="email"
className="block text-sm font-medium text-gray-700"
>
E-post
</label>
<input <input
type="email" type="email"
name="email" name="email"
id="email" id="email"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm p-2 border text-gray-900" className={`mt-1 block w-full rounded-md shadow-sm sm:text-sm p-2 border text-gray-900 ${fieldErrors.email ? "border-red-500 focus:border-red-500 focus:ring-red-500" : "border-gray-300 focus:border-blue-500 focus:ring-blue-500"}`}
value={formData.email} value={formData.email}
onChange={handleInputChange} onChange={handleInputChange}
/> />
{fieldErrors.email && (
<p className="mt-1 text-xs text-red-600">{fieldErrors.email}</p>
)}
</div> </div>
<div className="sm:col-span-2 pt-4 border-t border-gray-100 mt-4"> <div className="sm:col-span-2 pt-4 border-t border-gray-100 mt-4">
<h3 className="text-lg font-medium text-gray-900 mb-4">Vårdnadshavares Information</h3> <h3 className="text-lg font-medium text-gray-900 mb-4">
Vårdnadshavares Information
</h3>
</div> </div>
<div> <div>
<label htmlFor="guardianName" className="block text-sm font-medium text-gray-700">Vårdnadshavares Namn *</label> <label
htmlFor="guardianName"
className="block text-sm font-medium text-gray-700"
>
Vårdnadshavares Namn *
</label>
<input <input
type="text" type="text"
name="guardianName" name="guardianName"
id="guardianName" id="guardianName"
required required
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm p-2 border text-gray-900" className={`mt-1 block w-full rounded-md shadow-sm sm:text-sm p-2 border text-gray-900 ${fieldErrors.guardianName ? "border-red-500 focus:border-red-500 focus:ring-red-500" : "border-gray-300 focus:border-blue-500 focus:ring-blue-500"}`}
value={formData.guardianName} value={formData.guardianName}
onChange={handleInputChange} onChange={handleInputChange}
/> />
{fieldErrors.guardianName && (
<p className="mt-1 text-xs text-red-600">
{fieldErrors.guardianName}
</p>
)}
</div> </div>
<div> <div>
<label htmlFor="guardianPhoneNumber" className="block text-sm font-medium text-gray-700">Vårdnadshavares Mobilnummer *</label> <label
htmlFor="guardianPhoneNumber"
className="block text-sm font-medium text-gray-700"
>
Vårdnadshavares Mobilnummer *
</label>
<input <input
type="tel" type="tel"
name="guardianPhoneNumber" name="guardianPhoneNumber"
id="guardianPhoneNumber" id="guardianPhoneNumber"
required required
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm p-2 border text-gray-900" inputMode="tel"
placeholder="07XXXXXXXX"
className={`mt-1 block w-full rounded-md shadow-sm sm:text-sm p-2 border text-gray-900 ${fieldErrors.guardianPhoneNumber ? "border-red-500 focus:border-red-500 focus:ring-red-500" : "border-gray-300 focus:border-blue-500 focus:ring-blue-500"}`}
value={formData.guardianPhoneNumber} value={formData.guardianPhoneNumber}
onChange={handleInputChange} onChange={handleInputChange}
/> />
{fieldErrors.guardianPhoneNumber && (
<p className="mt-1 text-xs text-red-600">
{fieldErrors.guardianPhoneNumber}
</p>
)}
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<label htmlFor="guardianEmail" className="block text-sm font-medium text-gray-700">Vårdnadshavares Email *</label> <label
htmlFor="guardianEmail"
className="block text-sm font-medium text-gray-700"
>
Vårdnadshavares E-post *
</label>
<input <input
type="email" type="email"
name="guardianEmail" name="guardianEmail"
id="guardianEmail" id="guardianEmail"
required required
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm p-2 border text-gray-900" className={`mt-1 block w-full rounded-md shadow-sm sm:text-sm p-2 border text-gray-900 ${fieldErrors.guardianEmail ? "border-red-500 focus:border-red-500 focus:ring-red-500" : "border-gray-300 focus:border-blue-500 focus:ring-blue-500"}`}
value={formData.guardianEmail} value={formData.guardianEmail}
onChange={handleInputChange} onChange={handleInputChange}
/> />
{fieldErrors.guardianEmail && (
<p className="mt-1 text-xs text-red-600">
{fieldErrors.guardianEmail}
</p>
)}
</div> </div>
<div className="sm:col-span-2 pt-4 border-t border-gray-100 mt-4"> <div className="sm:col-span-2 pt-4 border-t border-gray-100 mt-4">
<h3 className="text-lg font-medium text-gray-900 mb-4">Yttrligare uppgifter</h3> <h3 className="text-lg font-medium text-gray-900 mb-4">
Ytterligare uppgifter
</h3>
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<label htmlFor="friends" className="block text-sm font-medium text-gray-700">"Önska" Vänner (att sitta jämte)</label> <label
htmlFor="friends"
className="block text-sm font-medium text-gray-700"
>
"Önska" Vänner (att sitta jämte)
</label>
<input <input
type="text" type="text"
name="friends" name="friends"
@ -276,7 +514,12 @@ export default function RegisterPage() {
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<label htmlFor="specialDiet" className="block text-sm font-medium text-gray-700">Specialkost / Allergier</label> <label
htmlFor="specialDiet"
className="block text-sm font-medium text-gray-700"
>
Specialkost / Allergier
</label>
<textarea <textarea
name="specialDiet" name="specialDiet"
id="specialDiet" id="specialDiet"
@ -301,7 +544,12 @@ export default function RegisterPage() {
/> />
</div> </div>
<div className="ml-3 text-sm"> <div className="ml-3 text-sm">
<label htmlFor="isVisitor" className="font-medium text-gray-700">Registrera dig som besökare</label> <label
htmlFor="isVisitor"
className="font-medium text-gray-700"
>
Registrera dig som besökare
</label>
<p className="text-gray-500"> <p className="text-gray-500">
{content?.visitorOnly {content?.visitorOnly
? "Just nu tillåts endast besöks-registreringar." ? "Just nu tillåts endast besöks-registreringar."
@ -322,29 +570,53 @@ export default function RegisterPage() {
/> />
</div> </div>
<div className="ml-3 text-sm"> <div className="ml-3 text-sm">
<label htmlFor="hasApprovedGdpr" className="font-medium text-gray-700">GDPR gokännande *</label> <label
<p className="text-gray-500">Jag Godkänner att min personliga uppgifter sparas och hanteras i syfte för detta event.</p> htmlFor="hasApprovedGdpr"
className="font-medium text-gray-700"
>
GDPR-godkännande *
</label>
<p className="text-gray-500">
Jag godkänner att mina personuppgifter sparas och hanteras i
syfte för detta event.
</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{message.text && ( {message.text && (
<div className={`p-4 rounded-md ${message.type === "success" ? "bg-green-50 text-green-800 border border-green-200" : <div
message.type === "error" ? "bg-red-50 text-red-800 border border-red-200" : className={`p-4 rounded-md ${
"bg-blue-50 text-blue-800 border border-blue-200" message.type === "success"
}`}> ? "bg-green-50 text-green-800 border border-green-200"
: message.type === "error"
? "bg-red-50 text-red-800 border border-red-200"
: "bg-blue-50 text-blue-800 border border-blue-200"
}`}
>
{message.text} {message.text}
</div> </div>
)} )}
{showBecomeMemberCta && message.type === "success" && (
<a
href="https://ebas.sverok.se/blimedlem/22393"
target="_blank"
rel="noreferrer"
className="w-full inline-flex justify-center py-3 px-4 border border-transparent rounded-md shadow-sm text-base font-medium text-white bg-gray-800 hover:bg-black focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-700"
>
Klicka här för att bli medlem i vBytes
</a>
)}
<div className="pt-6"> <div className="pt-6">
<button <button
type="submit" type="submit"
disabled={isSubmitting} disabled={isSubmitting}
className="w-full flex justify-center py-3 px-4 border border-transparent rounded-md shadow-sm text-base font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:bg-blue-400" className="w-full flex justify-center py-3 px-4 border border-transparent rounded-md shadow-sm text-base font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:bg-blue-400"
> >
{isSubmitting ? "Registering..." : "Register for LAN"} {isSubmitting ? "Registrerar..." : "Anmäl dig till LAN"}
</button> </button>
</div> </div>
</form> </form>

View file

@ -1,13 +1,27 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Image from "next/image";
import { useRouter } from "next/navigation";
interface EventContent { interface EventContent {
volunteerAreas: string; volunteerAreas: string;
} }
const isValidEmail = (value: string) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
const normalizeMobileNumber = (value: string) => {
const digitsOnly = value.replace(/\D/g, "");
if (digitsOnly.startsWith("0046")) return `0${digitsOnly.slice(4)}`;
if (digitsOnly.startsWith("46")) return `0${digitsOnly.slice(2)}`;
if (digitsOnly.startsWith("7")) return `0${digitsOnly}`;
return digitsOnly;
};
const isValidMobileNumber = (value: string) =>
/^07\d{8}$/.test(normalizeMobileNumber(value));
export default function VolunteerPage() { export default function VolunteerPage() {
const router = useRouter();
const [content, setContent] = useState<EventContent | null>(null); const [content, setContent] = useState<EventContent | null>(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
firstName: "", firstName: "",
@ -20,6 +34,7 @@ export default function VolunteerPage() {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [message, setMessage] = useState({ type: "", text: "" }); const [message, setMessage] = useState({ type: "", text: "" });
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
useEffect(() => { useEffect(() => {
fetch("/api/Content") fetch("/api/Content")
@ -34,6 +49,11 @@ export default function VolunteerPage() {
...prev, ...prev,
[name]: type === "checkbox" ? checked : value, [name]: type === "checkbox" ? checked : value,
})); }));
setFieldErrors((prev) => ({
...prev,
[name]: "",
}));
}; };
const handleAreaToggle = (area: string) => { const handleAreaToggle = (area: string) => {
@ -43,27 +63,69 @@ export default function VolunteerPage() {
: [...prev.selectedAreas, area]; : [...prev.selectedAreas, area];
return { ...prev, selectedAreas: areas }; return { ...prev, selectedAreas: areas };
}); });
setFieldErrors((prev) => ({
...prev,
selectedAreas: "",
}));
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const trimmedFirstName = formData.firstName.trim();
const trimmedSurName = formData.surName.trim();
const trimmedPhoneNumber = formData.phoneNumber.trim();
const trimmedEmail = formData.email.trim();
const normalizedPhoneNumber = normalizeMobileNumber(trimmedPhoneNumber);
const nextFieldErrors: Record<string, string> = {};
if (!trimmedFirstName)
nextFieldErrors.firstName = "Förnamn är obligatoriskt.";
if (!trimmedSurName)
nextFieldErrors.surName = "Efternamn är obligatoriskt.";
if (!trimmedPhoneNumber)
nextFieldErrors.phoneNumber = "Mobilnummer är obligatoriskt.";
if (!trimmedEmail) nextFieldErrors.email = "E-post är obligatorisk.";
if (trimmedEmail && !isValidEmail(trimmedEmail)) {
nextFieldErrors.email = "E-postadressen är ogiltig.";
}
if (trimmedPhoneNumber && !isValidMobileNumber(trimmedPhoneNumber)) {
nextFieldErrors.phoneNumber =
"Mobilnummer måste vara ett giltigt svenskt mobilnummer.";
}
if (formData.selectedAreas.length === 0) { if (formData.selectedAreas.length === 0) {
setMessage({ type: "error", text: "Vänligen välj minst ett område du vill hjälpa till med." }); nextFieldErrors.selectedAreas =
"Vänligen välj minst ett område du vill hjälpa till med.";
}
if (Object.keys(nextFieldErrors).length > 0) {
setFieldErrors(nextFieldErrors);
setMessage({
type: "error",
text: "Kontrollera markerade fält och försök igen.",
});
return; return;
} }
setFieldErrors({});
setIsSubmitting(true); setIsSubmitting(true);
setMessage({ type: "info", text: "Skickar in din ansökan..." }); setMessage({ type: "info", text: "Skickar in din ansökan..." });
try { try {
const payload = { const payload = {
firstName: formData.firstName, firstName: trimmedFirstName,
surName: formData.surName, surName: trimmedSurName,
phoneNumber: formData.phoneNumber, phoneNumber: normalizedPhoneNumber,
email: formData.email, email: trimmedEmail,
hasApprovedGdpr: formData.hasApprovedGdpr, hasApprovedGdpr: formData.hasApprovedGdpr,
areasOfInterest: formData.selectedAreas.map(name => ({ name })) areasOfInterest: formData.selectedAreas.map((name) => ({
name: name.trim(),
})),
}; };
const response = await fetch("/api/Volunteer/register", { const response = await fetch("/api/Volunteer/register", {
@ -75,7 +137,10 @@ export default function VolunteerPage() {
}); });
if (response.ok) { if (response.ok) {
setMessage({ type: "success", text: "Tack för din ansökan! Vi kommer kontakta dig snart." }); setMessage({
type: "success",
text: "Tack för din ansökan! Vi kommer kontakta dig snart.",
});
setFormData({ setFormData({
firstName: "", firstName: "",
surName: "", surName: "",
@ -86,90 +151,164 @@ export default function VolunteerPage() {
}); });
} else { } else {
const errorData = await response.json(); const errorData = await response.json();
setMessage({ type: "error", text: errorData.message || "Ett fel uppstod vid registreringen." }); setMessage({
type: "error",
text: errorData.message || "Ett fel uppstod vid registreringen.",
});
} }
} catch (error) { } catch (error) {
console.error("Volunteer registration error:", error); console.error("Volunteer registration error:", error);
setMessage({ type: "error", text: "Kunde inte ansluta till servern. Försök igen senare." }); setMessage({
type: "error",
text: "Kunde inte ansluta till servern. Försök igen senare.",
});
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}; };
const areas = content?.volunteerAreas?.split("\n").filter(a => a.trim()) || []; const areas =
content?.volunteerAreas?.split("\n").filter((a) => a.trim()) || [];
return ( return (
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8"> <div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-2xl mx-auto bg-white p-8 rounded-xl shadow-md"> <div className="max-w-2xl mx-auto bg-white p-8 rounded-xl shadow-md">
<div className="mb-6"> <div className="mb-3">
<Link href="/" className="text-blue-600 hover:text-blue-800 flex items-center gap-1 text-sm font-medium"> <button
Tillbaka till startsidan type="button"
</Link> onClick={() => router.push("/")}
className="inline-flex items-center gap-1 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
<span></span>
<span className="sm:hidden">Tillbaka</span>
<span className="hidden sm:inline">Tillbaka till information</span>
</button>
</div>
<div className="flex justify-center mb-6">
<Image
src="/vBytes_Logotyp.png"
alt="vBytes"
width={320}
height={128}
className="h-auto w-44"
priority
/>
</div> </div>
<div className="text-center mb-10"> <div className="text-center mb-10">
<h1 className="text-3xl font-bold text-gray-900">Bli Funktionär</h1> <h1 className="text-3xl font-bold text-gray-900">Bli Funktionär</h1>
<p className="mt-2 text-gray-600">Fyll i formuläret för att anmäla ditt intresse som funktionär under LANet.</p> <p className="mt-2 text-gray-600">
Fyll i formuläret för att anmäla ditt intresse som funktionär under
LANet.
</p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-2">
<div> <div>
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700">Förnamn *</label> <label
htmlFor="firstName"
className="block text-sm font-medium text-gray-700"
>
Förnamn *
</label>
<input <input
type="text" type="text"
name="firstName" name="firstName"
id="firstName" id="firstName"
required required
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm p-2 border text-gray-900" className={`mt-1 block w-full rounded-md shadow-sm sm:text-sm p-2 border text-gray-900 ${fieldErrors.firstName ? "border-red-500 focus:border-red-500 focus:ring-red-500" : "border-gray-300 focus:border-blue-500 focus:ring-blue-500"}`}
value={formData.firstName} value={formData.firstName}
onChange={handleInputChange} onChange={handleInputChange}
/> />
{fieldErrors.firstName && (
<p className="mt-1 text-xs text-red-600">
{fieldErrors.firstName}
</p>
)}
</div> </div>
<div> <div>
<label htmlFor="surName" className="block text-sm font-medium text-gray-700">Efternamn *</label> <label
htmlFor="surName"
className="block text-sm font-medium text-gray-700"
>
Efternamn *
</label>
<input <input
type="text" type="text"
name="surName" name="surName"
id="surName" id="surName"
required required
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm p-2 border text-gray-900" className={`mt-1 block w-full rounded-md shadow-sm sm:text-sm p-2 border text-gray-900 ${fieldErrors.surName ? "border-red-500 focus:border-red-500 focus:ring-red-500" : "border-gray-300 focus:border-blue-500 focus:ring-blue-500"}`}
value={formData.surName} value={formData.surName}
onChange={handleInputChange} onChange={handleInputChange}
/> />
{fieldErrors.surName && (
<p className="mt-1 text-xs text-red-600">
{fieldErrors.surName}
</p>
)}
</div> </div>
<div> <div>
<label htmlFor="phoneNumber" className="block text-sm font-medium text-gray-700">Mobilnummer *</label> <label
htmlFor="phoneNumber"
className="block text-sm font-medium text-gray-700"
>
Mobilnummer *
</label>
<input <input
type="tel" type="tel"
name="phoneNumber" name="phoneNumber"
id="phoneNumber" id="phoneNumber"
required required
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm p-2 border text-gray-900" inputMode="tel"
placeholder="07XXXXXXXX"
className={`mt-1 block w-full rounded-md shadow-sm sm:text-sm p-2 border text-gray-900 ${fieldErrors.phoneNumber ? "border-red-500 focus:border-red-500 focus:ring-red-500" : "border-gray-300 focus:border-blue-500 focus:ring-blue-500"}`}
value={formData.phoneNumber} value={formData.phoneNumber}
onChange={handleInputChange} onChange={handleInputChange}
/> />
{fieldErrors.phoneNumber && (
<p className="mt-1 text-xs text-red-600">
{fieldErrors.phoneNumber}
</p>
)}
</div> </div>
<div> <div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">E-post *</label> <label
htmlFor="email"
className="block text-sm font-medium text-gray-700"
>
E-post *
</label>
<input <input
type="email" type="email"
name="email" name="email"
id="email" id="email"
required required
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm p-2 border text-gray-900" className={`mt-1 block w-full rounded-md shadow-sm sm:text-sm p-2 border text-gray-900 ${fieldErrors.email ? "border-red-500 focus:border-red-500 focus:ring-red-500" : "border-gray-300 focus:border-blue-500 focus:ring-blue-500"}`}
value={formData.email} value={formData.email}
onChange={handleInputChange} onChange={handleInputChange}
/> />
{fieldErrors.email && (
<p className="mt-1 text-xs text-red-600">{fieldErrors.email}</p>
)}
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2">Områden jag kan hjälpa till med *</label> <label className="block text-sm font-medium text-gray-700 mb-2">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2"> Områden jag kan hjälpa till med *
</label>
<div
className={`grid grid-cols-1 sm:grid-cols-2 gap-2 rounded-md ${fieldErrors.selectedAreas ? "border border-red-500 p-2" : ""}`}
>
{areas.map((area) => ( {areas.map((area) => (
<label key={area} className="flex items-center space-x-3 p-3 border rounded-md hover:bg-gray-50 cursor-pointer transition"> <label
key={area}
className="flex items-center space-x-3 p-3 border rounded-md hover:bg-gray-50 cursor-pointer transition"
>
<input <input
type="checkbox" type="checkbox"
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
@ -180,6 +319,11 @@ export default function VolunteerPage() {
</label> </label>
))} ))}
</div> </div>
{fieldErrors.selectedAreas && (
<p className="mt-2 text-xs text-red-600">
{fieldErrors.selectedAreas}
</p>
)}
</div> </div>
<div className="sm:col-span-2 space-y-4 pt-4 border-t border-gray-100"> <div className="sm:col-span-2 space-y-4 pt-4 border-t border-gray-100">
@ -189,25 +333,37 @@ export default function VolunteerPage() {
id="hasApprovedGdpr" id="hasApprovedGdpr"
name="hasApprovedGdpr" name="hasApprovedGdpr"
type="checkbox" type="checkbox"
required
className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded" className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded"
checked={formData.hasApprovedGdpr} checked={formData.hasApprovedGdpr}
onChange={handleInputChange} onChange={handleInputChange}
/> />
</div> </div>
<div className="ml-3 text-sm"> <div className="ml-3 text-sm">
<label htmlFor="hasApprovedGdpr" className="font-medium text-gray-700">GDPR-godkännande *</label> <label
<p className="text-gray-500">Jag godkänner att mina personuppgifter behandlas i syfte för detta evenemang.</p> htmlFor="hasApprovedGdpr"
className="font-medium text-gray-700"
>
GDPR-godkännande *
</label>
<p className="text-gray-500">
Jag godkänner att mina personuppgifter behandlas i syfte för
detta evenemang.
</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{message.text && ( {message.text && (
<div className={`p-4 rounded-md ${message.type === "success" ? "bg-green-50 text-green-800 border border-green-200" : <div
message.type === "error" ? "bg-red-50 text-red-800 border border-red-200" : className={`p-4 rounded-md ${
"bg-blue-50 text-blue-800 border border-blue-200" message.type === "success"
}`}> ? "bg-green-50 text-green-800 border border-green-200"
: message.type === "error"
? "bg-red-50 text-red-800 border border-red-200"
: "bg-blue-50 text-blue-800 border border-blue-200"
}`}
>
{message.text} {message.text}
</div> </div>
)} )}

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB