create-tigra 1.0.7 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +80 -87
- package/bin/create-tigra.js +242 -309
- package/package.json +49 -41
- package/template/_claude/QUICK_REFERENCE.md +193 -0
- package/template/_claude/README.md +53 -0
- package/template/_claude/commands/create-client.md +881 -0
- package/template/_claude/commands/create-server.md +383 -0
- package/template/_claude/rules/client/01-project-structure.md +133 -0
- package/template/_claude/rules/client/02-components-and-types.md +146 -0
- package/template/_claude/rules/client/03-data-and-state.md +156 -0
- package/template/_claude/rules/client/04-design-system.md +185 -0
- package/template/_claude/rules/client/05-security.md +55 -0
- package/template/_claude/rules/client/06-ux-checklist.md +81 -0
- package/template/_claude/rules/client/core.md +42 -0
- package/template/_claude/rules/global/core.md +77 -0
- package/template/_claude/rules/server/core.md +50 -0
- package/template/_claude/rules/server/database.md +124 -0
- package/template/_claude/rules/server/project-conventions.md +150 -0
- package/template/_claude/rules/server/response-handling.md +144 -0
- package/template/client/.env.example +5 -0
- package/template/client/README.md +36 -0
- package/template/client/components.json +23 -0
- package/template/client/eslint.config.mjs +18 -0
- package/template/client/next.config.ts +34 -0
- package/template/client/package.json +44 -0
- package/template/client/postcss.config.mjs +7 -0
- package/template/client/src/app/(auth)/layout.tsx +18 -0
- package/template/client/src/app/(auth)/login/page.tsx +13 -0
- package/template/client/src/app/(auth)/register/page.tsx +13 -0
- package/template/client/src/app/(main)/dashboard/page.tsx +22 -0
- package/template/client/src/app/(main)/layout.tsx +11 -0
- package/template/client/src/app/error.tsx +27 -0
- package/template/client/src/app/favicon.ico +0 -0
- package/template/client/src/app/globals.css +145 -0
- package/template/client/src/app/layout.tsx +36 -0
- package/template/client/src/app/loading.tsx +11 -0
- package/template/client/src/app/not-found.tsx +23 -0
- package/template/client/src/app/page.tsx +45 -0
- package/template/client/src/app/providers.tsx +43 -0
- package/template/client/src/components/common/ConfirmDialog.tsx +56 -0
- package/template/client/src/components/common/EmptyState.tsx +31 -0
- package/template/client/src/components/common/LoadingSpinner.tsx +30 -0
- package/template/client/src/components/common/Pagination.tsx +55 -0
- package/template/client/src/components/layout/Footer.tsx +17 -0
- package/template/client/src/components/layout/Header.tsx +173 -0
- package/template/client/src/components/layout/MainLayout.tsx +18 -0
- package/template/client/src/components/ui/alert-dialog.tsx +196 -0
- package/template/client/src/components/ui/badge.tsx +48 -0
- package/template/client/src/components/ui/button.tsx +64 -0
- package/template/client/src/components/ui/card.tsx +92 -0
- package/template/client/src/components/ui/input.tsx +21 -0
- package/template/client/src/components/ui/label.tsx +24 -0
- package/template/client/src/components/ui/select.tsx +190 -0
- package/template/client/src/components/ui/skeleton.tsx +13 -0
- package/template/client/src/components/ui/table.tsx +116 -0
- package/template/client/src/features/auth/components/AuthInitializer.tsx +55 -0
- package/template/client/src/features/auth/components/LoginForm.tsx +107 -0
- package/template/client/src/features/auth/components/RegisterForm.tsx +178 -0
- package/template/client/src/features/auth/hooks/useAuth.ts +84 -0
- package/template/client/src/features/auth/services/auth.service.ts +52 -0
- package/template/client/src/features/auth/store/authSlice.ts +38 -0
- package/template/client/src/features/auth/types/auth.types.ts +32 -0
- package/template/client/src/hooks/useDebounce.ts +14 -0
- package/template/client/src/hooks/useLocalStorage.ts +55 -0
- package/template/client/src/hooks/useMediaQuery.ts +27 -0
- package/template/client/src/lib/api/api.types.ts +34 -0
- package/template/client/src/lib/api/axios.config.ts +98 -0
- package/template/client/src/lib/constants/api-endpoints.ts +18 -0
- package/template/client/src/lib/constants/app.constants.ts +12 -0
- package/template/client/src/lib/constants/routes.ts +9 -0
- package/template/client/src/lib/utils/error.ts +32 -0
- package/template/client/src/lib/utils/format.ts +37 -0
- package/template/client/src/lib/utils/security.ts +34 -0
- package/template/client/src/lib/utils.ts +6 -0
- package/template/client/src/middleware.ts +57 -0
- package/template/client/src/store/hooks.ts +7 -0
- package/template/client/src/store/index.ts +12 -0
- package/template/client/src/types/index.ts +3 -0
- package/template/client/tsconfig.json +34 -0
- package/template/gitignore +34 -0
- package/template/server/.dockerignore +66 -0
- package/template/server/.env.example +96 -69
- package/template/server/.env.production.example +90 -0
- package/template/server/Dockerfile +94 -0
- package/template/server/docker-compose.yml +80 -111
- package/template/server/docs/logging.md +62 -0
- package/template/server/eslint.config.mjs +17 -0
- package/template/server/package.json +68 -81
- package/template/server/phpmyadmin-config.php +26 -0
- package/template/server/postman_collection.json +666 -0
- package/template/server/prisma/schema.prisma +77 -93
- package/template/server/prisma/seed.ts +46 -142
- package/template/server/scripts/flush-redis.ts +41 -0
- package/template/server/src/app.ts +243 -71
- package/template/server/src/config/env.ts +67 -94
- package/template/server/src/libs/auth.ts +88 -0
- package/template/server/src/libs/cleanup.ts +35 -0
- package/template/server/src/libs/cookies.ts +46 -0
- package/template/server/src/libs/logger.ts +33 -60
- package/template/server/src/libs/monitoring.ts +205 -0
- package/template/server/src/libs/password.ts +38 -0
- package/template/server/src/libs/prisma.ts +68 -0
- package/template/server/src/libs/redis.ts +60 -79
- package/template/server/src/libs/requestLogger.ts +66 -0
- package/template/server/src/libs/storage/file-storage.service.ts +211 -0
- package/template/server/src/libs/storage/file-validator.ts +97 -0
- package/template/server/src/libs/storage/filename-sanitizer.ts +71 -0
- package/template/server/src/libs/storage/image-optimizer.service.ts +144 -0
- package/template/server/src/modules/auth/__tests__/auth.service.test.ts +365 -0
- package/template/server/src/modules/auth/auth.controller.ts +90 -141
- package/template/server/src/modules/auth/auth.repo.ts +120 -218
- package/template/server/src/modules/auth/auth.routes.ts +96 -83
- package/template/server/src/modules/auth/auth.schemas.ts +35 -137
- package/template/server/src/modules/auth/auth.service.ts +286 -329
- package/template/server/src/modules/auth/session.repo.ts +110 -0
- package/template/server/src/modules/users/users.controller.ts +120 -0
- package/template/server/src/modules/users/users.repo.ts +77 -0
- package/template/server/src/modules/users/users.routes.ts +89 -0
- package/template/server/src/modules/users/users.schemas.ts +21 -0
- package/template/server/src/modules/users/users.service.ts +169 -0
- package/template/server/src/server.ts +58 -139
- package/template/server/src/shared/errors/AppError.ts +21 -0
- package/template/server/src/shared/errors/errors.ts +43 -0
- package/template/server/src/shared/responses/paginatedResponse.ts +38 -0
- package/template/server/src/shared/responses/successResponse.ts +17 -0
- package/template/server/src/shared/schemas/pagination.schema.ts +12 -0
- package/template/server/src/shared/types/index.ts +26 -0
- package/template/server/src/test/setup.ts +74 -38
- package/template/server/tsconfig.json +27 -89
- package/template/server/uploads/avatars/.gitkeep +1 -0
- package/template/server/vitest.config.ts +43 -98
- package/template/.agent/rules/client/01-project-structure.md +0 -326
- package/template/.agent/rules/client/02-component-patterns.md +0 -249
- package/template/.agent/rules/client/03-typescript-rules.md +0 -226
- package/template/.agent/rules/client/04-state-management.md +0 -474
- package/template/.agent/rules/client/05-api-integration.md +0 -129
- package/template/.agent/rules/client/06-forms-validation.md +0 -129
- package/template/.agent/rules/client/07-common-patterns.md +0 -150
- package/template/.agent/rules/client/08-color-system.md +0 -93
- package/template/.agent/rules/client/09-security-rules.md +0 -97
- package/template/.agent/rules/client/10-testing-strategy.md +0 -370
- package/template/.agent/rules/global/ai-edit-safety.md +0 -38
- package/template/.agent/rules/server/01-db-and-migrations.md +0 -242
- package/template/.agent/rules/server/02-general-rules.md +0 -111
- package/template/.agent/rules/server/03-migrations.md +0 -20
- package/template/.agent/rules/server/04-pagination.md +0 -130
- package/template/.agent/rules/server/05-project-conventions.md +0 -71
- package/template/.agent/rules/server/06-response-handling.md +0 -173
- package/template/.agent/rules/server/07-testing-strategy.md +0 -506
- package/template/.agent/rules/server/08-observability.md +0 -180
- package/template/.agent/rules/server/10-background-jobs-v2.md +0 -185
- package/template/.agent/rules/server/11-rate-limiting-v2.md +0 -210
- package/template/.agent/rules/server/12-performance-optimization.md +0 -567
- package/template/.claude/rules/client-01-project-structure.md +0 -327
- package/template/.claude/rules/client-02-component-patterns.md +0 -250
- package/template/.claude/rules/client-03-typescript-rules.md +0 -227
- package/template/.claude/rules/client-04-state-management.md +0 -475
- package/template/.claude/rules/client-05-api-integration.md +0 -130
- package/template/.claude/rules/client-06-forms-validation.md +0 -130
- package/template/.claude/rules/client-07-common-patterns.md +0 -151
- package/template/.claude/rules/client-08-color-system.md +0 -94
- package/template/.claude/rules/client-09-security-rules.md +0 -98
- package/template/.claude/rules/client-10-testing-strategy.md +0 -371
- package/template/.claude/rules/global-ai-edit-safety.md +0 -39
- package/template/.claude/rules/server-01-db-and-migrations.md +0 -243
- package/template/.claude/rules/server-02-general-rules.md +0 -112
- package/template/.claude/rules/server-03-migrations.md +0 -21
- package/template/.claude/rules/server-04-pagination.md +0 -131
- package/template/.claude/rules/server-05-project-conventions.md +0 -72
- package/template/.claude/rules/server-06-response-handling.md +0 -174
- package/template/.claude/rules/server-07-testing-strategy.md +0 -507
- package/template/.claude/rules/server-08-observability.md +0 -181
- package/template/.claude/rules/server-10-background-jobs-v2.md +0 -186
- package/template/.claude/rules/server-11-rate-limiting-v2.md +0 -211
- package/template/.claude/rules/server-12-performance-optimization.md +0 -568
- package/template/.cursor/rules/client-01-project-structure.mdc +0 -327
- package/template/.cursor/rules/client-02-component-patterns.mdc +0 -250
- package/template/.cursor/rules/client-03-typescript-rules.mdc +0 -227
- package/template/.cursor/rules/client-04-state-management.mdc +0 -475
- package/template/.cursor/rules/client-05-api-integration.mdc +0 -130
- package/template/.cursor/rules/client-06-forms-validation.mdc +0 -130
- package/template/.cursor/rules/client-07-common-patterns.mdc +0 -151
- package/template/.cursor/rules/client-08-color-system.mdc +0 -94
- package/template/.cursor/rules/client-09-security-rules.mdc +0 -98
- package/template/.cursor/rules/client-10-testing-strategy.mdc +0 -371
- package/template/.cursor/rules/global-ai-edit-safety.mdc +0 -39
- package/template/.cursor/rules/server-01-db-and-migrations.mdc +0 -243
- package/template/.cursor/rules/server-02-general-rules.mdc +0 -112
- package/template/.cursor/rules/server-03-migrations.mdc +0 -21
- package/template/.cursor/rules/server-04-pagination.mdc +0 -131
- package/template/.cursor/rules/server-05-project-conventions.mdc +0 -72
- package/template/.cursor/rules/server-06-response-handling.mdc +0 -174
- package/template/.cursor/rules/server-07-testing-strategy.mdc +0 -507
- package/template/.cursor/rules/server-08-observability.mdc +0 -181
- package/template/.cursor/rules/server-09-api-documentation-v2.mdc +0 -169
- package/template/.cursor/rules/server-10-background-jobs-v2.mdc +0 -186
- package/template/.cursor/rules/server-11-rate-limiting-v2.mdc +0 -211
- package/template/.cursor/rules/server-12-performance-optimization.mdc +0 -568
- package/template/CLAUDE.md +0 -207
- package/template/server/.tsc-aliasrc.json +0 -12
- package/template/server/README.md +0 -183
- package/template/server/SECURITY.md +0 -190
- package/template/server/Tigra-API.postman_collection.json +0 -733
- package/template/server/biome.json +0 -42
- package/template/server/scripts/setup-env.js +0 -50
- package/template/server/scripts/wait-for-db.js +0 -60
- package/template/server/src/hooks/request-timing.hook.ts +0 -26
- package/template/server/src/libs/auth/authenticate.middleware.ts +0 -22
- package/template/server/src/libs/auth/rbac.middleware.test.ts +0 -134
- package/template/server/src/libs/auth/rbac.middleware.ts +0 -147
- package/template/server/src/libs/db.ts +0 -76
- package/template/server/src/libs/error-handler.ts +0 -89
- package/template/server/src/libs/queue.ts +0 -79
- package/template/server/src/modules/admin/admin.controller.ts +0 -122
- package/template/server/src/modules/admin/admin.routes.ts +0 -62
- package/template/server/src/modules/admin/admin.schemas.ts +0 -35
- package/template/server/src/modules/admin/admin.service.ts +0 -167
- package/template/server/src/modules/auth/auth.integration.test.ts +0 -150
- package/template/server/src/modules/auth/auth.service.test.ts +0 -119
- package/template/server/src/modules/auth/auth.types.ts +0 -97
- package/template/server/src/modules/resources/resources.controller.ts +0 -218
- package/template/server/src/modules/resources/resources.repo.ts +0 -253
- package/template/server/src/modules/resources/resources.routes.ts +0 -116
- package/template/server/src/modules/resources/resources.schemas.ts +0 -146
- package/template/server/src/modules/resources/resources.service.ts +0 -218
- package/template/server/src/modules/resources/resources.types.ts +0 -73
- package/template/server/src/plugins/rate-limit.plugin.ts +0 -21
- package/template/server/src/plugins/security.plugin.ts +0 -21
- package/template/server/src/routes/health.routes.ts +0 -31
- package/template/server/src/types/fastify.d.ts +0 -36
- package/template/server/src/utils/errors.ts +0 -108
- package/template/server/src/utils/pagination.ts +0 -120
- package/template/server/src/utils/response.ts +0 -110
- package/template/server/src/workers/file.worker.ts +0 -106
- package/template/server/tsconfig.build.json +0 -30
- package/template/server/tsconfig.test.json +0 -22
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
|
5
|
+
import { Select as SelectPrimitive } from "radix-ui"
|
|
6
|
+
|
|
7
|
+
import { cn } from "@/lib/utils"
|
|
8
|
+
|
|
9
|
+
function Select({
|
|
10
|
+
...props
|
|
11
|
+
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
|
12
|
+
return <SelectPrimitive.Root data-slot="select" {...props} />
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function SelectGroup({
|
|
16
|
+
...props
|
|
17
|
+
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
|
18
|
+
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function SelectValue({
|
|
22
|
+
...props
|
|
23
|
+
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
|
24
|
+
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function SelectTrigger({
|
|
28
|
+
className,
|
|
29
|
+
size = "default",
|
|
30
|
+
children,
|
|
31
|
+
...props
|
|
32
|
+
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
|
33
|
+
size?: "sm" | "default"
|
|
34
|
+
}) {
|
|
35
|
+
return (
|
|
36
|
+
<SelectPrimitive.Trigger
|
|
37
|
+
data-slot="select-trigger"
|
|
38
|
+
data-size={size}
|
|
39
|
+
className={cn(
|
|
40
|
+
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]: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 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
41
|
+
className
|
|
42
|
+
)}
|
|
43
|
+
{...props}
|
|
44
|
+
>
|
|
45
|
+
{children}
|
|
46
|
+
<SelectPrimitive.Icon asChild>
|
|
47
|
+
<ChevronDownIcon className="size-4 opacity-50" />
|
|
48
|
+
</SelectPrimitive.Icon>
|
|
49
|
+
</SelectPrimitive.Trigger>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function SelectContent({
|
|
54
|
+
className,
|
|
55
|
+
children,
|
|
56
|
+
position = "item-aligned",
|
|
57
|
+
align = "center",
|
|
58
|
+
...props
|
|
59
|
+
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
|
60
|
+
return (
|
|
61
|
+
<SelectPrimitive.Portal>
|
|
62
|
+
<SelectPrimitive.Content
|
|
63
|
+
data-slot="select-content"
|
|
64
|
+
className={cn(
|
|
65
|
+
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
|
66
|
+
position === "popper" &&
|
|
67
|
+
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
|
68
|
+
className
|
|
69
|
+
)}
|
|
70
|
+
position={position}
|
|
71
|
+
align={align}
|
|
72
|
+
{...props}
|
|
73
|
+
>
|
|
74
|
+
<SelectScrollUpButton />
|
|
75
|
+
<SelectPrimitive.Viewport
|
|
76
|
+
className={cn(
|
|
77
|
+
"p-1",
|
|
78
|
+
position === "popper" &&
|
|
79
|
+
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
|
80
|
+
)}
|
|
81
|
+
>
|
|
82
|
+
{children}
|
|
83
|
+
</SelectPrimitive.Viewport>
|
|
84
|
+
<SelectScrollDownButton />
|
|
85
|
+
</SelectPrimitive.Content>
|
|
86
|
+
</SelectPrimitive.Portal>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function SelectLabel({
|
|
91
|
+
className,
|
|
92
|
+
...props
|
|
93
|
+
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
|
94
|
+
return (
|
|
95
|
+
<SelectPrimitive.Label
|
|
96
|
+
data-slot="select-label"
|
|
97
|
+
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
|
98
|
+
{...props}
|
|
99
|
+
/>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function SelectItem({
|
|
104
|
+
className,
|
|
105
|
+
children,
|
|
106
|
+
...props
|
|
107
|
+
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
|
108
|
+
return (
|
|
109
|
+
<SelectPrimitive.Item
|
|
110
|
+
data-slot="select-item"
|
|
111
|
+
className={cn(
|
|
112
|
+
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-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 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
|
113
|
+
className
|
|
114
|
+
)}
|
|
115
|
+
{...props}
|
|
116
|
+
>
|
|
117
|
+
<span
|
|
118
|
+
data-slot="select-item-indicator"
|
|
119
|
+
className="absolute right-2 flex size-3.5 items-center justify-center"
|
|
120
|
+
>
|
|
121
|
+
<SelectPrimitive.ItemIndicator>
|
|
122
|
+
<CheckIcon className="size-4" />
|
|
123
|
+
</SelectPrimitive.ItemIndicator>
|
|
124
|
+
</span>
|
|
125
|
+
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
|
126
|
+
</SelectPrimitive.Item>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function SelectSeparator({
|
|
131
|
+
className,
|
|
132
|
+
...props
|
|
133
|
+
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
|
134
|
+
return (
|
|
135
|
+
<SelectPrimitive.Separator
|
|
136
|
+
data-slot="select-separator"
|
|
137
|
+
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
|
138
|
+
{...props}
|
|
139
|
+
/>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function SelectScrollUpButton({
|
|
144
|
+
className,
|
|
145
|
+
...props
|
|
146
|
+
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
|
147
|
+
return (
|
|
148
|
+
<SelectPrimitive.ScrollUpButton
|
|
149
|
+
data-slot="select-scroll-up-button"
|
|
150
|
+
className={cn(
|
|
151
|
+
"flex cursor-default items-center justify-center py-1",
|
|
152
|
+
className
|
|
153
|
+
)}
|
|
154
|
+
{...props}
|
|
155
|
+
>
|
|
156
|
+
<ChevronUpIcon className="size-4" />
|
|
157
|
+
</SelectPrimitive.ScrollUpButton>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function SelectScrollDownButton({
|
|
162
|
+
className,
|
|
163
|
+
...props
|
|
164
|
+
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
|
165
|
+
return (
|
|
166
|
+
<SelectPrimitive.ScrollDownButton
|
|
167
|
+
data-slot="select-scroll-down-button"
|
|
168
|
+
className={cn(
|
|
169
|
+
"flex cursor-default items-center justify-center py-1",
|
|
170
|
+
className
|
|
171
|
+
)}
|
|
172
|
+
{...props}
|
|
173
|
+
>
|
|
174
|
+
<ChevronDownIcon className="size-4" />
|
|
175
|
+
</SelectPrimitive.ScrollDownButton>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export {
|
|
180
|
+
Select,
|
|
181
|
+
SelectContent,
|
|
182
|
+
SelectGroup,
|
|
183
|
+
SelectItem,
|
|
184
|
+
SelectLabel,
|
|
185
|
+
SelectScrollDownButton,
|
|
186
|
+
SelectScrollUpButton,
|
|
187
|
+
SelectSeparator,
|
|
188
|
+
SelectTrigger,
|
|
189
|
+
SelectValue,
|
|
190
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { cn } from "@/lib/utils"
|
|
2
|
+
|
|
3
|
+
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
|
4
|
+
return (
|
|
5
|
+
<div
|
|
6
|
+
data-slot="skeleton"
|
|
7
|
+
className={cn("bg-accent animate-pulse rounded-md", className)}
|
|
8
|
+
{...props}
|
|
9
|
+
/>
|
|
10
|
+
)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export { Skeleton }
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
|
|
5
|
+
import { cn } from "@/lib/utils"
|
|
6
|
+
|
|
7
|
+
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
|
8
|
+
return (
|
|
9
|
+
<div
|
|
10
|
+
data-slot="table-container"
|
|
11
|
+
className="relative w-full overflow-x-auto"
|
|
12
|
+
>
|
|
13
|
+
<table
|
|
14
|
+
data-slot="table"
|
|
15
|
+
className={cn("w-full caption-bottom text-sm", className)}
|
|
16
|
+
{...props}
|
|
17
|
+
/>
|
|
18
|
+
</div>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
|
23
|
+
return (
|
|
24
|
+
<thead
|
|
25
|
+
data-slot="table-header"
|
|
26
|
+
className={cn("[&_tr]:border-b", className)}
|
|
27
|
+
{...props}
|
|
28
|
+
/>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
|
33
|
+
return (
|
|
34
|
+
<tbody
|
|
35
|
+
data-slot="table-body"
|
|
36
|
+
className={cn("[&_tr:last-child]:border-0", className)}
|
|
37
|
+
{...props}
|
|
38
|
+
/>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
|
43
|
+
return (
|
|
44
|
+
<tfoot
|
|
45
|
+
data-slot="table-footer"
|
|
46
|
+
className={cn(
|
|
47
|
+
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
|
48
|
+
className
|
|
49
|
+
)}
|
|
50
|
+
{...props}
|
|
51
|
+
/>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
|
56
|
+
return (
|
|
57
|
+
<tr
|
|
58
|
+
data-slot="table-row"
|
|
59
|
+
className={cn(
|
|
60
|
+
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
|
61
|
+
className
|
|
62
|
+
)}
|
|
63
|
+
{...props}
|
|
64
|
+
/>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
|
69
|
+
return (
|
|
70
|
+
<th
|
|
71
|
+
data-slot="table-head"
|
|
72
|
+
className={cn(
|
|
73
|
+
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
|
74
|
+
className
|
|
75
|
+
)}
|
|
76
|
+
{...props}
|
|
77
|
+
/>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
|
82
|
+
return (
|
|
83
|
+
<td
|
|
84
|
+
data-slot="table-cell"
|
|
85
|
+
className={cn(
|
|
86
|
+
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
|
87
|
+
className
|
|
88
|
+
)}
|
|
89
|
+
{...props}
|
|
90
|
+
/>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function TableCaption({
|
|
95
|
+
className,
|
|
96
|
+
...props
|
|
97
|
+
}: React.ComponentProps<"caption">) {
|
|
98
|
+
return (
|
|
99
|
+
<caption
|
|
100
|
+
data-slot="table-caption"
|
|
101
|
+
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
|
102
|
+
{...props}
|
|
103
|
+
/>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export {
|
|
108
|
+
Table,
|
|
109
|
+
TableHeader,
|
|
110
|
+
TableBody,
|
|
111
|
+
TableFooter,
|
|
112
|
+
TableHead,
|
|
113
|
+
TableRow,
|
|
114
|
+
TableCell,
|
|
115
|
+
TableCaption,
|
|
116
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type React from 'react';
|
|
4
|
+
import { useEffect } from 'react';
|
|
5
|
+
|
|
6
|
+
import { usePathname, useRouter } from 'next/navigation';
|
|
7
|
+
|
|
8
|
+
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
|
9
|
+
import { ROUTES } from '@/lib/constants/routes';
|
|
10
|
+
import { authService } from '../services/auth.service';
|
|
11
|
+
import { setUser, setInitialized } from '../store/authSlice';
|
|
12
|
+
|
|
13
|
+
const AUTH_PAGES: string[] = [ROUTES.LOGIN, ROUTES.REGISTER];
|
|
14
|
+
|
|
15
|
+
interface AuthInitializerProps {
|
|
16
|
+
children: React.ReactNode;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const AuthInitializer = ({ children }: AuthInitializerProps): React.ReactElement => {
|
|
20
|
+
const dispatch = useAppDispatch();
|
|
21
|
+
const pathname = usePathname();
|
|
22
|
+
const router = useRouter();
|
|
23
|
+
const { isAuthenticated, isLoggingOut } = useAppSelector((state) => state.auth);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (AUTH_PAGES.includes(pathname)) {
|
|
27
|
+
dispatch(setInitialized());
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (isAuthenticated || isLoggingOut) return;
|
|
31
|
+
|
|
32
|
+
let cancelled = false;
|
|
33
|
+
|
|
34
|
+
authService
|
|
35
|
+
.getMe()
|
|
36
|
+
.then((user) => {
|
|
37
|
+
if (!cancelled) dispatch(setUser(user));
|
|
38
|
+
})
|
|
39
|
+
.catch((error) => {
|
|
40
|
+
if (cancelled) return;
|
|
41
|
+
dispatch(setInitialized());
|
|
42
|
+
// Only redirect on auth errors (401/403), not network failures
|
|
43
|
+
const status = error?.response?.status;
|
|
44
|
+
if (status === 401 || status === 403) {
|
|
45
|
+
router.push(ROUTES.LOGIN);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return (): void => {
|
|
50
|
+
cancelled = true;
|
|
51
|
+
};
|
|
52
|
+
}, [dispatch, pathname, isAuthenticated, isLoggingOut, router]);
|
|
53
|
+
|
|
54
|
+
return <>{children}</>;
|
|
55
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type React from 'react';
|
|
4
|
+
|
|
5
|
+
import Link from 'next/link';
|
|
6
|
+
import { useForm } from 'react-hook-form';
|
|
7
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { Loader2 } from 'lucide-react';
|
|
10
|
+
|
|
11
|
+
import { Button } from '@/components/ui/button';
|
|
12
|
+
import { Input } from '@/components/ui/input';
|
|
13
|
+
import { Label } from '@/components/ui/label';
|
|
14
|
+
import { useAuth } from '../hooks/useAuth';
|
|
15
|
+
import { ROUTES } from '@/lib/constants/routes';
|
|
16
|
+
import { cn } from '@/lib/utils';
|
|
17
|
+
|
|
18
|
+
const loginSchema = z.object({
|
|
19
|
+
email: z.string().min(1, 'Email is required').email('Invalid email address'),
|
|
20
|
+
password: z.string().min(1, 'Password is required'),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
type LoginFormData = z.infer<typeof loginSchema>;
|
|
24
|
+
|
|
25
|
+
export const LoginForm = (): React.ReactElement => {
|
|
26
|
+
const { login, isLoggingIn } = useAuth();
|
|
27
|
+
|
|
28
|
+
const {
|
|
29
|
+
register,
|
|
30
|
+
handleSubmit,
|
|
31
|
+
formState: { errors },
|
|
32
|
+
} = useForm<LoginFormData>({
|
|
33
|
+
resolver: zodResolver(loginSchema),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const onSubmit = (data: LoginFormData): void => {
|
|
37
|
+
login(data);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className="mx-auto w-full max-w-sm space-y-6">
|
|
42
|
+
<div className="space-y-2 text-center">
|
|
43
|
+
<h1 className="text-2xl font-bold tracking-tight">Welcome back</h1>
|
|
44
|
+
<p className="text-sm text-muted-foreground">
|
|
45
|
+
Sign in to your account to continue
|
|
46
|
+
</p>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
|
50
|
+
<div className="space-y-2">
|
|
51
|
+
<Label htmlFor="email">Email</Label>
|
|
52
|
+
<Input
|
|
53
|
+
id="email"
|
|
54
|
+
type="email"
|
|
55
|
+
placeholder="you@example.com"
|
|
56
|
+
autoComplete="email"
|
|
57
|
+
aria-invalid={!!errors.email}
|
|
58
|
+
aria-describedby={errors.email ? 'email-error' : undefined}
|
|
59
|
+
{...register('email')}
|
|
60
|
+
className={cn(errors.email && 'border-destructive')}
|
|
61
|
+
/>
|
|
62
|
+
{errors.email && (
|
|
63
|
+
<p id="email-error" className="text-sm text-destructive">{errors.email.message}</p>
|
|
64
|
+
)}
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<div className="space-y-2">
|
|
68
|
+
<Label htmlFor="password">Password</Label>
|
|
69
|
+
<Input
|
|
70
|
+
id="password"
|
|
71
|
+
type="password"
|
|
72
|
+
placeholder="Enter your password"
|
|
73
|
+
autoComplete="current-password"
|
|
74
|
+
aria-invalid={!!errors.password}
|
|
75
|
+
aria-describedby={errors.password ? 'password-error' : undefined}
|
|
76
|
+
{...register('password')}
|
|
77
|
+
className={cn(errors.password && 'border-destructive')}
|
|
78
|
+
/>
|
|
79
|
+
{errors.password && (
|
|
80
|
+
<p id="password-error" className="text-sm text-destructive">{errors.password.message}</p>
|
|
81
|
+
)}
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<Button type="submit" className="w-full" disabled={isLoggingIn}>
|
|
85
|
+
{isLoggingIn ? (
|
|
86
|
+
<>
|
|
87
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
88
|
+
Signing in...
|
|
89
|
+
</>
|
|
90
|
+
) : (
|
|
91
|
+
'Sign in'
|
|
92
|
+
)}
|
|
93
|
+
</Button>
|
|
94
|
+
</form>
|
|
95
|
+
|
|
96
|
+
<p className="text-center text-sm text-muted-foreground">
|
|
97
|
+
Don't have an account?{' '}
|
|
98
|
+
<Link
|
|
99
|
+
href={ROUTES.REGISTER}
|
|
100
|
+
className="font-medium text-primary transition-colors duration-150 hover:text-primary/80"
|
|
101
|
+
>
|
|
102
|
+
Create account
|
|
103
|
+
</Link>
|
|
104
|
+
</p>
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type React from 'react';
|
|
4
|
+
|
|
5
|
+
import Link from 'next/link';
|
|
6
|
+
import { useForm } from 'react-hook-form';
|
|
7
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { Loader2 } from 'lucide-react';
|
|
10
|
+
|
|
11
|
+
import { Button } from '@/components/ui/button';
|
|
12
|
+
import { Input } from '@/components/ui/input';
|
|
13
|
+
import { Label } from '@/components/ui/label';
|
|
14
|
+
import { useAuth } from '../hooks/useAuth';
|
|
15
|
+
import { ROUTES } from '@/lib/constants/routes';
|
|
16
|
+
import { cn } from '@/lib/utils';
|
|
17
|
+
|
|
18
|
+
const registerSchema = z
|
|
19
|
+
.object({
|
|
20
|
+
firstName: z.string().min(1, 'First name is required').max(50),
|
|
21
|
+
lastName: z.string().min(1, 'Last name is required').max(50),
|
|
22
|
+
email: z.string().min(1, 'Email is required').email('Invalid email address'),
|
|
23
|
+
password: z
|
|
24
|
+
.string()
|
|
25
|
+
.min(8, 'Min 8 characters')
|
|
26
|
+
.regex(/[A-Z]/, 'Must contain uppercase')
|
|
27
|
+
.regex(/[a-z]/, 'Must contain lowercase')
|
|
28
|
+
.regex(/[0-9]/, 'Must contain number'),
|
|
29
|
+
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
|
30
|
+
})
|
|
31
|
+
.refine((data) => data.password === data.confirmPassword, {
|
|
32
|
+
message: 'Passwords do not match',
|
|
33
|
+
path: ['confirmPassword'],
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
type RegisterFormData = z.infer<typeof registerSchema>;
|
|
37
|
+
|
|
38
|
+
export const RegisterForm = (): React.ReactElement => {
|
|
39
|
+
const { register: registerUser, isRegistering } = useAuth();
|
|
40
|
+
|
|
41
|
+
const {
|
|
42
|
+
register,
|
|
43
|
+
handleSubmit,
|
|
44
|
+
formState: { errors },
|
|
45
|
+
} = useForm<RegisterFormData>({
|
|
46
|
+
resolver: zodResolver(registerSchema),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const onSubmit = (data: RegisterFormData): void => {
|
|
50
|
+
registerUser({
|
|
51
|
+
firstName: data.firstName,
|
|
52
|
+
lastName: data.lastName,
|
|
53
|
+
email: data.email,
|
|
54
|
+
password: data.password,
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div className="mx-auto w-full max-w-sm space-y-6">
|
|
60
|
+
<div className="space-y-2 text-center">
|
|
61
|
+
<h1 className="text-2xl font-bold tracking-tight">Create an account</h1>
|
|
62
|
+
<p className="text-sm text-muted-foreground">
|
|
63
|
+
Enter your details to get started
|
|
64
|
+
</p>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
|
68
|
+
<div className="grid grid-cols-2 gap-4">
|
|
69
|
+
<div className="space-y-2">
|
|
70
|
+
<Label htmlFor="firstName">First name</Label>
|
|
71
|
+
<Input
|
|
72
|
+
id="firstName"
|
|
73
|
+
placeholder="John"
|
|
74
|
+
autoComplete="given-name"
|
|
75
|
+
aria-invalid={!!errors.firstName}
|
|
76
|
+
aria-describedby={errors.firstName ? 'firstName-error' : undefined}
|
|
77
|
+
{...register('firstName')}
|
|
78
|
+
className={cn(errors.firstName && 'border-destructive')}
|
|
79
|
+
/>
|
|
80
|
+
{errors.firstName && (
|
|
81
|
+
<p id="firstName-error" className="text-sm text-destructive">{errors.firstName.message}</p>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<div className="space-y-2">
|
|
86
|
+
<Label htmlFor="lastName">Last name</Label>
|
|
87
|
+
<Input
|
|
88
|
+
id="lastName"
|
|
89
|
+
placeholder="Doe"
|
|
90
|
+
autoComplete="family-name"
|
|
91
|
+
aria-invalid={!!errors.lastName}
|
|
92
|
+
aria-describedby={errors.lastName ? 'lastName-error' : undefined}
|
|
93
|
+
{...register('lastName')}
|
|
94
|
+
className={cn(errors.lastName && 'border-destructive')}
|
|
95
|
+
/>
|
|
96
|
+
{errors.lastName && (
|
|
97
|
+
<p id="lastName-error" className="text-sm text-destructive">{errors.lastName.message}</p>
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<div className="space-y-2">
|
|
103
|
+
<Label htmlFor="email">Email</Label>
|
|
104
|
+
<Input
|
|
105
|
+
id="email"
|
|
106
|
+
type="email"
|
|
107
|
+
placeholder="you@example.com"
|
|
108
|
+
autoComplete="email"
|
|
109
|
+
aria-invalid={!!errors.email}
|
|
110
|
+
aria-describedby={errors.email ? 'reg-email-error' : undefined}
|
|
111
|
+
{...register('email')}
|
|
112
|
+
className={cn(errors.email && 'border-destructive')}
|
|
113
|
+
/>
|
|
114
|
+
{errors.email && (
|
|
115
|
+
<p id="reg-email-error" className="text-sm text-destructive">{errors.email.message}</p>
|
|
116
|
+
)}
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<div className="space-y-2">
|
|
120
|
+
<Label htmlFor="password">Password</Label>
|
|
121
|
+
<Input
|
|
122
|
+
id="password"
|
|
123
|
+
type="password"
|
|
124
|
+
placeholder="Min 8 characters"
|
|
125
|
+
autoComplete="new-password"
|
|
126
|
+
aria-invalid={!!errors.password}
|
|
127
|
+
aria-describedby={errors.password ? 'reg-password-error' : undefined}
|
|
128
|
+
{...register('password')}
|
|
129
|
+
className={cn(errors.password && 'border-destructive')}
|
|
130
|
+
/>
|
|
131
|
+
{errors.password && (
|
|
132
|
+
<p id="reg-password-error" className="text-sm text-destructive">{errors.password.message}</p>
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<div className="space-y-2">
|
|
137
|
+
<Label htmlFor="confirmPassword">Confirm password</Label>
|
|
138
|
+
<Input
|
|
139
|
+
id="confirmPassword"
|
|
140
|
+
type="password"
|
|
141
|
+
placeholder="Repeat your password"
|
|
142
|
+
autoComplete="new-password"
|
|
143
|
+
aria-invalid={!!errors.confirmPassword}
|
|
144
|
+
aria-describedby={errors.confirmPassword ? 'confirmPassword-error' : undefined}
|
|
145
|
+
{...register('confirmPassword')}
|
|
146
|
+
className={cn(errors.confirmPassword && 'border-destructive')}
|
|
147
|
+
/>
|
|
148
|
+
{errors.confirmPassword && (
|
|
149
|
+
<p id="confirmPassword-error" className="text-sm text-destructive">
|
|
150
|
+
{errors.confirmPassword.message}
|
|
151
|
+
</p>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
<Button type="submit" className="w-full" disabled={isRegistering}>
|
|
156
|
+
{isRegistering ? (
|
|
157
|
+
<>
|
|
158
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
159
|
+
Creating account...
|
|
160
|
+
</>
|
|
161
|
+
) : (
|
|
162
|
+
'Create account'
|
|
163
|
+
)}
|
|
164
|
+
</Button>
|
|
165
|
+
</form>
|
|
166
|
+
|
|
167
|
+
<p className="text-center text-sm text-muted-foreground">
|
|
168
|
+
Already have an account?{' '}
|
|
169
|
+
<Link
|
|
170
|
+
href={ROUTES.LOGIN}
|
|
171
|
+
className="font-medium text-primary transition-colors duration-150 hover:text-primary/80"
|
|
172
|
+
>
|
|
173
|
+
Sign in
|
|
174
|
+
</Link>
|
|
175
|
+
</p>
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
};
|