Initial commit

This commit is contained in:
Jose Selesan
2026-04-08 22:53:11 -03:00
commit 9ae270609d
179 changed files with 28096 additions and 0 deletions

3
apps/frontend/.env Normal file
View File

@@ -0,0 +1,3 @@
VITE_SUPABASE_URL=https://afzzqgvdkkpyxmifpaya.supabase.co
VITE_SUPABASE_ANON_KEY=sb_publishable_RgMLVBTCTClhK-1Ks_X02Q_Sq1Z1Pq-
VITE_API_BASE_URL=http://localhost:3000

View File

@@ -0,0 +1,3 @@
VITE_SUPABASE_URL=https://your-project-ref.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key
VITE_API_BASE_URL=http://localhost:3000

24
apps/frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

37
apps/frontend/Dockerfile Normal file
View File

@@ -0,0 +1,37 @@
# syntax=docker/dockerfile:1.7
FROM oven/bun:1.3 AS deps
WORKDIR /app
# Workspace manifests first for better layer caching.
COPY package.json bun.lock ./
COPY apps/frontend/package.json apps/frontend/package.json
COPY packages/api-contract/package.json packages/api-contract/package.json
RUN bun install --frozen-lockfile
FROM deps AS build
WORKDIR /app
COPY apps/frontend apps/frontend
COPY packages/api-contract packages/api-contract
# Build-time variables for Vite.
ARG VITE_API_BASE_URL=http://localhost:3000
ARG VITE_SUPABASE_URL=
ARG VITE_SUPABASE_ANON_KEY=
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
ENV VITE_SUPABASE_URL=${VITE_SUPABASE_URL}
ENV VITE_SUPABASE_ANON_KEY=${VITE_SUPABASE_ANON_KEY}
RUN bun --filter frontend build
FROM nginx:1.27-alpine AS runner
WORKDIR /usr/share/nginx/html
COPY apps/frontend/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/apps/frontend/dist ./
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

73
apps/frontend/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
apps/frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

16
apps/frontend/nginx.conf Normal file
View File

@@ -0,0 +1,16 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
}

View File

@@ -0,0 +1,51 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@fontsource-variable/geist": "^5.2.8",
"@hookform/resolvers": "^5.2.2",
"@repo/api-contract": "workspace:*",
"@supabase/supabase-js": "^2.101.1",
"@tanstack/react-query": "^5.96.1",
"@tanstack/react-query-devtools": "^5.96.1",
"@tanstack/react-router": "^1.168.10",
"@tanstack/router-devtools": "^1.166.11",
"axios": "^1.14.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"input-otp": "^1.4.2",
"lucide-react": "^1.7.0",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.72.0",
"shadcn": "^4.1.2",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/router-plugin": "^1.167.12",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"tailwindcss": "^4.2.2",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
"vite": "^8.0.1"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

184
apps/frontend/src/App.css Normal file
View File

@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,110 @@
import * as React from "react"
import { Avatar as AvatarPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}

View File

@@ -0,0 +1,67 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,269 @@
"use client"
import * as React from "react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { CheckIcon, ChevronRightIcon } from "lucide-react"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
align = "start",
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
align={align}
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 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 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn("z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,236 @@
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
horizontal:
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"last:mt-0 nth-last-2:-mt-1",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}

View File

@@ -0,0 +1,85 @@
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { cn } from "@/lib/utils"
import { MinusIcon } from "lucide-react"
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"cn-input-otp flex items-center has-disabled:opacity-50",
containerClassName
)}
spellCheck={false}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
)
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn(
"flex items-center rounded-lg has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number
}) {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"relative flex size-8 items-center justify-center border-y border-r border-input text-sm transition-all outline-none first:rounded-l-lg first:border-l last:rounded-r-lg aria-invalid:border-destructive data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:ring-3 data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:border-destructive data-[active=true]:aria-invalid:ring-destructive/20 dark:bg-input/30 dark:data-[active=true]:aria-invalid:ring-destructive/40",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
)
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-separator"
className="flex items-center [&_svg:not([class*='size-'])]:size-4"
role="separator"
{...props}
>
<MinusIcon
/>
</div>
)
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

View File

@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,190 @@
import * as React from "react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none 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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && ""
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground 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",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,12 @@
export function AboutPage() {
return (
<main className="mx-auto flex min-h-[60vh] w-full max-w-6xl items-center justify-center px-6">
<section className="w-full max-w-3xl rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
<h2 className="text-2xl font-semibold">About</h2>
<p className="mt-2 text-sm text-muted-foreground">
Ruta de ejemplo funcionando con TanStack Router.
</p>
</section>
</main>
)
}

View File

