AshameTheDestroyer commited on
Commit
5e44686
·
1 Parent(s): 92fcc3b

Jobs Page.

Browse files
backend/main.py CHANGED
@@ -1,5 +1,6 @@
1
  from contextlib import asynccontextmanager
2
  from fastapi import FastAPI
 
3
  import os
4
 
5
  # Import from our modules
@@ -35,6 +36,17 @@ app = FastAPI(
35
  lifespan=lifespan
36
  )
37
 
 
 
 
 
 
 
 
 
 
 
 
38
  # Include API routes
39
  app.include_router(root_router)
40
  app.include_router(user_router)
 
1
  from contextlib import asynccontextmanager
2
  from fastapi import FastAPI
3
+ from fastapi.middleware.cors import CORSMiddleware
4
  import os
5
 
6
  # Import from our modules
 
36
  lifespan=lifespan
37
  )
38
 
39
+ # Configure CORS for frontend dev server(s)
40
+ # Default to allowing the common Vite port and localhost:3000 for React dev
41
+ origins = os.getenv("CORS_ORIGINS", "http://localhost:5173,http://localhost:3000").split(",")
42
+ app.add_middleware(
43
+ CORSMiddleware,
44
+ allow_origins=[o.strip() for o in origins if o.strip()],
45
+ allow_credentials=True,
46
+ allow_methods=["*"],
47
+ allow_headers=["*"],
48
+ )
49
+
50
  # Include API routes
51
  app.include_router(root_router)
52
  app.include_router(user_router)
frontend/app/app.css CHANGED
@@ -9,7 +9,6 @@
9
  "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
10
  }
11
 
