493 lines
25 KiB
Plaintext
493 lines
25 KiB
Plaintext
@page "/developers"
|
||
@using SilverLabs.Website.Models
|
||
@using SilverLabs.Website.Services
|
||
@inject DeveloperApplicationService ApplicationService
|
||
@inject IJSRuntime JS
|
||
@rendermode InteractiveServer
|
||
|
||
<PageTitle>Join the Team - SilverLabs</PageTitle>
|
||
|
||
<div class="main-content visible">
|
||
<header class="header">
|
||
<img src="logo.png" alt="SilverLabs Logo" class="logo">
|
||
</header>
|
||
|
||
<div class="dev-container">
|
||
<div class="dev-header">
|
||
<h1>Join the SilverLabs Team</h1>
|
||
<p class="dev-subtitle">Help us build privacy-first infrastructure. Whether you test our products or write code, there's a place for you.</p>
|
||
</div>
|
||
|
||
@if (_submitted)
|
||
{
|
||
<div class="dev-success-panel">
|
||
<div class="success-icon">
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
||
<polyline points="22 4 12 14.01 9 11.01"></polyline>
|
||
</svg>
|
||
</div>
|
||
<h2>Application Submitted</h2>
|
||
<p>@_resultMessage</p>
|
||
<p class="dev-account-note">Your SilverDESK account has been created. Log in with the password you just chose to track your application.</p>
|
||
<div class="dev-success-actions">
|
||
<a href="https://silverdesk.silverlabs.uk/login?username=@(Uri.EscapeDataString(_application.DesiredUsername ?? ""))" target="_blank" class="dev-btn dev-btn-primary">Log in to SilverDESK</a>
|
||
<a href="/" class="dev-btn dev-btn-secondary">Back to Home</a>
|
||
</div>
|
||
</div>
|
||
}
|
||
else
|
||
{
|
||
<EditForm Model="_application" OnValidSubmit="HandleSubmit" FormName="developer-application">
|
||
<DataAnnotationsValidator />
|
||
|
||
<!-- Role Selector -->
|
||
<div class="dev-section">
|
||
<h2 class="dev-section-title">Choose Your Role</h2>
|
||
<div class="role-selector">
|
||
<div class="role-card @(_application.Role == ApplicationRole.Tester ? "role-active" : "")"
|
||
@onclick="() => SelectRole(ApplicationRole.Tester)">
|
||
<div class="role-icon">
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
||
<polyline points="14 2 14 8 20 8"></polyline>
|
||
<line x1="16" y1="13" x2="8" y2="13"></line>
|
||
<line x1="16" y1="17" x2="8" y2="17"></line>
|
||
<polyline points="10 9 9 9 8 9"></polyline>
|
||
</svg>
|
||
</div>
|
||
<h3>Product Tester</h3>
|
||
<p>Test our apps across devices, find bugs, and provide feedback that shapes our products.</p>
|
||
</div>
|
||
|
||
<div class="role-card @(_application.Role == ApplicationRole.Developer ? "role-active" : "")"
|
||
@onclick="() => SelectRole(ApplicationRole.Developer)">
|
||
<div class="role-icon">
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<polyline points="16 18 22 12 16 6"></polyline>
|
||
<polyline points="8 6 2 12 8 18"></polyline>
|
||
</svg>
|
||
</div>
|
||
<h3>Developer</h3>
|
||
<p>Contribute code, build modules, and help architect privacy-first solutions.</p>
|
||
</div>
|
||
</div>
|
||
<ValidationMessage For="() => _application.Role" />
|
||
</div>
|
||
|
||
<!-- Personal Details -->
|
||
<div class="dev-section">
|
||
<h2 class="dev-section-title">About You</h2>
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label for="fullName">Full Name</label>
|
||
<InputText id="fullName" @bind-Value="_application.FullName" class="form-input" placeholder="Jane Doe" />
|
||
<ValidationMessage For="() => _application.FullName" />
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="email">Email Address (optional)</label>
|
||
<InputText id="email" @bind-Value="_application.Email" class="form-input" placeholder="jane@example.com" />
|
||
<span class="form-hint">Leave blank to use your @@silverlabs.uk address</span>
|
||
<ValidationMessage For="() => _application.Email" />
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="username">Desired Username</label>
|
||
<input id="username" class="form-input" placeholder="janedoe" value="@_application.DesiredUsername"
|
||
@oninput="OnUsernameInput" @onfocusout="OnUsernameBlur" autocomplete="off" />
|
||
<span class="form-hint">3–30 characters: letters, numbers, hyphens and underscores</span>
|
||
@if (!string.IsNullOrEmpty(_usernameFormatError))
|
||
{
|
||
<span class="username-status username-format-error">@_usernameFormatError</span>
|
||
}
|
||
else if (_usernameCheckState == UsernameCheckState.Checking)
|
||
{
|
||
<span class="username-status username-checking">Checking availability...</span>
|
||
}
|
||
else if (_usernameCheckState == UsernameCheckState.Available)
|
||
{
|
||
<span class="username-status username-available">✓ Username is available</span>
|
||
}
|
||
else if (_usernameCheckState == UsernameCheckState.Taken)
|
||
{
|
||
<span class="username-status username-taken">✗ Username is already taken</span>
|
||
}
|
||
else if (_usernameCheckState == UsernameCheckState.Error)
|
||
{
|
||
<span class="username-status username-error">⚠ Could not check availability — you can still submit</span>
|
||
}
|
||
<ValidationMessage For="() => _application.DesiredUsername" />
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="timezone">Timezone</label>
|
||
<InputSelect id="timezone" @bind-Value="_application.Timezone" class="form-input">
|
||
<option value="">Select your timezone...</option>
|
||
@foreach (var tz in _timezones)
|
||
{
|
||
<option value="@tz.Id">@tz.Label</option>
|
||
}
|
||
</InputSelect>
|
||
<ValidationMessage For="() => _application.Timezone" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Password -->
|
||
<div class="dev-section">
|
||
<h2 class="dev-section-title">Create Your Password</h2>
|
||
<p class="dev-section-desc">This will be your password for SilverDESK and associated services.</p>
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label for="password">Password</label>
|
||
<InputText id="password" type="password" @bind-Value="_application.Password" class="form-input" placeholder="Min. 8 characters" />
|
||
<span class="form-hint">Must include uppercase, lowercase, and a number</span>
|
||
<ValidationMessage For="() => _application.Password" />
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="confirmPassword">Confirm Password</label>
|
||
<InputText id="confirmPassword" type="password" @bind-Value="_application.ConfirmPassword" class="form-input" placeholder="Re-enter your password" />
|
||
<ValidationMessage For="() => _application.ConfirmPassword" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Platforms -->
|
||
<div class="dev-section">
|
||
<h2 class="dev-section-title">Devices & Platforms</h2>
|
||
<p class="dev-section-desc">Which platforms do you use or have access to?</p>
|
||
<div class="platform-grid">
|
||
@foreach (var platform in _availablePlatforms)
|
||
{
|
||
var isChecked = _application.Platforms.Contains(platform);
|
||
<label class="platform-chip @(isChecked ? "platform-active" : "")">
|
||
<input type="checkbox" checked="@isChecked"
|
||
@onchange="() => TogglePlatform(platform)" />
|
||
<span>@platform</span>
|
||
</label>
|
||
}
|
||
</div>
|
||
<ValidationMessage For="() => _application.Platforms" />
|
||
</div>
|
||
|
||
<!-- Role-Specific Assessment -->
|
||
@if (_application.Role == ApplicationRole.Tester)
|
||
{
|
||
<div class="dev-section">
|
||
<h2 class="dev-section-title">About Your Experience</h2>
|
||
<p class="dev-section-desc">Help us understand your background — there are no wrong answers.</p>
|
||
|
||
<div class="form-group" style="margin-bottom: 1.5rem;">
|
||
<label>How well do you understand the internet and online services?</label>
|
||
<div class="star-rating">
|
||
@for (int i = 1; i <= 5; i++)
|
||
{
|
||
var rating = i;
|
||
<span class="star-rating-star @(rating <= (_internetHover > 0 ? _internetHover : (_application.InternetUnderstanding ?? 0)) ? "star-filled" : "")"
|
||
@onclick="() => _application.InternetUnderstanding = rating"
|
||
@onmouseover="() => _internetHover = rating"
|
||
@onmouseout="() => _internetHover = 0">@(rating <= (_internetHover > 0 ? _internetHover : (_application.InternetUnderstanding ?? 0)) ? "\u2605" : "\u2606")</span>
|
||
}
|
||
</div>
|
||
<div class="rating-labels">
|
||
<span>Beginner</span>
|
||
<span>Expert</span>
|
||
</div>
|
||
<ValidationMessage For="() => _application.InternetUnderstanding" />
|
||
</div>
|
||
|
||
<div class="form-group" style="margin-bottom: 1.5rem;">
|
||
<label>How much do you enjoy trying new software and finding issues?</label>
|
||
<div class="star-rating">
|
||
@for (int i = 1; i <= 5; i++)
|
||
{
|
||
var rating = i;
|
||
<span class="star-rating-star @(rating <= (_testingHover > 0 ? _testingHover : (_application.EnjoysTesting ?? 0)) ? "star-filled" : "")"
|
||
@onclick="() => _application.EnjoysTesting = rating"
|
||
@onmouseover="() => _testingHover = rating"
|
||
@onmouseout="() => _testingHover = 0">@(rating <= (_testingHover > 0 ? _testingHover : (_application.EnjoysTesting ?? 0)) ? "\u2605" : "\u2606")</span>
|
||
}
|
||
</div>
|
||
<div class="rating-labels">
|
||
<span>Not really</span>
|
||
<span>Love it</span>
|
||
</div>
|
||
<ValidationMessage For="() => _application.EnjoysTesting" />
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="additionalNotes">Anything else you'd like us to know? (optional)</label>
|
||
<InputTextArea id="additionalNotes" @bind-Value="_application.AdditionalNotes" class="form-input form-textarea"
|
||
placeholder="Previous testing experience, specific interests, etc." rows="3" />
|
||
</div>
|
||
</div>
|
||
}
|
||
else
|
||
{
|
||
<div class="dev-section">
|
||
<h2 class="dev-section-title">Your Skills</h2>
|
||
<p class="dev-section-desc">Select your experience level and the technologies you work with.</p>
|
||
|
||
<div class="form-group" style="margin-bottom: 1.5rem;">
|
||
<label>Experience Level</label>
|
||
<div class="experience-selector">
|
||
@foreach (var range in SkillCatalog.ExperienceRanges)
|
||
{
|
||
<button type="button"
|
||
class="exp-btn @(_application.ExperienceRange == range ? "exp-active" : "")"
|
||
@onclick="() => _application.ExperienceRange = range">@range</button>
|
||
}
|
||
</div>
|
||
<ValidationMessage For="() => _application.ExperienceRange" />
|
||
</div>
|
||
|
||
<div class="form-group" style="margin-bottom: 1.5rem;">
|
||
<label>Technologies (select all that apply)</label>
|
||
@foreach (var category in SkillCatalog.SkillCategories)
|
||
{
|
||
<div class="skill-category-label">@category.Key</div>
|
||
<div class="skill-bubbles">
|
||
@foreach (var skill in category.Value)
|
||
{
|
||
<button type="button"
|
||
class="skill-bubble @(_application.SelectedSkills.Contains(skill) ? "skill-active" : "")"
|
||
@onclick="() => ToggleSkill(skill)">@skill</button>
|
||
}
|
||
</div>
|
||
}
|
||
<ValidationMessage For="() => _application.SelectedSkills" />
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="additionalNotes">Anything not listed above? (optional)</label>
|
||
<InputTextArea id="additionalNotes" @bind-Value="_application.AdditionalNotes" class="form-input form-textarea"
|
||
placeholder="Other skills, open-source contributions, areas of interest..." rows="3" />
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
<!-- What You Get -->
|
||
<div class="dev-section dev-perks">
|
||
<h2 class="dev-section-title">What You'll Get</h2>
|
||
<div class="perks-grid">
|
||
<div class="perk-item">
|
||
<strong>@@@("username")@@silverlabs.uk</strong>
|
||
<span>Your own SilverLabs email</span>
|
||
</div>
|
||
<div class="perk-item">
|
||
<strong>SilverDESK</strong>
|
||
<span>Project management & issue tracking</span>
|
||
</div>
|
||
<div class="perk-item">
|
||
<strong>Mattermost</strong>
|
||
<span>Team chat & collaboration</span>
|
||
</div>
|
||
<div class="perk-item">
|
||
<strong>Gitea Access</strong>
|
||
<span>Source code repositories</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Submit -->
|
||
<div class="dev-submit-area">
|
||
@if (!string.IsNullOrEmpty(_errorMessage))
|
||
{
|
||
<div class="dev-error">@_errorMessage</div>
|
||
}
|
||
<button type="submit" class="dev-btn dev-btn-primary" disabled="@IsSubmitDisabled">
|
||
@if (_submitting)
|
||
{
|
||
<span class="btn-spinner"></span>
|
||
<span>Submitting...</span>
|
||
}
|
||
else
|
||
{
|
||
<span>Submit Application</span>
|
||
}
|
||
</button>
|
||
</div>
|
||
</EditForm>
|
||
}
|
||
|
||
<a href="/" class="back-link">← Back to SilverLabs Home</a>
|
||
</div>
|
||
</div>
|
||
|
||
@code {
|
||
private DeveloperApplication _application = new() { Role = ApplicationRole.Tester };
|
||
private bool _submitting;
|
||
private bool _submitted;
|
||
private string? _resultMessage;
|
||
private string? _errorMessage;
|
||
private int _internetHover;
|
||
private int _testingHover;
|
||
|
||
private UsernameCheckState _usernameCheckState = UsernameCheckState.None;
|
||
private string? _usernameFormatError;
|
||
private string? _lastCheckedUsername;
|
||
|
||
private readonly string[] _availablePlatforms = { "Windows", "macOS", "Linux", "Android", "iOS", "Other" };
|
||
|
||
private static readonly List<(string Id, string Label)> _timezones = new()
|
||
{
|
||
("Pacific/Midway", "(UTC-11:00) Midway Island"),
|
||
("Pacific/Honolulu", "(UTC-10:00) Hawaii"),
|
||
("America/Anchorage", "(UTC-09:00) Alaska"),
|
||
("America/Los_Angeles", "(UTC-08:00) Pacific Time (US & Canada)"),
|
||
("America/Denver", "(UTC-07:00) Mountain Time (US & Canada)"),
|
||
("America/Chicago", "(UTC-06:00) Central Time (US & Canada)"),
|
||
("America/New_York", "(UTC-05:00) Eastern Time (US & Canada)"),
|
||
("America/Caracas", "(UTC-04:00) Venezuela"),
|
||
("America/Halifax", "(UTC-04:00) Atlantic Time (Canada)"),
|
||
("America/Sao_Paulo", "(UTC-03:00) Brazil"),
|
||
("Atlantic/South_Georgia","(UTC-02:00) Mid-Atlantic"),
|
||
("Atlantic/Azores", "(UTC-01:00) Azores"),
|
||
("Europe/London", "(UTC+00:00) London, Dublin, Lisbon"),
|
||
("Europe/Berlin", "(UTC+01:00) Berlin, Paris, Amsterdam"),
|
||
("Europe/Bucharest", "(UTC+02:00) Bucharest, Helsinki, Athens"),
|
||
("Europe/Moscow", "(UTC+03:00) Moscow, Istanbul"),
|
||
("Asia/Dubai", "(UTC+04:00) Dubai, Baku"),
|
||
("Asia/Karachi", "(UTC+05:00) Karachi, Tashkent"),
|
||
("Asia/Kolkata", "(UTC+05:30) Mumbai, New Delhi"),
|
||
("Asia/Dhaka", "(UTC+06:00) Dhaka, Almaty"),
|
||
("Asia/Bangkok", "(UTC+07:00) Bangkok, Jakarta"),
|
||
("Asia/Shanghai", "(UTC+08:00) Beijing, Singapore, Perth"),
|
||
("Asia/Tokyo", "(UTC+09:00) Tokyo, Seoul"),
|
||
("Australia/Sydney", "(UTC+10:00) Sydney, Melbourne"),
|
||
("Pacific/Noumea", "(UTC+11:00) Solomon Islands"),
|
||
("Pacific/Auckland", "(UTC+12:00) Auckland, Fiji"),
|
||
};
|
||
|
||
private static readonly System.Text.RegularExpressions.Regex UsernamePattern =
|
||
new(@"^[a-zA-Z0-9_-]{3,30}$", System.Text.RegularExpressions.RegexOptions.Compiled);
|
||
|
||
private enum UsernameCheckState { None, Checking, Available, Taken, Error }
|
||
|
||
private bool IsSubmitDisabled =>
|
||
_submitting || _usernameCheckState == UsernameCheckState.Taken || _usernameCheckState == UsernameCheckState.Checking;
|
||
|
||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||
{
|
||
if (firstRender && string.IsNullOrEmpty(_application.Timezone))
|
||
{
|
||
try
|
||
{
|
||
var detectedTz = await JS.InvokeAsync<string>("eval", "Intl.DateTimeFormat().resolvedOptions().timeZone");
|
||
if (!string.IsNullOrEmpty(detectedTz) && _timezones.Any(tz => tz.Id == detectedTz))
|
||
{
|
||
_application.Timezone = detectedTz;
|
||
StateHasChanged();
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// Browser may not support Intl API — ignore
|
||
}
|
||
}
|
||
}
|
||
|
||
private void SelectRole(ApplicationRole role)
|
||
{
|
||
_application.Role = role;
|
||
}
|
||
|
||
private void TogglePlatform(string platform)
|
||
{
|
||
if (_application.Platforms.Contains(platform))
|
||
_application.Platforms.Remove(platform);
|
||
else
|
||
_application.Platforms.Add(platform);
|
||
}
|
||
|
||
private void ToggleSkill(string skill)
|
||
{
|
||
if (_application.SelectedSkills.Contains(skill))
|
||
_application.SelectedSkills.Remove(skill);
|
||
else
|
||
_application.SelectedSkills.Add(skill);
|
||
}
|
||
|
||
private void OnUsernameInput(ChangeEventArgs e)
|
||
{
|
||
var username = e.Value?.ToString() ?? "";
|
||
_application.DesiredUsername = username;
|
||
|
||
// Reset API check state while typing — we'll check on blur
|
||
_usernameCheckState = UsernameCheckState.None;
|
||
_usernameFormatError = null;
|
||
|
||
// Show inline format feedback as they type
|
||
if (username.Length > 0 && username.Length < 3)
|
||
{
|
||
_usernameFormatError = "Username must be at least 3 characters";
|
||
}
|
||
else if (username.Length > 30)
|
||
{
|
||
_usernameFormatError = "Username must be 30 characters or fewer";
|
||
}
|
||
else if (username.Length >= 3 && !UsernamePattern.IsMatch(username))
|
||
{
|
||
_usernameFormatError = "Only letters, numbers, hyphens and underscores allowed";
|
||
}
|
||
}
|
||
|
||
private async Task OnUsernameBlur()
|
||
{
|
||
var username = _application.DesiredUsername?.Trim() ?? "";
|
||
|
||
// Don't check if empty, invalid format, or already checked this exact username
|
||
if (string.IsNullOrEmpty(username) || !UsernamePattern.IsMatch(username))
|
||
return;
|
||
|
||
if (username == _lastCheckedUsername && _usernameCheckState != UsernameCheckState.Error)
|
||
return;
|
||
|
||
_usernameCheckState = UsernameCheckState.Checking;
|
||
StateHasChanged();
|
||
|
||
var available = await ApplicationService.CheckUsernameAsync(username);
|
||
_lastCheckedUsername = username;
|
||
|
||
_usernameCheckState = available switch
|
||
{
|
||
true => UsernameCheckState.Available,
|
||
false => UsernameCheckState.Taken,
|
||
null => UsernameCheckState.Error
|
||
};
|
||
|
||
StateHasChanged();
|
||
}
|
||
|
||
private async Task HandleSubmit()
|
||
{
|
||
_errorMessage = null;
|
||
_submitting = true;
|
||
|
||
try
|
||
{
|
||
var (success, message, token) = await ApplicationService.SubmitApplicationAsync(_application);
|
||
|
||
if (success)
|
||
{
|
||
_resultMessage = message;
|
||
_submitted = true;
|
||
}
|
||
else
|
||
{
|
||
_errorMessage = message;
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
_errorMessage = "An unexpected error occurred. Please try again later.";
|
||
}
|
||
finally
|
||
{
|
||
_submitting = false;
|
||
}
|
||
}
|
||
}
|