@@ -0,0 +1,547 @@
import { useEffect, useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { zodResolver } from '@hookform/resolvers/zod'
import {
createCourtSchema,
type Court,
type CreateCourtInput,
type DayOfWeek,
} from '@repo/api-contract'
import { Controller, useFieldArray, useForm } from 'react-hook-form'
import { Button } from '@/components/ui/button'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { ApiClientError, apiClient } from '@/lib/api-client'
import { setCurrentComplexSlug } from '@/lib/current-complex'
const DAY_OPTIONS: Array<{ value: DayOfWeek; label: string }> = [
{ value: 'MONDAY', label: 'Lunes' },
{ value: 'TUESDAY', label: 'Martes' },
{ value: 'WEDNESDAY', label: 'Miércoles' },
{ value: 'THURSDAY', label: 'Jueves' },
{ value: 'FRIDAY', label: 'Viernes' },
{ value: 'SATURDAY', label: 'Sábado' },
{ value: 'SUNDAY', label: 'Domingo' },
]
const DAY_ORDER: DayOfWeek[] = DAY_OPTIONS.map((option) => option.value)
const DAY_LABEL_BY_VALUE = new Map(
DAY_OPTIONS.map((option) => [option.value, option.label]),
)
function formatTimeCompact(value: string): string {
const [rawHours = '00', rawMinutes = '00'] = value.split(':')
const hours = Number(rawHours)
return `${hours}:${rawMinutes}`
}
function formatDayRange(dayIndexes: number[]): string {
if (dayIndexes.length === 0) return ''
const sorted = [...dayIndexes].sort((a, b) => a - b)
const chunks: Array<{ start: number; end: number }> = []
let start = sorted[0]
let previous = sorted[0]
for (let index = 1; index < sorted.length; index += 1) {
const current = sorted[index]
if (current === previous + 1) {
previous = current
continue
}
chunks.push({ start, end: previous })
start = current
previous = current
}
chunks.push({ start, end: previous })
return chunks
.map((chunk) => {
const startDay = DAY_ORDER[chunk.start]
const endDay = DAY_ORDER[chunk.end]
const startLabel = startDay ? DAY_LABEL_BY_VALUE.get(startDay) : undefined
const endLabel = endDay ? DAY_LABEL_BY_VALUE.get(endDay) : undefined
if (!startLabel || !endLabel) return ''
if (chunk.start === chunk.end) return startLabel
return `${startLabel} a ${endLabel}`
})
.filter(Boolean)
.join(', ')
}
function summarizeAvailability(
availability: Array<{
dayOfWeek: DayOfWeek
startTime: string
endTime: string
}>,
) {
const grouped = new Map<string, number[]>()
for (const slot of availability) {
const dayIndex = DAY_ORDER.indexOf(slot.dayOfWeek)
if (dayIndex < 0) continue
const key = `${slot.startTime}-${slot.endTime}`
const current = grouped.get(key) ?? []
current.push(dayIndex)
grouped.set(key, current)
}
return [...grouped.entries()]
.map(([timeRange, dayIndexes]) => {
const [startTime = '00:00', endTime = '00:00'] = timeRange.split('-')
const dayLabel = formatDayRange(dayIndexes)
return `${dayLabel} · ${formatTimeCompact(startTime)} a ${formatTimeCompact(endTime)}`
})
.filter(Boolean)
}
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('es-AR', {
style: 'currency',
currency: 'ARS',
maximumFractionDigits: 2,
}).format(amount)
}
function createDefaultValues(): CreateCourtInput {
return {
name: '',
sportId: '',
slotDurationMinutes: 60,
basePrice: 0,
availability: [
{
dayOfWeek: 'MONDAY',
startTime: '08:00',
endTime: '22:00',
},
],
}
}
type CourtFormSectionProps = {
complexId: string | null
editingCourt: Court | null
onCancelEdit: () => void
}
function CourtFormSection({
complexId,
editingCourt,
onCancelEdit,
}: CourtFormSectionProps) {
const queryClient = useQueryClient()
const [formError, setFormError] = useState<string | null>(null)
const sportsQuery = useQuery({
queryKey: ['sports'],
queryFn: () => apiClient.sports.list(),
})
const form = useForm<CreateCourtInput>({
resolver: zodResolver(createCourtSchema),
mode: 'onBlur',
defaultValues: createDefaultValues(),
})
const availabilityFieldArray = useFieldArray({
control: form.control,
name: 'availability',
})
const saveMutation = useMutation({
mutationFn: async (payload: CreateCourtInput) => {
if (editingCourt) {
return apiClient.courts.update(editingCourt.id, payload)
}
if (!complexId) {
throw new ApiClientError('No se pudo resolver el complejo para guardar la cancha.')
}
return apiClient.courts.create(complexId, payload)
},
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ['courts', complexId],
})
setFormError(null)
if (editingCourt) {
onCancelEdit()
}
form.reset(createDefaultValues())
},
onError: (error) => {
const message =
error instanceof ApiClientError
? error.message
: 'No se pudo guardar la cancha. Intenta nuevamente.'
setFormError(message)
},
})
const activeSports = useMemo(
() => (sportsQuery.data ?? []).filter((sport) => sport.isActive),
[sportsQuery.data],
)
const onSubmit = async (values: CreateCourtInput) => {
setFormError(null)
await saveMutation.mutateAsync(values)
}
useEffect(() => {
if (!editingCourt) {
form.reset(createDefaultValues())
return
}
form.reset({
name: editingCourt.name,
sportId: editingCourt.sportId,
slotDurationMinutes: editingCourt.slotDurationMinutes,
basePrice: editingCourt.basePrice,
availability: editingCourt.availability.map((slot) => ({
dayOfWeek: slot.dayOfWeek,
startTime: slot.startTime,
endTime: slot.endTime,
})),
})
}, [editingCourt, form])
const title = editingCourt ? 'Editar cancha' : 'Nueva cancha'
const buttonText = editingCourt ? 'Guardar cambios' : 'Agregar cancha'
return (
<section className="rounded-xl border bg-card p-4 text-card-foreground shadow-sm sm:p-5">
<h3 className="text-lg font-semibold">{title}</h3>
<p className="mt-1 text-sm text-muted-foreground">
Configura nombre, deporte, duración, precio base y horarios. El backend ya
contempla precios por franja horaria para la siguiente etapa.
</p>
<form
className="mt-4 space-y-4"
onSubmit={form.handleSubmit((values) => {
void onSubmit(values)
})}
>
<Field data-invalid={Boolean(form.formState.errors.name)}>
<FieldLabel htmlFor="court-name">Nombre o descripción</FieldLabel>
<Input
id="court-name"
placeholder="Ej: Futbol 5 - Cancha 1"
aria-invalid={Boolean(form.formState.errors.name)}
{...form.register('name')}
/>
<FieldError errors={[form.formState.errors.name]} />
</Field>
<div className="grid gap-4 md:grid-cols-3">
<Field data-invalid={Boolean(form.formState.errors.sportId)}>
<FieldLabel>Deporte</FieldLabel>
<Controller
control={form.control}
name="sportId"
render={({ field }) => (
<Select
value={field.value}
onValueChange={field.onChange}
disabled={sportsQuery.isLoading}
>
<SelectTrigger className="h-10 w-full">
<SelectValue placeholder="Selecciona un deporte" />
</SelectTrigger>
<SelectContent>
{activeSports.map((sport) => (
<SelectItem key={sport.id} value={sport.id}>
{sport.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FieldError errors={[form.formState.errors.sportId]} />
</Field>
<Field data-invalid={Boolean(form.formState.errors.slotDurationMinutes)}>
<FieldLabel htmlFor="slot-duration">Duración por turno (min)</FieldLabel>
<Input
id="slot-duration"
type="number"
min={15}
step={5}
aria-invalid={Boolean(form.formState.errors.slotDurationMinutes)}
{...form.register('slotDurationMinutes', { valueAsNumber: true })}
/>
<FieldError errors={[form.formState.errors.slotDurationMinutes]} />
</Field>
<Field data-invalid={Boolean(form.formState.errors.basePrice)}>
<FieldLabel htmlFor="base-price">Precio base</FieldLabel>
<Input
id="base-price"
type="number"
min={0}
step="0.01"
aria-invalid={Boolean(form.formState.errors.basePrice)}
{...form.register('basePrice', { valueAsNumber: true })}
/>
<FieldError errors={[form.formState.errors.basePrice]} />
</Field>
</div>
<div className="space-y-3 rounded-lg border p-3 sm:p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h4 className="text-sm font-semibold">Rangos horarios</h4>
<p className="text-xs text-muted-foreground">
Define uno o más rangos por día.
</p>
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
<Button
type="button"
variant="outline"
className="w-full sm:w-auto"
onClick={() => {
const availability = form.getValues('availability')
const baseStartTime = availability[0]?.startTime ?? '08:00'
const baseEndTime = availability[0]?.endTime ?? '22:00'
const existingDays = new Set(
availability.map((item) => item.dayOfWeek),
)
const missingDays = DAY_OPTIONS.filter(
(option) => !existingDays.has(option.value),
).map((option) => ({
dayOfWeek: option.value,
startTime: baseStartTime,
endTime: baseEndTime,
}))
if (missingDays.length > 0) {
availabilityFieldArray.append(missingDays)
}
}}
>
Agregar semana completa
</Button>
<Button
type="button"
variant="outline"
className="w-full sm:w-auto"
onClick={() => {
availabilityFieldArray.append({
dayOfWeek: 'MONDAY',
startTime: '08:00',
endTime: '22:00',
})
}}
>
Agregar rango
</Button>
</div>
</div>
<div className="space-y-2">
{availabilityFieldArray.fields.map((field, index) => (
<div
key={field.id}
className="grid gap-3 border-b pb-2 md:grid-cols-[1fr_1fr_1fr_auto]"
>
<Field
data-invalid={Boolean(form.formState.errors.availability?.[index]?.dayOfWeek)}
>
<FieldLabel>Día</FieldLabel>
<Controller
control={form.control}
name={`availability.${index}.dayOfWeek`}
render={({ field: dayField }) => (
<Select value={dayField.value} onValueChange={dayField.onChange}>
<SelectTrigger className="h-10 w-full">
<SelectValue placeholder="Día" />
</SelectTrigger>
<SelectContent>
{DAY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FieldError errors={[form.formState.errors.availability?.[index]?.dayOfWeek]} />
</Field>
<Field
data-invalid={Boolean(form.formState.errors.availability?.[index]?.startTime)}
>
<FieldLabel>Desde</FieldLabel>
<Input
type="time"
aria-invalid={Boolean(form.formState.errors.availability?.[index]?.startTime)}
{...form.register(`availability.${index}.startTime`)}
/>
<FieldError errors={[form.formState.errors.availability?.[index]?.startTime]} />
</Field>
<Field data-invalid={Boolean(form.formState.errors.availability?.[index]?.endTime)}>
<FieldLabel>Hasta</FieldLabel>
<Input
type="time"
aria-invalid={Boolean(form.formState.errors.availability?.[index]?.endTime)}
{...form.register(`availability.${index}.endTime`)}
/>
<FieldError errors={[form.formState.errors.availability?.[index]?.endTime]} />
</Field>
<div className="flex items-end">
<Button
type="button"
variant="ghost"
className="w-full md:w-auto"
disabled={availabilityFieldArray.fields.length === 1}
onClick={() => {
availabilityFieldArray.remove(index)
}}
>
Quitar
</Button>
</div>
</div>
))}
</div>
</div>
{formError && <p className="text-sm text-destructive">{formError}</p>}
<div className="flex flex-wrap items-center gap-2">
<Button
type="submit"
disabled={saveMutation.isPending || sportsQuery.isLoading || !complexId}
>
{saveMutation.isPending ? 'Guardando...' : buttonText}
</Button>
{editingCourt && (
<Button type="button" variant="outline" onClick={onCancelEdit}>
Cancelar edición
</Button>
)}
</div>
</form>
</section>
)
}
type ComplexCourtsPageProps = {
complexSlug: string
}
export function ComplexCourtsPage({ complexSlug }: ComplexCourtsPageProps) {
const [editingCourt, setEditingCourt] = useState<Court | null>(null)
useEffect(() => {
setCurrentComplexSlug(complexSlug)
}, [complexSlug])
const complexQuery = useQuery({
queryKey: ['complex-by-slug', complexSlug],
queryFn: () => apiClient.complexes.getBySlug(complexSlug),
})
const complexId = complexQuery.data?.id ?? null
const courtsQuery = useQuery({
queryKey: ['courts', complexId],
enabled: Boolean(complexId),
queryFn: () => apiClient.courts.listByComplex(complexId as string),
})
return (
<main className="mx-auto w-full max-w-6xl space-y-6 px-4 py-4 sm:px-6">
<section className="rounded-xl border bg-card p-4 text-card-foreground shadow-sm sm:p-5">
<h2 className="text-2xl font-semibold">
{complexQuery.data?.complexName ?? 'Configurar canchas'}
</h2>
<p className="mt-2 text-sm text-muted-foreground">
Alta y edición de canchas del complejo.
</p>
</section>
<CourtFormSection
complexId={complexId}
editingCourt={editingCourt}
onCancelEdit={() => {
setEditingCourt(null)
}}
/>
<section className="rounded-xl border bg-card p-4 text-card-foreground shadow-sm sm:p-5">
<h3 className="text-lg font-semibold">Canchas configuradas</h3>
{courtsQuery.isLoading && (
<p className="mt-3 text-sm text-muted-foreground">Cargando canchas...</p>
)}
{courtsQuery.isError && (
<p className="mt-3 text-sm text-destructive">
No se pudieron cargar las canchas del complejo.
</p>
)}
{!courtsQuery.isLoading && (courtsQuery.data?.length ?? 0) === 0 && (
<p className="mt-3 text-sm text-muted-foreground">
Aún no hay canchas. Crea la primera usando el formulario.
</p>
)}
<div className="mt-4 space-y-3">
{(courtsQuery.data ?? []).map((court) => (
<article key={court.id} className="rounded-lg border p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
<div>
<p className="font-medium">{court.name}</p>
<p className="text-sm text-muted-foreground">
{court.sport.name} · {court.slotDurationMinutes} min ·{' '}
{formatCurrency(court.basePrice)}
</p>
</div>
<Button
variant="outline"
className="w-full sm:w-auto"
onClick={() => {
setEditingCourt(court)
window.scrollTo({ top: 0, behavior: 'smooth' })
}}
>
Editar
</Button>
</div>
<div className="mt-3 flex flex-wrap gap-2 text-xs text-muted-foreground">
{summarizeAvailability(court.availability).map((summary) => (
<span key={`${court.id}-${summary}`} className="rounded-md border px-2 py-1">
{summary}
</span>
))}
</div>
</article>
))}
</div>
</section>
</main>
)
}

View File

@@ -0,0 +1,68 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { Button } from '@/components/ui/button'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
const emailSchema = z.object({
email: z.string().email('Ingresa un email válido.'),
})
type EmailForm = z.infer<typeof emailSchema>
export function HomePage() {
const {
register,
handleSubmit,
watch,
formState: { errors, isValid, isSubmitSuccessful },
} = useForm<EmailForm>({
resolver: zodResolver(emailSchema),
mode: 'onChange',
defaultValues: {
email: '',
},
})
const emailValue = watch('email')
const onSubmit = (_data: EmailForm) => {
// Demo form: no-op; success state is managed by react-hook-form.
}
return (
<main className="mx-auto flex min-h-[60vh] w-full max-w-6xl items-center justify-center px-6">
<section className="w-full max-w-3xl rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
<h1 className="mb-2 text-2xl font-semibold">Frontend listo</h1>
<p className="mb-4 text-sm text-muted-foreground">
React + Vite + TypeScript + shadcn + zod + react-hook-form.
</p>
<form onSubmit={handleSubmit(onSubmit)}>
<Field data-invalid={Boolean(errors.email)}>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
id="email"
type="email"
placeholder="tu@email.com"
aria-invalid={Boolean(errors.email)}
{...register('email')}
/>
<FieldError errors={[errors.email]} />
</Field>
<Button type="submit" className="mt-3 w-full" disabled={!isValid}>
Continuar
</Button>
</form>
{isSubmitSuccessful && !errors.email && emailValue && (
<p className="mt-3 text-sm text-emerald-600">
Email válido según esquema zod.
</p>
)}
</section>
</main>
)
}

View File

@@ -0,0 +1,215 @@
import { Link, Outlet, useNavigate } from '@tanstack/react-router'
import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { LogOut, Menu, UserRound } from 'lucide-react'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useAuth } from '@/lib/auth'
import {
getCurrentComplexSlug,
setCurrentComplexSlug as persistCurrentComplexSlug,
} from '@/lib/current-complex'
import { apiClient } from '@/lib/api-client'
import { ThemeSwitcher } from './theme-switcher'
export function RootLayout() {
const navigate = useNavigate()
const { isAuthenticated, displayName, initials, avatarUrl, signOut } = useAuth()
const [currentComplexSlug, setCurrentComplexSlug] = useState<string | null>(null)
const myComplexesQuery = useQuery({
queryKey: ['my-complexes'],
enabled: isAuthenticated,
queryFn: () => apiClient.complexes.listMine(),
})
useEffect(() => {
setCurrentComplexSlug(getCurrentComplexSlug())
}, [])
useEffect(() => {
if (currentComplexSlug) return
const fallbackSlug = myComplexesQuery.data?.[0]?.complexSlug
if (fallbackSlug) {
setCurrentComplexSlug(fallbackSlug)
persistCurrentComplexSlug(fallbackSlug)
}
}, [currentComplexSlug, myComplexesQuery.data])
const handleSignOut = async () => {
await signOut()
await navigate({ to: '/login' })
}
return (
<div className="flex min-h-screen flex-col">
<header className="mx-auto flex w-full max-w-6xl items-center justify-between px-6 py-4">
<div className="flex items-center gap-6">
<h1 className="text-sm font-semibold">TanStack Router</h1>
<nav className="hidden items-center gap-3 text-sm md:flex">
<Link
to="/"
className="text-muted-foreground"
activeProps={{ className: 'text-foreground font-medium' }}
>
Home
</Link>
<Link
to="/about"
className="text-muted-foreground"
activeProps={{ className: 'text-foreground font-medium' }}
>
About
</Link>
{isAuthenticated && currentComplexSlug && (
<Link
to="/complex/$slug/edit"
params={{ slug: currentComplexSlug }}
className="text-muted-foreground"
activeProps={{ className: 'text-foreground font-medium' }}
>
Configuración
</Link>
)}
</nav>
</div>
<div className="flex items-center gap-2">
<ThemeSwitcher />
{!isAuthenticated && (
<Button asChild size="sm" variant="outline" className="hidden md:inline-flex">
<Link to="/login">Login</Link>
</Button>
)}
{isAuthenticated && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="hidden items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-muted md:inline-flex"
>
<Avatar size="sm">
<AvatarImage src={avatarUrl ?? undefined} alt={displayName} />
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<span className="max-w-32 truncate text-sm text-foreground">
{displayName}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuLabel>Mi cuenta</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
void navigate({ to: '/profile' })
}}
>
<UserRound className="size-4" />
Profile
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => {
void handleSignOut()
}}
>
<LogOut className="size-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="icon-sm"
className="md:hidden"
aria-label="Abrir menú"
>
<Menu className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 md:hidden">
<DropdownMenuLabel>Navegación</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
void navigate({ to: '/' })
}}
>
Home
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
void navigate({ to: '/about' })
}}
>
About
</DropdownMenuItem>
{isAuthenticated && currentComplexSlug && (
<DropdownMenuItem
onSelect={() => {
void navigate({
to: '/complex/$slug/edit',
params: { slug: currentComplexSlug },
})
}}
>
Configuración
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{isAuthenticated ? (
<>
<DropdownMenuItem
onSelect={() => {
void navigate({ to: '/profile' })
}}
>
<UserRound className="size-4" />
Profile
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => {
void handleSignOut()
}}
>
<LogOut className="size-4" />
Sign out
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem
onSelect={() => {
void navigate({ to: '/login' })
}}
>
Login
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<div className="flex-1">
<Outlet />
</div>
</div>
)
}

View File

@@ -0,0 +1,51 @@
import { Laptop, Moon, Sun } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useTheme } from '@/lib/theme'
export function ThemeSwitcher() {
const { theme, resolvedTheme, setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline" className="px-2">
{resolvedTheme === 'dark' ? (
<Moon className="size-4" />
) : (
<Sun className="size-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuLabel>Apariencia</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={theme}
onValueChange={(value) => setTheme(value as 'light' | 'dark' | 'system')}
>
<DropdownMenuRadioItem value="light">
<Sun className="size-4" />
Light
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="dark">
<Moon className="size-4" />
Dark
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="system">
<Laptop className="size-4" />
System
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,100 @@
import { useState } from 'react'
import { Link, useNavigate } from '@tanstack/react-router'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { Button } from '@/components/ui/button'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { useAuth } from '@/lib/auth'
const loginSchema = z.object({
email: z.string().email('Ingresá un email válido.'),
password: z.string().min(6, 'La contraseña debe tener al menos 6 caracteres.'),
})
type LoginForm = z.infer<typeof loginSchema>
type LoginPageProps = {
redirectTo?: string
}
export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
const navigate = useNavigate()
const { signInWithPassword } = useAuth()
const [submitError, setSubmitError] = useState<string | null>(null)
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isValid },
} = useForm<LoginForm>({
resolver: zodResolver(loginSchema),
mode: 'onChange',
defaultValues: {
email: '',
password: '',
},
})
const onSubmit = async (values: LoginForm) => {
setSubmitError(null)
try {
await signInWithPassword(values)
await navigate({ to: redirectTo })
} catch {
setSubmitError('No pudimos iniciar sesión. Verificá tus credenciales.')
}
}
return (
<main className="mx-auto flex min-h-screen w-full max-w-6xl items-center px-6">
<section className="w-full max-w-3xl rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
<h1 className="mb-2 text-2xl font-semibold">Iniciar sesión</h1>
<p className="mb-4 text-sm text-muted-foreground">
Ingresá con tu cuenta para acceder a Home.
</p>
<form className="space-y-3" onSubmit={handleSubmit(onSubmit)}>
<Field data-invalid={Boolean(errors.email)}>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
id="email"
type="email"
placeholder="tu@email.com"
aria-invalid={Boolean(errors.email)}
{...register('email')}
/>
<FieldError errors={[errors.email]} />
</Field>
<Field data-invalid={Boolean(errors.password)}>
<FieldLabel htmlFor="password">Contraseña</FieldLabel>
<Input
id="password"
type="password"
placeholder="••••••••"
aria-invalid={Boolean(errors.password)}
{...register('password')}
/>
<FieldError errors={[errors.password]} />
</Field>
{submitError && <p className="text-sm text-destructive">{submitError}</p>}
<Button type="submit" className="w-full" disabled={!isValid || isSubmitting}>
{isSubmitting ? 'Ingresando...' : 'Ingresar'}
</Button>
</form>
<p className="mt-4 text-center text-sm text-muted-foreground">
¿Solo querés explorar?{' '}
<Link to="/about" className="text-primary hover:underline">
Ir a About
</Link>
</p>
</section>
</main>
)
}

View File

@@ -0,0 +1,40 @@
import { Link } from '@tanstack/react-router'
import { Compass, Home, Sparkles } from 'lucide-react'
import { Button } from '@/components/ui/button'
export function NotFoundPage() {
return (
<main className="relative isolate mx-auto flex min-h-screen w-full max-w-5xl items-center justify-center overflow-hidden px-6 py-10">
<div className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(circle_at_20%_20%,rgba(56,189,248,0.14),transparent_45%),radial-gradient(circle_at_80%_10%,rgba(244,114,182,0.12),transparent_40%),radial-gradient(circle_at_50%_90%,rgba(59,130,246,0.10),transparent_40%)]" />
<section className="w-full max-w-2xl rounded-2xl border bg-card/80 p-8 text-card-foreground shadow-xl backdrop-blur">
<div className="mb-6 inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs text-muted-foreground">
<Sparkles className="size-3.5" />
Error de navegación
</div>
<p className="text-sm font-medium tracking-wide text-muted-foreground">404</p>
<h1 className="mt-2 text-4xl font-semibold tracking-tight">Página no encontrada</h1>
<p className="mt-4 text-sm leading-relaxed text-muted-foreground">
La ruta que intentaste abrir no existe o fue movida. Volvé al inicio o andá al onboarding para continuar.
</p>
<div className="mt-8 flex flex-wrap gap-3">
<Button asChild>
<Link to="/" preload="intent">
<Home className="size-4" />
Ir al inicio
</Link>
</Button>
<Button asChild variant="outline">
<Link to="/onboard" preload="intent">
<Compass className="size-4" />
Ir a onboard
</Link>
</Button>
</div>
</section>
</main>
)
}

View File

@@ -0,0 +1,7 @@
export function OnboardingCheckEmailStep() {
return (
<p className="text-sm text-muted-foreground">
Revisa tu correo y abre el link de verificacion para continuar.
</p>
)
}

View File

@@ -0,0 +1,119 @@
import type { PlanSummary } from '@repo/api-contract'
import { Controller, type UseFormReturn } from 'react-hook-form'
import { Button } from '@/components/ui/button'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import type { CompleteValues } from '@/features/onboard/onboarding.types'
type OnboardingCompleteStepProps = {
form: UseFormReturn<CompleteValues>
plans: PlanSummary[]
verifiedEmail: string | null
errorMessage: string | null
infoMessage: string | null
planPlaceholder: string
onSubmit: (values: CompleteValues) => Promise<void>
}
export function OnboardingCompleteStep({
form,
plans,
verifiedEmail,
errorMessage,
infoMessage,
planPlaceholder,
onSubmit,
}: OnboardingCompleteStepProps) {
return (
<form className="space-y-3" onSubmit={form.handleSubmit(onSubmit)}>
{verifiedEmail && (
<Field>
<FieldLabel htmlFor="verified-email">Email verificado</FieldLabel>
<Input id="verified-email" type="email" value={verifiedEmail} disabled readOnly />
</Field>
)}
<Field data-invalid={Boolean(form.formState.errors.password)}>
<FieldLabel htmlFor="onboard-password">Contrasena</FieldLabel>
<Input
id="onboard-password"
type="password"
placeholder="********"
aria-invalid={Boolean(form.formState.errors.password)}
{...form.register('password')}
/>
<FieldError errors={[form.formState.errors.password]} />
</Field>
<Field data-invalid={Boolean(form.formState.errors.complexName)}>
<FieldLabel htmlFor="complex-name">Nombre del complejo</FieldLabel>
<Input
id="complex-name"
type="text"
placeholder="Complejo Las Palmeras"
aria-invalid={Boolean(form.formState.errors.complexName)}
{...form.register('complexName')}
/>
<FieldError errors={[form.formState.errors.complexName]} />
</Field>
<Field data-invalid={Boolean(form.formState.errors.physicalAddress)}>
<FieldLabel htmlFor="physical-address">Direccion fisica</FieldLabel>
<Input
id="physical-address"
type="text"
placeholder="Ej: Av. San Martin 1234, Salta"
aria-invalid={Boolean(form.formState.errors.physicalAddress)}
{...form.register('physicalAddress')}
/>
<FieldError errors={[form.formState.errors.physicalAddress]} />
</Field>
<Field data-invalid={Boolean(form.formState.errors.planCode)}>
<FieldLabel htmlFor="plan-code">Plan</FieldLabel>
<Controller
control={form.control}
name="planCode"
render={({ field }) => (
<select
id="plan-code"
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
aria-invalid={Boolean(form.formState.errors.planCode)}
value={field.value ?? ''}
onChange={(event) => {
field.onChange(event.target.value)
}}
disabled={plans.length === 0}
>
<option value="">
{plans.length > 0 ? planPlaceholder : 'No hay planes disponibles'}
</option>
{plans.map((plan) => (
<option key={plan.code} value={plan.code}>
{plan.name} - ${plan.price.toFixed(2)}
</option>
))}
</select>
)}
/>
{plans.length === 0 && (
<p className="text-xs text-muted-foreground">
Carga planes en la base para continuar con el onboarding.
</p>
)}
<FieldError errors={[form.formState.errors.planCode]} />
</Field>
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
{infoMessage && <p className="text-sm text-muted-foreground">{infoMessage}</p>}
<Button
type="submit"
className="w-full"
disabled={form.formState.isSubmitting || !form.formState.isValid}
>
{form.formState.isSubmitting ? 'Finalizando...' : 'Completar onboarding'}
</Button>
</form>
)
}

View File

@@ -0,0 +1,22 @@
import type { PropsWithChildren } from 'react'
type OnboardingLayoutProps = PropsWithChildren<{
title: string
description: string
}>
export function OnboardingLayout({
title,
description,
children,
}: OnboardingLayoutProps) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-6xl items-center justify-center px-6">
<section className="w-full max-w-3xl rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
<h1 className="mb-2 text-2xl font-semibold">{title}</h1>
<p className="mb-4 text-sm text-muted-foreground">{description}</p>
{children}
</section>
</main>
)
}

View File

@@ -0,0 +1,58 @@
import type { UseFormReturn } from 'react-hook-form'
import { Button } from '@/components/ui/button'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import type { StartValues } from '@/features/onboard/onboarding.types'
type OnboardingStartStepProps = {
form: UseFormReturn<StartValues>
errorMessage: string | null
infoMessage: string | null
onSubmit: (values: StartValues) => Promise<void>
}
export function OnboardingStartStep({
form,
errorMessage,
infoMessage,
onSubmit,
}: OnboardingStartStepProps) {
return (
<form className="space-y-3" onSubmit={form.handleSubmit(onSubmit)}>
<Field data-invalid={Boolean(form.formState.errors.fullName)}>
<FieldLabel htmlFor="onboard-fullname">Nombre</FieldLabel>
<Input
id="onboard-fullname"
type="text"
placeholder="Nombre Apellido"
aria-invalid={Boolean(form.formState.errors.fullName)}
{...form.register('fullName')}
/>
<FieldError errors={[form.formState.errors.fullName]} />
</Field>
<Field data-invalid={Boolean(form.formState.errors.email)}>
<FieldLabel htmlFor="onboard-email">Email</FieldLabel>
<Input
id="onboard-email"
type="email"
placeholder="tu@email.com"
aria-invalid={Boolean(form.formState.errors.email)}
{...form.register('email')}
/>
<FieldError errors={[form.formState.errors.email]} />
</Field>
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
{infoMessage && <p className="text-sm text-muted-foreground">{infoMessage}</p>}
<Button
type="submit"
className="w-full"
disabled={form.formState.isSubmitting || !form.formState.isValid}
>
{form.formState.isSubmitting ? 'Enviando...' : 'Enviar codigo OTP'}
</Button>
</form>
)
}

View File

@@ -0,0 +1,108 @@
import { RefreshCw } from 'lucide-react'
import type { UseFormReturn } from 'react-hook-form'
import { Button } from '@/components/ui/button'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from '@/components/ui/input-otp'
import type { VerifyOtpValues } from '@/features/onboard/onboarding.types'
type OnboardingVerifyOtpStepProps = {
form: UseFormReturn<VerifyOtpValues>
email: string | null
errorMessage: string | null
infoMessage: string | null
remainingAttempts: number
resendCooldownSeconds: number
isResending: boolean
onSubmit: (values: VerifyOtpValues) => Promise<void>
onResend: () => Promise<void>
}
export function OnboardingVerifyOtpStep({
form,
email,
errorMessage,
infoMessage,
remainingAttempts,
resendCooldownSeconds,
isResending,
onSubmit,
onResend,
}: OnboardingVerifyOtpStepProps) {
const resendDisabled = resendCooldownSeconds > 0 || isResending
return (
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
<p className="text-sm text-muted-foreground">
Ingresa el codigo de 6 digitos que enviamos a{' '}
<span className="font-medium text-foreground">{email ?? 'tu email'}</span>.
</p>
<Field data-invalid={Boolean(form.formState.errors.otp)}>
<div className="mb-2 flex items-center justify-between gap-3">
<FieldLabel htmlFor="onboard-otp">Codigo de verificacion</FieldLabel>
<Button
type="button"
variant="outline"
size="sm"
disabled={resendDisabled}
onClick={() => {
void onResend()
}}
>
<RefreshCw className="mr-1 h-3.5 w-3.5" />
{isResending
? 'Reenviando...'
: resendCooldownSeconds > 0
? `Reenviar en ${resendCooldownSeconds}s`
: 'Reenviar codigo'}
</Button>
</div>
<InputOTP
id="onboard-otp"
maxLength={6}
inputMode="numeric"
pattern="\d*"
value={form.watch('otp')}
onChange={(value) => {
form.setValue('otp', value, { shouldValidate: true })
}}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
<FieldError errors={[form.formState.errors.otp]} />
</Field>
<p className="text-xs text-muted-foreground">
Intentos restantes: <span className="font-medium">{remainingAttempts}</span>
</p>
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
{infoMessage && <p className="text-sm text-muted-foreground">{infoMessage}</p>}
<Button
type="submit"
className="w-full"
disabled={form.formState.isSubmitting || !form.formState.isValid}
>
{form.formState.isSubmitting ? 'Verificando...' : 'Verificar codigo'}
</Button>
</form>
)
}

View File

@@ -0,0 +1,7 @@
export function OnboardingVerifyingStep() {
return (
<p className="text-sm text-muted-foreground">
Verificando tu email, espera un momento...
</p>
)
}

View File

@@ -0,0 +1,241 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import {
onboardingCompleteSchema,
onboardingStartSchema,
onboardingVerifyOtpSchema,
type PlanSummary,
} from '@repo/api-contract'
import { OnboardingCompleteStep } from '@/features/onboard/components/onboarding-complete-step'
import { OnboardingLayout } from '@/features/onboard/components/onboarding-layout'
import { OnboardingStartStep } from '@/features/onboard/components/onboarding-start-step'
import { OnboardingVerifyOtpStep } from '@/features/onboard/components/onboarding-verify-otp-step'
import type {
CompleteValues,
StartValues,
VerifyOtpValues,
} from '@/features/onboard/onboarding.types'
import { apiClient } from '@/lib/api-client'
import { useAuth } from '@/lib/auth'
import { setCurrentComplexSlug } from '@/lib/current-complex'
const OTP_MAX_ATTEMPTS = 5
type OnboardStep = 'start' | 'verify-otp' | 'complete'
export function OnboardPage() {
const navigate = useNavigate()
const { signInWithPassword } = useAuth()
const [step, setStep] = useState<OnboardStep>('start')
const [requestId, setRequestId] = useState<string | null>(null)
const [onboardingEmail, setOnboardingEmail] = useState<string | null>(null)
const [verifiedEmail, setVerifiedEmail] = useState<string | null>(null)
const [plans, setPlans] = useState<PlanSummary[]>([])
const [remainingAttempts, setRemainingAttempts] = useState<number>(OTP_MAX_ATTEMPTS)
const [resendCooldownSeconds, setResendCooldownSeconds] = useState<number>(0)
const [isResending, setIsResending] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [infoMessage, setInfoMessage] = useState<string | null>(null)
const startForm = useForm<StartValues>({
resolver: zodResolver(onboardingStartSchema),
mode: 'onChange',
defaultValues: {
fullName: '',
email: '',
},
})
const completeForm = useForm<CompleteValues>({
resolver: zodResolver(
onboardingCompleteSchema.omit({ onboardingRequestId: true }),
),
mode: 'onChange',
defaultValues: {
password: '',
complexName: '',
physicalAddress: '',
planCode: '',
},
})
const verifyOtpForm = useForm<VerifyOtpValues>({
resolver: zodResolver(onboardingVerifyOtpSchema.omit({ requestId: true })),
mode: 'onChange',
defaultValues: {
otp: '',
},
})
useEffect(() => {
if (step !== 'complete') return
void (async () => {
try {
const result = await apiClient.plans.list()
setPlans(result)
} catch {
setPlans([])
}
})()
}, [step])
const planPlaceholder = useMemo(() => {
if (plans.length === 0) return 'Codigo del plan (por ejemplo: BASIC)'
return 'Selecciona un plan'
}, [plans.length])
useEffect(() => {
if (resendCooldownSeconds <= 0) return
const timeout = window.setTimeout(() => {
setResendCooldownSeconds((current) => Math.max(0, current - 1))
}, 1000)
return () => {
window.clearTimeout(timeout)
}
}, [resendCooldownSeconds])
const onSubmitStart = async (values: StartValues) => {
setErrorMessage(null)
setInfoMessage(null)
try {
const result = await apiClient.onboarding.start(values)
setRequestId(result.requestId)
setOnboardingEmail(result.email)
setRemainingAttempts(OTP_MAX_ATTEMPTS)
setResendCooldownSeconds(result.cooldownSeconds)
verifyOtpForm.reset({ otp: '' })
setStep('verify-otp')
setInfoMessage(result.message)
} catch {
setErrorMessage('No pudimos iniciar el onboarding. Intenta nuevamente.')
}
}
const onSubmitVerifyOtp = async (values: VerifyOtpValues) => {
if (!requestId) {
setErrorMessage('No encontramos una solicitud de onboarding activa.')
return
}
setErrorMessage(null)
try {
const result = await apiClient.onboarding.verifyOtp({
requestId,
otp: values.otp,
})
setRemainingAttempts(result.remainingAttempts)
setResendCooldownSeconds(result.cooldownSeconds)
setInfoMessage(result.message)
if (result.verified) {
setVerifiedEmail(result.email ?? onboardingEmail)
setStep('complete')
return
}
} catch {
setErrorMessage('No pudimos verificar el OTP. Intenta nuevamente.')
}
}
const onResendOtp = async () => {
if (!requestId) {
setErrorMessage('No encontramos una solicitud de onboarding activa.')
return
}
setIsResending(true)
setErrorMessage(null)
try {
const result = await apiClient.onboarding.resendOtp({ requestId })
setOnboardingEmail(result.email)
setRemainingAttempts(result.remainingAttempts)
setResendCooldownSeconds(result.cooldownSeconds)
verifyOtpForm.reset({ otp: '' })
setInfoMessage(result.message)
} catch {
setErrorMessage('No pudimos reenviar el OTP. Intenta nuevamente.')
} finally {
setIsResending(false)
}
}
const onSubmitComplete = async (values: CompleteValues) => {
if (!requestId) {
setErrorMessage('No encontramos una solicitud de onboarding activa.')
return
}
setErrorMessage(null)
try {
const result = await apiClient.onboarding.complete({
onboardingRequestId: requestId,
password: values.password,
complexName: values.complexName,
physicalAddress: values.physicalAddress,
planCode: values.planCode,
})
setCurrentComplexSlug(result.complexSlug)
const emailForSignIn = verifiedEmail ?? onboardingEmail
if (emailForSignIn) {
await signInWithPassword({ email: emailForSignIn, password: values.password })
}
await navigate({ to: '/complex/$slug/edit', params: { slug: result.complexSlug } })
} catch {
setErrorMessage('No pudimos completar el onboarding. Intenta nuevamente.')
}
}
return (
<OnboardingLayout
title="Onboarding"
description="Crea tu cuenta y configura tu complejo de canchas."
>
{step === 'start' && (
<OnboardingStartStep
form={startForm}
errorMessage={errorMessage}
infoMessage={infoMessage}
onSubmit={onSubmitStart}
/>
)}
{step === 'verify-otp' && (
<OnboardingVerifyOtpStep
form={verifyOtpForm}
email={onboardingEmail}
errorMessage={errorMessage}
infoMessage={infoMessage}
remainingAttempts={remainingAttempts}
resendCooldownSeconds={resendCooldownSeconds}
isResending={isResending}
onSubmit={onSubmitVerifyOtp}
onResend={onResendOtp}
/>
)}
{step === 'complete' && (
<OnboardingCompleteStep
form={completeForm}
plans={plans}
verifiedEmail={verifiedEmail}
errorMessage={errorMessage}
infoMessage={infoMessage}
planPlaceholder={planPlaceholder}
onSubmit={onSubmitComplete}
/>
)}
</OnboardingLayout>
)
}

View File

@@ -0,0 +1,16 @@
import {
onboardingCompleteSchema,
onboardingStartSchema,
onboardingVerifyOtpSchema,
} from '@repo/api-contract'
import { z } from 'zod'
export type StartValues = z.infer<typeof onboardingStartSchema>
export type VerifyOtpValues = Omit<
z.infer<typeof onboardingVerifyOtpSchema>,
'requestId'
>
export type CompleteValues = Omit<
z.infer<typeof onboardingCompleteSchema>,
'onboardingRequestId'
>

View File

@@ -0,0 +1,71 @@
import { Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import type { UserProfileResponse } from '@repo/api-contract'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { apiClient } from '@/lib/api-client'
import { useAuth } from '@/lib/auth'
export function ProfilePage() {
const { user, displayName, initials, avatarUrl, isAuthenticated } = useAuth()
const profileQuery = useQuery({
queryKey: ['user-profile'],
enabled: isAuthenticated,
queryFn: (): Promise<UserProfileResponse> => apiClient.user.getProfile(),
})
if (!isAuthenticated) {
return (
<main className="mx-auto flex min-h-[60vh] w-full max-w-6xl items-center justify-center px-6">
<section className="w-full max-w-3xl rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
<h1 className="text-2xl font-semibold">Perfil</h1>
<p className="mt-2 text-sm text-muted-foreground">
Necesitás iniciar sesión para ver tu perfil.
</p>
<Button asChild className="mt-4">
<Link to="/login">Ir a Login</Link>
</Button>
</section>
</main>
)
}
return (
<main className="mx-auto flex min-h-[60vh] w-full max-w-6xl items-center justify-center px-6">
<section className="w-full max-w-3xl rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
<div className="flex items-center gap-3">
<Avatar size="lg">
<AvatarImage
src={profileQuery.data?.avatarUrl ?? avatarUrl ?? undefined}
alt={displayName}
/>
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div>
<h1 className="text-2xl font-semibold">
{profileQuery.data?.fullName ?? displayName}
</h1>
<p className="text-sm text-muted-foreground">
{profileQuery.data?.email ?? user?.email}
</p>
{profileQuery.data?.role && (
<p className="text-sm text-muted-foreground">
Rol: {profileQuery.data.role}
</p>
)}
</div>
</div>
{profileQuery.isLoading && (
<p className="mt-4 text-sm text-muted-foreground">Cargando perfil...</p>
)}
{profileQuery.isError && (
<p className="mt-4 text-sm text-destructive">
No se pudo cargar el perfil desde el backend.
</p>
)}
</section>
</main>
)
}

View File

@@ -0,0 +1,111 @@
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { Button } from '@/components/ui/button'
import { ApiClientError, apiClient } from '@/lib/api-client'
type PublicBookingConfirmationPageProps = {
complexSlug: string
bookingCode: string
}
function extractMessage(error: unknown, fallback: string) {
if (error instanceof ApiClientError) {
return error.message || fallback
}
return fallback
}
function formatDateLabel(isoDate: string) {
const [year, month, day] = isoDate.split('-').map(Number)
const date = new Date(year, (month ?? 1) - 1, day ?? 1)
return new Intl.DateTimeFormat('es-AR', {
weekday: 'long',
day: '2-digit',
month: 'long',
year: 'numeric',
}).format(date)
}
export function PublicBookingConfirmationPage({
complexSlug,
bookingCode,
}: PublicBookingConfirmationPageProps) {
const navigate = useNavigate()
const confirmationQuery = useQuery({
queryKey: ['public-booking-confirmation', complexSlug, bookingCode],
queryFn: () => apiClient.publicBookings.getConfirmation(complexSlug, bookingCode),
})
return (
<main className="min-h-screen bg-linear-to-b from-background via-background to-primary/5">
<div className="mx-auto flex min-h-screen w-full max-w-3xl items-center px-4 py-8 sm:px-6">
<section className="w-full rounded-3xl border bg-card p-5 shadow-lg sm:p-8">
{confirmationQuery.isLoading && (
<p className="text-sm text-muted-foreground">Cargando confirmacion...</p>
)}
{confirmationQuery.isError && (
<p className="text-sm text-destructive">
{extractMessage(
confirmationQuery.error,
'No pudimos cargar la confirmacion de la reserva.',
)}
</p>
)}
{confirmationQuery.data && (
<div className="space-y-6">
<div>
<p className="text-xs font-medium tracking-[0.2em] text-muted-foreground uppercase">
Reserva confirmada
</p>
<h1 className="mt-2 text-2xl font-semibold sm:text-3xl">
{confirmationQuery.data.complexName}
</h1>
</div>
<div className="rounded-2xl border border-primary/30 bg-primary/5 p-4 text-center sm:p-5">
<p className="text-xs text-muted-foreground">Codigo de reserva</p>
<p className="mt-1 text-3xl font-bold tracking-[0.25em] text-primary sm:text-4xl">
{confirmationQuery.data.bookingCode}
</p>
</div>
<div className="rounded-2xl border bg-background p-4 sm:p-5">
<p className="text-sm text-muted-foreground">Fecha</p>
<p className="mt-1 text-base font-medium capitalize sm:text-lg">
{formatDateLabel(confirmationQuery.data.date)}
</p>
<p className="mt-4 text-sm text-muted-foreground">Horario</p>
<p className="mt-1 text-base font-medium sm:text-lg">
{confirmationQuery.data.startTime} - {confirmationQuery.data.endTime}
</p>
<p className="mt-4 text-sm text-muted-foreground">Cancha</p>
<p className="mt-1 text-base font-medium sm:text-lg">
{confirmationQuery.data.courtName} · {confirmationQuery.data.sport.name}
</p>
</div>
<Button
type="button"
className="h-11 w-full text-sm sm:text-base"
onClick={() => {
void navigate({
to: '/$complexSlug/booking',
params: { complexSlug },
})
}}
>
Hacer otra reserva
</Button>
</div>
)}
</section>
</div>
</main>
)
}

View File

@@ -0,0 +1,564 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { useEffect, useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { Button } from '@/components/ui/button'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { ApiClientError, apiClient } from '@/lib/api-client'
const DAYS_STEP = 5
const bookingFormSchema = z.object({
customerName: z
.string()
.trim()
.min(2, 'Ingresa tu nombre.')
.max(120, 'El nombre no puede superar los 120 caracteres.'),
customerPhone: z
.string()
.trim()
.min(6, 'Ingresa un telefono valido.')
.max(30, 'El telefono no puede superar los 30 caracteres.'),
})
type BookingFormValues = z.infer<typeof bookingFormSchema>
type PublicBookingPageProps = {
complexSlug: string
}
type SelectedSlot = {
courtId: string
courtName: string
sportId: string
sportName: string
startTime: string
endTime: string
}
function addDays(baseDate: Date, amount: number) {
const next = new Date(baseDate)
next.setDate(next.getDate() + amount)
return next
}
function toIsoDateLocal(date: Date) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function toMinutes(value: string): number {
const [hours, minutes] = value.split(':').map((part) => Number(part))
return hours * 60 + minutes
}
function formatDayLabel(date: Date) {
const weekday = new Intl.DateTimeFormat('es-AR', { weekday: 'short' }).format(date)
const dayMonth = new Intl.DateTimeFormat('es-AR', {
day: '2-digit',
month: '2-digit',
}).format(date)
return {
weekday: weekday.replace('.', ''),
dayMonth,
}
}
function extractMessage(error: unknown, fallback: string) {
if (error instanceof ApiClientError) {
return error.message || fallback
}
return fallback
}
export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
const navigate = useNavigate()
const today = useMemo(() => new Date(), [])
const todayIsoDate = useMemo(() => toIsoDateLocal(today), [today])
const [windowStartOffset, setWindowStartOffset] = useState(0)
const [selectedSportId, setSelectedSportId] = useState<string | undefined>()
const [selectedSlot, setSelectedSlot] = useState<SelectedSlot | null>(null)
const [navigationError, setNavigationError] = useState<string | null>(null)
const [hasAutoAdjustedInitialDate, setHasAutoAdjustedInitialDate] = useState(false)
const [autoAdjustedDateNotice, setAutoAdjustedDateNotice] = useState<string | null>(null)
const dayOptions = useMemo(() => {
return Array.from({ length: DAYS_STEP }).map((_, index) => {
const current = addDays(today, windowStartOffset + index)
return {
value: toIsoDateLocal(current),
date: current,
...formatDayLabel(current),
}
})
}, [today, windowStartOffset])
const [selectedDate, setSelectedDate] = useState(dayOptions[0]?.value ?? '')
useEffect(() => {
if (dayOptions.some((option) => option.value === selectedDate)) {
return
}
setSelectedDate(dayOptions[0]?.value ?? '')
}, [dayOptions, selectedDate])
const availabilityQuery = useQuery({
queryKey: ['public-booking-availability', complexSlug, selectedDate, selectedSportId],
enabled: Boolean(selectedDate),
queryFn: () =>
apiClient.publicBookings.getAvailability(complexSlug, {
date: selectedDate,
...(selectedSportId ? { sportId: selectedSportId } : {}),
}),
})
useEffect(() => {
const availability = availabilityQuery.data
if (!availability) return
if (!availability.sportSelectionRequired) {
if (!selectedSportId && availability.sports[0]) {
setSelectedSportId(availability.sports[0].id)
}
return
}
if (
selectedSportId &&
!availability.sports.some((sport) => sport.id === selectedSportId)
) {
setSelectedSportId(undefined)
}
}, [availabilityQuery.data, selectedSportId])
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting, isValid },
} = useForm<BookingFormValues>({
resolver: zodResolver(bookingFormSchema),
mode: 'onChange',
defaultValues: {
customerName: '',
customerPhone: '',
},
})
const createBookingMutation = useMutation({
mutationFn: (payload: BookingFormValues) => {
if (!selectedSlot) {
throw new Error('Debes seleccionar un horario.')
}
return apiClient.publicBookings.create(complexSlug, {
date: selectedDate,
sportId: selectedSportId,
courtId: selectedSlot.courtId,
startTime: selectedSlot.startTime,
customerName: payload.customerName,
customerPhone: payload.customerPhone,
})
},
})
const onSubmit = async (values: BookingFormValues) => {
setNavigationError(null)
const booking = await createBookingMutation.mutateAsync(values)
if (!booking.bookingCode) {
setNavigationError('No se pudo obtener el codigo de confirmacion de la reserva.')
return
}
reset()
setSelectedSlot(null)
await availabilityQuery.refetch()
await navigate({
to: '/$complexSlug/booking/confirmed/$bookingCode',
params: {
complexSlug,
bookingCode: booking.bookingCode,
},
})
}
const visibleCourts = useMemo(() => {
const courts = availabilityQuery.data?.courts ?? []
if (selectedDate !== todayIsoDate) {
return courts
}
const now = new Date()
const nowMinutes = now.getHours() * 60 + now.getMinutes()
return courts
.map((court) => ({
...court,
availableSlots: court.availableSlots.filter(
(slot) => toMinutes(slot.startTime) >= nowMinutes,
),
}))
.filter((court) => court.availableSlots.length > 0)
}, [availabilityQuery.data?.courts, selectedDate, todayIsoDate])
useEffect(() => {
if (!selectedSlot) return
const stillAvailable = visibleCourts.some(
(court) =>
court.courtId === selectedSlot.courtId &&
court.availableSlots.some((slot) => slot.startTime === selectedSlot.startTime),
)
if (!stillAvailable) {
setSelectedSlot(null)
}
}, [selectedSlot, visibleCourts])
useEffect(() => {
if (hasAutoAdjustedInitialDate) return
if (selectedDate !== todayIsoDate) {
setHasAutoAdjustedInitialDate(true)
return
}
if (availabilityQuery.isLoading || availabilityQuery.isError || !availabilityQuery.data) {
return
}
if (availabilityQuery.data.sportSelectionRequired && !selectedSportId) {
return
}
if (visibleCourts.length > 0) {
setHasAutoAdjustedInitialDate(true)
return
}
let cancelled = false
const findNextAvailableDate = async () => {
for (let dayOffset = 1; dayOffset <= 30; dayOffset += 1) {
const candidateDate = toIsoDateLocal(addDays(today, dayOffset))
try {
const availability = await apiClient.publicBookings.getAvailability(complexSlug, {
date: candidateDate,
...(selectedSportId ? { sportId: selectedSportId } : {}),
})
if (availability.courts.length === 0) {
continue
}
if (cancelled) return
setWindowStartOffset(dayOffset)
setSelectedDate(candidateDate)
setSelectedSlot(null)
setAutoAdjustedDateNotice('No habia turnos disponibles para hoy. Te mostramos el proximo dia con turnos.')
setHasAutoAdjustedInitialDate(true)
return
} catch {
break
}
}
if (!cancelled) {
setHasAutoAdjustedInitialDate(true)
}
}
void findNextAvailableDate()
return () => {
cancelled = true
}
}, [
availabilityQuery.data,
availabilityQuery.isError,
availabilityQuery.isLoading,
complexSlug,
hasAutoAdjustedInitialDate,
selectedDate,
selectedSportId,
today,
todayIsoDate,
visibleCourts.length,
])
const canGoBack = windowStartOffset > 0
const goToPreviousFiveDays = () => {
if (!canGoBack) return
setWindowStartOffset((previous) => {
const next = Math.max(0, previous - DAYS_STEP)
const nextStartDate = toIsoDateLocal(addDays(today, next))
setSelectedDate(nextStartDate)
setSelectedSlot(null)
setAutoAdjustedDateNotice(null)
return next
})
}
const goToNextFiveDays = () => {
setWindowStartOffset((previous) => {
const next = previous + DAYS_STEP
const nextStartDate = toIsoDateLocal(addDays(today, next))
setSelectedDate(nextStartDate)
setSelectedSlot(null)
setAutoAdjustedDateNotice(null)
return next
})
}
return (
<main className="min-h-screen bg-linear-to-b from-background to-muted/40">
<div className="mx-auto flex w-full max-w-5xl flex-col gap-4 px-4 py-4 sm:gap-6 sm:px-6 sm:py-8">
<section className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
<h1 className="text-xl font-semibold sm:text-2xl">Reserva tu turno</h1>
<p className="mt-2 text-sm text-muted-foreground">
Elige un dia, revisa horarios disponibles y confirma con tu nombre y telefono.
</p>
</section>
<section className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
<div className="space-y-3">
<div>
<h2 className="text-sm font-medium">Dia</h2>
<p className="text-xs text-muted-foreground">Mostramos 5 dias por vez.</p>
</div>
{autoAdjustedDateNotice && (
<p className="rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900">
{autoAdjustedDateNotice}
</p>
)}
<div className="grid grid-cols-[2.25rem_repeat(5,minmax(0,1fr))_2.25rem] gap-2">
{canGoBack ? (
<Button
type="button"
variant="outline"
size="icon"
className="h-full"
onClick={goToPreviousFiveDays}
aria-label="Mostrar 5 dias anteriores"
>
{'<'}
</Button>
) : (
<div />
)}
{dayOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
setSelectedDate(option.value)
setSelectedSlot(null)
setAutoAdjustedDateNotice(null)
}}
className={`rounded-xl border px-1 py-2 text-center transition-colors sm:px-2 ${
selectedDate === option.value
? 'border-primary bg-primary text-primary-foreground'
: 'border-border bg-background hover:bg-muted'
}`}
>
<p className="text-[11px] font-medium uppercase sm:text-xs">{option.weekday}</p>
<p className="text-xs sm:text-sm">{option.dayMonth}</p>
</button>
))}
<Button
type="button"
variant="outline"
size="icon"
className="h-full"
onClick={goToNextFiveDays}
aria-label="Mostrar proximos 5 dias"
>
{'>'}
</Button>
</div>
</div>
</section>
<section className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h2 className="text-sm font-medium">Disponibilidad</h2>
<p className="text-xs text-muted-foreground">Selecciona cancha y horario.</p>
</div>
</div>
{availabilityQuery.data?.sportSelectionRequired && (
<div className="mb-4">
<Field>
<FieldLabel>Deporte</FieldLabel>
<Select
value={selectedSportId ?? ''}
onValueChange={(value) => {
setSelectedSportId(value)
setSelectedSlot(null)
setAutoAdjustedDateNotice(null)
}}
>
<SelectTrigger className="h-10 w-full">
<SelectValue placeholder="Selecciona un deporte" />
</SelectTrigger>
<SelectContent>
{availabilityQuery.data.sports.map((sport) => (
<SelectItem key={sport.id} value={sport.id}>
{sport.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
)}
{availabilityQuery.isLoading && (
<p className="text-sm text-muted-foreground">Buscando disponibilidad...</p>
)}
{availabilityQuery.isError && (
<p className="text-sm text-destructive">
{extractMessage(availabilityQuery.error, 'No pudimos cargar la disponibilidad.')}
</p>
)}
{!availabilityQuery.isLoading &&
!availabilityQuery.isError &&
availabilityQuery.data?.sportSelectionRequired &&
!selectedSportId && (
<p className="text-sm text-muted-foreground">
Selecciona un deporte para ver los horarios disponibles.
</p>
)}
{!availabilityQuery.isLoading &&
!availabilityQuery.isError &&
availabilityQuery.data &&
(!availabilityQuery.data.sportSelectionRequired || selectedSportId) &&
visibleCourts.length === 0 && (
<p className="text-sm text-muted-foreground">
No hay horarios disponibles para la fecha elegida.
</p>
)}
<div className="space-y-3">
{visibleCourts.map((court) => (
<article key={court.courtId} className="rounded-xl border bg-background p-3 sm:p-4">
<div className="mb-3 flex flex-col gap-1">
<p className="text-sm font-medium sm:text-base">{court.courtName}</p>
<p className="text-xs text-muted-foreground">
{court.sport.name} · Turnos de {court.slotDurationMinutes} min
</p>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{court.availableSlots.map((slot) => {
const isSelected =
selectedSlot?.courtId === court.courtId &&
selectedSlot.startTime === slot.startTime
return (
<button
key={`${court.courtId}-${slot.startTime}`}
type="button"
onClick={() => {
setSelectedSlot({
courtId: court.courtId,
courtName: court.courtName,
sportId: court.sport.id,
sportName: court.sport.name,
startTime: slot.startTime,
endTime: slot.endTime,
})
}}
className={`h-10 rounded-lg border text-sm transition-colors ${
isSelected
? 'border-primary bg-primary text-primary-foreground'
: 'border-border bg-card hover:bg-muted'
}`}
>
{slot.startTime}
</button>
)
})}
</div>
</article>
))}
</div>
</section>
{selectedSlot && (
<section className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
<h2 className="text-sm font-medium">Confirmar turno</h2>
<p className="mt-1 text-xs text-muted-foreground">
{selectedSlot.courtName} · {selectedSlot.sportName} · {selectedSlot.startTime} -{' '}
{selectedSlot.endTime}
</p>
<form className="mt-4 space-y-3" onSubmit={handleSubmit(onSubmit)}>
<Field data-invalid={Boolean(errors.customerName)}>
<FieldLabel htmlFor="customerName">Nombre</FieldLabel>
<Input
id="customerName"
placeholder="Ej: Juan Perez"
aria-invalid={Boolean(errors.customerName)}
{...register('customerName')}
/>
<FieldError errors={[errors.customerName]} />
</Field>
<Field data-invalid={Boolean(errors.customerPhone)}>
<FieldLabel htmlFor="customerPhone">Telefono</FieldLabel>
<Input
id="customerPhone"
type="tel"
placeholder="Ej: 3875551234"
aria-invalid={Boolean(errors.customerPhone)}
{...register('customerPhone')}
/>
<FieldError errors={[errors.customerPhone]} />
</Field>
{createBookingMutation.isError && (
<p className="text-sm text-destructive">
{extractMessage(createBookingMutation.error, 'No pudimos confirmar el turno.')}
</p>
)}
{navigationError && (
<p className="text-sm text-destructive">{navigationError}</p>
)}
<Button type="submit" className="w-full" disabled={!isValid || isSubmitting}>
{createBookingMutation.isPending ? 'Confirmando...' : 'Confirmar turno'}
</Button>
</form>
</section>
)}
</div>
</main>
)
}

View File

@@ -0,0 +1,51 @@
import { Link, type ErrorComponentProps } from '@tanstack/react-router'
import { AlertTriangle, Home, RefreshCw, Route } from 'lucide-react'
import { Button } from '@/components/ui/button'
export function ServerErrorPage({ error, reset }: ErrorComponentProps) {
return (
<main className="relative isolate mx-auto flex min-h-screen w-full max-w-5xl items-center justify-center overflow-hidden px-6 py-10">
<div className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(circle_at_15%_10%,rgba(248,113,113,0.14),transparent_42%),radial-gradient(circle_at_80%_20%,rgba(251,191,36,0.12),transparent_38%),radial-gradient(circle_at_60%_90%,rgba(59,130,246,0.10),transparent_42%)]" />
<section className="w-full max-w-2xl rounded-2xl border bg-card/80 p-8 text-card-foreground shadow-xl backdrop-blur">
<div className="mb-6 inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs text-muted-foreground">
<AlertTriangle className="size-3.5" />
Error interno
</div>
<p className="text-sm font-medium tracking-wide text-muted-foreground">500</p>
<h1 className="mt-2 text-4xl font-semibold tracking-tight">Algo salió mal</h1>
<p className="mt-4 text-sm leading-relaxed text-muted-foreground">
Ocurrió un error inesperado en la aplicación. Podés reintentar o volver a una ruta segura.
</p>
{error?.message && (
<pre className="mt-5 overflow-x-auto rounded-lg border bg-muted/50 p-3 text-xs text-muted-foreground">
{error.message}
</pre>
)}
<div className="mt-8 flex flex-wrap gap-3">
<Button type="button" onClick={() => reset()}>
<RefreshCw className="size-4" />
Reintentar
</Button>
<Button asChild variant="outline">
<Link to="/" preload="intent">
<Home className="size-4" />
Ir al inicio
</Link>
</Button>
<Button asChild variant="outline">
<Link to="/onboard" preload="intent">
<Route className="size-4" />
Ir a onboard
</Link>
</Button>
</div>
</section>
</main>
)
}

130
apps/frontend/src/index.css Normal file
View File

@@ -0,0 +1,130 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-heading: var(--font-sans);
--font-sans: 'Geist Variable', sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}

