diff --git a/.github/workflows/dev-build-deploy-api.yml b/.github/workflows/dev-build-deploy-api.yml index 95a663e1..1aae4df9 100644 --- a/.github/workflows/dev-build-deploy-api.yml +++ b/.github/workflows/dev-build-deploy-api.yml @@ -13,10 +13,6 @@ permissions: contents: read packages: write -concurrency: - group: dev-deploy - cancel-in-progress: true - jobs: build-and-push: name: Build and Push Docker Image to GHCR diff --git a/.github/workflows/dev-build-deploy-email-worker.yml b/.github/workflows/dev-build-deploy-email-worker.yml new file mode 100644 index 00000000..93868318 --- /dev/null +++ b/.github/workflows/dev-build-deploy-email-worker.yml @@ -0,0 +1,90 @@ +name: Deploy Email Worker to Development + +on: + push: + branches: + - dev + paths: + - 'apps/api/**' + - 'infra/docker-compose.dev.yml' + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + build-and-push: + name: Build and Push Docker Image to GHCR + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up QEMU for cross-platform builds + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push multi-arch image + run: | + docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --target prod \ + --push \ + -f ./apps/api/cmd/email_worker/Dockerfile \ + -t ghcr.io/${{ github.repository_owner }}/core-email-worker:dev \ + ./apps/api + + run-migrations: + name: Run Goose Migrations + runs-on: ubuntu-latest + needs: build-and-push + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Goose + run: | + curl -fsSL https://raw.githubusercontent.com/pressly/goose/master/install.sh | sh + + - name: Run migrations + run: | + goose -dir ./apps/api/internal/db/migrations postgres "${{ secrets.DEV_DB_URL }}" up + + deploy: + name: Deploy to Development Server + runs-on: ubuntu-latest + needs: [build-and-push, run-migrations] + + steps: + - name: SSH proxy commmand + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.SSH_HOST_SWAMPHACKS }} + username: ${{ secrets.SSH_USERNAME_SWAMPHACKS }} + key: ${{ secrets.SSH_KEY_SWAMPHACKS }} + port: ${{ secrets.SSH_PORT_SWAMPHACKS }} + proxy_host: ${{ secrets.SSH_HOST_JUMP }} + proxy_username: ${{ secrets.SSH_USERNAME_JUMP }} + proxy_key: ${{ secrets.SSH_KEY_JUMP }} + proxy_port: ${{ secrets.SSH_PORT_JUMP }} + script: | + cd /home/admin/core/infra + git fetch + git checkout dev + git reset --hard origin/dev + git pull + infisical export --env=dev --format=dotenv --path="/api" --projectId=${{ secrets.INFISICAL_PROJECT_ID }} > ./secrets/.env.dev.api + docker compose -f docker-compose.dev.yml pull dev-email-worker + docker compose -f docker-compose.dev.yml up -d --no-deps --force-recreate dev-email-worker diff --git a/.github/workflows/prod-build-deploy-api.yml b/.github/workflows/prod-build-deploy-api.yml index 8250e992..85b85946 100644 --- a/.github/workflows/prod-build-deploy-api.yml +++ b/.github/workflows/prod-build-deploy-api.yml @@ -13,10 +13,6 @@ permissions: contents: read packages: write -concurrency: - group: production-deploy - cancel-in-progress: true - jobs: build-and-push: name: Build and Push Docker Image to GHCR diff --git a/.github/workflows/prod-build-deploy-email-worker.yml b/.github/workflows/prod-build-deploy-email-worker.yml new file mode 100644 index 00000000..3093a016 --- /dev/null +++ b/.github/workflows/prod-build-deploy-email-worker.yml @@ -0,0 +1,90 @@ +name: Deploy Email Worker to Production + +on: + push: + branches: + - main + paths: + - 'apps/api/**' + - 'infra/docker-compose.yml' + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + build-and-push: + name: Build and Push Docker Image to GHCR + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up QEMU for cross-platform builds + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push multi-arch image + run: | + docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --target prod \ + --push \ + -f ./apps/api/cmd/email_worker/Dockerfile \ + -t ghcr.io/${{ github.repository_owner }}/core-email-worker:latest \ + ./apps/api + + run-migrations: + name: Run Goose Migrations + runs-on: ubuntu-latest + needs: build-and-push + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Goose + run: | + curl -fsSL https://raw.githubusercontent.com/pressly/goose/master/install.sh | sh + + - name: Run migrations + run: | + goose -dir ./apps/api/internal/db/migrations postgres "${{ secrets.DEV_DB_URL }}" up + + deploy: + name: Deploy to Production Server + runs-on: ubuntu-latest + needs: [build-and-push, run-migrations] + + steps: + - name: SSH proxy commmand + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.SSH_HOST_SWAMPHACKS }} + username: ${{ secrets.SSH_USERNAME_SWAMPHACKS }} + key: ${{ secrets.SSH_KEY_SWAMPHACKS }} + port: ${{ secrets.SSH_PORT_SWAMPHACKS }} + proxy_host: ${{ secrets.SSH_HOST_JUMP }} + proxy_username: ${{ secrets.SSH_USERNAME_JUMP }} + proxy_key: ${{ secrets.SSH_KEY_JUMP }} + proxy_port: ${{ secrets.SSH_PORT_JUMP }} + script: | + cd /home/admin/core/infra + git fetch + git checkout main + git reset --hard origin/main + git pull + infisical export --env=prod --format=dotenv --path="/api" --projectId=${{ secrets.INFISICAL_PROJECT_ID }} > ./secrets/.env.api + docker compose pull email-worker + docker compose up -d --no-deps --force-recreate email-worker diff --git a/apps/api/.env.dev.example b/apps/api/.env.dev.example index c5c34a22..71121dd2 100644 --- a/apps/api/.env.dev.example +++ b/apps/api/.env.dev.example @@ -1,5 +1,6 @@ DATABASE_URL="postgres://postgres:postgres@postgres:5432/coredb" DATABASE_URL_MIGRATION="postgres://postgres:postgres@localhost:5432/coredb" +REDIS_URL="redis:6379" # For OAuth AUTH_DISCORD_CLIENT_ID= diff --git a/apps/api/cmd/api/main.go b/apps/api/cmd/api/main.go index 0d122dfc..360db852 100644 --- a/apps/api/cmd/api/main.go +++ b/apps/api/cmd/api/main.go @@ -4,6 +4,7 @@ import ( "net/http" "time" + "github.com/hibiken/asynq" "github.com/rs/zerolog/log" "github.com/swamphacks/core/apps/api/internal/api" "github.com/swamphacks/core/apps/api/internal/api/handlers" @@ -38,6 +39,13 @@ func main() { }, } + // Create asynq client + redisOpt, err := asynq.ParseRedisURI(cfg.RedisURL) + if err != nil { + logger.Fatal().Msg("Failed to parse REDIS_URL") + } + taskQueueClient := asynq.NewClient(redisOpt) + // Create new middleware injectable mw := middleware.NewMiddleware(database, logger, cfg) @@ -50,9 +58,10 @@ func main() { // Injections into services authService := services.NewAuthService(userRepo, accountRepo, sessionRepo, txm, client, logger, &cfg.Auth) eventInterestService := services.NewEventInterestService(eventInterestRepo, logger) + emailService := services.NewEmailService(taskQueueClient, logger) // Injections into handlers - apiHandlers := handlers.NewHandlers(authService, eventInterestService, cfg, logger) + apiHandlers := handlers.NewHandlers(authService, eventInterestService, emailService, cfg, logger) api := api.NewAPI(&logger, apiHandlers, mw) diff --git a/apps/api/cmd/email_worker/Dockerfile b/apps/api/cmd/email_worker/Dockerfile new file mode 100644 index 00000000..4b9f68c6 --- /dev/null +++ b/apps/api/cmd/email_worker/Dockerfile @@ -0,0 +1,27 @@ +FROM golang:1.24-alpine AS base + +WORKDIR /app + +COPY go.mod go.sum ./ + +RUN go mod download + +COPY . . + +# Dev +FROM base AS dev + +RUN go install github.com/air-verse/air@latest + +CMD ["air"] + +# Production +FROM base as prod + +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o email_worker ./cmd/email_worker + +RUN apk --no-cache add ca-certificates + +CMD ["./email_worker"] + + diff --git a/apps/api/cmd/email_worker/main.go b/apps/api/cmd/email_worker/main.go new file mode 100644 index 00000000..3364213a --- /dev/null +++ b/apps/api/cmd/email_worker/main.go @@ -0,0 +1,51 @@ +package main + +import ( + "fmt" + "log" + "time" + + "github.com/hibiken/asynq" + "github.com/swamphacks/core/apps/api/internal/config" + "github.com/swamphacks/core/apps/api/internal/logger" + "github.com/swamphacks/core/apps/api/internal/services" + "github.com/swamphacks/core/apps/api/internal/tasks" + "github.com/swamphacks/core/apps/api/internal/workers" +) + +func main() { + logger := logger.New() + cfg := config.Load() + + redisOpt, err := asynq.ParseRedisURI(cfg.RedisURL) + if err != nil { + logger.Fatal().Msg("Failed to parse REDIS_URL") + } + + srv := asynq.NewServer( + redisOpt, + asynq.Config{ + Concurrency: 1, + Queues: map[string]int{ + "email": 1, + }, + TaskCheckInterval: 60 * time.Second, + DelayedTaskCheckInterval: 2 * time.Minute, + HealthCheckInterval: 2 * time.Minute, + JanitorInterval: time.Hour, + JanitorBatchSize: 100, + }, + ) + + emailService := services.NewEmailService(nil, logger) + emailWorker := workers.NewEmailWorker(emailService, logger) + + mux := asynq.NewServeMux() + + mux.HandleFunc(tasks.TypeSendEmail, emailWorker.HandleSendEmailTask) + fmt.Println("Starting email worker") + + if err := srv.Run(mux); err != nil { + log.Fatalf("Failed to run email worker") + } +} diff --git a/apps/api/go.mod b/apps/api/go.mod index 73abb4b0..49a249e8 100644 --- a/apps/api/go.mod +++ b/apps/api/go.mod @@ -7,21 +7,28 @@ require ( github.com/go-chi/chi/v5 v5.2.2 github.com/go-chi/cors v1.2.1 github.com/google/uuid v1.6.0 + github.com/hibiken/asynq v0.25.1 github.com/jackc/pgx/v5 v5.7.4 github.com/joho/godotenv v1.5.1 - github.com/lib/pq v1.10.9 github.com/rs/zerolog v1.34.0 ) require ( + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.19 // indirect + github.com/redis/go-redis/v9 v9.7.0 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/spf13/cast v1.7.0 // indirect github.com/stretchr/testify v1.10.0 // indirect golang.org/x/crypto v0.38.0 // indirect golang.org/x/sync v0.14.0 // indirect - golang.org/x/sys v0.33.0 // indirect + golang.org/x/sys v0.34.0 // indirect golang.org/x/text v0.25.0 // indirect + golang.org/x/time v0.8.0 // indirect + google.golang.org/protobuf v1.35.2 // indirect ) diff --git a/apps/api/go.sum b/apps/api/go.sum index 3e9e272b..feb8ee50 100644 --- a/apps/api/go.sum +++ b/apps/api/go.sum @@ -1,16 +1,30 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hibiken/asynq v0.25.1 h1:phj028N0nm15n8O2ims+IvJ2gz4k2auvermngh9JhTw= +github.com/hibiken/asynq v0.25.1/go.mod h1:pazWNOLBu0FEynQRBvHA26qdIKRSmfdIfUm4HdsLmXg= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -21,8 +35,10 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= -github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -31,14 +47,24 @@ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= +github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= +github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= @@ -46,10 +72,14 @@ golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= +golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= +google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/apps/api/internal/api/api.go b/apps/api/internal/api/api.go index b846840f..5407a2e8 100644 --- a/apps/api/internal/api/api.go +++ b/apps/api/internal/api/api.go @@ -71,6 +71,11 @@ func (api *API) setupRoutes(mw *mw.Middleware) { r.Post("/{eventId}/interest", api.Handlers.EventInterest.AddEmailToEvent) }) + // Email routes + api.Router.Route("/email", func(r chi.Router) { + r.Post("/queue", api.Handlers.Email.QueueEmail) + }) + // Protected test routes api.Router.Route("/protected", func(r chi.Router) { r.Use(mw.Auth.RequireAuth) diff --git a/apps/api/internal/api/handlers/email.go b/apps/api/internal/api/handlers/email.go new file mode 100644 index 00000000..ec24e488 --- /dev/null +++ b/apps/api/internal/api/handlers/email.go @@ -0,0 +1,58 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/rs/zerolog" + res "github.com/swamphacks/core/apps/api/internal/api/response" + "github.com/swamphacks/core/apps/api/internal/email" + "github.com/swamphacks/core/apps/api/internal/services" +) + +type EmailHandler struct { + emailService *services.EmailService + logger zerolog.Logger +} + +func NewEmailHandler(emailService *services.EmailService, logger zerolog.Logger) *EmailHandler { + return &EmailHandler{ + emailService: emailService, + logger: logger.With().Str("handler", "EmailHandler").Str("component", "email").Logger(), + } +} + +type QueueEmailRequest struct { + To string `json:"to"` + From string `json:"from"` + Body string `json:"body"` +} + +func (h *EmailHandler) QueueEmail(w http.ResponseWriter, r *http.Request) { + var req QueueEmailRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("invalid_request", "Could not parse request body")) + return + } + + if !email.IsValidEmail(req.To) || !email.IsValidEmail(req.From) { + res.SendError(w, http.StatusBadRequest, res.NewError("malformed_email", "To and/or From email is malformed or missing")) + return + } + + if req.Body == "" { + res.SendError(w, http.StatusBadRequest, res.NewError("missing_body", "Body is missing or is an empty string.")) + return + } + + taskInfo, err := h.emailService.QueueSendEmail(req.To, req.From, req.Body) + if err != nil { + h.logger.Err(err).Msg("Failed to queue email sending from EmailHandler") + res.SendError(w, http.StatusInternalServerError, res.NewError("internal_err", "The server went kaput while queueing email sending")) + return + } + + h.logger.Info().Str("TaskID", taskInfo.ID).Str("Task Queue", taskInfo.Queue).Str("Task Type", taskInfo.Type).Msg("Queued Send Email task!") + + w.WriteHeader(http.StatusCreated) +} diff --git a/apps/api/internal/api/handlers/handlers.go b/apps/api/internal/api/handlers/handlers.go index 52d0d1ef..7841c371 100644 --- a/apps/api/internal/api/handlers/handlers.go +++ b/apps/api/internal/api/handlers/handlers.go @@ -9,11 +9,13 @@ import ( type Handlers struct { Auth *AuthHandler EventInterest *EventInterestHandler + Email *EmailHandler } -func NewHandlers(authService *services.AuthService, eventInterestService *services.EventInterestService, cfg *config.Config, logger zerolog.Logger) *Handlers { +func NewHandlers(authService *services.AuthService, eventInterestService *services.EventInterestService, emailService *services.EmailService, cfg *config.Config, logger zerolog.Logger) *Handlers { return &Handlers{ Auth: NewAuthHandler(authService, cfg, logger), EventInterest: NewEventInterestHandler(eventInterestService, cfg, logger), + Email: NewEmailHandler(emailService, logger), } } diff --git a/apps/api/internal/config/config.go b/apps/api/internal/config/config.go index b9039189..09bbd1b1 100644 --- a/apps/api/internal/config/config.go +++ b/apps/api/internal/config/config.go @@ -26,6 +26,7 @@ type AuthConfig struct { type Config struct { DatabaseURL string `env:"DATABASE_URL"` + RedisURL string `env:"REDIS_URL"` Port string `env:"PORT" envDefault:"8080"` Auth AuthConfig `envPrefix:"AUTH_"` diff --git a/apps/api/internal/services/email.go b/apps/api/internal/services/email.go new file mode 100644 index 00000000..829ee5a7 --- /dev/null +++ b/apps/api/internal/services/email.go @@ -0,0 +1,45 @@ +package services + +import ( + "github.com/hibiken/asynq" + "github.com/rs/zerolog" + "github.com/swamphacks/core/apps/api/internal/tasks" +) + +type EmailService struct { + logger zerolog.Logger + taskQueue *asynq.Client + // Add SMTP client later +} + +func NewEmailService(taskQueue *asynq.Client, logger zerolog.Logger) *EmailService { + return &EmailService{ + logger: logger.With().Str("service", "EmailService").Str("component", "email").Logger(), + taskQueue: taskQueue, + } +} + +func (s *EmailService) SendEmail(to, from, body string) error { + s.logger.Info().Msgf("Sending from %s to %s with body %s", from, to, body) + return nil +} + +func (s *EmailService) QueueSendEmail(to, from, body string) (*asynq.TaskInfo, error) { + task, err := tasks.NewTaskSendEmail(tasks.SendEmailPayload{ + To: to, + From: from, + Body: body, + }) + if err != nil { + s.logger.Err(err).Msg("Failed to create Send Email task") + return nil, err + } + + info, err := s.taskQueue.Enqueue(task, asynq.Queue("email")) + if err != nil { + s.logger.Err(err).Msg("Failed to queue Send Email task") + return nil, err + } + + return info, nil +} diff --git a/apps/api/internal/tasks/email.go b/apps/api/internal/tasks/email.go new file mode 100644 index 00000000..1a629810 --- /dev/null +++ b/apps/api/internal/tasks/email.go @@ -0,0 +1,26 @@ +package tasks + +import ( + "encoding/json" + + "github.com/hibiken/asynq" +) + +const ( + TypeSendEmail = "email:send" +) + +type SendEmailPayload struct { + To string + From string + Body string +} + +func NewTaskSendEmail(payload SendEmailPayload) (*asynq.Task, error) { + data, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + return asynq.NewTask(TypeSendEmail, data), nil +} diff --git a/apps/api/internal/workers/email.go b/apps/api/internal/workers/email.go new file mode 100644 index 00000000..aa5324cf --- /dev/null +++ b/apps/api/internal/workers/email.go @@ -0,0 +1,37 @@ +package workers + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/hibiken/asynq" + "github.com/rs/zerolog" + "github.com/swamphacks/core/apps/api/internal/services" + "github.com/swamphacks/core/apps/api/internal/tasks" +) + +type EmailWorker struct { + emailService *services.EmailService + logger zerolog.Logger +} + +func NewEmailWorker(emailService *services.EmailService, logger zerolog.Logger) *EmailWorker { + return &EmailWorker{ + emailService: emailService, + logger: logger.With().Str("worker", "EmailWorker").Str("component", "email").Logger(), + } +} + +func (w *EmailWorker) HandleSendEmailTask(ctx context.Context, t *asynq.Task) error { + var p tasks.SendEmailPayload + if err := json.Unmarshal(t.Payload(), &p); err != nil { + w.logger.Err(err) + return fmt.Errorf("json.Unmarshal failed: %v: %w", err, asynq.SkipRetry) + } + + if err := w.emailService.SendEmail(p.To, p.From, p.Body); err != nil { + w.logger.Err(err).Msg("Failed to send email from worker") + } + return nil +} diff --git a/apps/web/package.json b/apps/web/package.json index a2ef1782..393bd70c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -26,7 +26,9 @@ ] }, "dependencies": { + "@internationalized/date": "^3.8.2", "@tailwindcss/vite": "^4.1.5", + "@tanstack/react-form": "^1.14.1", "@tanstack/react-query": "^5.75.5", "@tanstack/react-router": "^1.120.2", "axios": "^1.9.0", @@ -34,14 +36,16 @@ "js-cookie": "^3.0.5", "ky": "^1.8.1", "react": "^19.1.0", - "react-aria-components": "^1.8.0", + "react-aria": "^3.41.1", + "react-aria-components": "^1.10.1", "react-dom": "^19.1.0", + "react-select": "^5.10.2", "react-stately": "^3.39.0", "tailwind-merge": "^3.3.0", "tailwind-variants": "^1.0.0", "tailwindcss": "^4.1.5", "tailwindcss-react-aria-components": "^2.0.0", - "zod": "^3.25.56" + "zod": "^4.0.5" }, "devDependencies": { "@chromatic-com/storybook": "^3", diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index 2c17d3d5..a6316aed 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -8,9 +8,15 @@ importers: .: dependencies: + '@internationalized/date': + specifier: ^3.8.2 + version: 3.8.2 '@tailwindcss/vite': specifier: ^4.1.5 version: 4.1.11(vite@6.3.5(@types/node@22.15.34)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)) + '@tanstack/react-form': + specifier: ^1.14.1 + version: 1.14.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@tanstack/react-query': specifier: ^5.75.5 version: 5.81.5(react@19.1.0) @@ -32,12 +38,18 @@ importers: react: specifier: ^19.1.0 version: 19.1.0 + react-aria: + specifier: ^3.41.1 + version: 3.41.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react-aria-components: - specifier: ^1.8.0 + specifier: ^1.10.1 version: 1.10.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react-dom: specifier: ^19.1.0 version: 19.1.0(react@19.1.0) + react-select: + specifier: ^5.10.2 + version: 5.10.2(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react-stately: specifier: ^3.39.0 version: 3.39.0(react@19.1.0) @@ -54,8 +66,8 @@ importers: specifier: ^2.0.0 version: 2.0.0(tailwindcss@4.1.11) zod: - specifier: ^3.25.56 - version: 3.25.67 + specifier: ^4.0.5 + version: 4.0.5 devDependencies: '@chromatic-com/storybook': specifier: ^3 @@ -381,6 +393,47 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} + '@emotion/babel-plugin@11.13.5': + resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} + + '@emotion/cache@11.14.0': + resolution: {integrity: sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==} + + '@emotion/hash@0.9.2': + resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} + + '@emotion/memoize@0.9.0': + resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==} + + '@emotion/react@11.14.0': + resolution: {integrity: sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==} + peerDependencies: + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + '@emotion/serialize@1.3.3': + resolution: {integrity: sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==} + + '@emotion/sheet@1.4.0': + resolution: {integrity: sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==} + + '@emotion/unitless@0.10.0': + resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==} + + '@emotion/use-insertion-effect-with-fallbacks@1.2.0': + resolution: {integrity: sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==} + peerDependencies: + react: '>=16.8.0' + + '@emotion/utils@1.4.2': + resolution: {integrity: sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==} + + '@emotion/weak-memoize@0.4.0': + resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} + '@esbuild/aix-ppc64@0.25.5': resolution: {integrity: sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==} engines: {node: '>=18'} @@ -573,6 +626,15 @@ packages: resolution: {integrity: sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@floating-ui/core@1.7.2': + resolution: {integrity: sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==} + + '@floating-ui/dom@1.7.2': + resolution: {integrity: sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==} + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@formatjs/ecma402-abstract@2.3.4': resolution: {integrity: sha512-qrycXDeaORzIqNhBOx0btnhpD1c+/qFIHAN9znofuMJX6QBwtbrmlpWfD4oiUUD2vJUOIYFA/gYtg2KAMGG7sA==} @@ -656,19 +718,28 @@ packages: '@jridgewell/gen-mapping@0.3.11': resolution: {integrity: sha512-C512c1ytBTio4MrpWKlJpyFHT6+qfFL8SZ58zBzJ1OOzUEjHeF1BtjY2fH7n4x/g2OV/KiiMLAivOp1DXmiMMw==} + '@jridgewell/gen-mapping@0.3.12': + resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} - '@jridgewell/source-map@0.3.9': - resolution: {integrity: sha512-amBU75CKOOkcQLfyM6J+DnWwz41yTsWI7o8MQ003LwUIWb4NYX/evAblTx1oBBYJySqL/zHPxHXDw5ewpQaUFw==} + '@jridgewell/source-map@0.3.10': + resolution: {integrity: sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q==} '@jridgewell/sourcemap-codec@1.5.3': resolution: {integrity: sha512-AiR5uKpFxP3PjO4R19kQGIMwxyRyPuXmKEEy301V1C0+1rVjS94EZQXf1QKZYN8Q0YM+estSPhmx5JwNftv6nw==} + '@jridgewell/sourcemap-codec@1.5.4': + resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==} + '@jridgewell/trace-mapping@0.3.28': resolution: {integrity: sha512-KNNHHwW3EIp4EDYOvYFGyIFfx36R2dNJYH4knnZlF8T5jdbD5Wx8xmSaQ2gP9URkJ04LGEtlcCtwArKcmFcwKw==} + '@jridgewell/trace-mapping@0.3.29': + resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==} + '@mdx-js/react@3.1.0': resolution: {integrity: sha512-QjHtSaoameoalGnKDT3FoIl4+9RwyTmo9ZJGBdLOks/YOiWHoRDI3PUwEzOE7kEmGcV3AFcp9K6dYu9rEuKLAQ==} peerDependencies: @@ -1752,6 +1823,9 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@tanstack/form-core@1.14.0': + resolution: {integrity: sha512-uAOW3IxkT/Cmy8JlznK8S/LSpvtHjpUQi2wyuPqVfJ04y95WuV90SO+VKtb9TrNp51QLrrTFBR8tMEuzqp5wmA==} + '@tanstack/history@1.121.34': resolution: {integrity: sha512-YL8dGi5ZU+xvtav2boRlw4zrRghkY6hvdcmHhA0RGSJ/CBgzv+cbADW9eYJLx74XMZvIQ1pp6VMbrpXnnM5gHA==} engines: {node: '>=12'} @@ -1759,6 +1833,18 @@ packages: '@tanstack/query-core@5.81.5': resolution: {integrity: sha512-ZJOgCy/z2qpZXWaj/oxvodDx07XcQa9BF92c0oINjHkoqUPsmm3uG08HpTaviviZ/N9eP1f9CM7mKSEkIo7O1Q==} + '@tanstack/react-form@1.14.1': + resolution: {integrity: sha512-Ioja3zcLZj082OdCH6pFNv15fD4UTfnJgKIXxY7Iumio8EcYLXSuxzanqNWewFvftshUFHknSEa7QtyOAkFs0Q==} + peerDependencies: + '@tanstack/react-start': ^1.112.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + vinxi: ^0.5.0 + peerDependenciesMeta: + '@tanstack/react-start': + optional: true + vinxi: + optional: true + '@tanstack/react-query@5.81.5': resolution: {integrity: sha512-lOf2KqRRiYWpQT86eeeftAGnjuTR35myTP8MXyvHa81VlomoAWNEd8x5vkcAfQefu0qtYCvyqLropFZqgI2EQw==} peerDependencies: @@ -1777,6 +1863,12 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/react-store@0.7.3': + resolution: {integrity: sha512-3Dnqtbw9P2P0gw8uUM8WP2fFfg8XMDSZCTsywRPZe/XqqYW8PGkXKZTvP0AHkE4mpqP9Y43GpOg9vwO44azu6Q==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/router-core@1.123.2': resolution: {integrity: sha512-k1GTymZ2CBOX9SDiVHlqtgrNiid8fSKGjAofLToMAoLgqESg1knuIsYSeivmPkVbeSSdKkwF2uqsrJqNRIp/TQ==} engines: {node: '>=12'} @@ -1813,6 +1905,9 @@ packages: '@tanstack/store@0.7.1': resolution: {integrity: sha512-PjUQKXEXhLYj2X5/6c1Xn/0/qKY0IVFxTJweopRfF26xfjVyb14yALydJrHupDh3/d+1WKmfEgZPBVCmDkzzwg==} + '@tanstack/store@0.7.2': + resolution: {integrity: sha512-RP80Z30BYiPX2Pyo0Nyw4s1SJFH2jyM6f9i3HfX4pA+gm5jsnYryscdq2aIQLnL4TaGuQMO+zXmN9nh1Qck+Pg==} + '@tanstack/virtual-file-routes@1.121.21': resolution: {integrity: sha512-3nuYsTyaq6ZN7jRZ9z6Gj3GXZqBOqOT0yzd/WZ33ZFfv4yVNIvsa5Lw+M1j3sgyEAxKMqGu/FaNi7FCjr3yOdw==} engines: {node: '>=12'} @@ -1901,11 +1996,22 @@ packages: '@types/node@22.15.34': resolution: {integrity: sha512-8Y6E5WUupYy1Dd0II32BsWAx5MWdcnRd8L84Oys3veg1YrYtNtzgO4CFhiBg6MDSjk7Ay36HYOnU7/tuOzIzcw==} + '@types/node@22.16.5': + resolution: {integrity: sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==} + + '@types/parse-json@4.0.2': + resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + '@types/react-dom@19.1.6': resolution: {integrity: sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==} peerDependencies: '@types/react': ^19.0.0 + '@types/react-transition-group@4.4.12': + resolution: {integrity: sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==} + peerDependencies: + '@types/react': '*' + '@types/react@19.1.8': resolution: {integrity: sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==} @@ -2205,6 +2311,10 @@ packages: babel-dead-code-elimination@1.0.10: resolution: {integrity: sha512-DV5bdJZTzZ0zn0DC24v3jD7Mnidh6xhKa4GfKCbq3sfW8kaWhDdZjP3i81geA8T33tdYqWKw4D3fVv0CwEgKVA==} + babel-plugin-macros@3.1.0: + resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} + engines: {node: '>=10', npm: '>=6'} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -2359,12 +2469,19 @@ packages: confbox@0.2.2: resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} + convert-source-map@1.9.0: + resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} cookie-es@1.2.2: resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==} + cosmiconfig@7.1.0: + resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} + engines: {node: '>=10'} + cosmiconfig@8.3.6: resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} engines: {node: '>=14'} @@ -2404,6 +2521,12 @@ packages: decimal.js@10.5.0: resolution: {integrity: sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + decode-formdata@0.9.0: + resolution: {integrity: sha512-q5uwOjR3Um5YD+ZWPOF/1sGHVW9A5rCrRwITQChRXlmPkxDFBqCm4jNTIVdGHNH9OnR+V9MoZVgRhsFb+ARbUw==} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -2431,6 +2554,9 @@ packages: resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} engines: {node: '>=8'} + devalue@5.1.1: + resolution: {integrity: sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==} + diff@8.0.2: resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==} engines: {node: '>=0.3.1'} @@ -2445,6 +2571,9 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + dot-case@3.0.4: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} @@ -2658,6 +2787,9 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-root@1.1.0: + resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -2786,6 +2918,9 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + html-encoding-sniffer@4.0.0: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} @@ -3128,6 +3263,10 @@ packages: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + loupe@3.1.4: resolution: {integrity: sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg==} @@ -3165,6 +3304,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + memoize-one@6.0.0: + resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} + memoizerific@1.11.3: resolution: {integrity: sha512-/EuHYwAPdLtXwAwSZkh/Gutery6pD2KYd44oQLhAvQp/50mpyduZh8Q7PYHXTCJ+wuXxt7oij2LXyIJOOYFPog==} @@ -3264,6 +3406,10 @@ packages: nwsapi@2.2.20: resolution: {integrity: sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + onetime@6.0.0: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} @@ -3414,6 +3560,9 @@ packages: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -3462,6 +3611,9 @@ packages: peerDependencies: react: ^19.1.0 + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} @@ -3469,11 +3621,23 @@ packages: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} + react-select@5.10.2: + resolution: {integrity: sha512-Z33nHdEFWq9tfnfVXaiM12rbJmk+QjFEztWLtmXqQhz6Al4UZZ9xc0wiatmGtUOCCnHN0WizL3tCMYRENX4rVQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-stately@3.39.0: resolution: {integrity: sha512-/8JC3Tmj7G8fHn47F88c6t5kFNhQAufwqjEKxYeNi7TPz9UL+35BeoH1poMmDHJsPz8qM/z4sWMzaW5AwYK8lQ==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-transition-group@4.4.5: + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + react@19.1.0: resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} engines: {node: '>=0.10.0'} @@ -3605,6 +3769,10 @@ packages: source-map-support@0.5.21: resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + source-map@0.6.1: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} @@ -3675,6 +3843,9 @@ packages: strip-literal@3.0.0: resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} + stylis@4.2.0: + resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} + supports-color@10.0.0: resolution: {integrity: sha512-HRVVSbCCMbj7/kdWF9Q+bbckjBHLtHMEoJWlkmYzzdwhYMkjkOwubLM6t7NbWKjgKamGDrWL1++KrjUO1t9oAQ==} engines: {node: '>=18'} @@ -3914,6 +4085,15 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-isomorphic-layout-effect@1.2.1: + resolution: {integrity: sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + use-sync-external-store@1.5.0: resolution: {integrity: sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==} peerDependencies: @@ -4099,6 +4279,10 @@ packages: yaml-ast-parser@0.0.43: resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} + yaml@1.10.2: + resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + engines: {node: '>= 6'} + yaml@2.8.0: resolution: {integrity: sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==} engines: {node: '>= 14.6'} @@ -4112,8 +4296,11 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - zod@3.25.67: - resolution: {integrity: sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zod@4.0.5: + resolution: {integrity: sha512-/5UuuRPStvHXu7RS+gmvRf4NXrNxpSllGwDnCBcJZtQsKrviYXm54yDGV2KYNLT5kq0lHGcl7lqWJLgSaG+tgA==} snapshots: @@ -4370,6 +4557,70 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} + '@emotion/babel-plugin@11.13.5': + dependencies: + '@babel/helper-module-imports': 7.27.1 + '@babel/runtime': 7.27.6 + '@emotion/hash': 0.9.2 + '@emotion/memoize': 0.9.0 + '@emotion/serialize': 1.3.3 + babel-plugin-macros: 3.1.0 + convert-source-map: 1.9.0 + escape-string-regexp: 4.0.0 + find-root: 1.1.0 + source-map: 0.5.7 + stylis: 4.2.0 + transitivePeerDependencies: + - supports-color + + '@emotion/cache@11.14.0': + dependencies: + '@emotion/memoize': 0.9.0 + '@emotion/sheet': 1.4.0 + '@emotion/utils': 1.4.2 + '@emotion/weak-memoize': 0.4.0 + stylis: 4.2.0 + + '@emotion/hash@0.9.2': {} + + '@emotion/memoize@0.9.0': {} + + '@emotion/react@11.14.0(@types/react@19.1.8)(react@19.1.0)': + dependencies: + '@babel/runtime': 7.27.6 + '@emotion/babel-plugin': 11.13.5 + '@emotion/cache': 11.14.0 + '@emotion/serialize': 1.3.3 + '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.1.0) + '@emotion/utils': 1.4.2 + '@emotion/weak-memoize': 0.4.0 + hoist-non-react-statics: 3.3.2 + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.8 + transitivePeerDependencies: + - supports-color + + '@emotion/serialize@1.3.3': + dependencies: + '@emotion/hash': 0.9.2 + '@emotion/memoize': 0.9.0 + '@emotion/unitless': 0.10.0 + '@emotion/utils': 1.4.2 + csstype: 3.1.3 + + '@emotion/sheet@1.4.0': {} + + '@emotion/unitless@0.10.0': {} + + '@emotion/use-insertion-effect-with-fallbacks@1.2.0(react@19.1.0)': + dependencies: + react: 19.1.0 + + '@emotion/utils@1.4.2': {} + + '@emotion/weak-memoize@0.4.0': {} + '@esbuild/aix-ppc64@0.25.5': optional: true @@ -4493,11 +4744,22 @@ snapshots: '@eslint/core': 0.15.1 levn: 0.4.1 + '@floating-ui/core@1.7.2': + dependencies: + '@floating-ui/utils': 0.2.10 + + '@floating-ui/dom@1.7.2': + dependencies: + '@floating-ui/core': 1.7.2 + '@floating-ui/utils': 0.2.10 + + '@floating-ui/utils@0.2.10': {} + '@formatjs/ecma402-abstract@2.3.4': dependencies: '@formatjs/fast-memoize': 2.2.7 '@formatjs/intl-localematcher': 0.6.1 - decimal.js: 10.5.0 + decimal.js: 10.6.0 tslib: 2.8.1 '@formatjs/fast-memoize@2.2.7': @@ -4601,20 +4863,32 @@ snapshots: '@jridgewell/sourcemap-codec': 1.5.3 '@jridgewell/trace-mapping': 0.3.28 + '@jridgewell/gen-mapping@0.3.12': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.4 + '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/resolve-uri@3.1.2': {} - '@jridgewell/source-map@0.3.9': + '@jridgewell/source-map@0.3.10': dependencies: - '@jridgewell/gen-mapping': 0.3.11 - '@jridgewell/trace-mapping': 0.3.28 + '@jridgewell/gen-mapping': 0.3.12 + '@jridgewell/trace-mapping': 0.3.29 '@jridgewell/sourcemap-codec@1.5.3': {} + '@jridgewell/sourcemap-codec@1.5.4': {} + '@jridgewell/trace-mapping@0.3.28': dependencies: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.3 + '@jridgewell/trace-mapping@0.3.29': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.4 + '@mdx-js/react@3.1.0(@types/react@19.1.8)(react@19.1.0)': dependencies: '@types/mdx': 2.0.13 @@ -6160,10 +6434,24 @@ snapshots: tailwindcss: 4.1.11 vite: 6.3.5(@types/node@22.15.34)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0) + '@tanstack/form-core@1.14.0': + dependencies: + '@tanstack/store': 0.7.2 + '@tanstack/history@1.121.34': {} '@tanstack/query-core@5.81.5': {} + '@tanstack/react-form@1.14.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@tanstack/form-core': 1.14.0 + '@tanstack/react-store': 0.7.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + decode-formdata: 0.9.0 + devalue: 5.1.1 + react: 19.1.0 + transitivePeerDependencies: + - react-dom + '@tanstack/react-query@5.81.5(react@19.1.0)': dependencies: '@tanstack/query-core': 5.81.5 @@ -6188,6 +6476,13 @@ snapshots: react-dom: 19.1.0(react@19.1.0) use-sync-external-store: 1.5.0(react@19.1.0) + '@tanstack/react-store@0.7.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@tanstack/store': 0.7.2 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + use-sync-external-store: 1.5.0(react@19.1.0) + '@tanstack/router-core@1.123.2': dependencies: '@tanstack/history': 1.121.34 @@ -6206,7 +6501,7 @@ snapshots: recast: 0.23.11 source-map: 0.7.4 tsx: 4.20.3 - zod: 3.25.67 + zod: 3.25.76 transitivePeerDependencies: - supports-color @@ -6225,7 +6520,7 @@ snapshots: babel-dead-code-elimination: 1.0.10 chokidar: 3.6.0 unplugin: 2.3.5 - zod: 3.25.67 + zod: 3.25.76 optionalDependencies: '@tanstack/react-router': 1.123.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) vite: 6.3.5(@types/node@22.15.34)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0) @@ -6246,6 +6541,8 @@ snapshots: '@tanstack/store@0.7.1': {} + '@tanstack/store@0.7.2': {} + '@tanstack/virtual-file-routes@1.121.21': {} '@testing-library/dom@10.4.0': @@ -6350,10 +6647,20 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@22.16.5': + dependencies: + undici-types: 6.21.0 + + '@types/parse-json@4.0.2': {} + '@types/react-dom@19.1.6(@types/react@19.1.8)': dependencies: '@types/react': 19.1.8 + '@types/react-transition-group@4.4.12(@types/react@19.1.8)': + dependencies: + '@types/react': 19.1.8 + '@types/react@19.1.8': dependencies: csstype: 3.1.3 @@ -6759,6 +7066,12 @@ snapshots: transitivePeerDependencies: - supports-color + babel-plugin-macros@3.1.0: + dependencies: + '@babel/runtime': 7.27.6 + cosmiconfig: 7.1.0 + resolve: 1.22.10 + balanced-match@1.0.2: {} better-opn@3.0.2: @@ -6895,10 +7208,20 @@ snapshots: confbox@0.2.2: {} + convert-source-map@1.9.0: {} + convert-source-map@2.0.0: {} cookie-es@1.2.2: {} + cosmiconfig@7.1.0: + dependencies: + '@types/parse-json': 4.0.2 + import-fresh: 3.3.1 + parse-json: 5.2.0 + path-type: 4.0.0 + yaml: 1.10.2 + cosmiconfig@8.3.6(typescript@5.8.3): dependencies: import-fresh: 3.3.1 @@ -6936,6 +7259,10 @@ snapshots: decimal.js@10.5.0: {} + decimal.js@10.6.0: {} + + decode-formdata@0.9.0: {} + deep-eql@5.0.2: {} deep-is@0.1.4: {} @@ -6954,6 +7281,8 @@ snapshots: detect-libc@2.0.4: {} + devalue@5.1.1: {} + diff@8.0.2: {} doctrine@3.0.0: @@ -6964,6 +7293,11 @@ snapshots: dom-accessibility-api@0.6.3: {} + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.27.6 + csstype: 3.1.3 + dot-case@3.0.4: dependencies: no-case: 3.0.4 @@ -7212,6 +7546,8 @@ snapshots: dependencies: to-regex-range: 5.0.1 + find-root@1.1.0: {} + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -7328,6 +7664,10 @@ snapshots: dependencies: function-bind: 1.1.2 + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 @@ -7474,7 +7814,7 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 22.15.34 + '@types/node': 22.16.5 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -7653,6 +7993,10 @@ snapshots: strip-ansi: 7.1.0 wrap-ansi: 9.0.0 + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + loupe@3.1.4: {} lower-case@2.0.2: @@ -7689,6 +8033,8 @@ snapshots: math-intrinsics@1.1.0: {} + memoize-one@6.0.0: {} + memoizerific@1.11.3: dependencies: map-or-similar: 1.5.0 @@ -7768,6 +8114,8 @@ snapshots: nwsapi@2.2.20: {} + object-assign@4.1.1: {} + onetime@6.0.0: dependencies: mimic-fn: 4.0.0 @@ -7912,6 +8260,12 @@ snapshots: kleur: 3.0.3 sisteransi: 1.0.5 + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + proxy-from-env@1.1.0: {} punycode@2.3.1: {} @@ -8033,10 +8387,29 @@ snapshots: react: 19.1.0 scheduler: 0.26.0 + react-is@16.13.1: {} + react-is@17.0.2: {} react-refresh@0.17.0: {} + react-select@5.10.2(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + '@babel/runtime': 7.27.6 + '@emotion/cache': 11.14.0 + '@emotion/react': 11.14.0(@types/react@19.1.8)(react@19.1.0) + '@floating-ui/dom': 1.7.2 + '@types/react-transition-group': 4.4.12(@types/react@19.1.8) + memoize-one: 6.0.0 + prop-types: 15.8.1 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-transition-group: 4.4.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + use-isomorphic-layout-effect: 1.2.1(@types/react@19.1.8)(react@19.1.0) + transitivePeerDependencies: + - '@types/react' + - supports-color + react-stately@3.39.0(react@19.1.0): dependencies: '@react-stately/calendar': 3.8.2(react@19.1.0) @@ -8067,6 +8440,15 @@ snapshots: '@react-types/shared': 3.30.0(react@19.1.0) react: 19.1.0 + react-transition-group@4.4.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + '@babel/runtime': 7.27.6 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react@19.1.0: {} readdirp@3.6.0: @@ -8219,6 +8601,8 @@ snapshots: buffer-from: 1.1.2 source-map: 0.6.1 + source-map@0.5.7: {} + source-map@0.6.1: {} source-map@0.7.4: {} @@ -8283,6 +8667,8 @@ snapshots: dependencies: js-tokens: 9.0.1 + stylis@4.2.0: {} + supports-color@10.0.0: {} supports-color@7.2.0: @@ -8327,7 +8713,7 @@ snapshots: terser-webpack-plugin@5.3.14(esbuild@0.25.5)(webpack@5.99.9(esbuild@0.25.5)): dependencies: - '@jridgewell/trace-mapping': 0.3.28 + '@jridgewell/trace-mapping': 0.3.29 jest-worker: 27.5.1 schema-utils: 4.3.2 serialize-javascript: 6.0.2 @@ -8338,7 +8724,7 @@ snapshots: terser@5.43.1: dependencies: - '@jridgewell/source-map': 0.3.9 + '@jridgewell/source-map': 0.3.10 acorn: 8.15.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -8478,6 +8864,12 @@ snapshots: dependencies: punycode: 2.3.1 + use-isomorphic-layout-effect@1.2.1(@types/react@19.1.8)(react@19.1.0): + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.8 + use-sync-external-store@1.5.0(react@19.1.0): dependencies: react: 19.1.0 @@ -8681,10 +9073,14 @@ snapshots: yaml-ast-parser@0.0.43: {} + yaml@1.10.2: {} + yaml@2.8.0: {} yargs-parser@21.1.1: {} yocto-queue@0.1.0: {} - zod@3.25.67: {} + zod@3.25.76: {} + + zod@4.0.5: {} diff --git a/apps/web/src/components/Form/Form.stories.tsx b/apps/web/src/components/Form/Form.stories.tsx new file mode 100644 index 00000000..b6051877 --- /dev/null +++ b/apps/web/src/components/Form/Form.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta } from "@storybook/react"; +import { Form, useAppForm } from "."; + +const meta: Meta = { + title: "UI/Form", + component: Form, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], +}; + +export default meta; + +export const Example = () => { + const form = useAppForm({ + onSubmit: ({ value }) => { + console.log(value); + }, + }); + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + > + ( + + )} + /> + + ( + + )} + /> + +
+ + + +
+ + ); +}; diff --git a/apps/web/src/components/Form/Form.tsx b/apps/web/src/components/Form/Form.tsx new file mode 100644 index 00000000..17c2e3e5 --- /dev/null +++ b/apps/web/src/components/Form/Form.tsx @@ -0,0 +1,23 @@ +import { type FormProps, Form as RACForm } from "react-aria-components"; +import { cn } from "@/utils/cn"; +import { Suspense } from "react"; +import { Spinner } from "@/components/ui/Spinner"; + +export function Form(props: FormProps) { + return ( + // We use suspense here since form fields are lazy loaded + + +

Loading form...

+ + } + > + +
+ ); +} diff --git a/apps/web/src/components/Form/fields/CheckboxField.tsx b/apps/web/src/components/Form/fields/CheckboxField.tsx new file mode 100644 index 00000000..6c68cdc7 --- /dev/null +++ b/apps/web/src/components/Form/fields/CheckboxField.tsx @@ -0,0 +1,17 @@ +import { useFieldContext } from "@/components/Form/formContext"; +import { + CheckboxGroup, + type CheckboxGroupProps, +} from "@/components/ui/Checkbox"; + +export default function CheckboxField(props: CheckboxGroupProps) { + const field = useFieldContext(); + + return ( + field.handleChange(val)} + onBlur={field.handleBlur} + {...props} + /> + ); +} diff --git a/apps/web/src/components/Form/fields/ComboBoxField.tsx b/apps/web/src/components/Form/fields/ComboBoxField.tsx new file mode 100644 index 00000000..8af5f7df --- /dev/null +++ b/apps/web/src/components/Form/fields/ComboBoxField.tsx @@ -0,0 +1,16 @@ +import { useFieldContext } from "@/components/Form/formContext"; +import { ComboBox, type ComboBoxProps } from "@/components/ui/ComboBox"; + +export default function ComboBoxField( + props: ComboBoxProps, +) { + const field = useFieldContext(); + + return ( + field.handleChange(val)} + onBlur={field.handleBlur} + {...props} + /> + ); +} diff --git a/apps/web/src/components/Form/fields/DatePickerField.tsx b/apps/web/src/components/Form/fields/DatePickerField.tsx new file mode 100644 index 00000000..96684260 --- /dev/null +++ b/apps/web/src/components/Form/fields/DatePickerField.tsx @@ -0,0 +1,18 @@ +import { useFieldContext } from "@/components/Form/formContext"; +import { DatePicker, type DatePickerProps } from "@/components/ui/DatePicker"; +import { getLocalTimeZone } from "@internationalized/date"; +import type { DateValue } from "react-aria-components"; + +export default function DatePickerField( + props: DatePickerProps, +) { + const field = useFieldContext(); + + return ( + field.handleChange(val?.toDate(getLocalTimeZone()))} + onBlur={field.handleBlur} + {...props} + /> + ); +} diff --git a/apps/web/src/components/Form/fields/MultiSelectField.tsx b/apps/web/src/components/Form/fields/MultiSelectField.tsx new file mode 100644 index 00000000..cdc504c6 --- /dev/null +++ b/apps/web/src/components/Form/fields/MultiSelectField.tsx @@ -0,0 +1,17 @@ +import { useFieldContext } from "@/components/Form/formContext"; +import { + MultiSelect, + type MultiSelectProps, +} from "@/components/ui/MultiSelect"; + +export default function MultiSelectField(props: MultiSelectProps) { + const field = useFieldContext(); + + return ( + field.setValue(data.map((item) => item.value))} + errors={field.getMeta().errors.map((error) => error.message)} + {...props} + /> + ); +} diff --git a/apps/web/src/components/Form/fields/RadioField.tsx b/apps/web/src/components/Form/fields/RadioField.tsx new file mode 100644 index 00000000..c59d0da9 --- /dev/null +++ b/apps/web/src/components/Form/fields/RadioField.tsx @@ -0,0 +1,8 @@ +import { useFieldContext } from "@/components/Form/formContext"; +import { RadioGroup, type RadioGroupProps } from "@/components/ui/Radio"; + +export default function RadioField(props: RadioGroupProps) { + const field = useFieldContext(); + + return field.handleChange(val)} {...props} />; +} diff --git a/apps/web/src/components/Form/fields/SelectField.tsx b/apps/web/src/components/Form/fields/SelectField.tsx new file mode 100644 index 00000000..a19444b2 --- /dev/null +++ b/apps/web/src/components/Form/fields/SelectField.tsx @@ -0,0 +1,10 @@ +import { useFieldContext } from "@/components/Form/formContext"; +import { Select, type SelectProps } from "@/components/ui/Select"; + +export default function SelectField(props: SelectProps) { + const field = useFieldContext(); + + return ( + + + + {description && {description}} + {errorMessage} + + + {children} + + + + ); +} + +export function ComboBoxItem(props: ListBoxItemProps) { + return ; +} + +export function ComboBoxSection( + props: DropdownSectionProps, +) { + return ; +} diff --git a/apps/web/src/components/ui/ComboBox/index.ts b/apps/web/src/components/ui/ComboBox/index.ts new file mode 100644 index 00000000..ec43be29 --- /dev/null +++ b/apps/web/src/components/ui/ComboBox/index.ts @@ -0,0 +1 @@ +export * from "./ComboBox"; diff --git a/apps/web/src/components/ui/DateField/DateField.stories.tsx b/apps/web/src/components/ui/DateField/DateField.stories.tsx new file mode 100644 index 00000000..eae3703b --- /dev/null +++ b/apps/web/src/components/ui/DateField/DateField.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta } from "@storybook/react"; +import { Form } from "react-aria-components"; +import { Button } from "@/components/ui/Button"; +import { DateField } from "."; + +const meta: Meta = { + title: "UI/DateField", + component: DateField, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + args: { + label: "Event date", + }, +}; + +export default meta; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +export const Example = (args: any) => ; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +export const Validation = (args: any) => ( +
+ + + +); + +Validation.args = { + isRequired: true, +}; diff --git a/apps/web/src/components/ui/DateField/DateField.tsx b/apps/web/src/components/ui/DateField/DateField.tsx new file mode 100644 index 00000000..7b5675fc --- /dev/null +++ b/apps/web/src/components/ui/DateField/DateField.tsx @@ -0,0 +1,78 @@ +import { + DateField as AriaDateField, + type DateFieldProps as AriaDateFieldProps, + DateInput as AriaDateInput, + type DateInputProps, + DateSegment, + type DateValue, + type ValidationResult, +} from "react-aria-components"; +import { tv } from "tailwind-variants"; +import { + Description, + FieldError, + Label, + fieldGroupStyles, +} from "@/components/ui/Field"; +import { composeTailwindRenderProps } from "@/components/ui/utils"; + +export interface DateFieldProps + extends AriaDateFieldProps { + label?: string; + description?: string; + errorMessage?: string | ((validation: ValidationResult) => string); +} + +export function DateField({ + label, + description, + errorMessage, + ...props +}: DateFieldProps) { + return ( + + {label && } + + {description && {description}} + {errorMessage} + + ); +} + +const segmentStyles = tv({ + base: "inline p-0.5 type-literal:px-0 rounded-xs outline-0 forced-color-adjust-none caret-transparent text-gray-800 dark:text-zinc-200 forced-colors:text-[ButtonText]", + variants: { + isPlaceholder: { + true: "text-gray-600 dark:text-zinc-400 italic", + }, + isDisabled: { + true: "text-gray-200 dark:text-zinc-600 forced-colors:text-[GrayText]", + }, + isFocused: { + true: "bg-blue-600 text-white dark:text-white forced-colors:bg-[Highlight] forced-colors:text-[HighlightText]", + }, + }, +}); + +export function DateInput(props: Omit) { + return ( + + fieldGroupStyles({ + ...renderProps, + className: + "block min-w-[150px] px-2 py-1.5 text-sm border-1 rounded-sm", + }) + } + {...props} + > + {(segment) => } + + ); +} diff --git a/apps/web/src/components/ui/DateField/index.ts b/apps/web/src/components/ui/DateField/index.ts new file mode 100644 index 00000000..29cda4d0 --- /dev/null +++ b/apps/web/src/components/ui/DateField/index.ts @@ -0,0 +1 @@ +export * from "./DateField"; diff --git a/apps/web/src/components/ui/DatePicker/DatePicker.stories.tsx b/apps/web/src/components/ui/DatePicker/DatePicker.stories.tsx new file mode 100644 index 00000000..19d3c070 --- /dev/null +++ b/apps/web/src/components/ui/DatePicker/DatePicker.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta } from "@storybook/react"; +import { Form } from "react-aria-components"; +import { Button } from "@/components/ui/Button"; +import { DatePicker } from "."; + +const meta: Meta = { + title: "UI/DatePicker", + component: DatePicker, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + args: { + label: "Event date", + }, +}; + +export default meta; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +export const Example = (args: any) => ; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +export const Validation = (args: any) => ( +
+ + + +); + +Validation.args = { + isRequired: true, +}; diff --git a/apps/web/src/components/ui/DatePicker/DatePicker.tsx b/apps/web/src/components/ui/DatePicker/DatePicker.tsx new file mode 100644 index 00000000..9aad5a3c --- /dev/null +++ b/apps/web/src/components/ui/DatePicker/DatePicker.tsx @@ -0,0 +1,64 @@ +import { + DatePicker as AriaDatePicker, + type DatePickerProps as AriaDatePickerProps, + type DateValue, + type ValidationResult, +} from "react-aria-components"; +import { Button } from "@/components/ui/Button"; +import { Calendar } from "@/components/ui/Calendar"; +import { DateInput } from "@/components/ui/DateField"; +import { Dialog } from "@/components/ui/Dialog"; +import { + Description, + FieldError, + FieldGroup, + Label, +} from "@/components/ui/Field"; +import { Popover } from "@/components/ui/Popover"; +import { composeTailwindRenderProps } from "@/components/ui/utils"; +import TablerCalendar from "~icons/tabler/calendar"; + +export interface DatePickerProps + extends AriaDatePickerProps { + label?: string; + description?: string; + errorMessage?: string | ((validation: ValidationResult) => string); +} + +export function DatePicker({ + label, + description, + errorMessage, + ...props +}: DatePickerProps) { + return ( + + {label && } + + + + + {description && {description}} + {errorMessage} + + + + + + + ); +} diff --git a/apps/web/src/components/ui/DatePicker/index.ts b/apps/web/src/components/ui/DatePicker/index.ts new file mode 100644 index 00000000..2f4cf5f5 --- /dev/null +++ b/apps/web/src/components/ui/DatePicker/index.ts @@ -0,0 +1 @@ +export * from "./DatePicker"; diff --git a/apps/web/src/components/ui/Dialog/Dialog.tsx b/apps/web/src/components/ui/Dialog/Dialog.tsx new file mode 100644 index 00000000..feaf0cb9 --- /dev/null +++ b/apps/web/src/components/ui/Dialog/Dialog.tsx @@ -0,0 +1,14 @@ +import { cn } from "@/utils/cn"; +import { type DialogProps, Dialog as RACDialog } from "react-aria-components"; + +export function Dialog(props: DialogProps) { + return ( + &]:p-2 max-h-[inherit] overflow-auto relative", + props.className, + )} + /> + ); +} diff --git a/apps/web/src/components/ui/Dialog/index.ts b/apps/web/src/components/ui/Dialog/index.ts new file mode 100644 index 00000000..24cc75a7 --- /dev/null +++ b/apps/web/src/components/ui/Dialog/index.ts @@ -0,0 +1 @@ +export * from "./Dialog"; diff --git a/apps/web/src/components/ui/Field/Field.tsx b/apps/web/src/components/ui/Field/Field.tsx new file mode 100644 index 00000000..b8e3d994 --- /dev/null +++ b/apps/web/src/components/ui/Field/Field.tsx @@ -0,0 +1,149 @@ +import { + type FieldErrorProps, + Group, + type GroupProps, + type InputProps, + type LabelProps, + FieldError as RACFieldError, + Input as RACInput, + Label as RACLabel, + Text, + type TextProps, + composeRenderProps, +} from "react-aria-components"; +import { tv } from "tailwind-variants"; +import { composeTailwindRenderProps, type Icon } from "@/components/ui/utils"; +import { cn } from "@/utils/cn"; +import TablerAsterisk from "~icons/tabler/asterisk"; +import { forwardRef } from "react"; + +export const fieldBorderStyles = tv({ + variants: { + isFocusWithin: { + false: "border-input-border forced-colors:border-[ButtonBorder]", + true: "border-input-border-focused forced-colors:border-[Highlight]", + }, + isInvalid: { + true: "border-input-border-invalid forced-colors:border-[Mark]", + }, + isDisabled: { + true: "border-input-border-disabled forced-colors:border-[GrayText]", + }, + }, +}); + +export const fieldGroupStyles = tv({ + base: "group flex items-start forced-colors:bg-[Field]", + variants: fieldBorderStyles.variants, +}); + +export function Label({ + isRequired, + ...props +}: LabelProps & { isRequired?: boolean }) { + return ( + + {props.children} + {isRequired && } + + ); +} + +export function Description(props: TextProps) { + return ( + + ); +} + +export function ErrorList({ errors }: { errors: string[] }) { + return ( +
    + {errors.map((error, i) => ( +
  • {error}
  • + ))} +
+ ); +} + +export function FieldError(props: FieldErrorProps) { + return ( + + {props.children + ? props.children + : ({ validationErrors }) => { + return validationErrors.length === 1 ? ( + validationErrors[0] + ) : ( + + ); + }} + + ); +} + +export function FieldGroup(props: GroupProps) { + return ( + + fieldGroupStyles({ ...renderProps, className }), + )} + /> + ); +} + +export const Input = forwardRef( + ( + { icon: Icon, ...props }: InputProps & { icon?: Icon }, + ref: React.ForwardedRef, + ) => { + const inputClassName = + "h-9 py-1.5 w-full min-w-0 outline-0 bg-surface text-base text-text-main disabled:text-input-text-disabled"; + + if (!Icon) { + return ( + + ); + } + + return ( +
+
+ +
+ + +
+ ); + }, +); diff --git a/apps/web/src/components/ui/Field/index.ts b/apps/web/src/components/ui/Field/index.ts new file mode 100644 index 00000000..cdf384cf --- /dev/null +++ b/apps/web/src/components/ui/Field/index.ts @@ -0,0 +1 @@ +export * from "./Field"; diff --git a/apps/web/src/components/ui/ListBox/ListBox.tsx b/apps/web/src/components/ui/ListBox/ListBox.tsx new file mode 100644 index 00000000..dd1020a4 --- /dev/null +++ b/apps/web/src/components/ui/ListBox/ListBox.tsx @@ -0,0 +1,123 @@ +import TablerCheck from "~icons/tabler/check"; +import { + ListBox as AriaListBox, + ListBoxItem as AriaListBoxItem, + type ListBoxProps as AriaListBoxProps, + Collection, + Header, + type ListBoxItemProps, + ListBoxSection, + type SectionProps, + composeRenderProps, +} from "react-aria-components"; +import { tv } from "tailwind-variants"; +import { composeTailwindRenderProps } from "@/components/ui/utils"; + +type ListBoxProps = Omit, "layout" | "orientation">; + +export const listBoxStyles = tv({ + base: "outline-0 p-1 border border-input-border rounded-sm", +}); + +export function ListBox({ + children, + ...props +}: ListBoxProps) { + return ( + + {children} + + ); +} + +export const itemStyles = tv({ + base: "group relative flex items-center gap-8 cursor-default select-none py-1.5 px-2.5 rounded-sm will-change-transform text-sm forced-color-adjust-none", + variants: { + isSelected: { + false: + "text-slate-700 dark:text-zinc-300 hover:bg-slate-200 dark:hover:bg-zinc-700 -outline-offset-2", + true: "bg-blue-600 text-white forced-colors:bg-[Highlight] forced-colors:text-[HighlightText] [&:has(+[data-selected])]:rounded-b-none [&+[data-selected]]:rounded-t-none -outline-offset-4 outline-white dark:outline-white forced-colors:outline-[HighlightText]", + }, + isDisabled: { + true: "text-slate-300 dark:text-zinc-600 forced-colors:text-[GrayText]", + }, + }, +}); + +export function ListBoxItem(props: ListBoxItemProps) { + const textValue = + props.textValue || + (typeof props.children === "string" ? props.children : undefined); + return ( + + {composeRenderProps(props.children, (children) => ( + <>{children} + ))} + + ); +} + +export const dropdownItemStyles = tv({ + base: "group flex items-center gap-4 cursor-default select-none py-2 pl-3 pr-1 rounded-sm outline-0 text-sm forced-color-adjust-none", + variants: { + isDisabled: { + false: "text-text-main", + true: "text-input-text-disabled forced-colors:text-[GrayText]", + }, + isFocused: { + true: "bg-blue-600 text-white forced-colors:bg-[Highlight] forced-colors:text-[HighlightText]", + }, + }, + compoundVariants: [ + { + isFocused: false, + isOpen: true, + className: "bg-gray-100 dark:bg-zinc-700/60", + }, + ], +}); + +export function DropdownItem(props: ListBoxItemProps) { + const textValue = + props.textValue || + (typeof props.children === "string" ? props.children : undefined); + return ( + + {composeRenderProps(props.children, (children, { isSelected }) => ( + <> + + {children} + + + {isSelected && } + + + ))} + + ); +} + +export interface DropdownSectionProps extends SectionProps { + title?: string; + // items?: any[]; +} + +export function DropdownSection( + props: DropdownSectionProps, +) { + return ( + +
+ {props.title} +
+ {props.children} +
+ ); +} diff --git a/apps/web/src/components/ui/ListBox/index.ts b/apps/web/src/components/ui/ListBox/index.ts new file mode 100644 index 00000000..de85b45a --- /dev/null +++ b/apps/web/src/components/ui/ListBox/index.ts @@ -0,0 +1 @@ +export * from "./ListBox"; diff --git a/apps/web/src/components/ui/MultiSelect/MultiSelect.stories.tsx b/apps/web/src/components/ui/MultiSelect/MultiSelect.stories.tsx new file mode 100644 index 00000000..479cb440 --- /dev/null +++ b/apps/web/src/components/ui/MultiSelect/MultiSelect.stories.tsx @@ -0,0 +1,40 @@ +/* eslint-disable */ +import type { Meta } from "@storybook/react"; +import { MultiSelect } from "."; + +const meta: Meta = { + title: "UI/MultiSelect", + component: MultiSelect, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + args: { + name: "Ice cream flavor", + }, +}; + +export default meta; + +const options = [ + { value: "chocolate", label: "Chocolate" }, + { value: "strawberry", label: "Strawberry" }, + { value: "vanilla", label: "Vanilla" }, + { value: "vanilla2", label: "Vanilla" }, + { value: "vanilla3", label: "Vanilla" }, + { value: "vanilla4", label: "Vanilla" }, + { value: "vanilla5", label: "Vanilla" }, + { value: "vanilla6", label: "Vanilla" }, + { value: "vanilla7", label: "Vanilla" }, + { value: "vanilla8", label: "Vanilla" }, +]; + +export const Example = (args: any) => ( + +); diff --git a/apps/web/src/components/ui/MultiSelect/MultiSelect.tsx b/apps/web/src/components/ui/MultiSelect/MultiSelect.tsx new file mode 100644 index 00000000..ede82f08 --- /dev/null +++ b/apps/web/src/components/ui/MultiSelect/MultiSelect.tsx @@ -0,0 +1,193 @@ +import Select, { + components, + type ClearIndicatorProps, + type DropdownIndicatorProps, + type MultiValueRemoveProps, +} from "react-select"; +import TablerChevronDown from "~icons/tabler/chevron-down"; +import { + listBoxContainerStyles, + styles as selectStyles, +} from "@/components/ui/Select"; +import { styles as PopoverStyles } from "@/components/ui/Popover"; +import { dropdownItemStyles } from "@/components/ui/ListBox"; +import { cn } from "@/utils/cn"; +import { + removeButtonStyles as removeTagButtonStyles, + tagStyles, +} from "@/components/ui/Tag"; +import TablerX from "~icons/tabler/x"; +import { useId, useRef, useState, type CSSProperties } from "react"; +import { ErrorList, fieldBorderStyles } from "@/components/ui/Field"; +import TablerAsterisk from "~icons/tabler/asterisk"; + +const DropdownIndicator = (props: DropdownIndicatorProps) => { + return ( + + + + ); +}; + +const ClearIndicator = (props: ClearIndicatorProps) => { + const { + getStyles, + innerProps: { ref, ...restInnerProps }, + } = props; + return ( +
+ +
+ ); +}; + +const MultiValueRemove = (props: MultiValueRemoveProps) => { + return ( + + + + ); +}; + +interface Option { + value: string; + label: string; +} + +export interface MultiSelectProps { + // Name must be unique and is required for form submissions + name: string; + label: string; + options: Option[]; + isRequired?: boolean; + onChange?: (data: Option[]) => void; + + // Not sure if its better to do this https://react-spectrum.adobe.com/react-aria/Form.html#custom-children + // accepting errors props should work for now. + errors?: string[]; +} + +export const MULTISELECT_NAME_PREFIX = "multiselect-"; + +const MultiSelect = ({ + isRequired, + label, + name, + options, + onChange, + errors, + ...props +}: MultiSelectProps) => { + const id = useId(); + const hiddenSelectRef = useRef(null); + const [isInvalid, setIsInvalid] = useState(false); + + const renderErrors = () => { + if (isInvalid) return

Please select an item in the list.

; + + if (errors && errors.length > 0) { + return ; + } + }; + + return ( +
+ + + setIsInvalid(true)} + > + {options.map((option) => ( +
+ ); +}; + +export { MultiSelect }; diff --git a/apps/web/src/components/ui/MultiSelect/index.ts b/apps/web/src/components/ui/MultiSelect/index.ts new file mode 100644 index 00000000..22ee6bcf --- /dev/null +++ b/apps/web/src/components/ui/MultiSelect/index.ts @@ -0,0 +1 @@ +export * from "./MultiSelect"; diff --git a/apps/web/src/components/ui/Popover/Popover.tsx b/apps/web/src/components/ui/Popover/Popover.tsx new file mode 100644 index 00000000..0551793e --- /dev/null +++ b/apps/web/src/components/ui/Popover/Popover.tsx @@ -0,0 +1,66 @@ +import { + OverlayArrow, + Popover as AriaPopover, + type PopoverProps as AriaPopoverProps, + composeRenderProps, + PopoverContext, + useSlottedContext, +} from "react-aria-components"; +import React from "react"; +import { tv } from "tailwind-variants"; + +export interface PopoverProps extends Omit { + showArrow?: boolean; + children: React.ReactNode; +} + +export const styles = tv({ + base: "bg-white dark:bg-zinc-900/70 dark:backdrop-blur-2xl dark:backdrop-saturate-200 forced-colors:bg-[Canvas] shadow-sm rounded-sm bg-clip-padding border border-black/10 dark:border-white/[15%] text-slate-700 dark:text-zinc-300", + variants: { + isEntering: { + true: "animate-in fade-in placement-bottom:slide-in-from-top-1 placement-top:slide-in-from-bottom-1 placement-left:slide-in-from-right-1 placement-right:slide-in-from-left-1 ease-out duration-200", + }, + isExiting: { + true: "animate-out fade-out placement-bottom:slide-out-to-top-1 placement-top:slide-out-to-bottom-1 placement-left:slide-out-to-right-1 placement-right:slide-out-to-left-1 ease-in duration-150", + }, + }, +}); + +const Popover = ({ + children, + showArrow, + className, + ...props +}: PopoverProps) => { + const popoverContext = useSlottedContext(PopoverContext)!; + const isSubmenu = popoverContext?.trigger === "SubmenuTrigger"; + let offset = showArrow ? 12 : 8; + offset = isSubmenu ? offset - 6 : offset; + return ( + + styles({ ...renderProps, className }), + )} + > + {showArrow && ( + + + + + + )} + {children} + + ); +}; + +Popover.displayName = "Popover"; + +export { Popover }; diff --git a/apps/web/src/components/ui/Popover/index.ts b/apps/web/src/components/ui/Popover/index.ts new file mode 100644 index 00000000..72f124f6 --- /dev/null +++ b/apps/web/src/components/ui/Popover/index.ts @@ -0,0 +1 @@ +export * from "./Popover"; diff --git a/apps/web/src/components/ui/Radio/Radio.stories.tsx b/apps/web/src/components/ui/Radio/Radio.stories.tsx new file mode 100644 index 00000000..285a7bb7 --- /dev/null +++ b/apps/web/src/components/ui/Radio/Radio.stories.tsx @@ -0,0 +1,44 @@ +/* eslint-disable */ +import { Form } from "react-aria-components"; +import { Button } from "@/components/ui/Button"; +import { Radio, RadioGroup } from "."; + +export default { + title: "UI/RadioGroup", + component: RadioGroup, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: {}, + args: { + label: "How many hackathons have you participated in?", + isDisabled: false, + isRequired: false, + description: "", + children: ( + <> + SwampHacks would be my first + 1 + 2 + + ), + }, +}; + +export const Default = { + args: {}, +}; + +export const Validation = (args: any) => ( +
+ + + +); + +Validation.args = { + isRequired: true, +}; diff --git a/apps/web/src/components/ui/Radio/Radio.tsx b/apps/web/src/components/ui/Radio/Radio.tsx new file mode 100644 index 00000000..07cd402b --- /dev/null +++ b/apps/web/src/components/ui/Radio/Radio.tsx @@ -0,0 +1,39 @@ +import { Radio as RACRadio, type RadioProps } from "react-aria-components"; +import { tv } from "tailwind-variants"; +import { composeTailwindRenderProps } from "@/components/ui/utils"; + +const styles = tv({ + base: "w-5 h-5 rounded-full border-2 bg-white dark:bg-zinc-900 transition-all", + variants: { + isSelected: { + false: + "border-border dark:border-gray-400 group-pressed:border-gray-400 dark:group-pressed:border-zinc-300", + true: "border-[7px] border-blue-600 dark:border-blue-300 forced-colors:border-[Highlight]! group-pressed:border-blue-800 dark:group-pressed:border-blue-200", + }, + isInvalid: { + true: "border-red-700 dark:border-red-600 group-pressed:border-red-800 dark:group-pressed:border-red-700 forced-colors:border-[Mark]!", + }, + isDisabled: { + true: "border-gray-200 dark:border-zinc-700 forced-colors:border-[GrayText]!", + }, + }, +}); + +export function Radio(props: RadioProps) { + return ( + + {(renderProps) => ( + <> +
+ {props.children} + + )} + + ); +} diff --git a/apps/web/src/components/ui/Radio/RadioGroup.tsx b/apps/web/src/components/ui/Radio/RadioGroup.tsx new file mode 100644 index 00000000..0c434d63 --- /dev/null +++ b/apps/web/src/components/ui/Radio/RadioGroup.tsx @@ -0,0 +1,34 @@ +import { type ReactNode } from "react"; +import { + RadioGroup as RACRadioGroup, + type RadioGroupProps as RACRadioGroupProps, + type ValidationResult, +} from "react-aria-components"; +import { Description, FieldError, Label } from "@/components/ui/Field"; +import { composeTailwindRenderProps } from "@/components/ui/utils"; + +export interface RadioGroupProps extends Omit { + label?: string; + children?: ReactNode; + description?: string; + errorMessage?: string | ((validation: ValidationResult) => string); +} + +export function RadioGroup(props: RadioGroupProps) { + return ( + + +
+ {props.children} +
+ {props.description && {props.description}} + {props.errorMessage} +
+ ); +} diff --git a/apps/web/src/components/ui/Radio/index.ts b/apps/web/src/components/ui/Radio/index.ts new file mode 100644 index 00000000..200c3ffc --- /dev/null +++ b/apps/web/src/components/ui/Radio/index.ts @@ -0,0 +1,2 @@ +export * from "./Radio"; +export * from "./RadioGroup"; diff --git a/apps/web/src/components/ui/Select/Select.stories.tsx b/apps/web/src/components/ui/Select/Select.stories.tsx new file mode 100644 index 00000000..e7c2c4ec --- /dev/null +++ b/apps/web/src/components/ui/Select/Select.stories.tsx @@ -0,0 +1,74 @@ +/* eslint-disable */ +import type { Meta } from "@storybook/react"; +import { Form } from "react-aria-components"; +import { Button } from "@/components/ui/Button"; +import { Select, SelectItem, SelectSection } from "."; + +const meta: Meta = { + title: "UI/Select", + component: Select, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + args: { + label: "Ice cream flavor", + }, +}; + +export default meta; + +export const Example = (args: any) => ( + +); + +export const DisabledItems = (args: any) => ; +DisabledItems.args = { + disabledKeys: ["mint"], +}; + +export const Sections = (args: any) => ( + +); + +Sections.args = { + label: "Preferred fruit or vegetable", +}; + +export const Validation = (args: any) => ( +
+ + + +); + +Validation.args = { + isRequired: true, +}; diff --git a/apps/web/src/components/ui/Select/Select.tsx b/apps/web/src/components/ui/Select/Select.tsx new file mode 100644 index 00000000..ad17df6a --- /dev/null +++ b/apps/web/src/components/ui/Select/Select.tsx @@ -0,0 +1,89 @@ +import React from "react"; +import { + Select as AriaSelect, + type SelectProps as AriaSelectProps, + Button, + ListBox, + type ListBoxItemProps, + SelectValue, + type ValidationResult, +} from "react-aria-components"; +import { tv } from "tailwind-variants"; +import { Description, FieldError, Label } from "@/components/ui/Field"; +import { + DropdownItem, + DropdownSection, + type DropdownSectionProps, +} from "@/components/ui/ListBox"; +import { Popover } from "@/components/ui/Popover"; +import { composeTailwindRenderProps } from "@/components/ui/utils"; +import TablerChevronDown from "~icons/tabler/chevron-down"; + +export const styles = tv({ + base: "h-9.5 flex items-center text-start gap-4 w-full cursor-default border border-input-border rounded-sm pl-3 pr-2 py-1.5 min-w-[150px] bg-surface", + variants: { + isDisabled: { + false: + "text-text-main hover:bg-gray-100 dark:hover:bg-neutral-700 group-invalid:border-red-600 forced-colors:group-invalid:border-[Mark]", + true: "text-gray-200 dark:text-zinc-600 forced-colors:text-[GrayText] dark:bg-zinc-800 dark:border-white/5 forced-colors:border-[GrayText]", + }, + }, +}); + +export const listBoxContainerStyles = tv({ + base: "outline-hidden p-1 max-h-[inherit] overflow-auto [clip-path:inset(0_0_0_0_round_.75rem)]", +}); + +export interface SelectProps + extends Omit, "children"> { + label?: string; + description?: string; + errorMessage?: string | ((validation: ValidationResult) => string); + items?: Iterable; + children: React.ReactNode | ((item: T) => React.ReactNode); +} + +export function Select({ + label, + description, + errorMessage, + children, + items, + ...props +}: SelectProps) { + return ( + + {label && } + + {description && {description}} + {errorMessage} + + + {children} + + + + ); +} + +export function SelectItem(props: ListBoxItemProps) { + return ; +} + +export function SelectSection( + props: DropdownSectionProps, +) { + return ; +} diff --git a/apps/web/src/components/ui/Select/index.ts b/apps/web/src/components/ui/Select/index.ts new file mode 100644 index 00000000..b6e8a07c --- /dev/null +++ b/apps/web/src/components/ui/Select/index.ts @@ -0,0 +1 @@ +export * from "./Select"; diff --git a/apps/web/src/components/ui/Spinner/Spinner.stories.tsx b/apps/web/src/components/ui/Spinner/Spinner.stories.tsx new file mode 100644 index 00000000..8ab74b7a --- /dev/null +++ b/apps/web/src/components/ui/Spinner/Spinner.stories.tsx @@ -0,0 +1,15 @@ +import type { Meta } from "@storybook/react"; +import { Spinner } from "."; + +const meta: Meta = { + title: "UI/Spinner", + component: Spinner, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], +}; + +export default meta; + +export const Example = () => ; diff --git a/apps/web/src/components/ui/Spinner/Spinner.tsx b/apps/web/src/components/ui/Spinner/Spinner.tsx new file mode 100644 index 00000000..2b3b632c --- /dev/null +++ b/apps/web/src/components/ui/Spinner/Spinner.tsx @@ -0,0 +1,30 @@ +// https://github.com/shadcn-ui/ui/discussions/1694#discussioncomment-7851248 + +import { cn } from "@/utils/cn"; + +export interface ISVGProps extends React.SVGProps { + size?: number; + className?: string; +} + +const Spinner = ({ size = 24, className, ...props }: ISVGProps) => { + return ( + + + + ); +}; + +export { Spinner }; diff --git a/apps/web/src/components/ui/Spinner/index.ts b/apps/web/src/components/ui/Spinner/index.ts new file mode 100644 index 00000000..2f83b969 --- /dev/null +++ b/apps/web/src/components/ui/Spinner/index.ts @@ -0,0 +1 @@ +export * from "./Spinner"; diff --git a/apps/web/src/components/ui/Tag/Tag.stories.tsx b/apps/web/src/components/ui/Tag/Tag.stories.tsx new file mode 100644 index 00000000..902aa046 --- /dev/null +++ b/apps/web/src/components/ui/Tag/Tag.stories.tsx @@ -0,0 +1,28 @@ +/* eslint-disable */ +import type { Meta } from "@storybook/react"; +import { Tag, TagGroup } from "."; + +const meta: Meta = { + title: "UI/Tag", + component: TagGroup, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], +}; + +export default meta; + +export const Example = (args: any) => ( + {}}> + Computer Science + Statistics + Accounting + Chemistry + +); + +Example.args = { + label: "Ice cream flavor", + selectionMode: "single", +}; diff --git a/apps/web/src/components/ui/Tag/Tag.tsx b/apps/web/src/components/ui/Tag/Tag.tsx new file mode 100644 index 00000000..60a0b1fb --- /dev/null +++ b/apps/web/src/components/ui/Tag/Tag.tsx @@ -0,0 +1,131 @@ +import { createContext, useContext } from "react"; +import { + Tag as AriaTag, + TagGroup as AriaTagGroup, + type TagGroupProps as AriaTagGroupProps, + type TagProps as AriaTagProps, + Button, + TagList, + type TagListProps, + Text, + composeRenderProps, +} from "react-aria-components"; +import { twMerge } from "tailwind-merge"; +import { tv } from "tailwind-variants"; +import { Description, Label } from "@/components/ui/Field"; +import TablerX from "~icons/tabler/x"; + +const colors = { + gray: "bg-gray-100 text-gray-600 border-gray-200 hover:border-gray-300 dark:bg-zinc-700 dark:text-zinc-200 dark:border-zinc-600 dark:hover:border-zinc-500", + green: + "bg-green-100 text-green-700 border-green-200 hover:border-green-300 dark:bg-green-300/20 dark:text-green-400 dark:border-green-300/10 dark:hover:border-green-300/20", + yellow: + "bg-yellow-100 text-yellow-700 border-yellow-200 hover:border-yellow-300 dark:bg-yellow-300/20 dark:text-yellow-400 dark:border-yellow-300/10 dark:hover:border-yellow-300/20", + blue: "bg-blue-100 text-blue-700 border-blue-200 hover:border-blue-300 dark:bg-blue-400/20 dark:text-blue-300 dark:border-blue-400/10 dark:hover:border-blue-400/20", +}; + +type Color = keyof typeof colors; +const ColorContext = createContext("gray"); + +export const tagStyles = tv({ + base: "transition cursor-default m-0 text-xs rounded-sm border px-1 py-0.5 flex items-center max-w-fit gap-1", + variants: { + color: { + gray: "", + green: "", + yellow: "", + blue: "", + }, + allowsRemoving: { + true: "pr-1", + }, + isSelected: { + true: "bg-blue-600 text-white border-transparent forced-colors:bg-[Highlight] forced-colors:text-[HighlightText] forced-color-adjust-none", + }, + isDisabled: { + true: "bg-gray-100 dark:bg-transparent dark:border-white/20 text-gray-300 dark:text-zinc-600 forced-colors:text-[GrayText]", + }, + }, + compoundVariants: (Object.keys(colors) as Color[]).map((color) => ({ + isSelected: false, + isDisabled: false, + color, + class: colors[color], + })), +}); + +export interface TagGroupProps + extends Omit, + Pick, "items" | "children" | "renderEmptyState"> { + color?: Color; + label?: string; + description?: string; + errorMessage?: string; +} + +export interface TagProps extends AriaTagProps { + color?: Color; +} + +export function TagGroup({ + label, + description, + errorMessage, + items, + children, + renderEmptyState, + ...props +}: TagGroupProps) { + return ( + + + + + {children} + + + {description && {description}} + {errorMessage && ( + + {errorMessage} + + )} + + ); +} + +export const removeButtonStyles = tv({ + base: "m-0 cursor-default rounded-full transition-[background-color] p-0.5 flex items-center justify-center hover:bg-black/10 dark:hover:bg-white/10 pressed:bg-black/20 dark:pressed:bg-white/20", +}); + +export function Tag({ children, color, ...props }: TagProps) { + const textValue = typeof children === "string" ? children : undefined; + const groupColor = useContext(ColorContext); + return ( + + tagStyles({ ...renderProps, className, color: color || groupColor }), + )} + > + {({ allowsRemoving }) => ( + <> + {children} + {allowsRemoving && ( + + )} + + )} + + ); +} diff --git a/apps/web/src/components/ui/Tag/index.ts b/apps/web/src/components/ui/Tag/index.ts new file mode 100644 index 00000000..5b4c58bc --- /dev/null +++ b/apps/web/src/components/ui/Tag/index.ts @@ -0,0 +1 @@ +export * from "./Tag"; diff --git a/apps/web/src/components/ui/TextField/TextField.stories.tsx b/apps/web/src/components/ui/TextField/TextField.stories.tsx new file mode 100644 index 00000000..c0c2dd8b --- /dev/null +++ b/apps/web/src/components/ui/TextField/TextField.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { TextField } from "."; +import TablerBrandLinkedin from "~icons/tabler/brand-linkedin"; + +const meta = { + component: TextField, + title: "UI/TextField", + tags: ["autodocs"], + args: { + className: "max-w-70", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + label: "First Name", + placeholder: "e.g. John", + description: "This is a sample description of the text field.", + isRequired: true, + }, +}; + +export const WithIcon: Story = { + args: { + label: "First Name", + placeholder: "e.g. John", + description: "This is a sample description of the text field.", + isRequired: true, + icon: TablerBrandLinkedin, + }, +}; diff --git a/apps/web/src/components/ui/TextField/TextField.tsx b/apps/web/src/components/ui/TextField/TextField.tsx new file mode 100644 index 00000000..94bd9175 --- /dev/null +++ b/apps/web/src/components/ui/TextField/TextField.tsx @@ -0,0 +1,72 @@ +import { + TextField as RAC_TextField, + type TextFieldProps as RAC_TextFieldProps, + type ValidationResult, +} from "react-aria-components"; +import { tv } from "tailwind-variants"; +import { + Description, + FieldError, + Input, + Label, + fieldBorderStyles, +} from "@/components/ui/Field"; +import { composeTailwindRenderProps, type Icon } from "@/components/ui/utils"; +import { TextArea } from "react-aria-components"; + +export const inputStyles = tv({ + base: "outline-0 border-1 rounded-sm", + variants: { + isFocused: fieldBorderStyles.variants.isFocusWithin, + isInvalid: fieldBorderStyles.variants.isInvalid, + isDisabled: fieldBorderStyles.variants.isDisabled, + }, +}); + +export interface TextFieldProps extends RAC_TextFieldProps { + label?: string; + placeholder?: string; + description?: string; + errorMessage?: string | ((validation: ValidationResult) => string); + icon?: Icon; + textarea?: boolean; +} + +const TextField = ({ + label, + placeholder, + description, + errorMessage, + isRequired, + icon, + textarea, + ...props +}: TextFieldProps) => { + return ( + + {label && } + {textarea ? ( +