initial commit
This commit is contained in:
@@ -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.
@@ -0,0 +1,8 @@
|
||||
approvedGitRepositories:
|
||||
- "**"
|
||||
|
||||
enableScripts: true
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
npmMinimalAgeGate: 0
|
||||
@@ -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/
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
@@ -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)'
|
||||
}
|
||||
]
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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'));
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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", "**/*"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import preact from '@preact/preset-vite';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [preact()],
|
||||
});
|
||||
+4693
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user