12
- html,
13
  body {
14
  @apply bg-indigo-50 text-gray-900 dark:bg-gray-900 dark:text-white;
15
 
@@ -74,8 +73,8 @@ body {
74
  --accent: rgb(243, 244, 246);
75
  --accent-foreground: rgb(16, 24, 40);
76
  --destructive: rgb(231, 0, 11);
77
- --border: rgb(229, 231, 235);
78
- --input: rgb(229, 231, 235);
79
  --ring: rgb(153, 161, 175);
80
  --chart-1: rgb(245, 73, 0);
81
  --chart-2: rgb(0, 150, 137);
@@ -107,7 +106,7 @@ body {
107
  --accent-foreground: rgb(249, 250, 251);
108
  --destructive: rgb(255, 100, 103);
109
  --border: rgba(255, 255, 255, 0.1);
110
- --input: rgba(255, 255, 255, 0.15);
111
  --ring: rgb(106, 114, 130);
112
  --chart-1: rgb(20, 71, 230);
113
  --chart-2: rgb(0, 188, 125);
 
9
  "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
10
  }
11
 
 
12
  body {
13
  @apply bg-indigo-50 text-gray-900 dark:bg-gray-900 dark:text-white;
14
 
 
73
  --accent: rgb(243, 244, 246);
74
  --accent-foreground: rgb(16, 24, 40);
75
  --destructive: rgb(231, 0, 11);
76
+ --border: rgb(141, 160, 197);
77
+ --input: rgb(141, 160, 197);
78
  --ring: rgb(153, 161, 175);
79
  --chart-1: rgb(245, 73, 0);
80
  --chart-2: rgb(0, 150, 137);
 
106
  --accent-foreground: rgb(249, 250, 251);
107
  --destructive: rgb(255, 100, 103);
108
  --border: rgba(255, 255, 255, 0.1);
109
+ --input: rgba(255, 255, 255, 0.1);
110
  --ring: rgb(106, 114, 130);
111
  --chart-1: rgb(20, 71, 230);
112
  --chart-2: rgb(0, 188, 125);
frontend/app/components/paginator.tsx ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Button } from "./ui/button";
2
+ import { usePagination } from "~/hooks/use-pagination";
3
+ import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from "./ui/combobox";
4
+
5
+ export function Paginator({ total }: { total: number }) {
6
+ const { page, limit, setPage, setLimit } = usePagination();
7
+ const totalPages = Math.max(1, Math.ceil(total / Math.max(1, limit)));
8
+ return (
9
+ <footer className="flex place-items-center place-content-between gap-4 flex-wrap">
10
+ <div className="flex flex-wrap gap-4 place-items-center">
11
+ <Button onClick={() => setPage(Math.max(1, page - 1))} disabled={page <= 1}>
12
+ Prev
13
+ </Button>
14
+ <span>Page {page} of {totalPages}</span>
15
+ <Button onClick={() => setPage(Math.min(totalPages, page + 1))} disabled={page >= totalPages}>
16
+ Next
17
+ </Button>
18
+ </div>
19
+ <div className="flex place-items-center gap-4">
20
+ <p>Per page:</p>
21
+ <Combobox items={[10, 25, 50, 100]} value={limit} onValueChange={(value) => { setLimit(value || 10); setPage(1); }}>
22
+ <ComboboxInput placeholder="Choose value" />
23
+ <ComboboxContent>
24
+ <ComboboxEmpty>No items found.</ComboboxEmpty>
25
+ <ComboboxList>
26
+ {(item) => (
27
+ <ComboboxItem key={item} value={item}>
28
+ {item}
29
+ </ComboboxItem>
30
+ )}
31
+ </ComboboxList>
32
+ </ComboboxContent>
33
+ </Combobox>
34
+ </div>
35
+ </footer>
36
+ )
37
+ }
frontend/app/components/ui/combobox.tsx ADDED
@@ -0,0 +1,310 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { Combobox as ComboboxPrimitive } from "@base-ui/react"
5
+ import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react"
6
+
7
+ import { cn } from "~/lib/utils"
8
+ import { Button } from "~/components/ui/button"
9
+ import {
10
+ InputGroup,
11
+ InputGroupAddon,
12
+ InputGroupButton,
13
+ InputGroupInput,
14
+ } from "~/components/ui/input-group"
15
+
16
+ const Combobox = ComboboxPrimitive.Root
17
+
18
+ function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
19
+ return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
20
+ }
21
+
22
+ function ComboboxTrigger({
23
+ className,
24
+ children,
25
+ ...props
26
+ }: ComboboxPrimitive.Trigger.Props) {
27
+ return (
28
+ <ComboboxPrimitive.Trigger
29
+ data-slot="combobox-trigger"
30
+ className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
31
+ {...props}
32
+ >
33
+ {children}
34
+ <ChevronDownIcon
35
+ data-slot="combobox-trigger-icon"
36
+ className="text-muted-foreground pointer-events-none size-4"
37
+ />
38
+ </ComboboxPrimitive.Trigger>
39
+ )
40
+ }
41
+
42
+ function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
43
+ return (
44
+ <ComboboxPrimitive.Clear
45
+ data-slot="combobox-clear"
46
+ render={<InputGroupButton variant="ghost" size="icon-xs" />}
47
+ className={cn(className)}
48
+ {...props}
49
+ >
50
+ <XIcon className="pointer-events-none" />
51
+ </ComboboxPrimitive.Clear>
52
+ )
53
+ }
54
+
55
+ function ComboboxInput({
56
+ className,
57
+ children,
58
+ disabled = false,
59
+ showTrigger = true,
60
+ showClear = false,
61
+ ...props
62
+ }: ComboboxPrimitive.Input.Props & {
63
+ showTrigger?: boolean
64
+ showClear?: boolean
65
+ }) {
66
+ return (
67
+ <InputGroup className={cn("w-auto", className)}>
68
+ <ComboboxPrimitive.Input
69
+ render={<InputGroupInput disabled={disabled} />}
70
+ {...props}
71
+ />
72
+ <InputGroupAddon align="inline-end">
73
+ {showTrigger && (
74
+ <InputGroupButton
75
+ size="icon-xs"
76
+ variant="ghost"
77
+ asChild
78
+ data-slot="input-group-button"
79
+ className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
80
+ disabled={disabled}
81
+ >
82
+ <ComboboxTrigger />
83
+ </InputGroupButton>
84
+ )}
85
+ {showClear && <ComboboxClear disabled={disabled} />}
86
+ </InputGroupAddon>
87
+ {children}
88
+ </InputGroup>
89
+ )
90
+ }
91
+
92
+ function ComboboxContent({
93
+ className,
94
+ side = "bottom",
95
+ sideOffset = 6,
96
+ align = "start",
97
+ alignOffset = 0,
98
+ anchor,
99
+ ...props
100
+ }: ComboboxPrimitive.Popup.Props &
101
+ Pick<
102
+ ComboboxPrimitive.Positioner.Props,
103
+ "side" | "align" | "sideOffset" | "alignOffset" | "anchor"
104
+ >) {
105
+ return (
106
+ <ComboboxPrimitive.Portal>
107
+ <ComboboxPrimitive.Positioner
108
+ side={side}
109
+ sideOffset={sideOffset}
110
+ align={align}
111
+ alignOffset={alignOffset}
112
+ anchor={anchor}
113
+ className="isolate z-50"
114
+ >
115
+ <ComboboxPrimitive.Popup
116
+ data-slot="combobox-content"
117
+ data-chips={!!anchor}
118
+ className={cn(
119
+ "bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:border-input/30 group/combobox-content relative max-h-96 w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-md shadow-md ring-1 duration-100 data-[chips=true]:min-w-(--anchor-width) *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:shadow-none",
120
+ className
121
+ )}
122
+ {...props}
123
+ />
124
+ </ComboboxPrimitive.Positioner>
125
+ </ComboboxPrimitive.Portal>
126
+ )
127
+ }
128
+
129
+ function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
130
+ return (
131
+ <ComboboxPrimitive.List
132
+ data-slot="combobox-list"
133
+ className={cn(
134
+ "max-h-[min(calc(--spacing(96)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto p-1 data-empty:p-0",
135
+ className
136
+ )}
137
+ {...props}
138
+ />
139
+ )
140
+ }
141
+
142
+ function ComboboxItem({
143
+ className,
144
+ children,
145
+ ...props
146
+ }: ComboboxPrimitive.Item.Props) {
147
+ return (
148
+ <ComboboxPrimitive.Item
149
+ data-slot="combobox-item"
150
+ className={cn(
151
+ "data-highlighted:bg-accent data-highlighted:text-accent-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
152
+ className
153
+ )}
154
+ {...props}
155
+ >
156
+ {children}
157
+ <ComboboxPrimitive.ItemIndicator
158
+ data-slot="combobox-item-indicator"
159
+ render={
160
+ <span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
161
+ }
162
+ >
163
+ <CheckIcon className="pointer-events-none size-4 pointer-coarse:size-5" />
164
+ </ComboboxPrimitive.ItemIndicator>
165
+ </ComboboxPrimitive.Item>
166
+ )
167
+ }
168
+
169
+ function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
170
+ return (
171
+ <ComboboxPrimitive.Group
172
+ data-slot="combobox-group"
173
+ className={cn(className)}
174
+ {...props}
175
+ />
176
+ )
177
+ }
178
+
179
+ function ComboboxLabel({
180
+ className,
181
+ ...props
182
+ }: ComboboxPrimitive.GroupLabel.Props) {
183
+ return (
184
+ <ComboboxPrimitive.GroupLabel
185
+ data-slot="combobox-label"
186
+ className={cn(
187
+ "text-muted-foreground px-2 py-1.5 text-xs pointer-coarse:px-3 pointer-coarse:py-2 pointer-coarse:text-sm",
188
+ className
189
+ )}
190
+ {...props}
191
+ />
192
+ )
193
+ }
194
+
195
+ function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
196
+ return (
197
+ <ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
198
+ )
199
+ }
200
+
201
+ function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
202
+ return (
203
+ <ComboboxPrimitive.Empty
204
+ data-slot="combobox-empty"
205
+ className={cn(
206
+ "text-muted-foreground hidden w-full justify-center py-2 text-center text-sm group-data-empty/combobox-content:flex",
207
+ className
208
+ )}
209
+ {...props}
210
+ />
211
+ )
212
+ }
213
+
214
+ function ComboboxSeparator({
215
+ className,
216
+ ...props
217
+ }: ComboboxPrimitive.Separator.Props) {
218
+ return (
219
+ <ComboboxPrimitive.Separator
220
+ data-slot="combobox-separator"
221
+ className={cn("bg-border -mx-1 my-1 h-px", className)}
222
+ {...props}
223
+ />
224
+ )
225
+ }
226
+
227
+ function ComboboxChips({
228
+ className,
229
+ ...props
230
+ }: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
231
+ ComboboxPrimitive.Chips.Props) {
232
+ return (
233
+ <ComboboxPrimitive.Chips
234
+ data-slot="combobox-chips"
235
+ className={cn(
236
+ "dark:bg-input/30 border-input focus-within:border-ring focus-within:ring-ring/50 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive dark:has-aria-invalid:border-destructive/50 flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border bg-transparent bg-clip-padding px-2.5 py-1.5 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-[3px] has-aria-invalid:ring-[3px] has-data-[slot=combobox-chip]:px-1.5",
237
+ className
238
+ )}
239
+ {...props}
240
+ />
241
+ )
242
+ }
243
+
244
+ function ComboboxChip({
245
+ className,
246
+ children,
247
+ showRemove = true,
248
+ ...props
249
+ }: ComboboxPrimitive.Chip.Props & {
250
+ showRemove?: boolean
251
+ }) {
252
+ return (
253
+ <ComboboxPrimitive.Chip
254
+ data-slot="combobox-chip"
255
+ className={cn(
256
+ "bg-muted text-foreground flex h-[calc(--spacing(5.5))] w-fit items-center justify-center gap-1 rounded-sm px-1.5 text-xs font-medium whitespace-nowrap has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
257
+ className
258
+ )}
259
+ {...props}
260
+ >
261
+ {children}
262
+ {showRemove && (
263
+ <ComboboxPrimitive.ChipRemove
264
+ render={<Button variant="ghost" size="icon-xs" />}
265
+ className="-ml-1 opacity-50 hover:opacity-100"
266
+ data-slot="combobox-chip-remove"
267
+ >
268
+ <XIcon className="pointer-events-none" />
269
+ </ComboboxPrimitive.ChipRemove>
270
+ )}
271
+ </ComboboxPrimitive.Chip>
272
+ )
273
+ }
274
+
275
+ function ComboboxChipsInput({
276
+ className,
277
+ children,
278
+ ...props
279
+ }: ComboboxPrimitive.Input.Props) {
280
+ return (
281
+ <ComboboxPrimitive.Input
282
+ data-slot="combobox-chip-input"
283
+ className={cn("min-w-16 flex-1 outline-none", className)}
284
+ {...props}
285
+ />
286
+ )
287
+ }
288
+
289
+ function useComboboxAnchor() {
290
+ return React.useRef<HTMLDivElement | null>(null)
291
+ }
292
+
293
+ export {
294
+ Combobox,
295
+ ComboboxInput,
296
+ ComboboxContent,
297
+ ComboboxList,
298
+ ComboboxItem,
299
+ ComboboxGroup,
300
+ ComboboxLabel,
301
+ ComboboxCollection,
302
+ ComboboxEmpty,
303
+ ComboboxSeparator,
304
+ ComboboxChips,
305
+ ComboboxChip,
306
+ ComboboxChipsInput,
307
+ ComboboxTrigger,
308
+ ComboboxValue,
309
+ useComboboxAnchor,
310
+ }
frontend/app/components/ui/input-group.tsx ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+
4
+ import { cn } from "~/lib/utils"
5
+ import { Button } from "~/components/ui/button"
6
+ import { Input } from "~/components/ui/input"
7
+ import { Textarea } from "~/components/ui/textarea"
8
+
9
+ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
10
+ return (
11
+ <div
12
+ data-slot="input-group"
13
+ role="group"
14
+ className={cn(
15
+ "group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
16
+ "h-9 min-w-0 has-[>textarea]:h-auto",
17
+
18
+ // Variants based on alignment.
19
+ "has-[>[data-align=inline-start]]:[&>input]:pl-2",
20
+ "has-[>[data-align=inline-end]]:[&>input]:pr-2",
21
+ "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
22
+ "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
23
+
24
+ // Focus state.
25
+ "has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
26
+
27
+ // Error state.
28
+ "has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
29
+
30
+ className
31
+ )}
32
+ {...props}
33
+ />
34
+ )
35
+ }
36
+
37
+ const inputGroupAddonVariants = cva(
38
+ "text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
39
+ {
40
+ variants: {
41
+ align: {
42
+ "inline-start":
43
+ "order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
44
+ "inline-end":
45
+ "order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
46
+ "block-start":
47
+ "order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
48
+ "block-end":
49
+ "order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
50
+ },
51
+ },
52
+ defaultVariants: {
53
+ align: "inline-start",
54
+ },
55
+ }
56
+ )
57
+
58
+ function InputGroupAddon({
59
+ className,
60
+ align = "inline-start",
61
+ ...props
62
+ }: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
63
+ return (
64
+ <div
65
+ role="group"
66
+ data-slot="input-group-addon"
67
+ data-align={align}
68
+ className={cn(inputGroupAddonVariants({ align }), className)}
69
+ onClick={(e) => {
70
+ if ((e.target as HTMLElement).closest("button")) {
71
+ return
72
+ }
73
+ e.currentTarget.parentElement?.querySelector("input")?.focus()
74
+ }}
75
+ {...props}
76
+ />
77
+ )
78
+ }
79
+
80
+ const inputGroupButtonVariants = cva(
81
+ "text-sm shadow-none flex gap-2 items-center",
82
+ {
83
+ variants: {
84
+ size: {
85
+ xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
86
+ sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
87
+ "icon-xs":
88
+ "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
89
+ "icon-sm": "size-8 p-0 has-[>svg]:p-0",
90
+ },
91
+ },
92
+ defaultVariants: {
93
+ size: "xs",
94
+ },
95
+ }
96
+ )
97
+
98
+ function InputGroupButton({
99
+ className,
100
+ type = "button",
101
+ variant = "ghost",
102
+ size = "xs",
103
+ ...props
104
+ }: Omit<React.ComponentProps<typeof Button>, "size"> &
105
+ VariantProps<typeof inputGroupButtonVariants>) {
106
+ return (
107
+ <Button
108
+ type={type}
109
+ data-size={size}
110
+ variant={variant}
111
+ className={cn(inputGroupButtonVariants({ size }), className)}
112
+ {...props}
113
+ />
114
+ )
115
+ }
116
+
117
+ function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
118
+ return (
119
+ <span
120
+ className={cn(
121
+ "text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
122
+ className
123
+ )}
124
+ {...props}
125
+ />
126
+ )
127
+ }
128
+
129
+ function InputGroupInput({
130
+ className,
131
+ ...props
132
+ }: React.ComponentProps<"input">) {
133
+ return (
134
+ <Input
135
+ data-slot="input-group-control"
136
+ className={cn(
137
+ "flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
138
+ className
139
+ )}
140
+ {...props}
141
+ />
142
+ )
143
+ }
144
+
145
+ function InputGroupTextarea({
146
+ className,
147
+ ...props
148
+ }: React.ComponentProps<"textarea">) {
149
+ return (
150
+ <Textarea
151
+ data-slot="input-group-control"
152
+ className={cn(
153
+ "flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
154
+ className
155
+ )}
156
+ {...props}
157
+ />
158
+ )
159
+ }
160
+
161
+ export {
162
+ InputGroup,
163
+ InputGroupAddon,
164
+ InputGroupButton,
165
+ InputGroupText,
166
+ InputGroupInput,
167
+ InputGroupTextarea,
168
+ }
frontend/app/components/ui/textarea.tsx ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+
3
+ import { cn } from "~/lib/utils"
4
+
5
+ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
6
+ return (
7
+ <textarea
8
+ data-slot="textarea"
9
+ className={cn(
10
+ "border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
11
+ className
12
+ )}
13
+ {...props}
14
+ />
15
+ )
16
+ }
17
+
18
+ export { Textarea }
frontend/app/hooks/use-pagination.ts ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect } from "react";
2
+ import { useSearchParams } from "react-router";
3
+
4
+ export function usePagination() {
5
+ const [searchParams, setSearchParams] = useSearchParams();
6
+ const page = parseInt(searchParams.get("page") || "1", 10);
7
+ const limit = parseInt(searchParams.get("limit") || "10", 10);
8
+
9
+ // useEffect(() => {
10
+ // if (limit <= 0) {
11
+ // setLimit(10);
12
+ // }
13
+ // }, [limit])
14
+
15
+ function setPage(newPage: number) {
16
+ searchParams.set("page", newPage.toString());
17
+ setSearchParams(searchParams);
18
+ }
19
+
20
+ function setLimit(newLimit: number) {
21
+ searchParams.set("limit", newLimit.toString());
22
+ setSearchParams(searchParams);
23
+ }
24
+
25
+ return {
26
+ page,
27
+ limit,
28
+ setPage,
29
+ setLimit,
30
+ };
31
+ }
frontend/app/managers/HTTPManager.ts ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import axios from "axios";
2
+ import { toast } from "react-toastify";
3
+
4
+ export const HTTPManager = axios.create({
5
+ baseURL: import.meta.env.VITE_APP_API_URL,
6
+ headers: {
7
+ "Content-Type": "application/json",
8
+ "Accept": "application/json",
9
+ },
10
+ });
11
+
12
+ HTTPManager.interceptors.request.use((config) => {
13
+ const token = localStorage.getItem("token");
14
+ if (token) {
15
+ config.headers["Authorization"] = `Bearer ${token}`;
16
+ }
17
+ return config;
18
+ }, (error) => {
19
+ toast.error("Request error: " + error.message);
20
+ return Promise.reject(error);
21
+ });
frontend/app/root.tsx CHANGED
@@ -8,10 +8,14 @@ import {
8
  } from "react-router";
