init
This commit is contained in:
53
src/components/DefaultCatchBoundary.tsx
Normal file
53
src/components/DefaultCatchBoundary.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
ErrorComponent,
|
||||
Link,
|
||||
rootRouteId,
|
||||
useMatch,
|
||||
useRouter,
|
||||
} from '@tanstack/react-router'
|
||||
import type { ErrorComponentProps } from '@tanstack/react-router'
|
||||
|
||||
export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
||||
const router = useRouter()
|
||||
const isRoot = useMatch({
|
||||
strict: false,
|
||||
select: (state) => state.id === rootRouteId,
|
||||
})
|
||||
|
||||
console.error('DefaultCatchBoundary Error:', error)
|
||||
|
||||
return (
|
||||
<div className="min-w-0 flex-1 p-4 flex flex-col items-center justify-center gap-6">
|
||||
<ErrorComponent error={error} />
|
||||
<div className="flex gap-2 items-center flex-wrap">
|
||||
<button
|
||||
onClick={() => {
|
||||
router.invalidate()
|
||||
}}
|
||||
className={`px-2 py-1 bg-gray-600 dark:bg-gray-700 rounded text-white uppercase font-extrabold`}
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
{isRoot ? (
|
||||
<Link
|
||||
to="/"
|
||||
className={`px-2 py-1 bg-gray-600 dark:bg-gray-700 rounded text-white uppercase font-extrabold`}
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
to="/"
|
||||
className={`px-2 py-1 bg-gray-600 dark:bg-gray-700 rounded text-white uppercase font-extrabold`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
window.history.back()
|
||||
}}
|
||||
>
|
||||
Go Back
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
15
src/components/avatar.tsx
Normal file
15
src/components/avatar.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Avatar as MantineAvatar, AvatarProps as MantineAvatarProps, Paper } from '@mantine/core';
|
||||
|
||||
interface AvatarProps extends Omit<MantineAvatarProps, 'radius' | 'color' | 'size'> {
|
||||
name: string;
|
||||
size?: number;
|
||||
radius?: string | number;
|
||||
}
|
||||
|
||||
const Avatar = ({ name, size = 35, radius = '100%', ...props }: AvatarProps) => {
|
||||
return <Paper p={size / 20} radius={radius} withBorder>
|
||||
<MantineAvatar alt={name} key={name} name={name} color='initials' size={size} radius={radius} {...props} />
|
||||
</Paper>
|
||||
}
|
||||
|
||||
export default Avatar;
|
||||
27
src/components/list-link.tsx
Normal file
27
src/components/list-link.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NavLink, Text } from "@mantine/core";
|
||||
import { CaretRightIcon, Icon } from "@phosphor-icons/react";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
|
||||
interface ListLinkProps {
|
||||
label: string;
|
||||
to: string;
|
||||
Icon: Icon;
|
||||
}
|
||||
|
||||
const ListLink = ({ label, to, Icon }: ListLinkProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
w='100%'
|
||||
p='md'
|
||||
component={'button'}
|
||||
onClick={() => navigate({ to })}
|
||||
label={<Text fw={500} size='md'>{label}</Text>}
|
||||
leftSection={<Icon weight='bold' size={20} />}
|
||||
rightSection={<CaretRightIcon size={20} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default ListLink;
|
||||
15
src/components/page.tsx
Normal file
15
src/components/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Container, ContainerProps } from "@mantine/core";
|
||||
import useHeaderConfig from "@/features/core/hooks/use-header-config";
|
||||
|
||||
interface PageProps extends ContainerProps, React.PropsWithChildren {
|
||||
noPadding?: boolean;
|
||||
}
|
||||
|
||||
const Page = ({ children, noPadding, ...props }: PageProps) => {
|
||||
const headerConfig = useHeaderConfig();
|
||||
return <Container px={noPadding ? 0 : 'md'} pt={headerConfig.collapsed ? 60 : 0} pb={20} m={0} maw={600} mx='auto' {...props}>
|
||||
{children}
|
||||
</Container>
|
||||
}
|
||||
|
||||
export default Page;
|
||||
34
src/components/phone-number-input.tsx
Normal file
34
src/components/phone-number-input.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Input, InputProps, Group, Text } from '@mantine/core';
|
||||
import { CheckFat, Phone } from '@phosphor-icons/react';
|
||||
import { IMaskInput } from 'react-imask';
|
||||
|
||||
interface PhoneNumberInputProps extends InputProps {
|
||||
id: string;
|
||||
value?: string;
|
||||
onChange: (value: string) => void;
|
||||
label: string;
|
||||
description?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const PhoneNumberInput: React.FC<PhoneNumberInputProps> = ({ id, value, onChange, label, description, error, ...props }) => {
|
||||
return (
|
||||
<Input.Wrapper id={id} label={label} description={description} error={error}>
|
||||
<Input
|
||||
id={id}
|
||||
component={IMaskInput}
|
||||
mask="(000) 000-0000"
|
||||
leftSection={<Group gap={2}><Phone size={20} /> <Text c='dimmed' size='sm'>+1</Text></Group>}
|
||||
leftSectionWidth={50}
|
||||
leftSectionProps={{ style: { padding: 0 } }}
|
||||
placeholder="(713) 867-5309"
|
||||
onAccept={(_, mask) => onChange(mask.unmaskedValue)}
|
||||
rightSection={value?.length === 10 && <CheckFat color='green' size={20} weight='fill' />}
|
||||
value={value}
|
||||
{...props}
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default PhoneNumberInput;
|
||||
16
src/components/providers.tsx
Normal file
16
src/components/providers.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { AuthProvider } from "@/contexts/auth-context"
|
||||
import MantineProvider from "@/lib/mantine/mantine-provider"
|
||||
import { Toaster } from "sonner"
|
||||
|
||||
const Providers = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<MantineProvider>
|
||||
<Toaster position='top-center' />
|
||||
{children}
|
||||
</MantineProvider>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default Providers;
|
||||
73
src/components/sheet/drawer.tsx
Normal file
73
src/components/sheet/drawer.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Box, Container } from "@mantine/core";
|
||||
import { PropsWithChildren, useEffect } from "react";
|
||||
import { Drawer as VaulDrawer } from 'vaul';
|
||||
import { useMantineColorScheme } from '@mantine/core';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
interface DrawerProps extends PropsWithChildren {
|
||||
title?: string;
|
||||
opened: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
}
|
||||
|
||||
const Drawer: React.FC<DrawerProps> = ({ title, children, opened, onChange }) => {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
|
||||
useEffect(() => {
|
||||
const appElement = document.querySelector('.app') as HTMLElement;
|
||||
|
||||
if (!appElement) return;
|
||||
|
||||
let themeColorMeta = document.querySelector('meta[name="theme-color"]') as HTMLMetaElement;
|
||||
if (!themeColorMeta) {
|
||||
themeColorMeta = document.createElement('meta');
|
||||
themeColorMeta.name = 'theme-color';
|
||||
document.head.appendChild(themeColorMeta);
|
||||
}
|
||||
|
||||
const colors = {
|
||||
light: {
|
||||
normal: 'rgb(255,255,255)',
|
||||
overlay: 'rgb(153,153,153)'
|
||||
},
|
||||
dark: {
|
||||
normal: 'rgb(36,36,36)',
|
||||
overlay: 'rgb(22,22,22)'
|
||||
}
|
||||
};
|
||||
|
||||
const currentColors = colors[colorScheme] || colors.light;
|
||||
|
||||
if (opened) {
|
||||
appElement.classList.add('drawer-scaling');
|
||||
themeColorMeta.content = currentColors.overlay;
|
||||
} else {
|
||||
appElement.classList.remove('drawer-scaling');
|
||||
themeColorMeta.content = currentColors.normal;
|
||||
}
|
||||
|
||||
return () => {
|
||||
appElement.classList.remove('drawer-scaling');
|
||||
themeColorMeta.content = currentColors.normal;
|
||||
};
|
||||
}, [opened, colorScheme]);
|
||||
|
||||
return (
|
||||
<VaulDrawer.Root open={opened} onOpenChange={onChange}>
|
||||
<VaulDrawer.Portal>
|
||||
<VaulDrawer.Overlay className={styles.drawerOverlay} />
|
||||
<VaulDrawer.Content className={styles.drawerContent}>
|
||||
<Container flex={1} p='md'>
|
||||
<Box mb='sm' bg='var(--mantine-color-gray-4)' w='3rem' h='0.375rem' ml='auto' mr='auto' style={{ borderRadius: '9999px' }} />
|
||||
<Container mah='fit-content' mx='auto' maw='28rem' px={0}>
|
||||
<VaulDrawer.Title>{title}</VaulDrawer.Title>
|
||||
{children}
|
||||
</Container>
|
||||
</Container>
|
||||
</VaulDrawer.Content>
|
||||
</VaulDrawer.Portal>
|
||||
</VaulDrawer.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export default Drawer;
|
||||
1
src/components/sheet/index.ts
Normal file
1
src/components/sheet/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './sheet';
|
||||
16
src/components/sheet/modal.tsx
Normal file
16
src/components/sheet/modal.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Modal as MantineModal, Title } from "@mantine/core";
|
||||
import { PropsWithChildren } from "react";
|
||||
|
||||
interface ModalProps extends PropsWithChildren {
|
||||
title?: string;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const Modal: React.FC<ModalProps> = ({ title, children, opened, onClose }) => (
|
||||
<MantineModal opened={opened} onClose={onClose} title={<Title order={3}>{title}</Title>}>
|
||||
{children}
|
||||
</MantineModal>
|
||||
)
|
||||
|
||||
export default Modal;
|
||||
30
src/components/sheet/sheet.tsx
Normal file
30
src/components/sheet/sheet.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { PropsWithChildren, useCallback } from "react";
|
||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||
import Drawer from "./drawer";
|
||||
import Modal from "./modal";
|
||||
import { Box, ScrollArea } from "@mantine/core";
|
||||
|
||||
interface SheetProps extends PropsWithChildren {
|
||||
title?: string;
|
||||
opened: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
}
|
||||
|
||||
const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => {
|
||||
const isMobile = useIsMobile();
|
||||
const handleClose = useCallback(() => onChange(false), [onChange]);
|
||||
|
||||
const SheetComponent = isMobile ? Drawer : Modal;
|
||||
|
||||
return (
|
||||
<SheetComponent title={title} opened={opened} onChange={onChange} onClose={handleClose}>
|
||||
<ScrollArea style={{ flex: 1 }} scrollbarSize={8} scrollbars='y' type='scroll'>
|
||||
<Box mah='70vh'>
|
||||
{children}
|
||||
</Box>
|
||||
</ScrollArea>
|
||||
</SheetComponent>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sheet;
|
||||
7
src/components/sheet/slide-panel/index.ts
Normal file
7
src/components/sheet/slide-panel/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { SlidePanel } from './slide-panel';
|
||||
|
||||
export * from './slide-panel';
|
||||
export * from './slide-panel-field';
|
||||
export * from './slide-panel-context';
|
||||
|
||||
export default SlidePanel;
|
||||
18
src/components/sheet/slide-panel/slide-panel-context.tsx
Normal file
18
src/components/sheet/slide-panel/slide-panel-context.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ComponentType, createContext } from "react";
|
||||
|
||||
interface SlidePanelContextType {
|
||||
openPanel: (config: PanelConfig) => void;
|
||||
closePanel: () => void;
|
||||
}
|
||||
|
||||
interface PanelConfig {
|
||||
title: string;
|
||||
Component: ComponentType<any>;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
componentProps?: Record<string, any>;
|
||||
}
|
||||
|
||||
const SlidePanelContext = createContext<SlidePanelContextType | null>(null);
|
||||
|
||||
export { SlidePanelContext, type SlidePanelContextType, type PanelConfig };
|
||||
89
src/components/sheet/slide-panel/slide-panel-field.tsx
Normal file
89
src/components/sheet/slide-panel/slide-panel-field.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Box, Text, UnstyledButton, Flex, Stack } from "@mantine/core";
|
||||
import { CaretRightIcon } from "@phosphor-icons/react";
|
||||
import { ComponentType, useContext } from "react";
|
||||
import { SlidePanelContext } from "./slide-panel-context";
|
||||
|
||||
interface SlidePanelFieldProps {
|
||||
key: string;
|
||||
value?: any;
|
||||
onChange?: (value: any) => void;
|
||||
Component: ComponentType<any>;
|
||||
title: string;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
formatValue?: (value: any) => string;
|
||||
componentProps?: Record<string, any>;
|
||||
withAsterisk?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const SlidePanelField = ({
|
||||
value,
|
||||
onChange,
|
||||
Component,
|
||||
title,
|
||||
label,
|
||||
placeholder = "Select value",
|
||||
withAsterisk = false,
|
||||
formatValue,
|
||||
componentProps,
|
||||
error,
|
||||
}: SlidePanelFieldProps) => {
|
||||
const context = useContext(SlidePanelContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('SlidePanelField must be used within a SlidePanel');
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
if (!onChange) return;
|
||||
|
||||
context.openPanel({
|
||||
title,
|
||||
Component,
|
||||
value,
|
||||
onChange,
|
||||
componentProps,
|
||||
});
|
||||
};
|
||||
|
||||
const displayValue = () => {
|
||||
if (formatValue && value != null) {
|
||||
return formatValue(value);
|
||||
}
|
||||
if (value != null) {
|
||||
if (value instanceof Date) {
|
||||
return value.toLocaleDateString();
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
return placeholder;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<UnstyledButton
|
||||
onClick={handleClick}
|
||||
p='sm'
|
||||
style={{
|
||||
width: '100%',
|
||||
border: error ? '1px solid var(--mantine-color-error)' : '1px solid var(--mantine-color-dimmed)',
|
||||
borderRadius: 'var(--mantine-radius-lg)',
|
||||
backgroundColor: 'var(--mantine-color-body)',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Stack>
|
||||
<Text size="sm" fw={500}>{label}{withAsterisk && <Text span size="sm" c='var(--mantine-color-error)' fw={500} ml={4}>*</Text>}</Text>
|
||||
<Text size="sm" c='dimmed'>{displayValue()}</Text>
|
||||
</Stack>
|
||||
<CaretRightIcon size={24} weight='thin' style={{ marginRight: '12px' }} />
|
||||
</Flex>
|
||||
</UnstyledButton>
|
||||
{error && <Text size="xs" c='var(--mantine-color-error)' fw={500} ml={4} mt={4}>{error}</Text>}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export { SlidePanelField };
|
||||
164
src/components/sheet/slide-panel/slide-panel.tsx
Normal file
164
src/components/sheet/slide-panel/slide-panel.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { Box, Text, Group, ActionIcon, Button, ScrollArea, Divider } from "@mantine/core";
|
||||
import { ArrowLeftIcon, CheckIcon } from "@phosphor-icons/react";
|
||||
import { useState, ReactNode} from "react";
|
||||
import { SlidePanelContext, type PanelConfig } from "./slide-panel-context";
|
||||
|
||||
interface SlidePanelProps {
|
||||
children: ReactNode;
|
||||
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
|
||||
onCancel?: () => void;
|
||||
submitText?: string;
|
||||
cancelText?: string;
|
||||
maxHeight?: string;
|
||||
formProps?: Record<string, any>;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* SlidePanel is a form component meant to be used inside a drawer/modal
|
||||
* It is used to create a form with multiple views/panels that slides in from the side
|
||||
* Use with SlidePanelField for an extra panel
|
||||
*/
|
||||
const SlidePanel = ({
|
||||
children,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitText = "Submit",
|
||||
cancelText = "Cancel",
|
||||
maxHeight = "70vh",
|
||||
formProps = {},
|
||||
loading = false
|
||||
}: SlidePanelProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [panelConfig, setPanelConfig] = useState<PanelConfig | null>(null);
|
||||
const [tempValue, setTempValue] = useState<any>(null);
|
||||
|
||||
const openPanel = (config: PanelConfig) => {
|
||||
setPanelConfig(config);
|
||||
setTempValue(config.value);
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const closePanel = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (panelConfig) {
|
||||
panelConfig.onChange(tempValue);
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
onSubmit(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<SlidePanelContext.Provider value={{ openPanel, closePanel }}>
|
||||
<Box
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: maxHeight,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
transform: isOpen ? 'translateX(-100%)' : 'translateX(0)',
|
||||
transition: 'transform 0.3s ease-in-out',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<form
|
||||
{...formProps}
|
||||
onSubmit={handleFormSubmit}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
...formProps.style,
|
||||
}}
|
||||
>
|
||||
<ScrollArea style={{ flex: 1 }} scrollbarSize={8} scrollbars='y' type='always'>
|
||||
<Box p="md">
|
||||
{children}
|
||||
</Box>
|
||||
</ScrollArea>
|
||||
|
||||
<Box p="sm">
|
||||
<Group gap="md">
|
||||
<Button type="submit" fullWidth loading={loading} disabled={loading}>
|
||||
{submitText}
|
||||
</Button>
|
||||
{onCancel && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
fullWidth
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
disabled={loading}
|
||||
>
|
||||
{cancelText}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
</form>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
transform: isOpen ? 'translateX(0)' : 'translateX(100%)',
|
||||
transition: 'transform 0.3s ease-in-out',
|
||||
backgroundColor: 'var(--mantine-color-body)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{panelConfig && (
|
||||
<>
|
||||
<Group justify="space-between" p="md" align="center" w='100%'>
|
||||
<ActionIcon variant="transparent" onClick={closePanel}>
|
||||
<ArrowLeftIcon size={24} />
|
||||
</ActionIcon>
|
||||
<Text fw={500}>{panelConfig.title}</Text>
|
||||
<ActionIcon variant="transparent" color="green" onClick={handleConfirm}>
|
||||
<CheckIcon size={24} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
<Divider h='1px' w='100%' bg='var(--mantine-color-dimmed)' my='xs'/>
|
||||
|
||||
<Box>
|
||||
<panelConfig.Component
|
||||
value={tempValue}
|
||||
onChange={setTempValue}
|
||||
{...(panelConfig.componentProps || {})}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</SlidePanelContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export { SlidePanel };
|
||||
20
src/components/sheet/styles.module.css
Normal file
20
src/components/sheet/styles.module.css
Normal file
@@ -0,0 +1,20 @@
|
||||
.drawerOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
z-index: 101;
|
||||
}
|
||||
|
||||
.drawerContent {
|
||||
z-index: 999;
|
||||
background-color: var(--mantine-color-body);
|
||||
border-top-left-radius: 20px;
|
||||
border-top-right-radius: 20px;
|
||||
margin-top: 24px;
|
||||
height: fit-content;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
outline: none;
|
||||
}
|
||||
Reference in New Issue
Block a user