initial commit
This commit is contained in:
commit
689276ea7d
482
.gitignore
vendored
Normal file
482
.gitignore
vendored
Normal file
@ -0,0 +1,482 @@
|
|||||||
|
## Ignore Visual Studio temporary files, build results, and
|
||||||
|
## files generated by popular Visual Studio add-ons.
|
||||||
|
##
|
||||||
|
## Get latest from `dotnet new gitignore`
|
||||||
|
|
||||||
|
# dotenv files
|
||||||
|
.env
|
||||||
|
|
||||||
|
# User-specific files
|
||||||
|
*.rsuser
|
||||||
|
*.suo
|
||||||
|
*.user
|
||||||
|
*.userosscache
|
||||||
|
*.sln.docstates
|
||||||
|
|
||||||
|
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||||
|
*.userprefs
|
||||||
|
|
||||||
|
# Mono auto generated files
|
||||||
|
mono_crash.*
|
||||||
|
|
||||||
|
# Build results
|
||||||
|
[Dd]ebug/
|
||||||
|
[Dd]ebugPublic/
|
||||||
|
[Rr]elease/
|
||||||
|
[Rr]eleases/
|
||||||
|
x64/
|
||||||
|
x86/
|
||||||
|
[Ww][Ii][Nn]32/
|
||||||
|
[Aa][Rr][Mm]/
|
||||||
|
[Aa][Rr][Mm]64/
|
||||||
|
bld/
|
||||||
|
[Bb]in/
|
||||||
|
[Oo]bj/
|
||||||
|
[Ll]og/
|
||||||
|
[Ll]ogs/
|
||||||
|
|
||||||
|
# Visual Studio 2015/2017 cache/options directory
|
||||||
|
.vs/
|
||||||
|
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||||
|
#wwwroot/
|
||||||
|
|
||||||
|
# Visual Studio 2017 auto generated files
|
||||||
|
Generated\ Files/
|
||||||
|
|
||||||
|
# MSTest test Results
|
||||||
|
[Tt]est[Rr]esult*/
|
||||||
|
[Bb]uild[Ll]og.*
|
||||||
|
|
||||||
|
# NUnit
|
||||||
|
*.VisualState.xml
|
||||||
|
TestResult.xml
|
||||||
|
nunit-*.xml
|
||||||
|
|
||||||
|
# Build Results of an ATL Project
|
||||||
|
[Dd]ebugPS/
|
||||||
|
[Rr]eleasePS/
|
||||||
|
dlldata.c
|
||||||
|
|
||||||
|
# Benchmark Results
|
||||||
|
BenchmarkDotNet.Artifacts/
|
||||||
|
|
||||||
|
# .NET
|
||||||
|
project.lock.json
|
||||||
|
project.fragment.lock.json
|
||||||
|
artifacts/
|
||||||
|
|
||||||
|
# Tye
|
||||||
|
.tye/
|
||||||
|
|
||||||
|
# ASP.NET Scaffolding
|
||||||
|
ScaffoldingReadMe.txt
|
||||||
|
|
||||||
|
# StyleCop
|
||||||
|
StyleCopReport.xml
|
||||||
|
|
||||||
|
# Files built by Visual Studio
|
||||||
|
*_i.c
|
||||||
|
*_p.c
|
||||||
|
*_h.h
|
||||||
|
*.ilk
|
||||||
|
*.meta
|
||||||
|
*.obj
|
||||||
|
*.iobj
|
||||||
|
*.pch
|
||||||
|
*.pdb
|
||||||
|
*.ipdb
|
||||||
|
*.pgc
|
||||||
|
*.pgd
|
||||||
|
*.rsp
|
||||||
|
# but not Directory.Build.rsp, as it configures directory-level build defaults
|
||||||
|
!Directory.Build.rsp
|
||||||
|
*.sbr
|
||||||
|
*.tlb
|
||||||
|
*.tli
|
||||||
|
*.tlh
|
||||||
|
*.tmp
|
||||||
|
*.tmp_proj
|
||||||
|
*_wpftmp.csproj
|
||||||
|
*.log
|
||||||
|
*.tlog
|
||||||
|
*.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
|
||||||
|
|
||||||
|
# Visual Studio Trace Files
|
||||||
|
*.e2e
|
||||||
|
|
||||||
|
# TFS 2012 Local Workspace
|
||||||
|
$tf/
|
||||||
|
|
||||||
|
# Guidance Automation Toolkit
|
||||||
|
*.gpState
|
||||||
|
|
||||||
|
# ReSharper is a .NET coding add-in
|
||||||
|
_ReSharper*/
|
||||||
|
*.[Rr]e[Ss]harper
|
||||||
|
*.DotSettings.user
|
||||||
|
|
||||||
|
# TeamCity is a build add-in
|
||||||
|
_TeamCity*
|
||||||
|
|
||||||
|
# DotCover is a Code Coverage Tool
|
||||||
|
*.dotCover
|
||||||
|
|
||||||
|
# AxoCover is a Code Coverage Tool
|
||||||
|
.axoCover/*
|
||||||
|
!.axoCover/settings.json
|
||||||
|
|
||||||
|
# Coverlet is a free, cross platform Code Coverage Tool
|
||||||
|
coverage*.json
|
||||||
|
coverage*.xml
|
||||||
|
coverage*.info
|
||||||
|
|
||||||
|
# Visual Studio code coverage results
|
||||||
|
*.coverage
|
||||||
|
*.coveragexml
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# Note: 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
|
||||||
|
# NuGet Symbol Packages
|
||||||
|
*.snupkg
|
||||||
|
# The packages folder can be ignored because of Package Restore
|
||||||
|
**/[Pp]ackages/*
|
||||||
|
# except build/, which is used as an MSBuild target.
|
||||||
|
!**/[Pp]ackages/build/
|
||||||
|
# Uncomment if necessary however generally it will be regenerated when needed
|
||||||
|
#!**/[Pp]ackages/repositories.config
|
||||||
|
# NuGet v3's project.json files produces more ignorable 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
|
||||||
|
*.appx
|
||||||
|
*.appxbundle
|
||||||
|
*.appxupload
|
||||||
|
|
||||||
|
# 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
|
||||||
|
orleans.codegen.cs
|
||||||
|
|
||||||
|
# Including strong name files can present a security risk
|
||||||
|
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||||
|
#*.snk
|
||||||
|
|
||||||
|
# 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
|
||||||
|
ServiceFabricBackup/
|
||||||
|
*.rptproj.bak
|
||||||
|
|
||||||
|
# SQL Server files
|
||||||
|
*.mdf
|
||||||
|
*.ldf
|
||||||
|
*.ndf
|
||||||
|
|
||||||
|
# Business Intelligence projects
|
||||||
|
*.rdl.data
|
||||||
|
*.bim.layout
|
||||||
|
*.bim_*.settings
|
||||||
|
*.rptproj.rsuser
|
||||||
|
*- [Bb]ackup.rdl
|
||||||
|
*- [Bb]ackup ([0-9]).rdl
|
||||||
|
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||||
|
|
||||||
|
# Microsoft Fakes
|
||||||
|
FakesAssemblies/
|
||||||
|
|
||||||
|
# GhostDoc plugin setting file
|
||||||
|
*.GhostDoc.xml
|
||||||
|
|
||||||
|
# Node.js Tools for Visual Studio
|
||||||
|
.ntvs_analysis.dat
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Visual Studio 6 build log
|
||||||
|
*.plg
|
||||||
|
|
||||||
|
# Visual Studio 6 workspace options file
|
||||||
|
*.opt
|
||||||
|
|
||||||
|
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||||
|
*.vbw
|
||||||
|
|
||||||
|
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
|
||||||
|
*.vbp
|
||||||
|
|
||||||
|
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
|
||||||
|
*.dsw
|
||||||
|
*.dsp
|
||||||
|
|
||||||
|
# Visual Studio 6 technical files
|
||||||
|
*.ncb
|
||||||
|
*.aps
|
||||||
|
|
||||||
|
# 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/
|
||||||
|
|
||||||
|
# CodeRush personal settings
|
||||||
|
.cr/personal
|
||||||
|
|
||||||
|
# Python Tools for Visual Studio (PTVS)
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# Cake - Uncomment if you are using it
|
||||||
|
# tools/**
|
||||||
|
# !tools/packages.config
|
||||||
|
|
||||||
|
# Tabs Studio
|
||||||
|
*.tss
|
||||||
|
|
||||||
|
# Telerik's JustMock configuration file
|
||||||
|
*.jmconfig
|
||||||
|
|
||||||
|
# BizTalk build output
|
||||||
|
*.btp.cs
|
||||||
|
*.btm.cs
|
||||||
|
*.odx.cs
|
||||||
|
*.xsd.cs
|
||||||
|
|
||||||
|
# OpenCover UI analysis results
|
||||||
|
OpenCover/
|
||||||
|
|
||||||
|
# Azure Stream Analytics local run output
|
||||||
|
ASALocalRun/
|
||||||
|
|
||||||
|
# MSBuild Binary and Structured Log
|
||||||
|
*.binlog
|
||||||
|
|
||||||
|
# NVidia Nsight GPU debugger configuration file
|
||||||
|
*.nvuser
|
||||||
|
|
||||||
|
# MFractors (Xamarin productivity tool) working folder
|
||||||
|
.mfractor/
|
||||||
|
|
||||||
|
# Local History for Visual Studio
|
||||||
|
.localhistory/
|
||||||
|
|
||||||
|
# Visual Studio History (VSHistory) files
|
||||||
|
.vshistory/
|
||||||
|
|
||||||
|
# BeatPulse healthcheck temp database
|
||||||
|
healthchecksdb
|
||||||
|
|
||||||
|
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||||
|
MigrationBackup/
|
||||||
|
|
||||||
|
# Ionide (cross platform F# VS Code tools) working folder
|
||||||
|
.ionide/
|
||||||
|
|
||||||
|
# Fody - auto-generated XML schema
|
||||||
|
FodyWeavers.xsd
|
||||||
|
|
||||||
|
# VS Code files for those working on multiple tools
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
*.code-workspace
|
||||||
|
|
||||||
|
# Local History for Visual Studio Code
|
||||||
|
.history/
|
||||||
|
|
||||||
|
# Windows Installer files from build outputs
|
||||||
|
*.cab
|
||||||
|
*.msi
|
||||||
|
*.msix
|
||||||
|
*.msm
|
||||||
|
*.msp
|
||||||
|
|
||||||
|
# JetBrains Rider
|
||||||
|
*.sln.iml
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
##
|
||||||
|
## Visual studio for Mac
|
||||||
|
##
|
||||||
|
|
||||||
|
|
||||||
|
# globs
|
||||||
|
Makefile.in
|
||||||
|
*.userprefs
|
||||||
|
*.usertasks
|
||||||
|
config.make
|
||||||
|
config.status
|
||||||
|
aclocal.m4
|
||||||
|
install-sh
|
||||||
|
autom4te.cache/
|
||||||
|
*.tar.gz
|
||||||
|
tarballs/
|
||||||
|
test-results/
|
||||||
|
|
||||||
|
# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
|
||||||
|
# General
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
|
||||||
|
# Icon must end with two \r
|
||||||
|
Icon
|
||||||
|
|
||||||
|
|
||||||
|
# Thumbnails
|
||||||
|
._*
|
||||||
|
|
||||||
|
# Files that might appear in the root of a volume
|
||||||
|
.DocumentRevisions-V100
|
||||||
|
.fseventsd
|
||||||
|
.Spotlight-V100
|
||||||
|
.TemporaryItems
|
||||||
|
.Trashes
|
||||||
|
.VolumeIcon.icns
|
||||||
|
.com.apple.timemachine.donotpresent
|
||||||
|
|
||||||
|
# Directories potentially created on remote AFP share
|
||||||
|
.AppleDB
|
||||||
|
.AppleDesktop
|
||||||
|
Network Trash Folder
|
||||||
|
Temporary Items
|
||||||
|
.apdisk
|
||||||
|
|
||||||
|
# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
|
||||||
|
# Windows thumbnail cache files
|
||||||
|
Thumbs.db
|
||||||
|
ehthumbs.db
|
||||||
|
ehthumbs_vista.db
|
||||||
|
|
||||||
|
# Dump file
|
||||||
|
*.stackdump
|
||||||
|
|
||||||
|
# Folder config file
|
||||||
|
[Dd]esktop.ini
|
||||||
|
|
||||||
|
# Recycle Bin used on file shares
|
||||||
|
$RECYCLE.BIN/
|
||||||
|
|
||||||
|
# Windows Installer files
|
||||||
|
*.cab
|
||||||
|
*.msi
|
||||||
|
*.msix
|
||||||
|
*.msm
|
||||||
|
*.msp
|
||||||
|
|
||||||
|
# Windows shortcuts
|
||||||
|
*.lnk
|
||||||
|
|
||||||
|
# Vim temporary swap files
|
||||||
|
*.swp
|
||||||
16
backend/Dockerfile
Normal file
16
backend/Dockerfile
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
|
WORKDIR /App
|
||||||
|
|
||||||
|
# Copy everything
|
||||||
|
COPY . ./
|
||||||
|
# Restore as distinct layers
|
||||||
|
RUN dotnet restore
|
||||||
|
# Build and publish a release
|
||||||
|
RUN dotnet publish -o out
|
||||||
|
|
||||||
|
# Build runtime image
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0
|
||||||
|
VOLUME /data
|
||||||
|
WORKDIR /App
|
||||||
|
COPY --from=build /App/out .
|
||||||
|
ENTRYPOINT ["dotnet", "SolicitorsApi.dll"]
|
||||||
13
backend/SolicitorsApi.slnx
Normal file
13
backend/SolicitorsApi.slnx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<Solution>
|
||||||
|
<Folder Name="/libs/">
|
||||||
|
<Project Path="libs/Solicitors.CacheBuild/Solicitors.CacheBuild.csproj" />
|
||||||
|
<Project Path="libs/Solicitors.Core/Solicitors.Core.csproj" />
|
||||||
|
<Project Path="libs/Solicitors.Data/Solicitors.Data.csproj" />
|
||||||
|
<Project Path="libs/Solicitors.HtmlParsing/Solicitors.HtmlParsing.csproj" />
|
||||||
|
</Folder>
|
||||||
|
<Folder Name="/tests/">
|
||||||
|
<Project Path="tests/Solicitors.HtmlParsing.Tests/Solicitors.HtmlParsing.Tests.csproj" />
|
||||||
|
</Folder>
|
||||||
|
<Project Path="SolicitorsApi/SolicitorsApi.csproj" />
|
||||||
|
<Project Path="Tester/Tester.csproj" />
|
||||||
|
</Solution>
|
||||||
176
backend/SolicitorsApi/Program.cs
Normal file
176
backend/SolicitorsApi/Program.cs
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Solicitors.CacheBuild;
|
||||||
|
using Solicitors.Core;
|
||||||
|
using Solicitors.Core.Misc;
|
||||||
|
using Solicitors.Core.Models;
|
||||||
|
using Solicitors.Data;
|
||||||
|
using Solicitors.Data.RepositorySetup.Sqlite;
|
||||||
|
using Solicitors.HtmlParsing;
|
||||||
|
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
builder.Services.AddOpenApi();
|
||||||
|
builder.Services.AddCore();
|
||||||
|
builder.Services.AddData(new SqliteDbSetupOptions("demo"));
|
||||||
|
builder.Services.AddCacheBuild();
|
||||||
|
builder.Services.AddHtmlParsing();
|
||||||
|
builder.Services.AddHttpClient();
|
||||||
|
builder.Services.AddCors(options =>
|
||||||
|
{
|
||||||
|
options.AddDefaultPolicy(policy => policy.AllowAnyHeader().AllowAnyMethod().AllowAnyOrigin());
|
||||||
|
});
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// Configure the HTTP request pipeline.
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.MapOpenApi();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseHttpsRedirection();
|
||||||
|
app.UseCors();
|
||||||
|
|
||||||
|
app.MapGet(
|
||||||
|
"/conveyancors",
|
||||||
|
(
|
||||||
|
[FromServices] IReadOnlySolicitorService solicitorService,
|
||||||
|
CancellationToken cancellationToken,
|
||||||
|
[FromQuery] uint pageNumber = 1,
|
||||||
|
[FromQuery] uint pageSize = 20,
|
||||||
|
[FromQuery] string? nameFilter = null,
|
||||||
|
[FromQuery] decimal minRating = 0,
|
||||||
|
[FromQuery] string[]? cities = null,
|
||||||
|
[FromQuery] string ratingsProvider = "Solicitors.com",
|
||||||
|
[FromQuery] string? orderingType = null) =>
|
||||||
|
{
|
||||||
|
IFilter<Solicitor>? filter = null;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(nameFilter))
|
||||||
|
filter = new WrapperFilter<Solicitor>(filter, solicitor => solicitor.Name.Contains(nameFilter));
|
||||||
|
|
||||||
|
if (cities is not null && cities.Length > 0)
|
||||||
|
filter = new WrapperFilter<Solicitor>(filter, solicitor => cities.Any(city => solicitor.Cities.Any(solCity => solCity.Name == city)));
|
||||||
|
|
||||||
|
if (minRating > 0)
|
||||||
|
filter = new WrapperFilter<Solicitor>(
|
||||||
|
filter,
|
||||||
|
solicitor => solicitor.Ratings.Any(rating => (rating.Provider == ratingsProvider) && (rating.Value / rating.Maximum >= minRating / 5.0m)));
|
||||||
|
|
||||||
|
IComparer<Solicitor>? ordering = orderingType switch
|
||||||
|
{
|
||||||
|
"rating-asc" => new RatingComparer(ratingsProvider, true),
|
||||||
|
"rating-desc" => new RatingComparer(ratingsProvider, false),
|
||||||
|
"alphabet-asc" => new NameComparer(true),
|
||||||
|
"alphabet-desc" => new NameComparer(false),
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
return solicitorService.GetSolicitorSummariesAsync(
|
||||||
|
new Pagination(pageNumber, pageSize),
|
||||||
|
ratingsProvider,
|
||||||
|
filter,
|
||||||
|
ordering,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.MapGet(
|
||||||
|
"/cities",
|
||||||
|
(
|
||||||
|
[FromServices] IReadOnlyCitiesService citiesService,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
return citiesService.GetAllCitiesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.MapGet(
|
||||||
|
"/conveyancors/{id}",
|
||||||
|
(
|
||||||
|
[FromServices] IReadOnlySolicitorService solicitorService,
|
||||||
|
[FromRoute] Guid id,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
) => solicitorService.GetSolicitorInfoByIdAsync(id, cancellationToken)
|
||||||
|
);
|
||||||
|
|
||||||
|
app.MapGet(
|
||||||
|
"/ratingsProviders",
|
||||||
|
(
|
||||||
|
[FromServices] IRatingsProviderService service,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
return service.GetRatingsProvidersAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var solicitors = scope.ServiceProvider.GetRequiredService<ISolicitorService>();
|
||||||
|
var first = await solicitors
|
||||||
|
.GetSolicitorSummariesAsync(
|
||||||
|
new Pagination(1, 1),
|
||||||
|
"Solicitors.com",
|
||||||
|
cancellationToken: CancellationToken.None);
|
||||||
|
if (first.Data.Length == 0)
|
||||||
|
await scope.ServiceProvider
|
||||||
|
.GetRequiredService<ISolicitorImporter>()
|
||||||
|
.RunFullImport(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Run();
|
||||||
|
|
||||||
|
public class RatingComparer(string ratingsProvider, bool ascending) : IComparer<Solicitor>
|
||||||
|
{
|
||||||
|
public int Compare(Solicitor? x, Solicitor? y)
|
||||||
|
{
|
||||||
|
var xRating = x?.Ratings?.FirstOrDefault(r => r.Provider == ratingsProvider);
|
||||||
|
var yRating = y?.Ratings?.FirstOrDefault(r => r.Provider == ratingsProvider);
|
||||||
|
|
||||||
|
decimal compare;
|
||||||
|
|
||||||
|
if (xRating is null)
|
||||||
|
compare = yRating is null ? 0 : 1;
|
||||||
|
else if (yRating is null)
|
||||||
|
compare = -1;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
compare = (xRating.Value / xRating.Maximum) - (yRating.Value / yRating.Maximum);
|
||||||
|
|
||||||
|
if (!ascending)
|
||||||
|
compare *= -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compare >= 0)
|
||||||
|
return (int)Math.Ceiling(compare);
|
||||||
|
else
|
||||||
|
return (int)Math.Floor(compare);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class NameComparer(bool ascending) : IComparer<Solicitor>
|
||||||
|
{
|
||||||
|
public int Compare(Solicitor? x, Solicitor? y)
|
||||||
|
{
|
||||||
|
int compare;
|
||||||
|
if (ReferenceEquals(x, y))
|
||||||
|
compare = 0;
|
||||||
|
else if (y is null)
|
||||||
|
compare = 1;
|
||||||
|
else if (x is null)
|
||||||
|
compare = -1;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
compare = string.Compare(x.Name, y.Name, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (!ascending)
|
||||||
|
compare *= -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return compare;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
backend/SolicitorsApi/Properties/launchSettings.json
Normal file
23
backend/SolicitorsApi/Properties/launchSettings.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "http://localhost:5084",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "https://localhost:7128;http://localhost:5084",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
backend/SolicitorsApi/SolicitorsApi.csproj
Normal file
24
backend/SolicitorsApi/SolicitorsApi.csproj
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.7" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\libs\Solicitors.CacheBuild\Solicitors.CacheBuild.csproj" />
|
||||||
|
<ProjectReference Include="..\libs\Solicitors.Core\Solicitors.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\libs\Solicitors.Data\Solicitors.Data.csproj" />
|
||||||
|
<ProjectReference Include="..\libs\Solicitors.HtmlParsing\Solicitors.HtmlParsing.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
6
backend/SolicitorsApi/SolicitorsApi.http
Normal file
6
backend/SolicitorsApi/SolicitorsApi.http
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
@SolicitorsApi_HostAddress = http://localhost:5084
|
||||||
|
|
||||||
|
GET {{SolicitorsApi_HostAddress}}/weatherforecast/
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
###
|
||||||
8
backend/SolicitorsApi/appsettings.Development.json
Normal file
8
backend/SolicitorsApi/appsettings.Development.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
backend/SolicitorsApi/appsettings.json
Normal file
9
backend/SolicitorsApi/appsettings.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
10
backend/Tester/Program.cs
Normal file
10
backend/Tester/Program.cs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Solicitors.HtmlParsing;
|
||||||
|
|
||||||
|
var _html = File.ReadAllText("/var/home/fox/main.html");
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddHtmlParsing();
|
||||||
|
var parser = services.BuildServiceProvider().GetRequiredService<IHtmlParser>();
|
||||||
|
|
||||||
|
var htmlNodes = parser.ParseHtml(_html).ToArray();
|
||||||
|
Console.ReadKey();
|
||||||
24
backend/Tester/Tester.csproj
Normal file
24
backend/Tester/Tester.csproj
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Reference Include="Microsoft.Extensions.DependencyInjection.Abstractions">
|
||||||
|
<HintPath>..\..\..\..\..\..\..\..\var\home\fox\.dotnet\shared\Microsoft.AspNetCore.App\10.0.7\Microsoft.Extensions.DependencyInjection.Abstractions.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\libs\Solicitors.HtmlParsing\Solicitors.HtmlParsing.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
15
backend/libs/Solicitors.CacheBuild/DIExtensions.cs
Normal file
15
backend/libs/Solicitors.CacheBuild/DIExtensions.cs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Solicitors.CacheBuild;
|
||||||
|
|
||||||
|
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||||
|
public static class DIExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddCacheBuild(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
return services
|
||||||
|
.AddScoped<ISolicitorParser, SolicitorsDotCom.SolicitorParser>()
|
||||||
|
.AddScoped<ISolicitorImporter, SolicitorImporter>();
|
||||||
|
}
|
||||||
|
}
|
||||||
6
backend/libs/Solicitors.CacheBuild/ISolicitorImporter.cs
Normal file
6
backend/libs/Solicitors.CacheBuild/ISolicitorImporter.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace Solicitors.CacheBuild;
|
||||||
|
|
||||||
|
public interface ISolicitorImporter
|
||||||
|
{
|
||||||
|
Task RunFullImport(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
9
backend/libs/Solicitors.CacheBuild/ISolicitorParser.cs
Normal file
9
backend/libs/Solicitors.CacheBuild/ISolicitorParser.cs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
using Solicitors.Core.Models.Imports;
|
||||||
|
|
||||||
|
namespace Solicitors.CacheBuild;
|
||||||
|
|
||||||
|
internal interface ISolicitorParser
|
||||||
|
{
|
||||||
|
Task<SolicitorData[]> GetSolicitorsAsync(
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
29
backend/libs/Solicitors.CacheBuild/SolicitorImporter.cs
Normal file
29
backend/libs/Solicitors.CacheBuild/SolicitorImporter.cs
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
using Solicitors.Core.Data;
|
||||||
|
using Solicitors.Core.Models.Imports;
|
||||||
|
|
||||||
|
namespace Solicitors.CacheBuild;
|
||||||
|
|
||||||
|
internal class SolicitorImporter : ISolicitorImporter
|
||||||
|
{
|
||||||
|
private readonly ISolicitorParser _parser;
|
||||||
|
private readonly ISolicitorRepository _repository;
|
||||||
|
|
||||||
|
public SolicitorImporter(
|
||||||
|
ISolicitorParser parser,
|
||||||
|
ISolicitorRepository repository)
|
||||||
|
{
|
||||||
|
_parser = parser;
|
||||||
|
_repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RunFullImport(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
foreach (var solicitor in await _parser.GetSolicitorsAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
await RunImport(solicitor, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task RunImport(SolicitorData solicitor, CancellationToken cancellationToken)
|
||||||
|
=> _repository.AddOrUpdateSolicitorAsync(solicitor, cancellationToken);
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Solicitors.Core\Solicitors.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\Solicitors.HtmlParsing\Solicitors.HtmlParsing.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
namespace Solicitors.CacheBuild.SolicitorsDotCom;
|
||||||
|
|
||||||
|
internal static class Consts
|
||||||
|
{
|
||||||
|
public const string BaseUrl = "https://www.solicitors.com";
|
||||||
|
public static readonly string[] Locations =
|
||||||
|
[
|
||||||
|
"london", "birmingham", "leeds", "manchester", "sheffield", "bradford", "liverpool", "bristol"
|
||||||
|
];
|
||||||
|
|
||||||
|
public const decimal MaxRating = 5.0m;
|
||||||
|
public const string Name = "Solicitors.com";
|
||||||
|
public const string IconPath = "/images/logo.svg";
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
namespace Solicitors.CacheBuild.SolicitorsDotCom;
|
||||||
|
|
||||||
|
internal class SolicitorBuilder
|
||||||
|
{
|
||||||
|
private readonly HashSet<string> _baseLocations = [];
|
||||||
|
|
||||||
|
public SolicitorBuilder(
|
||||||
|
string name,
|
||||||
|
string path,
|
||||||
|
string? shortDesc,
|
||||||
|
string baseLocation)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
ShortDescription = shortDesc;
|
||||||
|
_baseLocations.Add(baseLocation);
|
||||||
|
|
||||||
|
Path = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddBaseLocation(string baseLocation)
|
||||||
|
{
|
||||||
|
_baseLocations.Add(baseLocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Path { get; }
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
|
public string? ShortDescription { get; }
|
||||||
|
|
||||||
|
public string[] BaseLocations => _baseLocations.ToArray();
|
||||||
|
}
|
||||||
@ -0,0 +1,316 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using Solicitors.Core.Misc;
|
||||||
|
using Solicitors.Core.Models.Imports;
|
||||||
|
using Solicitors.HtmlParsing;
|
||||||
|
using Solicitors.HtmlParsing.Models;
|
||||||
|
|
||||||
|
namespace Solicitors.CacheBuild.SolicitorsDotCom;
|
||||||
|
|
||||||
|
internal class SolicitorParser : ISolicitorParser
|
||||||
|
{
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
private readonly IHtmlParser _htmlParser;
|
||||||
|
|
||||||
|
public SolicitorParser(
|
||||||
|
HttpClient client,
|
||||||
|
IHtmlParser htmlParser)
|
||||||
|
{
|
||||||
|
_client = client;
|
||||||
|
_client.BaseAddress = new Uri(Consts.BaseUrl);
|
||||||
|
|
||||||
|
_htmlParser = htmlParser;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SolicitorData[]> GetSolicitorsAsync(
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var builders = new Dictionary<string, SolicitorBuilder>();
|
||||||
|
|
||||||
|
foreach (var location in Consts.Locations)
|
||||||
|
{
|
||||||
|
var httpRequest = new HttpRequestMessage(HttpMethod.Get, $"/conveyancing+{location}.html");
|
||||||
|
httpRequest.Headers.Add("User-Agent", "Solicitors API Cache");
|
||||||
|
var httpResponse = await _client.SendAsync(httpRequest, cancellationToken);
|
||||||
|
var html = await httpResponse.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
var nodes = _htmlParser.ParseHtml(html);
|
||||||
|
IHtmlNode? mainNode = null;
|
||||||
|
foreach (var node in nodes)
|
||||||
|
if (node.TryGetByTagName("main", out mainNode))
|
||||||
|
break;
|
||||||
|
|
||||||
|
if (TryGetResultSection(mainNode, out var results))
|
||||||
|
ParseResultsSection(results, location, builders);
|
||||||
|
}
|
||||||
|
|
||||||
|
var batches = builders.Values
|
||||||
|
.Select(builder => GetFullSolicitorDataAsync(builder, cancellationToken))
|
||||||
|
.Batch(5);
|
||||||
|
|
||||||
|
var solicitors = new List<SolicitorData?>();
|
||||||
|
foreach (var batch in batches)
|
||||||
|
solicitors.AddRange(await Task.WhenAll(batch));
|
||||||
|
|
||||||
|
return solicitors
|
||||||
|
.Where(x => x is not null)
|
||||||
|
.Cast<SolicitorData>()
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryGetResultSection(
|
||||||
|
IHtmlNode? mainNode,
|
||||||
|
[NotNullWhen(true)] out IEnumerable<IHtmlNode>? results)
|
||||||
|
{
|
||||||
|
results = null;
|
||||||
|
|
||||||
|
// going one level at a time helps a little with performance here, at the cost of messier code
|
||||||
|
return mainNode is not null
|
||||||
|
&& mainNode.TryGetByTagNameAndClass("div", "content-holder", out var contentHolderNode)
|
||||||
|
&& contentHolderNode.TryGetByTagNameAndClass("div", "container", out var containerNode)
|
||||||
|
&& containerNode.TryGetByTagNameAndClass("div", "content", out var contentNode)
|
||||||
|
&& contentNode.TryGetByTagNameAndClass("div", "result-section", out var resultSectionNode)
|
||||||
|
&& resultSectionNode.TryGetChildren(out results);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ParseResultsSection(
|
||||||
|
IEnumerable<IHtmlNode> results,
|
||||||
|
string location,
|
||||||
|
Dictionary<string, SolicitorBuilder> builders)
|
||||||
|
{
|
||||||
|
foreach (var result in results)
|
||||||
|
{
|
||||||
|
if (result.TryGetByClass("result-item", out var resultItem, true)
|
||||||
|
&& TryParseToNewBuilder(resultItem, location, out var builder))
|
||||||
|
{
|
||||||
|
if (!builders.TryAdd(builder.Path, builder))
|
||||||
|
builders[builder.Path].AddBaseLocation(location);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryParseToNewBuilder(
|
||||||
|
IHtmlNode resultItemNode,
|
||||||
|
string baseLocation,
|
||||||
|
[NotNullWhen(true)] out SolicitorBuilder? builder)
|
||||||
|
{
|
||||||
|
builder = null;
|
||||||
|
if (!resultItemNode.TryGetByTagNameAndClass("a", "link-map", out var linkMapNode)
|
||||||
|
|| !linkMapNode.TryGetAttributeValue("href", out var uniqueUrl)
|
||||||
|
|| !resultItemNode.TryGetByTagNameAndClass("span", "h2", out var titleNode)
|
||||||
|
|| !titleNode.TryGetChildren(out var titleChildren))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
string? name = null;
|
||||||
|
foreach (var child in titleChildren)
|
||||||
|
if (child.TryGetText(out name))
|
||||||
|
break;
|
||||||
|
|
||||||
|
if (name is null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
string? shortDesc = null;
|
||||||
|
if (resultItemNode.TryGetByTagName("p", out var pNode)
|
||||||
|
&& pNode.TryGetChildren(out var pChildren))
|
||||||
|
foreach (var child in pChildren)
|
||||||
|
if (child.TryGetText(out shortDesc))
|
||||||
|
break;
|
||||||
|
|
||||||
|
builder = new SolicitorBuilder(name, uniqueUrl, shortDesc, baseLocation);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SolicitorData?> GetFullSolicitorDataAsync(
|
||||||
|
SolicitorBuilder builder,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
|
||||||
|
var httpRequest = new HttpRequestMessage(HttpMethod.Get, builder.Path);
|
||||||
|
httpRequest.Headers.Add("User-Agent", "Solicitors API Cache");
|
||||||
|
var httpResponse = await _client.SendAsync(httpRequest, cancellationToken);
|
||||||
|
var html = await httpResponse.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
var nodes = _htmlParser.ParseHtml(html);
|
||||||
|
IHtmlNode? mainNode = null;
|
||||||
|
foreach (var node in nodes)
|
||||||
|
if (node.TryGetByTagName("main", out mainNode))
|
||||||
|
break;
|
||||||
|
|
||||||
|
if (mainNode is null || !mainNode.TryGetByTagNameAndClass("div", "content-block", out var contentBlockNode))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
Location[] offices = [];
|
||||||
|
if (contentBlockNode.TryGetByTagNameAndClass("div", "office-item", out var officeItemNode)
|
||||||
|
&& officeItemNode.TryGetChildren(out var officeItems))
|
||||||
|
{
|
||||||
|
offices = ParseOffices(officeItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
Rating[] ratings = [];
|
||||||
|
string? phone = null;
|
||||||
|
string? email = null;
|
||||||
|
string? website = null;
|
||||||
|
if (mainNode.TryGetByTagNameAndClass("div", "side-holder", out var sidebarNode)
|
||||||
|
&& sidebarNode.TryGetChildren(out var sidebarChildren))
|
||||||
|
{
|
||||||
|
ParseSidebar(sidebarChildren, out phone, out email, out website, out ratings);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SolicitorData()
|
||||||
|
{
|
||||||
|
Name = builder.Name,
|
||||||
|
UrlPath = builder.Path,
|
||||||
|
ShortDescription = builder.ShortDescription,
|
||||||
|
Phone = phone,
|
||||||
|
Email = email,
|
||||||
|
Website = website,
|
||||||
|
Ratings = ratings,
|
||||||
|
Offices = offices,
|
||||||
|
Cities = builder.BaseLocations
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Location[] ParseOffices(IEnumerable<IHtmlNode> officeNodes)
|
||||||
|
=> officeNodes
|
||||||
|
.Select(ParseOffice)
|
||||||
|
.Where(x => x is not null)
|
||||||
|
.Cast<Location>()
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
Location? ParseOffice(IHtmlNode officeNode)
|
||||||
|
{
|
||||||
|
if (!officeNode.TryGetChildren(out var officeChildren)
|
||||||
|
|| !officeNode.TryGetByTagName("address", out var addressNode)
|
||||||
|
|| !addressNode.TryGetChildren(out var addressLines))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
Location? location = null;
|
||||||
|
string? address = "";
|
||||||
|
foreach (var line in addressLines)
|
||||||
|
{
|
||||||
|
if (line.TryGetText(out var lineText))
|
||||||
|
address += lineText;
|
||||||
|
else if (line.TryGetByTagName("br", out _, true))
|
||||||
|
address += "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(address))
|
||||||
|
address = null;
|
||||||
|
|
||||||
|
string? phone = null;
|
||||||
|
foreach (var child in officeChildren)
|
||||||
|
{
|
||||||
|
if (child.TryGetByTagName("a", out var anchor)
|
||||||
|
&& anchor.TryGetAttributeValue("href", out var telString)
|
||||||
|
&& telString.StartsWith("\"tel:")
|
||||||
|
&& telString.Length > 5)
|
||||||
|
{
|
||||||
|
phone = telString.Substring(5, telString.Length - 6);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (address is not null && phone is not null)
|
||||||
|
location = new Location(address, phone, []);
|
||||||
|
|
||||||
|
return location;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ParseSidebar(
|
||||||
|
IEnumerable<IHtmlNode> sidebarChildren,
|
||||||
|
out string? phone,
|
||||||
|
out string? email,
|
||||||
|
out string? website,
|
||||||
|
out Rating[] ratings)
|
||||||
|
{
|
||||||
|
List<Rating> ratingsList = [];
|
||||||
|
phone = null;
|
||||||
|
website = null;
|
||||||
|
email = null;
|
||||||
|
|
||||||
|
foreach (var node in sidebarChildren)
|
||||||
|
{
|
||||||
|
var isRevCount = node.TryGetByTagNameAndClass("div", "rev-count", out _, true);
|
||||||
|
var isLinksHolder = node.TryGetByTagNameAndClass("div", "links-holder", out _, true);
|
||||||
|
if ((!isRevCount && !isLinksHolder)
|
||||||
|
|| !node.TryGetChildren(out var children))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (isLinksHolder)
|
||||||
|
{
|
||||||
|
if (node.TryGetByTagNameAndClass("a", "website", out var websiteNode)
|
||||||
|
&& websiteNode.TryGetAttributeValue("href", out website))
|
||||||
|
website = website.Trim('"');
|
||||||
|
|
||||||
|
if (node.TryGetByTagNameAndClass("a", "phone", out var phoneNode)
|
||||||
|
&& phoneNode.TryGetAttributeValue("href", out var phoneStr)
|
||||||
|
&& phoneStr.StartsWith("\"tel:")
|
||||||
|
&& phoneStr.Length > 5)
|
||||||
|
{
|
||||||
|
phone = phoneStr.Substring(5, phoneStr.Length - 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var childrenArray = children as IHtmlNode[] ?? children.ToArray();
|
||||||
|
foreach (var child in childrenArray.Where(x => x.TryGetByClass("rev-box", out _, true)))
|
||||||
|
{
|
||||||
|
if (child.TryGetAttributeValue("title", out var ratingString)
|
||||||
|
&& TryParseRatingString(ratingString, out decimal rating, out decimal maxRating)
|
||||||
|
&& child.TryGetByTagNameAndClass("img", "rev-logo", out var imgNode)
|
||||||
|
&& imgNode.TryGetAttributeValue("src", out var imgSrc)
|
||||||
|
&& TryParseRatingSrc(imgSrc, out var ratingProvider))
|
||||||
|
{
|
||||||
|
ratingsList.Add(new Rating(rating, maxRating, ratingProvider, imgSrc));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ratingsList.Count == 0)
|
||||||
|
{
|
||||||
|
foreach (var child in childrenArray.Where(x => !x.HasAttribute("class")))
|
||||||
|
{
|
||||||
|
if (child.TryGetChildren(out var grandChildren))
|
||||||
|
foreach (var grandChild in grandChildren)
|
||||||
|
if (grandChild.TryGetText(out var textContent)
|
||||||
|
&& TryParseOwnRatingString(textContent, out var rating))
|
||||||
|
ratingsList.Add(new Rating(rating, Consts.MaxRating, Consts.Name, Consts.IconPath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ratings = ratingsList.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool TryParseRatingString(string ratingString, out decimal rating, out decimal maxRating)
|
||||||
|
{
|
||||||
|
rating = 0;
|
||||||
|
maxRating = 0;
|
||||||
|
var parts = ratingString.Trim('"').Split(" / ");
|
||||||
|
if (parts.Length != 2)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return decimal.TryParse(parts[0], out rating) && decimal.TryParse(parts[1], out maxRating);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TryParseRatingSrc(string imgSrc, out string provider)
|
||||||
|
{
|
||||||
|
provider = "";
|
||||||
|
imgSrc = imgSrc.Trim('"');
|
||||||
|
var prefix = "/images/logo-";
|
||||||
|
if (!imgSrc.StartsWith(prefix))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var providerAndExtension = imgSrc.Substring(prefix.Length);
|
||||||
|
provider = providerAndExtension.Split('.').First();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TryParseOwnRatingString(string ratingString, out decimal rating)
|
||||||
|
{
|
||||||
|
rating = 0;
|
||||||
|
var prefix = "Average review score : ";
|
||||||
|
if (!ratingString.StartsWith(prefix))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var ratingPart = ratingString.Substring(prefix.Length);
|
||||||
|
return decimal.TryParse(ratingPart, out rating);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
backend/libs/Solicitors.Core/DIExtensions.cs
Normal file
17
backend/libs/Solicitors.Core/DIExtensions.cs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Solicitors.Core;
|
||||||
|
|
||||||
|
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||||
|
public static class DIExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddCore(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
return services
|
||||||
|
.AddScoped<ISolicitorService, SolicitorService>()
|
||||||
|
.AddScoped<IReadOnlySolicitorService>(sp => sp.GetRequiredService<ISolicitorService>())
|
||||||
|
.AddScoped<IReadOnlyCitiesService, ReadOnlyCitiesService>()
|
||||||
|
.AddScoped<IRatingsProviderService, RatingsProviderService>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
namespace Solicitors.Core.Data;
|
||||||
|
|
||||||
|
public interface IRatingsProviderRepository
|
||||||
|
{
|
||||||
|
IAsyncEnumerable<string> GetAllRatingsProvidersAsync();
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
using Solicitors.Core.Models;
|
||||||
|
|
||||||
|
namespace Solicitors.Core.Data;
|
||||||
|
|
||||||
|
public interface IReadOnlySolicitorRepository
|
||||||
|
{
|
||||||
|
IAsyncEnumerable<Solicitor> GetAllSolicitorsAsync();
|
||||||
|
Task<Solicitor?> GetSolicitorByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IReadOnlyCitiesRepository
|
||||||
|
{
|
||||||
|
IAsyncEnumerable<string> GetCitiesAsync(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
using Solicitors.Core.Models.Imports;
|
||||||
|
|
||||||
|
namespace Solicitors.Core.Data;
|
||||||
|
|
||||||
|
public interface ISolicitorRepository : IReadOnlySolicitorRepository
|
||||||
|
{
|
||||||
|
Task AddOrUpdateSolicitorAsync(SolicitorData solicitor, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
6
backend/libs/Solicitors.Core/IRatingsProviderService.cs
Normal file
6
backend/libs/Solicitors.Core/IRatingsProviderService.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace Solicitors.Core;
|
||||||
|
|
||||||
|
public interface IRatingsProviderService
|
||||||
|
{
|
||||||
|
Task<string[]> GetRatingsProvidersAsync(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
6
backend/libs/Solicitors.Core/IReadOnlyCitiesService.cs
Normal file
6
backend/libs/Solicitors.Core/IReadOnlyCitiesService.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace Solicitors.Core;
|
||||||
|
|
||||||
|
public interface IReadOnlyCitiesService
|
||||||
|
{
|
||||||
|
Task<string[]> GetAllCitiesAsync(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
16
backend/libs/Solicitors.Core/IReadOnlySolicitorService.cs
Normal file
16
backend/libs/Solicitors.Core/IReadOnlySolicitorService.cs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
using Solicitors.Core.Misc;
|
||||||
|
using Solicitors.Core.Models;
|
||||||
|
|
||||||
|
namespace Solicitors.Core;
|
||||||
|
|
||||||
|
public interface IReadOnlySolicitorService
|
||||||
|
{
|
||||||
|
Task<PaginationResponse<SolicitorSummary>> GetSolicitorSummariesAsync(
|
||||||
|
Pagination pagination,
|
||||||
|
string ratingsProvider,
|
||||||
|
IFilter<Solicitor>? filter = null,
|
||||||
|
IComparer<Solicitor>? ordering = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<SolicitorInfo?> GetSolicitorInfoByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
6
backend/libs/Solicitors.Core/ISolicitorService.cs
Normal file
6
backend/libs/Solicitors.Core/ISolicitorService.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace Solicitors.Core;
|
||||||
|
|
||||||
|
public interface ISolicitorService : IReadOnlySolicitorService
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
using Solicitors.Core.Models;
|
||||||
|
|
||||||
|
namespace Solicitors.Core.Misc;
|
||||||
|
|
||||||
|
internal class DefaultSolicitorDataOrdering : IComparer<Solicitor>
|
||||||
|
{
|
||||||
|
public static IComparer<Solicitor> Instance { get; } = new DefaultSolicitorDataOrdering();
|
||||||
|
|
||||||
|
private DefaultSolicitorDataOrdering()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Compare(Solicitor? x, Solicitor? y)
|
||||||
|
{
|
||||||
|
if (x is null)
|
||||||
|
return y is null ? 0 : int.MaxValue;
|
||||||
|
|
||||||
|
else if (y is null)
|
||||||
|
return int.MinValue;
|
||||||
|
|
||||||
|
return Comparer<Guid>.Default.Compare(x.SolicitorId, y.SolicitorId);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
backend/libs/Solicitors.Core/Misc/EnumerableExtensions.cs
Normal file
21
backend/libs/Solicitors.Core/Misc/EnumerableExtensions.cs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
namespace Solicitors.Core.Misc;
|
||||||
|
|
||||||
|
public static class EnumerableExtensions
|
||||||
|
{
|
||||||
|
public static IEnumerable<T[]> Batch<T>(this IEnumerable<T> source, int batchSize)
|
||||||
|
{
|
||||||
|
var batch = new List<T>(batchSize);
|
||||||
|
foreach (var item in source)
|
||||||
|
{
|
||||||
|
batch.Add(item);
|
||||||
|
if (batch.Count == batch.Capacity)
|
||||||
|
{
|
||||||
|
yield return batch.ToArray();
|
||||||
|
batch.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (batch.Count > 0)
|
||||||
|
yield return batch.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
29
backend/libs/Solicitors.Core/Misc/IFilter.cs
Normal file
29
backend/libs/Solicitors.Core/Misc/IFilter.cs
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
namespace Solicitors.Core.Misc;
|
||||||
|
|
||||||
|
public interface IFilter<T>
|
||||||
|
{
|
||||||
|
bool Filter(T item);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WrapperFilter<T> : IFilter<T>
|
||||||
|
{
|
||||||
|
private readonly Func<T, bool> _filter;
|
||||||
|
private readonly IFilter<T>? _inner;
|
||||||
|
|
||||||
|
public WrapperFilter(
|
||||||
|
IFilter<T>? inner,
|
||||||
|
Func<T, bool> filter)
|
||||||
|
{
|
||||||
|
_inner = inner;
|
||||||
|
_filter = filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public bool Filter(T item)
|
||||||
|
{
|
||||||
|
if (_inner is not null)
|
||||||
|
return _inner.Filter(item) && _filter(item);
|
||||||
|
|
||||||
|
return _filter(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
backend/libs/Solicitors.Core/Misc/Pagination.cs
Normal file
7
backend/libs/Solicitors.Core/Misc/Pagination.cs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
namespace Solicitors.Core.Misc;
|
||||||
|
|
||||||
|
public readonly struct Pagination(uint currentPage, uint pageSize)
|
||||||
|
{
|
||||||
|
public uint CurrentPage { get; } = currentPage;
|
||||||
|
public uint PageSize { get; } = pageSize;
|
||||||
|
}
|
||||||
9
backend/libs/Solicitors.Core/Models/City.cs
Normal file
9
backend/libs/Solicitors.Core/Models/City.cs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
namespace Solicitors.Core.Models;
|
||||||
|
|
||||||
|
public record City
|
||||||
|
{
|
||||||
|
public Guid CityId { get; set; }
|
||||||
|
public required string Name { get; set; }
|
||||||
|
|
||||||
|
public List<Solicitor> Solicitors { get; } = new();
|
||||||
|
}
|
||||||
8
backend/libs/Solicitors.Core/Models/Imports/Location.cs
Normal file
8
backend/libs/Solicitors.Core/Models/Imports/Location.cs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
namespace Solicitors.Core.Models.Imports;
|
||||||
|
|
||||||
|
public record Location(string address, string phone, Rating[] locationRatings)
|
||||||
|
{
|
||||||
|
public string Address { get; } = address;
|
||||||
|
public string Phone { get; } = phone;
|
||||||
|
public Rating[] LocationRatings { get; } = locationRatings;
|
||||||
|
}
|
||||||
9
backend/libs/Solicitors.Core/Models/Imports/Rating.cs
Normal file
9
backend/libs/Solicitors.Core/Models/Imports/Rating.cs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
namespace Solicitors.Core.Models.Imports;
|
||||||
|
|
||||||
|
public record Rating(decimal value, decimal maxValue, string provider, string imgSrc)
|
||||||
|
{
|
||||||
|
public decimal Value { get; } = value;
|
||||||
|
public decimal MaxValue { get; } = maxValue;
|
||||||
|
public string RatingProvider { get; } = provider;
|
||||||
|
public string RatingProviderImgSrc { get; } = imgSrc;
|
||||||
|
}
|
||||||
16
backend/libs/Solicitors.Core/Models/Imports/SolicitorData.cs
Normal file
16
backend/libs/Solicitors.Core/Models/Imports/SolicitorData.cs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
namespace Solicitors.Core.Models.Imports;
|
||||||
|
|
||||||
|
public class SolicitorData
|
||||||
|
{
|
||||||
|
public required string Name { get; init; }
|
||||||
|
public required string UrlPath { get; init; }
|
||||||
|
|
||||||
|
public string? ShortDescription { get; init; }
|
||||||
|
public string? Phone { get; init; }
|
||||||
|
public string? Email { get; init; }
|
||||||
|
public string? Website { get; init; }
|
||||||
|
|
||||||
|
public string[] Cities { get; init; } = [];
|
||||||
|
public Rating[] Ratings { get; init; } = [];
|
||||||
|
public Location[] Offices { get; init; } = [];
|
||||||
|
}
|
||||||
18
backend/libs/Solicitors.Core/Models/LocationInfo.cs
Normal file
18
backend/libs/Solicitors.Core/Models/LocationInfo.cs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
namespace Solicitors.Core.Models;
|
||||||
|
|
||||||
|
public record LocationInfo
|
||||||
|
{
|
||||||
|
public LocationInfo(SolicitorLocation location)
|
||||||
|
{
|
||||||
|
Address = location.Address;
|
||||||
|
Phone = location.Phone;
|
||||||
|
Ratings = location.LocationRatings
|
||||||
|
.Select(rating => new RatingInfo(rating))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Address { get; }
|
||||||
|
public string Phone { get; }
|
||||||
|
|
||||||
|
public RatingInfo[] Ratings { get; }
|
||||||
|
}
|
||||||
15
backend/libs/Solicitors.Core/Models/RatingInfo.cs
Normal file
15
backend/libs/Solicitors.Core/Models/RatingInfo.cs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
namespace Solicitors.Core.Models;
|
||||||
|
|
||||||
|
public record RatingInfo
|
||||||
|
{
|
||||||
|
public RatingInfo(IRating rating)
|
||||||
|
{
|
||||||
|
Value = rating.Value;
|
||||||
|
Maximum = rating.Maximum;
|
||||||
|
Provider = rating.Provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public decimal Value { get; }
|
||||||
|
public decimal Maximum { get; }
|
||||||
|
public string Provider { get; }
|
||||||
|
}
|
||||||
18
backend/libs/Solicitors.Core/Models/Solicitor.cs
Normal file
18
backend/libs/Solicitors.Core/Models/Solicitor.cs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
namespace Solicitors.Core.Models;
|
||||||
|
|
||||||
|
public record Solicitor
|
||||||
|
{
|
||||||
|
public Guid SolicitorId { get; set; }
|
||||||
|
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public required string RelativeUrl { get; set; }
|
||||||
|
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Website { get; set; }
|
||||||
|
public string? ShortDescription { get; set; }
|
||||||
|
|
||||||
|
public List<City> Cities { get; } = new();
|
||||||
|
public List<SolicitorRating> Ratings { get; } = new();
|
||||||
|
public List<SolicitorLocation> Locations { get; } = new();
|
||||||
|
}
|
||||||
38
backend/libs/Solicitors.Core/Models/SolicitorInfo.cs
Normal file
38
backend/libs/Solicitors.Core/Models/SolicitorInfo.cs
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
namespace Solicitors.Core.Models;
|
||||||
|
|
||||||
|
public record SolicitorInfo
|
||||||
|
{
|
||||||
|
internal SolicitorInfo(Solicitor solicitor)
|
||||||
|
{
|
||||||
|
Id = solicitor.SolicitorId;
|
||||||
|
Name = solicitor.Name;
|
||||||
|
ShortDescription = solicitor.ShortDescription;
|
||||||
|
Phone = solicitor.Phone;
|
||||||
|
Email = solicitor.Email;
|
||||||
|
Website = solicitor.Website;
|
||||||
|
|
||||||
|
Cities = solicitor.Cities
|
||||||
|
.Select(city => city.Name)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
Ratings = solicitor.Ratings
|
||||||
|
.Select(rating => new RatingInfo(rating))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
Locations = solicitor.Locations
|
||||||
|
.Select(location => new LocationInfo(location))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Name { get; set; }
|
||||||
|
public string ShortDescription { get; set; }
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Website { get; set; }
|
||||||
|
|
||||||
|
public string[] Cities { get; set; }
|
||||||
|
public RatingInfo[] Ratings { get; set; }
|
||||||
|
public LocationInfo[] Locations { get; set; }
|
||||||
|
}
|
||||||
21
backend/libs/Solicitors.Core/Models/SolicitorLocation.cs
Normal file
21
backend/libs/Solicitors.Core/Models/SolicitorLocation.cs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
namespace Solicitors.Core.Models;
|
||||||
|
|
||||||
|
public record SolicitorLocation
|
||||||
|
{
|
||||||
|
public Guid SolicitorLocationId { get; set; }
|
||||||
|
|
||||||
|
public required string Address { get; set; }
|
||||||
|
public required string Phone { get; set; }
|
||||||
|
|
||||||
|
public List<SolicitorLocationRating> LocationRatings { get; } = new();
|
||||||
|
|
||||||
|
public Guid SolicitorId { get; set; }
|
||||||
|
public required Solicitor Solicitor { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IRating
|
||||||
|
{
|
||||||
|
decimal Value { get; }
|
||||||
|
decimal Maximum { get; }
|
||||||
|
string Provider { get; }
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
namespace Solicitors.Core.Models;
|
||||||
|
|
||||||
|
public record SolicitorLocationRating : IRating
|
||||||
|
{
|
||||||
|
public Guid SolicitorLocationRatingId { get; set; }
|
||||||
|
|
||||||
|
public required decimal Value { get; set; }
|
||||||
|
public required decimal Maximum { get; set; }
|
||||||
|
public required string Provider { get; set; }
|
||||||
|
|
||||||
|
public Guid SolicitorLocationId { get; set; }
|
||||||
|
public required SolicitorLocation SolicitorLocation { get; set; }
|
||||||
|
}
|
||||||
13
backend/libs/Solicitors.Core/Models/SolicitorRating.cs
Normal file
13
backend/libs/Solicitors.Core/Models/SolicitorRating.cs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
namespace Solicitors.Core.Models;
|
||||||
|
|
||||||
|
public record SolicitorRating : IRating
|
||||||
|
{
|
||||||
|
public Guid SolicitorRatingId { get; set; }
|
||||||
|
|
||||||
|
public required decimal Value { get; set; }
|
||||||
|
public required decimal Maximum { get; set; }
|
||||||
|
public required string Provider { get; set; }
|
||||||
|
|
||||||
|
public Guid SolicitorId { get; set; }
|
||||||
|
public required Solicitor Solicitor { get; set; }
|
||||||
|
}
|
||||||
19
backend/libs/Solicitors.Core/Models/SolicitorSummary.cs
Normal file
19
backend/libs/Solicitors.Core/Models/SolicitorSummary.cs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
namespace Solicitors.Core.Models;
|
||||||
|
|
||||||
|
public record SolicitorSummary
|
||||||
|
{
|
||||||
|
internal SolicitorSummary(Solicitor solicitor, string ratingsProvider)
|
||||||
|
{
|
||||||
|
Id = solicitor.SolicitorId;
|
||||||
|
Name = solicitor.Name;
|
||||||
|
ShortDescription = solicitor.ShortDescription;
|
||||||
|
var rating = solicitor.Ratings.FirstOrDefault(x => x.Provider == ratingsProvider);
|
||||||
|
if (rating is not null)
|
||||||
|
Rating = new RatingInfo(rating);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid Id { get; }
|
||||||
|
public string Name { get; }
|
||||||
|
public string? ShortDescription { get; }
|
||||||
|
public RatingInfo? Rating { get; }
|
||||||
|
}
|
||||||
7
backend/libs/Solicitors.Core/PaginationResponse.cs
Normal file
7
backend/libs/Solicitors.Core/PaginationResponse.cs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
namespace Solicitors.Core;
|
||||||
|
|
||||||
|
public record PaginationResponse<T>(int Total, T[] Data)
|
||||||
|
{
|
||||||
|
public T[] Data { get; set; } = Data;
|
||||||
|
public int Total { get; set; } = Total;
|
||||||
|
}
|
||||||
18
backend/libs/Solicitors.Core/RatingsProviderService.cs
Normal file
18
backend/libs/Solicitors.Core/RatingsProviderService.cs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
using Solicitors.Core.Data;
|
||||||
|
|
||||||
|
namespace Solicitors.Core;
|
||||||
|
|
||||||
|
internal class RatingsProviderService : IRatingsProviderService
|
||||||
|
{
|
||||||
|
private readonly IRatingsProviderRepository _repo;
|
||||||
|
|
||||||
|
public RatingsProviderService(IRatingsProviderRepository repo)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string[]> GetRatingsProvidersAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _repo.GetAllRatingsProvidersAsync().ToArrayAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
backend/libs/Solicitors.Core/ReadOnlyCitiesService.cs
Normal file
19
backend/libs/Solicitors.Core/ReadOnlyCitiesService.cs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
using Solicitors.Core.Data;
|
||||||
|
|
||||||
|
namespace Solicitors.Core;
|
||||||
|
|
||||||
|
internal class ReadOnlyCitiesService : IReadOnlyCitiesService
|
||||||
|
{
|
||||||
|
private readonly IReadOnlyCitiesRepository _repo;
|
||||||
|
|
||||||
|
public ReadOnlyCitiesService(IReadOnlyCitiesRepository repo)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string[]> GetAllCitiesAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var cities = _repo.GetCitiesAsync(cancellationToken);
|
||||||
|
return await cities.ToArrayAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
backend/libs/Solicitors.Core/SolicitorService.cs
Normal file
50
backend/libs/Solicitors.Core/SolicitorService.cs
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
using Solicitors.Core.Data;
|
||||||
|
using Solicitors.Core.Misc;
|
||||||
|
using Solicitors.Core.Models;
|
||||||
|
|
||||||
|
namespace Solicitors.Core;
|
||||||
|
|
||||||
|
internal class SolicitorService : ISolicitorService
|
||||||
|
{
|
||||||
|
private readonly IReadOnlySolicitorRepository _repo;
|
||||||
|
|
||||||
|
public SolicitorService(IReadOnlySolicitorRepository repo)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PaginationResponse<SolicitorSummary>> GetSolicitorSummariesAsync(
|
||||||
|
Pagination pagination,
|
||||||
|
string ratingsProvider,
|
||||||
|
IFilter<Solicitor>? filter = null,
|
||||||
|
IComparer<Solicitor>? ordering = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var allSolicitors = _repo.GetAllSolicitorsAsync();
|
||||||
|
if (filter is not null)
|
||||||
|
allSolicitors = allSolicitors.Where(filter.Filter);
|
||||||
|
|
||||||
|
var matchingSolicitors = await allSolicitors.ToArrayAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (ordering is null)
|
||||||
|
ordering = DefaultSolicitorDataOrdering.Instance;
|
||||||
|
|
||||||
|
var results = matchingSolicitors
|
||||||
|
.OrderBy(x => x, ordering)
|
||||||
|
.Skip((int)((pagination.CurrentPage - 1) * pagination.PageSize))
|
||||||
|
.Take((int)pagination.PageSize)
|
||||||
|
.Select(x => new SolicitorSummary(x, ratingsProvider))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return new PaginationResponse<SolicitorSummary>(matchingSolicitors.Length, results);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SolicitorInfo?> GetSolicitorInfoByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var data = await _repo.GetSolicitorByIdAsync(id, cancellationToken);
|
||||||
|
if (data is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return new SolicitorInfo(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
backend/libs/Solicitors.Core/Solicitors.Core.csproj
Normal file
13
backend/libs/Solicitors.Core/Solicitors.Core.csproj
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
36
backend/libs/Solicitors.Data/DIExtensions.cs
Normal file
36
backend/libs/Solicitors.Data/DIExtensions.cs
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Solicitors.Core;
|
||||||
|
using Solicitors.Core.Data;
|
||||||
|
using Solicitors.Data.RepositorySetup;
|
||||||
|
using Solicitors.Data.RepositorySetup.InMemory;
|
||||||
|
using Solicitors.Data.RepositorySetup.Sqlite;
|
||||||
|
|
||||||
|
namespace Solicitors.Data;
|
||||||
|
|
||||||
|
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||||
|
public static class DIExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddData(this IServiceCollection services, IRepoSetupOptions options)
|
||||||
|
{
|
||||||
|
return services
|
||||||
|
.AddScoped<SolicitorsRepository>()
|
||||||
|
.AddScoped<ISolicitorRepository>(sp => sp.GetRequiredService<SolicitorsRepository>())
|
||||||
|
.AddScoped<IReadOnlySolicitorRepository>(sp => sp.GetRequiredService<ISolicitorRepository>())
|
||||||
|
.AddScoped<IReadOnlyCitiesRepository>(sp => sp.GetRequiredService<SolicitorsRepository>())
|
||||||
|
.AddScoped<IRatingsProviderRepository>(sp => sp.GetRequiredService<SolicitorsRepository>())
|
||||||
|
.AddSingleton(GetSetupService(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IRepoSetupService GetSetupService(IRepoSetupOptions options)
|
||||||
|
{
|
||||||
|
if (options is InMemoryDbSetupOptions inMemOptions)
|
||||||
|
return new InMemorySetupService(inMemOptions);
|
||||||
|
|
||||||
|
if (options is SqliteDbSetupOptions sqliteOptions)
|
||||||
|
return new SqliteSetupService(sqliteOptions);
|
||||||
|
|
||||||
|
throw new ArgumentException("Options type not supported", nameof(options));
|
||||||
|
}
|
||||||
|
}
|
||||||
222
backend/libs/Solicitors.Data/Migrations/20260622195820_InitialCreate.Designer.cs
generated
Normal file
222
backend/libs/Solicitors.Data/Migrations/20260622195820_InitialCreate.Designer.cs
generated
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Solicitors.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Solicitors.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(SolicitorsRepository))]
|
||||||
|
[Migration("20260622195820_InitialCreate")]
|
||||||
|
partial class InitialCreate
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
|
||||||
|
|
||||||
|
modelBuilder.Entity("CitySolicitor", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("CitiesCityId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("SolicitorsSolicitorId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("CitiesCityId", "SolicitorsSolicitorId");
|
||||||
|
|
||||||
|
b.HasIndex("SolicitorsSolicitorId");
|
||||||
|
|
||||||
|
b.ToTable("CitySolicitor");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Solicitors.Core.Models.City", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("CityId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("CityId");
|
||||||
|
|
||||||
|
b.ToTable("Cities");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Solicitors.Core.Models.Solicitor", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("SolicitorId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Phone")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("RelativeUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ShortDescription")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Website")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("SolicitorId");
|
||||||
|
|
||||||
|
b.ToTable("Solicitors");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocation", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("SolicitorLocationId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Address")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Phone")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("SolicitorId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("SolicitorLocationId");
|
||||||
|
|
||||||
|
b.HasIndex("SolicitorId");
|
||||||
|
|
||||||
|
b.ToTable("SolicitorLocations");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocationRating", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("SolicitorLocationRatingId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<decimal>("Maximum")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Provider")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("SolicitorLocationId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<decimal>("Value")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("SolicitorLocationRatingId");
|
||||||
|
|
||||||
|
b.HasIndex("SolicitorLocationId");
|
||||||
|
|
||||||
|
b.ToTable("SolicitorLocationRatings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Solicitors.Core.Models.SolicitorRating", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("SolicitorRatingId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<decimal>("Maximum")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Provider")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("SolicitorId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<decimal>("Value")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("SolicitorRatingId");
|
||||||
|
|
||||||
|
b.HasIndex("SolicitorId");
|
||||||
|
|
||||||
|
b.ToTable("SolicitorRatings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CitySolicitor", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Solicitors.Core.Models.City", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CitiesCityId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Solicitors.Core.Models.Solicitor", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SolicitorsSolicitorId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocation", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Solicitors.Core.Models.Solicitor", "Solicitor")
|
||||||
|
.WithMany("Locations")
|
||||||
|
.HasForeignKey("SolicitorId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Solicitor");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocationRating", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Solicitors.Core.Models.SolicitorLocation", "SolicitorLocation")
|
||||||
|
.WithMany("LocationRatings")
|
||||||
|
.HasForeignKey("SolicitorLocationId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("SolicitorLocation");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Solicitors.Core.Models.SolicitorRating", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Solicitors.Core.Models.Solicitor", "Solicitor")
|
||||||
|
.WithMany("Ratings")
|
||||||
|
.HasForeignKey("SolicitorId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Solicitor");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Solicitors.Core.Models.Solicitor", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Locations");
|
||||||
|
|
||||||
|
b.Navigation("Ratings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocation", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("LocationRatings");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,172 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Solicitors.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class InitialCreate : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Cities",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
CityId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
Name = table.Column<string>(type: "TEXT", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Cities", x => x.CityId);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Solicitors",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
SolicitorId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
Name = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
RelativeUrl = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
Phone = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
Email = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
Website = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
ShortDescription = table.Column<string>(type: "TEXT", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Solicitors", x => x.SolicitorId);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "CitySolicitor",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
CitiesCityId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
SolicitorsSolicitorId = table.Column<Guid>(type: "TEXT", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_CitySolicitor", x => new { x.CitiesCityId, x.SolicitorsSolicitorId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_CitySolicitor_Cities_CitiesCityId",
|
||||||
|
column: x => x.CitiesCityId,
|
||||||
|
principalTable: "Cities",
|
||||||
|
principalColumn: "CityId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_CitySolicitor_Solicitors_SolicitorsSolicitorId",
|
||||||
|
column: x => x.SolicitorsSolicitorId,
|
||||||
|
principalTable: "Solicitors",
|
||||||
|
principalColumn: "SolicitorId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "SolicitorLocations",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
SolicitorLocationId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
Address = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
Phone = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
SolicitorId = table.Column<Guid>(type: "TEXT", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_SolicitorLocations", x => x.SolicitorLocationId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_SolicitorLocations_Solicitors_SolicitorId",
|
||||||
|
column: x => x.SolicitorId,
|
||||||
|
principalTable: "Solicitors",
|
||||||
|
principalColumn: "SolicitorId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "SolicitorRatings",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
SolicitorRatingId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
Value = table.Column<decimal>(type: "TEXT", nullable: false),
|
||||||
|
Maximum = table.Column<decimal>(type: "TEXT", nullable: false),
|
||||||
|
Provider = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
SolicitorId = table.Column<Guid>(type: "TEXT", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_SolicitorRatings", x => x.SolicitorRatingId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_SolicitorRatings_Solicitors_SolicitorId",
|
||||||
|
column: x => x.SolicitorId,
|
||||||
|
principalTable: "Solicitors",
|
||||||
|
principalColumn: "SolicitorId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "SolicitorLocationRatings",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
SolicitorLocationRatingId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
Value = table.Column<decimal>(type: "TEXT", nullable: false),
|
||||||
|
Maximum = table.Column<decimal>(type: "TEXT", nullable: false),
|
||||||
|
Provider = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
SolicitorLocationId = table.Column<Guid>(type: "TEXT", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_SolicitorLocationRatings", x => x.SolicitorLocationRatingId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_SolicitorLocationRatings_SolicitorLocations_SolicitorLocationId",
|
||||||
|
column: x => x.SolicitorLocationId,
|
||||||
|
principalTable: "SolicitorLocations",
|
||||||
|
principalColumn: "SolicitorLocationId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CitySolicitor_SolicitorsSolicitorId",
|
||||||
|
table: "CitySolicitor",
|
||||||
|
column: "SolicitorsSolicitorId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SolicitorLocationRatings_SolicitorLocationId",
|
||||||
|
table: "SolicitorLocationRatings",
|
||||||
|
column: "SolicitorLocationId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SolicitorLocations_SolicitorId",
|
||||||
|
table: "SolicitorLocations",
|
||||||
|
column: "SolicitorId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SolicitorRatings_SolicitorId",
|
||||||
|
table: "SolicitorRatings",
|
||||||
|
column: "SolicitorId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "CitySolicitor");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "SolicitorLocationRatings");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "SolicitorRatings");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Cities");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "SolicitorLocations");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Solicitors");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,219 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Solicitors.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Solicitors.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(SolicitorsRepository))]
|
||||||
|
partial class SolicitorsRepositoryModelSnapshot : ModelSnapshot
|
||||||
|
{
|
||||||
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
|
||||||
|
|
||||||
|
modelBuilder.Entity("CitySolicitor", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("CitiesCityId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("SolicitorsSolicitorId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("CitiesCityId", "SolicitorsSolicitorId");
|
||||||
|
|
||||||
|
b.HasIndex("SolicitorsSolicitorId");
|
||||||
|
|
||||||
|
b.ToTable("CitySolicitor");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Solicitors.Core.Models.City", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("CityId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("CityId");
|
||||||
|
|
||||||
|
b.ToTable("Cities");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Solicitors.Core.Models.Solicitor", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("SolicitorId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Phone")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("RelativeUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ShortDescription")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Website")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("SolicitorId");
|
||||||
|
|
||||||
|
b.ToTable("Solicitors");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocation", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("SolicitorLocationId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Address")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Phone")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("SolicitorId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("SolicitorLocationId");
|
||||||
|
|
||||||
|
b.HasIndex("SolicitorId");
|
||||||
|
|
||||||
|
b.ToTable("SolicitorLocations");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocationRating", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("SolicitorLocationRatingId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<decimal>("Maximum")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Provider")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("SolicitorLocationId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<decimal>("Value")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("SolicitorLocationRatingId");
|
||||||
|
|
||||||
|
b.HasIndex("SolicitorLocationId");
|
||||||
|
|
||||||
|
b.ToTable("SolicitorLocationRatings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Solicitors.Core.Models.SolicitorRating", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("SolicitorRatingId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<decimal>("Maximum")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Provider")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("SolicitorId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<decimal>("Value")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("SolicitorRatingId");
|
||||||
|
|
||||||
|
b.HasIndex("SolicitorId");
|
||||||
|
|
||||||
|
b.ToTable("SolicitorRatings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CitySolicitor", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Solicitors.Core.Models.City", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CitiesCityId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Solicitors.Core.Models.Solicitor", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SolicitorsSolicitorId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocation", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Solicitors.Core.Models.Solicitor", "Solicitor")
|
||||||
|
.WithMany("Locations")
|
||||||
|
.HasForeignKey("SolicitorId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Solicitor");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocationRating", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Solicitors.Core.Models.SolicitorLocation", "SolicitorLocation")
|
||||||
|
.WithMany("LocationRatings")
|
||||||
|
.HasForeignKey("SolicitorLocationId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("SolicitorLocation");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Solicitors.Core.Models.SolicitorRating", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Solicitors.Core.Models.Solicitor", "Solicitor")
|
||||||
|
.WithMany("Ratings")
|
||||||
|
.HasForeignKey("SolicitorId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Solicitor");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Solicitors.Core.Models.Solicitor", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Locations");
|
||||||
|
|
||||||
|
b.Navigation("Ratings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocation", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("LocationRatings");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
namespace Solicitors.Data.RepositorySetup;
|
||||||
|
|
||||||
|
public interface IRepoSetupOptions
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Solicitors.Data.RepositorySetup;
|
||||||
|
|
||||||
|
internal interface IRepoSetupService
|
||||||
|
{
|
||||||
|
void OnConfiguring(DbContextOptionsBuilder optionsBuilder);
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
namespace Solicitors.Data.RepositorySetup.InMemory;
|
||||||
|
|
||||||
|
public class InMemoryDbSetupOptions(string dbName) : IRepoSetupOptions
|
||||||
|
{
|
||||||
|
public string DbName { get; } = dbName;
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Solicitors.Data.RepositorySetup.InMemory;
|
||||||
|
|
||||||
|
internal class InMemorySetupService(InMemoryDbSetupOptions config) : IRepoSetupService
|
||||||
|
{
|
||||||
|
public void OnConfiguring(DbContextOptionsBuilder options)
|
||||||
|
{
|
||||||
|
options.UseInMemoryDatabase(config.DbName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
namespace Solicitors.Data.RepositorySetup.Sqlite;
|
||||||
|
|
||||||
|
public class SqliteDbSetupOptions(string dbName) : IRepoSetupOptions
|
||||||
|
{
|
||||||
|
public string DbName { get; } = dbName;
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Solicitors.Data.RepositorySetup.Sqlite;
|
||||||
|
|
||||||
|
internal class SqliteSetupService(SqliteDbSetupOptions config) : IRepoSetupService
|
||||||
|
{
|
||||||
|
public void OnConfiguring(DbContextOptionsBuilder options)
|
||||||
|
{
|
||||||
|
var folder = Environment.SpecialFolder.MyDocuments;
|
||||||
|
var path = Environment.GetFolderPath(folder);
|
||||||
|
path = Path.Combine(path, "/data");
|
||||||
|
var dbPath = System.IO.Path.Join(path, $"{config.DbName}.db");
|
||||||
|
var dbString = $"Data Source={dbPath}";
|
||||||
|
options.UseSqlite(dbString);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
backend/libs/Solicitors.Data/Solicitors.Data.csproj
Normal file
20
backend/libs/Solicitors.Data/Solicitors.Data.csproj
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Solicitors.Core\Solicitors.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
157
backend/libs/Solicitors.Data/SolicitorsRepository.cs
Normal file
157
backend/libs/Solicitors.Data/SolicitorsRepository.cs
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Solicitors.Core;
|
||||||
|
using Solicitors.Core.Data;
|
||||||
|
using Solicitors.Core.Models;
|
||||||
|
using Solicitors.Core.Models.Imports;
|
||||||
|
using Solicitors.Data.RepositorySetup;
|
||||||
|
|
||||||
|
namespace Solicitors.Data;
|
||||||
|
|
||||||
|
internal class SolicitorsRepository : DbContext, ISolicitorRepository, IReadOnlyCitiesRepository, IRatingsProviderRepository
|
||||||
|
{
|
||||||
|
private readonly IRepoSetupService _setup;
|
||||||
|
|
||||||
|
public SolicitorsRepository(IRepoSetupService setup)
|
||||||
|
{
|
||||||
|
_setup = setup;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DbSet<Solicitor> Solicitors { get; set; }
|
||||||
|
public DbSet<SolicitorRating> SolicitorRatings { get; set; }
|
||||||
|
public DbSet<SolicitorLocation> SolicitorLocations { get; set; }
|
||||||
|
public DbSet<SolicitorLocationRating> SolicitorLocationRatings { get; set; }
|
||||||
|
public DbSet<City> Cities { get; set; }
|
||||||
|
|
||||||
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
|
=> _setup.OnConfiguring(optionsBuilder);
|
||||||
|
|
||||||
|
public IAsyncEnumerable<Solicitor> GetAllSolicitorsAsync()
|
||||||
|
=> Solicitors
|
||||||
|
.Include(solicitor => solicitor.Cities)
|
||||||
|
.Include(solicitor => solicitor.Locations)
|
||||||
|
.ThenInclude(location => location.LocationRatings)
|
||||||
|
.Include(solicitor => solicitor.Ratings)
|
||||||
|
.AsAsyncEnumerable();
|
||||||
|
|
||||||
|
public Task<Solicitor?> GetSolicitorByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||||
|
=> Solicitors
|
||||||
|
.Include(solicitor => solicitor.Cities)
|
||||||
|
.Include(solicitor => solicitor.Locations)
|
||||||
|
.ThenInclude(location => location.LocationRatings)
|
||||||
|
.Include(solicitor => solicitor.Ratings)
|
||||||
|
.FirstOrDefaultAsync(x => x.SolicitorId == id, cancellationToken);
|
||||||
|
|
||||||
|
public async Task AddOrUpdateSolicitorAsync(SolicitorData solicitor, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var matchingSolicitor = await Solicitors
|
||||||
|
.FirstOrDefaultAsync(
|
||||||
|
item => item.Name == solicitor.Name,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
if (matchingSolicitor is null)
|
||||||
|
await AddNewSolicitor(solicitor, cancellationToken);
|
||||||
|
else
|
||||||
|
await UpdateExistingSolicitor(solicitor, matchingSolicitor, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddNewSolicitor(
|
||||||
|
SolicitorData solicitor,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var newSol = new Solicitor()
|
||||||
|
{
|
||||||
|
Name = solicitor.Name,
|
||||||
|
RelativeUrl = solicitor.UrlPath,
|
||||||
|
Phone = solicitor.Phone,
|
||||||
|
Email = solicitor.Email,
|
||||||
|
Website = solicitor.Website,
|
||||||
|
ShortDescription = solicitor.ShortDescription
|
||||||
|
};
|
||||||
|
Solicitors.Add(newSol);
|
||||||
|
|
||||||
|
var cityTasks = solicitor.Cities
|
||||||
|
.Distinct()
|
||||||
|
.Select(async (city) =>
|
||||||
|
{
|
||||||
|
var matchingCity = await Cities.FirstOrDefaultAsync(
|
||||||
|
item => item.Name == city,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
if (matchingCity is null)
|
||||||
|
{
|
||||||
|
matchingCity = new City() { Name = city };
|
||||||
|
Cities.Add(matchingCity);
|
||||||
|
newSol.Cities.Add(matchingCity);
|
||||||
|
}
|
||||||
|
|
||||||
|
matchingCity.Solicitors.Add(newSol);
|
||||||
|
});
|
||||||
|
await Task.WhenAll(cityTasks);
|
||||||
|
|
||||||
|
foreach (var rating in solicitor.Ratings)
|
||||||
|
{
|
||||||
|
var newRating = new SolicitorRating()
|
||||||
|
{
|
||||||
|
Value = rating.Value,
|
||||||
|
Maximum = rating.MaxValue,
|
||||||
|
Provider = rating.RatingProvider,
|
||||||
|
Solicitor = newSol
|
||||||
|
};
|
||||||
|
SolicitorRatings.Add(newRating);
|
||||||
|
newSol.Ratings.Add(newRating);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var location in solicitor.Offices)
|
||||||
|
{
|
||||||
|
var newLocation = new SolicitorLocation()
|
||||||
|
{
|
||||||
|
Address = location.Address,
|
||||||
|
Phone = location.Phone,
|
||||||
|
Solicitor = newSol
|
||||||
|
};
|
||||||
|
SolicitorLocations.Add(newLocation);
|
||||||
|
newSol.Locations.Add(newLocation);
|
||||||
|
|
||||||
|
foreach (var rating in location.LocationRatings)
|
||||||
|
{
|
||||||
|
var newLocRating = new SolicitorLocationRating()
|
||||||
|
{
|
||||||
|
Value = rating.value,
|
||||||
|
Maximum = rating.MaxValue,
|
||||||
|
Provider = rating.RatingProvider,
|
||||||
|
SolicitorLocation = newLocation
|
||||||
|
};
|
||||||
|
SolicitorLocationRatings.Add(newLocRating);
|
||||||
|
newLocation.LocationRatings.Add(newLocRating);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task UpdateExistingSolicitor(
|
||||||
|
SolicitorData solicitor,
|
||||||
|
Solicitor matchingSolicitor,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// TODO
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IAsyncEnumerable<string> GetCitiesAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Cities
|
||||||
|
.Select(city => city.Name)
|
||||||
|
.AsAsyncEnumerable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public IAsyncEnumerable<string> GetAllRatingsProvidersAsync()
|
||||||
|
{
|
||||||
|
return SolicitorRatings
|
||||||
|
.Select(rating => rating.Provider)
|
||||||
|
.Distinct()
|
||||||
|
.OrderBy(x => x == "Solicitors.com" ? 0 : 1)
|
||||||
|
.ThenBy(x => x)
|
||||||
|
.AsAsyncEnumerable();
|
||||||
|
}
|
||||||
|
}
|
||||||
11
backend/libs/Solicitors.HtmlParsing/DIExtensions.cs
Normal file
11
backend/libs/Solicitors.HtmlParsing/DIExtensions.cs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Solicitors.HtmlParsing;
|
||||||
|
|
||||||
|
public static class DIExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddHtmlParsing(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
return services.AddSingleton<IHtmlParser, HtmlParser>();
|
||||||
|
}
|
||||||
|
}
|
||||||
130
backend/libs/Solicitors.HtmlParsing/HtmlParser.cs
Normal file
130
backend/libs/Solicitors.HtmlParsing/HtmlParser.cs
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
namespace Solicitors.HtmlParsing;
|
||||||
|
|
||||||
|
using Models;
|
||||||
|
|
||||||
|
internal class HtmlParser : IHtmlParser
|
||||||
|
{
|
||||||
|
private readonly string[] _voidElements =
|
||||||
|
[
|
||||||
|
"area",
|
||||||
|
"base",
|
||||||
|
"br",
|
||||||
|
"col",
|
||||||
|
"embed",
|
||||||
|
"hr",
|
||||||
|
"img",
|
||||||
|
"input",
|
||||||
|
"link",
|
||||||
|
"meta",
|
||||||
|
"source",
|
||||||
|
"track",
|
||||||
|
"wbr"
|
||||||
|
];
|
||||||
|
|
||||||
|
public IEnumerable<IHtmlNode> ParseHtml(string html)
|
||||||
|
{
|
||||||
|
// wanted to do this in one pass but was quite time-consuming
|
||||||
|
// so taking a little lesson from compiler development
|
||||||
|
// and lex the text into tokens before parsing :)
|
||||||
|
// deferred execution might help it still be "one pass"?
|
||||||
|
var stringEnumerator = html.GetEnumerator();
|
||||||
|
var tokens = LexHtml(stringEnumerator);
|
||||||
|
var tokenEnumerator = tokens.GetEnumerator();
|
||||||
|
return ParseHtml(tokenEnumerator);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<string> LexHtml(IEnumerator<char> content)
|
||||||
|
{
|
||||||
|
var work = "";
|
||||||
|
while (content.MoveNext())
|
||||||
|
{
|
||||||
|
if (content.Current == '<' && !string.IsNullOrWhiteSpace(work))
|
||||||
|
{
|
||||||
|
yield return work.Trim();
|
||||||
|
work = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
work += content.Current;
|
||||||
|
|
||||||
|
if (content.Current == '>' && !string.IsNullOrWhiteSpace(work))
|
||||||
|
{
|
||||||
|
yield return work.Trim();
|
||||||
|
work = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<IHtmlNode> ParseHtml(IEnumerator<string> tokens)
|
||||||
|
{
|
||||||
|
while (tokens.MoveNext())
|
||||||
|
{
|
||||||
|
var token = tokens.Current;
|
||||||
|
if (!token.StartsWith('<'))
|
||||||
|
{
|
||||||
|
yield return new StringNode(token);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var noBraces = token.Substring(1, token.Length - 2);
|
||||||
|
var split = noBraces.Split(' ');
|
||||||
|
IHtmlAttribute[] attributes = [];
|
||||||
|
if (split.Length > 1)
|
||||||
|
{
|
||||||
|
attributes = ParseAttributes(noBraces).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsVoidOrSelfClosingElement(token))
|
||||||
|
yield return new HtmlNode(split.First(), attributes);
|
||||||
|
else if (token.StartsWith("</"))
|
||||||
|
yield break;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var children = ParseHtml(tokens).ToArray();
|
||||||
|
yield return new ParentHtmlNode(split.First(), attributes, children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsVoidOrSelfClosingElement(string token)
|
||||||
|
{
|
||||||
|
if (token.StartsWith("<!") || token.EndsWith("/>"))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var tagName = token.Split(' ').First().Substring(1);
|
||||||
|
if (tagName.EndsWith('>'))
|
||||||
|
tagName = tagName.Substring(0, tagName.Length - 1);
|
||||||
|
return _voidElements.Contains(tagName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<IHtmlAttribute> ParseAttributes(string tokenNoBraces)
|
||||||
|
{
|
||||||
|
var attributesString = string.Join(' ', tokenNoBraces.Split(' ').Skip(1));
|
||||||
|
var attString = "";
|
||||||
|
bool inQuote = false;
|
||||||
|
foreach (char c in attributesString)
|
||||||
|
{
|
||||||
|
if (!inQuote && c == ' ')
|
||||||
|
{
|
||||||
|
yield return ParseAttribute(attString);
|
||||||
|
attString = "";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
attString += c;
|
||||||
|
if (c == '\"')
|
||||||
|
inQuote = !inQuote;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return ParseAttribute(attString);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IHtmlAttribute ParseAttribute(string attString)
|
||||||
|
{
|
||||||
|
var split = attString.Split('=');
|
||||||
|
if (split.Length > 1)
|
||||||
|
return new HtmlValueAttribute(split[0], string.Join('=', split.Skip(1)));
|
||||||
|
else
|
||||||
|
return new HtmlAttribute(attString);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
backend/libs/Solicitors.HtmlParsing/IHtmlParser.cs
Normal file
8
backend/libs/Solicitors.HtmlParsing/IHtmlParser.cs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
namespace Solicitors.HtmlParsing;
|
||||||
|
|
||||||
|
using Models;
|
||||||
|
|
||||||
|
public interface IHtmlParser
|
||||||
|
{
|
||||||
|
IEnumerable<IHtmlNode> ParseHtml(string html);
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
namespace Solicitors.HtmlParsing.Models;
|
||||||
|
|
||||||
|
internal class HtmlAttribute(string name) : IHtmlAttribute
|
||||||
|
{
|
||||||
|
public string Name { get; } = name;
|
||||||
|
}
|
||||||
95
backend/libs/Solicitors.HtmlParsing/Models/HtmlNode.cs
Normal file
95
backend/libs/Solicitors.HtmlParsing/Models/HtmlNode.cs
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace Solicitors.HtmlParsing.Models;
|
||||||
|
|
||||||
|
internal class HtmlNode(string tagName, IHtmlAttribute[] attributes)
|
||||||
|
: IHtmlNode
|
||||||
|
{
|
||||||
|
private readonly IHtmlAttribute[] _attributes = attributes;
|
||||||
|
private readonly string _tagName = tagName;
|
||||||
|
|
||||||
|
public virtual bool TryGetByTagName(
|
||||||
|
string tagName,
|
||||||
|
[NotNullWhen(true)] out IHtmlNode? node,
|
||||||
|
bool noChildren = false)
|
||||||
|
{
|
||||||
|
node = null;
|
||||||
|
if (tagName == _tagName)
|
||||||
|
node = this;
|
||||||
|
|
||||||
|
return node is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string[] Classes
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var classAttribute = _attributes
|
||||||
|
.Where(a => a is HtmlValueAttribute)
|
||||||
|
.Select(a => (a as HtmlValueAttribute)!)
|
||||||
|
.FirstOrDefault(a => a.Name.Equals("class", StringComparison.CurrentCultureIgnoreCase));
|
||||||
|
|
||||||
|
return classAttribute is not null
|
||||||
|
? classAttribute.Value.Substring(1, classAttribute.Value.Length - 2).Split(' ')
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual bool TryGetByClass(
|
||||||
|
string className,
|
||||||
|
[NotNullWhen(true)] out IHtmlNode? node,
|
||||||
|
bool noChildren = false)
|
||||||
|
{
|
||||||
|
node = null;
|
||||||
|
|
||||||
|
if (Classes.Contains(className, StringComparer.InvariantCultureIgnoreCase))
|
||||||
|
node = this;
|
||||||
|
|
||||||
|
return node is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual bool TryGetByTagNameAndClass(
|
||||||
|
string tagName,
|
||||||
|
string className,
|
||||||
|
[NotNullWhen(true)] out IHtmlNode? node,
|
||||||
|
bool noChildren = false)
|
||||||
|
{
|
||||||
|
node = null;
|
||||||
|
if (tagName == _tagName && Classes.Contains(className, StringComparer.InvariantCultureIgnoreCase))
|
||||||
|
node = this;
|
||||||
|
|
||||||
|
return node is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual bool TryGetText([NotNullWhen(true)] out string? text)
|
||||||
|
{
|
||||||
|
text = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual bool TryGetChildren([NotNullWhen(true)] out IEnumerable<IHtmlNode>? children)
|
||||||
|
{
|
||||||
|
children = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasAttribute(string attributeName)
|
||||||
|
{
|
||||||
|
return _attributes
|
||||||
|
.Any(a => a.Name.Equals(attributeName, StringComparison.InvariantCultureIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetAttributeValue(string attributeName, [NotNullWhen(true)] out string? attributeValue)
|
||||||
|
{
|
||||||
|
attributeValue = null;
|
||||||
|
|
||||||
|
var matchingAttribute = _attributes.FirstOrDefault(
|
||||||
|
a => a.Name.Equals(attributeName, StringComparison.InvariantCultureIgnoreCase)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchingAttribute is HtmlValueAttribute valueAttribute)
|
||||||
|
attributeValue = valueAttribute.Value;
|
||||||
|
|
||||||
|
return attributeValue is not null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
namespace Solicitors.HtmlParsing.Models;
|
||||||
|
|
||||||
|
internal class HtmlValueAttribute(string name, string value) : HtmlAttribute(name)
|
||||||
|
{
|
||||||
|
public string Value { get; } = value;
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
namespace Solicitors.HtmlParsing.Models;
|
||||||
|
|
||||||
|
internal interface IHtmlAttribute
|
||||||
|
{
|
||||||
|
string Name { get; }
|
||||||
|
}
|
||||||
64
backend/libs/Solicitors.HtmlParsing/Models/IHtmlNode.cs
Normal file
64
backend/libs/Solicitors.HtmlParsing/Models/IHtmlNode.cs
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace Solicitors.HtmlParsing.Models;
|
||||||
|
|
||||||
|
public interface IHtmlNode
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Searches this node and its direct children for a matching tag name.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tagName">The tag name to match.</param>
|
||||||
|
/// <param name="node">If found, the node that matches the tag name.</param>
|
||||||
|
/// <param name="noChildren">If true, only check this node, not its children.</param>
|
||||||
|
/// <returns>True if this node, or any of its direct children, have a matching tag name; false otherwise.</returns>
|
||||||
|
bool TryGetByTagName(
|
||||||
|
string tagName,
|
||||||
|
[NotNullWhen(true)] out IHtmlNode? node,
|
||||||
|
bool noChildren = false);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Searches this node and its direct children for a matching class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="className">The class name to match.</param>
|
||||||
|
/// <param name="node">If found, the node that matches the class name.</param>
|
||||||
|
/// <param name="noChildren">If true, only check this node, not its children.</param>
|
||||||
|
/// <returns>True if this node, or any of its direct children, have a matching class name; false otherwise.</returns>
|
||||||
|
bool TryGetByClass(
|
||||||
|
string className,
|
||||||
|
[NotNullWhen(true)] out IHtmlNode? node,
|
||||||
|
bool noChildren = false);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Searches this node and its direct children for a matching tag name and matching class on the same node.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tagName">The tag name to match.</param>
|
||||||
|
/// <param name="className">The class name to match.</param>
|
||||||
|
/// <param name="node">If found, the node that matches the tag name and class name.</param>
|
||||||
|
/// <param name="noChildren">If true, only check this node, not its children.</param>
|
||||||
|
/// <returns>True if this node, or any of its direct children, have a matching tag name and class name; false otherwise.</returns>
|
||||||
|
bool TryGetByTagNameAndClass(
|
||||||
|
string tagName,
|
||||||
|
string className,
|
||||||
|
[NotNullWhen(true)] out IHtmlNode? node,
|
||||||
|
bool noChildren = false);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks this node (and *not* its children) to see if it is text content.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="text">If found, the text content.</param>
|
||||||
|
/// <returns>True if the node is a text node, false otherwise.</returns>
|
||||||
|
bool TryGetText([NotNullWhen(true)] out string? text);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks this node to see if it has child elements.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="children">If found, the child elements.</param>
|
||||||
|
/// <returns>True if the node has child elements, false otherwise.</returns>
|
||||||
|
bool TryGetChildren([NotNullWhen(true)] out IEnumerable<IHtmlNode>? children);
|
||||||
|
|
||||||
|
bool HasAttribute(string attributeName);
|
||||||
|
|
||||||
|
bool TryGetAttributeValue(
|
||||||
|
string attributeName,
|
||||||
|
[NotNullWhen(true)] out string? attributeValue);
|
||||||
|
}
|
||||||
94
backend/libs/Solicitors.HtmlParsing/Models/ParentHtmlNode.cs
Normal file
94
backend/libs/Solicitors.HtmlParsing/Models/ParentHtmlNode.cs
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace Solicitors.HtmlParsing.Models;
|
||||||
|
|
||||||
|
internal class ParentHtmlNode(string tagName, IHtmlAttribute[] attributes, IHtmlNode[] children)
|
||||||
|
: HtmlNode(tagName, attributes)
|
||||||
|
{
|
||||||
|
private readonly IHtmlNode[] _children = children;
|
||||||
|
|
||||||
|
public override bool TryGetByTagName(
|
||||||
|
string tagName,
|
||||||
|
[NotNullWhen(true)] out IHtmlNode? node,
|
||||||
|
bool noChildren = false)
|
||||||
|
{
|
||||||
|
node = null;
|
||||||
|
if (!base.TryGetByTagName(tagName, out node, noChildren) && !noChildren)
|
||||||
|
{
|
||||||
|
var nodesToCheck = new Queue<IHtmlNode>(_children);
|
||||||
|
while (nodesToCheck.TryDequeue(out var child))
|
||||||
|
{
|
||||||
|
if (child.TryGetByTagName(tagName, out var childNode, true))
|
||||||
|
{
|
||||||
|
node = childNode;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (child.TryGetChildren(out var grandChildren))
|
||||||
|
foreach (var grandChild in grandChildren)
|
||||||
|
nodesToCheck.Enqueue(grandChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return node is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool TryGetByClass(
|
||||||
|
string className,
|
||||||
|
[NotNullWhen(true)] out IHtmlNode? node,
|
||||||
|
bool noChildren = false)
|
||||||
|
{
|
||||||
|
node = null;
|
||||||
|
if (!base.TryGetByClass(className, out node, noChildren) && !noChildren)
|
||||||
|
{
|
||||||
|
var nodesToCheck = new Queue<IHtmlNode>(_children);
|
||||||
|
while (nodesToCheck.TryDequeue(out var child))
|
||||||
|
{
|
||||||
|
if (child.TryGetByClass(className, out var childNode, true))
|
||||||
|
{
|
||||||
|
node = childNode;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (child.TryGetChildren(out var grandChildren))
|
||||||
|
foreach (var grandChild in grandChildren)
|
||||||
|
nodesToCheck.Enqueue(grandChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return node is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool TryGetByTagNameAndClass(
|
||||||
|
string tagName,
|
||||||
|
string className,
|
||||||
|
[NotNullWhen(true)] out IHtmlNode? node,
|
||||||
|
bool noChildren = false)
|
||||||
|
{
|
||||||
|
node = null;
|
||||||
|
if (!base.TryGetByTagNameAndClass(tagName, className, out node, noChildren) && !noChildren)
|
||||||
|
{
|
||||||
|
var nodesToCheck = new Queue<IHtmlNode>(_children);
|
||||||
|
while (nodesToCheck.TryDequeue(out var child))
|
||||||
|
{
|
||||||
|
if (child.TryGetByTagNameAndClass(tagName, className, out var childNode, true))
|
||||||
|
{
|
||||||
|
node = childNode;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (child.TryGetChildren(out var grandChildren))
|
||||||
|
foreach (var grandChild in grandChildren)
|
||||||
|
nodesToCheck.Enqueue(grandChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return node is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool TryGetChildren([NotNullWhen(true)] out IEnumerable<IHtmlNode>? children)
|
||||||
|
{
|
||||||
|
children = _children;
|
||||||
|
return _children.Length != 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
59
backend/libs/Solicitors.HtmlParsing/Models/StringNode.cs
Normal file
59
backend/libs/Solicitors.HtmlParsing/Models/StringNode.cs
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace Solicitors.HtmlParsing.Models;
|
||||||
|
|
||||||
|
internal class StringNode(string content)
|
||||||
|
: IHtmlNode
|
||||||
|
{
|
||||||
|
private readonly string _content = content;
|
||||||
|
|
||||||
|
public bool TryGetByTagName(
|
||||||
|
string tagName,
|
||||||
|
[NotNullWhen(true)] out IHtmlNode? node,
|
||||||
|
bool noChildren = false)
|
||||||
|
{
|
||||||
|
node = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetByClass(
|
||||||
|
string className,
|
||||||
|
[NotNullWhen(true)] out IHtmlNode? node,
|
||||||
|
bool noChildren = false)
|
||||||
|
{
|
||||||
|
node = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetByTagNameAndClass(
|
||||||
|
string tagName,
|
||||||
|
string className,
|
||||||
|
[NotNullWhen(true)] out IHtmlNode? node,
|
||||||
|
bool noChildren = false)
|
||||||
|
{
|
||||||
|
node = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetText([NotNullWhen(true)] out string? text)
|
||||||
|
{
|
||||||
|
text = _content;
|
||||||
|
return !string.IsNullOrEmpty(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetChildren([NotNullWhen(true)] out IEnumerable<IHtmlNode>? children)
|
||||||
|
{
|
||||||
|
children = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasAttribute(string attributeName) => false;
|
||||||
|
|
||||||
|
public bool TryGetAttributeValue(
|
||||||
|
string attributeName,
|
||||||
|
[NotNullWhen(true)] out string? attributeValue)
|
||||||
|
{
|
||||||
|
attributeValue = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\libs\Solicitors.HtmlParsing\Solicitors.HtmlParsing.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
BIN
frontend/.yarn/install-state.gz
Normal file
BIN
frontend/.yarn/install-state.gz
Normal file
Binary file not shown.
8
frontend/.yarnrc.yml
Normal file
8
frontend/.yarnrc.yml
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
approvedGitRepositories:
|
||||||
|
- "**"
|
||||||
|
|
||||||
|
enableScripts: true
|
||||||
|
|
||||||
|
nodeLinker: node-modules
|
||||||
|
|
||||||
|
npmMinimalAgeGate: 0
|
||||||
15
frontend/Dockerfile
Normal file
15
frontend/Dockerfile
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
FROM node:26.3.1-alpine3.24 as build
|
||||||
|
|
||||||
|
ARG VITE_API_URL
|
||||||
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
|
|
||||||
|
RUN mkdir -p /code
|
||||||
|
WORKDIR /code
|
||||||
|
ADD . /code
|
||||||
|
RUN npm install -g -s --no-progress corepack
|
||||||
|
RUN COREPACK_ENABLE_DOWNLOAD_PROMPT=0 /bin/sh -c "yarn -v"
|
||||||
|
RUN yarn install
|
||||||
|
RUN yarn run build
|
||||||
|
|
||||||
|
FROM httpd:2.4.68-alpine3.24
|
||||||
|
COPY --from=build /code/dist /usr/local/apache2/htdocs/
|
||||||
15
frontend/README.md
Normal file
15
frontend/README.md
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# `create-preact`
|
||||||
|
|
||||||
|
<h2 align="center">
|
||||||
|
<img height="256" width="256" src="./src/assets/preact.svg">
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<h3 align="center">Get started using Preact and Vite!</h3>
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
- `yarn dev` - Starts a dev server at http://localhost:5173/
|
||||||
|
|
||||||
|
- `yarn build` - Builds for production, emitting to `dist/`
|
||||||
|
|
||||||
|
- `yarn preview` - Starts a server at http://localhost:4173/ to test production build locally
|
||||||
17
frontend/index.html
Normal file
17
frontend/index.html
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||||
|
<link href="https://fonts.bunny.net/css?family=roboto:300,400,500,700" rel="stylesheet" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
<title>Vite + Preact</title>
|
||||||
|
<style>body { margin: 0; max-height: 100vh; overflow: hidden; }</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/index.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
31
frontend/package.json
Normal file
31
frontend/package.json
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/core": "^8.0.1",
|
||||||
|
"@base-ui/react": "^1.6.0",
|
||||||
|
"@emotion/react": "^11.14.0",
|
||||||
|
"@emotion/styled": "^11.14.1",
|
||||||
|
"@fontsource/roboto": "^5.2.10",
|
||||||
|
"@mui/icons-material": "^9.1.1",
|
||||||
|
"@mui/material": "^9.1.2",
|
||||||
|
"axios": "^1.18.1",
|
||||||
|
"preact": "^10.26.9"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@preact/preset-vite": "^2.10.2",
|
||||||
|
"eslint": "^10.5.0",
|
||||||
|
"eslint-config-preact": "^2.0.0",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vite": "^7.0.4"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": "preact"
|
||||||
|
},
|
||||||
|
"packageManager": "yarn@4.17.0"
|
||||||
|
}
|
||||||
15
frontend/public/vite.svg
Normal file
15
frontend/public/vite.svg
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%">
|
||||||
|
<stop offset="0%" stop-color="#41D1FF"></stop>
|
||||||
|
<stop offset="100%" stop-color="#BD34FE"></stop>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%">
|
||||||
|
<stop offset="0%" stop-color="#FFEA83"></stop>
|
||||||
|
<stop offset="8.333%" stop-color="#FFDD35"></stop>
|
||||||
|
<stop offset="100%" stop-color="#FFA800"></stop>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path>
|
||||||
|
<path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
6
frontend/src/assets/preact.svg
Normal file
6
frontend/src/assets/preact.svg
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="27.68" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 296">
|
||||||
|
<path fill="#673AB8" d="m128 0l128 73.9v147.8l-128 73.9L0 221.7V73.9z"></path>
|
||||||
|
<path fill="#FFF" d="M34.865 220.478c17.016 21.78 71.095 5.185 122.15-34.704c51.055-39.888 80.24-88.345 63.224-110.126c-17.017-21.78-71.095-5.184-122.15 34.704c-51.055 39.89-80.24 88.346-63.224 110.126Zm7.27-5.68c-5.644-7.222-3.178-21.402 7.573-39.253c11.322-18.797 30.541-39.548 54.06-57.923c23.52-18.375 48.303-32.004 69.281-38.442c19.922-6.113 34.277-5.075 39.92 2.148c5.644 7.223 3.178 21.403-7.573 39.254c-11.322 18.797-30.541 39.547-54.06 57.923c-23.52 18.375-48.304 32.004-69.281 38.441c-19.922 6.114-34.277 5.076-39.92-2.147Z"></path>
|
||||||
|
<path fill="#FFF" d="M220.239 220.478c17.017-21.78-12.169-70.237-63.224-110.126C105.96 70.464 51.88 53.868 34.865 75.648c-17.017 21.78 12.169 70.238 63.224 110.126c51.055 39.889 105.133 56.485 122.15 34.704Zm-7.27-5.68c-5.643 7.224-19.998 8.262-39.92 2.148c-20.978-6.437-45.761-20.066-69.28-38.441c-23.52-18.376-42.74-39.126-54.06-57.923c-10.752-17.851-13.218-32.03-7.575-39.254c5.644-7.223 19.999-8.261 39.92-2.148c20.978 6.438 45.762 20.067 69.281 38.442c23.52 18.375 42.739 39.126 54.06 57.923c10.752 17.85 13.218 32.03 7.574 39.254Z"></path>
|
||||||
|
<path fill="#FFF" d="M127.552 167.667c10.827 0 19.603-8.777 19.603-19.604c0-10.826-8.776-19.603-19.603-19.603c-10.827 0-19.604 8.777-19.604 19.603c0 10.827 8.777 19.604 19.604 19.604Z"></path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
137
frontend/src/components/detail.tsx
Normal file
137
frontend/src/components/detail.tsx
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Modal from "@mui/material/Modal";
|
||||||
|
import { forwardRef, useEffect, useState } from "react";
|
||||||
|
import { SolicitorInfo } from "../models/solicitorSummary";
|
||||||
|
import getSolicitor from "../functions/getSolicitor";
|
||||||
|
import Card from "@mui/material/Card";
|
||||||
|
import { Grid, Rating, Typography } from "@mui/material";
|
||||||
|
|
||||||
|
interface ClosedProps {
|
||||||
|
open : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenProps {
|
||||||
|
open : true;
|
||||||
|
id : string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DetailInfo = OpenProps | ClosedProps;
|
||||||
|
type DetailProps = DetailInfo & { onClose : (error? : string) => void };
|
||||||
|
|
||||||
|
|
||||||
|
export default function Detail(props : DetailProps) {
|
||||||
|
const [data, setData] = useState<SolicitorInfo | 'loading'>('loading');
|
||||||
|
|
||||||
|
const handleResult = (result : SolicitorInfo | undefined) => {
|
||||||
|
console.log(data, props, result);
|
||||||
|
if (data == 'loading' && props.open && props.id == result?.id) {
|
||||||
|
setData(result);
|
||||||
|
} else if (!result) {
|
||||||
|
handleClose('An error occurred loading the solicitor');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = (error? : string) => {
|
||||||
|
props.onClose(error);
|
||||||
|
setData('loading');
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.open && data == 'loading') {
|
||||||
|
getSolicitor(props.id)
|
||||||
|
.then(result => { console.log(result); handleResult(result); });
|
||||||
|
}
|
||||||
|
}, [props.open]);
|
||||||
|
|
||||||
|
useEffect(() => {}, [data])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={props.open}
|
||||||
|
onClose={() => handleClose()}
|
||||||
|
>
|
||||||
|
<DetailDisplay data={data} />
|
||||||
|
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
width: 800,
|
||||||
|
maxWidth: '60%',
|
||||||
|
maxHeight: '80%',
|
||||||
|
bgcolor: 'background.default',
|
||||||
|
overflow: 'scroll',
|
||||||
|
p: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DetailDisplay = forwardRef((props : {data : SolicitorInfo | 'loading'}) => {
|
||||||
|
console.log(props.data);
|
||||||
|
useEffect(() => {console.log(props.data);}, [props.data]);
|
||||||
|
|
||||||
|
if (props.data === 'loading') {
|
||||||
|
return <Box>Nope</Box>;
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Box sx={style}>
|
||||||
|
<Typography variant='h3' component='h2'>{props.data.name}</Typography>
|
||||||
|
<Typography variant='body1'>{props.data.shortDescription}</Typography>
|
||||||
|
{props.data.ratings &&
|
||||||
|
(<>
|
||||||
|
<br />
|
||||||
|
<Typography variant='h5' component='h3'>Ratings</Typography>
|
||||||
|
<Grid container direction='row' spacing={2} sx={{justifyContent: 'flex-start', alignItems: 'stretch'}}>
|
||||||
|
{props.data.ratings.map(rating => (
|
||||||
|
<Grid size={3}>
|
||||||
|
<Box sx={{padding: '15px', height: '100%'}}>
|
||||||
|
<Typography variant='body2'>{rating.provider}</Typography>
|
||||||
|
<Rating
|
||||||
|
readOnly
|
||||||
|
value={rating.value * 5.0 / rating.maximum}
|
||||||
|
precision={0.1}
|
||||||
|
size='small'
|
||||||
|
sx={{top: '2px'}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</>)
|
||||||
|
}
|
||||||
|
{props.data.locations &&
|
||||||
|
(<>
|
||||||
|
<br />
|
||||||
|
<Typography variant='h5' component='h3'>Offices</Typography>
|
||||||
|
<Grid container direction='row' spacing={2} sx={{justifyContent: 'flex-start', alignItems: 'stretch'}}>
|
||||||
|
{props.data.locations.map(office => (
|
||||||
|
<Grid size={3}>
|
||||||
|
<Card sx={{padding: '15px', height: '100%'}}>
|
||||||
|
{office.address.split('\n').map(line => (<Typography variant='body2'>{line}</Typography>))}
|
||||||
|
{office.phone && <><br/><Typography variant='body2'>tel: {office.phone}</Typography></>}
|
||||||
|
{office.ratings && office.ratings.map(rating =>
|
||||||
|
(<>
|
||||||
|
<Typography variant='body2'>{rating.provider}</Typography>
|
||||||
|
<Rating
|
||||||
|
readOnly
|
||||||
|
value={rating.value * 5.0 / rating.maximum}
|
||||||
|
precision={0.1}
|
||||||
|
size='small'
|
||||||
|
sx={{top: '2px'}}
|
||||||
|
/>
|
||||||
|
</>)
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</>)
|
||||||
|
}
|
||||||
|
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
42
frontend/src/components/header.tsx
Normal file
42
frontend/src/components/header.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import DarkMode from "@mui/icons-material/DarkMode";
|
||||||
|
import LightMode from "@mui/icons-material/LightMode";
|
||||||
|
import { AppBar, IconButton, Toolbar, Tooltip, Typography } from "@mui/material";
|
||||||
|
import { Theme } from "@mui/material/styles";
|
||||||
|
|
||||||
|
export interface ThemeWrapper {
|
||||||
|
theme : Theme,
|
||||||
|
value : 'dark' | 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
theme : ThemeWrapper;
|
||||||
|
toggleTheme : () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Header(props : HeaderProps) {
|
||||||
|
return (
|
||||||
|
<AppBar position='static' sx={{}}>
|
||||||
|
<Toolbar sx={{bgcolor:'primary.main'}}>
|
||||||
|
<Typography variant="h4" component="h1" sx={{ flexGrow: 2, marginLeft: '16px' }}>
|
||||||
|
Conveyancing Search
|
||||||
|
</Typography>
|
||||||
|
<Tooltip title="Toggle theme">
|
||||||
|
<IconButton
|
||||||
|
size="large"
|
||||||
|
edge="start"
|
||||||
|
color="inherit"
|
||||||
|
aria-label="theme toggle"
|
||||||
|
sx={{ mr: 2 }}
|
||||||
|
onClick={props.toggleTheme}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
props.theme.value === 'dark'
|
||||||
|
? <DarkMode />
|
||||||
|
: <LightMode />
|
||||||
|
}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
frontend/src/components/numberField.tsx
Normal file
114
frontend/src/components/numberField.tsx
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { NumberField as BaseNumberField } from '@base-ui/react/number-field';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import FormControl from '@mui/material/FormControl';
|
||||||
|
import FormHelperText from '@mui/material/FormHelperText';
|
||||||
|
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||||
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
import InputLabel from '@mui/material/InputLabel';
|
||||||
|
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||||
|
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This component is a placeholder for FormControl to correctly set the shrink label state on SSR.
|
||||||
|
*/
|
||||||
|
function SSRInitialFilled(_: BaseNumberField.Root.Props) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
SSRInitialFilled.muiName = 'Input';
|
||||||
|
|
||||||
|
export default function NumberField({
|
||||||
|
id: idProp,
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
size = 'medium',
|
||||||
|
...other
|
||||||
|
}: BaseNumberField.Root.Props & {
|
||||||
|
label?: React.ReactNode;
|
||||||
|
size?: 'small' | 'medium';
|
||||||
|
error?: boolean;
|
||||||
|
}) {
|
||||||
|
let id = React.useId();
|
||||||
|
if (idProp) {
|
||||||
|
id = idProp;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<BaseNumberField.Root
|
||||||
|
{...other}
|
||||||
|
render={(props, state) => (
|
||||||
|
<FormControl
|
||||||
|
size={size}
|
||||||
|
ref={props.ref}
|
||||||
|
disabled={state.disabled}
|
||||||
|
required={state.required}
|
||||||
|
error={error}
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SSRInitialFilled {...other} />
|
||||||
|
<InputLabel htmlFor={id}>{label}</InputLabel>
|
||||||
|
<BaseNumberField.Input
|
||||||
|
id={id}
|
||||||
|
render={(props, state) => (
|
||||||
|
<OutlinedInput
|
||||||
|
aria-describedby={`${id}-helper-text`}
|
||||||
|
label={label}
|
||||||
|
inputRef={props.ref}
|
||||||
|
value={state.inputValue}
|
||||||
|
onBlur={props.onBlur}
|
||||||
|
onChange={props.onChange}
|
||||||
|
onKeyUp={props.onKeyUp}
|
||||||
|
onKeyDown={props.onKeyDown}
|
||||||
|
onFocus={props.onFocus}
|
||||||
|
slotProps={{
|
||||||
|
input: props,
|
||||||
|
}}
|
||||||
|
endAdornment={
|
||||||
|
<InputAdornment
|
||||||
|
position="end"
|
||||||
|
sx={{
|
||||||
|
flexDirection: 'column',
|
||||||
|
maxHeight: 'unset',
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
borderLeft: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
ml: 0,
|
||||||
|
'& button': {
|
||||||
|
py: 0,
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 0.5,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BaseNumberField.Increment
|
||||||
|
render={<IconButton size={size} aria-label="Increase" />}
|
||||||
|
>
|
||||||
|
<KeyboardArrowUpIcon
|
||||||
|
fontSize={size}
|
||||||
|
sx={{ transform: 'translateY(2px)' }}
|
||||||
|
/>
|
||||||
|
</BaseNumberField.Increment>
|
||||||
|
|
||||||
|
<BaseNumberField.Decrement
|
||||||
|
render={<IconButton size={size} aria-label="Decrease" />}
|
||||||
|
>
|
||||||
|
<KeyboardArrowDownIcon
|
||||||
|
fontSize={size}
|
||||||
|
sx={{ transform: 'translateY(-2px)' }}
|
||||||
|
/>
|
||||||
|
</BaseNumberField.Decrement>
|
||||||
|
</InputAdornment>
|
||||||
|
}
|
||||||
|
sx={{ pr: 0 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormHelperText id={`${id}-helper-text`} sx={{ ml: 0, '&:empty': { mt: 0 } }}>
|
||||||
|
Enter value between 10 and 40
|
||||||
|
</FormHelperText>
|
||||||
|
</BaseNumberField.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
frontend/src/components/result.tsx
Normal file
42
frontend/src/components/result.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import ListItemText from "@mui/material/ListItemText";
|
||||||
|
import SolicitorSummary from "../models/solicitorSummary";
|
||||||
|
import ListItemButton from "@mui/material/ListItemButton";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import Rating from "@mui/material/Rating";
|
||||||
|
import Divider from "@mui/material/Divider";
|
||||||
|
|
||||||
|
type ResultProps = SolicitorSummary & { last : boolean, onClick: () => void };
|
||||||
|
|
||||||
|
export default function Result(props : ResultProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ListItemButton
|
||||||
|
alignItems='flex-start'
|
||||||
|
onClick={props.onClick}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<>
|
||||||
|
<Typography
|
||||||
|
component='span'
|
||||||
|
variant='h6'
|
||||||
|
sx={{marginRight:'10px'}}
|
||||||
|
>
|
||||||
|
{props.name}
|
||||||
|
</Typography>
|
||||||
|
<Rating
|
||||||
|
readOnly
|
||||||
|
value={props.rating.value * 5.0 / props.rating.maximum}
|
||||||
|
precision={0.1}
|
||||||
|
size='small'
|
||||||
|
sx={{top: '2px'}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
secondary={props.shortDescription}
|
||||||
|
/>
|
||||||
|
</ListItemButton>
|
||||||
|
{props.last || <Divider variant='middle' component="li" />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
frontend/src/components/results.tsx
Normal file
38
frontend/src/components/results.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { useEffect, useState } from "preact/hooks";
|
||||||
|
import GetSolicitors from "../functions/getSolicitors";
|
||||||
|
import Grid from "@mui/material/Grid";
|
||||||
|
import Sidebar from "./sidebar";
|
||||||
|
import FilterState from "../models/filterState";
|
||||||
|
import { default as ResultsType, waitingResults } from "../models/results";
|
||||||
|
import ResultsList from "./resultsList";
|
||||||
|
|
||||||
|
export default function Results() {
|
||||||
|
const [results, setResults] = useState<ResultsType>(waitingResults);
|
||||||
|
const [filters, setFilters] = useState<FilterState>({cities: [], minRating: 3, resultsPerPage: 20, currentPage: 1, ratingsProvider: 'Solicitors.com', sortBy: 'rating-desc'});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (results.type == 'Waiting') {
|
||||||
|
GetSolicitors(filters)
|
||||||
|
.then(result => setResults({type: 'Success', data: result}));
|
||||||
|
}
|
||||||
|
}, [results]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setResults({type: 'Waiting'});
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid container spacing={0} sx={{minHeight: '100vh'}}>
|
||||||
|
<Grid size={4}>
|
||||||
|
<Sidebar
|
||||||
|
filters={filters}
|
||||||
|
onChange={setFilters}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={8}>
|
||||||
|
<ResultsList results={results} filters={filters} setFilters={setFilters} />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
42
frontend/src/components/resultsList.tsx
Normal file
42
frontend/src/components/resultsList.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import List from "@mui/material/List";
|
||||||
|
import Pagination from "@mui/material/Pagination";
|
||||||
|
import FilterState from "../models/filterState";
|
||||||
|
import Results from "../models/results";
|
||||||
|
import Result from "./result";
|
||||||
|
import Detail, { DetailInfo } from "./detail";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface ResultsListProps {
|
||||||
|
results : Results;
|
||||||
|
filters : FilterState;
|
||||||
|
setFilters : (state : FilterState) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ResultsList(props : ResultsListProps) {
|
||||||
|
const [detail, setDetail] = useState<DetailInfo>({open: false});
|
||||||
|
|
||||||
|
switch (props.results.type) {
|
||||||
|
case 'Success':
|
||||||
|
let data = props.results.data.data;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<List sx={{ width: '100%', maxHeight: '85vh', overflow: 'scroll' }}>
|
||||||
|
{data.map((r, i) => (<Result {...r} onClick={() => setDetail({open : true, id : r.id})} last={i == data.length - 1} />))}
|
||||||
|
</List>
|
||||||
|
<Detail {...detail} onClose={() => setDetail({open: false})} />
|
||||||
|
<Pagination
|
||||||
|
page={props.filters.currentPage}
|
||||||
|
count={Math.ceil(props.results.data.total / props.filters.resultsPerPage)}
|
||||||
|
shape='rounded'
|
||||||
|
onChange={(_, page) => props.setFilters(Object.assign({}, {...props.filters}, {currentPage: page}))}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case 'Waiting':
|
||||||
|
return <div>Waiting...</div>;
|
||||||
|
case 'Error':
|
||||||
|
return <div>Error...</div>;
|
||||||
|
case 'Warning':
|
||||||
|
return <div>Warning...</div>;
|
||||||
|
}
|
||||||
|
}
|
||||||
140
frontend/src/components/sidebar.tsx
Normal file
140
frontend/src/components/sidebar.tsx
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import { Autocomplete, Box, Divider, Grid, InputLabel, MenuItem, Paper, Rating, Select, SelectChangeEvent, TextField, Typography } from "@mui/material";
|
||||||
|
import getCities from "../functions/getCities";
|
||||||
|
import FilterState from "../models/filterState";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import NumberField from "./numberField";
|
||||||
|
import getRatingsProviders from "../functions/getRatingsProviders";
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
filters : FilterState;
|
||||||
|
onChange : (filters : FilterState) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function capitalizeFirstLetter(val : string) {
|
||||||
|
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Sidebar(props : SidebarProps) {
|
||||||
|
const [cities, setCities] = useState<string[]>([]);
|
||||||
|
const [ratingsProviders, setRatingsProviders] = useState<string[]>(['Solicitors.com']);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (cities.length === 0) {
|
||||||
|
getCities().then((results) => setCities(results));
|
||||||
|
}
|
||||||
|
}, [cities.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (cities.length === 0) {
|
||||||
|
getRatingsProviders().then((results) => setRatingsProviders(results));
|
||||||
|
}
|
||||||
|
}, [cities.length]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper sx={{margin: '8px', padding: '8px'}}>
|
||||||
|
<Box sx={{p: 2}}>
|
||||||
|
<Typography variant='h5' component='h2'>Filter Conveyancers</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ p: 2}}>
|
||||||
|
<Typography component='legend'>Cities</Typography>
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
options={cities}
|
||||||
|
value={props.filters.cities}
|
||||||
|
getOptionLabel={capitalizeFirstLetter}
|
||||||
|
onChange={(_, value) => props.onChange(Object.assign({}, {...props.filters}, {cities: value}))}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
variant="standard"
|
||||||
|
label="Cities"
|
||||||
|
placeholder=""
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Divider />
|
||||||
|
<Box sx={{p: 2}}>
|
||||||
|
<Grid container spacing={1} sx={{width: '100%'}}>
|
||||||
|
<Grid size={6}>
|
||||||
|
<Typography component='legend'>Min. Rating</Typography>
|
||||||
|
<Rating
|
||||||
|
name='Min. Rating'
|
||||||
|
precision={0.5}
|
||||||
|
value={props.filters.minRating}
|
||||||
|
size='medium'
|
||||||
|
onChange={(_, value) => props.onChange(Object.assign({}, {...props.filters}, {minRating: value}))}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={6}>
|
||||||
|
<InputLabel id='ratings-label'>Rating Provider</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId='ratings-label'
|
||||||
|
value={props.filters.ratingsProvider}
|
||||||
|
onChange={(e : SelectChangeEvent) => props.onChange(Object.assign({}, {...props.filters}, {ratingsProvider: (e.target as any).value}))}
|
||||||
|
>
|
||||||
|
{ratingsProviders.map(provider => {
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
value={provider}
|
||||||
|
selected={provider === props.filters.ratingsProvider}>{capitalizeFirstLetter(provider)}
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Select>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
<Divider />
|
||||||
|
<Box sx={{p: 2}}>
|
||||||
|
<Typography component='legend'>Results per Page</Typography>
|
||||||
|
<NumberField
|
||||||
|
label=""
|
||||||
|
value={props.filters.resultsPerPage}
|
||||||
|
onValueChange={value => props.onChange(Object.assign({}, {...props.filters}, {resultsPerPage: value, currentPage: 1}))}
|
||||||
|
min={10}
|
||||||
|
max={40}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Divider />
|
||||||
|
<Box sx={{p: 2}}>
|
||||||
|
<InputLabel id='sort-label'>Sort By</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId='sort-label'
|
||||||
|
value={props.filters.sortBy}
|
||||||
|
onChange={(e : SelectChangeEvent) => props.onChange(Object.assign({}, {...props.filters}, {sortBy: (e.target as any).value}))}
|
||||||
|
>
|
||||||
|
{sortingOptions.map(option => {
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
value={option.value}
|
||||||
|
selected={option.value === props.filters.sortBy}
|
||||||
|
>
|
||||||
|
{option.name}
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Select>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortingOptions = [
|
||||||
|
{
|
||||||
|
value: 'rating-desc',
|
||||||
|
name: 'Rating (descending)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'rating-asc',
|
||||||
|
name: 'Rating (ascending)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'alphabet-asc',
|
||||||
|
name: 'Alphabetical (ascending)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'alphabet-desc',
|
||||||
|
name: 'Alphabetical (descending)'
|
||||||
|
}
|
||||||
|
]
|
||||||
7
frontend/src/functions/axios.ts
Normal file
7
frontend/src/functions/axios.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_URL
|
||||||
|
});
|
||||||
|
|
||||||
|
export default api;
|
||||||
6
frontend/src/functions/getCities.ts
Normal file
6
frontend/src/functions/getCities.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import api from "./axios";
|
||||||
|
|
||||||
|
export default async function GetCiries() {
|
||||||
|
const response = await api.get('/cities');
|
||||||
|
return response.data as string[];
|
||||||
|
}
|
||||||
6
frontend/src/functions/getRatingsProviders.ts
Normal file
6
frontend/src/functions/getRatingsProviders.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import api from "./axios";
|
||||||
|
|
||||||
|
export default async function GetCiries() {
|
||||||
|
const response = await api.get('/ratingsProviders');
|
||||||
|
return response.data as string[];
|
||||||
|
}
|
||||||
7
frontend/src/functions/getSolicitor.ts
Normal file
7
frontend/src/functions/getSolicitor.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { SolicitorInfo } from "../models/solicitorSummary";
|
||||||
|
import api from "./axios";
|
||||||
|
|
||||||
|
export default async function GetSolicitor(id : string) : Promise<SolicitorInfo | undefined> {
|
||||||
|
const response = await api.get(`/conveyancors/${id}`);
|
||||||
|
return response.data as SolicitorInfo;
|
||||||
|
}
|
||||||
22
frontend/src/functions/getSolicitors.ts
Normal file
22
frontend/src/functions/getSolicitors.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import SolicitorSummary from "../models/solicitorSummary";
|
||||||
|
import PaginationResponse from "../models/paginationResponse";
|
||||||
|
import FilterState from "../models/filterState";
|
||||||
|
import api from "./axios";
|
||||||
|
|
||||||
|
export default async function GetSolicitors(filters : FilterState) {
|
||||||
|
console.log(api.defaults.baseURL);
|
||||||
|
var minRating = filters.minRating;
|
||||||
|
if (minRating == null) {
|
||||||
|
minRating = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let queryString = `pageSize=${filters.resultsPerPage}&pageNumber=${filters.currentPage}&ratingsProvider=${filters.ratingsProvider}&minRating=${minRating}`;
|
||||||
|
if (filters.cities.length > 0) {
|
||||||
|
queryString += '&cities=' + filters.cities.join('&cities=');
|
||||||
|
}
|
||||||
|
if (filters.sortBy != null) {
|
||||||
|
queryString += `&orderingType=${filters.sortBy}`;
|
||||||
|
}
|
||||||
|
const response = await api.get(`/conveyancors?${queryString}`);
|
||||||
|
return response.data as PaginationResponse<SolicitorSummary>;
|
||||||
|
}
|
||||||
129
frontend/src/index.tsx
Normal file
129
frontend/src/index.tsx
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import { render } from 'preact';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import AppBar from '@mui/material/AppBar';
|
||||||
|
import Toolbar from '@mui/material/Toolbar';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import Container from '@mui/material/Container';
|
||||||
|
import { useState } from 'preact/compat';
|
||||||
|
import { createTheme, Theme, ThemeProvider } from '@mui/material/styles';
|
||||||
|
import DarkMode from '@mui/icons-material/DarkMode';
|
||||||
|
import LightMode from '@mui/icons-material/LightMode';
|
||||||
|
import { CssBaseline, Tooltip } from '@mui/material';
|
||||||
|
import Header, { ThemeWrapper } from './components/header';
|
||||||
|
import Results from './components/results';
|
||||||
|
|
||||||
|
function prefersDarkMode() {
|
||||||
|
const darkModeMql = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
return darkModeMql && darkModeMql.matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
const darkTheme = createTheme({
|
||||||
|
palette: {
|
||||||
|
mode: 'dark',
|
||||||
|
primary: {
|
||||||
|
light: '#B79AD5',
|
||||||
|
main: '#A27EC9',
|
||||||
|
dark: '#8D61BD'
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
light: '#70DBFF',
|
||||||
|
main: '#47D1FF',
|
||||||
|
dark: '#1FC7FF'
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
light: '#E73936',
|
||||||
|
main: '#DD1C1A',
|
||||||
|
dark: '#B71815'
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
light: '#FA804C',
|
||||||
|
main: '#F9611F',
|
||||||
|
dark: '#EF4C06'
|
||||||
|
},
|
||||||
|
info : {
|
||||||
|
light: '#FFEB99',
|
||||||
|
main: '#FFE066',
|
||||||
|
dark: '#FFDA47'
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
light: '#6ADC98',
|
||||||
|
main: '#48D480',
|
||||||
|
dark: '#2FC66B'
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
default: '#1D2535',
|
||||||
|
paper: '#242E42'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const lightTheme = createTheme({
|
||||||
|
palette: {
|
||||||
|
mode: 'light',
|
||||||
|
primary: {
|
||||||
|
light: '#B79AD5',
|
||||||
|
main: '#A27EC9',
|
||||||
|
dark: '#8D61BD'
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
light: '#70DBFF',
|
||||||
|
main: '#47D1FF',
|
||||||
|
dark: '#1FC7FF'
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
light: '#E73936',
|
||||||
|
main: '#DD1C1A',
|
||||||
|
dark: '#B71815'
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
light: '#FA804C',
|
||||||
|
main: '#F9611F',
|
||||||
|
dark: '#EF4C06'
|
||||||
|
},
|
||||||
|
info : {
|
||||||
|
light: '#FFEB99',
|
||||||
|
main: '#FFE066',
|
||||||
|
dark: '#FFDA47'
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
light: '#6ADC98',
|
||||||
|
main: '#48D480',
|
||||||
|
dark: '#2FC66B'
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
default: '#EFFFFF',
|
||||||
|
paper: '#e8f1f1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [value, setValue] = useState(0);
|
||||||
|
const [theme, setTheme] = useState<ThemeWrapper>({
|
||||||
|
theme : prefersDarkMode() ? darkTheme : lightTheme,
|
||||||
|
value : prefersDarkMode() ? 'dark' : 'light'
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleChange = (event: any, newValue: number) => {
|
||||||
|
setValue(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
setTheme(theme.value == 'dark'
|
||||||
|
? { theme : lightTheme, value : 'light' }
|
||||||
|
: { theme : darkTheme, value : 'dark' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProvider theme={theme.theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<Header theme={theme} toggleTheme={toggleTheme} />
|
||||||
|
<Container maxWidth="lg" sx={{marginTop: '10px'}}>
|
||||||
|
<Results />
|
||||||
|
</Container>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<App />, document.getElementById('app'));
|
||||||
8
frontend/src/models/filterState.ts
Normal file
8
frontend/src/models/filterState.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export default interface FilterState {
|
||||||
|
cities : string[];
|
||||||
|
minRating : number;
|
||||||
|
resultsPerPage : number;
|
||||||
|
currentPage : number;
|
||||||
|
ratingsProvider : string;
|
||||||
|
sortBy : string;
|
||||||
|
};
|
||||||
4
frontend/src/models/paginationResponse.ts
Normal file
4
frontend/src/models/paginationResponse.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export default interface PaginationResponse<T> {
|
||||||
|
data : T[];
|
||||||
|
total : number;
|
||||||
|
}
|
||||||
25
frontend/src/models/results.ts
Normal file
25
frontend/src/models/results.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import PaginationResponse from "./paginationResponse";
|
||||||
|
import SolicitorSummary from "./solicitorSummary";
|
||||||
|
|
||||||
|
type Results = SuccessResults | ErrorResults | WaitingResults;
|
||||||
|
|
||||||
|
interface SuccessResults {
|
||||||
|
type : 'Success',
|
||||||
|
data : PaginationResponse<SolicitorSummary>
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ErrorResults {
|
||||||
|
type : 'Error' | 'Warning',
|
||||||
|
message : string
|
||||||
|
};
|
||||||
|
|
||||||
|
interface WaitingResults {
|
||||||
|
type : 'Waiting'
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitingResults : WaitingResults = {
|
||||||
|
type : 'Waiting'
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Results;
|
||||||
|
export {waitingResults};
|
||||||
30
frontend/src/models/solicitorSummary.ts
Normal file
30
frontend/src/models/solicitorSummary.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
export default interface SolicitorSummary {
|
||||||
|
name : string,
|
||||||
|
shortDescription : string | null,
|
||||||
|
id : string,
|
||||||
|
rating : Rating
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Rating {
|
||||||
|
value : number,
|
||||||
|
maximum : number,
|
||||||
|
provider : string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Location {
|
||||||
|
address : string,
|
||||||
|
phone : string,
|
||||||
|
ratings : Rating[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SolicitorInfo {
|
||||||
|
name : string,
|
||||||
|
shortDescription : string | null,
|
||||||
|
id : string,
|
||||||
|
phone? : string,
|
||||||
|
email? : string,
|
||||||
|
website? : string,
|
||||||
|
|
||||||
|
ratings : Rating[],
|
||||||
|
locations : Location[]
|
||||||
|
}
|
||||||
20
frontend/tsconfig.json
Normal file
20
frontend/tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"noEmit": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
|
||||||
|
/* Preact Config */
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "preact",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"paths": {
|
||||||
|
"react": ["./node_modules/preact/compat/"],
|
||||||
|
"react-dom": ["./node_modules/preact/compat/"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["node_modules/vite/client.d.ts", "**/*"]
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user