9
  import type { Route } from "./+types/root";
10
  import { Header } from "./components/header";
 
 
11
  import { SidebarProvider } from "./components/sidebar-provider";
12
 
13
  import "./app.css";
14
 
 
 
15
  export function Layout({ children }: { children: React.ReactNode }) {
16
  return (
17
  <html lang="en">
@@ -26,10 +30,13 @@ export function Layout({ children }: { children: React.ReactNode }) {
26
  <Links />
27
  </head>
28
  <body>
29
- <SidebarProvider>
30
- <Header />
31
- <main>{children}</main>
32
- </SidebarProvider>
 
 
 
33
  <ScrollRestoration />
34
  <Scripts />
35
  </body>
 
8
  } from "react-router";
9
  import type { Route } from "./+types/root";
10
  import { Header } from "./components/header";
11
+ import { ToastContainer } from "react-toastify";
12
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
13
  import { SidebarProvider } from "./components/sidebar-provider";
14
 
15
  import "./app.css";
16
 
17
+ export const queryClient = new QueryClient();
18
+
19
  export function Layout({ children }: { children: React.ReactNode }) {
20
  return (
21
  <html lang="en">
 
30
  <Links />
31
  </head>
32
  <body>
33
+ <QueryClientProvider client={queryClient}>
34
+ <SidebarProvider>
35
+ <Header />
36
+ <main>{children}</main>
37
+ </SidebarProvider>
38
+ </QueryClientProvider>
39
+ <ToastContainer />
40
  <ScrollRestoration />
41
  <Scripts />
42
  </body>
frontend/app/routes/home.tsx CHANGED
@@ -1,14 +1,82 @@
 
1
  import type { Route } from "./+types/home";
 
 
 
2
 
3
  export function meta({}: Route.MetaArgs) {
4
  return [
5
  { title: "Talent Technical Evaluation" },
6
- { name: "description", content: "Welcome to Talent Technical Evaluation!" },
 
 
 
7
  ];
8
  }
9
 
10
  export default function Home() {
11
- return <main className="container mx-auto p-4">
12
- <h1 className="font-bold text-4xl">Talent Technical Evaluation</h1>
13
- </main>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  }
 
1
+ import { useNavigate } from "react-router";
2
  import type { Route } from "./+types/home";
3
+ import { useGetJobs } from "~/services/useGetJobs";
4
+ import { Paginator } from "~/components/paginator";
5
+ import { Loader2Icon, User2Icon } from "lucide-react";
6
 
7
  export function meta({}: Route.MetaArgs) {
8
  return [
9
  { title: "Talent Technical Evaluation" },
10
+ {
11
+ name: "description",
12
+ content: "Welcome to Talent Technical Evaluation!",
13
+ },
14
  ];
15
  }
16
 
17
  export default function Home() {
18
+ const { data: { data: jobs, total } = { data: [] }, isLoading, isError, refetch } = useGetJobs();
19
+ const Navigate = useNavigate();
20
+
21
+ return (
22
+ <main className="container mx-auto p-4 flex flex-col gap-4">
23
+ <header className="flex place-content-between gap-4 flex-wrap">
24
+ <h1 className="font-bold text-4xl">Active Jobs</h1>
25
+ <p>{total} jobs in total</p>
26
+ </header>
27
+
28
+ <section className="grid grid-cols-[repeat(auto-fill,minmax(400px,1fr))] gap-4">
29
+ {isLoading && (
30
+ <div className="flex flex-col gap-2 place-items-center">
31
+ <Loader2Icon className="animate-spin" />
32
+ <p>Loading jobs...</p>
33
+ </div>
34
+ )}
35
+ {isError && (
36
+ <div className="bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-100 p-4 rounded flex flex-col gap-2 place-items-center">
37
+ <p className="text-center">Failed to load jobs<br />Please try again</p>
38
+ <button
39
+ onClick={() => refetch()}
40
+ className="ml-4 px-3 py-1 cursor-pointer bg-red-500 text-white dark:bg-red-200 dark:text-red-700 rounded"
41
+ >
42
+ Retry
43
+ </button>
44
+ </div>
45
+ )}
46
+ {jobs?.map(job =>
47
+ <div
48
+ key={job.id}
49
+ tabIndex={0}
50
+ className="border p-4 flex flex-col gap-2 rounded mb-4 bg-indigo-100 dark:bg-gray-800 [:is(:hover,:focus)]:shadow-lg [:is(:hover,:focus)]:scale-101 transition-all cursor-pointer"
51
+ onClick={() => Navigate(`/jobs/${job.id}`)}
52
+ >
53
+ <header className="flex flex-wrap place-content-between gap-4">
54
+ <h2 className="font-semibold text-2xl">{job.title}</h2>
55
+ <span className={"px-3 py-1.5 rounded-xl " + {
56
+ "intern": "bg-green-300 dark:bg-green-800",
57
+ "junior": "bg-blue-300 dark:bg-blue-800",
58
+ "mid": "bg-yellow-300 dark:bg-yellow-800",
59
+ "senior": "bg-red-300 dark:bg-red-800",
60
+ }[job.seniority]}>{job.seniority[0].toUpperCase()}{job.seniority.slice(1)}</span>
61
+ </header>
62
+ <p className="line-clamp-2">{job.description}</p>
63
+ <footer className="flex gap-2 mt-auto place-content-between">
64
+ <div className="flex flex-wrap gap-2">
65
+ {job.skill_categories.map((skill) => (
66
+ <span key={skill} className="inline-block bg-indigo-300 px-3 py-1.5 rounded-xl dark:bg-gray-950">
67
+ {skill}
68
+ </span>
69
+ ))}
70
+ </div>
71
+ <span className="place-self-end flex gap-2 place-items-center px-3 py-1.5 rounded-xl bg-indigo-50 dark:bg-gray-700">
72
+ <User2Icon />
73
+ <p>{job.applicants_count}</p>
74
+ </span>
75
+ </footer>
76
+ </div>
77
+ )}
78
+ {total && <Paginator total={total} />}
79
+ </section>
80
+ </main>
81
+ );
82
  }
frontend/app/services/useGetJobs.ts ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import { usePagination } from "~/hooks/use-pagination";
3
+ import { HTTPManager } from "~/managers/HTTPManager";
4
+ import type { Pagination } from "~/types/pagination";
5
+
6
+ export const GET_JOBS_KEY = "jobs";
7
+
8
+ export type Job = {
9
+ id: string;
10
+ title: string;
11
+ active: boolean;
12
+ description: string;
13
+ applicants_count: number;
14
+ skill_categories: Array<string>;
15
+ seniority: "intern" | "junior" | "mid" | "senior";
16
+ };
17
+
18
+ export const useGetJobs = () => {
19
+ const { page, limit } = usePagination();
20
+ const searchParams = new URLSearchParams({ page: String(page), limit: String(limit) });
21
+ return useQuery({
22
+ queryKey: [GET_JOBS_KEY, page, limit],
23
+ queryFn: async () => HTTPManager.get<Pagination<Job>>("/jobs?" + searchParams.toString()).then(response => response.data),
24
+ });
25
+ }
frontend/app/types/pagination.ts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ export type Pagination<T> = {
2
+ count: number;
3
+ total: number;
4
+ data: Array<T>;
5
+ };
frontend/package-lock.json CHANGED
@@ -6,8 +6,11 @@
6
  "": {
7
  "name": "talent-technical-evaluation",
8
  "dependencies": {
 
9
  "@react-router/node": "7.12.0",
10
  "@react-router/serve": "7.12.0",
 
 
11
  "class-variance-authority": "^0.7.1",
12
  "clsx": "^2.1.1",
13
  "isbot": "^5.1.31",
@@ -16,6 +19,7 @@
16
  "react": "^19.2.4",
17
  "react-dom": "^19.2.4",
18
  "react-router": "7.12.0",
 
19
  "tailwind-merge": "^3.4.0"
20
  },
21
  "devDependencies": {
@@ -446,6 +450,15 @@
446
  "@babel/core": "^7.0.0-0"
447
  }
448
  },
 
 
 
 
 
 
 
 
 
449
  "node_modules/@babel/template": {
450
  "version": "7.28.6",
451
  "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -494,6 +507,60 @@
494
  "node": ">=6.9.0"
495
  }
496
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
497
  "node_modules/@esbuild/aix-ppc64": {
498
  "version": "0.27.2",
499
  "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
@@ -3294,6 +3361,43 @@
3294
  "vite": "^5.2.0 || ^6 || ^7"
3295
  }
3296
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3297
  "node_modules/@types/estree": {
3298
  "version": "1.0.8",
3299
  "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -3378,6 +3482,23 @@
3378
  "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
3379
  "license": "MIT"
3380
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3381
  "node_modules/babel-dead-code-elimination": {
3382
  "version": "1.0.12",
3383
  "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz",
@@ -3604,6 +3725,18 @@
3604
  "node": ">=6"
3605
  }
3606
  },
 
 
 
 
 
 
 
 
 
 
 
 
3607
  "node_modules/compressible": {
3608
  "version": "2.0.18",
3609
  "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
@@ -3739,6 +3872,15 @@
3739
  }
3740
  }
3741
  },
 
 
 
 
 
 
 
 
 
3742
  "node_modules/depd": {
3743
  "version": "2.0.0",
3744
  "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -3861,6 +4003,21 @@
3861
  "node": ">= 0.4"
3862
  }
3863
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3864
  "node_modules/esbuild": {
3865
  "version": "0.27.2",
3866
  "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
@@ -4060,6 +4217,42 @@
4060
  "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
4061
  "license": "MIT"
4062
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4063
  "node_modules/forwarded": {
4064
  "version": "0.2.0",
4065
  "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -4208,6 +4401,21 @@
4208
  "url": "https://github.com/sponsors/ljharb"
4209
  }
4210
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4211
  "node_modules/hasown": {
4212
  "version": "2.0.2",
4213
  "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -4943,6 +5151,12 @@
4943
  "node": ">= 0.10"
4944
  }
4945
  },
 
 
 
 
 
 
4946
  "node_modules/qs": {
4947
  "version": "6.14.1",
4948
  "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
@@ -5194,6 +5408,19 @@
5194
  }
5195
  }
5196
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
5197
  "node_modules/readdirp": {
5198
  "version": "4.1.2",
5199
  "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -5208,6 +5435,12 @@
5208
  "url": "https://paulmillr.com/funding/"
5209
  }
5210
  },
 
 
 
 
 
 
