diff --git a/.env.example b/.env.example index 1627c75..e0ef310 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,9 @@ DATABASE_URL=postgres://financeiro:financeiro@localhost:5432/financeiro?sslmode= # Porta em que o servidor Go escuta PORT=8080 +# Segredo para assinar tokens JWT — gere com: openssl rand -hex 32 +JWT_SECRET=troque-antes-de-subir-em-producao + # ── docker-compose ────────────────────────────────────────────────────────────── # Porta exposta na máquina host para o container da app APP_PORT=8080 diff --git a/apps/api/cmd/server/main.go b/apps/api/cmd/server/main.go index 24af262..bbe0932 100644 --- a/apps/api/cmd/server/main.go +++ b/apps/api/cmd/server/main.go @@ -40,6 +40,12 @@ func main() { log.Fatalf("migration: %v", err) } + // CLI subcommand: create-user + if len(os.Args) > 1 && os.Args[1] == "create-user" { + authmw.RunCreateUser(pool) + return + } + port := os.Getenv("PORT") if port == "" { port = "8080" @@ -83,12 +89,17 @@ func main() { r.Get("/health", handler.Health) - r.Post("/api/login", authmw.LoginHandler) + // Public auth endpoints + r.Post("/api/auth/login", authmw.LoginHandler(pool)) r.Post("/api/logout", authmw.LogoutHandler) - r.Get("/api/auth/me", authmw.MeHandler) + // Protected routes r.Route("/api", func(r chi.Router) { r.Use(authmw.RequireAuth) + + r.Get("/auth/me", authmw.MeHandler) + r.Post("/auth/change-password", authmw.ChangePasswordHandler(pool)) + r.Get("/categories", categoryHandler.List) r.Post("/categories", categoryHandler.Create) r.Put("/categories/{id}", categoryHandler.Update) diff --git a/apps/api/go.mod b/apps/api/go.mod index 8791908..2964993 100644 --- a/apps/api/go.mod +++ b/apps/api/go.mod @@ -9,6 +9,7 @@ require ( ) require ( + github.com/golang-jwt/jwt/v5 v5.3.1 // 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 diff --git a/apps/api/go.sum b/apps/api/go.sum index aa56191..29bc527 100644 --- a/apps/api/go.sum +++ b/apps/api/go.sum @@ -3,6 +3,8 @@ 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/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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= diff --git a/apps/api/internal/middleware/auth.go b/apps/api/internal/middleware/auth.go index 04c92db..e66040c 100644 --- a/apps/api/internal/middleware/auth.go +++ b/apps/api/internal/middleware/auth.go @@ -1,51 +1,38 @@ package middleware import ( - "crypto/hmac" - "crypto/rand" - "crypto/sha256" - "encoding/hex" + "context" "encoding/json" + "errors" + "flag" "fmt" "net/http" "os" - "strconv" "strings" "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/jackc/pgx/v5/pgxpool" + "golang.org/x/crypto/bcrypt" + + "financeiro-carvalho/internal/model" ) -const cookieName = "cf_session" -const sessionDuration = 30 * 24 * time.Hour +type contextKey int -func secret() []byte { - // Use APP_PASSWORD as signing secret so tokens survive container restarts. - return []byte(os.Getenv("APP_PASSWORD")) -} +const ( + profileIDKey contextKey = 0 + profileNameKey contextKey = 1 +) -func signToken(expiry int64) string { - nonce := make([]byte, 8) - rand.Read(nonce) - payload := fmt.Sprintf("%s.%d", hex.EncodeToString(nonce), expiry) - mac := hmac.New(sha256.New, secret()) - mac.Write([]byte(payload)) - sig := hex.EncodeToString(mac.Sum(nil)) - return payload + "." + sig -} +const tokenDuration = 24 * time.Hour -func validToken(token string) bool { - parts := strings.SplitN(token, ".", 3) - if len(parts) != 3 { - return false +func jwtSecret() []byte { + s := os.Getenv("JWT_SECRET") + if s == "" { + s = "change-me-in-production" } - payload := parts[0] + "." + parts[1] - expiry, err := strconv.ParseInt(parts[1], 10, 64) - if err != nil || time.Now().Unix() > expiry { - return false - } - mac := hmac.New(sha256.New, secret()) - mac.Write([]byte(payload)) - expected := hex.EncodeToString(mac.Sum(nil)) - return hmac.Equal([]byte(parts[2]), []byte(expected)) + return []byte(s) } func jsonErr(w http.ResponseWriter, status int, msg string) { @@ -54,66 +41,253 @@ func jsonErr(w http.ResponseWriter, status int, msg string) { json.NewEncoder(w).Encode(map[string]string{"error": msg}) } +// ProfileIDFromCtx extracts the authenticated profile ID from the request context. +func ProfileIDFromCtx(ctx context.Context) int { + v, _ := ctx.Value(profileIDKey).(int) + return v +} + +// ProfileNameFromCtx extracts the authenticated profile name from the request context. +func ProfileNameFromCtx(ctx context.Context) string { + v, _ := ctx.Value(profileNameKey).(string) + return v +} + +type claims struct { + ProfileID int `json:"pid"` + ProfileName string `json:"pname"` + jwt.RegisteredClaims +} + +func issueToken(profileID int, name string) (string, error) { + c := claims{ + ProfileID: profileID, + ProfileName: name, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(tokenDuration)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + return jwt.NewWithClaims(jwt.SigningMethodHS256, c).SignedString(jwtSecret()) +} + +func parseToken(tokenStr string) (*claims, error) { + tok, err := jwt.ParseWithClaims(tokenStr, &claims{}, func(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("unexpected signing method") + } + return jwtSecret(), nil + }) + if err != nil || !tok.Valid { + return nil, errors.New("invalid token") + } + c, ok := tok.Claims.(*claims) + if !ok { + return nil, errors.New("invalid claims") + } + return c, nil +} + +// RequireAuth validates the Bearer JWT and injects profile_id + profile_name into context. func RequireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - cookie, err := r.Cookie(cookieName) - if err != nil || !validToken(cookie.Value) { + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, "Bearer ") { jsonErr(w, http.StatusUnauthorized, "unauthorized") return } - next.ServeHTTP(w, r) + c, err := parseToken(strings.TrimPrefix(auth, "Bearer ")) + if err != nil { + jsonErr(w, http.StatusUnauthorized, "unauthorized") + return + } + ctx := context.WithValue(r.Context(), profileIDKey, c.ProfileID) + ctx = context.WithValue(ctx, profileNameKey, c.ProfileName) + next.ServeHTTP(w, r.WithContext(ctx)) }) } -func LoginHandler(w http.ResponseWriter, r *http.Request) { - var body struct { - Username string `json:"username"` - Password string `json:"password"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - jsonErr(w, http.StatusBadRequest, "corpo inválido") - return - } +// LoginHandler handles POST /api/auth/login +func LoginHandler(pool *pgxpool.Pool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var body struct { + Name string `json:"name"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + jsonErr(w, http.StatusBadRequest, "corpo inválido") + return + } - wantUser := os.Getenv("APP_USERNAME") - wantPass := os.Getenv("APP_PASSWORD") - if wantUser == "" || body.Username != wantUser || body.Password != wantPass { - jsonErr(w, http.StatusUnauthorized, "credenciais inválidas") - return + var p model.Profile + err := pool.QueryRow(r.Context(), + `SELECT id, name, password_hash FROM profiles WHERE name = $1`, body.Name). + Scan(&p.ID, &p.Name, &p.PasswordHash) + if err != nil || p.PasswordHash == "" { + jsonErr(w, http.StatusUnauthorized, "credenciais inválidas") + return + } + if bcrypt.CompareHashAndPassword([]byte(p.PasswordHash), []byte(body.Password)) != nil { + jsonErr(w, http.StatusUnauthorized, "credenciais inválidas") + return + } + + token, err := issueToken(p.ID, p.Name) + if err != nil { + jsonErr(w, http.StatusInternalServerError, "erro interno") + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "token": token, + "profile": map[string]any{"id": p.ID, "name": p.Name}, + }) } - - expiry := time.Now().Add(sessionDuration).Unix() - token := signToken(expiry) - - http.SetCookie(w, &http.Cookie{ - Name: cookieName, - Value: token, - Path: "/", - HttpOnly: true, - Secure: true, - SameSite: http.SameSiteStrictMode, - MaxAge: int(sessionDuration.Seconds()), - }) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]string{"ok": "true"}) -} - -func LogoutHandler(w http.ResponseWriter, r *http.Request) { - http.SetCookie(w, &http.Cookie{ - Name: cookieName, - Value: "", - Path: "/", - MaxAge: -1, - }) - w.WriteHeader(http.StatusNoContent) } +// MeHandler handles GET /api/auth/me (requires auth middleware) func MeHandler(w http.ResponseWriter, r *http.Request) { - cookie, err := r.Cookie(cookieName) - if err != nil || !validToken(cookie.Value) { - jsonErr(w, http.StatusUnauthorized, "unauthorized") - return + id := ProfileIDFromCtx(r.Context()) + name := ProfileNameFromCtx(r.Context()) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"id": id, "name": name}) +} + +// ChangePasswordHandler handles POST /api/auth/change-password (requires auth middleware) +func ChangePasswordHandler(pool *pgxpool.Pool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + profileID := ProfileIDFromCtx(r.Context()) + var body struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + jsonErr(w, http.StatusBadRequest, "corpo inválido") + return + } + if len(body.NewPassword) < 8 { + jsonErr(w, http.StatusBadRequest, "nova senha deve ter pelo menos 8 caracteres") + return + } + + var hash string + if err := pool.QueryRow(r.Context(), + `SELECT password_hash FROM profiles WHERE id = $1`, profileID).Scan(&hash); err != nil { + jsonErr(w, http.StatusInternalServerError, "erro interno") + return + } + if bcrypt.CompareHashAndPassword([]byte(hash), []byte(body.CurrentPassword)) != nil { + jsonErr(w, http.StatusBadRequest, "senha atual incorreta") + return + } + + newHash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), 12) + if err != nil { + jsonErr(w, http.StatusInternalServerError, "erro interno") + return + } + if _, err := pool.Exec(r.Context(), + `UPDATE profiles SET password_hash = $1, updated_at = NOW() WHERE id = $2`, + string(newHash), profileID); err != nil { + jsonErr(w, http.StatusInternalServerError, "erro interno") + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"ok": "true"}) } +} + +// LogoutHandler — JWT is stateless; client drops the token. +func LogoutHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } + +// CreateUser creates or updates a profile with hashed password and seeds data for new profiles. +// Used by the CLI subcommand. +func CreateUser(ctx context.Context, pool *pgxpool.Pool, name, password string) (*model.Profile, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), 12) + if err != nil { + return nil, err + } + + var p model.Profile + var isNew bool + + err = pool.QueryRow(ctx, `SELECT id, name FROM profiles WHERE name = $1`, name).Scan(&p.ID, &p.Name) + if err != nil { + // Profile doesn't exist — create it + err = pool.QueryRow(ctx, + `INSERT INTO profiles (name, password_hash) VALUES ($1, $2) RETURNING id, name`, + name, string(hash)).Scan(&p.ID, &p.Name) + if err != nil { + return nil, err + } + isNew = true + } else { + // Profile exists — update password only + if _, err = pool.Exec(ctx, + `UPDATE profiles SET password_hash = $1, updated_at = NOW() WHERE id = $2`, + string(hash), p.ID); err != nil { + return nil, err + } + } + + if isNew { + if err := seedCategories(ctx, pool, p.ID); err != nil { + return nil, err + } + if _, err := pool.Exec(ctx, + `INSERT INTO player_profile (level, xp, xp_to_next, profile_id) VALUES (1, 0, 100, $1)`, + p.ID); err != nil { + return nil, err + } + } + + return &p, nil +} + +func seedCategories(ctx context.Context, pool *pgxpool.Pool, profileID int) error { + cats := []struct{ name, color string }{ + {"Saúde / Insulina", "#EF4444"}, + {"Veleiro", "#3B82F6"}, + {"Jogos de Tabuleiro", "#8B5CF6"}, + {"Alimentação", "#F59E0B"}, + {"Lazer", "#10B981"}, + {"Freelance", "#6366F1"}, + {"Outros", "#6B7280"}, + } + for _, c := range cats { + if _, err := pool.Exec(ctx, + `INSERT INTO categories (name, color, is_default, profile_id) VALUES ($1, $2, true, $3)`, + c.name, c.color, profileID); err != nil { + return err + } + } + return nil +} + +// RunCreateUser is the CLI entry point for the create-user subcommand. +func RunCreateUser(pool *pgxpool.Pool) { + fs := flag.NewFlagSet("create-user", flag.ExitOnError) + name := fs.String("name", "", "profile name (required)") + password := fs.String("password", "", "password (required)") + fs.Parse(os.Args[2:]) + + if *name == "" || *password == "" { + fmt.Fprintln(os.Stderr, "usage: ./api create-user --name --password ") + os.Exit(1) + } + if len(*password) < 8 { + fmt.Fprintln(os.Stderr, "error: password must be at least 8 characters") + os.Exit(1) + } + + ctx := context.Background() + p, err := CreateUser(ctx, pool, *name, *password) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + fmt.Printf("ok: profile id=%d name=%s\n", p.ID, p.Name) +} diff --git a/apps/api/internal/migration/migration.go b/apps/api/internal/migration/migration.go index 2122fc0..d128868 100644 --- a/apps/api/internal/migration/migration.go +++ b/apps/api/internal/migration/migration.go @@ -47,6 +47,9 @@ var m012 string //go:embed sql/013_transaction_original_date.sql var m013 string +//go:embed sql/014_profiles.sql +var m014 string + func Run(ctx context.Context, pool *pgxpool.Pool) error { // Bootstrap: ensure schema_migrations table exists before checking versions. if _, err := pool.Exec(ctx, ` @@ -58,7 +61,7 @@ func Run(ctx context.Context, pool *pgxpool.Pool) error { return fmt.Errorf("bootstrap schema_migrations: %w", err) } - migrations := []string{m001, m002, m003, m004, m005, m006, m007, m008, m009, m010, m011, m012, m013} + migrations := []string{m001, m002, m003, m004, m005, m006, m007, m008, m009, m010, m011, m012, m013, m014} for i, sql := range migrations { version := i + 1 var applied bool diff --git a/apps/api/internal/migration/sql/014_profiles.sql b/apps/api/internal/migration/sql/014_profiles.sql new file mode 100644 index 0000000..7b41427 --- /dev/null +++ b/apps/api/internal/migration/sql/014_profiles.sql @@ -0,0 +1,47 @@ +-- Multi-user support: profiles table + profile_id in all per-user entities + +CREATE TABLE IF NOT EXISTS profiles ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + password_hash VARCHAR(72) NOT NULL DEFAULT '', + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +-- Seed profile 1 for the existing data owner (Manoel) +INSERT INTO profiles (id, name, password_hash) VALUES (1, 'manoel', '') +ON CONFLICT DO NOTHING; + +SELECT setval('profiles_id_seq', GREATEST(1, (SELECT MAX(id) FROM profiles))); + +-- Add profile_id to all per-user tables (DEFAULT 1 makes existing rows belong to Manoel) +ALTER TABLE categories ADD COLUMN IF NOT EXISTS profile_id INTEGER NOT NULL DEFAULT 1 REFERENCES profiles(id); +ALTER TABLE transactions ADD COLUMN IF NOT EXISTS profile_id INTEGER NOT NULL DEFAULT 1 REFERENCES profiles(id); +ALTER TABLE recurring_expenses ADD COLUMN IF NOT EXISTS profile_id INTEGER NOT NULL DEFAULT 1 REFERENCES profiles(id); +ALTER TABLE accounts ADD COLUMN IF NOT EXISTS profile_id INTEGER NOT NULL DEFAULT 1 REFERENCES profiles(id); +ALTER TABLE player_profile ADD COLUMN IF NOT EXISTS profile_id INTEGER NOT NULL DEFAULT 1 REFERENCES profiles(id); +ALTER TABLE xp_events ADD COLUMN IF NOT EXISTS profile_id INTEGER NOT NULL DEFAULT 1 REFERENCES profiles(id); +ALTER TABLE player_quests ADD COLUMN IF NOT EXISTS profile_id INTEGER NOT NULL DEFAULT 1 REFERENCES profiles(id); +ALTER TABLE player_achievements ADD COLUMN IF NOT EXISTS profile_id INTEGER NOT NULL DEFAULT 1 REFERENCES profiles(id); +ALTER TABLE player_cosmetics ADD COLUMN IF NOT EXISTS profile_id INTEGER NOT NULL DEFAULT 1 REFERENCES profiles(id); + +-- Fix unique constraints to be scoped per profile +ALTER TABLE player_quests + DROP CONSTRAINT IF EXISTS player_quests_quest_id_period_key; +ALTER TABLE player_quests + ADD CONSTRAINT player_quests_quest_id_period_profile_key + UNIQUE (quest_id, period, profile_id); + +ALTER TABLE player_achievements + DROP CONSTRAINT IF EXISTS player_achievements_achievement_id_key; +ALTER TABLE player_achievements + ADD CONSTRAINT player_achievements_achievement_id_profile_key + UNIQUE (achievement_id, profile_id); + +ALTER TABLE player_cosmetics + DROP CONSTRAINT IF EXISTS player_cosmetics_cosmetic_id_key; +ALTER TABLE player_cosmetics + ADD CONSTRAINT player_cosmetics_cosmetic_id_profile_key + UNIQUE (cosmetic_id, profile_id); + +INSERT INTO schema_migrations (version) VALUES (14) ON CONFLICT DO NOTHING; diff --git a/apps/api/internal/model/profile.go b/apps/api/internal/model/profile.go new file mode 100644 index 0000000..eb846d0 --- /dev/null +++ b/apps/api/internal/model/profile.go @@ -0,0 +1,7 @@ +package model + +type Profile struct { + ID int + Name string + PasswordHash string +} diff --git a/apps/api/internal/repository/account.go b/apps/api/internal/repository/account.go index deabce9..ea9d556 100644 --- a/apps/api/internal/repository/account.go +++ b/apps/api/internal/repository/account.go @@ -5,6 +5,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" + "financeiro-carvalho/internal/middleware" "financeiro-carvalho/internal/model" ) @@ -28,6 +29,7 @@ func NewAccountRepository(pool *pgxpool.Pool) *AccountRepository { } func (r *AccountRepository) List(ctx context.Context) ([]model.Account, error) { + pid := middleware.ProfileIDFromCtx(ctx) rows, err := r.pool.Query(ctx, ` SELECT a.id, a.name, a.type, a.initial_balance, @@ -40,9 +42,10 @@ func (r *AccountRepository) List(ctx context.Context) ([]model.Account, error) { AS balance FROM accounts a LEFT JOIN transactions t ON t.account_id = a.id + WHERE a.profile_id = $1 GROUP BY a.id ORDER BY a.created_at - `) + `, pid) if err != nil { return nil, err } @@ -60,6 +63,7 @@ func (r *AccountRepository) List(ctx context.Context) ([]model.Account, error) { } func (r *AccountRepository) GetByID(ctx context.Context, id int) (*model.Account, error) { + pid := middleware.ProfileIDFromCtx(ctx) var a model.Account err := r.pool.QueryRow(ctx, ` SELECT @@ -73,9 +77,9 @@ func (r *AccountRepository) GetByID(ctx context.Context, id int) (*model.Account AS balance FROM accounts a LEFT JOIN transactions t ON t.account_id = a.id - WHERE a.id = $1 + WHERE a.id = $1 AND a.profile_id = $2 GROUP BY a.id - `, id).Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.YieldType, &a.LastYieldDate, &a.ClosingDay, &a.DueDay, &a.CreatedAt, &a.UpdatedAt, &a.Balance) + `, id, pid).Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.YieldType, &a.LastYieldDate, &a.ClosingDay, &a.DueDay, &a.CreatedAt, &a.UpdatedAt, &a.Balance) if err != nil { return nil, err } @@ -83,16 +87,17 @@ func (r *AccountRepository) GetByID(ctx context.Context, id int) (*model.Account } func (r *AccountRepository) Create(ctx context.Context, in model.AccountInput) (*model.Account, error) { + pid := middleware.ProfileIDFromCtx(ctx) yt := in.YieldType if yt == "" { yt = "none" } var a model.Account err := r.pool.QueryRow(ctx, ` - INSERT INTO accounts (name, type, initial_balance, yield_type, closing_day, due_day) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO accounts (name, type, initial_balance, yield_type, closing_day, due_day, profile_id) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, name, type, initial_balance, yield_type, last_yield_date::text, closing_day, due_day, created_at::text, updated_at::text - `, in.Name, in.Type, in.InitialBalance, yt, in.ClosingDay, in.DueDay). + `, in.Name, in.Type, in.InitialBalance, yt, in.ClosingDay, in.DueDay, pid). Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.YieldType, &a.LastYieldDate, &a.ClosingDay, &a.DueDay, &a.CreatedAt, &a.UpdatedAt) if err != nil { return nil, err @@ -102,15 +107,16 @@ func (r *AccountRepository) Create(ctx context.Context, in model.AccountInput) ( } func (r *AccountRepository) Update(ctx context.Context, id int, in model.AccountInput) (*model.Account, error) { + pid := middleware.ProfileIDFromCtx(ctx) yt := in.YieldType if yt == "" { yt = "none" } row := r.pool.QueryRow(ctx, ` UPDATE accounts SET name=$1, type=$2, initial_balance=$3, yield_type=$4, closing_day=$5, due_day=$6, updated_at=NOW() - WHERE id=$7 + WHERE id=$7 AND profile_id=$8 RETURNING id, name, type, initial_balance, yield_type, last_yield_date::text, closing_day, due_day, created_at::text, updated_at::text - `, in.Name, in.Type, in.InitialBalance, yt, in.ClosingDay, in.DueDay, id) + `, in.Name, in.Type, in.InitialBalance, yt, in.ClosingDay, in.DueDay, id, pid) var a model.Account if err := row.Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.YieldType, &a.LastYieldDate, &a.ClosingDay, &a.DueDay, &a.CreatedAt, &a.UpdatedAt); err != nil { return nil, err @@ -123,11 +129,13 @@ func (r *AccountRepository) Update(ctx context.Context, id int, in model.Account } func (r *AccountRepository) Delete(ctx context.Context, id int) error { - _, err := r.pool.Exec(ctx, `DELETE FROM accounts WHERE id = $1`, id) + pid := middleware.ProfileIDFromCtx(ctx) + _, err := r.pool.Exec(ctx, `DELETE FROM accounts WHERE id = $1 AND profile_id = $2`, id, pid) return err } func (r *AccountRepository) TotalPatrimony(ctx context.Context) (float64, error) { + pid := middleware.ProfileIDFromCtx(ctx) var total float64 err := r.pool.QueryRow(ctx, ` SELECT COALESCE(SUM( @@ -138,11 +146,14 @@ func (r *AccountRepository) TotalPatrimony(ctx context.Context) (float64, error) ), 0) ), 0) FROM accounts a - `).Scan(&total) + WHERE a.profile_id = $1 + `, pid).Scan(&total) return total, err } func (r *AccountRepository) UpdateLastYieldDate(ctx context.Context, id int, date string) error { - _, err := r.pool.Exec(ctx, `UPDATE accounts SET last_yield_date = $1 WHERE id = $2`, date, id) + pid := middleware.ProfileIDFromCtx(ctx) + _, err := r.pool.Exec(ctx, + `UPDATE accounts SET last_yield_date = $1 WHERE id = $2 AND profile_id = $3`, date, id, pid) return err } diff --git a/apps/api/internal/repository/category.go b/apps/api/internal/repository/category.go index 4c2b49a..152a92a 100644 --- a/apps/api/internal/repository/category.go +++ b/apps/api/internal/repository/category.go @@ -7,6 +7,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" + "financeiro-carvalho/internal/middleware" "financeiro-carvalho/internal/model" ) @@ -28,10 +29,12 @@ func NewCategoryRepository(db *pgxpool.Pool) CategoryRepository { } func (r *categoryRepo) List(ctx context.Context) ([]model.Category, error) { + pid := middleware.ProfileIDFromCtx(ctx) rows, err := r.db.Query(ctx, ` SELECT id, name, color, is_default, is_tax, created_at, updated_at FROM categories - ORDER BY is_default DESC, name ASC`) + WHERE profile_id = $1 + ORDER BY is_default DESC, name ASC`, pid) if err != nil { return nil, err } @@ -49,10 +52,11 @@ func (r *categoryRepo) List(ctx context.Context) ([]model.Category, error) { } func (r *categoryRepo) GetByID(ctx context.Context, id int) (*model.Category, error) { + pid := middleware.ProfileIDFromCtx(ctx) var c model.Category err := r.db.QueryRow(ctx, ` SELECT id, name, color, is_default, is_tax, created_at, updated_at - FROM categories WHERE id = $1`, id). + FROM categories WHERE id = $1 AND profile_id = $2`, id, pid). Scan(&c.ID, &c.Name, &c.Color, &c.IsDefault, &c.IsTax, &c.CreatedAt, &c.UpdatedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound @@ -61,24 +65,26 @@ func (r *categoryRepo) GetByID(ctx context.Context, id int) (*model.Category, er } func (r *categoryRepo) Create(ctx context.Context, name, color string, isTax bool) (*model.Category, error) { + pid := middleware.ProfileIDFromCtx(ctx) var c model.Category err := r.db.QueryRow(ctx, ` - INSERT INTO categories (name, color, is_tax) - VALUES ($1, $2, $3) + INSERT INTO categories (name, color, is_tax, profile_id) + VALUES ($1, $2, $3, $4) RETURNING id, name, color, is_default, is_tax, created_at, updated_at`, - name, color, isTax). + name, color, isTax, pid). Scan(&c.ID, &c.Name, &c.Color, &c.IsDefault, &c.IsTax, &c.CreatedAt, &c.UpdatedAt) return &c, err } func (r *categoryRepo) Update(ctx context.Context, id int, name, color string, isTax bool) (*model.Category, error) { + pid := middleware.ProfileIDFromCtx(ctx) var c model.Category err := r.db.QueryRow(ctx, ` UPDATE categories SET name = $1, color = $2, is_tax = $3, updated_at = NOW() - WHERE id = $4 + WHERE id = $4 AND profile_id = $5 RETURNING id, name, color, is_default, is_tax, created_at, updated_at`, - name, color, isTax, id). + name, color, isTax, id, pid). Scan(&c.ID, &c.Name, &c.Color, &c.IsDefault, &c.IsTax, &c.CreatedAt, &c.UpdatedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound @@ -87,7 +93,8 @@ func (r *categoryRepo) Update(ctx context.Context, id int, name, color string, i } func (r *categoryRepo) Delete(ctx context.Context, id int) error { - tag, err := r.db.Exec(ctx, `DELETE FROM categories WHERE id = $1`, id) + pid := middleware.ProfileIDFromCtx(ctx) + tag, err := r.db.Exec(ctx, `DELETE FROM categories WHERE id = $1 AND profile_id = $2`, id, pid) if err != nil { return err } @@ -98,9 +105,10 @@ func (r *categoryRepo) Delete(ctx context.Context, id int) error { } func (r *categoryRepo) HasTransactions(ctx context.Context, id int) (bool, error) { + pid := middleware.ProfileIDFromCtx(ctx) var count int err := r.db.QueryRow(ctx, - `SELECT COUNT(*) FROM transactions WHERE category_id = $1`, id). + `SELECT COUNT(*) FROM transactions WHERE category_id = $1 AND profile_id = $2`, id, pid). Scan(&count) return count > 0, err } diff --git a/apps/api/internal/repository/credit_bill.go b/apps/api/internal/repository/credit_bill.go index a0e1e79..568a21d 100644 --- a/apps/api/internal/repository/credit_bill.go +++ b/apps/api/internal/repository/credit_bill.go @@ -7,6 +7,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" + "financeiro-carvalho/internal/middleware" "financeiro-carvalho/internal/model" ) @@ -24,6 +25,7 @@ func NewCreditBillRepository(db *pgxpool.Pool) CreditBillRepository { } func (r *creditBillRepo) ListByAccount(ctx context.Context, accountID int) ([]model.CreditBill, error) { + pid := middleware.ProfileIDFromCtx(ctx) rows, err := r.db.Query(ctx, ` SELECT cb.id, cb.account_id, a.name, @@ -31,14 +33,14 @@ func (r *creditBillRepo) ListByAccount(ctx context.Context, accountID int) ([]mo COALESCE(SUM(t.amount) FILTER (WHERE t.type = 'expense'), 0) AS total, cb.paid, cb.paid_at::text, cb.payment_account_id FROM credit_bills cb - JOIN accounts a ON a.id = cb.account_id + JOIN accounts a ON a.id = cb.account_id AND a.profile_id = $2 LEFT JOIN transactions t ON t.account_id = cb.account_id AND t.date >= cb.period_start AND t.date <= cb.period_end AND t.type = 'expense' WHERE cb.account_id = $1 GROUP BY cb.id, a.name ORDER BY cb.period_start DESC - `, accountID) + `, accountID, pid) if err != nil { return nil, err } @@ -55,6 +57,7 @@ func (r *creditBillRepo) ListByAccount(ctx context.Context, accountID int) ([]mo } func (r *creditBillRepo) GetCurrent(ctx context.Context, accountID int) (*model.CreditBill, error) { + pid := middleware.ProfileIDFromCtx(ctx) var b model.CreditBill err := r.db.QueryRow(ctx, ` SELECT @@ -63,7 +66,7 @@ func (r *creditBillRepo) GetCurrent(ctx context.Context, accountID int) (*model. COALESCE(SUM(t.amount) FILTER (WHERE t.type = 'expense'), 0) AS total, cb.paid, cb.paid_at::text, cb.payment_account_id FROM credit_bills cb - JOIN accounts a ON a.id = cb.account_id + JOIN accounts a ON a.id = cb.account_id AND a.profile_id = $2 LEFT JOIN transactions t ON t.account_id = cb.account_id AND t.date >= cb.period_start AND t.date <= cb.period_end AND t.type = 'expense' @@ -71,7 +74,7 @@ func (r *creditBillRepo) GetCurrent(ctx context.Context, accountID int) (*model. GROUP BY cb.id, a.name ORDER BY cb.period_start DESC LIMIT 1 - `, accountID).Scan(&b.ID, &b.AccountID, &b.AccountName, &b.PeriodStart, &b.PeriodEnd, &b.DueDate, &b.Total, &b.Paid, &b.PaidAt, &b.PaymentAccountID) + `, accountID, pid).Scan(&b.ID, &b.AccountID, &b.AccountName, &b.PeriodStart, &b.PeriodEnd, &b.DueDate, &b.Total, &b.Paid, &b.PaidAt, &b.PaymentAccountID) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } @@ -94,10 +97,12 @@ func (r *creditBillRepo) Upsert(ctx context.Context, in model.CreditBillInput) ( } func (r *creditBillRepo) MarkPaid(ctx context.Context, id int, paymentAccountID *int) error { + pid := middleware.ProfileIDFromCtx(ctx) _, err := r.db.Exec(ctx, ` - UPDATE credit_bills + UPDATE credit_bills cb SET paid = TRUE, paid_at = NOW(), payment_account_id = $2 - WHERE id = $1 - `, id, paymentAccountID) + FROM accounts a + WHERE cb.id = $1 AND cb.account_id = a.id AND a.profile_id = $3 + `, id, paymentAccountID, pid) return err } diff --git a/apps/api/internal/repository/dashboard.go b/apps/api/internal/repository/dashboard.go index 9a26be0..f08ac8c 100644 --- a/apps/api/internal/repository/dashboard.go +++ b/apps/api/internal/repository/dashboard.go @@ -5,6 +5,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" + "financeiro-carvalho/internal/middleware" "financeiro-carvalho/internal/model" ) @@ -17,18 +18,20 @@ func NewDashboardRepository(pool *pgxpool.Pool) *DashboardRepository { } func (r *DashboardRepository) MonthlySummary(ctx context.Context, month string) (income, expenses float64, err error) { + pid := middleware.ProfileIDFromCtx(ctx) row := r.pool.QueryRow(ctx, ` SELECT COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0), COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) FROM transactions - WHERE to_char(date, 'YYYY-MM') = $1 - `, month) + WHERE to_char(date, 'YYYY-MM') = $1 AND profile_id = $2 + `, month, pid) err = row.Scan(&income, &expenses) return } func (r *DashboardRepository) ByCategory(ctx context.Context, month string) ([]model.CategoryTotal, error) { + pid := middleware.ProfileIDFromCtx(ctx) rows, err := r.pool.Query(ctx, ` SELECT t.category_id, @@ -39,9 +42,10 @@ func (r *DashboardRepository) ByCategory(ctx context.Context, month string) ([]m LEFT JOIN categories c ON c.id = t.category_id WHERE to_char(t.date, 'YYYY-MM') = $1 AND t.type = 'expense' + AND t.profile_id = $2 GROUP BY t.category_id, c.name, c.color ORDER BY total DESC - `, month) + `, month, pid) if err != nil { return nil, err } @@ -59,6 +63,7 @@ func (r *DashboardRepository) ByCategory(ctx context.Context, month string) ([]m } func (r *DashboardRepository) MonthlyEvolution(ctx context.Context, month string) ([]model.MonthEvolution, error) { + pid := middleware.ProfileIDFromCtx(ctx) rows, err := r.pool.Query(ctx, ` SELECT to_char(m.ms, 'YYYY-MM') AS month, @@ -69,10 +74,10 @@ func (r *DashboardRepository) MonthlyEvolution(ctx context.Context, month string date_trunc('month', ($1 || '-01')::date), '1 month'::interval ) AS m(ms) - LEFT JOIN transactions t ON date_trunc('month', t.date) = m.ms + LEFT JOIN transactions t ON date_trunc('month', t.date) = m.ms AND t.profile_id = $2 GROUP BY m.ms ORDER BY m.ms - `, month) + `, month, pid) if err != nil { return nil, err } @@ -91,6 +96,7 @@ func (r *DashboardRepository) MonthlyEvolution(ctx context.Context, month string } func (r *DashboardRepository) TotalTaxes(ctx context.Context, month string) (float64, error) { + pid := middleware.ProfileIDFromCtx(ctx) var total float64 err := r.pool.QueryRow(ctx, ` SELECT COALESCE(SUM(t.amount), 0) @@ -99,11 +105,13 @@ func (r *DashboardRepository) TotalTaxes(ctx context.Context, month string) (flo WHERE to_char(t.date, 'YYYY-MM') = $1 AND t.type = 'expense' AND c.is_tax = TRUE - `, month).Scan(&total) + AND t.profile_id = $2 + `, month, pid).Scan(&total) return total, err } func (r *DashboardRepository) RecentTransactions(ctx context.Context, month string) ([]model.RecentTransaction, error) { + pid := middleware.ProfileIDFromCtx(ctx) rows, err := r.pool.Query(ctx, ` SELECT t.id, @@ -115,9 +123,10 @@ func (r *DashboardRepository) RecentTransactions(ctx context.Context, month stri FROM transactions t LEFT JOIN categories c ON c.id = t.category_id WHERE to_char(t.date, 'YYYY-MM') = $1 + AND t.profile_id = $2 ORDER BY t.date DESC, t.id DESC LIMIT 10 - `, month) + `, month, pid) if err != nil { return nil, err } diff --git a/apps/api/internal/repository/game.go b/apps/api/internal/repository/game.go index a24eaa8..d37d1bf 100644 --- a/apps/api/internal/repository/game.go +++ b/apps/api/internal/repository/game.go @@ -6,6 +6,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" + "financeiro-carvalho/internal/middleware" "financeiro-carvalho/internal/model" ) @@ -18,32 +19,36 @@ func NewGameRepository(pool *pgxpool.Pool) *GameRepository { } func (r *GameRepository) GetProfile(ctx context.Context) (*model.PlayerProfile, error) { + pid := middleware.ProfileIDFromCtx(ctx) var p model.PlayerProfile - err := r.pool.QueryRow(ctx, `SELECT id, level, xp, xp_to_next FROM player_profile LIMIT 1`). + err := r.pool.QueryRow(ctx, + `SELECT id, level, xp, xp_to_next FROM player_profile WHERE profile_id = $1`, pid). Scan(&p.ID, &p.Level, &p.XP, &p.XPToNext) return &p, err } -// xpThreshold returns the XP required to reach the given level. func xpThreshold(level int) int { return level * level * 100 } func (r *GameRepository) AddXP(ctx context.Context, eventType string, amount int, description string) (*model.PlayerProfile, error) { + pid := middleware.ProfileIDFromCtx(ctx) tx, err := r.pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) - _, err = tx.Exec(ctx, `INSERT INTO xp_events (event_type, xp_earned, description) VALUES ($1, $2, $3)`, - eventType, amount, description) + _, err = tx.Exec(ctx, + `INSERT INTO xp_events (event_type, xp_earned, description, profile_id) VALUES ($1, $2, $3, $4)`, + eventType, amount, description, pid) if err != nil { return nil, err } var p model.PlayerProfile - err = tx.QueryRow(ctx, `SELECT id, level, xp, xp_to_next FROM player_profile LIMIT 1`). + err = tx.QueryRow(ctx, + `SELECT id, level, xp, xp_to_next FROM player_profile WHERE profile_id = $1`, pid). Scan(&p.ID, &p.Level, &p.XP, &p.XPToNext) if err != nil { return nil, err @@ -56,7 +61,8 @@ func (r *GameRepository) AddXP(ctx context.Context, eventType string, amount int p.XPToNext = xpThreshold(p.Level) } - _, err = tx.Exec(ctx, `UPDATE player_profile SET level=$1, xp=$2, xp_to_next=$3 WHERE id=$4`, + _, err = tx.Exec(ctx, + `UPDATE player_profile SET level=$1, xp=$2, xp_to_next=$3 WHERE id=$4`, p.Level, p.XP, p.XPToNext, p.ID) if err != nil { return nil, err @@ -66,6 +72,7 @@ func (r *GameRepository) AddXP(ctx context.Context, eventType string, amount int } func (r *GameRepository) ListActiveQuests(ctx context.Context) ([]model.PlayerQuest, error) { + pid := middleware.ProfileIDFromCtx(ctx) now := time.Now() dailyPeriod := now.Format("2006-01-02") weeklyPeriod := now.Format("2006") + "-W" + now.Format("01") @@ -80,14 +87,14 @@ func (r *GameRepository) ListActiveQuests(ctx context.Context) ([]model.PlayerQu COALESCE(pq.completed, false), COALESCE(pq.claimed, false) FROM quests q - LEFT JOIN player_quests pq ON pq.quest_id = q.id AND pq.period = CASE + LEFT JOIN player_quests pq ON pq.quest_id = q.id AND pq.profile_id = $4 AND pq.period = CASE WHEN q.quest_type = 'daily' THEN $1 WHEN q.quest_type = 'weekly' THEN $2 WHEN q.quest_type = 'monthly' THEN $3 END WHERE q.active = true ORDER BY q.quest_type, q.id - `, dailyPeriod, weeklyPeriod, monthlyPeriod) + `, dailyPeriod, weeklyPeriod, monthlyPeriod, pid) if err != nil { return nil, err } @@ -118,16 +125,18 @@ func (r *GameRepository) ListActiveQuests(ctx context.Context) ([]model.PlayerQu } func (r *GameRepository) EnsurePlayerQuest(ctx context.Context, questID int, period string) (int, error) { + pid := middleware.ProfileIDFromCtx(ctx) var id int err := r.pool.QueryRow(ctx, ` - INSERT INTO player_quests (quest_id, period) VALUES ($1, $2) - ON CONFLICT (quest_id, period) DO UPDATE SET quest_id = EXCLUDED.quest_id + INSERT INTO player_quests (quest_id, period, profile_id) VALUES ($1, $2, $3) + ON CONFLICT (quest_id, period, profile_id) DO UPDATE SET quest_id = EXCLUDED.quest_id RETURNING id - `, questID, period).Scan(&id) + `, questID, period, pid).Scan(&id) return id, err } func (r *GameRepository) IncrementQuestCount(ctx context.Context, questType, period string, delta int) error { + pid := middleware.ProfileIDFromCtx(ctx) _, err := r.pool.Exec(ctx, ` UPDATE player_quests pq SET current_count = current_count + $1, @@ -137,13 +146,15 @@ func (r *GameRepository) IncrementQuestCount(ctx context.Context, questType, per WHERE pq.quest_id = q.id AND q.quest_type = $2 AND pq.period = $3 + AND pq.profile_id = $4 AND q.target_count IS NOT NULL AND NOT pq.claimed - `, delta, questType, period) + `, delta, questType, period, pid) return err } func (r *GameRepository) UpdateQuestPct(ctx context.Context, period string, pct float64) error { + pid := middleware.ProfileIDFromCtx(ctx) _, err := r.pool.Exec(ctx, ` UPDATE player_quests pq SET current_pct = $1, @@ -153,33 +164,36 @@ func (r *GameRepository) UpdateQuestPct(ctx context.Context, period string, pct WHERE pq.quest_id = q.id AND q.quest_type = 'monthly' AND pq.period = $2 + AND pq.profile_id = $3 AND q.target_pct IS NOT NULL AND NOT pq.claimed - `, pct, period) + `, pct, period, pid) return err } func (r *GameRepository) ClaimQuest(ctx context.Context, playerQuestID int) (int, error) { + pid := middleware.ProfileIDFromCtx(ctx) var xpReward int err := r.pool.QueryRow(ctx, ` UPDATE player_quests pq SET claimed = true FROM quests q - WHERE pq.quest_id = q.id AND pq.id = $1 AND pq.completed AND NOT pq.claimed + WHERE pq.quest_id = q.id AND pq.id = $1 AND pq.profile_id = $2 AND pq.completed AND NOT pq.claimed RETURNING q.xp_reward - `, playerQuestID).Scan(&xpReward) + `, playerQuestID, pid).Scan(&xpReward) return xpReward, err } func (r *GameRepository) ListAchievements(ctx context.Context) ([]model.Achievement, error) { + pid := middleware.ProfileIDFromCtx(ctx) rows, err := r.pool.Query(ctx, ` SELECT a.id, a.title, a.description, a.icon, a.xp_reward, a.unlock_condition, pa.id IS NOT NULL, COALESCE(pa.earned_at::text, '') FROM achievements a - LEFT JOIN player_achievements pa ON pa.achievement_id = a.id + LEFT JOIN player_achievements pa ON pa.achievement_id = a.id AND pa.profile_id = $1 ORDER BY pa.earned_at DESC NULLS LAST, a.id - `) + `, pid) if err != nil { return nil, err } @@ -197,38 +211,42 @@ func (r *GameRepository) ListAchievements(ctx context.Context) ([]model.Achievem } func (r *GameRepository) IsAchievementUnlocked(ctx context.Context, condition string) (bool, error) { + pid := middleware.ProfileIDFromCtx(ctx) var count int err := r.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM player_achievements pa JOIN achievements a ON a.id = pa.achievement_id - WHERE a.unlock_condition = $1 - `, condition).Scan(&count) + WHERE a.unlock_condition = $1 AND pa.profile_id = $2 + `, condition, pid).Scan(&count) return count > 0, err } func (r *GameRepository) UnlockAchievement(ctx context.Context, condition string) (int, error) { + pid := middleware.ProfileIDFromCtx(ctx) var xpReward int err := r.pool.QueryRow(ctx, ` - INSERT INTO player_achievements (achievement_id) - SELECT id FROM achievements WHERE unlock_condition = $1 - ON CONFLICT (achievement_id) DO NOTHING + INSERT INTO player_achievements (achievement_id, profile_id) + SELECT id, $2 FROM achievements WHERE unlock_condition = $1 + ON CONFLICT (achievement_id, profile_id) DO NOTHING RETURNING (SELECT xp_reward FROM achievements WHERE unlock_condition = $1) - `, condition).Scan(&xpReward) + `, condition, pid).Scan(&xpReward) return xpReward, err } func (r *GameRepository) ListCosmetics(ctx context.Context) ([]model.Cosmetic, error) { + pid := middleware.ProfileIDFromCtx(ctx) var level int - _ = r.pool.QueryRow(ctx, `SELECT level FROM player_profile LIMIT 1`).Scan(&level) + _ = r.pool.QueryRow(ctx, + `SELECT level FROM player_profile WHERE profile_id = $1`, pid).Scan(&level) rows, err := r.pool.Query(ctx, ` SELECT c.id, c.name, c.type, c.unlock_level, c.css_data::text, (c.unlock_level <= $1) AS unlocked, COALESCE(pc.equipped, false) FROM cosmetics c - LEFT JOIN player_cosmetics pc ON pc.cosmetic_id = c.id + LEFT JOIN player_cosmetics pc ON pc.cosmetic_id = c.id AND pc.profile_id = $2 ORDER BY c.unlock_level, c.id - `, level) + `, level, pid) if err != nil { return nil, err } @@ -246,26 +264,27 @@ func (r *GameRepository) ListCosmetics(ctx context.Context) ([]model.Cosmetic, e } func (r *GameRepository) EquipCosmetic(ctx context.Context, cosmeticID int) error { + pid := middleware.ProfileIDFromCtx(ctx) tx, err := r.pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) - // unequip same type _, err = tx.Exec(ctx, ` UPDATE player_cosmetics pc SET equipped = false FROM cosmetics c WHERE pc.cosmetic_id = c.id AND c.type = (SELECT type FROM cosmetics WHERE id = $1) - `, cosmeticID) + AND pc.profile_id = $2 + `, cosmeticID, pid) if err != nil { return err } _, err = tx.Exec(ctx, ` - INSERT INTO player_cosmetics (cosmetic_id, equipped) VALUES ($1, true) - ON CONFLICT (cosmetic_id) DO UPDATE SET equipped = true - `, cosmeticID) + INSERT INTO player_cosmetics (cosmetic_id, equipped, profile_id) VALUES ($1, true, $2) + ON CONFLICT (cosmetic_id, profile_id) DO UPDATE SET equipped = true + `, cosmeticID, pid) if err != nil { return err } @@ -273,10 +292,11 @@ func (r *GameRepository) EquipCosmetic(ctx context.Context, cosmeticID int) erro } func (r *GameRepository) UnlockCosmeticsForLevel(ctx context.Context, level int) error { + pid := middleware.ProfileIDFromCtx(ctx) _, err := r.pool.Exec(ctx, ` - INSERT INTO player_cosmetics (cosmetic_id) - SELECT id FROM cosmetics WHERE unlock_level <= $1 - ON CONFLICT (cosmetic_id) DO NOTHING - `, level) + INSERT INTO player_cosmetics (cosmetic_id, profile_id) + SELECT id, $2 FROM cosmetics WHERE unlock_level <= $1 + ON CONFLICT (cosmetic_id, profile_id) DO NOTHING + `, level, pid) return err } diff --git a/apps/api/internal/repository/recurring.go b/apps/api/internal/repository/recurring.go index 9a39310..4a3dec0 100644 --- a/apps/api/internal/repository/recurring.go +++ b/apps/api/internal/repository/recurring.go @@ -7,6 +7,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" + "financeiro-carvalho/internal/middleware" "financeiro-carvalho/internal/model" ) @@ -31,9 +32,10 @@ func NewRecurringRepository(db *pgxpool.Pool) RecurringRepository { } func (r *recurringRepo) List(ctx context.Context) ([]model.RecurringExpense, error) { + pid := middleware.ProfileIDFromCtx(ctx) rows, err := r.db.Query(ctx, ` SELECT id, name, expected_amount, day_of_month, category_id, type, active, created_at, updated_at - FROM recurring_expenses ORDER BY name ASC`) + FROM recurring_expenses WHERE profile_id = $1 ORDER BY name ASC`, pid) if err != nil { return nil, err } @@ -50,10 +52,11 @@ func (r *recurringRepo) List(ctx context.Context) ([]model.RecurringExpense, err } func (r *recurringRepo) GetByID(ctx context.Context, id int) (*model.RecurringExpense, error) { + pid := middleware.ProfileIDFromCtx(ctx) var re model.RecurringExpense err := r.db.QueryRow(ctx, ` SELECT id, name, expected_amount, day_of_month, category_id, type, active, created_at, updated_at - FROM recurring_expenses WHERE id = $1`, id). + FROM recurring_expenses WHERE id = $1 AND profile_id = $2`, id, pid). Scan(&re.ID, &re.Name, &re.ExpectedAmount, &re.DayOfMonth, &re.CategoryID, &re.Type, &re.Active, &re.CreatedAt, &re.UpdatedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound @@ -62,21 +65,23 @@ func (r *recurringRepo) GetByID(ctx context.Context, id int) (*model.RecurringEx } func (r *recurringRepo) Create(ctx context.Context, in model.RecurringInput) (*model.RecurringExpense, error) { + pid := middleware.ProfileIDFromCtx(ctx) t := in.Type if t == "" { t = "expense" } var re model.RecurringExpense err := r.db.QueryRow(ctx, ` - INSERT INTO recurring_expenses (name, expected_amount, day_of_month, category_id, type) - VALUES ($1, $2, $3, $4, $5) + INSERT INTO recurring_expenses (name, expected_amount, day_of_month, category_id, type, profile_id) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, name, expected_amount, day_of_month, category_id, type, active, created_at, updated_at`, - in.Name, in.ExpectedAmount, in.DayOfMonth, in.CategoryID, t). + in.Name, in.ExpectedAmount, in.DayOfMonth, in.CategoryID, t, pid). Scan(&re.ID, &re.Name, &re.ExpectedAmount, &re.DayOfMonth, &re.CategoryID, &re.Type, &re.Active, &re.CreatedAt, &re.UpdatedAt) return &re, err } func (r *recurringRepo) Update(ctx context.Context, id int, in model.RecurringInput) (*model.RecurringExpense, error) { + pid := middleware.ProfileIDFromCtx(ctx) t := in.Type if t == "" { t = "expense" @@ -85,9 +90,9 @@ func (r *recurringRepo) Update(ctx context.Context, id int, in model.RecurringIn err := r.db.QueryRow(ctx, ` UPDATE recurring_expenses SET name = $1, expected_amount = $2, day_of_month = $3, category_id = $4, type = $5, updated_at = NOW() - WHERE id = $6 + WHERE id = $6 AND profile_id = $7 RETURNING id, name, expected_amount, day_of_month, category_id, type, active, created_at, updated_at`, - in.Name, in.ExpectedAmount, in.DayOfMonth, in.CategoryID, t, id). + in.Name, in.ExpectedAmount, in.DayOfMonth, in.CategoryID, t, id, pid). Scan(&re.ID, &re.Name, &re.ExpectedAmount, &re.DayOfMonth, &re.CategoryID, &re.Type, &re.Active, &re.CreatedAt, &re.UpdatedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound @@ -96,7 +101,8 @@ func (r *recurringRepo) Update(ctx context.Context, id int, in model.RecurringIn } func (r *recurringRepo) Delete(ctx context.Context, id int) error { - tag, err := r.db.Exec(ctx, `DELETE FROM recurring_expenses WHERE id = $1`, id) + pid := middleware.ProfileIDFromCtx(ctx) + tag, err := r.db.Exec(ctx, `DELETE FROM recurring_expenses WHERE id = $1 AND profile_id = $2`, id, pid) if err != nil { return err } @@ -106,6 +112,10 @@ func (r *recurringRepo) Delete(ctx context.Context, id int) error { return nil } +// IsIgnored, Ignore, Unignore, IsLate, MarkLate, UnmarkLate operate on recurring_ignores/recurring_late +// which are scoped via FK to recurring_expenses (already profile-scoped). The caller (service) verifies +// ownership via GetByID before reaching these methods. + func (r *recurringRepo) IsIgnored(ctx context.Context, id int, month string) (bool, string, error) { var reason string err := r.db.QueryRow(ctx, diff --git a/apps/api/internal/repository/transaction.go b/apps/api/internal/repository/transaction.go index 0386462..92f7c4d 100644 --- a/apps/api/internal/repository/transaction.go +++ b/apps/api/internal/repository/transaction.go @@ -6,6 +6,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" + "financeiro-carvalho/internal/middleware" "financeiro-carvalho/internal/model" ) @@ -22,17 +23,17 @@ func NewTransactionRepository(db *pgxpool.Pool) TransactionRepository { return &transactionRepo{db: db} } -// normalizeDesc lowercases and collapses whitespace for fuzzy dedup. func normalizeDesc(s string) string { return strings.ToLower(strings.Join(strings.Fields(s), " ")) } func (r *transactionRepo) IsDuplicate(ctx context.Context, date, description string, amount float64) (bool, error) { + pid := middleware.ProfileIDFromCtx(ctx) var count int err := r.db.QueryRow(ctx, ` SELECT COUNT(*) FROM transactions - WHERE date = $1 AND amount = $2 AND LOWER(description) = $3`, - date, amount, normalizeDesc(description)).Scan(&count) + WHERE date = $1 AND amount = $2 AND LOWER(description) = $3 AND profile_id = $4`, + date, amount, normalizeDesc(description), pid).Scan(&count) return count > 0, err } @@ -40,13 +41,16 @@ func (r *transactionRepo) IsExternalIDKnown(ctx context.Context, externalID stri if externalID == "" { return false, nil } + pid := middleware.ProfileIDFromCtx(ctx) var count int err := r.db.QueryRow(ctx, ` - SELECT COUNT(*) FROM transactions WHERE external_id = $1`, externalID).Scan(&count) + SELECT COUNT(*) FROM transactions WHERE external_id = $1 AND profile_id = $2`, + externalID, pid).Scan(&count) return count > 0, err } func (r *transactionRepo) BulkInsert(ctx context.Context, rows []model.ImportRow) (int, error) { + pid := middleware.ProfileIDFromCtx(ctx) tx, err := r.db.Begin(ctx) if err != nil { return 0, err @@ -67,9 +71,9 @@ func (r *transactionRepo) BulkInsert(ctx context.Context, rows []model.ImportRow origDate = &row.OriginalDate } _, err := tx.Exec(ctx, ` - INSERT INTO transactions (date, original_date, amount, description, type, source, external_id, category_id) - VALUES ($1, $2, $3, $4, $5, 'import', $6, $7)`, - row.Date, origDate, row.Amount, row.Description, row.Type, extID, row.CategoryID) + INSERT INTO transactions (date, original_date, amount, description, type, source, external_id, category_id, profile_id) + VALUES ($1, $2, $3, $4, $5, 'import', $6, $7, $8)`, + row.Date, origDate, row.Amount, row.Description, row.Type, extID, row.CategoryID, pid) if err != nil { return count, err } diff --git a/apps/api/internal/repository/transaction_manual.go b/apps/api/internal/repository/transaction_manual.go index afa8b37..7520ba6 100644 --- a/apps/api/internal/repository/transaction_manual.go +++ b/apps/api/internal/repository/transaction_manual.go @@ -7,6 +7,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" + "financeiro-carvalho/internal/middleware" "financeiro-carvalho/internal/model" ) @@ -17,7 +18,6 @@ type ManualTransactionRepository interface { Update(ctx context.Context, t model.Transaction) (*model.Transaction, error) Delete(ctx context.Context, id int) error DeleteByMonth(ctx context.Context, month string) (int, error) - // HasMatchingTransaction checks if a category has a transaction of the given type in the given month. HasMatchingTransaction(ctx context.Context, categoryID *int, month string, amount float64, txType string) (bool, error) } @@ -28,12 +28,13 @@ func NewManualTransactionRepository(db *pgxpool.Pool) ManualTransactionRepositor } func (r *manualTxRepo) List(ctx context.Context, month string) ([]model.Transaction, error) { + pid := middleware.ProfileIDFromCtx(ctx) query := ` SELECT id, date::text, amount, description, type, source, category_id, account_id, created_at, updated_at - FROM transactions` - args := []any{} + FROM transactions WHERE profile_id = $1` + args := []any{pid} if month != "" { - query += ` WHERE TO_CHAR(date, 'YYYY-MM') = $1` + query += ` AND TO_CHAR(date, 'YYYY-MM') = $2` args = append(args, month) } query += ` ORDER BY date DESC, id DESC` @@ -56,10 +57,11 @@ func (r *manualTxRepo) List(ctx context.Context, month string) ([]model.Transact } func (r *manualTxRepo) GetByID(ctx context.Context, id int) (*model.Transaction, error) { + pid := middleware.ProfileIDFromCtx(ctx) var t model.Transaction err := r.db.QueryRow(ctx, ` SELECT id, date::text, amount, description, type, source, category_id, account_id, created_at, updated_at - FROM transactions WHERE id = $1`, id). + FROM transactions WHERE id = $1 AND profile_id = $2`, id, pid). Scan(&t.ID, &t.Date, &t.Amount, &t.Description, &t.Type, &t.Source, &t.CategoryID, &t.AccountID, &t.CreatedAt, &t.UpdatedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound @@ -68,24 +70,26 @@ func (r *manualTxRepo) GetByID(ctx context.Context, id int) (*model.Transaction, } func (r *manualTxRepo) Create(ctx context.Context, t model.Transaction) (*model.Transaction, error) { + pid := middleware.ProfileIDFromCtx(ctx) var out model.Transaction err := r.db.QueryRow(ctx, ` - INSERT INTO transactions (date, amount, description, type, source, category_id, account_id) - VALUES ($1, $2, $3, $4, 'manual', $5, $6) + INSERT INTO transactions (date, amount, description, type, source, category_id, account_id, profile_id) + VALUES ($1, $2, $3, $4, 'manual', $5, $6, $7) RETURNING id, date::text, amount, description, type, source, category_id, account_id, created_at, updated_at`, - t.Date, t.Amount, t.Description, t.Type, t.CategoryID, t.AccountID). + t.Date, t.Amount, t.Description, t.Type, t.CategoryID, t.AccountID, pid). Scan(&out.ID, &out.Date, &out.Amount, &out.Description, &out.Type, &out.Source, &out.CategoryID, &out.AccountID, &out.CreatedAt, &out.UpdatedAt) return &out, err } func (r *manualTxRepo) Update(ctx context.Context, t model.Transaction) (*model.Transaction, error) { + pid := middleware.ProfileIDFromCtx(ctx) var out model.Transaction err := r.db.QueryRow(ctx, ` UPDATE transactions SET date=$1, amount=$2, description=$3, type=$4, category_id=$5, account_id=$6, updated_at=NOW() - WHERE id=$7 AND source='manual' + WHERE id=$7 AND source='manual' AND profile_id=$8 RETURNING id, date::text, amount, description, type, source, category_id, account_id, created_at, updated_at`, - t.Date, t.Amount, t.Description, t.Type, t.CategoryID, t.AccountID, t.ID). + t.Date, t.Amount, t.Description, t.Type, t.CategoryID, t.AccountID, t.ID, pid). Scan(&out.ID, &out.Date, &out.Amount, &out.Description, &out.Type, &out.Source, &out.CategoryID, &out.AccountID, &out.CreatedAt, &out.UpdatedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound @@ -94,7 +98,8 @@ func (r *manualTxRepo) Update(ctx context.Context, t model.Transaction) (*model. } func (r *manualTxRepo) Delete(ctx context.Context, id int) error { - tag, err := r.db.Exec(ctx, `DELETE FROM transactions WHERE id = $1`, id) + pid := middleware.ProfileIDFromCtx(ctx) + tag, err := r.db.Exec(ctx, `DELETE FROM transactions WHERE id = $1 AND profile_id = $2`, id, pid) if err != nil { return err } @@ -105,7 +110,9 @@ func (r *manualTxRepo) Delete(ctx context.Context, id int) error { } func (r *manualTxRepo) DeleteByMonth(ctx context.Context, month string) (int, error) { - tag, err := r.db.Exec(ctx, `DELETE FROM transactions WHERE TO_CHAR(date, 'YYYY-MM') = $1`, month) + pid := middleware.ProfileIDFromCtx(ctx) + tag, err := r.db.Exec(ctx, + `DELETE FROM transactions WHERE TO_CHAR(date, 'YYYY-MM') = $1 AND profile_id = $2`, month, pid) if err != nil { return 0, err } @@ -114,16 +121,17 @@ func (r *manualTxRepo) DeleteByMonth(ctx context.Context, month string) (int, er func (r *manualTxRepo) HasMatchingTransaction(ctx context.Context, categoryID *int, month string, amount float64, txType string) (bool, error) { if categoryID == nil { - // No category set — cannot auto-match, always report as uncovered return false, nil } + pid := middleware.ProfileIDFromCtx(ctx) var count int err := r.db.QueryRow(ctx, ` SELECT COUNT(*) FROM transactions WHERE category_id = $1 AND TO_CHAR(date, 'YYYY-MM') = $2 AND type = $3 - AND amount BETWEEN $4 * 0.9 AND $4 * 1.1`, - *categoryID, month, txType, amount).Scan(&count) + AND amount BETWEEN $4 * 0.9 AND $4 * 1.1 + AND profile_id = $5`, + *categoryID, month, txType, amount, pid).Scan(&count) return count > 0, err } diff --git a/apps/web/src/components/AppHud.vue b/apps/web/src/components/AppHud.vue index a834f0a..1f82e3e 100644 --- a/apps/web/src/components/AppHud.vue +++ b/apps/web/src/components/AppHud.vue @@ -50,7 +50,8 @@ const navItems = [ { to: '/contas', label: 'CONTAS' }, { to: '/personagem', label: 'PERS.' }, { to: '/recorrencias', label: 'REC.' }, - { to: '/categorias', label: 'CFG' }, + { to: '/categorias', label: 'CAT.' }, + { to: '/configuracoes', label: 'CFG' }, ] diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index 47a4ee1..f278e0e 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -48,7 +48,8 @@ const router = createRouter({ }, { path: '/configuracoes', - redirect: '/categorias', + name: 'settings', + component: () => import('../views/SettingsView.vue'), }, ], }) diff --git a/apps/web/src/services/api.ts b/apps/web/src/services/api.ts index 0ebf111..37e83dd 100644 --- a/apps/web/src/services/api.ts +++ b/apps/web/src/services/api.ts @@ -2,13 +2,24 @@ import router from '@/router' const BASE = '/api' +function getToken(): string | null { + return localStorage.getItem('fc_token') +} + async function request(method: string, path: string, body?: unknown): Promise { + const headers: Record = {} + if (body) headers['Content-Type'] = 'application/json' + const token = getToken() + if (token) headers['Authorization'] = `Bearer ${token}` + const res = await fetch(`${BASE}${path}`, { method, - headers: body ? { 'Content-Type': 'application/json' } : {}, + headers, body: body ? JSON.stringify(body) : undefined, }) if (res.status === 401) { + localStorage.removeItem('fc_token') + localStorage.removeItem('fc_profile') router.push({ name: 'login' }) throw new Error('Sessão expirada') } diff --git a/apps/web/src/stores/auth.ts b/apps/web/src/stores/auth.ts index 2ab4935..1422c3f 100644 --- a/apps/web/src/stores/auth.ts +++ b/apps/web/src/stores/auth.ts @@ -1,41 +1,91 @@ import { defineStore } from 'pinia' -import { ref } from 'vue' +import { ref, computed } from 'vue' + +const TOKEN_KEY = 'fc_token' +const PROFILE_KEY = 'fc_profile' + +interface Profile { + id: number + name: string +} export const useAuthStore = defineStore('auth', () => { - const checked = ref(false) - const authenticated = ref(false) + const token = ref(localStorage.getItem(TOKEN_KEY)) + const profile = ref( + (() => { + try { + const raw = localStorage.getItem(PROFILE_KEY) + return raw ? (JSON.parse(raw) as Profile) : null + } catch { + return null + } + })(), + ) + + const isAuthenticated = computed(() => token.value !== null && profile.value !== null) async function check(): Promise { - if (checked.value) return authenticated.value + if (!token.value) return false try { - const res = await fetch('/api/auth/me') - authenticated.value = res.status === 204 + const res = await fetch('/api/auth/me', { + headers: { Authorization: `Bearer ${token.value}` }, + }) + if (res.ok) { + const data = await res.json() + profile.value = data + return true + } } catch { - authenticated.value = false + // network error — keep cached state + return isAuthenticated.value } - checked.value = true - return authenticated.value + _clear() + return false } - async function login(username: string, password: string): Promise { - const res = await fetch('/api/login', { + async function login(name: string, password: string): Promise { + const res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, password }), + body: JSON.stringify({ name, password }), }) if (!res.ok) { const data = await res.json() throw new Error(data.error ?? 'Erro ao fazer login') } - authenticated.value = true - checked.value = true + const data = await res.json() + token.value = data.token + profile.value = data.profile + localStorage.setItem(TOKEN_KEY, data.token) + localStorage.setItem(PROFILE_KEY, JSON.stringify(data.profile)) } async function logout(): Promise { - await fetch('/api/logout', { method: 'POST' }) - authenticated.value = false - checked.value = false + await fetch('/api/logout', { method: 'POST' }).catch(() => {}) + _clear() } - return { checked, authenticated, check, login, logout } + async function changePassword(currentPassword: string, newPassword: string): Promise { + const res = await fetch('/api/auth/change-password', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token.value}`, + }, + body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }), + }) + if (!res.ok) { + const data = await res.json() + throw new Error(data.error ?? 'Erro ao trocar senha') + } + } + + function _clear() { + token.value = null + profile.value = null + localStorage.removeItem(TOKEN_KEY) + localStorage.removeItem(PROFILE_KEY) + } + + return { token, profile, isAuthenticated, check, login, logout, changePassword } }) diff --git a/apps/web/src/views/LoginView.vue b/apps/web/src/views/LoginView.vue index 238d27d..a83e485 100644 --- a/apps/web/src/views/LoginView.vue +++ b/apps/web/src/views/LoginView.vue @@ -6,7 +6,7 @@ import { useAuthStore } from '@/stores/auth' const router = useRouter() const auth = useAuthStore() -const username = ref('') +const name = ref('') const password = ref('') const error = ref('') const loading = ref(false) @@ -15,7 +15,7 @@ async function submit() { error.value = '' loading.value = true try { - await auth.login(username.value, password.value) + await auth.login(name.value, password.value) router.push('/') } catch (e: any) { error.value = e.message ?? 'Erro desconhecido' @@ -37,7 +37,7 @@ async function submit() {
-import { ref, onMounted } from 'vue' -import { useRecurringStore } from '@/stores/recurring' -import { useCategoriesStore } from '@/stores/categories' +import { ref } from 'vue' +import { useRouter } from 'vue-router' +import { useAuthStore } from '@/stores/auth' import NeonPanel from '@/components/NeonPanel.vue' -const store = useRecurringStore() -const catStore = useCategoriesStore() +const router = useRouter() +const auth = useAuthStore() -onMounted(() => { - store.fetchAll() - catStore.fetchAll() -}) +const currentPassword = ref('') +const newPassword = ref('') +const confirmPassword = ref('') +const pwError = ref(null) +const pwSuccess = ref(false) +const pwLoading = ref(false) -const blank = () => ({ name: '', expected_amount: 0, day_of_month: 1, category_id: null as number | null }) -const form = ref(blank()) -const editId = ref(null) -const amountRaw = ref('') -const formError = ref(null) - -function parseAmount(s: string) { - return parseFloat(s.replace(/\./g, '').replace(',', '.')) || 0 -} - -function startEdit(id: number) { - const item = store.items.find((x) => x.id === id) - if (!item) return - editId.value = id - form.value = { name: item.name, expected_amount: item.expected_amount, day_of_month: item.day_of_month, category_id: item.category_id } - amountRaw.value = item.expected_amount.toLocaleString('pt-BR', { minimumFractionDigits: 2 }) - formError.value = null -} - -function cancelEdit() { - editId.value = null - form.value = blank() - amountRaw.value = '' - formError.value = null -} - -async function submit() { - formError.value = null - form.value.expected_amount = parseAmount(amountRaw.value) +async function changePassword() { + pwError.value = null + pwSuccess.value = false + if (newPassword.value.length < 8) { + pwError.value = 'Nova senha deve ter pelo menos 8 caracteres' + return + } + if (newPassword.value !== confirmPassword.value) { + pwError.value = 'Senhas não conferem' + return + } + pwLoading.value = true try { - if (editId.value !== null) { - await store.update(editId.value, form.value) - cancelEdit() - } else { - await store.create(form.value) - form.value = blank() - amountRaw.value = '' - } + await auth.changePassword(currentPassword.value, newPassword.value) + pwSuccess.value = true + currentPassword.value = '' + newPassword.value = '' + confirmPassword.value = '' } catch (e: any) { - formError.value = e.message + pwError.value = e.message ?? 'Erro ao trocar senha' + } finally { + pwLoading.value = false } } -async function remove(id: number, name: string) { - if (!confirm(`Excluir recorrência "${name}"?`)) return - await store.remove(id) -} - -function catName(id: number | null) { - if (!id) return '—' - return catStore.categories.find((c) => c.id === id)?.name ?? '—' -} - -function fmt(v: number) { - return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }) +async function logout() { + await auth.logout() + router.push({ name: 'login' }) } diff --git a/docker-compose.yml b/docker-compose.yml index 01c0363..a34cfc1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,8 +20,7 @@ services: environment: DATABASE_URL: postgres://financeiro:${POSTGRES_PASSWORD:-financeiro}@postgres:5432/financeiro?sslmode=disable PORT: 8080 - APP_USERNAME: ${APP_USERNAME} - APP_PASSWORD: ${APP_PASSWORD} + JWT_SECRET: ${JWT_SECRET:-change-me-in-production} depends_on: postgres: condition: service_healthy diff --git a/neon-handoff/README.md b/neon-handoff/README.md new file mode 100644 index 0000000..a61b193 --- /dev/null +++ b/neon-handoff/README.md @@ -0,0 +1,164 @@ +# Financeiro Carvalho · Handoff (Arcade Neon) + +Pacote de design pronto pra ser entregue ao Claude Code / Cursor / Aider implementar. +Direção visual escolhida: **Arcade Neon**. + +## O que tem aqui + +``` +neon-handoff/ +├── STYLE_GUIDE.md ← leia primeiro +├── preview.html ← referência visual interativa (abra num servidor local) +├── tokens.css ← variáveis CSS + reset + classes utilitárias +├── components/ ← Vue 3 SFCs de referência +│ ├── NeonPanel.vue +│ ├── XPBar.vue +│ ├── HUDStat.vue +│ └── CharacterSprite.vue +├── sprites/ +│ ├── sprite-data.json ← grade raw 17×24 + lista de cosméticos +│ ├── icons.json ← heart, coin, star, anchor (8×8 cada) +│ ├── manoel-default.png (e 4x) +│ ├── manoel-lv01.png (e 4x) · cabelo castanho, camisa azul, calça caqui +│ ├── manoel-lv03-bone-verolme.png · boné azul +│ ├── manoel-lv05-wingspan.png · camisa verde +│ ├── manoel-lv07-chapeu-sol.png · look "equipado" atual +│ ├── manoel-lv10-anti-vento.png · casaco roxo +│ ├── manoel-lv15-capitao.png · trajado de capitão (vermelho/preto) +│ └── manoel-sheet-4x.png · todos lado a lado pra referência +└── README.md ← este arquivo +``` + +## Como integrar no apps/web + +1. Copie `tokens.css` para `src/styles/tokens.css`. Importe **uma vez** em `src/main.ts`: + + ```ts + import './styles/tokens.css' + ``` + +2. Copie `components/*.vue` para `src/components/`. Eles já consomem `var(--fc-*)`. + +3. Copie `sprites/sprite-data.json` e `sprites/icons.json` pra `src/assets/`. + Garanta que `tsconfig.json` tenha `"resolveJsonModule": true` (Vite default já tem). + +4. Embrulhe o `` em `
` — é isso que aplica o fundo + scanlines + grid. + + ```vue + + + ``` + +5. As 8 rotas do PRD (`/`, `/transacoes`, `/importar`, `/categorias`, `/recorrencias`, + `/contas`, `/personagem`, `/configuracoes`) usam o mesmo header HUD. Veja + `STYLE_GUIDE.md` §3.6 pro shape do componente. + +## Sprites · uso + +### Renderizar dinâmico (recomendado) + +`` lê `sprite-data.json` e aceita `theme` como prop. Cosméticos +equipados são merged no store (Pinia) e passados como `theme`. + +```vue + +``` + +Exemplo de store getter: + +```ts +// stores/game.ts +const equippedTheme = computed(() => { + return cosmetics.value + .filter(c => c.equipped) + .reduce((acc, c) => ({ ...acc, ...c.colors }), {} as Record) +}) +``` + +### Usar PNG estático + +Útil pra previews em página de loja (`/personagem` → seção "cosméticos"): + +```html +Boné Verolme +``` + +Sempre `image-rendering: pixelated;` no CSS pra não suavizar. + +### Regenerar PNGs + +`sprites/sprite-data.json` é a fonte da verdade. Se você ajustar uma cor de +cosmético lá, regenere os PNGs com um script Node: + +```js +// scripts/build-sprites.mjs +import { createCanvas } from '@napi-rs/canvas' // ou node-canvas +import fs from 'node:fs/promises' + +const data = JSON.parse(await fs.readFile('src/assets/sprite-data.json', 'utf8')) +// ... mesmo render do PixelSprite acima, salva PNG por cosmético +``` + +Ou peça pro Claude Code regenerar — o algoritmo é trivial (loop pela grade, +fillRect por célula com a cor do slot). + +## Princípios para o Claude implementar + +Por ordem de importância: + +1. **HUD persistente em todas as rotas.** Não use sidebar — o app é um HUD de jogo. +2. **Nunca use hex direto.** Sempre `var(--fc-*)`. Se faltar token, adicione-o ao + `tokens.css` e documente no `STYLE_GUIDE.md` §1.1. +3. **Press Start 2P pra label e número. JetBrains Mono pra dado. Inter pra prosa.** + Press Start 2P sempre em maiúscula. +4. **Glow é raro.** Só nos números heroicos (47%) e no painel principal de cada + rota. Se cada card brilha, nada brilha. +5. **Verde/vermelho são binários** — meta sim / meta não. Não use vermelho pra valor + negativo (transação) — só pra alerta. +6. **Cada animação tem propósito.** Ver §6 do guia. +7. **Mobile vira bottom-nav.** Não tente espremer a HUD horizontal num iPhone. + +## Telas a implementar + +Conforme `STYLE_GUIDE.md` + screens visíveis em `preview.html` (Arcade · 01): + +- [ ] `/` Dashboard (hero poupado, categorias, histórico, transações, recorrências, quests, patrimônio, widget personagem) +- [ ] `/transacoes` lista + CRUD +- [ ] `/importar` upload OFX/CSV + preview de deduplicação +- [ ] `/categorias` CRUD com pixel-icon picker +- [ ] `/recorrencias` lista mensal com status (paid / due / missing) +- [ ] `/contas` cards de conta + patrimônio total +- [ ] `/personagem` painel RPG completo +- [ ] `/configuracoes` formulário simples (perfil, meta %, prefs visuais) + +Para qualquer tela nova: comece copiando a estrutura do dashboard, +substitua o conteúdo dos painéis, mantenha a HUD. + +## Componentes adicionais que faltam + +Esses não estão neste pacote — implemente seguindo os tokens já definidos: + +- `AppHud.vue` (cabeçalho persistente) +- `BottomNav.vue` (mobile) +- `TransactionRow.vue` +- `QuestCard.vue` +- `CategoryBar.vue` +- `AchievementCell.vue` +- `LevelUpModal.vue` (overlay celebratório no level up) +- `MonthSwitcher.vue` (‹ MAIO · 2026 ›) + +## Tom de voz + +Português brasileiro, direto, com vocabulário de jogo. Não é app corporativo. +Ver `STYLE_GUIDE.md` §8. + +--- + +Qualquer dúvida sobre **intenção visual** (não implementação), volte ao +`STYLE_GUIDE.md` ou ao `preview.html`. A página de design original do projeto +(canvas com 4 direções) também segue acessível pra comparação. diff --git a/neon-handoff/STYLE_GUIDE.md b/neon-handoff/STYLE_GUIDE.md new file mode 100644 index 0000000..9dade3d --- /dev/null +++ b/neon-handoff/STYLE_GUIDE.md @@ -0,0 +1,640 @@ +# Financeiro Carvalho · Style Guide + +**Direção:** Arcade Neon +**Stack alvo:** Vue 3 + Vite + TypeScript + Pinia +**Princípio:** finanças vestindo roupa de fliperama. Fundo escuro, glow neon nos números que importam, fonte pixel nos labels e mono nos dados. HUD sempre presente. + +--- + +## 1. Tokens + +Copie `tokens.css` na raiz do `src/styles/` e importe em `main.ts`. Todas as variáveis CSS abaixo são o contrato — componentes consomem `var(--fc-*)`, nunca hex direto. + +### 1.1 Cores + +| Token | Valor | Uso | +|---|---|---| +| `--fc-bg` | `#0a0418` | Fundo base do app | +| `--fc-bg-raised` | `#150827` | Card / panel topo do gradiente | +| `--fc-bg-panel` | `#1c0d33` | Card / panel base do gradiente | +| `--fc-panel-edge` | `#2d1857` | Borda padrão de painéis | +| `--fc-line` | `#3a1f6e` | Grid de fundo, dividers internos | +| `--fc-text` | `#f4eaff` | Texto principal (off-white lilás) | +| `--fc-text-dim` | `#8b75b8` | Texto secundário, labels | +| `--fc-accent` | `#ff2d95` | Magenta · destaques quentes, alertas suaves, "diária" | +| `--fc-accent-2` | `#00f0ff` | Ciano · links, navegação, "semanal" | +| `--fc-accent-3` | `#a855f7` | Roxo · CTA padrão, painéis com glow | +| `--fc-gold` | `#ffd84d` | XP, conquistas, "mensal" | +| `--fc-green` | `#39ff7a` | Meta atingida, valores positivos | +| `--fc-red` | `#ff3b6b` | Meta falhou, alerta crítico, recorrência ausente | + +**Regra de uso:** +- **Magenta** é a cor do "agora" — botões primários secundários, status do dia. +- **Ciano** é a cor da informação fria — XP, links, navegação ativa. +- **Roxo** é estrutura — painel em destaque, hover, foco. Quase sempre tem `box-shadow: 0 0 24px rgba(168,85,247,.25)`. +- **Dourado** é recompensa — qualquer coisa relacionada a XP, nível, conquista. +- **Verde/vermelho** ficam reservados pro binário "meta sim/meta não". + +### 1.2 Tipografia + +Importe via Google Fonts no ``: + +```html + + + +``` + +| Família | Uso | Tamanhos comuns | +|---|---|---| +| `Press Start 2P` | Labels, títulos de painel, números heroicos (47%), nav | 7px, 8px, 9px, 14px, 22px, 40px, 56px | +| `JetBrains Mono` | Valores numéricos (R$), datas, IDs | 10px, 11px, 12px, 16px | +| `Inter` | Fallback para corpo de texto longo (descrições de quest, etc.) | 13px, 14px | + +Classes utilitárias: + +```css +.fc-pixel { font-family: 'Press Start 2P', monospace; letter-spacing: .02em; } +.fc-mono { font-family: 'JetBrains Mono', monospace; } +.fc-body { font-family: 'Inter', system-ui, sans-serif; } +``` + +**Regra:** nunca usar Inter pra número. Nunca usar Press Start 2P pra parágrafo (>2 linhas). Press Start 2P em maiúscula sempre. + +### 1.3 Tamanhos e espaçamento + +| Token | Valor | Uso | +|---|---|---| +| `--fc-radius-sm` | `2px` | Botões, chips | +| `--fc-radius` | `4px` | Painéis | +| `--fc-space-1` | `4px` | Gap minúsculo | +| `--fc-space-2` | `8px` | Gap entre chips | +| `--fc-space-3` | `12px` | Padding interno de card pequeno | +| `--fc-space-4` | `16px` | Padding/gap padrão | +| `--fc-space-5` | `24px` | Padding de painel grande | + +Página tem `padding: 24px` nas bordas. Grids usam `gap: 16px` entre cards. + +### 1.4 Sombras e glow + +Sombra padrão de painel: +```css +box-shadow: + inset 0 1px 0 rgba(255,255,255,.05), + 0 0 0 1px rgba(0,0,0,.4), + 0 8px 24px rgba(0,0,0,.4); +``` + +Painel com glow (destaque do hero): +```css +box-shadow: + inset 0 1px 0 rgba(255,255,255,.05), + 0 0 0 1px var(--fc-accent-3), + 0 0 24px rgba(168,85,247,.25), + 0 8px 24px rgba(0,0,0,.5); +border-color: var(--fc-accent-3); +``` + +Glow em texto (números heroicos): +```css +text-shadow: 0 0 8px rgba(var-rgb, .53); +/* ex: text-shadow: 0 0 8px #39ff7a88; */ +``` + +Glow em ícone/cor: +```css +box-shadow: 0 0 8px 88; +``` + +Sempre que o app exibir um número grande de "vitória", ele recebe um glow do tom correspondente. Use com parcimônia — só nos pontos focais. + +--- + +## 2. Layout + +### 2.1 Estrutura geral + +Toda tela do app tem essa estrutura vertical: + +``` +┌─────────────────────────────────────────┐ +│ HUD (Top Bar) │ ← persistente, navegação + stats RPG +├─────────────────────────────────────────┤ +│ Sub-header (título da rota + ações) │ +├─────────────────────────────────────────┤ +│ Conteúdo │ ← grid de painéis +│ │ +└─────────────────────────────────────────┘ +``` + +**HUD** é elemento de identidade — não dispensar em nenhuma rota. + +### 2.2 Grid de painéis + +Dashboard usa grid `1.6fr 1fr` (coluna principal mais larga que a lateral). Cards empilhados com `gap: 16px`. + +Personagem usa grid `1fr 1.4fr` (personagem à esquerda, quests/conquistas à direita). + +### 2.3 Fundo do app + +O fundo tem 3 camadas (em ordem, do mais ao fundo): + +1. Cor sólida `#0a0418` +2. Dois gradientes radiais sutis (roxo no topo, magenta no canto inferior direito) +3. Grid pontilhado fino (32×32px linhas, opacidade 12%) sobre tudo +4. Scanlines horizontais finas (CRT vibe) com opacidade 2,5% + +CSS do body/root do app: +```css +.fc-app { + background: + radial-gradient(ellipse 80% 50% at 50% 0%, rgba(168,85,247,.18), transparent 70%), + radial-gradient(ellipse 60% 40% at 80% 100%, rgba(255,45,149,.12), transparent 60%), + var(--fc-bg); + position: relative; + min-height: 100vh; +} +.fc-app::before { /* scanlines */ + content: ''; + position: absolute; inset: 0; + background-image: repeating-linear-gradient(0deg, rgba(255,255,255,.025) 0 1px, transparent 1px 3px); + pointer-events: none; + z-index: 2; +} +.fc-app::after { /* grid */ + content: ''; + position: absolute; inset: 0; + background-image: + linear-gradient(var(--fc-line) 1px, transparent 1px), + linear-gradient(90deg, var(--fc-line) 1px, transparent 1px); + background-size: 32px 32px; + opacity: .12; + pointer-events: none; +} +.fc-app > * { position: relative; z-index: 1; } +``` + +--- + +## 3. Componentes + +### 3.1 Panel + +Wrapper para qualquer conteúdo. Tem cantos em "L" (corner brackets) opcionais — a estética HUD do Arcade Neon. + +```vue + + + + +``` + +CSS: +```css +.fc-panel { + position: relative; + background: linear-gradient(180deg, var(--fc-bg-raised), var(--fc-bg-panel)); + border: 2px solid var(--fc-panel-edge); + border-radius: var(--fc-radius); + padding: 16px; + box-shadow: + inset 0 1px 0 rgba(255,255,255,.05), + 0 0 0 1px rgba(0,0,0,.4), + 0 8px 24px rgba(0,0,0,.4); +} +.fc-panel--glow { + border-color: var(--fc-accent-3); + box-shadow: + inset 0 1px 0 rgba(255,255,255,.05), + 0 0 0 1px var(--fc-accent-3), + 0 0 24px rgba(168,85,247,.25), + 0 8px 24px rgba(0,0,0,.5); +} +.fc-panel--danger { border-color: var(--fc-red); box-shadow: 0 0 0 1px var(--fc-red), 0 0 16px rgba(255,59,107,.3); } +.fc-corner { + position: absolute; width: 8px; height: 8px; + border: 2px solid var(--fc-accent-2); +} +.fc-corner--tl { top: -2px; left: -2px; border-right: none; border-bottom: none; } +.fc-corner--tr { top: -2px; right: -2px; border-left: none; border-bottom: none; } +.fc-corner--bl { bottom: -2px; left: -2px; border-right: none; border-top: none; } +.fc-corner--br { bottom: -2px; right: -2px; border-left: none; border-top: none; } +``` + +**Quando usar `corners`:** somente no painel principal de cada coluna ou no hero. Eles são "decoração de HUD", não devem aparecer em cada card. + +**Quando usar `glow`:** apenas no painel de status mais importante da rota — o de meta de poupança no dashboard, o card do personagem na tela RPG. + +### 3.2 Título de painel + +Todo painel começa com um título no padrão `:: NOME`: + +```html +
:: GASTOS · CATEGORIA
+``` + +```css +.fc-panel-title { + font-family: 'Press Start 2P', monospace; + font-size: 9px; + color: var(--fc-accent-2); + margin-bottom: 14px; + letter-spacing: .04em; +} +``` + +O `::` prefix é assinatura visual da direção. Mantém. + +### 3.3 Button + +Botão padrão é "neon outline": + +```css +.fc-btn { + font-family: 'Press Start 2P', monospace; + font-size: 9px; + padding: 9px 12px; + background: var(--fc-bg-raised); + border: 2px solid var(--fc-accent-3); + color: var(--fc-text); + cursor: pointer; + border-radius: 2px; + letter-spacing: .05em; + text-transform: uppercase; + transition: all .12s; +} +.fc-btn:hover { + background: var(--fc-accent-3); + color: white; + box-shadow: 0 0 16px rgba(168,85,247,.6); +} +.fc-btn--pink { border-color: var(--fc-accent); } +.fc-btn--pink:hover { background: var(--fc-accent); box-shadow: 0 0 16px rgba(255,45,149,.6); } +.fc-btn--cyan { border-color: var(--fc-accent-2); color: var(--fc-accent-2); } +.fc-btn--cyan:hover { background: var(--fc-accent-2); color: var(--fc-bg); box-shadow: 0 0 16px rgba(0,240,255,.6); } +``` + +Hierarquia: +- **Roxo** (padrão): ação primária genérica. +- **Magenta**: ação destrutiva ou de "agora" (registrar transação, atingir quest). +- **Ciano**: ação secundária, ver detalhes. + +### 3.4 Chip + +```css +.fc-chip { + display: inline-block; + font-family: 'Press Start 2P', monospace; + font-size: 7px; + padding: 4px 6px; + border-radius: 2px; + letter-spacing: .05em; + text-transform: uppercase; +} +``` + +Cores dependem do contexto: +- Quest "Diária" → `background: var(--fc-accent)` +- Quest "Semanal" → `background: var(--fc-accent-2)` +- Quest "Mensal" → `background: var(--fc-gold)` +- Categoria de transação → `color: var(--fc-text-dim)` apenas (sem fundo) + +### 3.5 Bar (XP, progresso de quest, gauge) + +```vue + + + +``` + +```css +.fc-bar { + height: 14px; + background: var(--fc-bg); + border: 2px solid var(--fc-panel-edge); + position: relative; + overflow: hidden; +} +.fc-bar__fill { + height: 100%; + background: linear-gradient(90deg, var(--fc-accent-2), var(--fc-accent-3), var(--fc-accent)); + position: relative; +} +.fc-bar__fill::after { + content:''; position: absolute; inset: 0; + background-image: repeating-linear-gradient(90deg, rgba(0,0,0,.2) 0 2px, transparent 2px 6px); +} +.fc-bar--success { border-color: var(--fc-green); } +.fc-bar--success .fc-bar__fill { background: linear-gradient(90deg, var(--fc-green), var(--fc-accent-2)); } +``` + +Tamanhos: +- XP geral (dashboard widget): 8px +- Quest mini: 6px +- Hero (poupado %): 14px +- Personagem (XP grande): 14px + +### 3.6 HUD (top bar) + +Persistente em todas as rotas. Contém: logo, separator, stats RPG (LV/XP/STREAK/META), nav. + +Stats são essenciais — mesmo em telas que não são `/personagem`, o usuário tem que ver seu progresso o tempo todo. Esse é o ponto da direção. + +```html +
+ +
+
+ + + + +
+ +
+``` + +```css +.fc-hud { + display: flex; align-items: center; gap: 16px; + padding: 12px 20px; + border-bottom: 2px solid var(--fc-panel-edge); + background: linear-gradient(180deg, var(--fc-bg-panel), var(--fc-bg)); + position: relative; z-index: 3; +} +.fc-hud__logo-mark { + display: inline-block; + width: 28px; height: 28px; + background: var(--fc-accent); + box-shadow: 0 0 12px var(--fc-accent); + position: relative; +} +.fc-hud__logo-mark::before, .fc-hud__logo-mark::after { content: ''; position: absolute; } +.fc-hud__logo-mark::before { inset: 5px; background: var(--fc-bg); } +.fc-hud__logo-mark::after { top: 9px; left: 9px; width: 10px; height: 10px; background: var(--fc-accent-2); } +``` + +Nav items são chips com borda. Item ativo: fundo `--fc-accent-3` + glow. + +### 3.7 Link (texto) + +```css +.fc-link { + color: var(--fc-accent-2); + font-family: 'Press Start 2P', monospace; + font-size: 8px; + cursor: pointer; + text-decoration: none; + letter-spacing: .04em; +} +.fc-link:hover { + text-decoration: underline; + text-shadow: 0 0 8px var(--fc-accent-2); +} +``` + +Use sempre `VER+`, `ABRIR >`, `REGISTRAR >` — verbo curto + seta. Toda navegação textual usa esse padrão pra reforçar o "menu de jogo". + +### 3.8 Alerta pulsante (recorrência ausente) + +```css +@keyframes fc-blink { 0%,49% { opacity: 1; } 50%,100% { opacity: 0.2; } } +.fc-blink { animation: fc-blink 1.2s steps(1) infinite; } +``` + +Use no triângulo ⚠ que precede o título do alerta. Pisca em vermelho. + +--- + +## 4. Tabelas / listas + +Padrão de tabela de transações: + +``` +[ data ][ descrição ......... ][ CATEGORIA ][ -R$ X,XX ] + mono10 mono11 ellipsis pixel7 dim mono11 right +``` + +- Linhas usam `border-bottom: 1px dashed var(--fc-panel-edge)` (a última, não). +- Padding vertical de 8px por linha. +- Valor positivo → cor `--fc-green`. +- Valor negativo → cor `--fc-text` (não vermelho — o vermelho é só pra alerta crítico). + +--- + +## 5. Pixel Sprite (Character) + +O personagem é desenhado a partir de uma grade 17×24 de células. Cores são parametrizáveis por slot de cosmético. + +### 5.1 Slots de cor + +| Slot | Token CSS sugerido | Cosmético que altera | +|---|---|---| +| `outline` | `--fc-sprite-outline` (`#10131c`) | nunca muda | +| `skin` | `--fc-sprite-skin` (`#f3c79b`) | nunca muda | +| `skinShade` | `--fc-sprite-skin-shade` (`#c98863`) | nunca muda | +| `hat` | `--fc-sprite-hat` | cosmético `hat` | +| `band` | `--fc-sprite-band` | faixa do chapéu | +| `shirt` | `--fc-sprite-shirt` | cosmético `shirt` | +| `shirtShade` | `--fc-sprite-shirt-shade` | shadow do shirt | +| `pants` | `--fc-sprite-pants` | cosmético `pants` | +| `pantsShade` | `--fc-sprite-pants-shade` | shadow do pants | +| `boots` | `--fc-sprite-boots` | cosmético `shoes` | + +### 5.2 Render (Vue) + +A grade vem do arquivo `sprites/sprite-data.json` (incluído). Componente: + +```vue + + + + + + +``` + +### 5.3 Tamanhos canônicos + +- **Dashboard widget**: `scale={3}` → 51×72px no SVG +- **Mobile inline**: `scale={2}` → 34×48px +- **Personagem hero**: `scale={6}` → 102×144px + +Sempre escalas inteiras. Nunca esticar. + +### 5.4 Cosméticos (data) + +A loja de cosméticos persiste no banco. Cada cosmético tem: + +```ts +type Cosmetic = { + id: string + name: string + slot: 'hat' | 'shirt' | 'pants' | 'shoes' | 'cape' + unlockLv: number // nível necessário + colors: Partial> +} +``` + +O store (Pinia) tem um getter `equippedTheme` que faz o merge das cores dos itens equipados. Esse objeto vai como prop `theme` para ``. + +--- + +## 6. Animações + +| Trigger | Comportamento | +|---|---| +| Idle (sempre) | Sprite bobinha verticalmente (`fc-sprite--bob`, 2.2s steps(2) infinite) | +| Quest completa | Sprite vira `fc-sprite--celebrate` por 3s, depois volta | +| Level up | Modal sobrepõe a tela: painel com glow magenta, "+1 LEVEL", som opcional | +| XP ganho | Bar enche com transição `width 0.6s ease-out`. Numero do XP no HUD soma com tween 1s | +| Hover em card | `transform: translateY(-2px)` + shadow um pouco mais profundo | +| Alerta de recorrência | Triângulo ⚠ pisca (`fc-blink`) | + +Sem motion gratuito. Cada animação tem um significado. + +--- + +## 7. Iconografia + +Esta direção evita ícones de biblioteca (Lucide, etc.). Em vez disso usa: + +- **Símbolos unicode pequenos** pra categorias: `⌂` casa, `✚` saúde, `◉` mercado, `⚓` veleiro, `▦` board games, `↦` transporte, `•` outros. +- **Cantos em "L"** (`fc-corner-*`) pra emoldurar painéis especiais. +- **Triângulo `▲`** pra alerta, **estrela `✦` ou `★`** pra conquista, **diamante `◆`** pra equipado. +- **Setas** `›` `‹` `>` pra navegação textual. + +Quando precisar de um ícone novo: SVG inline pixel-art (8×8 ou 12×12), nunca importar ícone vetorial moderno. + +Os pixels de `PixelHeart` e `PixelCoin` estão em `sprites/icons.json`. + +--- + +## 8. Voz e copywriting + +- Labels em maiúscula com Press Start 2P, curtas. `:: GASTOS · CATEGORIA`, `:: TRANSAÇÕES`, `:: PERSONAGEM`. +- Botões em maiúscula, verbo + (seta opcional). `REGISTRAR >`, `VER +`, `ABRIR >`, `IMPORTAR EXTRATO`. +- Mensagens de sucesso em tom de jogo: `QUEST OK`, `META BATIDA`, `+200 XP`. +- Mensagens de aviso em tom direto: `RECORRÊNCIA AUSENTE`, `QUEST FAIL`. +- Nome de seção da vida real ganha "stage": `:: STAGE 05 ::` antes do nome do mês na rota `/`. + +Evite tom motivacional vazio ("você consegue!"). O jogo recompensa com XP, não com frase. + +--- + +## 9. Responsivo + +- Desktop ≥ 1024px: grid `1.6fr 1fr` (dashboard) ou `1fr 1.4fr` (personagem). HUD horizontal completo. +- Tablet 768–1023px: grid colapsa pra 1 coluna. HUD ainda horizontal mas nav vira menu hambúrguer (preserve os stats). +- Mobile <768px: + - HUD compacta. Apenas LV + XP-bar visíveis + streak. + - Nav vira **bottom bar** fixa, 4 itens: HOME / TX / ADD (+) / PERS. + - Painéis empilham com `gap: 12px`, padding interno reduz pra 14px. + - Hero "47%" ocupa toda a largura, centralizado. + +--- + +## 10. Arquivos deste pacote + +``` +neon-handoff/ +├── STYLE_GUIDE.md ← este arquivo +├── tokens.css ← variáveis CSS prontas pra importar +├── preview.html ← referência visual interativa +├── components/ +│ ├── NeonPanel.vue +│ ├── XPBar.vue +│ ├── HUDStat.vue +│ └── CharacterSprite.vue +├── sprites/ +│ ├── sprite-data.json ← grade raw 17×24 (chave por cor) +│ ├── icons.json ← heart, coin, achievement +│ ├── manoel-default.png ← 1× (17×24px) e 4× (68×96px) +│ ├── manoel-default-4x.png +│ ├── manoel-lv01.png +│ ├── manoel-lv03-bone-verolme.png +│ ├── manoel-lv05-wingspan.png +│ ├── manoel-lv07-chapeu-sol.png +│ ├── manoel-lv10-anti-vento.png +│ ├── manoel-lv15-capitao.png +│ └── manoel-sheet-4x.png ← todos os 7 lado a lado, pra sprite-sheet +└── README.md +``` diff --git a/neon-handoff/components/CharacterSprite.vue b/neon-handoff/components/CharacterSprite.vue new file mode 100644 index 0000000..89afcca --- /dev/null +++ b/neon-handoff/components/CharacterSprite.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/neon-handoff/components/HUDStat.vue b/neon-handoff/components/HUDStat.vue new file mode 100644 index 0000000..14ba543 --- /dev/null +++ b/neon-handoff/components/HUDStat.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/neon-handoff/components/NeonPanel.vue b/neon-handoff/components/NeonPanel.vue new file mode 100644 index 0000000..08d7326 --- /dev/null +++ b/neon-handoff/components/NeonPanel.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/neon-handoff/components/XPBar.vue b/neon-handoff/components/XPBar.vue new file mode 100644 index 0000000..03dd481 --- /dev/null +++ b/neon-handoff/components/XPBar.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/neon-handoff/exampleCSV/Fatura2026-06-05.csv b/neon-handoff/exampleCSV/Fatura2026-06-05.csv new file mode 100644 index 0000000..cb3bded --- /dev/null +++ b/neon-handoff/exampleCSV/Fatura2026-06-05.csv @@ -0,0 +1,44 @@ +Data;Estabelecimento;Portador;Valor;Parcela +;;;; +02/05/2026;PAC MOTIVA CENTRO DE E;MANOEL CARVALHO;R$ 329,00;- +02/05/2026;RECIBOM SUPERMERCADOS;MANOEL CARVALHO;R$ 369,36;- +03/01/2026;AMAZON BR;MANOEL CARVALHO;R$ 59,69;5 de 10 +04/04/2026;JIM.COM SPANGHERO COMERCI;MANOEL CARVALHO;R$ 3,00;2 de 6 +04/05/2026;DROGASIL 1744;MANOEL CARVALHO;R$ 59,44;- +04/05/2026;PALATO RIOMAR;MANOEL CARVALHO;R$ 16,90;- +04/05/2026;MERCADOLIVRE*MERCADOLIVRE;MANOEL CARVALHO;R$ 245,47;- +04/05/2026;RMR BARBEARIA CAFE BAR;MANOEL CARVALHO;R$ 145,00;- +04/05/2026;CLAUDE.AI SUBSCRIPTION;MANOEL CARVALHO;R$ 3,85;- +04/05/2026;CLAUDE.AI SUBSCRIPTION;MANOEL CARVALHO;R$ 110,00;- +05/04/2026;MP*FERRAMENTASME;MANOEL CARVALHO;R$ 559,84;2 de 10 +06/05/2026;MP*BIADOCERIA;MANOEL CARVALHO;R$ 3,00;- +06/05/2026;IFD*IFOOD;MANOEL CARVALHO;R$ 5,95;- +07/05/2026;DROGARIA SAO PAULO SA;MANOEL CARVALHO;R$ 808,11;- +07/05/2026;KANELLE RESTAURANTE E;MANOEL CARVALHO;R$ 12,00;- +07/05/2026;TIDAL HIFI GLOBAL BR;MANOEL CARVALHO;R$ 22,80;- +08/05/2026;LOTTI RIO MAR RECIFE;MANOEL CARVALHO;R$ 244,40;- +10/05/2026;DROGASIL 1744;MANOEL CARVALHO;R$ 142,65;- +11/04/2026;A B VILELA SILVA;MANOEL CARVALHO;R$ 54,50;2 de 10 +12/05/2026;MERCADOLIVRE*MERCADOLIVRE;MANOEL CARVALHO;R$ 75,77;- +13/05/2026;AMAZONMKTPLC*TOCADOTAB;MANOEL CARVALHO;R$ 58,15;1 de 6 +14/05/2026;SNACK BOX;MANOEL CARVALHO;R$ 3,60;- +14/05/2026;DL*GOOGLE YOUTUB;MANOEL CARVALHO;R$ 11,90;- +15/05/2026;MIX MATEUS BOA VIAGEM;MANOEL CARVALHO;R$ 609,76;- +15/05/2026;CEMOPEL III;MANOEL CARVALHO;R$ 232,67;- +15/05/2026;CONTABILIZEI TECNOLOGIA L;MANOEL CARVALHO;R$ 255,00;- +19/05/2026;DL*GOOGLE DIREWO;MANOEL CARVALHO;R$ 25,50;- +20/05/2026;DL*GOOGLE GOOGLE;MANOEL CARVALHO;R$ 9,99;- +21/05/2026;RESTAURANTE DO SESC SH;MANOEL CARVALHO;R$ 21,74;- +22/01/2026;MP*MERCADOLIVRE;MANOEL CARVALHO;R$ 50,40;5 de 7 +24/05/2026;MERCADO EXTRA 1381;MANOEL CARVALHO;R$ 278,03;- +25/04/2026;SNACK BOX;MANOEL CARVALHO;R$ -3,15;- +25/05/2026;DROGARIA SAO PAULO SA;MANOEL CARVALHO;R$ 54,54;- +26/04/2026;DL*GOOGLE PLAY P;MANOEL CARVALHO;R$ 9,90;- +27/04/2026;MERCADOLIVRE*WMCOMPONENTE;MANOEL CARVALHO;R$ 39,80;- +28/04/2026;LASA;MANOEL CARVALHO;R$ 14,49;- +28/04/2026;RESTAURANTE DO SESC SH;MANOEL CARVALHO;R$ 20,22;- +29/04/2026;KANELLE RESTAURANTE E;MANOEL CARVALHO;R$ 9,00;- +29/04/2026;CONVENIENCIA CARICE;MANOEL CARVALHO;R$ 14,00;- +29/04/2026;MP*ALIEXPRESS;MANOEL CARVALHO;R$ 244,44;- +29/04/2026;PAO E PROSA;MANOEL CARVALHO;R$ 20,00;- +30/04/2026;PALATO RIOMAR;MANOEL CARVALHO;R$ 160,90;- diff --git a/neon-handoff/preview.html b/neon-handoff/preview.html new file mode 100644 index 0000000..ecc47e6 --- /dev/null +++ b/neon-handoff/preview.html @@ -0,0 +1,454 @@ + + + + +Financeiro Carvalho · Style Guide · Arcade Neon + + + + + + +
+
+ +
+
+ VER + 1.0 +
+
+ DATA + 26/05/26 +
+
+
+ +

