feat: multi-usuário com autenticação JWT
- Tabela `profiles` + coluna `profile_id` em todas as entidades (categories, transactions, recurring_expenses, accounts, player_profile, xp_events, player_quests, player_achievements, player_cosmetics) - Dados existentes migrados para profile_id = 1 (Manoel) - CLI `./api create-user --name <n> --password <p>` cria perfil com seed de categorias e player_profile; faz upsert de senha se já existir - Auth substituída: cookie+APP_PASSWORD → JWT Bearer 24h (HS256) - Middleware RequireAuth injeta profile_id no context de todas as rotas - Todos os repositórios filtram por profile_id do context - Endpoints: POST /api/auth/login, GET /api/auth/me, POST /api/auth/change-password, POST /api/logout - Frontend: auth store usa localStorage (fc_token/fc_profile), api.ts envia Authorization header, LoginView usa campo name - SettingsView reescrita com troca de senha e logout - docker-compose.yml: remove APP_USERNAME/APP_PASSWORD, adiciona JWT_SECRET Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 <name> --password <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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,7 @@
|
||||
package model
|
||||
|
||||
type Profile struct {
|
||||
ID int
|
||||
Name string
|
||||
PasswordHash string
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
</script>
|
||||
|
||||
|
||||
@@ -48,7 +48,8 @@ const router = createRouter({
|
||||
},
|
||||
{
|
||||
path: '/configuracoes',
|
||||
redirect: '/categorias',
|
||||
name: 'settings',
|
||||
component: () => import('../views/SettingsView.vue'),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -2,13 +2,24 @@ import router from '@/router'
|
||||
|
||||
const BASE = '/api'
|
||||
|
||||
function getToken(): string | null {
|
||||
return localStorage.getItem('fc_token')
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const headers: Record<string, string> = {}
|
||||
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')
|
||||
}
|
||||
|
||||
@@ -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<string | null>(localStorage.getItem(TOKEN_KEY))
|
||||
const profile = ref<Profile | null>(
|
||||
(() => {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
const res = await fetch('/api/login', {
|
||||
async function login(name: string, password: string): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 }
|
||||
})
|
||||
|
||||
@@ -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() {
|
||||
<div class="field">
|
||||
<label class="fc-pixel field-label">USUÁRIO</label>
|
||||
<input
|
||||
v-model="username"
|
||||
v-model="name"
|
||||
type="text"
|
||||
class="login-input"
|
||||
autocomplete="username"
|
||||
|
||||
@@ -1,138 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
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<string | null>(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<number | null>(null)
|
||||
const amountRaw = ref('')
|
||||
const formError = ref<string | null>(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' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fc-view">
|
||||
<span class="fc-pixel fc-view__title">:: RECORRÊNCIAS</span>
|
||||
<span class="fc-pixel fc-view__title">:: CONFIGURAÇÕES</span>
|
||||
|
||||
<!-- Form -->
|
||||
<NeonPanel :title="editId !== null ? 'EDITAR RECORRÊNCIA' : 'NOVA RECORRÊNCIA'">
|
||||
<form class="fc-rec-form" @submit.prevent="submit">
|
||||
<div class="fc-rec-form__row">
|
||||
<input v-model="form.name" placeholder="Nome (ex: Netflix)" required class="fc-input fc-rec-form__name" />
|
||||
<input v-model="amountRaw" placeholder="55,90" required class="fc-input fc-rec-form__amount" />
|
||||
<div class="fc-label fc-rec-form__day-wrap">
|
||||
Dia do mês
|
||||
<input
|
||||
type="number"
|
||||
v-model.number="form.day_of_month"
|
||||
min="1"
|
||||
max="31"
|
||||
class="fc-input fc-rec-form__day"
|
||||
/>
|
||||
</div>
|
||||
<select v-model="form.category_id" class="fc-select fc-rec-form__cat">
|
||||
<option :value="null">Sem categoria</option>
|
||||
<option v-for="c in catStore.categories" :key="c.id" :value="c.id">{{ c.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<p v-if="formError" class="fc-rec-form__error fc-mono">{{ formError }}</p>
|
||||
<div class="fc-rec-form__actions">
|
||||
<button type="submit" class="fc-btn fc-btn--primary">{{ editId !== null ? 'SALVAR' : 'ADICIONAR' }}</button>
|
||||
<button v-if="editId !== null" type="button" class="fc-btn fc-btn--ghost" @click="cancelEdit">CANCELAR</button>
|
||||
</div>
|
||||
</form>
|
||||
<NeonPanel title="PERFIL">
|
||||
<div class="fc-settings-profile">
|
||||
<div class="fc-label fc-pixel">USUÁRIO</div>
|
||||
<div class="fc-settings-profile__name fc-mono">{{ auth.profile?.name ?? '—' }}</div>
|
||||
</div>
|
||||
<button class="fc-btn fc-btn--danger fc-settings-logout" @click="logout">SAIR</button>
|
||||
</NeonPanel>
|
||||
|
||||
<!-- List -->
|
||||
<NeonPanel title="RECORRÊNCIAS">
|
||||
<p v-if="store.loading" class="fc-rec-loading fc-mono">carregando...</p>
|
||||
<ul v-else class="fc-rec-list">
|
||||
<li
|
||||
v-for="item in store.items"
|
||||
:key="item.id"
|
||||
class="fc-rec-item"
|
||||
:class="{ 'fc-rec-item--editing': editId === item.id }"
|
||||
>
|
||||
<div class="fc-rec-item__info">
|
||||
<span class="fc-body fc-rec-item__name">{{ item.name }}</span>
|
||||
<span class="fc-mono fc-rec-item__meta">
|
||||
Todo dia {{ item.day_of_month }} · {{ fmt(item.expected_amount) }} · {{ catName(item.category_id) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="fc-rec-item__actions">
|
||||
<button class="fc-btn fc-btn--sm fc-btn--ghost" @click="startEdit(item.id)">EDITAR</button>
|
||||
<button class="fc-btn fc-btn--sm fc-btn--danger" @click="remove(item.id, item.name)">✕</button>
|
||||
</div>
|
||||
</li>
|
||||
<li v-if="store.items.length === 0" class="fc-rec-empty fc-mono">— nenhuma recorrência cadastrada —</li>
|
||||
</ul>
|
||||
<NeonPanel title="TROCAR SENHA">
|
||||
<form class="fc-settings-pw" @submit.prevent="changePassword">
|
||||
<input
|
||||
v-model="currentPassword"
|
||||
type="password"
|
||||
placeholder="Senha atual"
|
||||
class="fc-input"
|
||||
autocomplete="current-password"
|
||||
:disabled="pwLoading"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
v-model="newPassword"
|
||||
type="password"
|
||||
placeholder="Nova senha (mín. 8 chars)"
|
||||
class="fc-input"
|
||||
autocomplete="new-password"
|
||||
:disabled="pwLoading"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
placeholder="Confirmar nova senha"
|
||||
class="fc-input"
|
||||
autocomplete="new-password"
|
||||
:disabled="pwLoading"
|
||||
required
|
||||
/>
|
||||
<p v-if="pwError" class="fc-settings-pw__msg fc-settings-pw__msg--err fc-mono">{{ pwError }}</p>
|
||||
<p v-if="pwSuccess" class="fc-settings-pw__msg fc-settings-pw__msg--ok fc-mono">Senha alterada!</p>
|
||||
<button type="submit" class="fc-btn fc-btn--primary" :disabled="pwLoading">
|
||||
{{ pwLoading ? 'AGUARDE...' : 'SALVAR SENHA' }}
|
||||
</button>
|
||||
</form>
|
||||
</NeonPanel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fc-view {
|
||||
max-width: 640px;
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
padding: var(--fc-space-4);
|
||||
display: flex;
|
||||
@@ -140,78 +106,32 @@ function fmt(v: number) {
|
||||
gap: var(--fc-space-4);
|
||||
padding-bottom: 96px;
|
||||
}
|
||||
|
||||
.fc-view__title {
|
||||
font-size: 11px;
|
||||
color: var(--fc-accent-2);
|
||||
}
|
||||
|
||||
.fc-rec-form__row {
|
||||
display: flex;
|
||||
gap: var(--fc-space-2);
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.fc-rec-form__name { flex: 1; min-width: 150px; }
|
||||
.fc-rec-form__amount { width: 110px; }
|
||||
.fc-rec-form__day-wrap { width: 90px; flex-shrink: 0; }
|
||||
.fc-rec-form__day { width: 100%; }
|
||||
.fc-rec-form__cat { width: 160px; }
|
||||
|
||||
.fc-rec-form__error {
|
||||
color: var(--fc-red);
|
||||
font-size: 11px;
|
||||
margin-top: var(--fc-space-2);
|
||||
}
|
||||
|
||||
.fc-rec-form__actions {
|
||||
display: flex;
|
||||
gap: var(--fc-space-2);
|
||||
margin-top: var(--fc-space-3);
|
||||
}
|
||||
|
||||
/* List */
|
||||
.fc-rec-loading, .fc-rec-empty {
|
||||
font-size: 11px;
|
||||
color: var(--fc-text-dim);
|
||||
text-align: center;
|
||||
padding: var(--fc-space-4) 0;
|
||||
}
|
||||
|
||||
.fc-rec-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
.fc-settings-profile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--fc-space-2);
|
||||
gap: 6px;
|
||||
margin-bottom: var(--fc-space-4);
|
||||
}
|
||||
|
||||
.fc-rec-item {
|
||||
.fc-settings-profile__name {
|
||||
font-size: 18px;
|
||||
color: var(--fc-text);
|
||||
}
|
||||
.fc-settings-logout {
|
||||
width: 100%;
|
||||
}
|
||||
.fc-settings-pw {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
gap: var(--fc-space-3);
|
||||
padding: 12px var(--fc-space-3);
|
||||
border: 1px solid var(--fc-panel-edge);
|
||||
border-radius: var(--fc-radius);
|
||||
flex-wrap: wrap;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
|
||||
.fc-rec-item--editing {
|
||||
border-color: var(--fc-accent-3);
|
||||
box-shadow: 0 0 8px rgba(168,85,247,.3);
|
||||
.fc-settings-pw__msg {
|
||||
font-size: 11px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fc-rec-item__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.fc-rec-item__name { font-size: 14px; font-weight: 500; }
|
||||
.fc-rec-item__meta { font-size: 11px; color: var(--fc-text-dim); }
|
||||
.fc-rec-item__actions { display: flex; gap: var(--fc-space-1); }
|
||||
.fc-settings-pw__msg--err { color: var(--fc-red); }
|
||||
.fc-settings-pw__msg--ok { color: var(--fc-green); }
|
||||
</style>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 `<router-view>` em `<div class="fc-app">` — é isso que aplica o fundo + scanlines + grid.
|
||||
|
||||
```vue
|
||||
<!-- App.vue -->
|
||||
<template>
|
||||
<div class="fc-app">
|
||||
<AppHud />
|
||||
<RouterView />
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
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)
|
||||
|
||||
`<CharacterSprite>` lê `sprite-data.json` e aceita `theme` como prop. Cosméticos
|
||||
equipados são merged no store (Pinia) e passados como `theme`.
|
||||
|
||||
```vue
|
||||
<CharacterSprite :scale="6" :theme="gameStore.equippedTheme" :celebrate="gameStore.justLeveledUp" />
|
||||
```
|
||||
|
||||
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<string, string>)
|
||||
})
|
||||
```
|
||||
|
||||
### Usar PNG estático
|
||||
|
||||
Útil pra previews em página de loja (`/personagem` → seção "cosméticos"):
|
||||
|
||||
```html
|
||||
<img src="@/assets/sprites/manoel-lv03-bone-verolme-4x.png" alt="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.
|
||||
@@ -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 `<head>`:
|
||||
|
||||
```html
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="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" rel="stylesheet" />
|
||||
```
|
||||
|
||||
| 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 <cor>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
|
||||
<!-- NeonPanel.vue -->
|
||||
<template>
|
||||
<div class="fc-panel" :class="{ 'fc-panel--glow': glow, [`fc-panel--${variant}`]: variant }">
|
||||
<span v-if="corners" class="fc-corner fc-corner--tl" />
|
||||
<span v-if="corners" class="fc-corner fc-corner--tr" />
|
||||
<span v-if="corners" class="fc-corner fc-corner--bl" />
|
||||
<span v-if="corners" class="fc-corner fc-corner--br" />
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
glow?: boolean
|
||||
corners?: boolean
|
||||
variant?: 'danger' | 'success'
|
||||
}>()
|
||||
</script>
|
||||
```
|
||||
|
||||
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
|
||||
<div class="fc-panel-title">:: GASTOS · CATEGORIA</div>
|
||||
```
|
||||
|
||||
```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
|
||||
<template>
|
||||
<div class="fc-bar" :class="{ 'fc-bar--success': success }">
|
||||
<div class="fc-bar__fill" :style="{ width: pct + '%' }" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ pct: number; success?: boolean }>()
|
||||
</script>
|
||||
```
|
||||
|
||||
```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
|
||||
<header class="fc-hud">
|
||||
<div class="fc-hud__logo">
|
||||
<span class="fc-hud__logo-mark" />
|
||||
<div>
|
||||
<div class="fc-pixel" style="font-size: 10px; color: var(--fc-accent-2)">CARVALHO</div>
|
||||
<div class="fc-pixel" style="font-size: 7px; color: var(--fc-text-dim)">FIN.SYS v1.4</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fc-hud__sep" />
|
||||
<div class="fc-hud__stats">
|
||||
<HUDStat label="LV" :value="user.level" color="var(--fc-gold)" />
|
||||
<HUDStat label="XP" :value="user.xp" :sub="`/${user.xpNext}`" color="var(--fc-accent-2)" />
|
||||
<HUDStat label="STREAK" :value="`${user.streak}d`" color="var(--fc-accent)" />
|
||||
<HUDStat label="META" value="47%" sub="/40%" color="var(--fc-green)" />
|
||||
</div>
|
||||
<nav class="fc-hud__nav">
|
||||
<!-- chip-shaped buttons, one per route -->
|
||||
</nav>
|
||||
</header>
|
||||
```
|
||||
|
||||
```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
|
||||
<!-- CharacterSprite.vue -->
|
||||
<template>
|
||||
<svg :width="cols * scale" :height="rows * scale"
|
||||
:viewBox="`0 0 ${cols * scale} ${rows * scale}`"
|
||||
shape-rendering="crispEdges"
|
||||
:class="['fc-sprite', { 'fc-sprite--bob': bob, 'fc-sprite--celebrate': celebrate }]">
|
||||
<rect v-for="px in pixels" :key="`${px.x}-${px.y}`"
|
||||
:x="px.x * scale" :y="px.y * scale" :width="scale" :height="scale"
|
||||
:fill="colors[px.k]" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import spriteData from '@/assets/sprite-data.json'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
scale?: number
|
||||
theme?: Partial<Record<string,string>>
|
||||
bob?: boolean
|
||||
celebrate?: boolean
|
||||
}>(), { scale: 4, bob: true })
|
||||
|
||||
const rows = spriteData.rows
|
||||
const cols = spriteData.cols
|
||||
|
||||
const defaults: Record<string,string> = {
|
||||
L: '#10131c', s: '#f3c79b', S: '#c98863', e: '#10131c', m: '#80484b',
|
||||
h: '#ffd84d', w: '#fde68a',
|
||||
c: '#00f0ff', C: '#0369a1',
|
||||
p: '#7c5a2e', P: '#4f3a1e',
|
||||
b: '#0f172a',
|
||||
}
|
||||
|
||||
const colors = computed(() => ({ ...defaults, ...(props.theme || {}) }))
|
||||
|
||||
const pixels = computed(() => {
|
||||
const out: { x: number, y: number, k: string }[] = []
|
||||
spriteData.grid.forEach((row: string, y: number) => {
|
||||
for (let x = 0; x < row.length; x++) {
|
||||
const k = row[x]
|
||||
if (k === '.' || !colors.value[k]) continue
|
||||
out.push({ x, y, k })
|
||||
}
|
||||
})
|
||||
return out
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-sprite { image-rendering: pixelated; line-height: 0; }
|
||||
@keyframes fc-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-3px); } }
|
||||
.fc-sprite--bob { animation: fc-bob 2.2s steps(2) infinite; }
|
||||
@keyframes fc-celebrate { 0%,100% { transform: translateY(0) rotate(0); } 25% { transform: translateY(-6px) rotate(-3deg); } 75% { transform: translateY(-6px) rotate(3deg); } }
|
||||
.fc-sprite--celebrate { animation: fc-celebrate 1s ease-in-out infinite; }
|
||||
</style>
|
||||
```
|
||||
|
||||
### 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<Record<'h'|'w'|'c'|'C'|'p'|'P'|'b', string>>
|
||||
}
|
||||
```
|
||||
|
||||
O store (Pinia) tem um getter `equippedTheme` que faz o merge das cores dos itens equipados. Esse objeto vai como prop `theme` para `<CharacterSprite>`.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
```
|
||||
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<svg
|
||||
:width="cols * scale"
|
||||
:height="rows * scale"
|
||||
:viewBox="`0 0 ${cols * scale} ${rows * scale}`"
|
||||
shape-rendering="crispEdges"
|
||||
class="fc-sprite"
|
||||
:class="{ 'fc-sprite--bob': bob && !celebrate, 'fc-sprite--celebrate': celebrate }"
|
||||
aria-label="Personagem pixel art"
|
||||
>
|
||||
<rect
|
||||
v-for="(px, i) in pixels"
|
||||
:key="i"
|
||||
:x="px.x * scale"
|
||||
:y="px.y * scale"
|
||||
:width="scale"
|
||||
:height="scale"
|
||||
:fill="colors[px.k]"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Renders Manoel as a 17x24 pixel sprite.
|
||||
*
|
||||
* The pixel grid is imported from `sprite-data.json`. Each cell's character
|
||||
* is a color *slot key* — '.' is transparent, otherwise the key maps into
|
||||
* `defaultColors` (which the user can override via the `theme` prop).
|
||||
*
|
||||
* Cosmetics work by overriding slots. For example, `Camisa Tripulante` sets
|
||||
* `{ c: '#0ea5e9', C: '#0369a1' }`. Merge equipped cosmetics' color maps in
|
||||
* your store and pass the result here.
|
||||
*/
|
||||
|
||||
import { computed } from 'vue'
|
||||
// In Vite/TS, ensure `resolveJsonModule: true` and adjust import path:
|
||||
import spriteData from '../sprites/sprite-data.json'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** Pixel zoom factor. 2 = mobile, 3 = widget, 4 = personagem hero (default), 6 = focus. */
|
||||
scale?: number
|
||||
/** Per-slot color overrides (cosmetics). Keys are sprite-data color slots. */
|
||||
theme?: Partial<Record<string, string>>
|
||||
/** Idle bob animation. Off when celebrate is on. */
|
||||
bob?: boolean
|
||||
/** Celebration pose (use after quest complete, ~3s). */
|
||||
celebrate?: boolean
|
||||
}>(), {
|
||||
scale: 4,
|
||||
bob: true,
|
||||
})
|
||||
|
||||
const rows = spriteData.rows
|
||||
const cols = spriteData.cols
|
||||
|
||||
const defaultColors: Record<string, string> = {
|
||||
L: 'var(--fc-sprite-outline, #10131c)',
|
||||
s: 'var(--fc-sprite-skin, #f3c79b)',
|
||||
S: 'var(--fc-sprite-skin-shade, #c98863)',
|
||||
e: 'var(--fc-sprite-eye, #10131c)',
|
||||
m: 'var(--fc-sprite-mouth, #80484b)',
|
||||
h: 'var(--fc-sprite-hat, #ffd84d)',
|
||||
w: 'var(--fc-sprite-band, #fde68a)',
|
||||
c: 'var(--fc-sprite-shirt, #00f0ff)',
|
||||
C: 'var(--fc-sprite-shirt-shade, #0369a1)',
|
||||
p: 'var(--fc-sprite-pants, #3a1f6e)',
|
||||
P: 'var(--fc-sprite-pants-shade, #2d1857)',
|
||||
b: 'var(--fc-sprite-boots, #0f172a)',
|
||||
}
|
||||
|
||||
const colors = computed(() => ({ ...defaultColors, ...(props.theme ?? {}) }))
|
||||
|
||||
const pixels = computed(() => {
|
||||
const out: { x: number; y: number; k: string }[] = []
|
||||
spriteData.grid.forEach((row: string, y: number) => {
|
||||
for (let x = 0; x < row.length; x++) {
|
||||
const k = row[x]
|
||||
if (k === '.' || !colors.value[k]) continue
|
||||
out.push({ x, y, k })
|
||||
}
|
||||
})
|
||||
return out
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-sprite {
|
||||
image-rendering: pixelated;
|
||||
display: inline-block;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
@keyframes fc-sprite-bob {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-3px); }
|
||||
}
|
||||
.fc-sprite--bob { animation: fc-sprite-bob 2.2s steps(2) infinite; }
|
||||
|
||||
@keyframes fc-sprite-celebrate {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
25% { transform: translateY(-6px) rotate(-3deg); }
|
||||
75% { transform: translateY(-6px) rotate(3deg); }
|
||||
}
|
||||
.fc-sprite--celebrate { animation: fc-sprite-celebrate 1s ease-in-out infinite; }
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="fc-hud-stat">
|
||||
<div class="fc-hud-stat__label">{{ label }}</div>
|
||||
<div class="fc-hud-stat__value">
|
||||
<span :style="{ color, textShadow: `0 0 8px ${color}66` }">{{ value }}</span>
|
||||
<span v-if="sub" class="fc-hud-stat__sub">{{ sub }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
/** Short uppercase label, ex: "LV", "XP", "STREAK", "META". */
|
||||
label: string
|
||||
/** Main value, displayed in pixel font. */
|
||||
value: string | number
|
||||
/** Optional fraction-style suffix, ex: "/4900" or "/40%". */
|
||||
sub?: string
|
||||
/** CSS color string (use var(--fc-*) tokens). Default = text color. */
|
||||
color?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-hud-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.fc-hud-stat__label {
|
||||
font-family: var(--fc-font-pixel);
|
||||
font-size: 7px;
|
||||
color: var(--fc-text-dim);
|
||||
letter-spacing: .06em;
|
||||
}
|
||||
.fc-hud-stat__value {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
font-family: var(--fc-font-pixel);
|
||||
font-size: 13px;
|
||||
}
|
||||
.fc-hud-stat__sub {
|
||||
font-family: var(--fc-font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--fc-text-dim);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div
|
||||
class="fc-panel"
|
||||
:class="{ 'fc-panel--glow': glow, [`fc-panel--${variant}`]: !!variant }"
|
||||
>
|
||||
<template v-if="corners">
|
||||
<span class="fc-corner fc-corner--tl" />
|
||||
<span class="fc-corner fc-corner--tr" />
|
||||
<span class="fc-corner fc-corner--bl" />
|
||||
<span class="fc-corner fc-corner--br" />
|
||||
</template>
|
||||
|
||||
<header v-if="title || $slots.header" class="fc-panel__header">
|
||||
<div v-if="title" class="fc-panel__title">:: {{ title }}</div>
|
||||
<slot name="header" />
|
||||
</header>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
/** Painel "principal" da rota: borda roxa + halo. Use com parcimônia. */
|
||||
glow?: boolean
|
||||
/** Cantos em L decorativos. Reserve pro card de destaque. */
|
||||
corners?: boolean
|
||||
/** 'danger' (recorrência ausente, meta falhou) | 'success' */
|
||||
variant?: 'danger' | 'success'
|
||||
/** Título estilo HUD; renderizado automaticamente como `:: NOME`. */
|
||||
title?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.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: var(--fc-space-4);
|
||||
box-shadow: var(--fc-shadow-panel);
|
||||
}
|
||||
|
||||
.fc-panel--glow {
|
||||
border-color: var(--fc-accent-3);
|
||||
box-shadow: var(--fc-shadow-panel-glow);
|
||||
}
|
||||
|
||||
.fc-panel--danger {
|
||||
border-color: var(--fc-red);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255,255,255,.05),
|
||||
0 0 0 1px var(--fc-red),
|
||||
0 0 16px rgba(255, 59, 107, .3),
|
||||
0 8px 24px rgba(0,0,0,.4);
|
||||
}
|
||||
|
||||
.fc-panel--success {
|
||||
border-color: var(--fc-green);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255,255,255,.05),
|
||||
0 0 0 1px var(--fc-green),
|
||||
0 0 16px rgba(57, 255, 122, .25),
|
||||
0 8px 24px rgba(0,0,0,.4);
|
||||
}
|
||||
|
||||
.fc-panel__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--fc-space-3);
|
||||
gap: var(--fc-space-2);
|
||||
}
|
||||
|
||||
.fc-panel__title {
|
||||
font-family: var(--fc-font-pixel);
|
||||
font-size: 9px;
|
||||
color: var(--fc-accent-2);
|
||||
letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.fc-corner {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border: 2px solid var(--fc-accent-2);
|
||||
pointer-events: none;
|
||||
}
|
||||
.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; }
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div class="fc-bar" :class="[`fc-bar--${size}`, { 'fc-bar--success': success, 'fc-bar--danger': danger }]">
|
||||
<div class="fc-bar__fill" :style="{ width: clamped + '%' }" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** Percentual 0–100 (ou maior, será clampado). */
|
||||
pct: number
|
||||
/** 'sm' (6px) | 'md' (8px) | 'lg' (14px) */
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
/** Pinta de verde quando meta foi atingida. */
|
||||
success?: boolean
|
||||
/** Pinta de vermelho. */
|
||||
danger?: boolean
|
||||
}>(), { size: 'md' })
|
||||
|
||||
const clamped = computed(() => Math.max(0, Math.min(100, props.pct)))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-bar {
|
||||
background: var(--fc-bg);
|
||||
border: 2px solid var(--fc-panel-edge);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.fc-bar--sm { height: 6px; }
|
||||
.fc-bar--md { height: 8px; }
|
||||
.fc-bar--lg { height: 14px; }
|
||||
|
||||
.fc-bar__fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--fc-accent-2), var(--fc-accent-3), var(--fc-accent));
|
||||
position: relative;
|
||||
transition: width .6s cubic-bezier(.2,.7,.3,1);
|
||||
}
|
||||
.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));
|
||||
}
|
||||
|
||||
.fc-bar--danger { border-color: var(--fc-red); }
|
||||
.fc-bar--danger .fc-bar__fill {
|
||||
background: linear-gradient(90deg, var(--fc-red), var(--fc-accent));
|
||||
}
|
||||
</style>
|
||||
@@ -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;-
|
||||
|
@@ -0,0 +1,454 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Financeiro Carvalho · Style Guide · Arcade Neon</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<link rel="stylesheet" href="tokens.css" />
|
||||
<style>
|
||||
/* preview-page-only chrome */
|
||||
body { padding: 40px 32px 80px; max-width: 1200px; margin: 0 auto; }
|
||||
h1 { font-family: var(--fc-font-pixel); font-size: 18px; color: var(--fc-accent-2); margin: 0 0 8px; }
|
||||
h2 { font-family: var(--fc-font-pixel); font-size: 13px; color: var(--fc-accent); margin: 56px 0 6px; padding-bottom: 14px; border-bottom: 1px dashed var(--fc-panel-edge); letter-spacing: .04em; }
|
||||
h2::before { content: ":: "; color: var(--fc-text-dim); }
|
||||
p.lede { font-family: var(--fc-font-body); color: var(--fc-text-dim); margin: 0 0 32px; max-width: 720px; line-height: 1.6; }
|
||||
h3 { font-family: var(--fc-font-pixel); font-size: 10px; color: var(--fc-text); letter-spacing: .04em; margin: 28px 0 12px; }
|
||||
.grid { display: grid; gap: 16px; }
|
||||
.g2 { grid-template-columns: 1fr 1fr; }
|
||||
.g3 { grid-template-columns: 1fr 1fr 1fr; }
|
||||
.g4 { grid-template-columns: repeat(4, 1fr); }
|
||||
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||
|
||||
/* swatch card */
|
||||
.swatch { background: linear-gradient(180deg, var(--fc-bg-raised), var(--fc-bg-panel));
|
||||
border: 2px solid var(--fc-panel-edge); border-radius: 4px;
|
||||
padding: 0; overflow: hidden; box-shadow: var(--fc-shadow-panel); }
|
||||
.swatch__chip { height: 80px; }
|
||||
.swatch__body { padding: 10px 12px; }
|
||||
.swatch__token { font-family: var(--fc-font-mono); font-size: 11px; color: var(--fc-text); font-weight: 600; }
|
||||
.swatch__hex { font-family: var(--fc-font-mono); font-size: 10px; color: var(--fc-text-dim); margin-top: 3px; }
|
||||
.swatch__note { font-family: var(--fc-font-body); font-size: 11px; color: var(--fc-text-dim); margin-top: 6px; line-height: 1.4; }
|
||||
|
||||
/* tokens / labels */
|
||||
.label { font-family: var(--fc-font-pixel); font-size: 7px; color: var(--fc-text-dim); letter-spacing: .06em; margin-bottom: 6px; }
|
||||
|
||||
/* generic demo panel */
|
||||
.demo {
|
||||
background: linear-gradient(180deg, var(--fc-bg-raised), var(--fc-bg-panel));
|
||||
border: 2px solid var(--fc-panel-edge);
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
box-shadow: var(--fc-shadow-panel);
|
||||
position: relative;
|
||||
}
|
||||
.demo--glow { border-color: var(--fc-accent-3); box-shadow: var(--fc-shadow-panel-glow); }
|
||||
.demo--danger { border-color: var(--fc-red); box-shadow: 0 0 0 1px var(--fc-red), 0 0 16px rgba(255,59,107,.3); }
|
||||
|
||||
.corner { position: absolute; width: 8px; height: 8px; border: 2px solid var(--fc-accent-2); }
|
||||
.corner.tl { top:-2px; left:-2px; border-right:none; border-bottom:none; }
|
||||
.corner.tr { top:-2px; right:-2px; border-left:none; border-bottom:none; }
|
||||
.corner.bl { bottom:-2px; left:-2px; border-right:none; border-top:none; }
|
||||
.corner.br { bottom:-2px; right:-2px; border-left:none; border-top:none; }
|
||||
|
||||
.ptitle { font-family: var(--fc-font-pixel); font-size: 9px; color: var(--fc-accent-2); margin-bottom: 12px; letter-spacing: .04em; }
|
||||
|
||||
/* buttons */
|
||||
.btn {
|
||||
font-family: var(--fc-font-pixel); 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;
|
||||
}
|
||||
.btn:hover { background: var(--fc-accent-3); color: white; box-shadow: 0 0 16px rgba(168,85,247,.6); }
|
||||
.btn--pink { border-color: var(--fc-accent); }
|
||||
.btn--pink:hover { background: var(--fc-accent); box-shadow: 0 0 16px rgba(255,45,149,.6); }
|
||||
.btn--cyan { border-color: var(--fc-accent-2); color: var(--fc-accent-2); }
|
||||
.btn--cyan:hover { background: var(--fc-accent-2); color: var(--fc-bg); box-shadow: 0 0 16px rgba(0,240,255,.6); }
|
||||
|
||||
/* chips */
|
||||
.chip { display: inline-block; font-family: var(--fc-font-pixel); font-size: 7px;
|
||||
padding: 4px 6px; border-radius: 2px; letter-spacing: .05em; color: #000; }
|
||||
.chip--magenta { background: var(--fc-accent); }
|
||||
.chip--cyan { background: var(--fc-accent-2); }
|
||||
.chip--gold { background: var(--fc-gold); }
|
||||
.chip--green { background: var(--fc-green); }
|
||||
.chip--red { background: var(--fc-red); color: #fff; }
|
||||
|
||||
/* bar */
|
||||
.bar { height: 14px; background: var(--fc-bg); border: 2px solid var(--fc-panel-edge); position: relative; overflow: hidden; }
|
||||
.bar__fill { height: 100%;
|
||||
background: linear-gradient(90deg, var(--fc-accent-2), var(--fc-accent-3), var(--fc-accent));
|
||||
position: relative; }
|
||||
.bar__fill::after { content:''; position:absolute; inset:0;
|
||||
background-image: repeating-linear-gradient(90deg, rgba(0,0,0,.2) 0 2px, transparent 2px 6px); }
|
||||
.bar--success { border-color: var(--fc-green); }
|
||||
.bar--success .bar__fill { background: linear-gradient(90deg, var(--fc-green), var(--fc-accent-2)); }
|
||||
|
||||
/* link */
|
||||
.link { color: var(--fc-accent-2); font-family: var(--fc-font-pixel); font-size: 8px;
|
||||
cursor: pointer; text-decoration: none; letter-spacing: .04em; }
|
||||
.link:hover { text-decoration: underline; text-shadow: 0 0 8px var(--fc-accent-2); }
|
||||
|
||||
/* font sample */
|
||||
.font-sample { display: grid; grid-template-columns: 140px 1fr; align-items: baseline; gap: 16px; padding: 12px 0; border-bottom: 1px dashed var(--fc-panel-edge); }
|
||||
.font-sample__meta { font-family: var(--fc-font-mono); font-size: 11px; color: var(--fc-text-dim); }
|
||||
.font-sample__demo .pix { font-family: var(--fc-font-pixel); }
|
||||
.font-sample__demo .mon { font-family: var(--fc-font-mono); }
|
||||
|
||||
/* sprite cards */
|
||||
.sprite-card { background: linear-gradient(180deg, var(--fc-bg-raised), var(--fc-bg-panel));
|
||||
border: 2px solid var(--fc-panel-edge); border-radius: 4px;
|
||||
padding: 16px 12px 12px; text-align: center;
|
||||
box-shadow: var(--fc-shadow-panel); }
|
||||
.sprite-card svg, .sprite-card img { display: block; margin: 0 auto 10px; image-rendering: pixelated; }
|
||||
.sprite-card__name { font-family: var(--fc-font-pixel); font-size: 8px; color: var(--fc-text); margin-bottom: 4px; }
|
||||
.sprite-card__meta { font-family: var(--fc-font-pixel); font-size: 6px; color: var(--fc-gold); letter-spacing: .06em; }
|
||||
|
||||
/* example HUD */
|
||||
.hud-demo {
|
||||
display: flex; align-items: center; gap: 16px; padding: 12px 20px;
|
||||
border: 2px solid var(--fc-panel-edge);
|
||||
background: linear-gradient(180deg, var(--fc-bg-panel), var(--fc-bg));
|
||||
border-radius: 4px;
|
||||
box-shadow: var(--fc-shadow-panel);
|
||||
}
|
||||
.hud-demo__logo { display: flex; align-items: center; gap: 10px; }
|
||||
.hud-demo__mark { width: 28px; height: 28px; background: var(--fc-accent); box-shadow: 0 0 12px var(--fc-accent); position: relative; }
|
||||
.hud-demo__mark::before { content:''; position:absolute; inset:5px; background: var(--fc-bg); }
|
||||
.hud-demo__mark::after { content:''; position:absolute; top:9px; left:9px; width:10px; height:10px; background: var(--fc-accent-2); }
|
||||
.stat { display: flex; flex-direction: column; gap: 2px; }
|
||||
.stat__l { font-family: var(--fc-font-pixel); font-size: 7px; color: var(--fc-text-dim); }
|
||||
.stat__v { display: flex; align-items: baseline; gap: 4px; font-family: var(--fc-font-pixel); font-size: 13px; }
|
||||
.stat__s { font-family: var(--fc-font-mono); font-size: 10px; color: var(--fc-text-dim); }
|
||||
|
||||
code { font-family: var(--fc-font-mono); font-size: 11px; color: var(--fc-accent-2); background: rgba(0,240,255,.08); padding: 1px 6px; border-radius: 3px; }
|
||||
|
||||
hr { border: 0; border-top: 1px dashed var(--fc-panel-edge); margin: 32px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="fc-app">
|
||||
|
||||
<header>
|
||||
<div class="hud-demo">
|
||||
<div class="hud-demo__logo">
|
||||
<div class="hud-demo__mark"></div>
|
||||
<div>
|
||||
<div class="fc-pixel" style="font-size:10px;color:var(--fc-accent-2)">CARVALHO</div>
|
||||
<div class="fc-pixel" style="font-size:7px;color:var(--fc-text-dim);margin-top:2px">STYLE GUIDE · ARCADE NEON</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex:1"></div>
|
||||
<div class="stat">
|
||||
<span class="stat__l">VER</span>
|
||||
<span class="stat__v" style="color:var(--fc-gold);text-shadow:0 0 8px rgba(255,216,77,.4)">1.0</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat__l">DATA</span>
|
||||
<span class="stat__v" style="color:var(--fc-accent-2);font-size:11px">26/05/26</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<h1 style="margin-top:32px">Style Guide · Financeiro Carvalho</h1>
|
||||
<p class="lede">Referência visual da direção <strong>Arcade Neon</strong>. Use junto do <code>STYLE_GUIDE.md</code>. Tokens vivem em <code>tokens.css</code> — esta página apenas os exibe. Componentes Vue prontos pra copiar estão em <code>components/</code>.</p>
|
||||
|
||||
<!-- ─────────────── COLORS ─────────────── -->
|
||||
<h2>Colors</h2>
|
||||
|
||||
<h3>Estrutura (escuro)</h3>
|
||||
<div class="grid g4">
|
||||
<div class="swatch"><div class="swatch__chip" style="background:#0a0418"></div><div class="swatch__body"><div class="swatch__token">--fc-bg</div><div class="swatch__hex">#0a0418</div><div class="swatch__note">Fundo base do app</div></div></div>
|
||||
<div class="swatch"><div class="swatch__chip" style="background:#150827"></div><div class="swatch__body"><div class="swatch__token">--fc-bg-raised</div><div class="swatch__hex">#150827</div><div class="swatch__note">Topo do gradiente do painel</div></div></div>
|
||||
<div class="swatch"><div class="swatch__chip" style="background:#1c0d33"></div><div class="swatch__body"><div class="swatch__token">--fc-bg-panel</div><div class="swatch__hex">#1c0d33</div><div class="swatch__note">Base do gradiente do painel</div></div></div>
|
||||
<div class="swatch"><div class="swatch__chip" style="background:#2d1857"></div><div class="swatch__body"><div class="swatch__token">--fc-panel-edge</div><div class="swatch__hex">#2d1857</div><div class="swatch__note">Borda padrão de painel + dividers</div></div></div>
|
||||
</div>
|
||||
|
||||
<h3>Texto</h3>
|
||||
<div class="grid g2">
|
||||
<div class="swatch"><div class="swatch__chip" style="background:#f4eaff"></div><div class="swatch__body"><div class="swatch__token">--fc-text</div><div class="swatch__hex">#f4eaff</div><div class="swatch__note">Texto principal. Off-white com tom lilás.</div></div></div>
|
||||
<div class="swatch"><div class="swatch__chip" style="background:#8b75b8"></div><div class="swatch__body"><div class="swatch__token">--fc-text-dim</div><div class="swatch__hex">#8b75b8</div><div class="swatch__note">Texto secundário, labels, sub.</div></div></div>
|
||||
</div>
|
||||
|
||||
<h3>Acentos</h3>
|
||||
<div class="grid g4">
|
||||
<div class="swatch"><div class="swatch__chip" style="background:#ff2d95;box-shadow:inset 0 0 24px rgba(0,0,0,.3)"></div><div class="swatch__body"><div class="swatch__token">--fc-accent</div><div class="swatch__hex">#ff2d95</div><div class="swatch__note">Magenta · alertas suaves, quest diária, CTA primário accent.</div></div></div>
|
||||
<div class="swatch"><div class="swatch__chip" style="background:#00f0ff"></div><div class="swatch__body"><div class="swatch__token">--fc-accent-2</div><div class="swatch__hex">#00f0ff</div><div class="swatch__note">Ciano · links, navegação ativa, XP, quest semanal.</div></div></div>
|
||||
<div class="swatch"><div class="swatch__chip" style="background:#a855f7"></div><div class="swatch__body"><div class="swatch__token">--fc-accent-3</div><div class="swatch__hex">#a855f7</div><div class="swatch__note">Roxo · botão padrão, painel em destaque, glow.</div></div></div>
|
||||
<div class="swatch"><div class="swatch__chip" style="background:#ffd84d"></div><div class="swatch__body"><div class="swatch__token">--fc-gold</div><div class="swatch__hex">#ffd84d</div><div class="swatch__note">XP, conquistas, quest mensal.</div></div></div>
|
||||
</div>
|
||||
|
||||
<h3>Estado</h3>
|
||||
<div class="grid g2">
|
||||
<div class="swatch"><div class="swatch__chip" style="background:#39ff7a"></div><div class="swatch__body"><div class="swatch__token">--fc-green</div><div class="swatch__hex">#39ff7a</div><div class="swatch__note">Meta atingida, valores positivos.</div></div></div>
|
||||
<div class="swatch"><div class="swatch__chip" style="background:#ff3b6b"></div><div class="swatch__body"><div class="swatch__token">--fc-red</div><div class="swatch__hex">#ff3b6b</div><div class="swatch__note">Meta falhou, recorrência ausente, alerta crítico.</div></div></div>
|
||||
</div>
|
||||
|
||||
<!-- ─────────────── TYPOGRAPHY ─────────────── -->
|
||||
<h2>Typography</h2>
|
||||
|
||||
<div class="demo" style="padding: 0 20px">
|
||||
<div class="font-sample">
|
||||
<div class="font-sample__meta">Press Start 2P<br><span style="color:var(--fc-text-dim);font-size:9px">pixel · labels & numbers</span></div>
|
||||
<div class="font-sample__demo">
|
||||
<div class="pix" style="font-size:7px;color:var(--fc-text-dim)">:: LABEL HUD ::</div>
|
||||
<div class="pix" style="font-size:9px;color:var(--fc-accent-2);margin-top:6px">:: TÍTULO DE PAINEL</div>
|
||||
<div class="pix" style="font-size:14px;color:var(--fc-text);margin-top:6px">MAIO · 2026</div>
|
||||
<div class="pix" style="font-size:40px;color:var(--fc-green);text-shadow:0 0 12px #39ff7a88;margin-top:8px">47%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="font-sample">
|
||||
<div class="font-sample__meta">JetBrains Mono<br><span style="color:var(--fc-text-dim);font-size:9px">mono · valores</span></div>
|
||||
<div class="font-sample__demo">
|
||||
<div class="mon" style="font-size:10px;color:var(--fc-text-dim)">26/05/26 · Itaú · Crédito</div>
|
||||
<div class="mon" style="font-size:11px;color:var(--fc-text);margin-top:4px">Mercado Pão de Açúcar</div>
|
||||
<div class="mon" style="font-size:16px;color:var(--fc-text);font-weight:600;margin-top:4px">R$ 7.844,21</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="font-sample" style="border-bottom:none">
|
||||
<div class="font-sample__meta">Inter<br><span style="color:var(--fc-text-dim);font-size:9px">body · descrições</span></div>
|
||||
<div class="font-sample__demo">
|
||||
<div class="fc-body" style="font-size:13px;color:var(--fc-text);line-height:1.55;max-width:520px">
|
||||
Mantenha gastos com veleiro abaixo de R$ 500 neste mês. Recompensa: <span class="fc-text-gold">+180 XP</span>. Última atualização há 2h.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─────────────── PANELS ─────────────── -->
|
||||
<h2>Panels</h2>
|
||||
<p class="lede">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.</p>
|
||||
|
||||
<div class="grid g2">
|
||||
<div class="demo">
|
||||
<div class="ptitle">:: PAINEL PADRÃO</div>
|
||||
<p class="fc-body" style="margin:0;color:var(--fc-text);font-size:13px;line-height:1.5">Container neutro. Use pra listar transações, categorias, contas, qualquer dado tabular.</p>
|
||||
</div>
|
||||
<div class="demo demo--glow">
|
||||
<div class="ptitle">:: PAINEL COM GLOW</div>
|
||||
<p class="fc-body" style="margin:0;color:var(--fc-text);font-size:13px;line-height:1.5">Borda roxa + halo. Reservado pro card mais importante da rota.</p>
|
||||
</div>
|
||||
<div class="demo">
|
||||
<span class="corner tl"></span><span class="corner tr"></span><span class="corner bl"></span><span class="corner br"></span>
|
||||
<div class="ptitle">:: PAINEL COM CANTOS EM L</div>
|
||||
<p class="fc-body" style="margin:0;color:var(--fc-text);font-size:13px;line-height:1.5">Decoração HUD opcional. Use no hero ou no card-de-status do personagem.</p>
|
||||
</div>
|
||||
<div class="demo demo--danger">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
||||
<span class="fc-blink" style="color:var(--fc-red);font-size:16px">▲</span>
|
||||
<span class="fc-pixel" style="font-size:8px;color:var(--fc-red)">RECORRÊNCIA AUSENTE</span>
|
||||
</div>
|
||||
<p class="fc-body" style="margin:0;color:var(--fc-text);font-size:13px">Variante <code>danger</code>. Borda vermelha + halo + triângulo pulsante.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─────────────── BUTTONS ─────────────── -->
|
||||
<h2>Buttons</h2>
|
||||
<p class="lede">Texto sempre Press Start 2P em maiúscula. Verbo + (seta opcional). Hover acende o glow da cor da borda.</p>
|
||||
<div class="row">
|
||||
<button class="btn">REGISTRAR ▶</button>
|
||||
<button class="btn btn--pink">IMPORTAR EXTRATO</button>
|
||||
<button class="btn btn--cyan">VER DETALHES</button>
|
||||
</div>
|
||||
|
||||
<!-- ─────────────── CHIPS ─────────────── -->
|
||||
<h2>Chips</h2>
|
||||
<p class="lede">Tags pequenas. Sempre Press Start 2P 7px maiúsculo. A cor é semântica.</p>
|
||||
<div class="row">
|
||||
<span class="chip chip--magenta">DIÁRIA</span>
|
||||
<span class="chip chip--cyan">SEMANAL</span>
|
||||
<span class="chip chip--gold">MENSAL</span>
|
||||
<span class="chip chip--green">QUEST OK</span>
|
||||
<span class="chip chip--red">QUEST FAIL</span>
|
||||
</div>
|
||||
|
||||
<!-- ─────────────── BARS ─────────────── -->
|
||||
<h2>Bars (XP, progresso)</h2>
|
||||
<p class="lede">Sempre 2px de borda. Fill com gradiente diagonal + listras internas — assinatura visual.</p>
|
||||
|
||||
<div class="demo">
|
||||
<div style="display:flex;justify-content:space-between;margin-bottom:6px">
|
||||
<span class="fc-pixel" style="font-size:7px;color:var(--fc-text-dim)">XP</span>
|
||||
<span class="fc-mono" style="font-size:10px;color:var(--fc-text-dim)">3240 / 4900</span>
|
||||
</div>
|
||||
<div class="bar" style="height:14px">
|
||||
<div class="bar__fill" style="width:66%"></div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;margin:14px 0 6px">
|
||||
<span class="fc-pixel" style="font-size:7px;color:var(--fc-text-dim)">PROGRESSO QUEST · DIÁRIA</span>
|
||||
<span class="fc-mono" style="font-size:10px;color:var(--fc-text-dim)">3 / 5</span>
|
||||
</div>
|
||||
<div class="bar" style="height:6px"><div class="bar__fill" style="width:60%"></div></div>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;margin:14px 0 6px">
|
||||
<span class="fc-pixel" style="font-size:7px;color:var(--fc-text-dim)">META POUPANÇA · ATINGIDA</span>
|
||||
<span class="fc-mono" style="font-size:10px;color:var(--fc-green)">47%</span>
|
||||
</div>
|
||||
<div class="bar bar--success" style="height:14px"><div class="bar__fill" style="width:47%"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- ─────────────── LINKS ─────────────── -->
|
||||
<h2>Links (text actions)</h2>
|
||||
<p class="lede">Verbo curto + seta. Sempre Press Start 2P em ciano. Hover ganha glow.</p>
|
||||
<div class="row">
|
||||
<a class="link">VER+</a>
|
||||
<a class="link">ABRIR ▶</a>
|
||||
<a class="link">REGISTRAR ▶</a>
|
||||
</div>
|
||||
|
||||
<!-- ─────────────── HUD ─────────────── -->
|
||||
<h2>HUD (top bar)</h2>
|
||||
<p class="lede">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.</p>
|
||||
<div class="hud-demo">
|
||||
<div class="hud-demo__logo">
|
||||
<div class="hud-demo__mark"></div>
|
||||
<div>
|
||||
<div class="fc-pixel" style="font-size:10px;color:var(--fc-accent-2)">CARVALHO</div>
|
||||
<div class="fc-pixel" style="font-size:7px;color:var(--fc-text-dim);margin-top:2px">FIN.SYS v1.4</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="width:2px;height:32px;background:var(--fc-panel-edge);margin-inline:8px"></div>
|
||||
<div class="stat">
|
||||
<span class="stat__l">LV</span>
|
||||
<span class="stat__v"><span style="color:var(--fc-gold);text-shadow:0 0 8px #ffd84d66">7</span></span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat__l">XP</span>
|
||||
<span class="stat__v"><span style="color:var(--fc-accent-2);text-shadow:0 0 8px #00f0ff66">3240</span><span class="stat__s">/4900</span></span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat__l">STREAK</span>
|
||||
<span class="stat__v"><span style="color:var(--fc-accent);text-shadow:0 0 8px #ff2d9566">23d</span></span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat__l">META</span>
|
||||
<span class="stat__v"><span style="color:var(--fc-green);text-shadow:0 0 8px #39ff7a66">47%</span><span class="stat__s">/40%</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─────────────── SPRITES ─────────────── -->
|
||||
<h2>Pixel Sprites</h2>
|
||||
<p class="lede">Personagem é 17×24 pixels. Cores parametrizadas por slot (hat / shirt / pants / boots). Cosméticos do banco fazem merge desses slots em runtime.</p>
|
||||
|
||||
<h3>Escalas canônicas</h3>
|
||||
<div class="grid g4" id="scales"></div>
|
||||
|
||||
<h3>Variantes por cosmético</h3>
|
||||
<div class="grid g4" id="variants"></div>
|
||||
|
||||
<!-- ─────────────── ANIMATIONS ─────────────── -->
|
||||
<h2>Animations</h2>
|
||||
<p class="lede">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.</p>
|
||||
<div class="grid g3">
|
||||
<div class="demo" style="text-align:center">
|
||||
<div class="ptitle">IDLE BOB</div>
|
||||
<div id="bob-demo" style="line-height:0;margin:14px 0 8px"></div>
|
||||
<span class="fc-mono" style="font-size:10px;color:var(--fc-text-dim)">animation: 2.2s steps(2)</span>
|
||||
</div>
|
||||
<div class="demo" style="text-align:center">
|
||||
<div class="ptitle">CELEBRATE</div>
|
||||
<div id="celebrate-demo" style="line-height:0;margin:14px 0 8px"></div>
|
||||
<span class="fc-mono" style="font-size:10px;color:var(--fc-text-dim)">animation: 1s ease-in-out</span>
|
||||
</div>
|
||||
<div class="demo" style="text-align:center">
|
||||
<div class="ptitle">ALERT BLINK</div>
|
||||
<div style="font-size:48px;line-height:1;margin:14px 0 8px"><span class="fc-blink" style="color:var(--fc-red);text-shadow:0 0 14px #ff3b6b">▲</span></div>
|
||||
<span class="fc-mono" style="font-size:10px;color:var(--fc-text-dim)">animation: 1.2s steps(1)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─────────────── SHADOWS ─────────────── -->
|
||||
<h2>Shadows & Glow</h2>
|
||||
<div class="grid g3">
|
||||
<div style="background:var(--fc-bg-raised);border:2px solid var(--fc-panel-edge);padding:24px;border-radius:4px;text-align:center;
|
||||
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)">
|
||||
<div class="fc-pixel" style="font-size:8px;color:var(--fc-accent-2)">PADRÃO</div>
|
||||
<div class="fc-mono" style="font-size:9px;color:var(--fc-text-dim);margin-top:6px">--fc-shadow-panel</div>
|
||||
</div>
|
||||
<div style="background:var(--fc-bg-raised);border:2px solid var(--fc-accent-3);padding:24px;border-radius:4px;text-align:center;
|
||||
box-shadow:var(--fc-shadow-panel-glow)">
|
||||
<div class="fc-pixel" style="font-size:8px;color:var(--fc-accent-3)">GLOW</div>
|
||||
<div class="fc-mono" style="font-size:9px;color:var(--fc-text-dim);margin-top:6px">--fc-shadow-panel-glow</div>
|
||||
</div>
|
||||
<div style="background:var(--fc-bg-raised);border:2px solid var(--fc-red);padding:24px;border-radius:4px;text-align:center;
|
||||
box-shadow:0 0 0 1px var(--fc-red), 0 0 16px rgba(255,59,107,.3), 0 8px 24px rgba(0,0,0,.4)">
|
||||
<div class="fc-pixel" style="font-size:8px;color:var(--fc-red)">DANGER</div>
|
||||
<div class="fc-mono" style="font-size:9px;color:var(--fc-text-dim);margin-top:6px">--fc-red border + halo</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<p class="lede" style="margin-top:24px;font-size:11px">
|
||||
<span class="fc-pixel" style="font-size:8px;color:var(--fc-text-dim)">v1.0 · </span>
|
||||
Style guide gerado automaticamente. Para regenerar sprites: rode <code>run.js</code> em <code>sprites/</code>. Para perguntas, abra <code>STYLE_GUIDE.md</code> primeiro.
|
||||
</p>
|
||||
|
||||
<script>
|
||||
// Render sprite scales + variants from sprite-data.json
|
||||
fetch('sprites/sprite-data.json').then(r => r.json()).then(data => {
|
||||
const GRID = data.grid;
|
||||
const COLS = data.cols;
|
||||
const ROWS = data.rows;
|
||||
const DEFAULTS = data._legend;
|
||||
|
||||
function spriteSvg(theme, scale, className) {
|
||||
const w = COLS * scale, h = ROWS * scale;
|
||||
let rects = '';
|
||||
for (let y = 0; y < ROWS; y++) {
|
||||
for (let x = 0; x < COLS; x++) {
|
||||
const k = GRID[y][x];
|
||||
if (k === '.' || !k) continue;
|
||||
const color = (theme && theme[k]) || DEFAULTS[k];
|
||||
if (!color) continue;
|
||||
rects += `<rect x="${x*scale}" y="${y*scale}" width="${scale}" height="${scale}" fill="${color}"/>`;
|
||||
}
|
||||
}
|
||||
return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" shape-rendering="crispEdges" class="${className||''}" style="image-rendering:pixelated">${rects}</svg>`;
|
||||
}
|
||||
|
||||
// Scales
|
||||
const defaultTheme = data.cosmetics.find(c => c.id === 'default').theme;
|
||||
document.getElementById('scales').innerHTML = [
|
||||
{ scale: 2, label: 'scale=2', sub: 'mobile inline' },
|
||||
{ scale: 3, label: 'scale=3', sub: 'dashboard widget' },
|
||||
{ scale: 4, label: 'scale=4', sub: 'padrão' },
|
||||
{ scale: 6, label: 'scale=6', sub: 'personagem hero' },
|
||||
].map(({scale, label, sub}) => `
|
||||
<div class="sprite-card">
|
||||
${spriteSvg(defaultTheme, scale)}
|
||||
<div class="sprite-card__name">${label.toUpperCase()}</div>
|
||||
<div class="sprite-card__meta">${sub.toUpperCase()}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Variants
|
||||
document.getElementById('variants').innerHTML = data.cosmetics.map(cos => `
|
||||
<div class="sprite-card">
|
||||
${spriteSvg(cos.theme || {}, 4)}
|
||||
<div class="sprite-card__name">${cos.name.toUpperCase()}</div>
|
||||
<div class="sprite-card__meta">LV ${cos.unlockLv}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Bob demo
|
||||
document.getElementById('bob-demo').innerHTML = spriteSvg(defaultTheme, 3, 'demo-bob');
|
||||
document.getElementById('celebrate-demo').innerHTML = spriteSvg(defaultTheme, 3, 'demo-celebrate');
|
||||
|
||||
// animations
|
||||
const s = document.createElement('style');
|
||||
s.textContent = `
|
||||
@keyframes b-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-3px); } }
|
||||
.demo-bob { animation: b-bob 2.2s steps(2) infinite; }
|
||||
@keyframes b-cel { 0%,100% { transform: translateY(0) rotate(0); } 25% { transform: translateY(-6px) rotate(-3deg); } 75% { transform: translateY(-6px) rotate(3deg); } }
|
||||
.demo-celebrate { animation: b-cel 1s ease-in-out infinite; }
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
}).catch(e => {
|
||||
document.getElementById('scales').innerHTML = '<p style="color:var(--fc-red)">Erro carregando sprites: ' + e.message + '. Abra este arquivo via servidor HTTP, não com file://.</p>';
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 884 B |
|
After Width: | Height: | Size: 472 B |
|
After Width: | Height: | Size: 874 B |
|
After Width: | Height: | Size: 465 B |
|
After Width: | Height: | Size: 883 B |
|
After Width: | Height: | Size: 472 B |
|
After Width: | Height: | Size: 878 B |
|
After Width: | Height: | Size: 476 B |
|
After Width: | Height: | Size: 884 B |
|
After Width: | Height: | Size: 472 B |
|
After Width: | Height: | Size: 872 B |
|
After Width: | Height: | Size: 464 B |
|
After Width: | Height: | Size: 864 B |
|
After Width: | Height: | Size: 455 B |
|
After Width: | Height: | Size: 3.9 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 <router-view> in <div class="fc-app"> */
|
||||
.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; }
|
||||