5211
  "node_modules/rollup": {
5212
  "version": "4.57.1",
5213
  "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
@@ -5474,6 +5707,12 @@
5474
  "node": ">= 0.8"
5475
  }
5476
  },
 
 
 
 
 
 
5477
  "node_modules/tailwind-merge": {
5478
  "version": "3.4.0",
5479
  "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
 
6
  "": {
7
  "name": "talent-technical-evaluation",
8
  "dependencies": {
9
+ "@base-ui/react": "^1.1.0",
10
  "@react-router/node": "7.12.0",
11
  "@react-router/serve": "7.12.0",
12
+ "@tanstack/react-query": "^4.43.0",
13
+ "axios": "^1.13.4",
14
  "class-variance-authority": "^0.7.1",
15
  "clsx": "^2.1.1",
16
  "isbot": "^5.1.31",
 
19
  "react": "^19.2.4",
20
  "react-dom": "^19.2.4",
21
  "react-router": "7.12.0",
22
+ "react-toastify": "^11.0.5",
23
  "tailwind-merge": "^3.4.0"
24
  },
25
  "devDependencies": {
 
450
  "@babel/core": "^7.0.0-0"
451
  }
452
  },
453
+ "node_modules/@babel/runtime": {
454
+ "version": "7.28.6",
455
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
456
+ "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
457
+ "license": "MIT",
458
+ "engines": {
459
+ "node": ">=6.9.0"
460
+ }
461
+ },
462
  "node_modules/@babel/template": {
463
  "version": "7.28.6",
464
  "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
 
507
  "node": ">=6.9.0"
508
  }
509
  },
