initial commit

This commit is contained in:
fox
2026-06-24 14:25:32 +01:00
commit 9788479ae7
109 changed files with 8666 additions and 0 deletions
+24
View 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?
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
approvedGitRepositories:
- "**"
enableScripts: true
nodeLinker: node-modules
npmMinimalAgeGate: 0
+15
View 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
View 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
View 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
View 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
View 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
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

+138
View File
@@ -0,0 +1,138 @@
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";
import capitaliseFirstLetter from "../functions/capitaliseFirstLetter";
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) => {
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 => 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'}) => {
if (props.data === 'loading') {
return (
<Box sx={style}>
Waiting...
</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'>{capitaliseFirstLetter(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'>{capitaliseFirstLetter(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>
);
}
+44
View File
@@ -0,0 +1,44 @@
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>
{props.rating &&
<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" />}
</>
);
}
+46
View File
@@ -0,0 +1,46 @@
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: 'RatingDescending',
namePart: '',
nameOnly: false});
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>;
}
}
+159
View File
@@ -0,0 +1,159 @@
import { Autocomplete, Box, Divider, Grid, InputLabel, MenuItem, Paper, Rating, Select, SelectChangeEvent, Switch, TextField, ToggleButton, 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";
import capitaliseFirstLetter from "../functions/capitaliseFirstLetter";
interface SidebarProps {
filters : FilterState;
onChange : (filters : FilterState) => void;
}
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'>Firm Name</Typography>
<TextField
label='Firm Name'
variant='standard'
sx={{width: '100%'}}
value={props.filters.namePart}
onChange={e => props.onChange(Object.assign({}, {...props.filters}, {namePart: (e.target as any).value, currentPage: 1}))}
/>
</Box>
<Divider />
<Box sx={{ p: 2}}>
<Typography component='legend'>Search by Firm Name Only</Typography>
<Switch
value={props.filters.nameOnly}
onChange={(_, value) => props.onChange(Object.assign({}, {...props.filters}, {nameOnly: value, currentPage: 1}))}
/>
</Box>
<Divider />
{!props.filters.nameOnly &&
<>
<Box sx={{ p: 2}}>
<Typography component='legend'>Cities</Typography>
<Autocomplete
multiple
options={cities}
value={props.filters.cities}
getOptionLabel={capitaliseFirstLetter}
onChange={(_, value) => props.onChange(Object.assign({}, {...props.filters}, {cities: value, currentPage: 1}))}
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, currentPage: 1}))}
/>
</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, currentPage: 1}))}
>
{ratingsProviders.map(provider => {
return (
<MenuItem
value={provider}
selected={provider === props.filters.ratingsProvider}>{capitaliseFirstLetter(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, currentPage: 1}))}
>
{sortingOptions.map(option => {
return (
<MenuItem
value={option.value}
selected={option.value === props.filters.sortBy}
>
{option.name}
</MenuItem>
)
})}
</Select>
</Box>
</Paper>
)
}
const sortingOptions = [
{
value: 'RatingDescending',
name: 'Rating (descending)'
},
{
value: 'RatingAscending',
name: 'Rating (ascending)'
},
{
value: 'AlphabetAscending',
name: 'Alphabetical (ascending)'
},
{
value: 'AlphabetDescending',
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;
@@ -0,0 +1,3 @@
export default function capitaliseFirstLetter(val : string) {
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
}
+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(`/solicitors/${id}`);
return response.data as SolicitorInfo;
}
+32
View File
@@ -0,0 +1,32 @@
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) {
let queryString = `pageSize=${filters.resultsPerPage}&pageNumber=${filters.currentPage}`;
if (filters.namePart != null && filters.namePart !== '') {
queryString += `&nameFilter=${filters.namePart}`;
}
if (!filters.nameOnly) {
var minRating = filters.minRating;
if (minRating == null) {
minRating = 0;
}
queryString += `&minRating=${minRating}&ratingsProvider=${filters.ratingsProvider}`;
if (filters.cities.length > 0) {
queryString += '&cities=' + filters.cities.join('&cities=');
}
}
if (filters.sortBy != null) {
queryString += `&orderingType=${filters.sortBy}`;
}
const response = await api.get(`/solicitors?${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'));
+10
View File
@@ -0,0 +1,10 @@
export default interface FilterState {
cities : string[];
minRating : number;
resultsPerPage : number;
currentPage : number;
ratingsProvider : string;
sortBy : string;
namePart : string;
nameOnly : boolean;
};
@@ -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 | null
};
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
View 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", "**/*"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite';
import preact from '@preact/preset-vite';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [preact()],
});
+4693
View File
File diff suppressed because it is too large Load Diff