Style Guide · Financeiro Carvalho

+

Referência visual da direção Arcade Neon. Use junto do STYLE_GUIDE.md. Tokens vivem em tokens.css — esta página apenas os exibe. Componentes Vue prontos pra copiar estão em components/.

+ + +

Colors

+ +

Estrutura (escuro)

+
+
--fc-bg
#0a0418
Fundo base do app
+
--fc-bg-raised
#150827
Topo do gradiente do painel
+
--fc-bg-panel
#1c0d33
Base do gradiente do painel
+
--fc-panel-edge
#2d1857
Borda padrão de painel + dividers
+
+ +

Texto

+
+
--fc-text
#f4eaff
Texto principal. Off-white com tom lilás.
+
--fc-text-dim
#8b75b8
Texto secundário, labels, sub.
+
+ +

Acentos

+
+
--fc-accent
#ff2d95
Magenta · alertas suaves, quest diária, CTA primário accent.
+
--fc-accent-2
#00f0ff
Ciano · links, navegação ativa, XP, quest semanal.
+
--fc-accent-3
#a855f7
Roxo · botão padrão, painel em destaque, glow.
+
--fc-gold
#ffd84d
XP, conquistas, quest mensal.
+
+ +

Estado

+
+
--fc-green
#39ff7a
Meta atingida, valores positivos.
+
--fc-red
#ff3b6b
Meta falhou, recorrência ausente, alerta crítico.
+
+ + +