View File

@@ -0,0 +1,273 @@
import axios, { AxiosError } from 'axios'
import type {
Court,
CreateCourtInput,
CreatePublicBookingInput,
Complex,
CreateComplexPayload,
CreateComplexResponse,
CreateSportInput,
OnboardingCompleteInput,
OnboardingCompleteResponse,
OnboardingResendOtpInput,
OnboardingResendOtpResponse,
OnboardingStartInput,
OnboardingStartResponse,
OnboardingVerifyOtpInput,
OnboardingVerifyOtpResponse,
PlanSummary,
PublicAvailabilityResponse,
PublicBooking,
PublicBookingConfirmation,
Sport,
UpdateCourtInput,
UpdateComplexPayload,
UpdateComplexResponse,
UpdateSportInput,
UserProfileResponse,
} from '@repo/api-contract'
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000'
let accessToken: string | null = null
export class ApiClientError extends Error {
status?: number
details?: unknown
causeError?: unknown
constructor(
message: string,
options?: { status?: number; details?: unknown; causeError?: unknown },
) {
super(message)
this.name = 'ApiClientError'
this.status = options?.status
this.details = options?.details
this.causeError = options?.causeError
}
}
type ErrorHandlers = {
onForbidden?: (error: ApiClientError) => void | Promise<void>
onError?: (error: ApiClientError) => void | Promise<void>
}
const handlers: ErrorHandlers = {}
const http = axios.create({
baseURL: apiBaseUrl,
headers: {
'Content-Type': 'application/json',
},
})
function extractMessage(details: unknown, fallback: string) {
if (
typeof details === 'object' &&
details &&
'message' in details &&
typeof details.message === 'string'
) {
return details.message
}
return fallback
}
function normalizeError(error: unknown): ApiClientError {
if (error instanceof ApiClientError) return error
if (axios.isAxiosError(error)) {
const status = error.response?.status
const details = error.response?.data
return new ApiClientError(
extractMessage(details, error.message || 'Request failed'),
{
status,
details,
causeError: error,
},
)
}
if (error instanceof Error) {
return new ApiClientError(error.message, {
causeError: error,
})
}
return new ApiClientError('Error inesperado.', {
causeError: error,
})
}
http.interceptors.request.use((config) => {
if (!accessToken) return config
config.headers = config.headers ?? {}
config.headers.Authorization = `Bearer ${accessToken}`
return config
})
http.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const normalized = normalizeError(error)
if (normalized.status === 403 && handlers.onForbidden) {
await handlers.onForbidden(normalized)
}
if (handlers.onError) {
await handlers.onError(normalized)
}
return Promise.reject(normalized)
},
)
export const apiClient = {
onboarding: {
start: async (payload: OnboardingStartInput) => {
const response = await http.post<OnboardingStartResponse>(
'/api/onboarding/start',
payload,
)
return response.data
},
verifyOtp: async (payload: OnboardingVerifyOtpInput) => {
const response = await http.post<OnboardingVerifyOtpResponse>(
'/api/onboarding/verify-otp',
payload,
)
return response.data
},
resendOtp: async (payload: OnboardingResendOtpInput) => {
const response = await http.post<OnboardingResendOtpResponse>(
'/api/onboarding/resend-otp',
payload,
)
return response.data
},
complete: async (payload: OnboardingCompleteInput) => {
const response = await http.post<OnboardingCompleteResponse>(
'/api/onboarding/complete',
payload,
)
return response.data
},
},
plans: {
list: async () => {
const response = await http.get<PlanSummary[]>('/api/plans')
return response.data
},
},
user: {
getProfile: async () => {
const response = await http.get<UserProfileResponse>('/api/user/profile')
return response.data
},
},
complexes: {
create: async (payload: CreateComplexPayload) => {
const response = await http.post<CreateComplexResponse>(
'/api/complexes',
payload,
)
return response.data
},
getById: async (id: string) => {
const response = await http.get<Complex>(`/api/complexes/${id}`)
return response.data
},
getBySlug: async (slug: string) => {
const response = await http.get<Complex>(`/api/complexes/slug/${slug}`)
return response.data
},
update: async (id: string, payload: UpdateComplexPayload) => {
const response = await http.patch<UpdateComplexResponse>(
`/api/complexes/${id}`,
payload,
)
return response.data
},
listMine: async () => {
const response = await http.get<Complex[]>('/api/complexes/mine')
return response.data
},
},
sports: {
list: async () => {
const response = await http.get<Sport[]>('/api/sports')
return response.data
},
create: async (payload: CreateSportInput) => {
const response = await http.post<Sport>('/api/sports', payload)
return response.data
},
update: async (id: string, payload: UpdateSportInput) => {
const response = await http.patch<Sport>(`/api/sports/${id}`, payload)
return response.data
},
},
courts: {
listByComplex: async (complexId: string) => {
const response = await http.get<Court[]>(`/api/courts/complex/${complexId}`)
return response.data
},
create: async (complexId: string, payload: CreateCourtInput) => {
const response = await http.post<Court>(
`/api/courts/complex/${complexId}`,
payload,
)
return response.data
},
update: async (id: string, payload: UpdateCourtInput) => {
const response = await http.patch<Court>(`/api/courts/${id}`, payload)
return response.data
},
},
publicBookings: {
getAvailability: async (
complexSlug: string,
params: {
date: string
sportId?: string
},
) => {
const response = await http.get<PublicAvailabilityResponse>(
`/api/public-bookings/complex/${complexSlug}/availability`,
{
params,
},
)
return response.data
},
create: async (complexSlug: string, payload: CreatePublicBookingInput) => {
const response = await http.post<PublicBooking>(
`/api/public-bookings/complex/${complexSlug}`,
payload,
)
return response.data
},
getConfirmation: async (complexSlug: string, bookingCode: string) => {
const response = await http.get<PublicBookingConfirmation>(
`/api/public-bookings/complex/${complexSlug}/confirmation/${bookingCode}`,
)
return response.data
},
},
}
export type ApiClient = typeof apiClient
export function configureApiClient(nextHandlers: ErrorHandlers) {
handlers.onForbidden = nextHandlers.onForbidden
handlers.onError = nextHandlers.onError
}
export function setApiAccessToken(token: string | null) {
accessToken = token
}

