4
.gitignore
vendored
4
.gitignore
vendored
@@ -398,3 +398,7 @@ FodyWeavers.xsd
|
||||
*.sln.iml
|
||||
/BeauFindlay/BeauFindlay/package-lock.json
|
||||
/BeauFindlay/BeauFindlay/package.json
|
||||
/BeauFindlay/src/BeauFindlay.Client/package-lock.json
|
||||
/BeauFindlay/src/BeauFindlay.Client/package.json
|
||||
/BeauFindlay/src/BeauFindlay.Client/package-lock.json
|
||||
/BeauFindlay/src/BeauFindlay.Client/package.json
|
||||
|
||||
@@ -3,7 +3,13 @@ Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.9.34622.214
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BeauFindlay", "BeauFindlay\BeauFindlay.csproj", "{F6AA110B-9F12-441F-858C-AE476CB9B107}"
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{3407557D-A21B-4F48-930C-6FDCE961ED2A}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BeauFindlay.Client", "src\BeauFindlay.Client\BeauFindlay.Client.csproj", "{979CCAA2-5F1B-457B-9536-02F94BC98F9F}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BeauFindlay.Api", "src\BeauFindlay.Api\BeauFindlay.Api.csproj", "{D2F248BF-8487-4CE7-B6AA-D558587A52DA}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BeauFindlay.Shared", "src\BeauFindlay.Shared\BeauFindlay.Shared.csproj", "{0A17E6ED-1B40-4FAE-94D5-1255C3569F6E}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
@@ -11,14 +17,27 @@ Global
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{F6AA110B-9F12-441F-858C-AE476CB9B107}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F6AA110B-9F12-441F-858C-AE476CB9B107}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F6AA110B-9F12-441F-858C-AE476CB9B107}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F6AA110B-9F12-441F-858C-AE476CB9B107}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{979CCAA2-5F1B-457B-9536-02F94BC98F9F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{979CCAA2-5F1B-457B-9536-02F94BC98F9F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{979CCAA2-5F1B-457B-9536-02F94BC98F9F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{979CCAA2-5F1B-457B-9536-02F94BC98F9F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D2F248BF-8487-4CE7-B6AA-D558587A52DA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D2F248BF-8487-4CE7-B6AA-D558587A52DA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D2F248BF-8487-4CE7-B6AA-D558587A52DA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D2F248BF-8487-4CE7-B6AA-D558587A52DA}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{0A17E6ED-1B40-4FAE-94D5-1255C3569F6E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{0A17E6ED-1B40-4FAE-94D5-1255C3569F6E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{0A17E6ED-1B40-4FAE-94D5-1255C3569F6E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{0A17E6ED-1B40-4FAE-94D5-1255C3569F6E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{979CCAA2-5F1B-457B-9536-02F94BC98F9F} = {3407557D-A21B-4F48-930C-6FDCE961ED2A}
|
||||
{D2F248BF-8487-4CE7-B6AA-D558587A52DA} = {3407557D-A21B-4F48-930C-6FDCE961ED2A}
|
||||
{0A17E6ED-1B40-4FAE-94D5-1255C3569F6E} = {3407557D-A21B-4F48-930C-6FDCE961ED2A}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {6D05FF39-4D7B-4F0D-8136-E48F71106C3A}
|
||||
EndGlobalSection
|
||||
|
||||
264
BeauFindlay/src/BeauFindlay.Api/.gitignore
vendored
Normal file
264
BeauFindlay/src/BeauFindlay.Api/.gitignore
vendored
Normal file
@@ -0,0 +1,264 @@
|
||||
## Ignore Visual Studio temporary files, build results, and
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
|
||||
# Azure Functions localsettings file
|
||||
local.settings.json
|
||||
|
||||
# User-specific files
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||
*.userprefs
|
||||
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
[Rr]eleases/
|
||||
x64/
|
||||
x86/
|
||||
bld/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Ll]og/
|
||||
|
||||
# Visual Studio 2015 cache/options directory
|
||||
.vs/
|
||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||
#wwwroot/
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
# NUNIT
|
||||
*.VisualState.xml
|
||||
TestResult.xml
|
||||
|
||||
# Build Results of an ATL Project
|
||||
[Dd]ebugPS/
|
||||
[Rr]eleasePS/
|
||||
dlldata.c
|
||||
|
||||
# DNX
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
|
||||
*_i.c
|
||||
*_p.c
|
||||
*_i.h
|
||||
*.ilk
|
||||
*.meta
|
||||
*.obj
|
||||
*.pch
|
||||
*.pdb
|
||||
*.pgc
|
||||
*.pgd
|
||||
*.rsp
|
||||
*.sbr
|
||||
*.tlb
|
||||
*.tli
|
||||
*.tlh
|
||||
*.tmp
|
||||
*.tmp_proj
|
||||
*.log
|
||||
*.vspscc
|
||||
*.vssscc
|
||||
.builds
|
||||
*.pidb
|
||||
*.svclog
|
||||
*.scc
|
||||
|
||||
# Chutzpah Test files
|
||||
_Chutzpah*
|
||||
|
||||
# Visual C++ cache files
|
||||
ipch/
|
||||
*.aps
|
||||
*.ncb
|
||||
*.opendb
|
||||
*.opensdf
|
||||
*.sdf
|
||||
*.cachefile
|
||||
*.VC.db
|
||||
*.VC.VC.opendb
|
||||
|
||||
# Visual Studio profiler
|
||||
*.psess
|
||||
*.vsp
|
||||
*.vspx
|
||||
*.sap
|
||||
|
||||
# TFS 2012 Local Workspace
|
||||
$tf/
|
||||
|
||||
# Guidance Automation Toolkit
|
||||
*.gpState
|
||||
|
||||
# ReSharper is a .NET coding add-in
|
||||
_ReSharper*/
|
||||
*.[Rr]e[Ss]harper
|
||||
*.DotSettings.user
|
||||
|
||||
# JustCode is a .NET coding add-in
|
||||
.JustCode
|
||||
|
||||
# TeamCity is a build add-in
|
||||
_TeamCity*
|
||||
|
||||
# DotCover is a Code Coverage Tool
|
||||
*.dotCover
|
||||
|
||||
# NCrunch
|
||||
_NCrunch_*
|
||||
.*crunch*.local.xml
|
||||
nCrunchTemp_*
|
||||
|
||||
# MightyMoose
|
||||
*.mm.*
|
||||
AutoTest.Net/
|
||||
|
||||
# Web workbench (sass)
|
||||
.sass-cache/
|
||||
|
||||
# Installshield output folder
|
||||
[Ee]xpress/
|
||||
|
||||
# DocProject is a documentation generator add-in
|
||||
DocProject/buildhelp/
|
||||
DocProject/Help/*.HxT
|
||||
DocProject/Help/*.HxC
|
||||
DocProject/Help/*.hhc
|
||||
DocProject/Help/*.hhk
|
||||
DocProject/Help/*.hhp
|
||||
DocProject/Help/Html2
|
||||
DocProject/Help/html
|
||||
|
||||
# Click-Once directory
|
||||
publish/
|
||||
|
||||
# Publish Web Output
|
||||
*.[Pp]ublish.xml
|
||||
*.azurePubxml
|
||||
# TODO: Comment the next line if you want to checkin your web deploy settings
|
||||
# but database connection strings (with potential passwords) will be unencrypted
|
||||
#*.pubxml
|
||||
*.publishproj
|
||||
|
||||
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||
# in these scripts will be unencrypted
|
||||
PublishScripts/
|
||||
|
||||
# NuGet Packages
|
||||
*.nupkg
|
||||
# The packages folder can be ignored because of Package Restore
|
||||
**/packages/*
|
||||
# except build/, which is used as an MSBuild target.
|
||||
!**/packages/build/
|
||||
# Uncomment if necessary however generally it will be regenerated when needed
|
||||
#!**/packages/repositories.config
|
||||
# NuGet v3's project.json files produces more ignoreable files
|
||||
*.nuget.props
|
||||
*.nuget.targets
|
||||
|
||||
# Microsoft Azure Build Output
|
||||
csx/
|
||||
*.build.csdef
|
||||
|
||||
# Microsoft Azure Emulator
|
||||
ecf/
|
||||
rcf/
|
||||
|
||||
# Windows Store app package directories and files
|
||||
AppPackages/
|
||||
BundleArtifacts/
|
||||
Package.StoreAssociation.xml
|
||||
_pkginfo.txt
|
||||
|
||||
# Visual Studio cache files
|
||||
# files ending in .cache can be ignored
|
||||
*.[Cc]ache
|
||||
# but keep track of directories ending in .cache
|
||||
!*.[Cc]ache/
|
||||
|
||||
# Others
|
||||
ClientBin/
|
||||
~$*
|
||||
*~
|
||||
*.dbmdl
|
||||
*.dbproj.schemaview
|
||||
*.jfm
|
||||
*.pfx
|
||||
*.publishsettings
|
||||
node_modules/
|
||||
orleans.codegen.cs
|
||||
|
||||
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||
#bower_components/
|
||||
|
||||
# RIA/Silverlight projects
|
||||
Generated_Code/
|
||||
|
||||
# Backup & report files from converting an old project file
|
||||
# to a newer Visual Studio version. Backup files are not needed,
|
||||
# because we have git ;-)
|
||||
_UpgradeReport_Files/
|
||||
Backup*/
|
||||
UpgradeLog*.XML
|
||||
UpgradeLog*.htm
|
||||
|
||||
# SQL Server files
|
||||
*.mdf
|
||||
*.ldf
|
||||
|
||||
# Business Intelligence projects
|
||||
*.rdl.data
|
||||
*.bim.layout
|
||||
*.bim_*.settings
|
||||
|
||||
# Microsoft Fakes
|
||||
FakesAssemblies/
|
||||
|
||||
# GhostDoc plugin setting file
|
||||
*.GhostDoc.xml
|
||||
|
||||
# Node.js Tools for Visual Studio
|
||||
.ntvs_analysis.dat
|
||||
|
||||
# Visual Studio 6 build log
|
||||
*.plg
|
||||
|
||||
# Visual Studio 6 workspace options file
|
||||
*.opt
|
||||
|
||||
# Visual Studio LightSwitch build output
|
||||
**/*.HTMLClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/ModelManifest.xml
|
||||
**/*.Server/GeneratedArtifacts
|
||||
**/*.Server/ModelManifest.xml
|
||||
_Pvt_Extensions
|
||||
|
||||
# Paket dependency manager
|
||||
.paket/paket.exe
|
||||
paket-files/
|
||||
|
||||
# FAKE - F# Make
|
||||
.fake/
|
||||
|
||||
# JetBrains Rider
|
||||
.idea/
|
||||
*.sln.iml
|
||||
|
||||
# CodeRush
|
||||
.cr/
|
||||
|
||||
# Python Tools for Visual Studio (PTVS)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,16 @@
|
||||
using BeauFindlay.Shared.Abstractions;
|
||||
using MediatR;
|
||||
|
||||
namespace BeauFindlay.Api.Abstractions.Messaging;
|
||||
|
||||
public interface ICommand : IRequest<Result>, IBaseCommand
|
||||
{
|
||||
}
|
||||
|
||||
public interface ICommand<TReponse> : IRequest<Result<TReponse>>, IBaseCommand
|
||||
{
|
||||
}
|
||||
|
||||
public interface IBaseCommand
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using BeauFindlay.Shared.Abstractions;
|
||||
using MediatR;
|
||||
|
||||
namespace BeauFindlay.Api.Abstractions.Messaging;
|
||||
|
||||
public interface ICommandHandler<TCommand> : IRequestHandler<TCommand, Result>
|
||||
where TCommand : ICommand
|
||||
{
|
||||
}
|
||||
|
||||
public interface ICommandHandler<TCommand, TResponse> : IRequestHandler<TCommand, Result<TResponse>>
|
||||
where TCommand : ICommand<TResponse>
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using MediatR;
|
||||
|
||||
namespace BeauFindlay.Api.Abstractions.Messaging;
|
||||
|
||||
public interface IDomainEvent : INotification
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using BeauFindlay.Shared.Abstractions;
|
||||
using MediatR;
|
||||
|
||||
namespace BeauFindlay.Api.Abstractions.Messaging;
|
||||
|
||||
public interface IQuery<TResponse> : IRequest<Result<TResponse>>
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using BeauFindlay.Shared.Abstractions;
|
||||
using MediatR;
|
||||
|
||||
namespace BeauFindlay.Api.Abstractions.Messaging;
|
||||
|
||||
public interface IQueryHandler<TQuery, TResponse> : IRequestHandler<TQuery, Result<TResponse>>
|
||||
where TQuery : IQuery<TResponse>
|
||||
{
|
||||
}
|
||||
35
BeauFindlay/src/BeauFindlay.Api/BeauFindlay.Api.csproj
Normal file
35
BeauFindlay/src/BeauFindlay.Api/BeauFindlay.Api.csproj
Normal file
@@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.9.0" />
|
||||
<PackageReference Include="MediatR" Version="12.2.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="1.20.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.1.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="1.16.2" />
|
||||
<PackageReference Include="Microsoft.ApplicationInsights.WorkerService" Version="2.21.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="1.0.0" />
|
||||
<PackageReference Include="SendGrid" Version="9.29.2" />
|
||||
<PackageReference Include="SendGrid.Extensions.DependencyInjection" Version="1.0.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="host.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="local.settings.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="System.Threading.ExecutionContext" Alias="ExecutionContext" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\BeauFindlay.Shared\BeauFindlay.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,45 @@
|
||||
using BeauFindlay.Api.Features.Contact;
|
||||
using FluentValidation;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SendGrid.Extensions.DependencyInjection;
|
||||
|
||||
namespace BeauFindlay.Api.Extensions;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static void AddApplicationServices(this IServiceCollection services)
|
||||
{
|
||||
var assembly = typeof(ServiceCollectionExtensions).Assembly;
|
||||
|
||||
services.AddMediatR(config => { config.RegisterServicesFromAssembly(assembly); });
|
||||
|
||||
services.AddValidatorsFromAssembly(assembly, includeInternalTypes: true);
|
||||
|
||||
services.AddEmailService();
|
||||
|
||||
services.AddRecaptchaService();
|
||||
}
|
||||
|
||||
private static void AddEmailService(this IServiceCollection services)
|
||||
{
|
||||
var apiKey = Environment.GetEnvironmentVariable("SendGridApiKey")
|
||||
?? throw new ArgumentException("SendGrid API key cannot be null");
|
||||
|
||||
services.AddSendGrid(config => config.ApiKey = apiKey);
|
||||
|
||||
services.AddScoped<ISendGridService, SendGridService>();
|
||||
}
|
||||
|
||||
private static void AddRecaptchaService(this IServiceCollection services)
|
||||
{
|
||||
var apiKey = Environment.GetEnvironmentVariable("RecaptchaApiKey")
|
||||
?? throw new ArgumentException("Google Recaptcha API key cannot be null");
|
||||
|
||||
var settings = new RecaptchaSettings(apiKey);
|
||||
|
||||
services.AddSingleton(settings);
|
||||
|
||||
services.AddHttpClient<IRecaptchaService, RecaptchaService>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using BeauFindlay.Shared.Abstractions;
|
||||
|
||||
namespace BeauFindlay.Api.Features.Contact;
|
||||
|
||||
internal interface IRecaptchaService
|
||||
{
|
||||
Task<Result> ValidateResponseAsync(string recaptchaResponse,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using BeauFindlay.Shared.Abstractions;
|
||||
|
||||
namespace BeauFindlay.Api.Features.Contact;
|
||||
|
||||
internal interface ISendGridService
|
||||
{
|
||||
Task<Result> SendEmailAsync(string from, string to, string subject, string plainTextContent,
|
||||
string htmlContent);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using BeauFindlay.Shared.Abstractions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace BeauFindlay.Api.Features.Contact;
|
||||
|
||||
internal sealed class RecaptchaService(
|
||||
HttpClient httpClient,
|
||||
ILogger<RecaptchaService> logger,
|
||||
RecaptchaSettings settings)
|
||||
: IRecaptchaService
|
||||
{
|
||||
public async Task<Result> ValidateResponseAsync(string recaptchaResponse,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(recaptchaResponse))
|
||||
{
|
||||
logger.LogWarning("Recaptcha response is null.");
|
||||
|
||||
return Result.Failure(RecaptchaErrors.ResponseNull);
|
||||
}
|
||||
|
||||
var response = await httpClient.PostAsync(
|
||||
$"https://www.google.com/recaptcha/api/siteverify?secret={settings.ApiKey}&response={recaptchaResponse}",
|
||||
null, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
logger.LogError(
|
||||
"Google Recaptcha API validation request failed. Code: {StatusCode}",
|
||||
response.StatusCode);
|
||||
|
||||
return Result.Failure(RecaptchaErrors.ApiRequestFailed);
|
||||
}
|
||||
|
||||
var responseString = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var recaptchaResult = JsonConvert.DeserializeObject<RecaptchaVerificationResult>(responseString);
|
||||
|
||||
if (recaptchaResult is null)
|
||||
{
|
||||
logger.LogError("Unable to deserialize Recaptcha result.");
|
||||
|
||||
return Result.Failure(RecaptchaErrors.ResponseSerializationFailed);
|
||||
}
|
||||
|
||||
if (!recaptchaResult.Success)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Google Recaptcha validation failed. Errors: {Errors}",
|
||||
recaptchaResult.ErrorCodes);
|
||||
|
||||
return Result.Failure(RecaptchaErrors.ValidationFailed);
|
||||
}
|
||||
|
||||
logger.LogInformation("Recaptcha validation passed.");
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
private class RecaptchaVerificationResult
|
||||
{
|
||||
[JsonProperty("success")]
|
||||
public bool Success { get; set; }
|
||||
|
||||
[JsonProperty("challenge_ts")]
|
||||
public DateTime ChallengeTs { get; set; }
|
||||
|
||||
[JsonProperty("hostname")]
|
||||
public string Hostname { get; set; } = string.Empty;
|
||||
|
||||
[JsonProperty("error-codes")]
|
||||
public List<string> ErrorCodes { get; set; } = [];
|
||||
}
|
||||
}
|
||||
|
||||
public static class RecaptchaErrors
|
||||
{
|
||||
public static readonly Error ResponseNull = new(
|
||||
"Recaptcha.ResponseNull",
|
||||
"Recaptcha response is null.");
|
||||
|
||||
public static readonly Error ValidationFailed = new(
|
||||
"Recaptcha.ValidationFailed",
|
||||
"Recaptcha validation failed.");
|
||||
|
||||
public static readonly Error ResponseSerializationFailed = new(
|
||||
"Recaptcha.ResponseSerializationFailed",
|
||||
"Unable to deserialize Recaptcha result.");
|
||||
|
||||
public static readonly Error ApiRequestFailed = new(
|
||||
"Recaptcha.ApiRequestFailed",
|
||||
"Recaptcha API validation request failed.");
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace BeauFindlay.Api.Features.Contact;
|
||||
|
||||
internal sealed record RecaptchaSettings(string ApiKey);
|
||||
@@ -0,0 +1,71 @@
|
||||
using BeauFindlay.Api.Abstractions.Messaging;
|
||||
using BeauFindlay.Shared.Abstractions;
|
||||
using FluentValidation;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace BeauFindlay.Api.Features.Contact;
|
||||
|
||||
public sealed record SendContactEmailCommand(string Name, string FromEmail, string Message, string RecaptchaResponse)
|
||||
: ICommand;
|
||||
|
||||
internal sealed class SendContactEmailCommandValidator : AbstractValidator<SendContactEmailCommand>
|
||||
{
|
||||
public SendContactEmailCommandValidator(IRecaptchaService recaptchaService)
|
||||
{
|
||||
RuleFor(c => c.Name)
|
||||
.NotEmpty()
|
||||
.MaximumLength(50);
|
||||
|
||||
RuleFor(c => c.FromEmail)
|
||||
.NotEmpty()
|
||||
.EmailAddress();
|
||||
|
||||
RuleFor(c => c.Message)
|
||||
.NotEmpty()
|
||||
.MaximumLength(500);
|
||||
|
||||
RuleFor(c => c.RecaptchaResponse)
|
||||
.NotEmpty()
|
||||
.MustAsync(async (response, cancellation) =>
|
||||
{
|
||||
var validationResult = await recaptchaService.ValidateResponseAsync(response, cancellation);
|
||||
|
||||
return validationResult.IsSuccess;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class SendContactEmailCommandHandler(
|
||||
ISendGridService sendGridService,
|
||||
IValidator<SendContactEmailCommand> validator,
|
||||
ILogger<SendContactEmailCommandHandler> logger)
|
||||
: ICommandHandler<SendContactEmailCommand>
|
||||
{
|
||||
private const string EmailSubjectBase = "New website enquiry";
|
||||
private const string MyEmail = "me@beaufindlay.com";
|
||||
|
||||
public async Task<Result> Handle(SendContactEmailCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var validationResult = await validator.ValidateAsync(request, cancellationToken);
|
||||
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
logger.LogError("Command validation failed. Errors: {ValidationErrors}", validationResult.ToString());
|
||||
|
||||
return Result.Failure(new Error("ValidationFailed", "Command validation failed"));
|
||||
}
|
||||
|
||||
var subject = $"{EmailSubjectBase} - {request.FromEmail}";
|
||||
var message = $"From: {request.FromEmail}. Message: {request.Message}";
|
||||
var htmlMessage = $"<p><b>From:</b> {request.FromEmail}</p> <p><b>Message:</b> <br /> {request.Message}</p>";
|
||||
|
||||
var emailResult = await sendGridService.SendEmailAsync(
|
||||
MyEmail,
|
||||
MyEmail,
|
||||
subject,
|
||||
message,
|
||||
htmlMessage);
|
||||
|
||||
return emailResult.IsFailure ? Result.Failure(emailResult.Error) : Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using BeauFindlay.Shared.Contracts;
|
||||
using MediatR;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace BeauFindlay.Api.Features.Contact;
|
||||
|
||||
public class SendContactEmailFunction(ILoggerFactory loggerFactory, ISender sender)
|
||||
{
|
||||
private readonly ILogger logger = loggerFactory.CreateLogger<SendContactEmailFunction>();
|
||||
|
||||
[Function(nameof(SendContactEmailFunction))]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Function, "post", Route = "send-contact-email")] HttpRequestData req,
|
||||
FunctionContext executionContext, CancellationToken cancellationToken)
|
||||
{
|
||||
logger.LogInformation($"{nameof(SendContactEmailFunction)} function received a request.");
|
||||
|
||||
var requestBody = await new StreamReader(req.Body).ReadToEndAsync(cancellationToken);
|
||||
var request = JsonConvert.DeserializeObject<SendContactEmailRequest>(requestBody);
|
||||
|
||||
HttpResponseData response;
|
||||
|
||||
try
|
||||
{
|
||||
if (request == null)
|
||||
{
|
||||
throw new ApplicationException("Unable to deserialize response.");
|
||||
}
|
||||
|
||||
var emailCommand = new SendContactEmailCommand(
|
||||
request.Name,
|
||||
request.FromEmail,
|
||||
request.Message,
|
||||
request.RecaptchaResponse);
|
||||
|
||||
var sendEmailResult = await sender.Send(emailCommand, cancellationToken);
|
||||
|
||||
if (sendEmailResult.IsFailure)
|
||||
{
|
||||
logger.LogError("Send email command failed. Error: {Error}", sendEmailResult.Error.Message);
|
||||
|
||||
response = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
|
||||
var error = new ErrorResponse
|
||||
{
|
||||
Code = (int)HttpStatusCode.BadRequest,
|
||||
Message = sendEmailResult.Error.Message
|
||||
};
|
||||
|
||||
await response.WriteAsJsonAsync(error, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
response = req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
await response.WriteAsJsonAsync("", cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.LogError(e, "Exception occured. Error: '{Message}'", e.Message);
|
||||
|
||||
response = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
|
||||
await response.WriteAsJsonAsync(ErrorResponse.Generic, cancellationToken);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using BeauFindlay.Shared.Abstractions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SendGrid;
|
||||
using SendGrid.Helpers.Mail;
|
||||
|
||||
namespace BeauFindlay.Api.Features.Contact;
|
||||
|
||||
internal sealed class SendGridService(ISendGridClient sendGridClient, ILogger<SendGridService> logger)
|
||||
: ISendGridService
|
||||
{
|
||||
public async Task<Result> SendEmailAsync(string from, string to, string subject, string plainTextContent,
|
||||
string htmlContent)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(from, nameof(from));
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(to, nameof(to));
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(subject, nameof(subject));
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(plainTextContent, nameof(plainTextContent));
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(htmlContent, nameof(htmlContent));
|
||||
|
||||
var fromEmail = new EmailAddress(from);
|
||||
var toEmail = new EmailAddress(to);
|
||||
|
||||
var message = MailHelper.CreateSingleEmail(fromEmail, toEmail, subject, plainTextContent, htmlContent);
|
||||
|
||||
var response = await sendGridClient.SendEmailAsync(message);
|
||||
|
||||
if (response is not { IsSuccessStatusCode: true })
|
||||
{
|
||||
logger.LogError("Failed to send email. Status code: '{StatusCode}'", response?.StatusCode);
|
||||
|
||||
return Result.Failure(new Error("Email.SendFailed", "Failed to send email."));
|
||||
}
|
||||
|
||||
logger.LogInformation("Email sent successfully.");
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
25
BeauFindlay/src/BeauFindlay.Api/Function1.cs
Normal file
25
BeauFindlay/src/BeauFindlay.Api/Function1.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using System.Net;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace BeauFindlay.Api
|
||||
{
|
||||
public class Function1(ILoggerFactory loggerFactory)
|
||||
{
|
||||
private readonly ILogger _logger = loggerFactory.CreateLogger<Function1>();
|
||||
|
||||
[Function("Function1")]
|
||||
public HttpResponseData Run([HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req)
|
||||
{
|
||||
_logger.LogInformation("C# HTTP trigger function processed a request.");
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
response.Headers.Add("Content-Type", "text/plain; charset=utf-8");
|
||||
|
||||
response.WriteString("Welcome to Azure Functions!");
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
16
BeauFindlay/src/BeauFindlay.Api/Program.cs
Normal file
16
BeauFindlay/src/BeauFindlay.Api/Program.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using BeauFindlay.Api.Extensions;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
var host = new HostBuilder()
|
||||
.ConfigureFunctionsWorkerDefaults()
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.AddApplicationInsightsTelemetryWorkerService();
|
||||
services.ConfigureFunctionsApplicationInsights();
|
||||
services.AddApplicationServices();
|
||||
})
|
||||
.Build();
|
||||
|
||||
host.Run();
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"profiles": {
|
||||
"BeauFindlay.Api": {
|
||||
"commandName": "Project",
|
||||
"commandLineArgs": "--port 7197",
|
||||
"launchBrowser": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"appInsights1": {
|
||||
"type": "appInsights"
|
||||
},
|
||||
"storage1": {
|
||||
"type": "storage",
|
||||
"connectionId": "AzureWebJobsStorage"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"appInsights1": {
|
||||
"type": "appInsights.sdk"
|
||||
},
|
||||
"storage1": {
|
||||
"type": "storage.emulator",
|
||||
"connectionId": "AzureWebJobsStorage"
|
||||
}
|
||||
}
|
||||
}
|
||||
12
BeauFindlay/src/BeauFindlay.Api/host.json
Normal file
12
BeauFindlay/src/BeauFindlay.Api/host.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": "2.0",
|
||||
"logging": {
|
||||
"applicationInsights": {
|
||||
"samplingSettings": {
|
||||
"isEnabled": true,
|
||||
"excludedTypes": "Request"
|
||||
},
|
||||
"enableLiveMetricsFilters": true
|
||||
}
|
||||
}
|
||||
}
|
||||
13
BeauFindlay/src/BeauFindlay.Client/.idea/.idea.BeauFindlay.dir/.idea/.gitignore
generated
vendored
Normal file
13
BeauFindlay/src/BeauFindlay.Client/.idea/.idea.BeauFindlay.dir/.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Rider ignored files
|
||||
/contentModel.xml
|
||||
/modules.xml
|
||||
/.idea.BeauFindlay.iml
|
||||
/projectSettingsUpdater.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
1
BeauFindlay/src/BeauFindlay.Client/.idea/.idea.BeauFindlay.dir/.idea/.name
generated
Normal file
1
BeauFindlay/src/BeauFindlay.Client/.idea/.idea.BeauFindlay.dir/.idea/.name
generated
Normal file
@@ -0,0 +1 @@
|
||||
BeauFindlay
|
||||
4
BeauFindlay/src/BeauFindlay.Client/.idea/.idea.BeauFindlay.dir/.idea/encodings.xml
generated
Normal file
4
BeauFindlay/src/BeauFindlay.Client/.idea/.idea.BeauFindlay.dir/.idea/encodings.xml
generated
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
|
||||
</project>
|
||||
8
BeauFindlay/src/BeauFindlay.Client/.idea/.idea.BeauFindlay.dir/.idea/indexLayout.xml
generated
Normal file
8
BeauFindlay/src/BeauFindlay.Client/.idea/.idea.BeauFindlay.dir/.idea/indexLayout.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="UserContentModel">
|
||||
<attachedFolders />
|
||||
<explicitIncludes />
|
||||
<explicitExcludes />
|
||||
</component>
|
||||
</project>
|
||||
6
BeauFindlay/src/BeauFindlay.Client/.idea/.idea.BeauFindlay.dir/.idea/vcs.xml
generated
Normal file
6
BeauFindlay/src/BeauFindlay.Client/.idea/.idea.BeauFindlay.dir/.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$/../../.." vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -11,4 +11,8 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.2" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\BeauFindlay.Shared\BeauFindlay.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,46 @@
|
||||
<div class="p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
@if (Type == AlertType.Success)
|
||||
{
|
||||
<svg class="h-5 w-5 text-green-500" v iewBox="0 0 20 20"
|
||||
fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
||||
clip-rule="evenodd"/>
|
||||
</svg>
|
||||
}
|
||||
else
|
||||
{
|
||||
<svg class="h-5 w-5 text-red-500" v iewBox="0 0 20 20"
|
||||
fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z"
|
||||
clip-rule="evenodd"/>
|
||||
</svg>
|
||||
}
|
||||
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="font-medium">
|
||||
@Title
|
||||
</h3>
|
||||
<div class="mt-2">
|
||||
<p>@ChildContent</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
|
||||
[Parameter]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public AlertType Type { get; set; }
|
||||
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace BeauFindlay.Client.Components.Alert;
|
||||
|
||||
public enum AlertType
|
||||
{
|
||||
Success,
|
||||
Error
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<button type="@Type" disabled="@IsLoading"
|
||||
class="border-0 ring-1 ring-inset ring-gray-300 bg-black px-3.5 py-2.5 text-sm font-semibold text-white shadow hover:bg-gray-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-600 disabled:bg-gray-800">
|
||||
@if (IsLoading)
|
||||
{
|
||||
<LoadingSpinner Size="LoadingSpinnerSize.Small"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
@ChildContent
|
||||
}
|
||||
</button>
|
||||
|
||||
@code {
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string Type { get; set; } = "button";
|
||||
|
||||
[Parameter]
|
||||
public bool IsLoading { get; set; }
|
||||
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<div role="status">
|
||||
<svg aria-hidden="true"
|
||||
class="@sizeCss inline text-gray-200 animate-spin dark:text-gray-600 fill-gray-600 dark:fill-gray-300"
|
||||
viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor"/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill"/>
|
||||
</svg>
|
||||
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
|
||||
[Parameter]
|
||||
public LoadingSpinnerSize Size { get; set; }
|
||||
|
||||
private string sizeCss = "w-8 h-8";
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
SetSpinnerSize();
|
||||
}
|
||||
|
||||
private void SetSpinnerSize()
|
||||
{
|
||||
sizeCss = Size switch
|
||||
{
|
||||
LoadingSpinnerSize.Small => "w-6 h-6",
|
||||
LoadingSpinnerSize.Medium => "w-8 h-8",
|
||||
LoadingSpinnerSize.Large => "w-12 h-12",
|
||||
_ => sizeCss
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace BeauFindlay.Client.Components.LoadingSpinner;
|
||||
|
||||
public enum LoadingSpinnerSize
|
||||
{
|
||||
Small,
|
||||
Medium,
|
||||
Large
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace BeauFindlay.Components.Typewriter;
|
||||
namespace BeauFindlay.Client.Components.Typewriter;
|
||||
|
||||
public interface ITypewriterNotificationService
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace BeauFindlay.Components.Typewriter;
|
||||
namespace BeauFindlay.Client.Components.Typewriter;
|
||||
|
||||
public static class TypewriterConstants
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace BeauFindlay.Components.Typewriter;
|
||||
namespace BeauFindlay.Client.Components.Typewriter;
|
||||
|
||||
public class TypewriterNotificationService : ITypewriterNotificationService
|
||||
{
|
||||
@@ -1,5 +1,5 @@
|
||||
<footer class="mt-auto fade-in">
|
||||
<div class="mx-auto p-8 lg:p-8">
|
||||
<div class="mx-auto py-8 px-4 md:px-12">
|
||||
<div class="md:flex md:items-center md:justify-between">
|
||||
<div class="flex space-x-6 md:order-2">
|
||||
<a href="https://github.com/bdfin" class="text-slate-200 hover:text-slate-500">
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="flex flex-col min-h-screen">
|
||||
<NavBar/>
|
||||
|
||||
<div class="px-4 md:px-8">
|
||||
<div class="px-4 md:px-12 py-4">
|
||||
@Body
|
||||
</div>
|
||||
|
||||
122
BeauFindlay/src/BeauFindlay.Client/Pages/Contact.razor
Normal file
122
BeauFindlay/src/BeauFindlay.Client/Pages/Contact.razor
Normal file
@@ -0,0 +1,122 @@
|
||||
@page "/contact"
|
||||
@using BeauFindlay.Shared.Contracts
|
||||
|
||||
@inject HttpClient HttpClient
|
||||
@inject IJSRuntime JsRuntime
|
||||
|
||||
<div class="text-center">
|
||||
<h1 class="text-4xl">Contact</h1>
|
||||
|
||||
<p class="text-lg mt-8">
|
||||
If you think I can help with your project or you'd just like to talk tech, send me a message!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-xl pt-10 pb-4">
|
||||
<EditForm Model="contactInput" OnValidSubmit="HandleValidSubmit">
|
||||
<DataAnnotationsValidator/>
|
||||
|
||||
<div class="grid grid-cols-1 gap-x-8 gap-y-6 sm:grid-cols-2">
|
||||
<div class="sm:col-span-2">
|
||||
<label for="name" class="block font-semibold leading-6">Name</label>
|
||||
<div class="mt-2.5">
|
||||
<InputText id="name" @bind-Value="contactInput.Name" class="block w-full text-lg border-0 px-3.5 py-2 bg-black shadow ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-gray-600"/>
|
||||
<ValidationMessage For="() => contactInput.Name" class="text-red-600"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label for="email" class="block font-semibold leading-6">Email</label>
|
||||
<div class="mt-2.5">
|
||||
<InputText id="email" @bind-Value="contactInput.Email" class="block w-full text-lg border-0 px-3.5 py-2 bg-black shadow ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-gray-600"/>
|
||||
<ValidationMessage For="() => contactInput.Email" class="text-red-600"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label for="message" class="block font-semibold leading-6">Message</label>
|
||||
<div class="mt-2.5">
|
||||
<InputTextArea id="message" @bind-Value="contactInput.Message" rows="4" class="block w-full text-lg border-0 px-3.5 py-2 bg-black shadow ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-gray-600"/>
|
||||
<ValidationMessage For="() => contactInput.Message" class="text-red-600"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-8 flex items-center justify-center">
|
||||
<Button Type="submit" IsLoading="@isSubmitting">
|
||||
<i class="fa-solid fa-paper-plane"></i> Send
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</EditForm>
|
||||
</div>
|
||||
|
||||
@if (isSubmitted)
|
||||
{
|
||||
<div class="m-auto max-w-xl">
|
||||
@if (sendEmailSuccess)
|
||||
{
|
||||
<Alert Type="AlertType.Success" Title="Email sent successfully">
|
||||
Thanks for getting in touch! I'll get back to you as soon as I can.
|
||||
</Alert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<Alert Type="AlertType.Error" Title="Email sending failed">
|
||||
Looks like something went wrong trying to send that email. Please try again.
|
||||
</Alert>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<script src="https://www.google.com/recaptcha/api.js?render=6LcvxZIpAAAAAOIP5L6kGngwDZRpwkTdMezPn06x" async
|
||||
defer></script>
|
||||
<script src="js/recaptcha.js"></script>
|
||||
|
||||
@code {
|
||||
private readonly ContactInputModel contactInput = new();
|
||||
|
||||
private bool isSubmitting;
|
||||
private bool isSubmitted;
|
||||
private bool sendEmailSuccess;
|
||||
|
||||
private class ContactInputModel
|
||||
{
|
||||
[Required(ErrorMessage = "Please enter your name.")]
|
||||
[MaxLength(50, ErrorMessage = "Please use a shorter name. 50 characters max.")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[Required(ErrorMessage = "Please enter your email.")]
|
||||
[EmailAddress(ErrorMessage = "Please enter a valid email address.")]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
[Required(ErrorMessage = "Please include a message.")]
|
||||
[MaxLength(500, ErrorMessage = "Please enter a shorter message. 500 characters max.")]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private async Task HandleValidSubmit()
|
||||
{
|
||||
isSubmitting = true;
|
||||
|
||||
var recaptchaResponse = await JsRuntime.InvokeAsync<string>("executeRecaptcha");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(recaptchaResponse))
|
||||
{
|
||||
sendEmailSuccess = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
var sendEmailRequest = new SendContactEmailRequest(
|
||||
contactInput.Name,
|
||||
contactInput.Email,
|
||||
contactInput.Message,
|
||||
recaptchaResponse);
|
||||
|
||||
var response = await HttpClient.PostAsJsonAsync("/api/send-contact-email", sendEmailRequest);
|
||||
|
||||
sendEmailSuccess = response.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
isSubmitting = false;
|
||||
isSubmitted = true;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
using BeauFindlay;
|
||||
using BeauFindlay.Components.Typewriter;
|
||||
using BeauFindlay.Client;
|
||||
using BeauFindlay.Client.Components.Typewriter;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||
|
||||
@@ -7,7 +7,10 @@ var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||
builder.RootComponents.Add<App>("#app");
|
||||
builder.RootComponents.Add<HeadOutlet>("head::after");
|
||||
|
||||
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
|
||||
var apiBase = builder.Configuration["ApiBase"]
|
||||
?? throw new ArgumentException("API base address not found in config.");
|
||||
|
||||
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(apiBase) });
|
||||
|
||||
builder.Services.AddSingleton<ITypewriterNotificationService, TypewriterNotificationService>();
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.AspNetCore.Components.WebAssembly.Http
|
||||
@using Microsoft.JSInterop
|
||||
@using BeauFindlay
|
||||
@using BeauFindlay.Layout
|
||||
@using BeauFindlay.Components.Typewriter
|
||||
@using BeauFindlay.Client
|
||||
@using BeauFindlay.Client.Layout
|
||||
@using BeauFindlay.Client.Components.Alert
|
||||
@using BeauFindlay.Client.Components.Typewriter
|
||||
@using BeauFindlay.Client.Components.Button
|
||||
@using BeauFindlay.Client.Components.LoadingSpinner
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"navigationFallback": {
|
||||
"rewrite": "/index.html"
|
||||
}
|
||||
}
|
||||
10
BeauFindlay/src/BeauFindlay.Client/wwwroot/appsettings.json
Normal file
10
BeauFindlay/src/BeauFindlay.Client/wwwroot/appsettings.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ApiBase": "http://localhost:7071"
|
||||
}
|
||||
@@ -560,15 +560,31 @@ video {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.m-auto {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.mx-auto {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.ml-3 {
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
.mt-16 {
|
||||
margin-top: 4rem;
|
||||
}
|
||||
|
||||
.mt-2 {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.mt-2\.5 {
|
||||
margin-top: 0.625rem;
|
||||
}
|
||||
|
||||
.mt-4 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@@ -581,16 +597,36 @@ video {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.mt-10 {
|
||||
margin-top: 2.5rem;
|
||||
.block {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.inline {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.h-20 {
|
||||
height: 5rem;
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.h-12 {
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
.h-5 {
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.h-6 {
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.h-8 {
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.h-full {
|
||||
@@ -601,6 +637,48 @@ video {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.w-12 {
|
||||
width: 3rem;
|
||||
}
|
||||
|
||||
.w-5 {
|
||||
width: 1.25rem;
|
||||
}
|
||||
|
||||
.w-6 {
|
||||
width: 1.5rem;
|
||||
}
|
||||
|
||||
.w-8 {
|
||||
width: 2rem;
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.max-w-xl {
|
||||
max-width: 36rem;
|
||||
}
|
||||
|
||||
.flex-shrink-0 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.grid-cols-1 {
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -613,6 +691,15 @@ video {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gap-x-8 {
|
||||
-moz-column-gap: 2rem;
|
||||
column-gap: 2rem;
|
||||
}
|
||||
|
||||
.gap-y-6 {
|
||||
row-gap: 1.5rem;
|
||||
}
|
||||
|
||||
.space-x-6 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-x-reverse: 0;
|
||||
margin-right: calc(1.5rem * var(--tw-space-x-reverse));
|
||||
@@ -629,6 +716,18 @@ video {
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.rounded-md {
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.border-0 {
|
||||
border-width: 0px;
|
||||
}
|
||||
|
||||
.border {
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.border-l-2 {
|
||||
border-left-width: 2px;
|
||||
}
|
||||
@@ -642,8 +741,27 @@ video {
|
||||
background-color: rgb(0 0 0 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.p-8 {
|
||||
padding: 2rem;
|
||||
.bg-green-50 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(240 253 244 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-red-50 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(254 242 242 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.fill-gray-600 {
|
||||
fill: #4b5563;
|
||||
}
|
||||
|
||||
.p-4 {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.px-10 {
|
||||
padding-left: 2.5rem;
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
|
||||
.px-2 {
|
||||
@@ -651,6 +769,16 @@ video {
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.px-3 {
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
|
||||
.px-3\.5 {
|
||||
padding-left: 0.875rem;
|
||||
padding-right: 0.875rem;
|
||||
}
|
||||
|
||||
.px-4 {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
@@ -661,21 +789,43 @@ video {
|
||||
padding-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.py-28 {
|
||||
padding-top: 7rem;
|
||||
padding-bottom: 7rem;
|
||||
}
|
||||
|
||||
.py-20 {
|
||||
padding-top: 5rem;
|
||||
padding-bottom: 5rem;
|
||||
}
|
||||
|
||||
.py-12 {
|
||||
padding-top: 3rem;
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
|
||||
.py-2 {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.py-2\.5 {
|
||||
padding-top: 0.625rem;
|
||||
padding-bottom: 0.625rem;
|
||||
}
|
||||
|
||||
.py-4 {
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.py-8 {
|
||||
padding-top: 2rem;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.pt-10 {
|
||||
padding-top: 2.5rem;
|
||||
}
|
||||
|
||||
.pb-4 {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
@@ -685,11 +835,26 @@ video {
|
||||
line-height: 2rem;
|
||||
}
|
||||
|
||||
.text-3xl {
|
||||
font-size: 1.875rem;
|
||||
line-height: 2.25rem;
|
||||
}
|
||||
|
||||
.text-4xl {
|
||||
font-size: 2.25rem;
|
||||
line-height: 2.5rem;
|
||||
}
|
||||
|
||||
.text-lg {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.75rem;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
.text-xl {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.75rem;
|
||||
@@ -700,9 +865,8 @@ video {
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.text-3xl {
|
||||
font-size: 1.875rem;
|
||||
line-height: 2.25rem;
|
||||
.font-medium {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.font-semibold {
|
||||
@@ -713,6 +877,50 @@ video {
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
.leading-6 {
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
.text-gray-200 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(229 231 235 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-green-400 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(74 222 128 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-green-700 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(21 128 61 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-green-800 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(22 101 52 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-red-400 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(248 113 113 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-red-600 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(220 38 38 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-red-700 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(185 28 28 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-red-800 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(153 27 27 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-slate-100 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(241 245 249 / var(--tw-text-opacity));
|
||||
@@ -728,12 +936,28 @@ video {
|
||||
color: rgb(248 250 252 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.underline {
|
||||
text-decoration-line: underline;
|
||||
.text-white {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(255 255 255 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.underline-offset-4 {
|
||||
text-underline-offset: 4px;
|
||||
.text-green-600 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(22 163 74 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-green-500 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(34 197 94 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-red-500 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(239 68 68 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.underline {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
|
||||
.antialiased {
|
||||
@@ -741,6 +965,27 @@ video {
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.shadow {
|
||||
--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
|
||||
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
|
||||
}
|
||||
|
||||
.ring-1 {
|
||||
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
|
||||
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
|
||||
}
|
||||
|
||||
.ring-inset {
|
||||
--tw-ring-inset: inset;
|
||||
}
|
||||
|
||||
.ring-gray-300 {
|
||||
--tw-ring-opacity: 1;
|
||||
--tw-ring-color: rgb(209 213 219 / var(--tw-ring-opacity));
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
from, to {
|
||||
opacity: 1
|
||||
@@ -771,11 +1016,72 @@ video {
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder\:text-gray-400::-moz-placeholder {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(156 163 175 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.placeholder\:text-gray-400::placeholder {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(156 163 175 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.hover\:bg-gray-800:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(31 41 55 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.hover\:text-slate-500:hover {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(100 116 139 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.focus\:ring-2:focus {
|
||||
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
|
||||
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
|
||||
}
|
||||
|
||||
.focus\:ring-inset:focus {
|
||||
--tw-ring-inset: inset;
|
||||
}
|
||||
|
||||
.focus\:ring-gray-600:focus {
|
||||
--tw-ring-opacity: 1;
|
||||
--tw-ring-color: rgb(75 85 99 / var(--tw-ring-opacity));
|
||||
}
|
||||
|
||||
.focus-visible\:outline:focus-visible {
|
||||
outline-style: solid;
|
||||
}
|
||||
|
||||
.focus-visible\:outline-2:focus-visible {
|
||||
outline-width: 2px;
|
||||
}
|
||||
|
||||
.focus-visible\:outline-offset-2:focus-visible {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.focus-visible\:outline-gray-600:focus-visible {
|
||||
outline-color: #4b5563;
|
||||
}
|
||||
|
||||
.disabled\:bg-gray-800:disabled {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(31 41 55 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.sm\:col-span-2 {
|
||||
grid-column: span 2 / span 2;
|
||||
}
|
||||
|
||||
.sm\:grid-cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.md\:order-1 {
|
||||
order: 1;
|
||||
@@ -801,14 +1107,19 @@ video {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.md\:px-8 {
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
.md\:px-12 {
|
||||
padding-left: 3rem;
|
||||
padding-right: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.lg\:p-8 {
|
||||
padding: 2rem;
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.dark\:fill-gray-300 {
|
||||
fill: #d1d5db;
|
||||
}
|
||||
|
||||
.dark\:text-gray-600 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(75 85 99 / var(--tw-text-opacity));
|
||||
}
|
||||
}
|
||||
18
BeauFindlay/src/BeauFindlay.Client/wwwroot/js/recaptcha.js
Normal file
18
BeauFindlay/src/BeauFindlay.Client/wwwroot/js/recaptcha.js
Normal file
@@ -0,0 +1,18 @@
|
||||
window.executeRecaptcha = function () {
|
||||
return new Promise((resolve, reject) => {
|
||||
grecaptcha.ready(function () {
|
||||
grecaptcha
|
||||
.execute("6LcvxZIpAAAAAOIP5L6kGngwDZRpwkTdMezPn06x", {
|
||||
action: "submit",
|
||||
})
|
||||
.then(
|
||||
function (token) {
|
||||
resolve(token);
|
||||
},
|
||||
function (error) {
|
||||
reject(error);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
7
BeauFindlay/src/BeauFindlay.Shared/Abstractions/Error.cs
Normal file
7
BeauFindlay/src/BeauFindlay.Shared/Abstractions/Error.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace BeauFindlay.Shared.Abstractions;
|
||||
|
||||
public record Error(string Code, string Message)
|
||||
{
|
||||
public static readonly Error None = new(string.Empty, string.Empty);
|
||||
public static readonly Error NullValue = new("Error.NullValue", "Null value was provided.");
|
||||
}
|
||||
66
BeauFindlay/src/BeauFindlay.Shared/Abstractions/Result.cs
Normal file
66
BeauFindlay/src/BeauFindlay.Shared/Abstractions/Result.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace BeauFindlay.Shared.Abstractions;
|
||||
|
||||
public class Result
|
||||
{
|
||||
protected Result(bool isSuccess, Error error)
|
||||
{
|
||||
switch (isSuccess)
|
||||
{
|
||||
case true when error != Error.None:
|
||||
throw new InvalidOperationException("Successful result cannot contain an error.");
|
||||
case false when error == Error.None:
|
||||
throw new InvalidOperationException("Failed result must contain an error.");
|
||||
default:
|
||||
IsSuccess = isSuccess;
|
||||
Error = error;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsSuccess { get; }
|
||||
|
||||
public bool IsFailure => !IsSuccess;
|
||||
|
||||
public Error Error { get; }
|
||||
|
||||
public static Result Success()
|
||||
{
|
||||
return new Result(true, Error.None);
|
||||
}
|
||||
|
||||
public static Result Failure(Error error)
|
||||
{
|
||||
return new Result(false, error);
|
||||
}
|
||||
|
||||
public static Result<TValue> Success<TValue>(TValue value)
|
||||
{
|
||||
return new Result<TValue>(value, true, Error.None);
|
||||
}
|
||||
|
||||
public static Result<TValue> Failure<TValue>(Error error)
|
||||
{
|
||||
return new Result<TValue>(default, false, error);
|
||||
}
|
||||
|
||||
protected static Result<TValue> Create<TValue>(TValue? value)
|
||||
{
|
||||
return value is not null ? Success(value) : Failure<TValue>(Error.NullValue);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class Result<TValue>(TValue? value, bool isSuccess, Error error)
|
||||
: Result(isSuccess, error)
|
||||
{
|
||||
[NotNull]
|
||||
public TValue Value => IsSuccess
|
||||
? value!
|
||||
: throw new InvalidOperationException("The value of a failure result can not be accessed.");
|
||||
|
||||
public static implicit operator Result<TValue>(TValue? value)
|
||||
{
|
||||
return Create(value);
|
||||
}
|
||||
}
|
||||
13
BeauFindlay/src/BeauFindlay.Shared/BeauFindlay.Shared.csproj
Normal file
13
BeauFindlay/src/BeauFindlay.Shared/BeauFindlay.Shared.csproj
Normal file
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.Net;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace BeauFindlay.Shared.Contracts;
|
||||
|
||||
public sealed class ErrorResponse
|
||||
{
|
||||
[JsonProperty("code")]
|
||||
public int Code { get; set; }
|
||||
|
||||
[JsonProperty("message")]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
public static ErrorResponse Generic => new ErrorResponse
|
||||
{
|
||||
Code = (int)HttpStatusCode.BadRequest,
|
||||
Message = "Opps... something went wrong."
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace BeauFindlay.Shared.Contracts;
|
||||
|
||||
public record SendContactEmailRequest(string Name, string FromEmail, string Message, string RecaptchaResponse);
|
||||
Reference in New Issue
Block a user