Typography

+ +
+
+
Press Start 2P
pixel · labels & numbers
+
+
:: LABEL HUD ::
+
:: TÍTULO DE PAINEL
+
MAIO · 2026
+
47%
+
+
+
+
JetBrains Mono
mono · valores
+
+
26/05/26 · Itaú · Crédito
+
Mercado Pão de Açúcar
+
R$ 7.844,21
+
+
+
+
Inter
body · descrições
+
+
+ Mantenha gastos com veleiro abaixo de R$ 500 neste mês. Recompensa: +180 XP. Última atualização há 2h. +
+
+
+
+ + +

Panels

+

Painel é o tijolo do app. Versão glow é reservada pro card de destaque da rota (poupado-do-mês, status do personagem). Cantos em "L" decoram só painéis especiais.

+ +
+
+
:: PAINEL PADRÃO
+

Container neutro. Use pra listar transações, categorias, contas, qualquer dado tabular.

+
+
+
:: PAINEL COM GLOW
+

Borda roxa + halo. Reservado pro card mais importante da rota.

+
+
+ +
:: PAINEL COM CANTOS EM L
+

Decoração HUD opcional. Use no hero ou no card-de-status do personagem.

+
+
+
+ + RECORRÊNCIA AUSENTE +
+

Variante danger. Borda vermelha + halo + triângulo pulsante.