510
+ "node_modules/@base-ui/react": {
511
+ "version": "1.1.0",
512
+ "resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.1.0.tgz",
513
+ "integrity": "sha512-ikcJRNj1mOiF2HZ5jQHrXoVoHcNHdBU5ejJljcBl+VTLoYXR6FidjTN86GjO6hyshi6TZFuNvv0dEOgaOFv6Lw==",
514
+ "license": "MIT",
515
+ "dependencies": {
516
+ "@babel/runtime": "^7.28.4",
517
+ "@base-ui/utils": "0.2.4",
518
+ "@floating-ui/react-dom": "^2.1.6",
519
+ "@floating-ui/utils": "^0.2.10",
520
+ "reselect": "^5.1.1",
521
+ "tabbable": "^6.4.0",
522
+ "use-sync-external-store": "^1.6.0"
523
+ },
524
+ "engines": {
525
+ "node": ">=14.0.0"
526
+ },
527
+ "funding": {
528
+ "type": "opencollective",
529
+ "url": "https://opencollective.com/mui-org"
530
+ },
531
+ "peerDependencies": {
532
+ "@types/react": "^17 || ^18 || ^19",
533
+ "react": "^17 || ^18 || ^19",
534
+ "react-dom": "^17 || ^18 || ^19"
535
+ },
536
+ "peerDependenciesMeta": {
537
+ "@types/react": {
538
+ "optional": true
539
+ }
540
+ }
541
+ },
542
+ "node_modules/@base-ui/utils": {
543
+ "version": "0.2.4",
544
+ "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.2.4.tgz",
545
+ "integrity": "sha512-smZwpMhjO29v+jrZusBSc5T+IJ3vBb9cjIiBjtKcvWmRj9Z4DWGVR3efr1eHR56/bqY5a4qyY9ElkOY5ljo3ng==",
546
+ "license": "MIT",
547
+ "dependencies": {
548
+ "@babel/runtime": "^7.28.4",
549
+ "@floating-ui/utils": "^0.2.10",
550
+ "reselect": "^5.1.1",
551
+ "use-sync-external-store": "^1.6.0"
552
+ },
553
+ "peerDependencies": {
554
+ "@types/react": "^17 || ^18 || ^19",
555
+ "react": "^17 || ^18 || ^19",
556
+ "react-dom": "^17 || ^18 || ^19"
557
+ },
558
+ "peerDependenciesMeta": {
559
+ "@types/react": {
560
+ "optional": true
561
+ }
562
+ }
563
+ },
564
  "node_modules/@esbuild/aix-ppc64": {
565
  "version": "0.27.2",
566
  "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
 
3361
  "vite": "^5.2.0 || ^6 || ^7"
3362
  }