View File

@@ -0,0 +1,166 @@
import {
createContext,
useContext,
useEffect,
useMemo,
useState,
type PropsWithChildren,
} from 'react'
import type { Session, User } from '@supabase/supabase-js'
import { setApiAccessToken } from '@/lib/api-client'
import { supabase } from '@/lib/supabase'
type SignInParams = {
email: string
password: string
}
type SignUpParams = {
email: string
password: string
fullName: string
}
export type AuthContextValue = {
user: User | null
session: Session | null
loading: boolean
isAuthenticated: boolean
displayName: string
initials: string
avatarUrl: string | null
signInWithPassword: (params: SignInParams) => Promise<void>
signUp: (params: SignUpParams) => Promise<{ requiresEmailConfirmation: boolean }>
signOut: () => Promise<void>
}
const AuthContext = createContext<AuthContextValue | null>(null)
function getDisplayName(user: User | null): string {
if (!user) return 'Usuario'
const fromMetadata =
user.user_metadata?.name ??
user.user_metadata?.full_name ??
user.user_metadata?.user_name
if (typeof fromMetadata === 'string' && fromMetadata.trim().length > 0) {
return fromMetadata.trim()
}
return user.email ?? 'Usuario'
}
function getInitials(name: string): string {
const words = name.trim().split(/\s+/).slice(0, 2)
const initials = words.map((word) => word[0]?.toUpperCase() ?? '').join('')
return initials || 'U'
}
function getAvatarUrl(user: User | null): string | null {
if (!user) return null
const value = user.user_metadata?.avatar_url ?? user.user_metadata?.picture
return typeof value === 'string' && value.length > 0 ? value : null
}
export function AuthProvider({ children }: PropsWithChildren) {
const [session, setSession] = useState<Session | null>(null)
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let mounted = true
const init = async () => {
const { data } = await supabase.auth.getSession()
if (!mounted) return
setSession(data.session)
setUser(data.session?.user ?? null)
setApiAccessToken(data.session?.access_token ?? null)
setLoading(false)
}
void init()
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, nextSession) => {
setSession(nextSession)
setUser(nextSession?.user ?? null)
setApiAccessToken(nextSession?.access_token ?? null)
setLoading(false)
})
return () => {
mounted = false
subscription.unsubscribe()
}
}, [])
const value = useMemo<AuthContextValue>(() => {
const displayName = getDisplayName(user)
const initials = getInitials(displayName)
const avatarUrl = getAvatarUrl(user)
return {
user,
session,
loading,
isAuthenticated: Boolean(user),
displayName,
initials,
avatarUrl,
signInWithPassword: async ({ email, password }) => {
const { error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) {
throw error
}
},
signUp: async ({ email, password, fullName }) => {
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
data: {
full_name: fullName,
name: fullName,
},
},
})
if (error) {
throw error
}
return {
requiresEmailConfirmation: !data.session,
}
},
signOut: async () => {
const { error } = await supabase.auth.signOut()
if (error) {
throw error
}
},
}
}, [loading, session, user])
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export function useAuth() {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within AuthProvider')
}
return context
}

