Compare commits
5 Commits
main
...
add-langua
Author | SHA1 | Date |
---|---|---|
Joshua Potter | c7515b2981 | |
Joshua Potter | 1c1f8f6fb0 | |
Joshua Potter | a61477a5fe | |
Joshua Potter | 283980176e | |
Joshua Potter | ba5070be04 |
|
@ -1,37 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import clsx from "clsx"
|
|
||||||
|
|
||||||
type BorderProps<T extends React.ElementType> = {
|
|
||||||
as?: T
|
|
||||||
className?: string
|
|
||||||
position?: "top" | "left"
|
|
||||||
invert?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Border<T extends React.ElementType = "div">({
|
|
||||||
as,
|
|
||||||
className,
|
|
||||||
position = "top",
|
|
||||||
invert = false,
|
|
||||||
...props
|
|
||||||
}: Omit<React.ComponentPropsWithoutRef<T>, keyof BorderProps<T>> &
|
|
||||||
BorderProps<T>) {
|
|
||||||
let Component = as ?? "div"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Component
|
|
||||||
className={clsx(
|
|
||||||
"relative before:absolute after:absolute",
|
|
||||||
invert
|
|
||||||
? "before:bg-white after:bg-white/10"
|
|
||||||
: "before:bg-neutral-950 after:bg-neutral-950/10",
|
|
||||||
position === "top" &&
|
|
||||||
"before:left-0 before:top-0 before:h-px before:w-6 after:left-8 after:right-0 after:top-0 after:h-px",
|
|
||||||
position === "left" &&
|
|
||||||
"before:left-0 before:top-0 before:h-6 before:w-px after:bottom-0 after:left-0 after:top-8 after:w-px",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,48 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import clsx from "clsx"
|
|
||||||
import {
|
|
||||||
Input as BaseInput,
|
|
||||||
InputOwnerState,
|
|
||||||
InputProps,
|
|
||||||
MultiLineInputProps,
|
|
||||||
} from "@mui/base/Input"
|
|
||||||
|
|
||||||
import { FieldContext } from "./FieldSet"
|
|
||||||
import { resolveSlotProps } from "../utils/props"
|
|
||||||
|
|
||||||
export type CheckBoxProps = Omit<InputProps, keyof MultiLineInputProps>
|
|
||||||
|
|
||||||
export const CheckBox = React.forwardRef<HTMLInputElement, CheckBoxProps>(
|
|
||||||
function CheckBox(
|
|
||||||
props: CheckBoxProps,
|
|
||||||
ref: React.ForwardedRef<HTMLInputElement>
|
|
||||||
) {
|
|
||||||
const fieldContext = React.useContext(FieldContext)
|
|
||||||
const { disabled = fieldContext?.disabled, className, ...other } = props
|
|
||||||
|
|
||||||
const inputSlotProps = (ownerState: InputOwnerState) => {
|
|
||||||
const resolved = resolveSlotProps(props.slotProps?.input, ownerState)
|
|
||||||
return {
|
|
||||||
...resolved,
|
|
||||||
className: clsx(
|
|
||||||
"w-5 h-5 accent-black cursor-pointer -translate-y-[3px]",
|
|
||||||
resolved?.className
|
|
||||||
),
|
|
||||||
style: {
|
|
||||||
color: "black",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BaseInput
|
|
||||||
ref={ref}
|
|
||||||
{...other}
|
|
||||||
type="checkbox"
|
|
||||||
className={clsx("h-5 w-5", { "opacity-60": disabled }, className)}
|
|
||||||
slotProps={{ input: inputSlotProps }}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
|
@ -1,8 +1,10 @@
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { MotionProps, motion, useReducedMotion } from "framer-motion"
|
import { motion, useReducedMotion } from "framer-motion"
|
||||||
|
|
||||||
const FadeInStaggerContext = React.createContext(false)
|
const FadeInStaggerContext = React.createContext(false)
|
||||||
|
|
||||||
|
const viewport = { once: true, margin: "0px 0px -200px" }
|
||||||
|
|
||||||
export function FadeIn({ ...props }) {
|
export function FadeIn({ ...props }) {
|
||||||
let shouldReduceMotion = useReducedMotion()
|
let shouldReduceMotion = useReducedMotion()
|
||||||
let isInStaggerGroup = React.useContext(FadeInStaggerContext)
|
let isInStaggerGroup = React.useContext(FadeInStaggerContext)
|
||||||
|
@ -19,42 +21,23 @@ export function FadeIn({ ...props }) {
|
||||||
: {
|
: {
|
||||||
initial: "hidden",
|
initial: "hidden",
|
||||||
whileInView: "visible",
|
whileInView: "visible",
|
||||||
viewport: {
|
viewport,
|
||||||
once: true,
|
|
||||||
margin: "0px 0px -120px",
|
|
||||||
},
|
|
||||||
})}
|
})}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FadeInStaggerProps = {
|
export function FadeInStagger({ faster = false, ...props }) {
|
||||||
faster?: boolean
|
|
||||||
className?: string
|
|
||||||
} & MotionProps
|
|
||||||
|
|
||||||
export const FadeInStagger = React.forwardRef(function FadeInStagger(
|
|
||||||
props: FadeInStaggerProps,
|
|
||||||
ref: React.ForwardedRef<HTMLDivElement>
|
|
||||||
) {
|
|
||||||
const { faster = false, ...other } = props
|
|
||||||
|
|
||||||
// Consider dropping framer-motion:
|
|
||||||
// https://github.com/framer/motion/issues/776
|
|
||||||
return (
|
return (
|
||||||
<FadeInStaggerContext.Provider value={true}>
|
<FadeInStaggerContext.Provider value={true}>
|
||||||
<motion.div
|
<motion.div
|
||||||
ref={ref}
|
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
whileInView="visible"
|
whileInView="visible"
|
||||||
viewport={{
|
viewport={viewport}
|
||||||
once: true,
|
|
||||||
margin: "0px 0px -200px",
|
|
||||||
}}
|
|
||||||
transition={{ staggerChildren: faster ? 0.12 : 0.2 }}
|
transition={{ staggerChildren: faster ? 0.12 : 0.2 }}
|
||||||
{...other}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</FadeInStaggerContext.Provider>
|
</FadeInStaggerContext.Provider>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
|
|
@ -3,10 +3,10 @@ import clsx from "clsx"
|
||||||
|
|
||||||
import { FadeIn } from "./FadeIn"
|
import { FadeIn } from "./FadeIn"
|
||||||
|
|
||||||
export type FallbackMessageProps = {
|
export type FallbackMessageProps = React.ComponentPropsWithoutRef<"div"> & {
|
||||||
title: string
|
title: string
|
||||||
body: string
|
body: string
|
||||||
} & React.ComponentPropsWithoutRef<"div">
|
}
|
||||||
|
|
||||||
export const FallbackMessage = React.forwardRef(function FallbackMessage(
|
export const FallbackMessage = React.forwardRef(function FallbackMessage(
|
||||||
props: FallbackMessageProps,
|
props: FallbackMessageProps,
|
||||||
|
|
|
@ -0,0 +1,171 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { Controller, useForm } from "react-hook-form"
|
||||||
|
|
||||||
|
import { Button } from "./Button"
|
||||||
|
import { Field } from "./FieldSet"
|
||||||
|
import { Input } from "./Input"
|
||||||
|
import { Label } from "./Label"
|
||||||
|
import { Modal } from "./Modal"
|
||||||
|
import { SelectLanguage, SelectLanguageProps } from "./SelectLanguage"
|
||||||
|
import { Slider } from "./Slider"
|
||||||
|
|
||||||
|
import {
|
||||||
|
FIDE_RATING_MIN,
|
||||||
|
FIDE_RATING_MAX,
|
||||||
|
SearchParams,
|
||||||
|
} from "../types/SearchParams"
|
||||||
|
|
||||||
|
const computeStepLabels = (
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
// The number of labels (+ 1) that should be produced.
|
||||||
|
steps: number,
|
||||||
|
// To which value numbers should be rounded to.
|
||||||
|
round: number
|
||||||
|
) => {
|
||||||
|
let labels = []
|
||||||
|
const delta = Math.floor((max - min) / steps)
|
||||||
|
for (let i = min; i <= max; i += delta) {
|
||||||
|
if (i % round <= round / 2) {
|
||||||
|
labels.push(i - (i % round))
|
||||||
|
} else {
|
||||||
|
labels.push(i + round - (i % round))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
labels[labels.length - 1] = max
|
||||||
|
|
||||||
|
return labels
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterModalProps {
|
||||||
|
open: boolean
|
||||||
|
defaultValues: SearchParams
|
||||||
|
onClose: () => void
|
||||||
|
onSubmit: (p: SearchParams) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterModal({
|
||||||
|
open,
|
||||||
|
defaultValues,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: FilterModalProps) {
|
||||||
|
const idPrefix = React.useId()
|
||||||
|
|
||||||
|
const { watch, reset, control, register, setValue, handleSubmit } =
|
||||||
|
useForm<SearchParams>({ defaultValues })
|
||||||
|
|
||||||
|
// Default values are processed immediately despite the modal not being open
|
||||||
|
// at the start. Furthermore, values are preserved after closing and
|
||||||
|
// re-opening the modal, but we want closing the modal to signify canceling.
|
||||||
|
// A simple workaround is to reset everytime we open the modal.
|
||||||
|
React.useEffect(() => reset(defaultValues), [open])
|
||||||
|
|
||||||
|
// Registration
|
||||||
|
|
||||||
|
const proxyLanguages = register("languages")
|
||||||
|
const registerLanguages: Pick<
|
||||||
|
SelectLanguageProps,
|
||||||
|
"defaultValue" | "onChange"
|
||||||
|
> = {
|
||||||
|
...proxyLanguages,
|
||||||
|
defaultValue: defaultValues.languages,
|
||||||
|
onChange: (event, value) => {
|
||||||
|
event && proxyLanguages.onChange(event)
|
||||||
|
setValue("languages", (value ?? []) as string[])
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const controlFIDERating = register("fideRating")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
closeAfterTransition
|
||||||
|
frame={{
|
||||||
|
as: "form",
|
||||||
|
title: "Filters",
|
||||||
|
footer: (
|
||||||
|
<Button className="float-right py-2" type="submit">
|
||||||
|
Search coaches
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
onSubmit: handleSubmit(onSubmit),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-12">
|
||||||
|
<Field>
|
||||||
|
<Label htmlFor={`${idPrefix}-languages`}>
|
||||||
|
Preferred Language(s):
|
||||||
|
</Label>
|
||||||
|
<p className="py-2 text-sm">
|
||||||
|
Select languages you prefer communicating in. We{"'"}ll prioritize
|
||||||
|
finding coaches that can speak fluently in at least one of your
|
||||||
|
selections.
|
||||||
|
</p>
|
||||||
|
<SelectLanguage
|
||||||
|
id={`${idPrefix}-languages`}
|
||||||
|
slotProps={{
|
||||||
|
root: { className: "w-full" },
|
||||||
|
}}
|
||||||
|
{...registerLanguages}
|
||||||
|
multiple
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<Label htmlFor={`${idPrefix}-fideRating`}>FIDE Rating:</Label>
|
||||||
|
<p className="py-2 text-sm">
|
||||||
|
Find coaches that have a rating within the specified range. Keep in
|
||||||
|
mind, a higher rating does not necessarily mean a better coach{" "}
|
||||||
|
<i>for you</i>. If you are unsure of this or do not have any
|
||||||
|
preference, leave as is.
|
||||||
|
</p>
|
||||||
|
<div id={`${idPrefix}-fideRating`} className="mt-2 w-full px-4">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={controlFIDERating.name}
|
||||||
|
render={({ field: { onChange, onBlur, value, ref } }) => (
|
||||||
|
<Slider
|
||||||
|
ref={ref}
|
||||||
|
value={value}
|
||||||
|
onBlur={onBlur}
|
||||||
|
onChange={(event, newValue: any) => {
|
||||||
|
event && onChange(event)
|
||||||
|
setValue("fideRating.0", newValue[0])
|
||||||
|
setValue("fideRating.1", newValue[1])
|
||||||
|
}}
|
||||||
|
step={10}
|
||||||
|
min={FIDE_RATING_MIN}
|
||||||
|
max={FIDE_RATING_MAX}
|
||||||
|
marks={computeStepLabels(
|
||||||
|
FIDE_RATING_MIN,
|
||||||
|
FIDE_RATING_MAX,
|
||||||
|
7,
|
||||||
|
50
|
||||||
|
).map((s) => ({ value: s, label: `${s}` }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="mt-16 flex flex-wrap items-center justify-center gap-x-20 gap-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-neutral-850 text-sm font-medium">
|
||||||
|
Min:
|
||||||
|
</label>
|
||||||
|
<Input value={watch("fideRating.0")} disabled />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-neutral-850 text-sm font-medium">
|
||||||
|
Max:
|
||||||
|
</label>
|
||||||
|
<Input value={watch("fideRating.1")} disabled />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
|
@ -3,46 +3,28 @@ import clsx from "clsx"
|
||||||
|
|
||||||
import type { SearchParams } from "../types/SearchParams"
|
import type { SearchParams } from "../types/SearchParams"
|
||||||
|
|
||||||
import BulletIcon from "../icons/Bullet"
|
import FilterIcon from "../icons/Filter"
|
||||||
import EnglishIcon from "../icons/English"
|
import EnglishIcon from "../icons/English"
|
||||||
import SortIcon from "../icons/Sort"
|
|
||||||
import KnightIcon from "../icons/Knight"
|
|
||||||
import LightningIcon from "../icons/Lightning"
|
|
||||||
import PawnIcon from "../icons/Pawn"
|
|
||||||
import RabbitIcon from "../icons/Rabbit"
|
|
||||||
import RightArrowIcon from "../icons/RightArrow"
|
import RightArrowIcon from "../icons/RightArrow"
|
||||||
import RisingGraphIcon from "../icons/RisingGraph"
|
import RisingGraphIcon from "../icons/RisingGraph"
|
||||||
import TrophyIcon from "../icons/Trophy"
|
|
||||||
import { Button } from "./Button"
|
import { Button } from "./Button"
|
||||||
import { Mode } from "../types/Mode"
|
|
||||||
import { Site } from "../types/Site"
|
|
||||||
import { Title } from "../types/Title"
|
|
||||||
|
|
||||||
interface SortOption {
|
interface FilterOption {
|
||||||
title: string
|
title: string
|
||||||
Icon: ({ ...props }: { [x: string]: any }) => React.JSX.Element
|
Icon: ({ ...props }: { [x: string]: any }) => React.JSX.Element
|
||||||
enable: (p: SearchParams) => SearchParams
|
enable: (p: SearchParams) => SearchParams
|
||||||
isEnabled: (p: SearchParams) => boolean
|
isEnabled: (p: SearchParams) => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const filters: SortOption[] = [
|
const filters: FilterOption[] = [
|
||||||
{
|
{
|
||||||
title: "On Lichess",
|
title: "FIDE 2000+",
|
||||||
Icon: KnightIcon,
|
Icon: RisingGraphIcon,
|
||||||
enable: (p) => {
|
enable: (p) => {
|
||||||
p.sites.push(Site.LICHESS)
|
p.fideRating[0] = Math.max(2000, p.fideRating[0])
|
||||||
return p
|
return p
|
||||||
},
|
},
|
||||||
isEnabled: (p) => p.sites.includes(Site.LICHESS),
|
isEnabled: (p) => p.fideRating[0] >= 2000,
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "On Chess.com",
|
|
||||||
Icon: PawnIcon,
|
|
||||||
enable: (p) => {
|
|
||||||
p.sites.push(Site.CHESSCOM)
|
|
||||||
return p
|
|
||||||
},
|
|
||||||
isEnabled: (p) => p.sites.includes(Site.CHESSCOM),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "English Speaking",
|
title: "English Speaking",
|
||||||
|
@ -60,51 +42,6 @@ const filters: SortOption[] = [
|
||||||
isEnabled: (p) =>
|
isEnabled: (p) =>
|
||||||
p.languages.includes("en-US") || p.languages.includes("en-GB"),
|
p.languages.includes("en-US") || p.languages.includes("en-GB"),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "ELO 2000+",
|
|
||||||
Icon: RisingGraphIcon,
|
|
||||||
enable: (p) => {
|
|
||||||
p.rating[0] = Math.max(2000, p.rating[0])
|
|
||||||
return p
|
|
||||||
},
|
|
||||||
isEnabled: (p) => p.rating[0] >= 2000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Rapid Specialty",
|
|
||||||
Icon: RabbitIcon,
|
|
||||||
enable: (p) => {
|
|
||||||
p.modes = [Mode.RAPID]
|
|
||||||
return p
|
|
||||||
},
|
|
||||||
isEnabled: (p) => p.modes.length === 1 && p.modes.includes(Mode.RAPID),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Blitz Specialty",
|
|
||||||
Icon: LightningIcon,
|
|
||||||
enable: (p) => {
|
|
||||||
p.modes = [Mode.BLITZ]
|
|
||||||
return p
|
|
||||||
},
|
|
||||||
isEnabled: (p) => p.modes.length === 1 && p.modes.includes(Mode.BLITZ),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Bullet Specialty",
|
|
||||||
Icon: BulletIcon,
|
|
||||||
enable: (p) => {
|
|
||||||
p.modes = [Mode.BULLET]
|
|
||||||
return p
|
|
||||||
},
|
|
||||||
isEnabled: (p) => p.modes.length === 1 && p.modes.includes(Mode.BULLET),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Titled Player",
|
|
||||||
Icon: TrophyIcon,
|
|
||||||
enable: (p) => {
|
|
||||||
p.titles = Object.keys(Title) as Title[]
|
|
||||||
return p
|
|
||||||
},
|
|
||||||
isEnabled: (p) => p.titles.length > 0,
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
enum Direction {
|
enum Direction {
|
||||||
|
@ -112,13 +49,13 @@ enum Direction {
|
||||||
RIGHT,
|
RIGHT,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SortScrollProps {
|
interface FilterScrollProps {
|
||||||
params: SearchParams
|
params: SearchParams
|
||||||
onModal: () => void
|
onModal: () => void
|
||||||
onSelect: (p: SearchParams) => void
|
onSelect: (p: SearchParams) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SortScroll({ params, onModal, onSelect }: SortScrollProps) {
|
export function FilterScroll({ params, onModal, onSelect }: FilterScrollProps) {
|
||||||
const viewport = React.useRef<HTMLDivElement>(null)
|
const viewport = React.useRef<HTMLDivElement>(null)
|
||||||
const [isFlush, setIsFlush] = React.useState([true, false])
|
const [isFlush, setIsFlush] = React.useState([true, false])
|
||||||
|
|
||||||
|
@ -138,7 +75,7 @@ export function SortScroll({ params, onModal, onSelect }: SortScrollProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-x-8">
|
<div className="flex items-center gap-x-8">
|
||||||
<div className="relative flex flex-grow overflow-hidden">
|
<div className="relative flex overflow-hidden">
|
||||||
<div
|
<div
|
||||||
ref={viewport}
|
ref={viewport}
|
||||||
className="flex items-center gap-x-12 overflow-hidden"
|
className="flex items-center gap-x-12 overflow-hidden"
|
||||||
|
@ -190,8 +127,8 @@ export function SortScroll({ params, onModal, onSelect }: SortScrollProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button className="flex gap-x-2 py-4" onClick={onModal}>
|
<Button className="flex gap-x-2 py-4" onClick={onModal}>
|
||||||
<SortIcon className="h-6 w-6 fill-white" />
|
<FilterIcon className="h-6 w-6 fill-white" />
|
||||||
<span className="font-display">Sort</span>
|
<span className="font-display">Filter</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
|
@ -1,60 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import clsx from "clsx"
|
|
||||||
|
|
||||||
import { Border } from "./Border"
|
|
||||||
|
|
||||||
export function GridList({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<ul
|
|
||||||
role="list"
|
|
||||||
className={clsx(
|
|
||||||
"grid grid-cols-1 gap-10 sm:grid-cols-2 lg:grid-cols-3",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ul>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GridListItem({
|
|
||||||
title,
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
invert = false,
|
|
||||||
}: {
|
|
||||||
title: string
|
|
||||||
children: React.ReactNode
|
|
||||||
className?: string
|
|
||||||
invert?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
className={clsx(
|
|
||||||
"text-base",
|
|
||||||
invert
|
|
||||||
? "text-neutral-300 before:bg-white after:bg-white/10"
|
|
||||||
: "text-neutral-600 before:bg-neutral-950 after:bg-neutral-100",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Border position="left" className="pl-8" invert={invert}>
|
|
||||||
<strong
|
|
||||||
className={clsx(
|
|
||||||
"font-semibold",
|
|
||||||
invert ? "text-white" : "text-neutral-950"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{title}.
|
|
||||||
</strong>{" "}
|
|
||||||
{children}
|
|
||||||
</Border>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,48 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import clsx from "clsx"
|
|
||||||
|
|
||||||
import { Container } from "./Container"
|
|
||||||
import { FadeIn } from "./FadeIn"
|
|
||||||
|
|
||||||
export function PageIntro({
|
|
||||||
eyebrow,
|
|
||||||
title,
|
|
||||||
children,
|
|
||||||
centered = false,
|
|
||||||
}: {
|
|
||||||
eyebrow: string
|
|
||||||
title: string | React.ReactNode
|
|
||||||
children: React.ReactNode
|
|
||||||
centered?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Container
|
|
||||||
className={clsx("mt-24 sm:mt-32 lg:mt-40", centered && "text-center")}
|
|
||||||
>
|
|
||||||
<FadeIn>
|
|
||||||
<h1>
|
|
||||||
<span className="block font-display text-base font-semibold text-neutral-950">
|
|
||||||
{eyebrow}
|
|
||||||
</span>
|
|
||||||
<span className="sr-only"> - </span>
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
"mt-6 block max-w-5xl font-display text-5xl font-medium tracking-tight text-neutral-950 [text-wrap:balance] sm:text-6xl",
|
|
||||||
centered && "mx-auto"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</span>
|
|
||||||
</h1>
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
"mt-6 max-w-3xl text-xl text-neutral-600",
|
|
||||||
centered && "mx-auto"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</FadeIn>
|
|
||||||
</Container>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -40,6 +40,8 @@ function Navigation() {
|
||||||
<nav className="mt-px font-display text-5xl font-medium tracking-tight text-white">
|
<nav className="mt-px font-display text-5xl font-medium tracking-tight text-white">
|
||||||
<NavigationRow>
|
<NavigationRow>
|
||||||
<NavigationItem href="/about/">About Us</NavigationItem>
|
<NavigationItem href="/about/">About Us</NavigationItem>
|
||||||
|
</NavigationRow>
|
||||||
|
<NavigationRow>
|
||||||
<NavigationItem href="/contact/">Contact Us</NavigationItem>
|
<NavigationItem href="/contact/">Contact Us</NavigationItem>
|
||||||
</NavigationRow>
|
</NavigationRow>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
@ -1,77 +1,41 @@
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import clsx from "clsx"
|
import clsx from "clsx"
|
||||||
|
|
||||||
import type { Coach } from "../types/Coach"
|
type SearchResultProps = {
|
||||||
|
title?: string
|
||||||
import PawnIcon from "../icons/Pawn"
|
subtitle?: string
|
||||||
import KnightIcon from "../icons/Knight"
|
src?: string
|
||||||
|
} & React.ComponentPropsWithoutRef<"div">
|
||||||
function getSiteIcon(coach: Coach) {
|
|
||||||
switch (coach.site) {
|
|
||||||
case "chesscom":
|
|
||||||
return ({ className, ...props }: { className?: string }) => (
|
|
||||||
<PawnIcon
|
|
||||||
className={clsx("stroke-black fill-lime-600", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
case "lichess":
|
|
||||||
return ({ className, ...props }: { className?: string }) => (
|
|
||||||
<KnightIcon
|
|
||||||
className={clsx("stroke-black fill-white", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getProfileUrl(coach: Coach) {
|
|
||||||
switch (coach.site) {
|
|
||||||
case "chesscom":
|
|
||||||
return `https://www.chess.com/member/${coach.username}`
|
|
||||||
case "lichess":
|
|
||||||
return `https://lichess.org/coach/${coach.username}`
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SearchResult({ coach }: { coach: Coach }) {
|
|
||||||
const profileUrl = getProfileUrl(coach)
|
|
||||||
const Component = profileUrl ? "a" : "div"
|
|
||||||
const Icon = getSiteIcon(coach)
|
|
||||||
|
|
||||||
|
export function SearchResult({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
src,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SearchResultProps) {
|
||||||
return (
|
return (
|
||||||
<Component
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"group relative h-96 overflow-hidden rounded-3xl bg-neutral-100",
|
"group relative h-96 overflow-hidden rounded-3xl bg-neutral-100",
|
||||||
{ "cursor-pointer": profileUrl }
|
className
|
||||||
)}
|
)}
|
||||||
href={profileUrl || undefined}
|
{...props}
|
||||||
target={profileUrl ? "_blank" : undefined}
|
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={coach.image_url ?? ""}
|
src={src}
|
||||||
className="h-full w-full object-cover transition duration-500 motion-safe:group-hover:scale-105"
|
className="h-full w-full object-cover transition duration-500 motion-safe:group-hover:scale-105"
|
||||||
/>
|
/>
|
||||||
{Icon && (
|
|
||||||
<div className="absolute -top-10 -right-10 pt-4 pr-4 flex flex-col justify-end items-start bg-radial-gradient/gray w-[5.2rem] h-[5.2rem]">
|
|
||||||
<Icon className="w-6 h-6 ml-2 mb-2" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="absolute inset-0 flex flex-col justify-end bg-gradient-to-t from-black to-black/0 to-40% p-6">
|
<div className="absolute inset-0 flex flex-col justify-end bg-gradient-to-t from-black to-black/0 to-40% p-6">
|
||||||
{coach.name ? (
|
{title ? (
|
||||||
<p className="font-display text-base/6 font-semibold tracking-wide text-white">
|
<p className="font-display text-base/6 font-semibold tracking-wide text-white">
|
||||||
{coach.title ? (
|
{title}
|
||||||
<span className="font-bold pr-1">{coach.title}</span>
|
|
||||||
) : null}
|
|
||||||
{coach.name}
|
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<p className="mt-2 text-sm text-white">{coach.username}</p>
|
{subtitle ? (
|
||||||
|
<p className="mt-2 text-sm text-white">{subtitle}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Component>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,65 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import clsx from "clsx"
|
|
||||||
|
|
||||||
import { Container } from "./Container"
|
|
||||||
|
|
||||||
export function SectionIntro({
|
|
||||||
title,
|
|
||||||
eyebrow,
|
|
||||||
children,
|
|
||||||
smaller = false,
|
|
||||||
invert = false,
|
|
||||||
...props
|
|
||||||
}: Omit<
|
|
||||||
React.ComponentPropsWithoutRef<typeof Container>,
|
|
||||||
"title" | "children"
|
|
||||||
> & {
|
|
||||||
title: string
|
|
||||||
eyebrow?: string
|
|
||||||
children?: React.ReactNode
|
|
||||||
smaller?: boolean
|
|
||||||
invert?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Container {...props}>
|
|
||||||
<div className="max-w-2xl">
|
|
||||||
<h2>
|
|
||||||
{eyebrow && (
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
"mb-6 block font-display text-base font-semibold",
|
|
||||||
invert ? "text-white" : "text-neutral-950"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{eyebrow}
|
|
||||||
</span>
|
|
||||||
<span className="sr-only"> - </span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
"block font-display tracking-tight [text-wrap:balance]",
|
|
||||||
smaller
|
|
||||||
? "text-2xl font-semibold"
|
|
||||||
: "text-4xl font-medium sm:text-5xl",
|
|
||||||
invert ? "text-white" : "text-neutral-950"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</span>
|
|
||||||
</h2>
|
|
||||||
{children && (
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
"mt-6 text-xl",
|
|
||||||
invert ? "text-neutral-300" : "text-neutral-600"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -4,7 +4,7 @@ import { SelectProps } from "@mui/base/Select"
|
||||||
import type { Language } from "../types/Language"
|
import type { Language } from "../types/Language"
|
||||||
|
|
||||||
import { Select, Option } from "./Select"
|
import { Select, Option } from "./Select"
|
||||||
import { useLanguagesQuery } from "../utils/queries"
|
import { useFetchLanguages } from "../utils/queries"
|
||||||
|
|
||||||
export type SelectLanguageProps = SelectProps<{}, boolean>
|
export type SelectLanguageProps = SelectProps<{}, boolean>
|
||||||
|
|
||||||
|
@ -15,7 +15,7 @@ export const SelectLanguage = React.forwardRef(function SelectLanguage(
|
||||||
const id = React.useId()
|
const id = React.useId()
|
||||||
const [options, setOptions] = React.useState<Language[] | null>(null)
|
const [options, setOptions] = React.useState<Language[] | null>(null)
|
||||||
const { defaultValue, ...other } = props
|
const { defaultValue, ...other } = props
|
||||||
const { isLoading, data } = useLanguagesQuery()
|
const { isLoading, data } = useFetchLanguages()
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
|
|
|
@ -1,42 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import { SelectProps } from "@mui/base/Select"
|
|
||||||
|
|
||||||
import { Select, Option } from "./Select"
|
|
||||||
import { Title } from "../types/Title"
|
|
||||||
|
|
||||||
interface TitleOption {
|
|
||||||
value: Title | ""
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const options: TitleOption[] = [
|
|
||||||
{ value: Title.GM, label: "Grandmaster" },
|
|
||||||
{ value: Title.IM, label: "International Master" },
|
|
||||||
{ value: Title.FM, label: "FIDE Master" },
|
|
||||||
{ value: Title.CM, label: "Candidate Master" },
|
|
||||||
{ value: Title.NM, label: "National Master" },
|
|
||||||
{ value: Title.WGM, label: "Woman Grandmaster" },
|
|
||||||
{ value: Title.WIM, label: "Woman International Master" },
|
|
||||||
{ value: Title.WFM, label: "Woman FIDE Master" },
|
|
||||||
{ value: Title.WCM, label: "Woman Candidate Master" },
|
|
||||||
{ value: Title.WNM, label: "Woman National Master" },
|
|
||||||
]
|
|
||||||
|
|
||||||
export type SelectTitleProps = SelectProps<{}, boolean>
|
|
||||||
|
|
||||||
export const SelectTitle = React.forwardRef(function SelectTitle(
|
|
||||||
props: SelectTitleProps,
|
|
||||||
ref: React.ForwardedRef<HTMLButtonElement>
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<Select ref={ref} {...props}>
|
|
||||||
{options.map((entry, index) => {
|
|
||||||
return (
|
|
||||||
<Option key={index} value={entry.value}>
|
|
||||||
{entry.label}
|
|
||||||
</Option>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Select>
|
|
||||||
)
|
|
||||||
})
|
|
|
@ -1,246 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import { Controller, useForm } from "react-hook-form"
|
|
||||||
|
|
||||||
import { Button } from "./Button"
|
|
||||||
import { CheckBox } from "./CheckBox"
|
|
||||||
import { Field, FieldSet } from "./FieldSet"
|
|
||||||
import { Input } from "./Input"
|
|
||||||
import { Label } from "./Label"
|
|
||||||
import { Modal } from "./Modal"
|
|
||||||
import { Mode, getModeName } from "../types/Mode"
|
|
||||||
import { SelectLanguage, SelectLanguageProps } from "./SelectLanguage"
|
|
||||||
import { SelectTitle, SelectTitleProps } from "./SelectTitle"
|
|
||||||
import { Site, getSiteName } from "../types/Site"
|
|
||||||
import { Slider } from "./Slider"
|
|
||||||
import { Title } from "../types/Title"
|
|
||||||
import { RATING_MIN, RATING_MAX, SearchParams } from "../types/SearchParams"
|
|
||||||
|
|
||||||
const computeStepLabels = (
|
|
||||||
min: number,
|
|
||||||
max: number,
|
|
||||||
// The number of labels (+ 1) that should be produced.
|
|
||||||
steps: number,
|
|
||||||
// To which value numbers should be rounded to.
|
|
||||||
round: number
|
|
||||||
) => {
|
|
||||||
let labels = []
|
|
||||||
const delta = Math.floor((max - min) / steps)
|
|
||||||
for (let i = min; i <= max; i += delta) {
|
|
||||||
if (i % round <= round / 2) {
|
|
||||||
labels.push(i - (i % round))
|
|
||||||
} else {
|
|
||||||
labels.push(i + round - (i % round))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
labels[labels.length - 1] = max
|
|
||||||
|
|
||||||
return labels
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SortModalProps {
|
|
||||||
open: boolean
|
|
||||||
defaultValues: SearchParams
|
|
||||||
onClose: () => void
|
|
||||||
onSubmit: (p: SearchParams) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SortModal({
|
|
||||||
open,
|
|
||||||
defaultValues,
|
|
||||||
onClose,
|
|
||||||
onSubmit,
|
|
||||||
}: SortModalProps) {
|
|
||||||
const idPrefix = React.useId()
|
|
||||||
|
|
||||||
const {
|
|
||||||
watch,
|
|
||||||
reset,
|
|
||||||
control,
|
|
||||||
register,
|
|
||||||
setValue,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { errors },
|
|
||||||
} = useForm<SearchParams>({
|
|
||||||
mode: "onChange",
|
|
||||||
defaultValues,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Default values are processed immediately despite the modal not being open
|
|
||||||
// at the start. Furthermore, values are preserved after closing and
|
|
||||||
// re-opening the modal, but we want closing the modal to signify canceling.
|
|
||||||
// A simple workaround is to reset everytime we open the modal.
|
|
||||||
React.useEffect(() => reset(defaultValues), [open])
|
|
||||||
|
|
||||||
// Registration
|
|
||||||
|
|
||||||
const registerSites = register("sites", {
|
|
||||||
required: "Please select at least one site.",
|
|
||||||
})
|
|
||||||
|
|
||||||
const proxyLanguages = register("languages")
|
|
||||||
const registerLanguages: Pick<
|
|
||||||
SelectLanguageProps,
|
|
||||||
"defaultValue" | "onChange"
|
|
||||||
> = {
|
|
||||||
...proxyLanguages,
|
|
||||||
defaultValue: defaultValues.languages,
|
|
||||||
onChange: (event, value) => {
|
|
||||||
event && proxyLanguages.onChange(event)
|
|
||||||
setValue("languages", (value ?? []) as string[])
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const registerRating = register("rating")
|
|
||||||
const registerModes = register("modes", {
|
|
||||||
required: "Please select at least one mode.",
|
|
||||||
})
|
|
||||||
|
|
||||||
const proxyTitles = register("titles")
|
|
||||||
const registerTitles: Pick<SelectTitleProps, "defaultValue" | "onChange"> = {
|
|
||||||
...proxyTitles,
|
|
||||||
defaultValue: defaultValues.titles,
|
|
||||||
onChange: (event, value) => {
|
|
||||||
event && proxyTitles.onChange(event)
|
|
||||||
setValue("titles", (value ?? []) as Title[])
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={open}
|
|
||||||
onClose={onClose}
|
|
||||||
closeAfterTransition
|
|
||||||
frame={{
|
|
||||||
as: "form",
|
|
||||||
title: "Sort Coaches",
|
|
||||||
footer: (
|
|
||||||
<Button
|
|
||||||
className="float-right py-2"
|
|
||||||
type="submit"
|
|
||||||
disabled={Object.keys(errors).length > 0}
|
|
||||||
>
|
|
||||||
Submit
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
onSubmit: handleSubmit(onSubmit),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-12">
|
|
||||||
<FieldSet error={errors?.sites?.message}>
|
|
||||||
<Label htmlFor={`${idPrefix}-rating`}>Sites:</Label>
|
|
||||||
<p className="py-2 text-sm">
|
|
||||||
Prioritize coaches from the selected site(s).
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-2 pt-2 text-sm">
|
|
||||||
{(Object.values(Site) as Site[]).map((s) => (
|
|
||||||
<div key={s} className="col-span-1 flex items-center gap-x-2">
|
|
||||||
<CheckBox value={s} {...registerSites} />
|
|
||||||
<div>{getSiteName(s)}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</FieldSet>
|
|
||||||
|
|
||||||
<Field>
|
|
||||||
<Label htmlFor={`${idPrefix}-languages`}>
|
|
||||||
Preferred Language(s):
|
|
||||||
</Label>
|
|
||||||
<p className="py-2 text-sm">
|
|
||||||
Select languages you prefer communicating in. We{"'"}ll prioritize
|
|
||||||
finding coaches that can speak fluently in at least one of your
|
|
||||||
selections.
|
|
||||||
</p>
|
|
||||||
<SelectLanguage
|
|
||||||
id={`${idPrefix}-languages`}
|
|
||||||
slotProps={{
|
|
||||||
root: { className: "w-full" },
|
|
||||||
}}
|
|
||||||
{...registerLanguages}
|
|
||||||
multiple
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field>
|
|
||||||
<Label htmlFor={`${idPrefix}-titles`}>Titles:</Label>
|
|
||||||
<p className="py-2 text-sm">
|
|
||||||
Prioritize coaches with one or more of the following titles. That
|
|
||||||
said, this is usually not an aspect of a coach that is important to
|
|
||||||
focus on.
|
|
||||||
</p>
|
|
||||||
<SelectTitle
|
|
||||||
id={`${idPrefix}-titles`}
|
|
||||||
slotProps={{
|
|
||||||
root: { className: "w-full" },
|
|
||||||
}}
|
|
||||||
{...registerTitles}
|
|
||||||
multiple
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<FieldSet error={errors?.modes?.message}>
|
|
||||||
<Label htmlFor={`${idPrefix}-rating`}>Mode:</Label>
|
|
||||||
<p className="py-2 text-sm">
|
|
||||||
Prefer a specific game mode? We{"'"}ll prioritize coaches that
|
|
||||||
specialize in the modes selected.
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-3 pt-2 text-sm">
|
|
||||||
{(Object.values(Mode) as Mode[]).map((m) => (
|
|
||||||
<div key={m} className="col-span-1 flex items-center gap-x-2">
|
|
||||||
<CheckBox value={m} {...registerModes} />
|
|
||||||
<div>{getModeName(m)}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</FieldSet>
|
|
||||||
|
|
||||||
<Field>
|
|
||||||
<Label htmlFor={`${idPrefix}-rating`}>Rating:</Label>
|
|
||||||
<p className="py-2 text-sm">
|
|
||||||
Find coaches that have a rating within the specified range. A higher
|
|
||||||
rating does not necessarily correspond to a better coach. If you are
|
|
||||||
unsure of this or do not have any preference, leave as is.
|
|
||||||
</p>
|
|
||||||
<div id={`${idPrefix}-rating`} className="mt-2 w-full px-4">
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name={registerRating.name}
|
|
||||||
render={({ field: { onChange, onBlur, value, ref } }) => (
|
|
||||||
<Slider
|
|
||||||
ref={ref}
|
|
||||||
value={value}
|
|
||||||
onBlur={onBlur}
|
|
||||||
onChange={(event, newValue: any) => {
|
|
||||||
event && onChange(event)
|
|
||||||
setValue("rating.0", newValue[0])
|
|
||||||
setValue("rating.1", newValue[1])
|
|
||||||
}}
|
|
||||||
step={10}
|
|
||||||
min={RATING_MIN}
|
|
||||||
max={RATING_MAX}
|
|
||||||
marks={computeStepLabels(RATING_MIN, RATING_MAX, 7, 50).map(
|
|
||||||
(s) => ({ value: s, label: `${s}` })
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="mt-16 flex flex-wrap items-center justify-center gap-x-20 gap-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="text-neutral-850 text-sm font-medium">
|
|
||||||
Min:
|
|
||||||
</label>
|
|
||||||
<Input value={watch("rating.0")} disabled />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-neutral-850 text-sm font-medium">
|
|
||||||
Max:
|
|
||||||
</label>
|
|
||||||
<Input value={watch("rating.1")} disabled />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,14 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
|
|
||||||
const SvgComponent = ({ ...props }) => (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlSpace="preserve"
|
|
||||||
viewBox="0 0 512 512"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<path d="M297.236 327.676 184.324 214.763 35.455 363.632l-13.606-13.605L0 371.876l13.605 13.605 112.913 112.914h.001L140.124 512l21.849-21.849-13.605-13.606zM479.49 12.536c-3.024 1.166-70.873 27.806-141.442 93.595l67.821 67.821c65.787-70.565 92.429-138.417 93.595-141.441L512 0l-32.51 12.536zM306.914 118.696l-45.368 45.368-55.052 29.17 112.271 112.271 29.17-55.05 45.368-45.369z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
export default SvgComponent
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
const SvgComponent = ({ ...props }) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlSpace="preserve"
|
||||||
|
viewBox="0 0 32 32"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path d="M8 9.142V4H6v5.142c-1.72.447-3 2-3 3.858s1.28 3.411 3 3.858v10.096h2V16.858c1.72-.447 3-2 3-3.858S9.72 9.589 8 9.142zM7 15a2 2 0 1 1-.001-3.999A2 2 0 0 1 7 15zm10 1.142V4h-2v12.142c-1.72.447-3 2-3 3.858s1.28 3.411 3 3.858v3.096h2v-3.096c1.72-.447 3-2 3-3.858s-1.28-3.411-3-3.858zM16 22a2 2 0 1 1-.001-3.999A2 2 0 0 1 16 22zm13-6c0-1.858-1.28-3.411-3-3.858V4h-2v8.142c-1.72.447-3 2-3 3.858s1.28 3.411 3 3.858v7.096h2v-7.096c1.72-.447 3-2 3-3.858zm-4 2a2 2 0 1 1-.001-3.999A2 2 0 0 1 25 18z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default SvgComponent
|
|
@ -1,14 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
|
|
||||||
const SvgComponent = ({ ...props }) => (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlSpace="preserve"
|
|
||||||
viewBox="0 0 32 32"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<path d="m7.2 16 1.1-.2c1.6-.3 3.3-.5 5-.7-2.4 2.3-3.9 5.3-4.7 7.9h14.7c.4-1.5 1.1-3 2.3-4.1l.2-.2c.2-.2.3-.4.3-.6.5-5.1-1.9-10.1-6.3-12.8-.8-1.4-2-2.4-3.6-2.9l-.9-.3c-.3-.1-.6-.1-.9.1-.2.2-.4.5-.4.8v2.4l-1.4.7c-.4.2-.6.5-.6.9v.5l-4.7 3.1c-.8.5-1.3 1.5-1.3 2.5V15c0 .3.1.6.4.8.2.2.5.2.8.2zM6.8 25c-.5.5-.8 1.2-.8 2v2c0 .6.4 1 1 1h18c.6 0 1-.4 1-1v-2c0-.8-.3-1.5-.8-2H6.8z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
export default SvgComponent
|
|
|
@ -1,13 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
|
|
||||||
const SvgComponent = ({ ...props }) => (
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" {...props}>
|
|
||||||
<path
|
|
||||||
d="M30.8 2.29a.49.49 0 0 0-.45-.29H16.42a.5.5 0 0 0-.42.23l-10.71 17a.49.49 0 0 0 .41.77h7.67L6.6 33.25a.52.52 0 0 0 .46.75h3a.5.5 0 0 0 .37-.16L28 14.85a.5.5 0 0 0-.37-.85h-6.74l9.83-11.18a.49.49 0 0 0 .08-.53Z"
|
|
||||||
className="clr-i-solid clr-i-solid-path-1"
|
|
||||||
/>
|
|
||||||
<path fill="none" d="M0 0h36v36H0z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
export default SvgComponent
|
|
|
@ -1,9 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
|
|
||||||
const SvgComponent = ({ ...props }) => (
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" {...props}>
|
|
||||||
<path d="M22.777 23.384c-.147-.983-.91-1.857-2.058-2.507-3.059-1.95-3.595-5.268-2.184-7.45 1.799-.518 3.028-1.562 3.028-2.766 0-1.095-1.017-2.058-2.555-2.613a4.336 4.336 0 0 0 1.277-3.079c0-2.396-1.933-4.338-4.318-4.338s-4.318 1.942-4.318 4.338c0 1.204.488 2.292 1.276 3.078-1.538.555-2.556 1.518-2.556 2.613 0 1.218 1.259 2.273 3.093 2.784 1.434 2.175.824 5.451-2.332 7.463-1.107.646-1.834 1.513-1.975 2.477-1.989.842-3.235 2.047-3.235 3.386 0 2.544 4.498 4.607 10.047 4.607s10.047-2.062 10.047-4.607c0-1.339-1.247-2.545-3.237-3.387z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
export default SvgComponent
|
|
|
@ -1,9 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
|
|
||||||
const SvgComponent = ({ ...props }) => (
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" {...props}>
|
|
||||||
<path d="M23.961 5.99a6.144 6.144 0 0 0-1.03.065c-4.414-1.996-5.841-4.914-7.812-4.914-1.705 0-1.62 3.149 4.884 6.521l-.035.046c-.496.68-6.986-3.097-8.985-3.093-2.692.006 1.257 6.368 7.689 5.369.61-.095-.302.909-.227 1.48.023.176.065.345.123.506-6.275-.164-10.188.982-12.463 2.774-1.616-.903-1.672-4.089-3.337-3.265-1.77.876-.679 5.582.831 7.142-1.022 4.432 2.247 9.722 4.846 11.331h20.509c1.112-.789.487-1.41 0-1.997-.602-.725-2.461-1.199-3.993-.998-2.23-.908 5.444-5.973.027-11.95 1.021.058 2.186-.023 3.2-.342l.071.049.045-.086c.931-.313 1.723-.836 2.137-1.648.51-.998-3.303-6.922-6.479-6.989zm.996 4.983a.998.998 0 1 1 0-1.996.998.998 0 0 1 0 1.996z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
export default SvgComponent
|
|
|
@ -1,14 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
|
|
||||||
const SvgComponent = ({ ...props }) => (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<path d="M6.293 4.293a1 1 0 0 1 1.414 0l4 4a1 1 0 0 1-1.414 1.414L8 7.414V19a1 1 0 1 1-2 0V7.414L3.707 9.707a1 1 0 0 1-1.414-1.414l4-4zM16 16.586V5a1 1 0 1 1 2 0v11.586l2.293-2.293a1 1 0 0 1 1.414 1.414l-4 4a1 1 0 0 1-1.414 0l-4-4a1 1 0 0 1 1.414-1.414L16 16.586z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
export default SvgComponent
|
|
|
@ -1,16 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
|
|
||||||
const SvgComponet = ({ ...props }) => (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlSpace="preserve"
|
|
||||||
width={800}
|
|
||||||
height={800}
|
|
||||||
viewBox="0 0 100 100"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<path d="M82.296 23.931a1.918 1.918 0 0 0-1.918-1.918h-8.074v-6.998c.004-.061.018-.118.018-.179a2.935 2.935 0 0 0-2.934-2.935c-.036 0-.07.009-.106.011v-.011H30.365v.054a2.925 2.925 0 0 0-2.696 2.911c0 .062.014.119.018.179v6.967H19.62a1.914 1.914 0 0 0-1.909 1.839h-.007v.073l-.001.007.001.007v26.038l-.001.004.001.009V50h.001c.01 1.051.863 1.9 1.916 1.9h8.328c1.354 9.109 8.197 16.422 17.069 18.449v12.746h-9.969a2.493 2.493 0 0 0 0 4.988v.017h29.894v-.017a2.493 2.493 0 0 0 0-4.988h-9.969V70.353c8.881-2.02 15.733-9.337 17.087-18.453h8.318c1.028 0 1.86-.81 1.909-1.825h.011V23.931h-.003zM27.687 46.913H22.69V27h4.997v19.913zm49.623 0h-5.007V27h5.007v19.913z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
export default SvgComponet
|
|
|
@ -1,29 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
|
|
||||||
import { PageIntro } from "../components/PageIntro"
|
|
||||||
|
|
||||||
const Title = (
|
|
||||||
<span>
|
|
||||||
The{" "}
|
|
||||||
<span className="bg-gradient-to-r from-amber-500 via-orange-500 to-amber-500 bg-clip-text text-transparent">
|
|
||||||
BoardWise
|
|
||||||
</span>{" "}
|
|
||||||
Mission
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
|
|
||||||
export function About() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageIntro eyebrow="About Us" title={Title}>
|
|
||||||
<p>A better approach to finding the right coach for you.</p>
|
|
||||||
<p className="mt-4 text-base">
|
|
||||||
We are a small group of chess enthusiasts dedicated to helping other
|
|
||||||
players improve their game as efficiently as they can. We{"'"}re
|
|
||||||
starting this initiative the best way we know how - with experts in
|
|
||||||
the field.
|
|
||||||
</p>
|
|
||||||
</PageIntro>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,23 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
|
|
||||||
import { PageIntro } from "../components/PageIntro"
|
|
||||||
|
|
||||||
export function Contact() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageIntro eyebrow="Contact Us" title="Questions or Comments?">
|
|
||||||
<p>
|
|
||||||
Tell us how we can improve our site by leaving an issue at{" "}
|
|
||||||
<a
|
|
||||||
target="_blank"
|
|
||||||
className="underline"
|
|
||||||
href="https://github.com/boardwise-gg/website"
|
|
||||||
>
|
|
||||||
our GitHub repo
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
</PageIntro>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,39 +1,17 @@
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
import type { SearchParams } from "../types/SearchParams"
|
|
||||||
|
|
||||||
import { Container } from "../components/Container"
|
import { Container } from "../components/Container"
|
||||||
import { FadeIn } from "../components/FadeIn"
|
import { FadeIn, FadeInStagger } from "../components/FadeIn"
|
||||||
import { FallbackMessage } from "../components/FallbackMessage"
|
import { FallbackMessage } from "../components/FallbackMessage"
|
||||||
|
import { FilterModal } from "../components/FilterModal"
|
||||||
|
import { FilterScroll } from "../components/FilterScroll"
|
||||||
import { Loading } from "../components/Loading"
|
import { Loading } from "../components/Loading"
|
||||||
import { SearchResult } from "../components/SearchResult"
|
import { SearchResult } from "../components/SearchResult"
|
||||||
import { SortModal } from "../components/SortModal"
|
|
||||||
import { SortScroll } from "../components/SortScroll"
|
|
||||||
import { defaultSearchParams } from "../types/SearchParams"
|
import { defaultSearchParams } from "../types/SearchParams"
|
||||||
import { useCoachesInfiniteQuery } from "../utils/queries"
|
import { useFetchCoaches } from "../utils/queries"
|
||||||
|
|
||||||
function SearchResults({ searchParams }: { searchParams: SearchParams }) {
|
function SearchResults() {
|
||||||
const { isLoading, isError, data, fetchNextPage, hasNextPage, isFetching } =
|
const { isLoading, isError, data } = useFetchCoaches()
|
||||||
useCoachesInfiniteQuery(searchParams)
|
|
||||||
|
|
||||||
const resultsRef = React.useRef<HTMLDivElement | null>(null)
|
|
||||||
|
|
||||||
const handleScroll = React.useCallback(() => {
|
|
||||||
if (!resultsRef.current) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const resultRect = resultsRef.current.getBoundingClientRect()
|
|
||||||
if (!isFetching && hasNextPage && resultRect.bottom < 800) {
|
|
||||||
fetchNextPage()
|
|
||||||
}
|
|
||||||
}, [isFetching, hasNextPage, resultsRef])
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
window.addEventListener("scroll", handleScroll, { passive: true })
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("scroll", handleScroll)
|
|
||||||
}
|
|
||||||
}, [isFetching, hasNextPage, resultsRef])
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <Loading className="mt-40" loading />
|
return <Loading className="mt-40" loading />
|
||||||
|
@ -50,26 +28,20 @@ function SearchResults({ searchParams }: { searchParams: SearchParams }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<FadeInStagger
|
||||||
<div
|
|
||||||
ref={resultsRef}
|
|
||||||
className="mt-10 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
className="mt-10 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||||
|
faster
|
||||||
>
|
>
|
||||||
{data?.pages.map((group, index) => (
|
{data?.map((coach, index) => (
|
||||||
<React.Fragment key={index}>
|
<FadeIn key={index} className="flex cursor-pointer flex-col">
|
||||||
{group.map((coach) => (
|
<SearchResult
|
||||||
<FadeIn
|
src={coach.image_url ?? ""}
|
||||||
key={`${coach.site}-${coach.username}`}
|
title={coach.name ?? ""}
|
||||||
className="flex flex-col"
|
subtitle={coach.name ?? ""}
|
||||||
>
|
/>
|
||||||
<SearchResult coach={coach} />
|
|
||||||
</FadeIn>
|
</FadeIn>
|
||||||
))}
|
))}
|
||||||
</React.Fragment>
|
</FadeInStagger>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{hasNextPage ? <Loading className="pt-20" loading /> : null}
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -79,12 +51,12 @@ export function Search() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="pt-8">
|
<Container className="pt-8">
|
||||||
<SortScroll
|
<FilterScroll
|
||||||
params={searchParams}
|
params={searchParams}
|
||||||
onSelect={setSearchParams}
|
onSelect={setSearchParams}
|
||||||
onModal={() => setModalOpen(true)}
|
onModal={() => setModalOpen(true)}
|
||||||
/>
|
/>
|
||||||
<SortModal
|
<FilterModal
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
defaultValues={searchParams}
|
defaultValues={searchParams}
|
||||||
onClose={() => setModalOpen(false)}
|
onClose={() => setModalOpen(false)}
|
||||||
|
@ -93,7 +65,7 @@ export function Search() {
|
||||||
setModalOpen(false)
|
setModalOpen(false)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<SearchResults searchParams={searchParams} />
|
<SearchResults />
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,8 +2,6 @@ import * as React from "react"
|
||||||
import { createBrowserRouter } from "react-router-dom"
|
import { createBrowserRouter } from "react-router-dom"
|
||||||
|
|
||||||
import { FallbackMessage } from "./components/FallbackMessage"
|
import { FallbackMessage } from "./components/FallbackMessage"
|
||||||
import { About } from "./pages/About"
|
|
||||||
import { Contact } from "./pages/Contact"
|
|
||||||
import { Search } from "./pages/Search"
|
import { Search } from "./pages/Search"
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
|
@ -11,14 +9,6 @@ export const router = createBrowserRouter([
|
||||||
path: "/",
|
path: "/",
|
||||||
element: <Search />,
|
element: <Search />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "/about/",
|
|
||||||
element: <About />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/contact/",
|
|
||||||
element: <Contact />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "*",
|
path: "*",
|
||||||
element: (
|
element: (
|
||||||
|
|
|
@ -3,7 +3,6 @@ export type Coach = {
|
||||||
username: string
|
username: string
|
||||||
name: string | null
|
name: string | null
|
||||||
image_url: string | null
|
image_url: string | null
|
||||||
title: string | null
|
|
||||||
languages: string[] | null
|
languages: string[] | null
|
||||||
rapid: number | null
|
rapid: number | null
|
||||||
blitz: number | null
|
blitz: number | null
|
||||||
|
|
|
@ -1,9 +0,0 @@
|
||||||
export enum Mode {
|
|
||||||
RAPID = "RAPID",
|
|
||||||
BLITZ = "BLITZ",
|
|
||||||
BULLET = "BULLET",
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getModeName = (mode: Mode) => {
|
|
||||||
return mode.charAt(0) + mode.toLowerCase().slice(1)
|
|
||||||
}
|
|
|
@ -1,45 +1,12 @@
|
||||||
import { Mode } from "./Mode"
|
|
||||||
import { Site } from "./Site"
|
|
||||||
import { Title } from "./Title"
|
|
||||||
|
|
||||||
export type SearchParams = {
|
export type SearchParams = {
|
||||||
rating: [number, number]
|
fideRating: [number, number]
|
||||||
modes: Mode[]
|
|
||||||
languages: string[]
|
languages: string[]
|
||||||
titles: Title[]
|
|
||||||
sites: Site[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RATING_MIN = 1500
|
export const FIDE_RATING_MIN = 1500
|
||||||
export const RATING_MAX = 3200
|
export const FIDE_RATING_MAX = 3200
|
||||||
|
|
||||||
export const defaultSearchParams: SearchParams = {
|
export const defaultSearchParams: SearchParams = {
|
||||||
rating: [RATING_MIN, RATING_MAX],
|
fideRating: [FIDE_RATING_MIN, FIDE_RATING_MAX],
|
||||||
modes: [Mode.RAPID, Mode.BLITZ, Mode.BULLET],
|
|
||||||
languages: [],
|
languages: [],
|
||||||
titles: [],
|
|
||||||
sites: [Site.LICHESS],
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toQueryParams(p: SearchParams) {
|
|
||||||
const queryParams: { [key: string]: any } = {}
|
|
||||||
|
|
||||||
if (p.sites.length > 0) {
|
|
||||||
queryParams["sites"] = p.sites.join(",")
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const mode of p.modes) {
|
|
||||||
queryParams[`${mode.toLowerCase()}_gte`] = p.rating[0]
|
|
||||||
queryParams[`${mode.toLowerCase()}_lte`] = p.rating[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (p.languages.length > 0) {
|
|
||||||
queryParams["languages"] = p.languages.join(",")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (p.titles) {
|
|
||||||
queryParams["titles"] = p.titles.join(",")
|
|
||||||
}
|
|
||||||
|
|
||||||
return queryParams
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,16 +0,0 @@
|
||||||
export enum Site {
|
|
||||||
CHESSCOM = "chesscom",
|
|
||||||
LICHESS = "lichess",
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSiteName(site: Site) {
|
|
||||||
switch (site) {
|
|
||||||
case Site.CHESSCOM:
|
|
||||||
return "Chess.com"
|
|
||||||
case Site.LICHESS:
|
|
||||||
return "Lichess"
|
|
||||||
default:
|
|
||||||
const _exhaustivenessCheck: never = site
|
|
||||||
return _exhaustivenessCheck
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,12 +0,0 @@
|
||||||
export enum Title {
|
|
||||||
GM = "GM",
|
|
||||||
IM = "IM",
|
|
||||||
FM = "FM",
|
|
||||||
CM = "CM",
|
|
||||||
NM = "NM",
|
|
||||||
WGM = "WGM",
|
|
||||||
WIM = "WIM",
|
|
||||||
WFM = "WFM",
|
|
||||||
WCM = "WCM",
|
|
||||||
WNM = "WNM",
|
|
||||||
}
|
|
|
@ -1,38 +1,22 @@
|
||||||
import axios from "axios"
|
import axios from "axios"
|
||||||
import { useQuery, useInfiniteQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
|
||||||
import type { Coach } from "../types/Coach"
|
import type { Coach } from "../types/Coach"
|
||||||
import type { Language } from "../types/Language"
|
import type { Language } from "../types/Language"
|
||||||
import { type SearchParams, toQueryParams } from "../types/SearchParams"
|
|
||||||
|
|
||||||
export const useCoachesInfiniteQuery = (searchParams: SearchParams) => {
|
export const useFetchCoaches = () => {
|
||||||
const queryParams = toQueryParams(searchParams)
|
return useQuery({
|
||||||
|
queryKey: ["api", "coaches"],
|
||||||
return useInfiniteQuery({
|
queryFn: async () => {
|
||||||
queryKey: ["coaches", queryParams],
|
const response = await axios.get<{ data: Coach[] }>("/api/coaches/")
|
||||||
queryFn: async ({ pageParam = 1 }) => {
|
|
||||||
const response = await axios.get<{ data: Coach[] }>("/api/coaches/", {
|
|
||||||
params: {
|
|
||||||
page_no: pageParam,
|
|
||||||
page_size: 15,
|
|
||||||
...queryParams,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return response.data.data
|
return response.data.data
|
||||||
},
|
},
|
||||||
getNextPageParam: (lastPage, _allPages, lastPageParam) => {
|
|
||||||
if (lastPage.length === 0) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
return lastPageParam + 1
|
|
||||||
},
|
|
||||||
initialPageParam: 1,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useLanguagesQuery = () => {
|
export const useFetchLanguages = () => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["languages"],
|
queryKey: ["api", "languages"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await axios.get<{ data: Language[] }>("/api/languages/")
|
const response = await axios.get<{ data: Language[] }>("/api/languages/")
|
||||||
return response.data.data
|
return response.data.data
|
||||||
|
|
|
@ -28,8 +28,8 @@ module.exports = {
|
||||||
},
|
},
|
||||||
extend: {
|
extend: {
|
||||||
backgroundImage: {
|
backgroundImage: {
|
||||||
"radial-gradient/gray":
|
"radial-gradient/black":
|
||||||
"radial-gradient(circle at center, #d3d3d3 0%, transparent 100%)",
|
"radial-gradient(circle at center, black 0%, transparent 50%)",
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
"4xl": "2.5rem",
|
"4xl": "2.5rem",
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
// https://esbuild.github.io/content-types/#tsconfig-json
|
// https://esbuild.github.io/content-types/#tsconfig-json
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
// Keep in mind that ES6+ syntax to ES5 is not supported in esbuild yet.
|
// Keep in mind that ES6+ syntax to ES5 is not supported in esbuild yet.
|
||||||
"target": "es2017",
|
"target": "es2016",
|
||||||
// https://www.typescriptlang.org/docs/handbook/modules/theory.html
|
// https://www.typescriptlang.org/docs/handbook/modules/theory.html
|
||||||
"module": "nodenext",
|
"module": "nodenext",
|
||||||
// Even when transpiling a single module, the TypeScript compiler actually
|
// Even when transpiling a single module, the TypeScript compiler actually
|
||||||
|
|
12
flake.lock
12
flake.lock
|
@ -19,11 +19,11 @@
|
||||||
"systems": "systems"
|
"systems": "systems"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1701680307,
|
"lastModified": 1694529238,
|
||||||
"narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=",
|
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
|
||||||
"owner": "numtide",
|
"owner": "numtide",
|
||||||
"repo": "flake-utils",
|
"repo": "flake-utils",
|
||||||
"rev": "4022d587cbbfd70fe950c1e2083a02621806a725",
|
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
@ -34,11 +34,11 @@
|
||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1701718080,
|
"lastModified": 1700794826,
|
||||||
"narHash": "sha256-6ovz0pG76dE0P170pmmZex1wWcQoeiomUZGggfH9XPs=",
|
"narHash": "sha256-RyJTnTNKhO0yqRpDISk03I/4A67/dp96YRxc86YOPgU=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "2c7f3c0fb7c08a0814627611d9d7d45ab6d75335",
|
"rev": "5a09cb4b393d58f9ed0d9ca1555016a8543c2ac8",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
|
@ -7,88 +7,21 @@ defmodule BoardWise.Coaches do
|
||||||
alias BoardWise.Repo
|
alias BoardWise.Repo
|
||||||
|
|
||||||
alias BoardWise.Coaches.Coach
|
alias BoardWise.Coaches.Coach
|
||||||
alias BoardWise.Coaches.QueryParams
|
|
||||||
|
|
||||||
@prefix "coach_scraper"
|
@prefix "coach_scraper"
|
||||||
|
|
||||||
defmacrop rating_fragment(field, gte, lte) do
|
|
||||||
quote do
|
|
||||||
fragment(
|
|
||||||
"""
|
|
||||||
CASE
|
|
||||||
WHEN ? IS NULL THEN 0
|
|
||||||
WHEN ? IS NULL THEN 0
|
|
||||||
WHEN ? >= ? AND ? <= ? THEN 5
|
|
||||||
ELSE 0
|
|
||||||
END
|
|
||||||
""",
|
|
||||||
type(unquote(gte), :integer),
|
|
||||||
type(unquote(lte), :integer),
|
|
||||||
unquote(field),
|
|
||||||
type(unquote(gte), :integer),
|
|
||||||
unquote(field),
|
|
||||||
type(unquote(lte), :integer)
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Return the list of coaches according to the specified params.
|
Returns the list of coaches.
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
iex> list_coaches(%QueryParams{...})
|
iex> list_coaches()
|
||||||
[%Coach{}, ...]
|
[%Coach{}, ...]
|
||||||
|
|
||||||
"""
|
"""
|
||||||
def list_coaches(%QueryParams{
|
def list_coaches do
|
||||||
:rapid_gte => rapid_gte,
|
|
||||||
:rapid_lte => rapid_lte,
|
|
||||||
:blitz_gte => blitz_gte,
|
|
||||||
:blitz_lte => blitz_lte,
|
|
||||||
:bullet_gte => bullet_gte,
|
|
||||||
:bullet_lte => bullet_lte,
|
|
||||||
:languages => languages,
|
|
||||||
:titles => titles,
|
|
||||||
:sites => sites,
|
|
||||||
:page_no => page_no,
|
|
||||||
:page_size => page_size
|
|
||||||
}) do
|
|
||||||
Coach
|
Coach
|
||||||
|> select([c], c)
|
|> limit(6)
|
||||||
|> select_merge(
|
|
||||||
[c],
|
|
||||||
%{
|
|
||||||
score:
|
|
||||||
fragment(
|
|
||||||
"""
|
|
||||||
CASE WHEN ? IS NOT NULL THEN 1000 ELSE 0 END +
|
|
||||||
CASE WHEN ? IS NOT NULL THEN 500 ELSE 0 END +
|
|
||||||
? +
|
|
||||||
? +
|
|
||||||
? +
|
|
||||||
(5 * (SELECT COUNT(*) FROM UNNEST(?) WHERE UNNEST = ANY(?))) +
|
|
||||||
CASE WHEN ? = ANY(?) THEN 5 ELSE 0 END +
|
|
||||||
CASE WHEN ? = ANY(?) THEN 30 ELSE 0 END
|
|
||||||
""",
|
|
||||||
c.name,
|
|
||||||
c.image_url,
|
|
||||||
rating_fragment(c.rapid, ^rapid_gte, ^rapid_lte),
|
|
||||||
rating_fragment(c.blitz, ^blitz_gte, ^blitz_lte),
|
|
||||||
rating_fragment(c.bullet, ^bullet_gte, ^bullet_lte),
|
|
||||||
c.languages,
|
|
||||||
type(^languages, {:array, :string}),
|
|
||||||
c.title,
|
|
||||||
type(^titles, {:array, :string}),
|
|
||||||
c.site,
|
|
||||||
type(^sites, {:array, :string})
|
|
||||||
)
|
|
||||||
|> selected_as(:score)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|> order_by(desc: selected_as(:score), asc: :position, asc: :username, asc: :id)
|
|
||||||
|> limit(^page_size)
|
|
||||||
|> offset(^((page_no - 1) * page_size))
|
|
||||||
|> Repo.all(prefix: @prefix)
|
|> Repo.all(prefix: @prefix)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -15,22 +15,14 @@ defmodule BoardWise.Coaches.Coach do
|
||||||
import Ecto.Changeset
|
import Ecto.Changeset
|
||||||
|
|
||||||
schema "export" do
|
schema "export" do
|
||||||
# required fields
|
|
||||||
field :site, :string
|
field :site, :string
|
||||||
field :username, :string
|
field :username, :string
|
||||||
|
|
||||||
# optional fields
|
|
||||||
field :name, :string
|
field :name, :string
|
||||||
field :image_url, :string
|
field :image_url, :string
|
||||||
field :title, :string
|
|
||||||
field :languages, {:array, :string}
|
field :languages, {:array, :string}
|
||||||
field :rapid, :integer
|
|
||||||
field :blitz, :integer
|
field :blitz, :integer
|
||||||
field :bullet, :integer
|
field :bullet, :integer
|
||||||
field :position, :integer
|
field :rapid, :integer
|
||||||
|
|
||||||
# virtual fields
|
|
||||||
field :score, :integer, virtual: true
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc false
|
@doc false
|
||||||
|
@ -41,12 +33,9 @@ defmodule BoardWise.Coaches.Coach do
|
||||||
:username,
|
:username,
|
||||||
:name,
|
:name,
|
||||||
:image_url,
|
:image_url,
|
||||||
:title,
|
|
||||||
:languages,
|
|
||||||
:rapid,
|
:rapid,
|
||||||
:blitz,
|
:blitz,
|
||||||
:bullet,
|
:bullet
|
||||||
:position
|
|
||||||
])
|
])
|
||||||
|> validate_required([:site, :username])
|
|> validate_required([:site, :username])
|
||||||
|> unique_constraint(:site_username_unique, name: :site_username_unique)
|
|> unique_constraint(:site_username_unique, name: :site_username_unique)
|
||||||
|
|
|
@ -1,15 +0,0 @@
|
||||||
defmodule BoardWise.Coaches.QueryParams do
|
|
||||||
defstruct [
|
|
||||||
:rapid_gte,
|
|
||||||
:rapid_lte,
|
|
||||||
:blitz_gte,
|
|
||||||
:blitz_lte,
|
|
||||||
:bullet_gte,
|
|
||||||
:bullet_lte,
|
|
||||||
:titles,
|
|
||||||
:languages,
|
|
||||||
:sites,
|
|
||||||
page_no: 1,
|
|
||||||
page_size: 15
|
|
||||||
]
|
|
||||||
end
|
|
|
@ -5,14 +5,13 @@ defmodule BoardWise.Languages.Language do
|
||||||
schema "languages" do
|
schema "languages" do
|
||||||
field :code, :string
|
field :code, :string
|
||||||
field :name, :string
|
field :name, :string
|
||||||
field :pos, :integer
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc false
|
@doc false
|
||||||
def changeset(language, attrs) do
|
def changeset(language, attrs) do
|
||||||
language
|
language
|
||||||
|> cast(attrs, [:code, :name, :pos])
|
|> cast(attrs, [:code, :name])
|
||||||
|> validate_required([:code, :name, :pos])
|
|> validate_required([:code, :name])
|
||||||
|> unique_constraint(:code_unique, name: :code_unique)
|
|> unique_constraint(:code_unique, name: :code_unique)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
/>
|
/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="csrf-token" content={get_csrf_token()} />
|
<meta name="csrf-token" content={get_csrf_token()} />
|
||||||
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
|
<link rel="icon" href={~p"/favicon.ico"} type="image/x-icon" />
|
||||||
<title>BoardWise</title>
|
<title>BoardWise</title>
|
||||||
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
|
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
|
||||||
<script defer type="text/javascript" src={~p"/assets/react/main.js"}>
|
<script defer type="text/javascript" src={~p"/assets/react/main.js"}>
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
/>
|
/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="csrf-token" content={get_csrf_token()} />
|
<meta name="csrf-token" content={get_csrf_token()} />
|
||||||
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
|
<link rel="icon" href={~p"/favicon.ico"} type="image/x-icon" />
|
||||||
<.live_title suffix="">
|
<.live_title suffix="">
|
||||||
<%= assigns[:page_title] || "BoardWise" %>
|
<%= assigns[:page_title] || "BoardWise" %>
|
||||||
</.live_title>
|
</.live_title>
|
||||||
|
|
|
@ -2,43 +2,9 @@ defmodule BoardWiseWeb.CoachController do
|
||||||
use BoardWiseWeb, :controller
|
use BoardWiseWeb, :controller
|
||||||
|
|
||||||
alias BoardWise.Coaches
|
alias BoardWise.Coaches
|
||||||
alias BoardWise.Coaches.QueryParams
|
|
||||||
|
|
||||||
def index(conn, params) do
|
def index(conn, _params) do
|
||||||
query_params =
|
coaches = Coaches.list_coaches()
|
||||||
%QueryParams{}
|
|
||||||
|> override_param(:rapid_gte, params, :integer)
|
|
||||||
|> override_param(:rapid_lte, params, :integer)
|
|
||||||
|> override_param(:blitz_gte, params, :integer)
|
|
||||||
|> override_param(:blitz_lte, params, :integer)
|
|
||||||
|> override_param(:bullet_gte, params, :integer)
|
|
||||||
|> override_param(:bullet_lte, params, :integer)
|
|
||||||
|> override_param(:languages, params, :strlist)
|
|
||||||
|> override_param(:titles, params, :strlist)
|
|
||||||
|> override_param(:sites, params, :strlist)
|
|
||||||
|> override_param(:page_no, params, :integer)
|
|
||||||
|> override_param(:page_size, params, :integer)
|
|
||||||
|
|
||||||
# Ensure we never attempt to query too large of a response all at once.
|
|
||||||
query_params = %{query_params | page_size: Enum.min([query_params.page_size, 25])}
|
|
||||||
|
|
||||||
coaches = Coaches.list_coaches(query_params)
|
|
||||||
render(conn, :index, coaches: coaches)
|
render(conn, :index, coaches: coaches)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp override_param(query_params, key, params, type) do
|
|
||||||
case Map.get(params, Atom.to_string(key)) do
|
|
||||||
nil ->
|
|
||||||
query_params
|
|
||||||
|
|
||||||
val when type == :strlist ->
|
|
||||||
%{query_params | key => String.split(val, ",")}
|
|
||||||
|
|
||||||
val when type == :integer ->
|
|
||||||
case Integer.parse(val) do
|
|
||||||
{parsed, ""} -> %{query_params | key => parsed}
|
|
||||||
_ -> query_params
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -14,7 +14,6 @@ defmodule BoardWiseWeb.CoachJSON do
|
||||||
username: coach.username,
|
username: coach.username,
|
||||||
name: coach.name,
|
name: coach.name,
|
||||||
image_url: coach.image_url,
|
image_url: coach.image_url,
|
||||||
title: coach.title,
|
|
||||||
languages: coach.languages,
|
languages: coach.languages,
|
||||||
rapid: coach.rapid,
|
rapid: coach.rapid,
|
||||||
blitz: coach.blitz,
|
blitz: coach.blitz,
|
||||||
|
|
|
@ -1,11 +0,0 @@
|
||||||
defmodule BoardWise.Repo.Migrations.Titles do
|
|
||||||
use Ecto.Migration
|
|
||||||
|
|
||||||
@prefix "coach_scraper"
|
|
||||||
|
|
||||||
def change do
|
|
||||||
alter table(:export, prefix: @prefix) do
|
|
||||||
add :title, :string
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,11 +0,0 @@
|
||||||
defmodule BoardWise.Repo.Migrations.Position do
|
|
||||||
use Ecto.Migration
|
|
||||||
|
|
||||||
@prefix "coach_scraper"
|
|
||||||
|
|
||||||
def change do
|
|
||||||
alter table(:export, prefix: @prefix) do
|
|
||||||
add :position, :integer
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -5,15 +5,14 @@ defmodule BoardWise.CoachesTest do
|
||||||
|
|
||||||
describe "coaches" do
|
describe "coaches" do
|
||||||
alias BoardWise.Coaches.Coach
|
alias BoardWise.Coaches.Coach
|
||||||
alias BoardWise.Coaches.QueryParams
|
|
||||||
|
|
||||||
import BoardWise.CoachesFixtures
|
import BoardWise.CoachesFixtures
|
||||||
|
|
||||||
@invalid_attrs %{blitz: nil, bullet: nil, rapid: nil, site: nil, username: nil}
|
@invalid_attrs %{blitz: nil, bullet: nil, rapid: nil, site: nil, username: nil}
|
||||||
|
|
||||||
test "list_coaches/2 returns all coaches" do
|
test "list_coaches/0 returns all coaches" do
|
||||||
coach = coach_fixture()
|
coach = coach_fixture()
|
||||||
assert Coaches.list_coaches(%QueryParams{}) == [%{coach | score: 0}]
|
assert Coaches.list_coaches() == [coach]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "get_coach!/1 returns the coach with given id" do
|
test "get_coach!/1 returns the coach with given id" do
|
||||||
|
|
|
@ -21,7 +21,7 @@ defmodule BoardWise.LanguagesTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "create_language/1 with valid data creates a language" do
|
test "create_language/1 with valid data creates a language" do
|
||||||
valid_attrs = %{code: "some code", name: "some name", pos: 1000}
|
valid_attrs = %{code: "some code", name: "some name"}
|
||||||
|
|
||||||
assert {:ok, %Language{} = language} = Languages.create_language(valid_attrs)
|
assert {:ok, %Language{} = language} = Languages.create_language(valid_attrs)
|
||||||
assert language.code == "some code"
|
assert language.code == "some code"
|
||||||
|
|
|
@ -12,8 +12,7 @@ defmodule BoardWise.LanguagesFixtures do
|
||||||
attrs
|
attrs
|
||||||
|> Enum.into(%{
|
|> Enum.into(%{
|
||||||
code: "some code",
|
code: "some code",
|
||||||
name: "some name",
|
name: "some name"
|
||||||
pos: 0
|
|
||||||
})
|
})
|
||||||
|> BoardWise.Languages.create_language()
|
|> BoardWise.Languages.create_language()
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue