This commit is contained in:
2025-02-21 14:56:02 -03:00
parent a8d0667b89
commit 634f422528
9 changed files with 335 additions and 39 deletions

View File

@@ -0,0 +1,145 @@
import React, { forwardRef, useContext, useId } from 'react'
import { ExclamationCircleIcon } from '@heroicons/react/24/outline'
import { omit } from 'ramda'
import Loader from './loader'
import clsx from 'clsx'
const ControlContext = React.createContext({})
function ControlProvider({ children, ...props }) {
return (
<ControlContext.Provider value={props}>{children}</ControlContext.Provider>
)
}
function useControl(props) {
const field = useContext(ControlContext)
return { ...field, ...props }
}
export const Control = forwardRef(function Control(
{ as = 'div', children, ...props },
ref,
) {
const id = useId()
const props_ = omit(['id'], props)
return (
<ControlProvider id={props?.id || id} {...props_}>
{React.createElement(as, { ref, ...props }, children)}
</ControlProvider>
)
})
export function Button({
children,
as = 'button',
className,
isLoading = false,
...props
}) {
if (isLoading) {
props['disabled'] = isLoading
}
return React.createElement(
as,
{
className: clsx(
'font-medium text-green-primary rounded-lg bg-green-secondary hover:bg-green-support',
'h-12 px-4 relative',
'disabled:bg-green-secondary/50 disabled:pointer-events-none',
'transition',
className,
),
...props,
},
<>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-green-secondary rounded-xl">
<Loader className="w-5 text-white" />
</div>
)}
{children}
</>,
)
}
export function Label({ children, className, ...props }) {
const { id, htmlFor, className: _, ...field } = useControl(props)
return (
<label
className={clsx(
"black cursor-pointer aria-required:after:content-['*'] aria-required:after:ml-0.5 aria-required:after:text-red-500",
className,
)}
htmlFor={htmlFor ?? id}
{...field}
>
{children}
</label>
)
}
export const Input = forwardRef(function Input(
{ as = 'input', size = 'base', className, children, ...props },
ref,
) {
const { className: _, ...field } = useControl(props)
const sizes = { base: 'h-12' }
return React.createElement(
as,
{
className: clsx(
'bg-white outline-none px-4 rounded-lg transition',
'border border-green-light dark:border-gray-700 dark:bg-gray-800',
'focus:ring-1 focus:border-green-secondary focus:ring-green-secondary focus:placeholder:text-transparent',
// Tailwind's won't inherit focus behavior; you must define it explicitly for both modes.
'dark:focus:border-green-secondary',
'aria-[invalid=true]:border-red-400 aria-[invalid=true]:ring-red-400',
'dark:aria-[invalid=true]:border-red-500 dark:aria-[invalid=true]:ring-red-500',
'disabled:text-gray-400 disabled:border-gray-300 disabled:bg-gray-200',
'dark:disabled:text-gray-500 dark:disabled:bg-gray-700',
sizes?.[size],
className,
),
ref,
...field,
},
children,
)
})
export const Checkbox = forwardRef(function Checkbox(
{ className, ...props },
ref,
) {
const { className: _, ...field } = useControl(props)
return (
<input
type="checkbox"
className={clsx(
'text-green-secondary border border-gray-300',
'focus:ring-2 focus:border-green-secondary focus:ring-green-secondary focus:ring-offset-0 focus:ring-opacity-30',
'dark:border-gray-700 dark:bg-gray-800 focus:dark:border-security dark:checked:bg-green-secondary dark:checked:border-security dark:disabled:bg-gray-700',
'disabled:bg-gray-200 outline-none rounded transition',
className,
)}
ref={ref}
{...field}
/>
)
})
export function Error({ children }) {
return (
<div className="text-sm text-red-500 flex items-start gap-0.5">
<ExclamationCircleIcon className="w-4 mt-[1.5px] flex-shrink-0" />
<div>{children}</div>
</div>
)
}

View File

@@ -0,0 +1,27 @@
import clsx from 'clsx'
export default function Loader({ className, ...props }) {
return (
<svg
className={clsx('animate-spin', className)}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-50"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
)
}

View File