+
+
+ + +

Buttons

+

Texto sempre Press Start 2P em maiúscula. Verbo + (seta opcional). Hover acende o glow da cor da borda.

+
+ + + +
+ + +

Chips

+

Tags pequenas. Sempre Press Start 2P 7px maiúsculo. A cor é semântica.

+
+ DIÁRIA + SEMANAL + MENSAL + QUEST OK + QUEST FAIL +
+ + +

Bars (XP, progresso)

+

Sempre 2px de borda. Fill com gradiente diagonal + listras internas — assinatura visual.

+ +
+
+ XP + 3240 / 4900 +
+
+
+
+ +
+ PROGRESSO QUEST · DIÁRIA + 3 / 5 +
+
+ +
+ META POUPANÇA · ATINGIDA + 47% +
+
+
+ + +

Links (text actions)

+

Verbo curto + seta. Sempre Press Start 2P em ciano. Hover ganha glow.

+ + + +

HUD (top bar)

+

Persistente em todas as rotas. Os stats RPG (LV/XP/STREAK/META) sempre visíveis — é assim que a direção mantém a metáfora de jogo.

+
+ +
+
+ LV + 7 +
+
+ XP + 3240/4900 +
+
+ STREAK + 23d +
+
+ META + 47%/40% +
+
+ + +

