This commit is contained in:
2025-11-11 17:06:25 -03:00
parent c00a42ea39
commit 39aedac972
30 changed files with 802 additions and 220 deletions

View File

@@ -46,6 +46,11 @@ app.include_router(orders.router, prefix='/orders')
app.include_router(orgs.custom_pricing, prefix='/orgs') app.include_router(orgs.custom_pricing, prefix='/orgs')
@app.get('/health')
def health():
return {'status': 'available'}
@app.exception_handler(ServiceError) @app.exception_handler(ServiceError)
def exc_error(exc: ServiceError): def exc_error(exc: ServiceError):
return JSONResponse( return JSONResponse(

View File

@@ -0,0 +1,45 @@
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
from aws_lambda_powertools import Logger, Tracer
from aws_lambda_powertools.utilities.data_classes import (
EventBridgeEvent,
event_source,
)
from aws_lambda_powertools.utilities.typing import LambdaContext
logger = Logger(__name__)
tracer = Tracer()
urls = ['https://bcs7fgb9og.execute-api.sa-east-1.amazonaws.com/health']
@tracer.capture_lambda_handler
@event_source(data_class=EventBridgeEvent)
def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
results = []
with ThreadPoolExecutor(max_workers=5) as executor:
futures = [executor.submit(ping, url) for url in urls]
for future in as_completed(futures):
results.append(future.result())
logger.info(results)
return True
def ping(url: str):
try:
r = requests.get(url, timeout=4)
r.raise_for_status()
except requests.exceptions.RequestException as exc:
return {
'url': url,
'error': str(exc),
}
else:
return {
'url': url,
'status': r.status_code,
}

View File

@@ -1,5 +1,5 @@
AWSTemplateFormatVersion: "2010-09-09" AWSTemplateFormatVersion: '2010-09-09'
Transform: "AWS::Serverless-2016-10-31" Transform: 'AWS::Serverless-2016-10-31'
Parameters: Parameters:
UserTable: UserTable:
@@ -46,11 +46,16 @@ Resources:
Properties: Properties:
RetentionInDays: 90 RetentionInDays: 90
ScheduleLog:
Type: AWS::Logs::LogGroup
Properties:
RetentionInDays: 7
HttpApi: HttpApi:
Type: AWS::Serverless::HttpApi Type: AWS::Serverless::HttpApi
Properties: Properties:
CorsConfiguration: CorsConfiguration:
AllowOrigins: ["*"] AllowOrigins: ['*']
AllowMethods: [GET, POST, PUT, DELETE, PATCH, OPTIONS] AllowMethods: [GET, POST, PUT, DELETE, PATCH, OPTIONS]
AllowHeaders: [Content-Type, X-Requested-With, Authorization] AllowHeaders: [Content-Type, X-Requested-With, Authorization]
AllowCredentials: false AllowCredentials: false
@@ -59,13 +64,13 @@ Resources:
DefaultAuthorizer: OAuth2Authorizer DefaultAuthorizer: OAuth2Authorizer
Authorizers: Authorizers:
OAuth2Authorizer: OAuth2Authorizer:
IdentitySource: "$request.header.Authorization" IdentitySource: '$request.header.Authorization'
JwtConfiguration: JwtConfiguration:
issuer: "https://id.saladeaula.digital" issuer: 'https://id.saladeaula.digital'
audience: audience:
- "1a5483ab-4521-4702-9115-5857ac676851" - '1a5483ab-4521-4702-9115-5857ac676851'
- "1db63660-063d-4280-b2ea-388aca4a9459" - '1db63660-063d-4280-b2ea-388aca4a9459'
- "78a0819e-1f9b-4da1-b05f-40ec0eaed0c8" - '78a0819e-1f9b-4da1-b05f-40ec0eaed0c8'
HttpApiFunction: HttpApiFunction:
Type: AWS::Serverless::Function Type: AWS::Serverless::Function
@@ -97,12 +102,33 @@ Resources:
Path: /{proxy+} Path: /{proxy+}
Method: ANY Method: ANY
ApiId: !Ref HttpApi ApiId: !Ref HttpApi
Health:
Type: HttpApi
Properties:
Path: /health
Method: GET
ApiId: !Ref HttpApi
Auth:
Authorizer: NONE
EventKeepWarmScheduledFunction:
Type: AWS::Serverless::Function
Properties:
Handler: keep_warm.lambda_handler
LoggingConfig:
LogGroup: !Ref ScheduleLog
Events:
ScheduleEvent:
Type: ScheduleV2
Properties:
ScheduleExpression: 'cron(*/3 5-23 * * ? *)'
ScheduleExpressionTimezone: America/Sao_Paulo
Outputs: Outputs:
HttpApiUrl: HttpApiUrl:
Description: URL of your API endpoint Description: URL of your API endpoint
Value: Value:
Fn::Sub: "https://${HttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}" Fn::Sub: 'https://${HttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}'
HttpApiId: HttpApiId:
Description: Api ID of HttpApi Description: Api ID of HttpApi
Value: Value:

View File

@@ -0,0 +1,5 @@
import keep_warm
def test_keep_warm():
keep_warm.lambda_handler({}, {})

View File

@@ -12,7 +12,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { NavMain } from '@/components/nav-main' import { NavMain } from '@/components/nav-main'
import { OrgSwitcher } from '@/components/org-switcher' import { WorkspaceSwitcher } from '@/components/workspace-switcher'
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -42,11 +42,6 @@ const data = {
url: '/admins', url: '/admins',
icon: ShieldUserIcon icon: ShieldUserIcon
} }
// {
// title: 'Webhooks',
// url: '/webhooks',
// icon: WebhookIcon
// }
], ],
navContent: [ navContent: [
{ {
@@ -76,7 +71,7 @@ export function AppSidebar({ orgs = [] }) {
return ( return (
<Sidebar collapsible="icon"> <Sidebar collapsible="icon">
<SidebarHeader> <SidebarHeader>
<OrgSwitcher orgs={orgs} /> <WorkspaceSwitcher />
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
<NavMain data={data} /> <NavMain data={data} />

View File

@@ -29,6 +29,7 @@ import {
TableHeader, TableHeader,
TableRow TableRow
} from '@repo/ui/components/ui/table' } from '@repo/ui/components/ui/table'
import { cn } from '@repo/ui/lib/utils'
import { DataTablePagination } from './pagination' import { DataTablePagination } from './pagination'
interface DataTableProps<TData, TValue> { interface DataTableProps<TData, TValue> {
@@ -145,7 +146,7 @@ export function DataTable<TData, TValue>({
<CardContent> <CardContent>
{children} {children}
<Table_> <Table_ className="table-auto">
<TableHeader> <TableHeader>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<TableRow <TableRow
@@ -154,7 +155,13 @@ export function DataTable<TData, TValue>({
> >
{headerGroup.headers.map((header) => { {headerGroup.headers.map((header) => {
return ( return (
<TableHead key={header.id} className="p-2.5"> <TableHead
key={header.id}
className={cn(
'p-2.5',
header.column.columnDef.meta?.className
)}
>
{header.isPlaceholder {header.isPlaceholder
? null ? null
: flexRender( : flexRender(
@@ -176,7 +183,13 @@ export function DataTable<TData, TValue>({
data-state={row.getIsSelected() && 'selected'} data-state={row.getIsSelected() && 'selected'}
> >
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="p-2.5"> <TableCell
key={cell.id}
className={cn(
'p-2.5',
cell.column.columnDef.meta?.className
)}
>
{flexRender( {flexRender(
cell.column.columnDef.cell, cell.column.columnDef.cell,
cell.getContext() cell.getContext()

View File

@@ -2,7 +2,7 @@
import { formatCNPJ } from '@brazilian-utils/brazilian-utils' import { formatCNPJ } from '@brazilian-utils/brazilian-utils'
import { CheckIcon, ChevronsUpDownIcon, PlusIcon } from 'lucide-react' import { CheckIcon, ChevronsUpDownIcon, PlusIcon } from 'lucide-react'
import { useState } from 'react' import { createContext, useContext, useState } from 'react'
import { useLocation, useParams } from 'react-router' import { useLocation, useParams } from 'react-router'
import { import {
@@ -23,23 +23,60 @@ import {
} from '@repo/ui/components/ui/sidebar' } from '@repo/ui/components/ui/sidebar'
import { initials } from '@repo/ui/lib/utils' import { initials } from '@repo/ui/lib/utils'
type Org = { type Workspace = {
id: string id: string
name: string name: string
cnpj: string cnpj: string
} }
export function OrgSwitcher({ orgs }: { orgs: Org[] }) { type WorkspaceContextProps = {
workspaces: Workspace[]
activeWorkspace: Workspace | null
setActiveWorkspace: React.Dispatch<React.SetStateAction<Workspace | null>>
}
const WorkspaceContext = createContext<WorkspaceContextProps | null>(null)
export function useWorksapce() {
const ctx = useContext(WorkspaceContext)
if (!ctx) {
throw new Error('WorkspaceContext is null')
}
return ctx
}
export function WorkspaceProvider({
workspaces,
children
}: {
workspaces: Workspace[]
children: React.ReactNode
}) {
const { orgid } = useParams()
const [activeWorkspace, setActiveWorkspace] = useState<Workspace | null>(
() => workspaces.find((ws) => ws.id === orgid) ?? null
)
return (
<WorkspaceContext
value={{ workspaces, activeWorkspace, setActiveWorkspace }}
>
{children}
</WorkspaceContext>
)
}
export function WorkspaceSwitcher() {
const location = useLocation() const location = useLocation()
const { isMobile, state } = useSidebar() const { isMobile, state } = useSidebar()
const { orgid } = useParams() const { activeWorkspace, setActiveWorkspace, workspaces } = useWorksapce()
const org = orgs.find((org) => org.id === orgid) as Org
const [activeOrg, setActiveOrg] = useState<Org>(org)
const [, fragment, _] = location.pathname.slice(1).split('/') const [, fragment, _] = location.pathname.slice(1).split('/')
const onSelect = (org: Org) => { const onSelect = (ws: Workspace) => {
setActiveOrg(org) setActiveWorkspace(ws)
window.location.assign(`/${org.id}/${fragment}`) window.location.assign(`/${ws.id}/${fragment}`)
} }
return ( return (
@@ -55,17 +92,20 @@ export function OrgSwitcher({ orgs }: { orgs: Org[] }) {
className="aria-expanded:border flex aspect-square size-8 items-center justify-center rounded-lg" className="aria-expanded:border flex aspect-square size-8 items-center justify-center rounded-lg"
aria-expanded={state === 'expanded'} aria-expanded={state === 'expanded'}
> >
{initials(activeOrg?.name)} {initials(activeWorkspace?.name)}
</div> </div>
<div className="grid flex-1 text-left text-sm leading-tight"> <div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{activeOrg?.name}</span> <span className="truncate font-medium">
{activeWorkspace?.name}
</span>
<span className="truncate text-xs text-muted-foreground"> <span className="truncate text-xs text-muted-foreground">
{formatCNPJ(activeOrg?.cnpj)} {formatCNPJ(activeWorkspace?.cnpj)}
</span> </span>
</div> </div>
<ChevronsUpDownIcon className="ml-auto" /> <ChevronsUpDownIcon className="ml-auto" />
</SidebarMenuButton> </SidebarMenuButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg" className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
align="start" align="start"
@@ -75,20 +115,23 @@ export function OrgSwitcher({ orgs }: { orgs: Org[] }) {
<DropdownMenuLabel className="text-muted-foreground text-xs"> <DropdownMenuLabel className="text-muted-foreground text-xs">
Empresas Empresas
</DropdownMenuLabel> </DropdownMenuLabel>
{orgs.map((org, index) => (
{workspaces.map((workspace, index) => (
<DropdownMenuItem <DropdownMenuItem
key={index} key={index}
onClick={() => onSelect(org)} onClick={() => onSelect(workspace)}
className="group gap-2 p-2 cursor-pointer aria-selected:pointer-events-none" className="group gap-2 p-2 cursor-pointer aria-selected:pointer-events-none"
aria-selected={org.id === activeOrg.id} aria-selected={workspace.id === activeWorkspace?.id}
> >
<div className="flex size-8 items-center justify-center rounded-lg border"> <div className="flex size-8 items-center justify-center rounded-lg border">
{initials(org?.name)} {initials(workspace?.name)}
</div> </div>
<div className="grid flex-1 text-left text-sm leading-tight"> <div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{org?.name}</span> <span className="truncate font-medium">
{workspace?.name}
</span>
<span className="truncate text-xs text-muted-foreground"> <span className="truncate text-xs text-muted-foreground">
{formatCNPJ(org?.cnpj)} {formatCNPJ(workspace?.cnpj)}
</span> </span>
</div> </div>
<DropdownMenuShortcut className="not-group-aria-selected:hidden"> <DropdownMenuShortcut className="not-group-aria-selected:hidden">
@@ -96,7 +139,9 @@ export function OrgSwitcher({ orgs }: { orgs: Org[] }) {
</DropdownMenuShortcut> </DropdownMenuShortcut>
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem className="gap-2 p-2"> <DropdownMenuItem className="gap-2 p-2">
<div className="flex size-6 items-center justify-center rounded-md border bg-transparent"> <div className="flex size-6 items-center justify-center rounded-md border bg-transparent">
<PlusIcon className="size-4" /> <PlusIcon className="size-4" />

View File

@@ -43,4 +43,4 @@ export default async function handleRequest(
} }
// https://reactrouter.com/how-to/suspense#timeouts // https://reactrouter.com/how-to/suspense#timeouts
export const streamTimeout = 6_000 export const streamTimeout = 7_000

View File

@@ -31,9 +31,15 @@ export function request({
const requestId = context.get(requestIdContext) as string const requestId = context.get(requestIdContext) as string
const user = context.get(userContext) as User const user = context.get(userContext) as User
const url_ = new URL(url, context.cloudflare.env.API_URL) const url_ = new URL(url, context.cloudflare.env.API_URL)
const headers = new Headers( const headers = new Headers({
Object.assign({ Authorization: `Bearer ${user.accessToken}` }, _headers) Authorization: `Bearer ${user.accessToken}`
) })
if (_headers instanceof Headers) {
_headers.forEach((value, key) => headers.set(key, value))
} else {
Object.entries(_headers).forEach(([key, value]) => headers.set(key, value))
}
console.log( console.log(
`[${new Date().toISOString()}] [${requestId}] ${method} ${url_.toString()}` `[${new Date().toISOString()}] [${requestId}] ${method} ${url_.toString()}`

View File

@@ -119,9 +119,6 @@ export default function Route({ loaderData: { data } }) {
{selectedRows.length ? ( {selectedRows.length ? (
<> <>
<div className="flex gap-2.5 items-center"> <div className="flex gap-2.5 items-center">
<Button variant="outline">
<TagIcon /> Marcador
</Button>
<DropdownMenuExport <DropdownMenuExport
headers={headers} headers={headers}
selectedRows={selectedRows} selectedRows={selectedRows}

View File

@@ -43,7 +43,14 @@ export const columns: ColumnDef<Order>[] = [
} }
}, },
{ {
header: 'Data de venc.', header: 'Comprado em',
cell: ({ row }) => {
const createdAt = new Date(row.original.create_date)
return formatted.format(createdAt)
}
},
{
header: 'Vencimento em',
cell: ({ row }) => { cell: ({ row }) => {
try { try {
const dueDate = new Date(row.original.due_date) const dueDate = new Date(row.original.due_date)
@@ -54,10 +61,14 @@ export const columns: ColumnDef<Order>[] = [
} }
}, },
{ {
header: 'Comprado em', header: 'Pago em',
cell: ({ row }) => { cell: ({ row }) => {
const createdAt = new Date(row.original.create_date) if (row.original.payment_date) {
return formatted.format(createdAt) const createdAt = new Date(row.original.payment_date)
return formatted.format(createdAt)
}
return <></>
} }
} }
] ]

View File

@@ -1,12 +1,9 @@
import type { Route } from './+types' import type { Route } from './+types'
import { isValidCPF } from '@brazilian-utils/brazilian-utils'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { PatternFormat } from 'react-number-format' import { PatternFormat } from 'react-number-format'
import { Link, useOutletContext } from 'react-router' import { Link, useOutletContext } from 'react-router'
import { z } from 'zod'
import type { User } from '@/routes/_.$orgid.users.$id/route'
import { Button } from '@repo/ui/components/ui/button' import { Button } from '@repo/ui/components/ui/button'
import { import {
Card, Card,
@@ -27,13 +24,8 @@ import { Input } from '@repo/ui/components/ui/input'
import { Spinner } from '@repo/ui/components/ui/spinner' import { Spinner } from '@repo/ui/components/ui/spinner'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
const formSchema = z.object({ import type { User } from '../_.$orgid.users.$id/route'
name: z.string().trim().nonempty('Digite seu nome'), import { formSchema, type Schema } from '../_.$orgid.users.add/route'
email: z.email(),
cpf: z
.string('CPF obrigatório')
.refine(isValidCPF, { message: 'CPF inválido' })
})
export default function Route() { export default function Route() {
const { user } = useOutletContext() as { user: User } const { user } = useOutletContext() as { user: User }
@@ -43,7 +35,7 @@ export default function Route() {
}) })
const { handleSubmit, control, formState } = form const { handleSubmit, control, formState } = form
const onSubmit = async (data: z.infer<typeof formSchema>) => { const onSubmit = async (data: Schema) => {
console.log(data) console.log(data)
} }
@@ -133,7 +125,7 @@ export default function Route() {
disabled={formState.isSubmitting} disabled={formState.isSubmitting}
> >
{formState.isSubmitting && <Spinner />} {formState.isSubmitting && <Spinner />}
Editar colaborador Editar
</Button> </Button>
</div> </div>
</form> </form>

View File

@@ -1,10 +1,27 @@
import type { Route } from './+types' import type { Route } from './+types'
import { Suspense } from 'react' import { Suspense } from 'react'
import { Await } from 'react-router' import { Await, useOutletContext } from 'react-router'
import { request as req } from '@/lib/request' import { request as req } from '@/lib/request'
import { Skeleton } from '@repo/ui/components/skeleton' import { Skeleton } from '@repo/ui/components/skeleton'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@repo/ui/components/ui/card'
import { Item, ItemContent, ItemGroup } from '@repo/ui/components/ui/item'
import { Kbd } from '@repo/ui/components/ui/kbd'
import {
NativeSelect,
NativeSelectOption
} from '@repo/ui/components/ui/native-select'
import { Button } from '@repo/ui/components/ui/button'
import type { User } from '../_.$orgid.users.$id/route'
export async function loader({ params, request, context }: Route.LoaderArgs) { export async function loader({ params, request, context }: Route.LoaderArgs) {
const { id } = params const { id } = params
@@ -18,16 +35,58 @@ export async function loader({ params, request, context }: Route.LoaderArgs) {
} }
export default function Route({ loaderData: { data } }) { export default function Route({ loaderData: { data } }) {
const { user } = useOutletContext() as { user: User }
return ( return (
<Suspense fallback={<Skeleton />}> <Suspense fallback={<Skeleton />}>
<Await resolve={data}> <Await resolve={data}>
{({ items = [] }) => ( {({ items = [] }) => (
<ul> <div className="space-y-4">
{items.map(({ sk }: { sk: string }, idx: number) => { <Card>
const [, email] = sk.split('#') <CardHeader>
return <li key={idx}>{email}</li> <CardTitle className="text-lg">Emails</CardTitle>
})} <CardDescription>
</ul> Podem ser associados vários emails a uma conta. É possível
usar qualquer email para recuperar a senha, mas apenas o email
principal receberá as mensagens.
</CardDescription>
</CardHeader>
<CardContent>
<ul>
{items.map(({ sk }: { sk: string }, idx: number) => {
const [, email] = sk.split('#')
return <li key={idx}>{email}</li>
})}
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Email principal</CardTitle>
<CardDescription>
<Kbd className="font-mono border">{user.email}</Kbd> será
usado para mensagens e pode ser usado para redefinições de
senha.
</CardDescription>
</CardHeader>
<CardContent>
<form className="flex gap-1.5">
<NativeSelect value={user.email}>
{items.map(({ sk }: { sk: string }, idx: number) => {
const [, email] = sk.split('#')
return (
<NativeSelectOption key={idx} value={email}>
{email}
</NativeSelectOption>
)
})}
</NativeSelect>
<Button>Mudar</Button>
</form>
</CardContent>
</Card>
</div>
)} )}
</Await> </Await>
</Suspense> </Suspense>

View File

@@ -1,40 +0,0 @@
import { Await } from 'react-router'
import type { Route } from './+types'
import { request as req } from '@/lib/request'
import { Skeleton } from '@repo/ui/components/skeleton'
import { Suspense } from 'react'
export async function loader({ params, request, context }: Route.LoaderArgs) {
const { id } = params
const r = req({
url: `/users/${id}/orgs`,
request,
context
}).then((r) => r.json())
return { data: r }
}
export default function Route({ loaderData: { data } }) {
return (
<Suspense fallback={<Skeleton />}>
<Await resolve={data}>
{({ items = [] }) => (
<ul>
{items.map(
({ name, cnpj }: { name: string; cnpj: string }, idx: number) => {
return (
<li key={idx}>
{name} {cnpj}
</li>
)
}
)}
</ul>
)}
</Await>
</Suspense>
)
}

View File

@@ -8,6 +8,7 @@ import {
} from 'react-router' } from 'react-router'
import { request as req } from '@/lib/request' import { request as req } from '@/lib/request'
import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar' import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar'
import { import {
Breadcrumb, Breadcrumb,
@@ -90,7 +91,7 @@ export default function Route({
</Avatar> </Avatar>
<ul> <ul>
<li className="font-bold">{user.name}</li> <li className="font-bold text-lg">{user.name}</li>
<li className="text-muted-foreground text-sm">{user.email}</li> <li className="text-muted-foreground text-sm">{user.email}</li>
</ul> </ul>
</div> </div>

View File

@@ -2,12 +2,23 @@
import { formatCPF } from '@brazilian-utils/brazilian-utils' import { formatCPF } from '@brazilian-utils/brazilian-utils'
import { type ColumnDef } from '@tanstack/react-table' import { type ColumnDef } from '@tanstack/react-table'
import { ArrowRight } from 'lucide-react' import {
ArrowRight,
EllipsisVerticalIcon,
PencilIcon,
UserRoundMinusIcon
} from 'lucide-react'
import { NavLink } from 'react-router' import { NavLink } from 'react-router'
import { Abbr } from '@/components/abbr' import { Abbr } from '@/components/abbr'
import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar' import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar'
import { Button } from '@repo/ui/components/ui/button' import { Button } from '@repo/ui/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@repo/ui/components/ui/dropdown-menu'
import { Spinner } from '@repo/ui/components/ui/spinner' import { Spinner } from '@repo/ui/components/ui/spinner'
import { initials } from '@repo/ui/lib/utils' import { initials } from '@repo/ui/lib/utils'
@@ -65,13 +76,6 @@ export const columns: ColumnDef<User>[] = [
return <></> return <></>
} }
}, },
{
header: 'Cadastrado em',
cell: ({ row }) => {
const created_at = new Date(row.original.createDate)
return formatted.format(created_at)
}
},
{ {
header: 'Último accesso', header: 'Último accesso',
cell: ({ row }) => { cell: ({ row }) => {
@@ -85,30 +89,47 @@ export const columns: ColumnDef<User>[] = [
} }
}, },
{ {
header: ' ', header: 'Cadastrado em',
meta: {
className: 'w-1/12'
},
cell: ({ row }) => { cell: ({ row }) => {
return ( const created_at = new Date(row.original.createDate)
<div className="flex justify-end"> return formatted.format(created_at)
<Button
variant="outline"
size="sm"
className="relative group"
asChild
>
<NavLink to={`${row.original?.id}`}>
{({ isPending }) => (
<>
{isPending && <Spinner className="absolute" />}
<span className="group-[.pending]:invisible">
Editar
</span>{' '}
<ArrowRight className="group-[.pending]:invisible" />
</>
)}
</NavLink>
</Button>
</div>
)
} }
},
{
id: 'actions',
cell: ({ row }) => (
<div className="flex justify-end items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="data-[state=open]:bg-muted text-muted-foreground cursor-pointer"
size="icon-sm"
>
<EllipsisVerticalIcon />
<span className="sr-only">Abrir menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36 *:cursor-pointer">
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<NavLink to={`${row.id}`}>
{({ isPending }) => (
<>
{isPending ? <Spinner /> : <PencilIcon />}
Editar
</>
)}
</NavLink>
</DropdownMenuItem>
<DropdownMenuItem variant="destructive">
<UserRoundMinusIcon /> Desvincular
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
} }
] ]

View File

@@ -61,6 +61,7 @@ export default function Route({ loaderData: { data } }) {
{({ hits, page, hitsPerPage, totalHits }) => { {({ hits, page, hitsPerPage, totalHits }) => {
return ( return (
<DataTable <DataTable
sort={[{ id: 'created_at', desc: true }]}
columns={columns} columns={columns}
data={hits as User[]} data={hits as User[]}
pageIndex={page - 1} pageIndex={page - 1}

View File

@@ -1,4 +1,12 @@
import { Link } from 'react-router' import type { Route } from './+types'
import { isValidCPF } from '@brazilian-utils/brazilian-utils'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { PatternFormat } from 'react-number-format'
import { Link, useFetcher } from 'react-router'
import { toast } from 'sonner'
import { z } from 'zod'
import { import {
Breadcrumb, Breadcrumb,
@@ -8,6 +16,7 @@ import {
BreadcrumbPage, BreadcrumbPage,
BreadcrumbSeparator BreadcrumbSeparator
} from '@repo/ui/components/ui/breadcrumb' } from '@repo/ui/components/ui/breadcrumb'
import { Button } from '@repo/ui/components/ui/button'
import { import {
Card, Card,
CardContent, CardContent,
@@ -15,8 +24,91 @@ import {
CardHeader, CardHeader,
CardTitle CardTitle
} from '@repo/ui/components/ui/card' } from '@repo/ui/components/ui/card'
import { Checkbox } from '@repo/ui/components/ui/checkbox'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@repo/ui/components/ui/form'
import { Input } from '@repo/ui/components/ui/input'
import { Label } from '@repo/ui/components/ui/label'
import { Spinner } from '@repo/ui/components/ui/spinner'
import { useWorksapce } from '@/components/workspace-switcher'
import { HttpMethod, request as req } from '@/lib/request'
import { useEffect } from 'react'
const isName = (name: string) => name && name.includes(' ')
export const formSchema = z.object({
name: z
.string()
.trim()
.nonempty('Digite um nome')
.refine(isName, { message: 'Nome inválido' }),
email: z.email('Email inválido').trim().toLowerCase(),
cpf: z
.string('CPF obrigatório')
.refine(isValidCPF, { message: 'CPF inválido' })
})
export type Schema = z.infer<typeof formSchema>
export function meta({}: Route.MetaArgs) {
return [{ title: 'Adicionar colaborador' }]
}
export async function action({ request, context }: Route.ActionArgs) {
const body = await request.json()
const r = await req({
url: `users`,
headers: new Headers({ 'Content-Type': 'application/json' }),
method: HttpMethod.POST,
body: JSON.stringify(body),
request,
context
})
console.log(r)
if (!r.ok) {
const error = await r.json().catch(() => ({}))
return { ok: false, error }
}
return { ok: true }
}
export default function Route() { export default function Route() {
const fetcher = useFetcher()
const { activeWorkspace } = useWorksapce()
const form = useForm({
resolver: zodResolver(formSchema)
})
const { handleSubmit, control, formState, reset } = form
const onSubmit = async (user: Schema) => {
await fetcher.submit(JSON.stringify({ user, org: activeWorkspace }), {
method: 'post',
encType: 'application/json'
})
}
useEffect(() => {
if (fetcher.data?.ok) {
toast.success('O colaborador foi adicionado')
return reset()
}
switch (fetcher.data?.error?.type) {
case 'UserConflictError':
toast.error('O colaborador já foi vinculado anteriormente')
}
}, [fetcher.data])
return ( return (
<div className="space-y-2.5"> <div className="space-y-2.5">
<Breadcrumb> <Breadcrumb>
@@ -33,17 +125,97 @@ export default function Route() {
</BreadcrumbList> </BreadcrumbList>
</Breadcrumb> </Breadcrumb>
<div className="lg:max-w-2xl mx-auto space-y-2.5"> <div className="lg:max-w-2xl mx-auto">
<Card> <Form {...form}>
<CardHeader> <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<CardTitle className="text-2xl">Adicionar colaborador</CardTitle> <Card>
<CardDescription> <CardHeader>
Siga os passos abaixo para cadastrar um novo colaborador <CardTitle className="text-2xl">
</CardDescription> Adicionar colaborador
</CardHeader> </CardTitle>
<CardDescription>
Siga os passos abaixo para cadastrar um novo colaborador
</CardDescription>
</CardHeader>
<CardContent></CardContent> <CardContent className="space-y-4">
</Card> <FormField
control={control}
name="name"
defaultValue=""
render={({ field }) => (
<FormItem>
<FormLabel>Nome</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2.5">
<FormField
control={control}
name="email"
defaultValue=""
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex items-center gap-2">
<Checkbox id="terms" tabIndex={-1} />
<Label htmlFor="terms">
Usar um email fornecido pela plataforma.
</Label>
</div>
</div>
<FormField
control={control}
name="cpf"
defaultValue=""
render={({ field: { onChange, ref, ...props } }) => (
<FormItem>
<FormLabel>CPF</FormLabel>
<FormControl>
<PatternFormat
format="###.###.###-##"
mask="_"
placeholder="___.___.___-__"
customInput={Input}
getInputRef={ref}
onValueChange={({ value }) => {
onChange(value)
}}
{...props}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<div className="flex justify-end">
<Button
type="submit"
className="cursor-pointer"
disabled={formState.isSubmitting}
>
{formState.isSubmitting && <Spinner />}
Adicionar
</Button>
</div>
</form>
</Form>
</div> </div>
</div> </div>
) )

View File

@@ -6,6 +6,7 @@ import { Outlet, type ShouldRevalidateFunctionArgs } from 'react-router'
import { AppSidebar } from '@/components/app-sidebar' import { AppSidebar } from '@/components/app-sidebar'
import { request as req } from '@/lib/request' import { request as req } from '@/lib/request'
import { WorkspaceProvider } from '@/components/workspace-switcher'
import { userContext } from '@repo/auth/context' import { userContext } from '@repo/auth/context'
import { authMiddleware } from '@repo/auth/middleware/auth' import { authMiddleware } from '@repo/auth/middleware/auth'
import { ModeToggle, ThemedImage } from '@repo/ui/components/dark-mode' import { ModeToggle, ThemedImage } from '@repo/ui/components/dark-mode'
@@ -15,6 +16,7 @@ import {
SidebarProvider, SidebarProvider,
SidebarTrigger SidebarTrigger
} from '@repo/ui/components/ui/sidebar' } from '@repo/ui/components/ui/sidebar'
import { Toaster } from '@repo/ui/components/ui/sonner'
export const middleware: Route.MiddlewareFunction[] = [authMiddleware] export const middleware: Route.MiddlewareFunction[] = [authMiddleware]
@@ -59,31 +61,34 @@ export default function Route({ loaderData }: Route.ComponentProps) {
const { user, orgs, sidebar_state } = loaderData const { user, orgs, sidebar_state } = loaderData
return ( return (
<SidebarProvider defaultOpen={sidebar_state === 'true'} className="flex"> <WorkspaceProvider workspaces={orgs}>
<AppSidebar orgs={orgs} /> <SidebarProvider defaultOpen={sidebar_state === 'true'} className="flex">
<AppSidebar />
<SidebarInset className="relative flex flex-col flex-1 min-w-0"> <SidebarInset className="relative flex flex-col flex-1 min-w-0">
<header <header
className="bg-background/15 backdrop-blur-sm className="bg-background/15 backdrop-blur-sm
px-4 py-2 lg:py-4 sticky top-0 z-5" px-4 py-2 lg:py-4 sticky top-0 z-5"
> >
<div className="container mx-auto flex items-center"> <div className="container mx-auto flex items-center">
<SidebarTrigger className="md:hidden" /> <SidebarTrigger className="md:hidden" />
<ThemedImage className="max-md:hidden" /> <ThemedImage className="max-md:hidden" />
<div className="ml-auto flex gap-2.5 items-center"> <div className="ml-auto flex gap-2.5 items-center">
<ModeToggle /> <ModeToggle />
<NavUser user={user} /> <NavUser user={user} />
</div>
</div> </div>
</div> </header>
</header>
<main className="p-4"> <main className="p-4">
<div className="container mx-auto"> <div className="container mx-auto relative">
<Outlet /> <Outlet />
</div> <Toaster position="top-center" richColors={true} />
</main> </div>
</SidebarInset> </main>
</SidebarProvider> </SidebarInset>
</SidebarProvider>
</WorkspaceProvider>
) )
} }

View File

@@ -1,4 +0,0 @@
import type { User } from '@/middleware/auth'
import { createContext } from 'react-router'
export const userContext = createContext<User | null>(null)

View File

@@ -1,7 +1,7 @@
import type { LoaderFunctionArgs } from 'react-router'
import type { User } from '@repo/auth/auth' import type { User } from '@repo/auth/auth'
import { userContext } from '@repo/auth/context' import { requestIdContext, userContext } from '@repo/auth/context'
import type { LoaderFunctionArgs } from 'react-router'
export enum HttpMethod { export enum HttpMethod {
GET = 'GET', GET = 'GET',
@@ -23,23 +23,21 @@ type RequestArgs = {
export function request({ export function request({
url, url,
method = HttpMethod.GET, method = HttpMethod.GET,
headers: _headers = {},
body = null, body = null,
headers: _headers = {},
request: { signal }, request: { signal },
context context
}: RequestArgs): Promise<Response> { }: RequestArgs): Promise<Response> {
const requestId = context.get(requestIdContext) as string
const user = context.get(userContext) as User const user = context.get(userContext) as User
// @ts-ignore const url_ = new URL(url, context.cloudflare.env.API_URL)
const headers = new Headers( const headers = new Headers(
Object.assign(_headers, { Authorization: `Bearer ${user.accessToken}` }) Object.assign({ Authorization: `Bearer ${user.accessToken}` }, _headers)
) )
// @ts-ignore
const endpoint = new URL(url, context.cloudflare.env.API_URL)
return fetch(endpoint.toString(), { console.log(
method, `[${new Date().toISOString()}] [${requestId}] ${method} ${url_.toString()}`
headers, )
body,
signal return fetch(url_.toString(), { method, headers, body, signal })
})
} }

View File

@@ -1,12 +1,7 @@
import type { Route } from './+types/edit' import type { Route } from './+types/edit'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { import { FileBadgeIcon, FileCode2Icon, MoreHorizontalIcon } from 'lucide-react'
CircleCheckIcon,
FileBadgeIcon,
FileCode2Icon,
MoreHorizontalIcon
} from 'lucide-react'
import { Suspense, useState, type ReactNode } from 'react' import { Suspense, useState, type ReactNode } from 'react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { Await, useAsyncValue, useFetcher } from 'react-router' import { Await, useAsyncValue, useFetcher } from 'react-router'
@@ -15,15 +10,7 @@ import { z } from 'zod'
import { HttpMethod, request as req } from '@/lib/request' import { HttpMethod, request as req } from '@/lib/request'
import type { User } from '@repo/auth/auth'
import { userContext } from '@repo/auth/context'
import { Skeleton } from '@repo/ui/components/skeleton' import { Skeleton } from '@repo/ui/components/skeleton'
import {
Alert,
AlertDescription,
AlertTitle
} from '@repo/ui/components/ui/alert'
import { import {
Breadcrumb, Breadcrumb,
BreadcrumbItem, BreadcrumbItem,
@@ -66,7 +53,7 @@ import {
import { Spinner } from '@repo/ui/components/ui/spinner' import { Spinner } from '@repo/ui/components/ui/spinner'
import { Switch } from '@repo/ui/components/ui/switch' import { Switch } from '@repo/ui/components/ui/switch'
const schema = z const formSchema = z
.object({ .object({
given_cert: z.coerce.boolean(), given_cert: z.coerce.boolean(),
never_expires: z.coerce.boolean(), never_expires: z.coerce.boolean(),
@@ -94,7 +81,7 @@ const schema = z
} }
) )
type Schema = z.infer<typeof schema> type Schema = z.infer<typeof formSchema>
type Cert = { type Cert = {
exp_interval: number exp_interval: number
@@ -132,16 +119,12 @@ export const loader = async ({
} }
export async function action({ params, request, context }: Route.ActionArgs) { export async function action({ params, request, context }: Route.ActionArgs) {
const user = context.get(userContext) as User
const formData = await request.formData() const formData = await request.formData()
const r = await req({ const r = await req({
url: `courses/${params.id}`, url: `courses/${params.id}`,
method: HttpMethod.PUT, method: HttpMethod.PUT,
body: formData, body: formData,
headers: new Headers({
Authorization: `Bearer ${user.accessToken}`
}),
request, request,
context context
}) })
@@ -180,7 +163,7 @@ function Editing() {
const fetcher = useFetcher() const fetcher = useFetcher()
const form = useForm({ const form = useForm({
resolver: zodResolver(schema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
draft: false, draft: false,
given_cert: !!course?.cert, given_cert: !!course?.cert,
@@ -415,7 +398,7 @@ function Editing() {
disabled={formState.isSubmitting} disabled={formState.isSubmitting}
> >
{formState.isSubmitting && <Spinner />} {formState.isSubmitting && <Spinner />}
Editar curso Editar
</Button> </Button>
</div> </div>
</form> </form>

View File

@@ -10,7 +10,7 @@ from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from config import API_URL, COURSE_TABLE from config import API_URL, COURSE_TABLE
course_layer = DynamoDBPersistenceLayer(COURSE_TABLE, dynamodb_client) dyn = DynamoDBPersistenceLayer(COURSE_TABLE, dynamodb_client)
@event_source(data_class=DynamoDBStreamEvent) @event_source(data_class=DynamoDBStreamEvent)
@@ -21,7 +21,7 @@ def lambda_handler(event: DynamoDBStreamEvent, context: LambdaContext) -> bool:
data = r.json() data = r.json()
now_ = now() now_ = now()
with course_layer.transact_writer() as transact: with dyn.transact_writer() as transact:
for course in data: for course in data:
transact.update( transact.update(
key=KeyPair(course['id'], '0'), key=KeyPair(course['id'], '0'),

View File

@@ -14,7 +14,7 @@ Globals:
Architectures: Architectures:
- x86_64 - x86_64
Layers: Layers:
- !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:83 - !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:100
Environment: Environment:
Variables: Variables:
TZ: America/Sao_Paulo TZ: America/Sao_Paulo
@@ -28,19 +28,19 @@ Resources:
EventLog: EventLog:
Type: AWS::Logs::LogGroup Type: AWS::Logs::LogGroup
Properties: Properties:
RetentionInDays: 90 RetentionInDays: 7
EventDailySyncCourseMetadataFunction: EventCopyCourseMetadataScheduledFunction:
Type: AWS::Serverless::Function Type: AWS::Serverless::Function
Properties: Properties:
Handler: events.daily_sync_course_metadata.lambda_handler Handler: events.copy_course_metadata.lambda_handler
LoggingConfig: LoggingConfig:
LogGroup: !Ref EventLog LogGroup: !Ref EventLog
Policies: Policies:
- DynamoDBWritePolicy: - DynamoDBWritePolicy:
TableName: !Ref CourseTable TableName: !Ref CourseTable
Events: Events:
Rule: ScheduleEvent:
Type: ScheduleV2 Type: ScheduleV2
Properties: Properties:
ScheduleExpression: cron(0 0 * * ? *) ScheduleExpression: cron(0 0 * * ? *)

View File

@@ -3,7 +3,7 @@ from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
def test_daily_sync_course_metadata( def test_copy_course_metadata(
dynamodb_client, dynamodb_client,
dynamodb_seeds, dynamodb_seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer, dynamodb_persistence_layer: DynamoDBPersistenceLayer,

View File

@@ -20,5 +20,7 @@ export const loggingMiddleware = async (
`[${new Date().toISOString()}] [${requestId}] Response ${response.status} (${duration}ms)` `[${new Date().toISOString()}] [${requestId}] Response ${response.status} (${duration}ms)`
) )
response.headers.set('Server-Timing', `page;req=${requestId};dur=${duration}`)
return response return response
} }

View File

@@ -46,6 +46,7 @@ export function ModeToggle() {
type ThemedImageProps = { type ThemedImageProps = {
children?: string children?: string
className?: string
} }
export function ThemedImage({ children, ...props }: ThemedImageProps) { export function ThemedImage({ children, ...props }: ThemedImageProps) {

View File

@@ -0,0 +1,193 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
role="list"
data-slot="item-group"
className={cn("group/item-group flex flex-col", className)}
{...props}
/>
)
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn("my-0", className)}
{...props}
/>
)
}
const itemVariants = cva(
"group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border-border",
muted: "bg-muted/50",
},
size: {
default: "p-4 gap-4 ",
sm: "py-3 px-4 gap-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Item({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="item"
data-variant={variant}
data-size={size}
className={cn(itemVariants({ variant, size, className }))}
{...props}
/>
)
}
const itemMediaVariants = cva(
"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5",
{
variants: {
variant: {
default: "bg-transparent",
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
image:
"size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
}
)
function ItemMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
)
}
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-content"
className={cn(
"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none",
className
)}
{...props}
/>
)
}
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-title"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium",
className
)}
{...props}
/>
)
}
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="item-description"
className={cn(
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-actions"
className={cn("flex items-center gap-2", className)}
{...props}
/>
)
}
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-header"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-footer"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
}

View File

@@ -0,0 +1,48 @@
import * as React from "react"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function NativeSelect({ className, ...props }: React.ComponentProps<"select">) {
return (
<div
className="group/native-select relative w-fit has-[select:disabled]:opacity-50"
data-slot="native-select-wrapper"
>
<select
data-slot="native-select"
className={cn(
"border-input placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 dark:hover:bg-input/50 h-9 w-full min-w-0 appearance-none rounded-md border bg-transparent px-3 py-2 pr-9 text-sm shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
<ChevronDownIcon
className="text-muted-foreground pointer-events-none absolute top-1/2 right-3.5 size-4 -translate-y-1/2 opacity-50 select-none"
aria-hidden="true"
data-slot="native-select-icon"
/>
</div>
)
}
function NativeSelectOption({ ...props }: React.ComponentProps<"option">) {
return <option data-slot="native-select-option" {...props} />
}
function NativeSelectOptGroup({
className,
...props
}: React.ComponentProps<"optgroup">) {
return (
<optgroup
data-slot="native-select-optgroup"
className={cn(className)}
{...props}
/>
)
}
export { NativeSelect, NativeSelectOptGroup, NativeSelectOption }

View File

@@ -1,3 +1,5 @@
"use client"
import * as React from "react" import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator" import * as SeparatorPrimitive from "@radix-ui/react-separator"