Compare commits
No commits in common. "378f7fbf2a727b53b2608988dc6eef6410088561" and "bc7452cd7f97b9ced7ef800c9df16d2bd6fe702d" have entirely different histories.
378f7fbf2a
...
bc7452cd7f
18 changed files with 139 additions and 789 deletions
|
|
@ -3,7 +3,6 @@ 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"));
|
||||||
|
|
@ -19,8 +18,7 @@ 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)
|
||||||
|
|
@ -31,12 +29,10 @@ 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");
|
||||||
|
|
|
||||||
|
|
@ -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; } = string.Empty;
|
public string ParticipantRegisterPath { get; set; } = "/api/participant";
|
||||||
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; } = string.Empty;
|
public string VolunteerRegisterPath { get; set; } = "/api/volunteer";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
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;
|
||||||
|
|
@ -13,33 +11,8 @@ 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] ParticipantRegistrationRequest request, CancellationToken cancellationToken)
|
public async Task<IActionResult> RegisterForLan([FromBody] Participant participant, 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"))
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
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
|
||||||
|
|
@ -15,24 +14,14 @@ 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)
|
||||||
{
|
{
|
||||||
if (!TryNormalizeSsn(ssn, out var normalizedSsn, out var errorResult))
|
var isMember = await _authService.IsMemberAsync(ssn, cancellationToken);
|
||||||
{
|
|
||||||
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)
|
||||||
{
|
{
|
||||||
if (!TryNormalizeSsn(ssn, out var normalizedSsn, out var errorResult))
|
var added = await _memberRepository.AddRegistration(ssn);
|
||||||
{
|
|
||||||
return errorResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
var added = await _memberRepository.AddRegistration(normalizedSsn);
|
|
||||||
|
|
||||||
return added ? Ok() : Conflict();
|
return added ? Ok() : Conflict();
|
||||||
}
|
}
|
||||||
|
|
@ -40,12 +29,7 @@ namespace Registration.API.Controllers
|
||||||
[HttpGet("registered/{ssn}")]
|
[HttpGet("registered/{ssn}")]
|
||||||
public async Task<IActionResult> IsMemberRegistered(string ssn)
|
public async Task<IActionResult> IsMemberRegistered(string ssn)
|
||||||
{
|
{
|
||||||
if (!TryNormalizeSsn(ssn, out var normalizedSsn, out var errorResult))
|
var isRegistered = await _memberRepository.GetIsRegistered(ssn);
|
||||||
{
|
|
||||||
return errorResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
var isRegistered = await _memberRepository.GetIsRegistered(normalizedSsn);
|
|
||||||
return isRegistered ? Conflict() : Ok();
|
return isRegistered ? Conflict() : Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,25 +39,5 @@ 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
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;
|
||||||
|
|
@ -13,35 +11,8 @@ 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] VolunteerRegistrationRequest request, CancellationToken cancellationToken)
|
public async Task<IActionResult> RegisterVolunteer([FromBody] Volunteer volunteer, 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"))
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ 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();
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
|
|
||||||
namespace Registration.API.RequestModels;
|
|
||||||
|
|
||||||
public class AreaOfInterestRequest
|
|
||||||
{
|
|
||||||
[Required]
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
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; }
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
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; } = [];
|
|
||||||
}
|
|
||||||
|
|
@ -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, logger), IVbytesParticipantRelayService
|
: VbytesRelayServiceBase(httpClient, options), IVbytesParticipantRelayService
|
||||||
{
|
{
|
||||||
public const string HttpClientName = "VbytesRelay";
|
public const string HttpClientName = "VbytesRelay";
|
||||||
|
|
||||||
|
|
@ -20,22 +20,24 @@ public class VbytesParticipantRelayService(
|
||||||
var configError = ValidateConfiguration();
|
var configError = ValidateConfiguration();
|
||||||
if (configError is not null)
|
if (configError is not null)
|
||||||
{
|
{
|
||||||
_logger.LogError("Relay configuration invalid: {Message}", configError.Message);
|
_logger.LogWarning("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.LogError("Relay timed out calling {Path}", Options.ParticipantRegisterPath);
|
_logger.LogWarning("Relay timed out calling {Path}", Options.ParticipantRegisterPath);
|
||||||
|
|
||||||
return new VbytesRelayResult(false, StatusCodes.Status504GatewayTimeout, "Upstream timeout.");
|
return new VbytesRelayResult(false, StatusCodes.Status504GatewayTimeout, "Upstream timeout.");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Text.Json;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Registration.API.Configuration;
|
using Registration.API.Configuration;
|
||||||
|
|
||||||
|
|
@ -5,11 +6,9 @@ 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)
|
||||||
|
|
@ -23,16 +22,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;
|
||||||
_logger.LogError("Outbound relay response error: {StatusCode}. Body: {Body}", statusCode, safeBody);
|
Console.WriteLine($"[DEBUG] Relay error body: {body}");
|
||||||
var message = safeBody;
|
var message = body.Length > 600 ? body[..600] : body;
|
||||||
return new VbytesRelayResult(false, statusCode, string.IsNullOrWhiteSpace(message) ? "Upstream request failed." : message);
|
return new VbytesRelayResult(false, statusCode, string.IsNullOrWhiteSpace(message) ? "Upstream request failed." : message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,6 +60,17 @@ 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);
|
||||||
|
|
|
||||||
|
|
@ -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, logger), IVbytesVolunteerRelayService
|
: VbytesRelayServiceBase(httpClient, options), IVbytesVolunteerRelayService
|
||||||
{
|
{
|
||||||
private readonly ILogger<VbytesVolunteerRelayService> _logger = logger;
|
private readonly ILogger<VbytesVolunteerRelayService> _logger = logger;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
@ -14,8 +14,8 @@ const geistMono = Geist_Mono({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Vbytes LAN-anmälan",
|
title: "Vbytes LAN Registration",
|
||||||
description: "Anmäl dig till kommande LAN-evenemang hos Vbytes.",
|
description: "Register for the upcoming Vbytes LAN event.",
|
||||||
};
|
};
|
||||||
|
|
||||||
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="sv">
|
<html lang="en">
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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-[52vh] flex items-center justify-center bg-black overflow-hidden">
|
<header className="relative h-[70vh] 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,34 +60,15 @@ 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-12 px-6 space-y-20">
|
<main className="max-w-4xl mx-auto py-16 px-6 space-y-20">
|
||||||
{(content.eventDate ||
|
{(content.eventDate || content.eventTime || content.locationName || content.locationAddress || content.additionalInfo) && (
|
||||||
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">
|
<h2 className="text-3xl font-bold border-b-2 border-blue-500 pb-2 inline-block">Information</h2>
|
||||||
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">
|
<h3 className="font-semibold text-lg text-blue-600">Tid/Datum</h3>
|
||||||
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>
|
||||||
|
|
@ -110,25 +91,18 @@ 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">
|
<h2 className="text-3xl font-bold border-b-2 border-blue-500 pb-2 inline-block">Ta Med(bokad LAN-plats)</h2>
|
||||||
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
|
{content.whatToBring.split('\n').filter(line => line.trim()).map((line, i) => (
|
||||||
.split("\n")
|
<li key={i}>{line}</li>
|
||||||
.filter((line) => line.trim())
|
))}
|
||||||
.map((line, i) => (
|
|
||||||
<li key={i}>{line}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{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">
|
<h2 className="text-3xl font-bold border-b-2 border-blue-500 pb-2 inline-block">Regler för LAN & GDPR</h2>
|
||||||
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>
|
||||||
|
|
@ -140,9 +114,7 @@ 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">
|
<p className="text-gray-600 text-lg">Begränsade platser, Säkra din plats idag!</p>
|
||||||
Begränsade platser, Säkra din plats idag!
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/register"
|
href="/register"
|
||||||
|
|
@ -165,7 +137,7 @@ export default function LandingPage() {
|
||||||
Bli Funktionär
|
Bli Funktionär
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<p>© 2026 Vbytes Gaming. Alla rättigheter förbehållna.</p>
|
<p>© 2026 Vbytes Gaming. All rights reserved.</p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,7 @@
|
||||||
|
|
||||||
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;
|
||||||
|
|
@ -12,22 +10,6 @@ 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({
|
||||||
|
|
@ -48,8 +30,6 @@ 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")
|
||||||
|
|
@ -67,143 +47,53 @@ 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">
|
<h1 className="text-2xl font-bold text-gray-900 mb-4">Registreringen är stängd</h1>
|
||||||
Registreringen är stängd
|
<p className="text-gray-600 mb-6">Vi tar tyvärr inte emot fler anmälningar just nu.</p>
|
||||||
</h1>
|
<Link href="/" className="text-blue-600 hover:underline">Gå tillbaka till startsidan</Link>
|
||||||
<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">
|
|
||||||
Gå tillbaka till startsidan
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleInputChange = (
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
||||||
e: React.ChangeEvent<
|
|
||||||
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
|
|
||||||
>,
|
|
||||||
) => {
|
|
||||||
const { name, value, type } = e.target;
|
const { name, value, type } = e.target;
|
||||||
const val =
|
const val = type === "checkbox" ? (e.target as HTMLInputElement).checked : value;
|
||||||
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();
|
||||||
|
|
||||||
const normalizedSsn = normalizeSsn(formData.ssn);
|
if (!formData.ssn) {
|
||||||
const trimmedFirstName = formData.firstName.trim();
|
setMessage({ type: "error", text: "SSN is required for registration." });
|
||||||
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: "Behandlar din anmälan..." });
|
setMessage({ type: "info", text: "Processing your registration..." });
|
||||||
|
|
||||||
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(
|
const checkRes = await fetch(`/api/Registration/registered/${formData.ssn}`);
|
||||||
`/api/Registration/registered/${normalizedSsn}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (checkRes.status === 409) {
|
if (checkRes.status === 409) {
|
||||||
setMessage({
|
setMessage({ type: "error", text: "This SSN is already registered for the LAN." });
|
||||||
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(
|
const validateRes = await fetch(`/api/Registration/register/${formData.ssn}`);
|
||||||
`/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,
|
||||||
firstName: trimmedFirstName,
|
isMember: isMember
|
||||||
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", {
|
||||||
|
|
@ -215,28 +105,18 @@ export default function RegisterPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setMessage({
|
setMessage({ type: "success", text: "Registrering slutförd!" });
|
||||||
type: "success",
|
|
||||||
text: "Registreringen är klar! Du är nu anmäld till LAN:et.",
|
|
||||||
});
|
|
||||||
setShowBecomeMemberCta(!isMember);
|
|
||||||
|
|
||||||
await fetch(`/api/Registration/register/${normalizedSsn}`, {
|
await fetch(`/api/Registration/register/${formData.ssn}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json();
|
||||||
setMessage({
|
setMessage({ type: "error", text: errorData.message || "Event registration failed." });
|
||||||
type: "error",
|
|
||||||
text: errorData.message || "Registreringen misslyckades.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Registration error:", error);
|
console.error("Registration error:", error);
|
||||||
setMessage({
|
setMessage({ type: "error", text: "A connection error occurred. Please check your internet and try again." });
|
||||||
type: "error",
|
|
||||||
text: "Ett anslutningsfel uppstod. Kontrollera internet och försök igen.",
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|
@ -245,264 +125,146 @@ 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-3">
|
<div className="mb-6">
|
||||||
<button
|
<Link href="/" className="text-blue-600 hover:text-blue-800 flex items-center gap-1 text-sm font-medium">
|
||||||
type="button"
|
← Tillbaka till startsidan
|
||||||
onClick={() => router.push("/")}
|
</Link>
|
||||||
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">
|
<p className="mt-2 text-gray-600">Vänligen fyll i detta formulär för att anmäla dig till lanet.</p>
|
||||||
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
|
<label htmlFor="ssn" className="block text-sm font-medium text-gray-700">Personnummer*</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 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"}`}
|
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"
|
||||||
value={formData.ssn}
|
value={formData.ssn}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
/>
|
/>
|
||||||
{fieldErrors.ssn && (
|
<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>
|
||||||
<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
|
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700">Förnamn *</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 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"}`}
|
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"
|
||||||
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
|
<label htmlFor="surName" className="block text-sm font-medium text-gray-700">Efternamn *</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 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"}`}
|
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"
|
||||||
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
|
<label htmlFor="grade" className="block text-sm font-medium text-gray-700">Årskurs *</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 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"}`}
|
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"
|
||||||
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
|
<label htmlFor="phoneNumber" className="block text-sm font-medium text-gray-700">Mobilnummer</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"
|
||||||
inputMode="tel"
|
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"
|
||||||
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
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700">Email</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 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"}`}
|
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"
|
||||||
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">
|
<h3 className="text-lg font-medium text-gray-900 mb-4">Vårdnadshavares Information</h3>
|
||||||
Vårdnadshavares Information
|
|
||||||
</h3>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label htmlFor="guardianName" className="block text-sm font-medium text-gray-700">Vårdnadshavares Namn *</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 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"}`}
|
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"
|
||||||
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
|
<label htmlFor="guardianPhoneNumber" className="block text-sm font-medium text-gray-700">Vårdnadshavares Mobilnummer *</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
|
||||||
inputMode="tel"
|
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"
|
||||||
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
|
<label htmlFor="guardianEmail" className="block text-sm font-medium text-gray-700">Vårdnadshavares Email *</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 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"}`}
|
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"
|
||||||
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">
|
<h3 className="text-lg font-medium text-gray-900 mb-4">Yttrligare uppgifter</h3>
|
||||||
Ytterligare uppgifter
|
|
||||||
</h3>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<label
|
<label htmlFor="friends" className="block text-sm font-medium text-gray-700">"Önska" Vänner (att sitta jämte)</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"
|
||||||
|
|
@ -514,12 +276,7 @@ export default function RegisterPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<label
|
<label htmlFor="specialDiet" className="block text-sm font-medium text-gray-700">Specialkost / Allergier</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"
|
||||||
|
|
@ -544,12 +301,7 @@ export default function RegisterPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-3 text-sm">
|
<div className="ml-3 text-sm">
|
||||||
<label
|
<label htmlFor="isVisitor" className="font-medium text-gray-700">Registrera dig som besökare</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."
|
||||||
|
|
@ -570,53 +322,29 @@ export default function RegisterPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-3 text-sm">
|
<div className="ml-3 text-sm">
|
||||||
<label
|
<label htmlFor="hasApprovedGdpr" className="font-medium text-gray-700">GDPR gokännande *</label>
|
||||||
htmlFor="hasApprovedGdpr"
|
<p className="text-gray-500">Jag Godkänner att min personliga uppgifter sparas och hanteras i syfte för detta event.</p>
|
||||||
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
|
<div className={`p-4 rounded-md ${message.type === "success" ? "bg-green-50 text-green-800 border border-green-200" :
|
||||||
className={`p-4 rounded-md ${
|
message.type === "error" ? "bg-red-50 text-red-800 border border-red-200" :
|
||||||
message.type === "success"
|
"bg-blue-50 text-blue-800 border border-blue-200"
|
||||||
? "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 ? "Registrerar..." : "Anmäl dig till LAN"}
|
{isSubmitting ? "Registering..." : "Register for LAN"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Image from "next/image";
|
import Link from "next/link";
|
||||||
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: "",
|
||||||
|
|
@ -34,7 +20,6 @@ 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")
|
||||||
|
|
@ -49,11 +34,6 @@ 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) => {
|
||||||
|
|
@ -63,69 +43,27 @@ 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) {
|
||||||
nextFieldErrors.selectedAreas =
|
setMessage({ type: "error", text: "Vänligen välj minst ett område du vill hjälpa till med." });
|
||||||
"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: trimmedFirstName,
|
firstName: formData.firstName,
|
||||||
surName: trimmedSurName,
|
surName: formData.surName,
|
||||||
phoneNumber: normalizedPhoneNumber,
|
phoneNumber: formData.phoneNumber,
|
||||||
email: trimmedEmail,
|
email: formData.email,
|
||||||
hasApprovedGdpr: formData.hasApprovedGdpr,
|
hasApprovedGdpr: formData.hasApprovedGdpr,
|
||||||
areasOfInterest: formData.selectedAreas.map((name) => ({
|
areasOfInterest: formData.selectedAreas.map(name => ({ name }))
|
||||||
name: name.trim(),
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await fetch("/api/Volunteer/register", {
|
const response = await fetch("/api/Volunteer/register", {
|
||||||
|
|
@ -137,10 +75,7 @@ export default function VolunteerPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setMessage({
|
setMessage({ type: "success", text: "Tack för din ansökan! Vi kommer kontakta dig snart." });
|
||||||
type: "success",
|
|
||||||
text: "Tack för din ansökan! Vi kommer kontakta dig snart.",
|
|
||||||
});
|
|
||||||
setFormData({
|
setFormData({
|
||||||
firstName: "",
|
firstName: "",
|
||||||
surName: "",
|
surName: "",
|
||||||
|
|
@ -151,164 +86,90 @@ export default function VolunteerPage() {
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json();
|
||||||
setMessage({
|
setMessage({ type: "error", text: errorData.message || "Ett fel uppstod vid registreringen." });
|
||||||
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({
|
setMessage({ type: "error", text: "Kunde inte ansluta till servern. Försök igen senare." });
|
||||||
type: "error",
|
|
||||||
text: "Kunde inte ansluta till servern. Försök igen senare.",
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const areas =
|
const areas = content?.volunteerAreas?.split("\n").filter(a => a.trim()) || [];
|
||||||
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-3">
|
<div className="mb-6">
|
||||||
<button
|
<Link href="/" className="text-blue-600 hover:text-blue-800 flex items-center gap-1 text-sm font-medium">
|
||||||
type="button"
|
← Tillbaka till startsidan
|
||||||
onClick={() => router.push("/")}
|
</Link>
|
||||||
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">
|
<p className="mt-2 text-gray-600">Fyll i formuläret för att anmäla ditt intresse som funktionär under LANet.</p>
|
||||||
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
|
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700">Förnamn *</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 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"}`}
|
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"
|
||||||
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
|
<label htmlFor="surName" className="block text-sm font-medium text-gray-700">Efternamn *</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 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"}`}
|
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"
|
||||||
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
|
<label htmlFor="phoneNumber" className="block text-sm font-medium text-gray-700">Mobilnummer *</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
|
||||||
inputMode="tel"
|
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"
|
||||||
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
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700">E-post *</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 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"}`}
|
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"
|
||||||
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">
|
<label className="block text-sm font-medium text-gray-700 mb-2">Områden jag kan hjälpa till med *</label>
|
||||||
Områden jag kan hjälpa till med *
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
</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
|
<label key={area} className="flex items-center space-x-3 p-3 border rounded-md hover:bg-gray-50 cursor-pointer transition">
|
||||||
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"
|
||||||
|
|
@ -319,11 +180,6 @@ 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">
|
||||||
|
|
@ -333,37 +189,25 @@ 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
|
<label htmlFor="hasApprovedGdpr" className="font-medium text-gray-700">GDPR-godkännande *</label>
|
||||||
htmlFor="hasApprovedGdpr"
|
<p className="text-gray-500">Jag godkänner att mina personuppgifter behandlas i syfte för detta evenemang.</p>
|
||||||
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
|
<div className={`p-4 rounded-md ${message.type === "success" ? "bg-green-50 text-green-800 border border-green-200" :
|
||||||
className={`p-4 rounded-md ${
|
message.type === "error" ? "bg-red-50 text-red-800 border border-red-200" :
|
||||||
message.type === "success"
|
"bg-blue-50 text-blue-800 border border-blue-200"
|
||||||
? "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.
|
Before Width: | Height: | Size: 186 KiB |
Loading…
Reference in a new issue