View File

@@ -0,0 +1,11 @@
const CURRENT_COMPLEX_SLUG_KEY = 'current-complex-slug'
export function getCurrentComplexSlug(): string | null {
if (typeof window === 'undefined') return null
return window.localStorage.getItem(CURRENT_COMPLEX_SLUG_KEY)
}
export function setCurrentComplexSlug(complexSlug: string) {
if (typeof window === 'undefined') return
window.localStorage.setItem(CURRENT_COMPLEX_SLUG_KEY, complexSlug)
}

View File

@@ -0,0 +1,11 @@
import { QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
refetchOnWindowFocus: false,
retry: 1,
},
},
})

View File

@@ -0,0 +1,18 @@
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error(
'Missing Supabase environment variables. Define VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY.',
)
}
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: true,
},
})

View File

@@ -0,0 +1,89 @@
import {
createContext,
useContext,
useEffect,
useMemo,
useState,
type PropsWithChildren,
} from 'react'
type Theme = 'light' | 'dark' | 'system'
type ResolvedTheme = 'light' | 'dark'
type ThemeContextValue = {
theme: Theme
resolvedTheme: ResolvedTheme
setTheme: (theme: Theme) => void
}
const THEME_STORAGE_KEY = 'theme'
const ThemeContext = createContext<ThemeContextValue | null>(null)
function getSystemTheme(): ResolvedTheme {
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
}
function applyResolvedTheme(theme: ResolvedTheme) {
document.documentElement.classList.toggle('dark', theme === 'dark')
}
export function ThemeProvider({ children }: PropsWithChildren) {
const [theme, setThemeState] = useState<Theme>('system')
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>('light')
useEffect(() => {
const saved = localStorage.getItem(THEME_STORAGE_KEY)
const initialTheme: Theme =
saved === 'light' || saved === 'dark' || saved === 'system'
? saved
: 'system'
setThemeState(initialTheme)
}, [])
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const updateTheme = () => {
const nextResolvedTheme =
theme === 'system' ? getSystemTheme() : theme
setResolvedTheme(nextResolvedTheme)
applyResolvedTheme(nextResolvedTheme)
}
updateTheme()
mediaQuery.addEventListener('change', updateTheme)
return () => mediaQuery.removeEventListener('change', updateTheme)
}, [theme])
const setTheme = (nextTheme: Theme) => {
setThemeState(nextTheme)
localStorage.setItem(THEME_STORAGE_KEY, nextTheme)
}
const value = useMemo<ThemeContextValue>(
() => ({
theme,
resolvedTheme,
setTheme,
}),
[theme, resolvedTheme],
)
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}
export function useTheme() {
const context = useContext(ThemeContext)
if (!context) {
throw new Error('useTheme must be used within ThemeProvider')
}
return context
}
export type { Theme }

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -0,0 +1,64 @@
import { StrictMode, useEffect } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { RouterProvider } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/router-devtools'
import { AuthProvider, useAuth } from '@/lib/auth'
import { apiClient, configureApiClient } from '@/lib/api-client'
import { queryClient } from '@/lib/query-client'
import { ThemeProvider } from '@/lib/theme'
import { router } from './router'
import './index.css'
function AppRouter() {
const auth = useAuth()
useEffect(() => {
router.invalidate()
}, [auth.isAuthenticated, auth.loading, auth.user])
useEffect(() => {
configureApiClient({
onForbidden: async () => {
if (!auth.isAuthenticated) {
return
}
await auth.signOut()
await router.navigate({ to: '/login' })
},
onError: (error) => {
console.error('[API]', error)
},
})
}, [auth])
if (auth.loading) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-6xl items-center justify-center px-6">
<p className="text-sm text-muted-foreground">Validando sesión...</p>
</main>
)
}
return (
<>
<RouterProvider router={router} context={{ auth, api: apiClient }} />
{import.meta.env.DEV && <TanStackRouterDevtools router={router} />}
</>
)
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider>
<QueryClientProvider client={queryClient}>
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
<AuthProvider>
<AppRouter />
</AuthProvider>
</QueryClientProvider>
</ThemeProvider>
</StrictMode>,
)