@@ -16,7 +16,7 @@ interface AuthContextType {
username: string username: string
password: string password: string
}) => Promise<Auth.SignInOutput> }) => Promise<Auth.SignInOutput>
currentUser: () => Promise<Auth.FetchUserAttributesOutput | void> signOut: () => Promise<void>
} }
const AuthContext = createContext<AuthContextType | null>(null) const AuthContext = createContext<AuthContextType | null>(null)
@@ -29,17 +29,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [authUser, setAuthUser] = const [authUser, setAuthUser] =
useState<Auth.FetchUserAttributesOutput | null>(null) useState<Auth.FetchUserAttributesOutput | null>(null)
const currentUser = useCallback(async () => {
try {
const currentUser = await Auth.fetchUserAttributes()
setAuthUser(currentUser)
return currentUser
} catch {
setAuthUser(null)
}
}, [])
const signIn = useCallback( const signIn = useCallback(
async ({ async ({
username, username,
@@ -65,16 +54,22 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
[], [],
) )
const ctxValue = useMemo<AuthContextType>( const signOut = useCallback(async (): Promise<void> => {
try {
return await Auth.signOut()
} catch {}
}, [])
const authContext = useMemo<AuthContextType>(
() => ({ () => ({
authUser, authUser,
signIn, signIn,
currentUser, signOut,
}), }),
[authUser, signIn, currentUser], [authUser, signIn, signOut],
) )
return ( return (
<AuthContext.Provider value={ctxValue}>{children}</AuthContext.Provider> <AuthContext.Provider value={authContext}>{children}</AuthContext.Provider>
) )
} }

View File

@@ -1,21 +1,48 @@
import { useNavigation } from 'react-router' import { useNavigation, redirect, useNavigate } from 'react-router'
import { fetchAuthSession } from 'aws-amplify/auth'
import { Link } from 'react-router' import { Link } from 'react-router'
import { Outlet } from 'react-router' import { Outlet } from 'react-router'
import { useAuth } from '~/hooks/use-auth'
export async function clientLoader() {
const session = await fetchAuthSession()
if (!session?.tokens?.idToken) {
throw redirect('/auth')
}
}
export default function Layout() { export default function Layout() {
const navigation = useNavigation() const navigation = useNavigation()
const navigate = useNavigate()
const { signOut } = useAuth()
const isNavigating = Boolean(navigation.location) const isNavigating = Boolean(navigation.location)
return ( return (
<> <>
<ul> <nav>
<ul className="flex gap-1">
<li> <li>
<Link to="/orders">Pagamentos</Link> <Link to="/orders">Pagamentos</Link>
</li>
<li>
<Link to="/users">Usuários</Link> <Link to="/users">Usuários</Link>
</li>
<li>
<Link to="/enrollments">Matrículas</Link> <Link to="/enrollments">Matrículas</Link>
</li> </li>
</ul> </ul>
<button
onClick={() => {
signOut()
navigate('/auth')
}}
>
Sair
</button>
</nav>
{isNavigating ? <>Loading...</> : <Outlet />} {isNavigating ? <>Loading...</> : <Outlet />}
</> </>
) )

View File

@@ -49,3 +49,17 @@ export default function Auth() {
</> </>
) )
} }
export function Card({ children }: { children: React.ReactNode }) {
return (
<Container>
<div className="space-y-2.5 xl:space-y-5 rounded-xl bg-yellow-50 dark:bg-gray-700/60 p-4 lg:p-8 drop-shadow-sm shadow-sm">
{children}
</div>
</Container>
)
}
export function Container({ children }: { children: React.ReactNode }) {
return <div className="w-full 2xl:w-[26rem]">{children}</div>
}

View File

@@ -10,6 +10,7 @@ import {
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Amplify } from 'aws-amplify' import { Amplify } from 'aws-amplify'
import { AuthProvider } from '~/hooks/use-auth' import { AuthProvider } from '~/hooks/use-auth'
import { Smallest as Logo } from '~/components/logo'
import amplifyconfig from './amplifyconfiguration.json' import amplifyconfig from './amplifyconfiguration.json'
Amplify.configure(amplifyconfig) Amplify.configure(amplifyconfig)
@@ -59,16 +60,7 @@ export default function App() {
export function HydrateFallback() { export function HydrateFallback() {
return ( return (
<div className="flex items-center justify-center min-h-screen bg-gray-100"> <Logo className="w-14 absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2" />
<div className="w-10 h-10 border-4 border-gray-300 border-t-4 border-t-blue-500 rounded-full animate-spin" />
<style>{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}</style>
</div>
) )
} }

View File

@@ -1,8 +1,35 @@
import { useForm } from 'react-hook-form'
import { useAuth } from '~/hooks/use-auth'
import { Card } from '~/layouts/auth'
import { Control, Label, Input, Button } from '~/components/form'
import { useNavigate } from 'react-router'
export default function Signin() { export default function Signin() {
const navigate = useNavigate()
const { register, handleSubmit, formState } = useForm()
const { signIn } = useAuth()
const onSubmit = async (payload) => {
const { nextStep } = await signIn(payload)
return navigate('/')
}
return ( return (
<form> <Card>
Username <form onSubmit={handleSubmit(onSubmit)}>
<input /> <Control>
<Label>Email ou CPF</Label>
<Input {...register('username')} />
</Control>
<Control>
<Label>Senha</Label>
<Input type="password" {...register('password')} />
</Control>
<Button type="submit" isLoading={formState.isSubmitting}>
Entrar
</Button>
</form> </form>
</Card>
) )
} }

View File

@@ -6,15 +6,21 @@
"": { "": {
"name": "dashboard_js", "name": "dashboard_js",
"dependencies": { "dependencies": {
"@heroicons/react": "^2.2.0",
"@hookform/error-message": "^2.0.1",
"@hookform/resolvers": "^4.1.0",
"@react-router/fs-routes": "^7.2.0", "@react-router/fs-routes": "^7.2.0",
"@react-router/node": "^7.2.0", "@react-router/node": "^7.2.0",
"@react-router/serve": "^7.2.0", "@react-router/serve": "^7.2.0",
"@tanstack/react-query": "^5.66.9", "@tanstack/react-query": "^5.66.9",
"aws-amplify": "^6.13.1", "aws-amplify": "^6.13.1",
"axios": "^1.7.9", "axios": "^1.7.9",
"clsx": "^2.1.1",
"isbot": "^5.1.17", "isbot": "^5.1.17",
"ramda": "^0.30.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"react-router": "^7.2.0" "react-router": "^7.2.0"
}, },
"devDependencies": { "devDependencies": {
@@ -2450,6 +2456,38 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@heroicons/react": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz",
"integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==",
"license": "MIT",
"peerDependencies": {
"react": ">= 16 || ^19.0.0-rc"
}
},
"node_modules/@hookform/error-message": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@hookform/error-message/-/error-message-2.0.1.tgz",
"integrity": "sha512-U410sAr92xgxT1idlu9WWOVjndxLdgPUHEB8Schr27C9eh7/xUnITWpCMF93s+lGiG++D4JnbSnrb5A21AdSNg==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
"react-hook-form": "^7.0.0"
}
},
"node_modules/@hookform/resolvers": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-4.1.0.tgz",
"integrity": "sha512-fX/uHKb+OOCpACLc6enuTQsf0ZpRrKbeBBPETg5PCPLCIYV6osP2Bw6ezuclM61lH+wBF9eXcuC0+BFh9XOEnQ==",
"license": "MIT",
"dependencies": {
"caniuse-lite": "^1.0.30001698"
},
"peerDependencies": {
"react-hook-form": "^7.0.0"
}
},
"node_modules/@isaacs/cliui": { "node_modules/@isaacs/cliui": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -5209,7 +5247,6 @@
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@@ -7649,6 +7686,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/ramda": {
"version": "0.30.1",
"resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz",
"integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ramda"
}
},
"node_modules/range-parser": { "node_modules/range-parser": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -7736,6 +7783,22 @@
"react": "^19.0.0" "react": "^19.0.0"
} }
}, },
"node_modules/react-hook-form": {
"version": "7.54.2",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.54.2.tgz",
"integrity": "sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-hotkeys-hook": { "node_modules/react-hotkeys-hook": {
"version": "4.6.1", "version": "4.6.1",
"resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-4.6.1.tgz", "resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-4.6.1.tgz",

View File

@@ -9,15 +9,21 @@
"typecheck": "react-router typegen && tsc" "typecheck": "react-router typegen && tsc"
}, },
"dependencies": { "dependencies": {
"@heroicons/react": "^2.2.0",
"@hookform/error-message": "^2.0.1",
"@hookform/resolvers": "^4.1.0",
"@react-router/fs-routes": "^7.2.0", "@react-router/fs-routes": "^7.2.0",
"@react-router/node": "^7.2.0", "@react-router/node": "^7.2.0",
"@react-router/serve": "^7.2.0", "@react-router/serve": "^7.2.0",
"@tanstack/react-query": "^5.66.9", "@tanstack/react-query": "^5.66.9",
"aws-amplify": "^6.13.1", "aws-amplify": "^6.13.1",
"axios": "^1.7.9", "axios": "^1.7.9",
"clsx": "^2.1.1",
"isbot": "^5.1.17", "isbot": "^5.1.17",
"ramda": "^0.30.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"react-router": "^7.2.0" "react-router": "^7.2.0"
}, },
"devDependencies": { "devDependencies": {