initial commit

This commit is contained in:
fox
2026-06-23 19:03:31 +01:00
commit 689276ea7d
102 changed files with 8740 additions and 0 deletions
+6
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File
@@ -0,0 +1,7 @@
import axios from 'axios';
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL
});
export default api;
+6
View 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[];
}
@@ -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
View 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
View 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
View 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
View File
@@ -0,0 +1,8 @@
export default interface FilterState {
cities : string[];
minRating : number;
resultsPerPage : number;
currentPage : number;
ratingsProvider : string;
sortBy : string;
};
@@ -0,0 +1,4 @@
export default interface PaginationResponse<T> {
data : T[];
total : number;
}
+25
View 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
View 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[]
}