View File

@@ -0,0 +1,294 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as OnboardRouteImport } from './routes/onboard'
import { Route as LoginRouteImport } from './routes/login'
import { Route as AppRouteRouteImport } from './routes/_app/route'
import { Route as AppProfileRouteImport } from './routes/_app/profile'
import { Route as AppAboutRouteImport } from './routes/_app/about'
import { Route as ComplexSlugBookingRouteImport } from './routes/$complexSlug/booking'
import { Route as AppAuthenticatedRouteRouteImport } from './routes/_app/_authenticated/route'
import { Route as AppAuthenticatedIndexRouteImport } from './routes/_app/_authenticated/index'
import { Route as ComplexSlugBookingIndexRouteImport } from './routes/$complexSlug/booking/index'
import { Route as ComplexSlugBookingConfirmedBookingCodeRouteImport } from './routes/$complexSlug/booking/confirmed/$bookingCode'
import { Route as AppAuthenticatedComplexSlugEditRouteImport } from './routes/_app/_authenticated/complex/$slug/edit'
const OnboardRoute = OnboardRouteImport.update({
id: '/onboard',
path: '/onboard',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const AppRouteRoute = AppRouteRouteImport.update({
id: '/_app',
getParentRoute: () => rootRouteImport,
} as any)
const AppProfileRoute = AppProfileRouteImport.update({
id: '/profile',
path: '/profile',
getParentRoute: () => AppRouteRoute,
} as any)
const AppAboutRoute = AppAboutRouteImport.update({
id: '/about',
path: '/about',
getParentRoute: () => AppRouteRoute,
} as any)
const ComplexSlugBookingRoute = ComplexSlugBookingRouteImport.update({
id: '/$complexSlug/booking',
path: '/$complexSlug/booking',
getParentRoute: () => rootRouteImport,
} as any)
const AppAuthenticatedRouteRoute = AppAuthenticatedRouteRouteImport.update({
id: '/_authenticated',
getParentRoute: () => AppRouteRoute,
} as any)
const AppAuthenticatedIndexRoute = AppAuthenticatedIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AppAuthenticatedRouteRoute,
} as any)
const ComplexSlugBookingIndexRoute = ComplexSlugBookingIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => ComplexSlugBookingRoute,
} as any)
const ComplexSlugBookingConfirmedBookingCodeRoute =
ComplexSlugBookingConfirmedBookingCodeRouteImport.update({
id: '/confirmed/$bookingCode',
path: '/confirmed/$bookingCode',
getParentRoute: () => ComplexSlugBookingRoute,
} as any)
const AppAuthenticatedComplexSlugEditRoute =
AppAuthenticatedComplexSlugEditRouteImport.update({
id: '/complex/$slug/edit',
path: '/complex/$slug/edit',
getParentRoute: () => AppAuthenticatedRouteRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof AppAuthenticatedIndexRoute
'/login': typeof LoginRoute
'/onboard': typeof OnboardRoute
'/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren
'/about': typeof AppAboutRoute
'/profile': typeof AppProfileRoute
'/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
'/complex/$slug/edit': typeof AppAuthenticatedComplexSlugEditRoute
}
export interface FileRoutesByTo {
'/': typeof AppAuthenticatedIndexRoute
'/login': typeof LoginRoute
'/onboard': typeof OnboardRoute
'/about': typeof AppAboutRoute
'/profile': typeof AppProfileRoute
'/$complexSlug/booking': typeof ComplexSlugBookingIndexRoute
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
'/complex/$slug/edit': typeof AppAuthenticatedComplexSlugEditRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/_app': typeof AppRouteRouteWithChildren
'/login': typeof LoginRoute
'/onboard': typeof OnboardRoute
'/_app/_authenticated': typeof AppAuthenticatedRouteRouteWithChildren
'/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren
'/_app/about': typeof AppAboutRoute
'/_app/profile': typeof AppProfileRoute
'/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute
'/_app/_authenticated/': typeof AppAuthenticatedIndexRoute
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
'/_app/_authenticated/complex/$slug/edit': typeof AppAuthenticatedComplexSlugEditRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/login'
| '/onboard'
| '/$complexSlug/booking'
| '/about'
| '/profile'
| '/$complexSlug/booking/'
| '/$complexSlug/booking/confirmed/$bookingCode'
| '/complex/$slug/edit'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/login'
| '/onboard'
| '/about'
| '/profile'
| '/$complexSlug/booking'
| '/$complexSlug/booking/confirmed/$bookingCode'
| '/complex/$slug/edit'
id:
| '__root__'
| '/_app'
| '/login'
| '/onboard'
| '/_app/_authenticated'
| '/$complexSlug/booking'
| '/_app/about'
| '/_app/profile'
| '/$complexSlug/booking/'
| '/_app/_authenticated/'
| '/$complexSlug/booking/confirmed/$bookingCode'
| '/_app/_authenticated/complex/$slug/edit'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
AppRouteRoute: typeof AppRouteRouteWithChildren
LoginRoute: typeof LoginRoute
OnboardRoute: typeof OnboardRoute
ComplexSlugBookingRoute: typeof ComplexSlugBookingRouteWithChildren
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/onboard': {
id: '/onboard'
path: '/onboard'
fullPath: '/onboard'
preLoaderRoute: typeof OnboardRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/_app': {
id: '/_app'
path: ''
fullPath: '/'
preLoaderRoute: typeof AppRouteRouteImport
parentRoute: typeof rootRouteImport
}
'/_app/profile': {
id: '/_app/profile'
path: '/profile'
fullPath: '/profile'
preLoaderRoute: typeof AppProfileRouteImport
parentRoute: typeof AppRouteRoute
}
'/_app/about': {
id: '/_app/about'
path: '/about'
fullPath: '/about'
preLoaderRoute: typeof AppAboutRouteImport
parentRoute: typeof AppRouteRoute
}
'/$complexSlug/booking': {
id: '/$complexSlug/booking'
path: '/$complexSlug/booking'
fullPath: '/$complexSlug/booking'
preLoaderRoute: typeof ComplexSlugBookingRouteImport
parentRoute: typeof rootRouteImport
}
'/_app/_authenticated': {
id: '/_app/_authenticated'
path: ''
fullPath: '/'
preLoaderRoute: typeof AppAuthenticatedRouteRouteImport
parentRoute: typeof AppRouteRoute
}
'/_app/_authenticated/': {
id: '/_app/_authenticated/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof AppAuthenticatedIndexRouteImport
parentRoute: typeof AppAuthenticatedRouteRoute
}
'/$complexSlug/booking/': {
id: '/$complexSlug/booking/'
path: '/'
fullPath: '/$complexSlug/booking/'
preLoaderRoute: typeof ComplexSlugBookingIndexRouteImport
parentRoute: typeof ComplexSlugBookingRoute
}
'/$complexSlug/booking/confirmed/$bookingCode': {
id: '/$complexSlug/booking/confirmed/$bookingCode'
path: '/confirmed/$bookingCode'
fullPath: '/$complexSlug/booking/confirmed/$bookingCode'
preLoaderRoute: typeof ComplexSlugBookingConfirmedBookingCodeRouteImport
parentRoute: typeof ComplexSlugBookingRoute
}
'/_app/_authenticated/complex/$slug/edit': {
id: '/_app/_authenticated/complex/$slug/edit'
path: '/complex/$slug/edit'
fullPath: '/complex/$slug/edit'
preLoaderRoute: typeof AppAuthenticatedComplexSlugEditRouteImport
parentRoute: typeof AppAuthenticatedRouteRoute
}
}
}
interface AppAuthenticatedRouteRouteChildren {
AppAuthenticatedIndexRoute: typeof AppAuthenticatedIndexRoute
AppAuthenticatedComplexSlugEditRoute: typeof AppAuthenticatedComplexSlugEditRoute
}
const AppAuthenticatedRouteRouteChildren: AppAuthenticatedRouteRouteChildren = {
AppAuthenticatedIndexRoute: AppAuthenticatedIndexRoute,
AppAuthenticatedComplexSlugEditRoute: AppAuthenticatedComplexSlugEditRoute,
}
const AppAuthenticatedRouteRouteWithChildren =
AppAuthenticatedRouteRoute._addFileChildren(
AppAuthenticatedRouteRouteChildren,
)
interface AppRouteRouteChildren {
AppAuthenticatedRouteRoute: typeof AppAuthenticatedRouteRouteWithChildren
AppAboutRoute: typeof AppAboutRoute
AppProfileRoute: typeof AppProfileRoute
}
const AppRouteRouteChildren: AppRouteRouteChildren = {
AppAuthenticatedRouteRoute: AppAuthenticatedRouteRouteWithChildren,
AppAboutRoute: AppAboutRoute,
AppProfileRoute: AppProfileRoute,
}
const AppRouteRouteWithChildren = AppRouteRoute._addFileChildren(
AppRouteRouteChildren,
)
interface ComplexSlugBookingRouteChildren {
ComplexSlugBookingIndexRoute: typeof ComplexSlugBookingIndexRoute
ComplexSlugBookingConfirmedBookingCodeRoute: typeof ComplexSlugBookingConfirmedBookingCodeRoute
}
const ComplexSlugBookingRouteChildren: ComplexSlugBookingRouteChildren = {
ComplexSlugBookingIndexRoute: ComplexSlugBookingIndexRoute,
ComplexSlugBookingConfirmedBookingCodeRoute:
ComplexSlugBookingConfirmedBookingCodeRoute,
}
const ComplexSlugBookingRouteWithChildren =
ComplexSlugBookingRoute._addFileChildren(ComplexSlugBookingRouteChildren)
const rootRouteChildren: RootRouteChildren = {
AppRouteRoute: AppRouteRouteWithChildren,
LoginRoute: LoginRoute,
OnboardRoute: OnboardRoute,
ComplexSlugBookingRoute: ComplexSlugBookingRouteWithChildren,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()

View File

@@ -0,0 +1,17 @@
import { createRouter } from '@tanstack/react-router'
import { apiClient } from '@/lib/api-client'
import { routeTree } from './routeTree.gen'
export const router = createRouter({
routeTree,
context: {
auth: undefined!,
api: apiClient,
},
})
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}