3363
  },
3364
+ "node_modules/@tanstack/query-core": {
3365
+ "version": "4.43.0",
3366
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.43.0.tgz",
3367
+ "integrity": "sha512-m1QeUUIpNXDYxmfuuWNFZLky0EwVmbE0hj8ulZ2nIGA1183raJgDCn0IKlxug80NotRqzodxAaoYTKHbE1/P/Q==",
3368
+ "license": "MIT",
3369
+ "funding": {
3370
+ "type": "github",
3371
+ "url": "https://github.com/sponsors/tannerlinsley"
3372
+ }
3373
+ },
3374
+ "node_modules/@tanstack/react-query": {
3375
+ "version": "4.43.0",
3376
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.43.0.tgz",
3377
+ "integrity": "sha512-Lj8luFKHQL27oZbw5T8xdTbsfAPp2+bCtSCa2bAVvIwnvNfRP0hpB1GxfKFgCktat8lPcYBHAu8eMTXzz2sQtQ==",
3378
+ "license": "MIT",
3379
+ "dependencies": {
3380
+ "@tanstack/query-core": "4.43.0",
3381
+ "use-sync-external-store": "^1.6.0"
3382
+ },
3383
+ "funding": {
3384
+ "type": "github",
3385
+ "url": "https://github.com/sponsors/tannerlinsley"
3386
+ },
3387
+ "peerDependencies": {
3388
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
3389
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
3390
+ "react-native": "*"
3391
+ },
3392
+ "peerDependenciesMeta": {
3393
+ "react-dom": {
3394
+ "optional": true
3395
+ },
3396
+ "react-native": {
3397
+ "optional": true
3398
+ }
3399
+ }
3400
+ },
3401
  "node_modules/@types/estree": {
3402
  "version": "1.0.8",
3403
  "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
 
3482
  "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
3483
  "license": "MIT"
3484
  },
3485
+ "node_modules/asynckit": {
3486
+ "version": "0.4.0",
3487
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
3488
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
3489
+ "license": "MIT"
3490
+ },
3491
+ "node_modules/axios": {
3492
+ "version": "1.13.4",
3493
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz",
3494
+ "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==",
3495
+ "license": "MIT",
3496
+ "dependencies": {
3497
+ "follow-redirects": "^1.15.6",
3498
+ "form-data": "^4.0.4",
3499
+ "proxy-from-env": "^1.1.0"
3500
+ }
3501
+ },
3502
  "node_modules/babel-dead-code-elimination": {
3503
  "version": "1.0.12",
3504
  "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz",
 
3725
  "node": ">=6"
3726
  }
3727
  },
3728
+ "node_modules/combined-stream": {
3729
+ "version": "1.0.8",
3730
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
3731
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
3732
+ "license": "MIT",
3733
+ "dependencies": {
3734
+ "delayed-stream": "~1.0.0"
3735
+ },
3736
+ "engines": {
3737
+ "node": ">= 0.8"
3738
+ }
3739
+ },
3740
  "node_modules/compressible": {
3741
  "version": "2.0.18",
3742
  "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
 
3872
  }
3873
  }
3874
  },
3875
+ "node_modules/delayed-stream": {
3876
+ "version": "1.0.0",
3877
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
3878
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
3879
+ "license": "MIT",
3880
+ "engines": {
3881
+ "node": ">=0.4.0"
3882
+ }
3883
+ },
3884
  "node_modules/depd": {
3885
  "version": "2.0.0",
3886
  "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
 
4003
  "node": ">= 0.4"
4004
  }
4005
  },
