Add contact feature
This commit is contained in:
@@ -9,6 +9,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BeauFindlay.Client", "src\B
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BeauFindlay.Api", "src\BeauFindlay.Api\BeauFindlay.Api.csproj", "{D2F248BF-8487-4CE7-B6AA-D558587A52DA}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BeauFindlay.Api", "src\BeauFindlay.Api\BeauFindlay.Api.csproj", "{D2F248BF-8487-4CE7-B6AA-D558587A52DA}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BeauFindlay.Shared", "src\BeauFindlay.Shared\BeauFindlay.Shared.csproj", "{0A17E6ED-1B40-4FAE-94D5-1255C3569F6E}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -23,6 +25,10 @@ Global
|
|||||||
{D2F248BF-8487-4CE7-B6AA-D558587A52DA}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{D2F248BF-8487-4CE7-B6AA-D558587A52DA}.Release|Any CPU.Build.0 = 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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -30,6 +36,7 @@ Global
|
|||||||
GlobalSection(NestedProjects) = preSolution
|
GlobalSection(NestedProjects) = preSolution
|
||||||
{979CCAA2-5F1B-457B-9536-02F94BC98F9F} = {3407557D-A21B-4F48-930C-6FDCE961ED2A}
|
{979CCAA2-5F1B-457B-9536-02F94BC98F9F} = {3407557D-A21B-4F48-930C-6FDCE961ED2A}
|
||||||
{D2F248BF-8487-4CE7-B6AA-D558587A52DA} = {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
|
EndGlobalSection
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
SolutionGuid = {6D05FF39-4D7B-4F0D-8136-E48F71106C3A}
|
SolutionGuid = {6D05FF39-4D7B-4F0D-8136-E48F71106C3A}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -7,22 +7,29 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<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" Version="1.20.0" />
|
||||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.1.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.Azure.Functions.Worker.Sdk" Version="1.16.2" />
|
||||||
<PackageReference Include="Microsoft.ApplicationInsights.WorkerService" Version="2.21.0" />
|
<PackageReference Include="Microsoft.ApplicationInsights.WorkerService" Version="2.21.0" />
|
||||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="1.0.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>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Update="host.json">
|
<None Update="host.json">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</None>
|
</None>
|
||||||
<None Update="local.settings.json">
|
<None Update="local.settings.json">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
|
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
|
||||||
</None>
|
</None>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Using Include="System.Threading.ExecutionContext" Alias="ExecutionContext"/>
|
<Using Include="System.Threading.ExecutionContext" Alias="ExecutionContext" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\BeauFindlay.Shared\BeauFindlay.Shared.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using BeauFindlay.Api.Extensions;
|
||||||
using Microsoft.Azure.Functions.Worker;
|
using Microsoft.Azure.Functions.Worker;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
@@ -8,6 +9,7 @@ var host = new HostBuilder()
|
|||||||
{
|
{
|
||||||
services.AddApplicationInsightsTelemetryWorkerService();
|
services.AddApplicationInsightsTelemetryWorkerService();
|
||||||
services.ConfigureFunctionsApplicationInsights();
|
services.ConfigureFunctionsApplicationInsights();
|
||||||
|
services.AddApplicationServices();
|
||||||
})
|
})
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
|
|||||||
@@ -11,4 +11,8 @@
|
|||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.2" PrivateAssets="all" />
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.2" PrivateAssets="all" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\BeauFindlay.Shared\BeauFindlay.Shared.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</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,5 +1,5 @@
|
|||||||
<footer class="mt-auto fade-in">
|
<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="md:flex md:items-center md:justify-between">
|
||||||
<div class="flex space-x-6 md:order-2">
|
<div class="flex space-x-6 md:order-2">
|
||||||
<a href="https://github.com/bdfin" class="text-slate-200 hover:text-slate-500">
|
<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">
|
<div class="flex flex-col min-h-screen">
|
||||||
<NavBar/>
|
<NavBar/>
|
||||||
|
|
||||||
<div class="px-4 md:px-8">
|
<div class="px-4 md:px-12 py-4">
|
||||||
@Body
|
@Body
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,122 @@
|
|||||||
@page "/contact"
|
@page "/contact"
|
||||||
|
@using BeauFindlay.Shared.Contracts
|
||||||
|
|
||||||
@inject HttpClient HttpClient
|
@inject HttpClient HttpClient
|
||||||
|
@inject IJSRuntime JsRuntime
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(apiResponse))
|
<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)
|
||||||
{
|
{
|
||||||
<p class="text-xl">Response from Azure Function API: @apiResponse</p>
|
<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>
|
||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
<script src="https://www.google.com/recaptcha/api.js?render=6LcvxZIpAAAAAOIP5L6kGngwDZRpwkTdMezPn06x" async
|
||||||
private string apiResponse = "";
|
defer></script>
|
||||||
|
<script src="js/recaptcha.js"></script>
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
var response = await HttpClient.GetStringAsync("/api/Function1");
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(response))
|
@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))
|
||||||
{
|
{
|
||||||
apiResponse = response;
|
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,6 @@
|
|||||||
@using System.Net.Http
|
@using System.Net.Http
|
||||||
@using System.Net.Http.Json
|
@using System.Net.Http.Json
|
||||||
|
@using System.ComponentModel.DataAnnotations
|
||||||
@using Microsoft.AspNetCore.Components.Forms
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
@using Microsoft.AspNetCore.Components.Routing
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
@using Microsoft.AspNetCore.Components.Web
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
@@ -8,4 +9,7 @@
|
|||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
@using BeauFindlay.Client
|
@using BeauFindlay.Client
|
||||||
@using BeauFindlay.Client.Layout
|
@using BeauFindlay.Client.Layout
|
||||||
@using BeauFindlay.Client.Components.Typewriter
|
@using BeauFindlay.Client.Components.Alert
|
||||||
|
@using BeauFindlay.Client.Components.Typewriter
|
||||||
|
@using BeauFindlay.Client.Components.Button
|
||||||
|
@using BeauFindlay.Client.Components.LoadingSpinner
|
||||||
@@ -560,15 +560,31 @@ video {
|
|||||||
position: static;
|
position: static;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.m-auto {
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.mx-auto {
|
.mx-auto {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ml-3 {
|
||||||
|
margin-left: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.mt-16 {
|
.mt-16 {
|
||||||
margin-top: 4rem;
|
margin-top: 4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mt-2 {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-2\.5 {
|
||||||
|
margin-top: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
.mt-4 {
|
.mt-4 {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
@@ -581,16 +597,36 @@ video {
|
|||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mt-10 {
|
.block {
|
||||||
margin-top: 2.5rem;
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline {
|
||||||
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flex {
|
.flex {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.h-20 {
|
.grid {
|
||||||
height: 5rem;
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-12 {
|
||||||
|
height: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-5 {
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-6 {
|
||||||
|
height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-8 {
|
||||||
|
height: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.h-full {
|
.h-full {
|
||||||
@@ -601,6 +637,48 @@ video {
|
|||||||
min-height: 100vh;
|
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-col {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -613,6 +691,15 @@ video {
|
|||||||
justify-content: center;
|
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]) {
|
.space-x-6 > :not([hidden]) ~ :not([hidden]) {
|
||||||
--tw-space-x-reverse: 0;
|
--tw-space-x-reverse: 0;
|
||||||
margin-right: calc(1.5rem * var(--tw-space-x-reverse));
|
margin-right: calc(1.5rem * var(--tw-space-x-reverse));
|
||||||
@@ -629,6 +716,18 @@ video {
|
|||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rounded-md {
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-0 {
|
||||||
|
border-width: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border {
|
||||||
|
border-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.border-l-2 {
|
.border-l-2 {
|
||||||
border-left-width: 2px;
|
border-left-width: 2px;
|
||||||
}
|
}
|
||||||
@@ -642,8 +741,27 @@ video {
|
|||||||
background-color: rgb(0 0 0 / var(--tw-bg-opacity));
|
background-color: rgb(0 0 0 / var(--tw-bg-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
.p-8 {
|
.bg-green-50 {
|
||||||
padding: 2rem;
|
--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 {
|
.px-2 {
|
||||||
@@ -651,6 +769,16 @@ video {
|
|||||||
padding-right: 0.5rem;
|
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 {
|
.px-4 {
|
||||||
padding-left: 1rem;
|
padding-left: 1rem;
|
||||||
padding-right: 1rem;
|
padding-right: 1rem;
|
||||||
@@ -661,21 +789,43 @@ video {
|
|||||||
padding-bottom: 2.5rem;
|
padding-bottom: 2.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.py-28 {
|
|
||||||
padding-top: 7rem;
|
|
||||||
padding-bottom: 7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.py-20 {
|
|
||||||
padding-top: 5rem;
|
|
||||||
padding-bottom: 5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.py-12 {
|
.py-12 {
|
||||||
padding-top: 3rem;
|
padding-top: 3rem;
|
||||||
padding-bottom: 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-mono {
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
}
|
}
|
||||||
@@ -685,11 +835,26 @@ video {
|
|||||||
line-height: 2rem;
|
line-height: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-3xl {
|
||||||
|
font-size: 1.875rem;
|
||||||
|
line-height: 2.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
.text-4xl {
|
.text-4xl {
|
||||||
font-size: 2.25rem;
|
font-size: 2.25rem;
|
||||||
line-height: 2.5rem;
|
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 {
|
.text-xl {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
line-height: 1.75rem;
|
line-height: 1.75rem;
|
||||||
@@ -700,9 +865,8 @@ video {
|
|||||||
line-height: 1rem;
|
line-height: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-3xl {
|
.font-medium {
|
||||||
font-size: 1.875rem;
|
font-weight: 500;
|
||||||
line-height: 2.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.font-semibold {
|
.font-semibold {
|
||||||
@@ -713,6 +877,50 @@ video {
|
|||||||
line-height: 1.25rem;
|
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 {
|
.text-slate-100 {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(241 245 249 / var(--tw-text-opacity));
|
color: rgb(241 245 249 / var(--tw-text-opacity));
|
||||||
@@ -728,12 +936,28 @@ video {
|
|||||||
color: rgb(248 250 252 / var(--tw-text-opacity));
|
color: rgb(248 250 252 / var(--tw-text-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
.underline {
|
.text-white {
|
||||||
text-decoration-line: underline;
|
--tw-text-opacity: 1;
|
||||||
|
color: rgb(255 255 255 / var(--tw-text-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
.underline-offset-4 {
|
.text-green-600 {
|
||||||
text-underline-offset: 4px;
|
--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 {
|
.antialiased {
|
||||||
@@ -741,6 +965,27 @@ video {
|
|||||||
-moz-osx-font-smoothing: grayscale;
|
-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 {
|
@keyframes blink {
|
||||||
from, to {
|
from, to {
|
||||||
opacity: 1
|
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 {
|
.hover\:text-slate-500:hover {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(100 116 139 / var(--tw-text-opacity));
|
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) {
|
@media (min-width: 768px) {
|
||||||
.md\:order-1 {
|
.md\:order-1 {
|
||||||
order: 1;
|
order: 1;
|
||||||
@@ -801,14 +1107,19 @@ video {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md\:px-8 {
|
.md\:px-12 {
|
||||||
padding-left: 2rem;
|
padding-left: 3rem;
|
||||||
padding-right: 2rem;
|
padding-right: 3rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (prefers-color-scheme: dark) {
|
||||||
.lg\:p-8 {
|
.dark\:fill-gray-300 {
|
||||||
padding: 2rem;
|
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