View File

@@ -0,0 +1,5 @@
import { Outlet, createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/$complexSlug/booking')({
component: Outlet,
})

View File

@@ -0,0 +1,17 @@
import { createFileRoute } from '@tanstack/react-router'
import { PublicBookingConfirmationPage } from '@/features/public-booking/public-booking-confirmation-page'
export const Route = createFileRoute('/$complexSlug/booking/confirmed/$bookingCode')({
component: PublicBookingConfirmationRouteComponent,
})
function PublicBookingConfirmationRouteComponent() {
const { complexSlug, bookingCode } = Route.useParams()
return (
<PublicBookingConfirmationPage
complexSlug={complexSlug}
bookingCode={bookingCode}
/>
)
}

View File

@@ -0,0 +1,12 @@
import { createFileRoute } from '@tanstack/react-router'
import { PublicBookingPage } from '@/features/public-booking/public-booking-page'
export const Route = createFileRoute('/$complexSlug/booking/')({
component: PublicBookingIndexRouteComponent,
})
function PublicBookingIndexRouteComponent() {
const { complexSlug } = Route.useParams()
return <PublicBookingPage complexSlug={complexSlug} />
}

View File

@@ -0,0 +1,16 @@
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'
import type { AuthContextValue } from '@/lib/auth'
import type { ApiClient } from '@/lib/api-client'
import { NotFoundPage } from '@/features/not-found/not-found-page'
import { ServerErrorPage } from '@/features/server-error/server-error-page'
type RouterContext = {
auth: AuthContextValue
api: ApiClient
}
export const Route = createRootRouteWithContext<RouterContext>()({
component: Outlet,
errorComponent: ServerErrorPage,
notFoundComponent: NotFoundPage,
})