Pixel Sprites

+

Personagem é 17×24 pixels. Cores parametrizadas por slot (hat / shirt / pants / boots). Cosméticos do banco fazem merge desses slots em runtime.

+ +

Escalas canônicas

+
+ +

Variantes por cosmético

+
+ + +

Animations

+

Toda animação tem propósito. Idle bob é o único motion "passivo". Celebrate roda 3s após quest complete. Level-up sobrepõe modal com glow magenta.

+
+
+
IDLE BOB
+
+ animation: 2.2s steps(2) +
+
+
CELEBRATE
+
+ animation: 1s ease-in-out +
+
+
ALERT BLINK
+
+ animation: 1.2s steps(1) +
+
+ + +

Shadows & Glow

+
+
+
PADRÃO
+
--fc-shadow-panel
+
+
+
GLOW
+
--fc-shadow-panel-glow
+
+
+
DANGER
+
--fc-red border + halo
+
+
+ +
+ +

+ v1.0 · + Style guide gerado automaticamente. Para regenerar sprites: rode run.js em sprites/. Para perguntas, abra STYLE_GUIDE.md primeiro. +

+ + + + + diff --git a/neon-handoff/sprites/icons.json b/neon-handoff/sprites/icons.json new file mode 100644 index 0000000..d157d42 --- /dev/null +++ b/neon-handoff/sprites/icons.json @@ -0,0 +1,67 @@ +{ + "_note": "Each icon is a flat grid of single-char keys. '.' = transparent. Colors are filled at render time from CSS tokens or props.", + "heart": { + "rows": 8, + "cols": 8, + "grid": [ + "........", + ".XX..XX.", + "XXXXXXXX", + "XXXXXXXX", + ".XXXXXX.", + "..XXXX..", + "...XX...", + "........" + ], + "defaultColor": "var(--fc-red, #ff3b6b)" + }, + "coin": { + "rows": 8, + "cols": 8, + "grid": [ + "..XXXX..", + ".XYYYYX.", + "XYYSYYYX", + "XYYSYYYX", + "XYYSYYYX", + "XYYSYYYX", + ".XYYYYX.", + "..XXXX.." + ], + "colors": { + "X": "var(--fc-gold, #ffd84d)", + "Y": "var(--fc-gold, #ffd84d)", + "S": "#a16207" + } + }, + "star": { + "rows": 8, + "cols": 8, + "grid": [ + "...XX...", + "...XX...", + "XX.XX.XX", + ".XXXXXX.", + "..XXXX..", + ".XXXXXX.", + "XX.XX.XX", + "...XX..." + ], + "defaultColor": "var(--fc-gold, #ffd84d)" + }, + "anchor": { + "rows": 8, + "cols": 8, + "grid": [ + "...XX...", + "..XXXX..", + "...XX...", + "XXXXXXXX", + "...XX...", + "X..XX..X", + "XXXXXXXX", + "..XXXX.." + ], + "defaultColor": "var(--fc-accent-2, #00f0ff)" + } +} diff --git a/neon-handoff/sprites/manoel-default-4x.png b/neon-handoff/sprites/manoel-default-4x.png new file mode 100644 index 0000000..03d72dc Binary files /dev/null and b/neon-handoff/sprites/manoel-default-4x.png differ diff --git a/neon-handoff/sprites/manoel-default.png b/neon-handoff/sprites/manoel-default.png new file mode 100644 index 0000000..a391da2 Binary files /dev/null and b/neon-handoff/sprites/manoel-default.png differ diff --git a/neon-handoff/sprites/manoel-lv01-4x.png b/neon-handoff/sprites/manoel-lv01-4x.png new file mode 100644 index 0000000..5be578e Binary files /dev/null and b/neon-handoff/sprites/manoel-lv01-4x.png differ diff --git a/neon-handoff/sprites/manoel-lv01.png b/neon-handoff/sprites/manoel-lv01.png new file mode 100644 index 0000000..c010162 Binary files /dev/null and b/neon-handoff/sprites/manoel-lv01.png differ diff --git a/neon-handoff/sprites/manoel-lv03-bone-verolme-4x.png b/neon-handoff/sprites/manoel-lv03-bone-verolme-4x.png new file mode 100644 index 0000000..fcbae5e Binary files /dev/null and b/neon-handoff/sprites/manoel-lv03-bone-verolme-4x.png differ diff --git a/neon-handoff/sprites/manoel-lv03-bone-verolme.png b/neon-handoff/sprites/manoel-lv03-bone-verolme.png new file mode 100644 index 0000000..c679f2d Binary files /dev/null and b/neon-handoff/sprites/manoel-lv03-bone-verolme.png differ diff --git a/neon-handoff/sprites/manoel-lv05-wingspan-4x.png b/neon-handoff/sprites/manoel-lv05-wingspan-4x.png new file mode 100644 index 0000000..b92a448 Binary files /dev/null and b/neon-handoff/sprites/manoel-lv05-wingspan-4x.png differ diff --git a/neon-handoff/sprites/manoel-lv05-wingspan.png b/neon-handoff/sprites/manoel-lv05-wingspan.png new file mode 100644 index 0000000..c161509 Binary files /dev/null and b/neon-handoff/sprites/manoel-lv05-wingspan.png differ diff --git a/neon-handoff/sprites/manoel-lv07-chapeu-sol-4x.png b/neon-handoff/sprites/manoel-lv07-chapeu-sol-4x.png new file mode 100644 index 0000000..03d72dc Binary files /dev/null and b/neon-handoff/sprites/manoel-lv07-chapeu-sol-4x.png differ diff --git a/neon-handoff/sprites/manoel-lv07-chapeu-sol.png b/neon-handoff/sprites/manoel-lv07-chapeu-sol.png new file mode 100644 index 0000000..a391da2 Binary files /dev/null and b/neon-handoff/sprites/manoel-lv07-chapeu-sol.png differ diff --git a/neon-handoff/sprites/manoel-lv10-anti-vento-4x.png b/neon-handoff/sprites/manoel-lv10-anti-vento-4x.png new file mode 100644 index 0000000..9143d46 Binary files /dev/null and b/neon-handoff/sprites/manoel-lv10-anti-vento-4x.png differ diff --git a/neon-handoff/sprites/manoel-lv10-anti-vento.png b/neon-handoff/sprites/manoel-lv10-anti-vento.png new file mode 100644 index 0000000..9938031 Binary files /dev/null and b/neon-handoff/sprites/manoel-lv10-anti-vento.png differ diff --git a/neon-handoff/sprites/manoel-lv15-capitao-4x.png b/neon-handoff/sprites/manoel-lv15-capitao-4x.png new file mode 100644 index 0000000..9a2ef2c Binary files /dev/null and b/neon-handoff/sprites/manoel-lv15-capitao-4x.png differ diff --git a/neon-handoff/sprites/manoel-lv15-capitao.png b/neon-handoff/sprites/manoel-lv15-capitao.png new file mode 100644 index 0000000..04f5295 Binary files /dev/null and b/neon-handoff/sprites/manoel-lv15-capitao.png differ diff --git a/neon-handoff/sprites/manoel-sheet-4x.png b/neon-handoff/sprites/manoel-sheet-4x.png new file mode 100644 index 0000000..94dd71c Binary files /dev/null and b/neon-handoff/sprites/manoel-sheet-4x.png differ diff --git a/neon-handoff/sprites/sprite-data.json b/neon-handoff/sprites/sprite-data.json new file mode 100644 index 0000000..5319030 --- /dev/null +++ b/neon-handoff/sprites/sprite-data.json @@ -0,0 +1,160 @@ +{ + "name": "manoel", + "rows": 24, + "cols": 17, + "_colorSlots": { + "L": "outline (immutable)", + "s": "skin highlight (immutable)", + "S": "skin shade (immutable)", + "e": "eye (immutable)", + "m": "mouth (immutable)", + "h": "hat main", + "w": "hat band", + "c": "shirt main", + "C": "shirt shade", + "p": "pants main", + "P": "pants shade", + "b": "boots" + }, + "_legend": { + ".": "transparent", + "L": "#10131c", + "s": "#f3c79b", + "S": "#c98863", + "e": "#10131c", + "m": "#80484b", + "h": "#ffd84d", + "w": "#fde68a", + "c": "#00f0ff", + "C": "#0369a1", + "p": "#3a1f6e", + "P": "#2d1857", + "b": "#0f172a" + }, + "grid": [ + "....LLLLLLLLL....", + "..LLhhhhhhhhhLL..", + ".LhhhhhhhhhhhhhL.", + "LLhhhhhhhhhhhhhLL", + "LwwwwwwwwwwwwwwwL", + "LLLLLLLLLLLLLLLLL", + "....LssssssssL...", + "...LssssssssssL..", + "..LsseSssssSesL..", + "..LssssssssssL...", + "..LSsLmmmmmLSsL..", + "...LSssssssSSL...", + "....LLsssssLL....", + "...LccccccccL....", + "..LcccCwwCccccL..", + ".LccccCwwCccccL..", + ".LccccCwwCccccL..", + ".LCCCCCCCCCCCCL..", + "..LpppLLLLpppL...", + "..LppppppppppL...", + "..LppppppppppL...", + "..LPPPPPPPPPPL...", + "..LLbbLL.LLbbLL..", + "...LLLL...LLLL..." + ], + "cosmetics": [ + { + "id": "default", + "name": "Look Equipado (atual)", + "unlockLv": 7, + "preview": "manoel-default", + "theme": {} + }, + { + "id": "lv01-tripulante", + "name": "Tripulante Iniciante", + "unlockLv": 1, + "preview": "manoel-lv01", + "_notes": "sem chapéu — h/w viram cabelo castanho", + "theme": { + "h": "#5a3a1d", + "w": "#5a3a1d", + "c": "#0ea5e9", + "C": "#075985", + "p": "#a16207", + "P": "#7c4a08", + "b": "#1e293b" + } + }, + { + "id": "lv03-bone-verolme", + "name": "Boné Verolme", + "unlockLv": 3, + "preview": "manoel-lv03-bone-verolme", + "theme": { + "h": "#1e40af", + "w": "#fde68a", + "c": "#0ea5e9", + "C": "#075985", + "p": "#a16207", + "P": "#7c4a08", + "b": "#1e293b" + } + }, + { + "id": "lv05-wingspan", + "name": "Camiseta Wingspan", + "unlockLv": 5, + "preview": "manoel-lv05-wingspan", + "theme": { + "h": "#ffd84d", + "w": "#fde68a", + "c": "#10b981", + "C": "#065f46", + "p": "#3a1f6e", + "P": "#2d1857", + "b": "#0f172a" + } + }, + { + "id": "lv07-chapeu-sol", + "name": "Chapéu de Sol", + "unlockLv": 7, + "preview": "manoel-lv07-chapeu-sol", + "theme": { + "h": "#ffd84d", + "w": "#fde68a", + "c": "#00f0ff", + "C": "#0369a1", + "p": "#3a1f6e", + "P": "#2d1857", + "b": "#0f172a" + } + }, + { + "id": "lv10-anti-vento", + "name": "Casaco Anti-Vento", + "unlockLv": 10, + "preview": "manoel-lv10-anti-vento", + "theme": { + "h": "#1e40af", + "w": "#fde68a", + "c": "#a855f7", + "C": "#581c87", + "p": "#1e293b", + "P": "#0f172a", + "b": "#0f172a" + } + }, + { + "id": "lv15-capitao", + "name": "Trajado de Capitão", + "unlockLv": 15, + "preview": "manoel-lv15-capitao", + "theme": { + "h": "#ffd84d", + "w": "#dc2626", + "c": "#dc2626", + "C": "#7a0e0e", + "p": "#1c1917", + "P": "#0c0a09", + "b": "#0c0a09" + } + } + ] +} diff --git a/neon-handoff/tokens.css b/neon-handoff/tokens.css new file mode 100644 index 0000000..765d613 --- /dev/null +++ b/neon-handoff/tokens.css @@ -0,0 +1,140 @@ +/* Financeiro Carvalho · Arcade Neon + * Drop this into src/styles/tokens.css and import once in main.ts: + * import './styles/tokens.css' + * + * All other components MUST consume var(--fc-*) instead of literal hex. + */ + +@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@400;500;600;700&display=swap'); + +:root { + /* ─────────────── color tokens ─────────────── */ + --fc-bg: #0a0418; + --fc-bg-raised: #150827; + --fc-bg-panel: #1c0d33; + --fc-panel-edge: #2d1857; + --fc-line: #3a1f6e; + + --fc-text: #f4eaff; + --fc-text-dim: #8b75b8; + + --fc-accent: #ff2d95; /* magenta — alerts, "today", primary CTA accent */ + --fc-accent-2: #00f0ff; /* cyan — links, nav, "weekly" */ + --fc-accent-3: #a855f7; /* purple — structure, glow, default button */ + --fc-gold: #ffd84d; /* XP, achievements, "monthly" */ + --fc-green: #39ff7a; /* goal hit, positive value */ + --fc-red: #ff3b6b; /* goal missed, critical alert */ + + /* rgb fragments for shadows / alphas (no color-mix support? use these) */ + --fc-accent-rgb: 255 45 149; + --fc-accent-2-rgb: 0 240 255; + --fc-accent-3-rgb: 168 85 247; + --fc-gold-rgb: 255 216 77; + --fc-green-rgb: 57 255 122; + --fc-red-rgb: 255 59 107; + + /* ─────────────── sprite color slots ─────────────── */ + --fc-sprite-outline: #10131c; + --fc-sprite-skin: #f3c79b; + --fc-sprite-skin-shade: #c98863; + --fc-sprite-eye: #10131c; + --fc-sprite-mouth: #80484b; + --fc-sprite-hat: #ffd84d; + --fc-sprite-band: #fde68a; + --fc-sprite-shirt: #00f0ff; + --fc-sprite-shirt-shade: #0369a1; + --fc-sprite-pants: #3a1f6e; + --fc-sprite-pants-shade: #2d1857; + --fc-sprite-boots: #0f172a; + + /* ─────────────── sizing ─────────────── */ + --fc-radius-sm: 2px; + --fc-radius: 4px; + --fc-radius-lg: 6px; + + --fc-space-1: 4px; + --fc-space-2: 8px; + --fc-space-3: 12px; + --fc-space-4: 16px; + --fc-space-5: 24px; + + /* ─────────────── shadows ─────────────── */ + --fc-shadow-panel: + inset 0 1px 0 rgba(255,255,255,.05), + 0 0 0 1px rgba(0,0,0,.4), + 0 8px 24px rgba(0,0,0,.4); + + --fc-shadow-panel-glow: + inset 0 1px 0 rgba(255,255,255,.05), + 0 0 0 1px var(--fc-accent-3), + 0 0 24px rgba(168,85,247,.25), + 0 8px 24px rgba(0,0,0,.5); + + /* ─────────────── fonts ─────────────── */ + --fc-font-pixel: 'Press Start 2P', monospace; + --fc-font-mono: 'JetBrains Mono', ui-monospace, monospace; + --fc-font-body: 'Inter', system-ui, sans-serif; +} + +/* Global reset for the app root */ +html, body { + margin: 0; + padding: 0; + background: var(--fc-bg); + color: var(--fc-text); + font-family: var(--fc-font-body); +} +* { box-sizing: border-box; } + +/* Util classes */ +.fc-pixel { font-family: var(--fc-font-pixel); letter-spacing: .02em; } +.fc-mono { font-family: var(--fc-font-mono); } +.fc-body { font-family: var(--fc-font-body); } + +.fc-text-dim { color: var(--fc-text-dim); } +.fc-text-accent { color: var(--fc-accent); } +.fc-text-accent2 { color: var(--fc-accent-2); } +.fc-text-accent3 { color: var(--fc-accent-3); } +.fc-text-gold { color: var(--fc-gold); } +.fc-text-green { color: var(--fc-green); } +.fc-text-red { color: var(--fc-red); } + +.fc-glow-cyan { text-shadow: 0 0 8px rgba(0,240,255,.6); } +.fc-glow-magenta { text-shadow: 0 0 8px rgba(255,45,149,.6); } +.fc-glow-purple { text-shadow: 0 0 8px rgba(168,85,247,.6); } +.fc-glow-gold { text-shadow: 0 0 8px rgba(255,216,77,.6); } +.fc-glow-green { text-shadow: 0 0 8px rgba(57,255,122,.6); } +.fc-glow-red { text-shadow: 0 0 8px rgba(255,59,107,.6); } + +/* App shell — wrap your in
*/ +.fc-app { + background: + radial-gradient(ellipse 80% 50% at 50% 0%, rgba(168,85,247,.18), transparent 70%), + radial-gradient(ellipse 60% 40% at 80% 100%, rgba(255,45,149,.12), transparent 60%), + var(--fc-bg); + position: relative; + min-height: 100vh; + overflow-x: hidden; +} +.fc-app::before { + content: ''; + position: absolute; inset: 0; + background-image: repeating-linear-gradient(0deg, rgba(255,255,255,.025) 0 1px, transparent 1px 3px); + pointer-events: none; + z-index: 2; +} +.fc-app::after { + content: ''; + position: absolute; inset: 0; + background-image: + linear-gradient(var(--fc-line) 1px, transparent 1px), + linear-gradient(90deg, var(--fc-line) 1px, transparent 1px); + background-size: 32px 32px; + opacity: .12; + pointer-events: none; +} +.fc-app > * { position: relative; z-index: 1; } + +/* Blinking utility for critical alerts (recurrence missing, etc.) */ +@keyframes fc-blink { 0%,49% { opacity: 1 } 50%,100% { opacity: 0.2 } } +.fc-blink { animation: fc-blink 1.2s steps(1) infinite; }