4006
+ "node_modules/es-set-tostringtag": {
4007
+ "version": "2.1.0",
4008
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
4009
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
4010
+ "license": "MIT",
4011
+ "dependencies": {
4012
+ "es-errors": "^1.3.0",
4013
+ "get-intrinsic": "^1.2.6",
4014
+ "has-tostringtag": "^1.0.2",
4015
+ "hasown": "^2.0.2"
4016
+ },
4017
+ "engines": {
4018
+ "node": ">= 0.4"
4019
+ }
4020
+ },
4021
  "node_modules/esbuild": {
4022
  "version": "0.27.2",
4023
  "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
 
4217
  "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
4218
  "license": "MIT"
4219
  },
4220
+ "node_modules/follow-redirects": {
4221
+ "version": "1.15.11",
4222
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
4223
+ "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
4224
+ "funding": [
4225
+ {
4226
+ "type": "individual",
4227
+ "url": "https://github.com/sponsors/RubenVerborgh"
4228
+ }
4229
+ ],
4230
+ "license": "MIT",
4231
+ "engines": {
4232
+ "node": ">=4.0"
4233
+ },
4234
+ "peerDependenciesMeta": {
4235
+ "debug": {
4236
+ "optional": true
4237
+ }
4238
+ }
4239
+ },
4240
+ "node_modules/form-data": {
4241
+ "version": "4.0.5",
4242
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
4243
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
4244
+ "license": "MIT",
4245
+ "dependencies": {
4246
+ "asynckit": "^0.4.0",
4247
+ "combined-stream": "^1.0.8",
4248
+ "es-set-tostringtag": "^2.1.0",
4249
+ "hasown": "^2.0.2",
4250
+ "mime-types": "^2.1.12"
4251
+ },
4252
+ "engines": {
4253
+ "node": ">= 6"
4254
+ }
4255
+ },
4256
  "node_modules/forwarded": {
4257
  "version": "0.2.0",
4258
  "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
 
4401
  "url": "https://github.com/sponsors/ljharb"
4402
  }
4403
  },
4404
+ "node_modules/has-tostringtag": {
4405
+ "version": "1.0.2",
4406
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
4407
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
4408
+ "license": "MIT",
4409
+ "dependencies": {
4410
+ "has-symbols": "^1.0.3"
4411
+ },
4412
+ "engines": {
4413
+ "node": ">= 0.4"
4414
+ },
4415
+ "funding": {
4416
+ "url": "https://github.com/sponsors/ljharb"
4417
+ }
4418
+ },
4419
  "node_modules/hasown": {
4420
  "version": "2.0.2",
4421
  "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
 
5151
  "node": ">= 0.10"
5152
  }
5153
  },
5154
+ "node_modules/proxy-from-env": {
5155
+ "version": "1.1.0",
5156
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
5157
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
5158
+ "license": "MIT"
5159
+ },
5160
  "node_modules/qs": {
5161
  "version": "6.14.1",
5162
  "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
 
5408
  }
5409
  }
5410
  },
5411
+ "node_modules/react-toastify": {
5412
+ "version": "11.0.5",
5413
+ "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-11.0.5.tgz",
5414
+ "integrity": "sha512-EpqHBGvnSTtHYhCPLxML05NLY2ZX0JURbAdNYa6BUkk+amz4wbKBQvoKQAB0ardvSarUBuY4Q4s1sluAzZwkmA==",
5415
+ "license": "MIT",
5416
+ "dependencies": {
5417
+ "clsx": "^2.1.1"
5418
+ },
5419
+ "peerDependencies": {
5420
+ "react": "^18 || ^19",
5421
+ "react-dom": "^18 || ^19"
5422
+ }
5423
+ },
5424
  "node_modules/readdirp": {
5425
  "version": "4.1.2",
5426
  "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
 
5435
  "url": "https://paulmillr.com/funding/"
5436
  }
5437
  },
5438
+ "node_modules/reselect": {
5439
+ "version": "5.1.1",
5440
+ "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
5441
+ "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
5442
+ "license": "MIT"
5443
+ },
5444
  "node_modules/rollup": {
5445
  "version": "4.57.1",
5446
  "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
 
5707
  "node": ">= 0.8"
5708
  }
5709
  },
5710
+ "node_modules/tabbable": {
5711
+ "version": "6.4.0",
5712
+ "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
5713
+ "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==",
5714
+ "license": "MIT"
5715
+ },
5716
  "node_modules/tailwind-merge": {
5717
  "version": "3.4.0",
5718
  "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
frontend/package.json CHANGED
@@ -9,8 +9,11 @@
9
  "typecheck": "react-router typegen && tsc"
10
  },
11
  "dependencies": {
 
12
  "@react-router/node": "7.12.0",
13
  "@react-router/serve": "7.12.0",
 
 
14
  "class-variance-authority": "^0.7.1",
15
  "clsx": "^2.1.1",
16
  "isbot": "^5.1.31",
@@ -19,6 +22,7 @@
19
  "react": "^19.2.4",
20
  "react-dom": "^19.2.4",
21
  "react-router": "7.12.0",
 
22
  "tailwind-merge": "^3.4.0"
23
  },
24
  "devDependencies": {
 
9
  "typecheck": "react-router typegen && tsc"
10
  },
11
  "dependencies": {
12
+ "@base-ui/react": "^1.1.0",
13
  "@react-router/node": "7.12.0",
14
  "@react-router/serve": "7.12.0",
15
+ "@tanstack/react-query": "^4.43.0",
16
+ "axios": "^1.13.4",
17
  "class-variance-authority": "^0.7.1",
18
  "clsx": "^2.1.1",
19
  "isbot": "^5.1.31",
 
22
  "react": "^19.2.4",
23
  "react-dom": "^19.2.4",
24
  "react-router": "7.12.0",
25
+ "react-toastify": "^11.0.5",
26
  "tailwind-merge": "^3.4.0"
27
  },
28
  "devDependencies": {