View File

@@ -0,0 +1,11 @@
import { createFileRoute } from '@tanstack/react-router'
import { ComplexCourtsPage } from '@/features/complex/complex-courts-page'
export const Route = createFileRoute('/_app/_authenticated/complex/$slug/edit')({
component: RouteComponent,
})
function RouteComponent() {
const { slug } = Route.useParams()
return <ComplexCourtsPage complexSlug={slug} />
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { HomePage } from '@/features/home/home-page'
export const Route = createFileRoute('/_app/_authenticated/')({
component: HomePage,
})

View File

@@ -0,0 +1,12 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_app/_authenticated')({
beforeLoad: ({ context, location }) => {
if (!context.auth.isAuthenticated) {
throw redirect({
to: '/login',
search: { redirect: location.href },
})
}
},
})

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { AboutPage } from '@/features/about/about-page'
export const Route = createFileRoute('/_app/about')({
component: AboutPage,
})

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { ProfilePage } from '@/features/profile/profile-page'
export const Route = createFileRoute('/_app/profile')({
component: ProfilePage,
})

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { RootLayout } from '@/features/layout/root-layout'
export const Route = createFileRoute('/_app')({
component: RootLayout,
})

View File

@@ -0,0 +1,22 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import { z } from 'zod'
import { LoginPage } from '@/features/login/login-page'
const loginSearchSchema = z.object({
redirect: z.string().optional(),
})
export const Route = createFileRoute('/login')({
validateSearch: loginSearchSchema,
beforeLoad: ({ context }) => {
if (context.auth.isAuthenticated) {
throw redirect({ to: '/' })
}
},
component: LoginRouteComponent,
})
function LoginRouteComponent() {
const search = Route.useSearch()
return <LoginPage redirectTo={search.redirect} />
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { OnboardPage } from '@/features/onboard/onboard-page'
export const Route = createFileRoute('/onboard')({
component: OnboardPage,
})

View File

@@ -0,0 +1,33 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"ignoreDeprecations": "5.0",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,45 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
import tailwindcss from '@tailwindcss/vite'
import path from 'node:path'
// https://vite.dev/config/
export default defineConfig({
plugins: [TanStackRouterVite(), react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
build: {
rolldownOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules/react') || id.includes('node_modules/react-dom')) {
return 'react-vendor'
}
if (id.includes('node_modules/@tanstack/react-router')) {
return 'router-vendor'
}
if (id.includes('node_modules/@supabase/supabase-js')) {
return 'supabase-vendor'
}
if (
id.includes('node_modules/radix-ui') ||
id.includes('node_modules/lucide-react')
) {
return 'ui-vendor'
}
if (
id.includes('node_modules/react-hook-form') ||
id.includes('node_modules/@hookform/resolvers') ||
id.includes('node_modules/zod')
) {
return 'form-vendor'
}
},
},
},
},
})