feat(#22): gamificação RPG — XP, quests, conquistas, cosméticos e personagem SVG pixel art

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
2026-05-26 21:46:41 -03:00
co-authored by Claude Sonnet 4.6
parent 9a964423fd
commit c1964ea036
21 changed files with 1346 additions and 10 deletions
+13 -2
View File
@@ -52,13 +52,17 @@ func main() {
categorySvc := service.NewCategoryService(categoryRepo)
categoryHandler := handler.NewCategoryHandler(categorySvc)
gameRepo := repository.NewGameRepository(pool)
gameSvc := service.NewGameService(gameRepo)
gameHandler := handler.NewGameHandler(gameSvc)
transactionRepo := repository.NewTransactionRepository(pool)
importSvc := service.NewImportService(transactionRepo)
importHandler := handler.NewImportHandler(importSvc)
importHandler := handler.NewImportHandler(importSvc, gameSvc)
manualTxRepo := repository.NewManualTransactionRepository(pool)
txSvc := service.NewTransactionService(manualTxRepo)
txHandler := handler.NewTransactionHandler(txSvc)
txHandler := handler.NewTransactionHandler(txSvc, gameSvc)
recurringRepo := repository.NewRecurringRepository(pool)
recurringSvc := service.NewRecurringService(recurringRepo, manualTxRepo)
@@ -102,6 +106,13 @@ func main() {
r.Delete("/accounts/{id}", accountHandler.Delete)
r.Get("/dashboard", dashboardHandler.Get)
r.Get("/game/summary", gameHandler.Summary)
r.Post("/game/xp", gameHandler.AwardXP)
r.Post("/game/quests/{id}/claim", gameHandler.ClaimQuest)
r.Get("/game/achievements", gameHandler.Achievements)
r.Get("/game/cosmetics", gameHandler.Cosmetics)
r.Post("/game/cosmetics/{id}/equip", gameHandler.EquipCosmetic)
})
// Serve Vue SPA — non-API routes fall through to index.html
+9
View File
@@ -7,3 +7,12 @@ require (
github.com/jackc/pgx/v5 v5.7.2
github.com/joho/godotenv v1.5.1
)
require (
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
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/text v0.21.0 // indirect
)
+90
View File
@@ -0,0 +1,90 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"financeiro-carvalho/internal/service"
)
type GameHandler struct {
svc *service.GameService
}
func NewGameHandler(svc *service.GameService) *GameHandler {
return &GameHandler{svc: svc}
}
func (h *GameHandler) Summary(w http.ResponseWriter, r *http.Request) {
s, err := h.svc.GetSummary(r.Context())
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get game summary")
return
}
respondJSON(w, http.StatusOK, s)
}
func (h *GameHandler) AwardXP(w http.ResponseWriter, r *http.Request) {
var body struct {
EventType string `json:"event_type"`
Amount int `json:"amount"`
Description string `json:"description"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
respondError(w, http.StatusBadRequest, "invalid JSON")
return
}
p, err := h.svc.AwardXP(r.Context(), body.EventType, body.Amount, body.Description)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to award xp")
return
}
respondJSON(w, http.StatusOK, p)
}
func (h *GameHandler) ClaimQuest(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
respondError(w, http.StatusBadRequest, "invalid id")
return
}
if err := h.svc.ClaimQuest(r.Context(), id); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *GameHandler) Achievements(w http.ResponseWriter, r *http.Request) {
list, err := h.svc.ListAllAchievements(r.Context())
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list achievements")
return
}
respondJSON(w, http.StatusOK, list)
}
func (h *GameHandler) Cosmetics(w http.ResponseWriter, r *http.Request) {
list, err := h.svc.ListCosmetics(r.Context())
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list cosmetics")
return
}
respondJSON(w, http.StatusOK, list)
}
func (h *GameHandler) EquipCosmetic(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
respondError(w, http.StatusBadRequest, "invalid id")
return
}
if err := h.svc.EquipCosmetic(r.Context(), id); err != nil {
respondError(w, http.StatusInternalServerError, "failed to equip cosmetic")
return
}
w.WriteHeader(http.StatusNoContent)
}
+5 -3
View File
@@ -11,11 +11,12 @@ import (
const maxUploadSize = 10 << 20 // 10 MB
type ImportHandler struct {
svc *service.ImportService
svc *service.ImportService
gameSvc *service.GameService
}
func NewImportHandler(svc *service.ImportService) *ImportHandler {
return &ImportHandler{svc: svc}
func NewImportHandler(svc *service.ImportService, gameSvc *service.GameService) *ImportHandler {
return &ImportHandler{svc: svc, gameSvc: gameSvc}
}
// Preview parses the uploaded file and returns rows with duplicate flags.
@@ -78,5 +79,6 @@ func (h *ImportHandler) Confirm(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "failed to save transactions")
return
}
h.gameSvc.NotifyAction(r.Context(), "import_confirmed", map[string]any{"count": result.Imported})
respondJSON(w, http.StatusOK, result)
}
+5 -3
View File
@@ -14,11 +14,12 @@ import (
)
type TransactionHandler struct {
svc *service.TransactionService
svc *service.TransactionService
gameSvc *service.GameService
}
func NewTransactionHandler(svc *service.TransactionService) *TransactionHandler {
return &TransactionHandler{svc: svc}
func NewTransactionHandler(svc *service.TransactionService, gameSvc *service.GameService) *TransactionHandler {
return &TransactionHandler{svc: svc, gameSvc: gameSvc}
}
func (h *TransactionHandler) List(w http.ResponseWriter, r *http.Request) {
@@ -42,6 +43,7 @@ func (h *TransactionHandler) Create(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusBadRequest, err.Error())
return
}
h.gameSvc.NotifyAction(r.Context(), "transaction_created", map[string]any{"category_id": out.CategoryID})
respondJSON(w, http.StatusCreated, out)
}
+13 -1
View File
@@ -20,8 +20,20 @@ var m003 string
//go:embed sql/004_accounts.sql
var m004 string
//go:embed sql/005_game_profile.sql
var m005 string
//go:embed sql/006_quests.sql
var m006 string
//go:embed sql/007_achievements.sql
var m007 string
//go:embed sql/008_cosmetics.sql
var m008 string
func Run(ctx context.Context, pool *pgxpool.Pool) error {
migrations := []string{m001, m002, m003, m004}
migrations := []string{m001, m002, m003, m004, m005, m006, m007, m008}
for i, sql := range migrations {
if _, err := pool.Exec(ctx, sql); err != nil {
return fmt.Errorf("migration %03d: %w", i+1, err)
@@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS player_profile (
id SERIAL PRIMARY KEY,
level INTEGER NOT NULL DEFAULT 1,
xp INTEGER NOT NULL DEFAULT 0,
xp_to_next INTEGER NOT NULL DEFAULT 100
);
INSERT INTO player_profile (level, xp, xp_to_next) VALUES (1, 0, 100)
ON CONFLICT DO NOTHING;
CREATE TABLE IF NOT EXISTS xp_events (
id SERIAL PRIMARY KEY,
event_type VARCHAR(50) NOT NULL,
xp_earned INTEGER NOT NULL,
description TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
INSERT INTO schema_migrations (version) VALUES (5) ON CONFLICT DO NOTHING;
@@ -0,0 +1,32 @@
CREATE TABLE IF NOT EXISTS quests (
id SERIAL PRIMARY KEY,
title VARCHAR(100) NOT NULL,
description TEXT,
quest_type VARCHAR(20) NOT NULL CHECK (quest_type IN ('daily','weekly','monthly')),
target_count INTEGER,
target_pct NUMERIC(5,2),
xp_reward INTEGER NOT NULL DEFAULT 50,
active BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE IF NOT EXISTS player_quests (
id SERIAL PRIMARY KEY,
quest_id INTEGER NOT NULL REFERENCES quests(id) ON DELETE CASCADE,
period VARCHAR(10) NOT NULL,
current_count INTEGER NOT NULL DEFAULT 0,
current_pct NUMERIC(5,2) NOT NULL DEFAULT 0,
completed BOOLEAN NOT NULL DEFAULT FALSE,
completed_at TIMESTAMP WITH TIME ZONE,
claimed BOOLEAN NOT NULL DEFAULT FALSE,
UNIQUE (quest_id, period)
);
INSERT INTO quests (title, description, quest_type, target_count, target_pct, xp_reward) VALUES
('Economista do Mês', 'Poupe ≥40% da receita este mês', 'monthly', NULL, 40, 200),
('Capitão Organizado', 'Importe 1 extrato esta semana', 'weekly', 1, NULL, 50),
('Categorizador Ativo', 'Categorize 5 transações hoje', 'daily', 5, NULL, 30),
('Controlador Semanal', 'Registre ao menos 3 transações esta semana', 'weekly', 3, NULL, 40),
('Mês Sem Surpresas', 'Cubra todas as recorrências do mês', 'monthly', NULL, NULL, 150)
ON CONFLICT DO NOTHING;
INSERT INTO schema_migrations (version) VALUES (6) ON CONFLICT DO NOTHING;
@@ -0,0 +1,30 @@
CREATE TABLE IF NOT EXISTS achievements (
id SERIAL PRIMARY KEY,
title VARCHAR(100) NOT NULL,
description TEXT,
icon VARCHAR(10) NOT NULL DEFAULT '🏆',
xp_reward INTEGER NOT NULL DEFAULT 100,
unlock_condition VARCHAR(100) NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS player_achievements (
id SERIAL PRIMARY KEY,
achievement_id INTEGER NOT NULL REFERENCES achievements(id) ON DELETE CASCADE UNIQUE,
earned_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
INSERT INTO achievements (title, description, icon, xp_reward, unlock_condition) VALUES
('Primeiro Passo', 'Registre sua primeira transação manual', '👣', 50, 'first_transaction'),
('Explorador de Extratos', 'Importe seu primeiro extrato', '📄', 100, 'first_import'),
('Meta Atingida!', 'Poupe 40% pela primeira vez', '🎯', 200, 'first_40pct'),
('Tri-Campeão', 'Poupe 40% por 3 meses seguidos', '🥇', 500, 'three_months_40pct'),
('Veleirista Econômico', 'Gaste menos de R$500 no Veleiro em um mês', '', 150, 'veleiro_under_500'),
('Nível 5', 'Alcance o nível 5', '', 100, 'level_5'),
('Nível 10', 'Alcance o nível 10', '🌟', 300, 'level_10'),
('Mestre das Categorias', 'Categorize 100 transações', '🗂️', 200, 'categorize_100'),
('Semana Perfeita', 'Importe extrato por 4 semanas seguidas', '📅', 250, 'four_weeks_import'),
('Guardião da Saúde', 'Registre gasto com Saúde/Insulina em 3 meses', '💉', 300, 'health_3months'),
('Mês Limpo', 'Todas as recorrências cobertas em um mês', '', 200, 'full_recurring_month')
ON CONFLICT DO NOTHING;
INSERT INTO schema_migrations (version) VALUES (7) ON CONFLICT DO NOTHING;
@@ -0,0 +1,25 @@
CREATE TABLE IF NOT EXISTS cosmetics (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
type VARCHAR(20) NOT NULL CHECK (type IN ('hat','outfit','accessory')),
unlock_level INTEGER NOT NULL,
css_data JSONB NOT NULL DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS player_cosmetics (
id SERIAL PRIMARY KEY,
cosmetic_id INTEGER NOT NULL REFERENCES cosmetics(id) ON DELETE CASCADE UNIQUE,
equipped BOOLEAN NOT NULL DEFAULT FALSE,
unlocked_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
INSERT INTO cosmetics (name, type, unlock_level, css_data) VALUES
('Chapéu de Aventureiro', 'hat', 2, '{"color":"#8B4513","accent":"#d4a85a"}'),
('Roupa de Marinheiro', 'outfit', 3, '{"primary":"#1e3a5f","accent":"#ffffff"}'),
('Óculos de Sol', 'accessory', 4, '{"color":"#1a1a1a"}'),
('Chapéu de Capitão', 'hat', 5, '{"color":"#1e293b","badge":"#ffd700"}'),
('Roupa Dourada', 'outfit', 7, '{"primary":"#d4af37","accent":"#ffd700"}'),
('Coroa de Campeão', 'hat', 10, '{"color":"#ffd700","gems":"#e11d48"}')
ON CONFLICT DO NOTHING;
INSERT INTO schema_migrations (version) VALUES (8) ON CONFLICT DO NOTHING;
+69
View File
@@ -0,0 +1,69 @@
package model
type PlayerProfile struct {
ID int `json:"id"`
Level int `json:"level"`
XP int `json:"xp"`
XPToNext int `json:"xp_to_next"`
}
type XPEvent struct {
ID int `json:"id"`
EventType string `json:"event_type"`
XPEarned int `json:"xp_earned"`
Description string `json:"description"`
CreatedAt string `json:"created_at"`
}
type Quest struct {
ID int `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
QuestType string `json:"quest_type"`
TargetCount *int `json:"target_count"`
TargetPct *float64 `json:"target_pct"`
XPReward int `json:"xp_reward"`
}
type PlayerQuest struct {
ID int `json:"id"`
QuestID int `json:"quest_id"`
Title string `json:"title"`
QuestType string `json:"quest_type"`
TargetCount *int `json:"target_count"`
TargetPct *float64 `json:"target_pct"`
XPReward int `json:"xp_reward"`
Period string `json:"period"`
CurrentCount int `json:"current_count"`
CurrentPct float64 `json:"current_pct"`
Completed bool `json:"completed"`
Claimed bool `json:"claimed"`
}
type Achievement struct {
ID int `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Icon string `json:"icon"`
XPReward int `json:"xp_reward"`
UnlockCondition string `json:"unlock_condition"`
Earned bool `json:"earned"`
EarnedAt string `json:"earned_at,omitempty"`
}
type Cosmetic struct {
ID int `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
UnlockLevel int `json:"unlock_level"`
CSSData string `json:"css_data"`
Unlocked bool `json:"unlocked"`
Equipped bool `json:"equipped"`
}
type GameSummary struct {
Profile PlayerProfile `json:"profile"`
ActiveQuests []PlayerQuest `json:"active_quests"`
RecentAchievements []Achievement `json:"recent_achievements"`
EquippedCosmetics []Cosmetic `json:"equipped_cosmetics"`
}
+282
View File
@@ -0,0 +1,282 @@
package repository
import (
"context"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"financeiro-carvalho/internal/model"
)
type GameRepository struct {
pool *pgxpool.Pool
}
func NewGameRepository(pool *pgxpool.Pool) *GameRepository {
return &GameRepository{pool: pool}
}
func (r *GameRepository) GetProfile(ctx context.Context) (*model.PlayerProfile, error) {
var p model.PlayerProfile
err := r.pool.QueryRow(ctx, `SELECT id, level, xp, xp_to_next FROM player_profile LIMIT 1`).
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) {
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)
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`).
Scan(&p.ID, &p.Level, &p.XP, &p.XPToNext)
if err != nil {
return nil, err
}
p.XP += amount
for p.XP >= p.XPToNext {
p.XP -= p.XPToNext
p.Level++
p.XPToNext = xpThreshold(p.Level)
}
_, 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
}
return &p, tx.Commit(ctx)
}
func (r *GameRepository) ListActiveQuests(ctx context.Context) ([]model.PlayerQuest, error) {
now := time.Now()
dailyPeriod := now.Format("2006-01-02")
weeklyPeriod := now.Format("2006") + "-W" + now.Format("01")
monthlyPeriod := now.Format("2006-01")
rows, err := r.pool.Query(ctx, `
SELECT q.id, q.title, q.quest_type, q.target_count, q.target_pct, q.xp_reward,
COALESCE(pq.id, 0),
COALESCE(pq.period, ''),
COALESCE(pq.current_count, 0),
COALESCE(pq.current_pct, 0),
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
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)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.PlayerQuest
for rows.Next() {
var pq model.PlayerQuest
var pqID int
if err := rows.Scan(&pq.QuestID, &pq.Title, &pq.QuestType, &pq.TargetCount, &pq.TargetPct,
&pq.XPReward, &pqID, &pq.Period, &pq.CurrentCount, &pq.CurrentPct, &pq.Completed, &pq.Claimed); err != nil {
return nil, err
}
pq.ID = pqID
if pq.Period == "" {
switch pq.QuestType {
case "daily":
pq.Period = dailyPeriod
case "weekly":
pq.Period = weeklyPeriod
case "monthly":
pq.Period = monthlyPeriod
}
}
out = append(out, pq)
}
return out, rows.Err()
}
func (r *GameRepository) EnsurePlayerQuest(ctx context.Context, questID int, period string) (int, error) {
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
RETURNING id
`, questID, period).Scan(&id)
return id, err
}
func (r *GameRepository) IncrementQuestCount(ctx context.Context, questType, period string, delta int) error {
_, err := r.pool.Exec(ctx, `
UPDATE player_quests pq
SET current_count = current_count + $1,
completed = (current_count + $1 >= q.target_count),
completed_at = CASE WHEN (current_count + $1 >= q.target_count) AND NOT completed THEN NOW() ELSE completed_at END
FROM quests q
WHERE pq.quest_id = q.id
AND q.quest_type = $2
AND pq.period = $3
AND q.target_count IS NOT NULL
AND NOT pq.claimed
`, delta, questType, period)
return err
}
func (r *GameRepository) UpdateQuestPct(ctx context.Context, period string, pct float64) error {
_, err := r.pool.Exec(ctx, `
UPDATE player_quests pq
SET current_pct = $1,
completed = ($1 >= q.target_pct),
completed_at = CASE WHEN ($1 >= q.target_pct) AND NOT completed THEN NOW() ELSE completed_at END
FROM quests q
WHERE pq.quest_id = q.id
AND q.quest_type = 'monthly'
AND pq.period = $2
AND q.target_pct IS NOT NULL
AND NOT pq.claimed
`, pct, period)
return err
}
func (r *GameRepository) ClaimQuest(ctx context.Context, playerQuestID int) (int, error) {
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
RETURNING q.xp_reward
`, playerQuestID).Scan(&xpReward)
return xpReward, err
}
func (r *GameRepository) ListAchievements(ctx context.Context) ([]model.Achievement, error) {
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
ORDER BY pa.earned_at DESC NULLS LAST, a.id
`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.Achievement
for rows.Next() {
var a model.Achievement
if err := rows.Scan(&a.ID, &a.Title, &a.Description, &a.Icon, &a.XPReward, &a.UnlockCondition, &a.Earned, &a.EarnedAt); err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
func (r *GameRepository) IsAchievementUnlocked(ctx context.Context, condition string) (bool, error) {
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)
return count > 0, err
}
func (r *GameRepository) UnlockAchievement(ctx context.Context, condition string) (int, error) {
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
RETURNING (SELECT xp_reward FROM achievements WHERE unlock_condition = $1)
`, condition).Scan(&xpReward)
return xpReward, err
}
func (r *GameRepository) ListCosmetics(ctx context.Context) ([]model.Cosmetic, error) {
var level int
_ = r.pool.QueryRow(ctx, `SELECT level FROM player_profile LIMIT 1`).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
ORDER BY c.unlock_level, c.id
`, level)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.Cosmetic
for rows.Next() {
var c model.Cosmetic
if err := rows.Scan(&c.ID, &c.Name, &c.Type, &c.UnlockLevel, &c.CSSData, &c.Unlocked, &c.Equipped); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
func (r *GameRepository) EquipCosmetic(ctx context.Context, cosmeticID int) error {
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)
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)
if err != nil {
return err
}
return tx.Commit(ctx)
}
func (r *GameRepository) UnlockCosmeticsForLevel(ctx context.Context, level int) error {
_, 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)
return err
}
+174
View File
@@ -0,0 +1,174 @@
package service
import (
"context"
"time"
"financeiro-carvalho/internal/model"
)
type GameRepo interface {
GetProfile(ctx context.Context) (*model.PlayerProfile, error)
AddXP(ctx context.Context, eventType string, amount int, description string) (*model.PlayerProfile, error)
ListActiveQuests(ctx context.Context) ([]model.PlayerQuest, error)
EnsurePlayerQuest(ctx context.Context, questID int, period string) (int, error)
IncrementQuestCount(ctx context.Context, questType, period string, delta int) error
UpdateQuestPct(ctx context.Context, period string, pct float64) error
ClaimQuest(ctx context.Context, playerQuestID int) (int, error)
ListAchievements(ctx context.Context) ([]model.Achievement, error)
IsAchievementUnlocked(ctx context.Context, condition string) (bool, error)
UnlockAchievement(ctx context.Context, condition string) (int, error)
ListCosmetics(ctx context.Context) ([]model.Cosmetic, error)
EquipCosmetic(ctx context.Context, cosmeticID int) error
UnlockCosmeticsForLevel(ctx context.Context, level int) error
}
type GameService struct {
repo GameRepo
}
func NewGameService(repo GameRepo) *GameService {
return &GameService{repo: repo}
}
func (s *GameService) GetProfile(ctx context.Context) (*model.PlayerProfile, error) {
return s.repo.GetProfile(ctx)
}
func (s *GameService) AwardXP(ctx context.Context, eventType string, amount int, description string) (*model.PlayerProfile, error) {
prevProfile, _ := s.repo.GetProfile(ctx)
profile, err := s.repo.AddXP(ctx, eventType, amount, description)
if err != nil {
return nil, err
}
// unlock cosmetics if levelled up
if prevProfile != nil && profile.Level > prevProfile.Level {
_ = s.repo.UnlockCosmeticsForLevel(ctx, profile.Level)
if profile.Level >= 5 {
s.tryUnlockAchievement(ctx, "level_5")
}
if profile.Level >= 10 {
s.tryUnlockAchievement(ctx, "level_10")
}
}
return profile, nil
}
func (s *GameService) tryUnlockAchievement(ctx context.Context, condition string) {
unlocked, _ := s.repo.IsAchievementUnlocked(ctx, condition)
if unlocked {
return
}
xp, err := s.repo.UnlockAchievement(ctx, condition)
if err == nil && xp > 0 {
_, _ = s.repo.AddXP(ctx, "achievement_"+condition, xp, "Achievement desbloqueado")
}
}
func (s *GameService) CheckAndUnlockAchievement(ctx context.Context, condition string) {
s.tryUnlockAchievement(ctx, condition)
}
func (s *GameService) GetSummary(ctx context.Context) (*model.GameSummary, error) {
profile, err := s.repo.GetProfile(ctx)
if err != nil {
return nil, err
}
quests, err := s.repo.ListActiveQuests(ctx)
if err != nil {
return nil, err
}
if quests == nil {
quests = []model.PlayerQuest{}
}
achievements, err := s.repo.ListAchievements(ctx)
if err != nil {
return nil, err
}
recent := []model.Achievement{}
for _, a := range achievements {
if a.Earned {
recent = append(recent, a)
if len(recent) >= 3 {
break
}
}
}
cosmetics, err := s.repo.ListCosmetics(ctx)
if err != nil {
return nil, err
}
equipped := []model.Cosmetic{}
for _, c := range cosmetics {
if c.Equipped {
equipped = append(equipped, c)
}
}
return &model.GameSummary{
Profile: *profile,
ActiveQuests: quests,
RecentAchievements: recent,
EquippedCosmetics: equipped,
}, nil
}
func (s *GameService) ClaimQuest(ctx context.Context, playerQuestID int) error {
xp, err := s.repo.ClaimQuest(ctx, playerQuestID)
if err != nil || xp == 0 {
return err
}
_, err = s.AwardXP(ctx, "quest_claimed", xp, "Quest concluída")
return err
}
func (s *GameService) ListAllAchievements(ctx context.Context) ([]model.Achievement, error) {
return s.repo.ListAchievements(ctx)
}
func (s *GameService) ListCosmetics(ctx context.Context) ([]model.Cosmetic, error) {
return s.repo.ListCosmetics(ctx)
}
func (s *GameService) EquipCosmetic(ctx context.Context, cosmeticID int) error {
return s.repo.EquipCosmetic(ctx, cosmeticID)
}
// NotifyAction is called by other handlers after key financial actions to award XP and update quests.
func (s *GameService) NotifyAction(ctx context.Context, action string, extra map[string]any) {
now := time.Now()
monthlyPeriod := now.Format("2006-01")
weeklyPeriod := now.Format("2006") + "-W" + now.Format("01")
dailyPeriod := now.Format("2006-01-02")
switch action {
case "transaction_created":
_, _ = s.AwardXP(ctx, "transaction_created", 5, "Transação registrada")
if catID, ok := extra["category_id"]; ok && catID != nil {
_, _ = s.AwardXP(ctx, "transaction_categorized", 10, "Transação categorizada")
_ = s.repo.IncrementQuestCount(ctx, "daily", dailyPeriod, 1)
_ = s.repo.IncrementQuestCount(ctx, "weekly", weeklyPeriod, 1)
}
s.tryUnlockAchievement(ctx, "first_transaction")
case "import_confirmed":
count, _ := extra["count"].(int)
if count < 1 {
count = 1
}
_, _ = s.AwardXP(ctx, "import_confirmed", 50, "Extrato importado")
_ = s.repo.IncrementQuestCount(ctx, "weekly", weeklyPeriod, 1)
s.tryUnlockAchievement(ctx, "first_import")
case "savings_checked":
pct, _ := extra["pct"].(float64)
_ = s.repo.UpdateQuestPct(ctx, monthlyPeriod, pct)
if pct >= 40 {
_, _ = s.AwardXP(ctx, "savings_goal_met", 200, "Meta de 40% atingida!")
s.tryUnlockAchievement(ctx, "first_40pct")
}
}
}
+98
View File
@@ -0,0 +1,98 @@
package service_test
import (
"context"
"testing"
"financeiro-carvalho/internal/model"
"financeiro-carvalho/internal/service"
)
type mockGameRepo struct {
profile model.PlayerProfile
achievements map[string]bool
}
func newMockGameRepo() *mockGameRepo {
return &mockGameRepo{
profile: model.PlayerProfile{ID: 1, Level: 1, XP: 0, XPToNext: 100},
achievements: map[string]bool{},
}
}
func (m *mockGameRepo) GetProfile(_ context.Context) (*model.PlayerProfile, error) {
cp := m.profile
return &cp, nil
}
func (m *mockGameRepo) AddXP(_ context.Context, _ string, amount int, _ string) (*model.PlayerProfile, error) {
m.profile.XP += amount
for m.profile.XP >= m.profile.XPToNext {
m.profile.XP -= m.profile.XPToNext
m.profile.Level++
m.profile.XPToNext = m.profile.Level * m.profile.Level * 100
}
cp := m.profile
return &cp, nil
}
func (m *mockGameRepo) ListActiveQuests(_ context.Context) ([]model.PlayerQuest, error) {
return nil, nil
}
func (m *mockGameRepo) EnsurePlayerQuest(_ context.Context, _ int, _ string) (int, error) { return 1, nil }
func (m *mockGameRepo) IncrementQuestCount(_ context.Context, _, _ string, _ int) error { return nil }
func (m *mockGameRepo) UpdateQuestPct(_ context.Context, _ string, _ float64) error { return nil }
func (m *mockGameRepo) ClaimQuest(_ context.Context, _ int) (int, error) { return 50, nil }
func (m *mockGameRepo) ListAchievements(_ context.Context) ([]model.Achievement, error) { return nil, nil }
func (m *mockGameRepo) IsAchievementUnlocked(_ context.Context, c string) (bool, error) {
return m.achievements[c], nil
}
func (m *mockGameRepo) UnlockAchievement(_ context.Context, c string) (int, error) {
m.achievements[c] = true
return 100, nil
}
func (m *mockGameRepo) ListCosmetics(_ context.Context) ([]model.Cosmetic, error) { return nil, nil }
func (m *mockGameRepo) EquipCosmetic(_ context.Context, _ int) error { return nil }
func (m *mockGameRepo) UnlockCosmeticsForLevel(_ context.Context, _ int) error { return nil }
func TestGame_XPAccumulates(t *testing.T) {
repo := newMockGameRepo()
svc := service.NewGameService(repo)
p, err := svc.AwardXP(context.Background(), "test", 50, "test")
if err != nil {
t.Fatal(err)
}
if p.XP != 50 {
t.Errorf("expected xp=50, got %d", p.XP)
}
}
func TestGame_LevelUpAtThreshold(t *testing.T) {
repo := newMockGameRepo()
svc := service.NewGameService(repo)
// level 1 threshold is 100 XP
p, err := svc.AwardXP(context.Background(), "test", 100, "test")
if err != nil {
t.Fatal(err)
}
if p.Level != 2 {
t.Errorf("expected level=2 after 100 XP, got %d", p.Level)
}
if p.XP != 0 {
t.Errorf("expected remaining xp=0, got %d", p.XP)
}
}
func TestGame_AchievementNotDuplicated(t *testing.T) {
repo := newMockGameRepo()
svc := service.NewGameService(repo)
svc.CheckAndUnlockAchievement(context.Background(), "first_transaction")
svc.CheckAndUnlockAchievement(context.Background(), "first_transaction")
// second call should be a no-op — XP only awarded once
// repo tracks it as unlocked after first call
if !repo.achievements["first_transaction"] {
t.Error("achievement should be marked unlocked")
}
}
+1
View File
@@ -10,6 +10,7 @@
<RouterLink to="/transacoes">Transações</RouterLink>
<RouterLink to="/importar">Importar</RouterLink>
<RouterLink to="/contas">Contas</RouterLink>
<RouterLink to="/personagem">Personagem</RouterLink>
<RouterLink to="/configuracoes">Configurações</RouterLink>
</nav>
</header>
@@ -0,0 +1,96 @@
<template>
<div class="character-wrapper" :class="{ celebrate }">
<svg class="character-svg" viewBox="0 0 32 48" xmlns="http://www.w3.org/2000/svg" shape-rendering="crispEdges">
<!-- hair -->
<rect x="10" y="2" width="12" height="4" :fill="hairColor" />
<rect x="8" y="4" width="16" height="2" :fill="hairColor" />
<!-- face -->
<rect x="9" y="6" width="14" height="12" fill="#f5c5a3" />
<!-- eyes -->
<rect x="11" y="10" width="3" height="3" fill="#1e293b" />
<rect x="18" y="10" width="3" height="3" fill="#1e293b" />
<!-- mouth -->
<rect x="13" y="15" width="6" height="2" fill="#e07070" />
<!-- neck -->
<rect x="13" y="18" width="6" height="3" fill="#f5c5a3" />
<!-- body (shirt) -->
<rect x="8" y="21" width="16" height="14" :fill="shirtColor" />
<!-- collar -->
<rect x="12" y="21" width="8" height="3" fill="#fff" opacity="0.4" />
<!-- arms -->
<rect x="2" y="21" width="6" height="12" :fill="shirtColor" />
<rect x="24" y="21" width="6" height="12" :fill="shirtColor" />
<!-- hands -->
<rect x="2" y="33" width="6" height="4" fill="#f5c5a3" />
<rect x="24" y="33" width="6" height="4" fill="#f5c5a3" />
<!-- pants -->
<rect x="8" y="35" width="7" height="10" :fill="pantsColor" />
<rect x="17" y="35" width="7" height="10" :fill="pantsColor" />
<!-- shoes -->
<rect x="7" y="45" width="8" height="3" :fill="shoeColor" />
<rect x="17" y="45" width="8" height="3" :fill="shoeColor" />
<!-- accessory slot -->
<slot name="accessory" />
</svg>
<div v-if="celebrate" class="sparkles"></div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Cosmetic } from '@/stores/game'
const props = defineProps<{
level: number
cosmetics?: Cosmetic[]
celebrate?: boolean
}>()
function equippedOfType(type: string) {
return props.cosmetics?.find(c => c.type === type && c.equipped)
}
function cssValue(type: string, fallback: string) {
const c = equippedOfType(type)
if (!c) return fallback
try {
const data = JSON.parse(c.css_data)
return data.color ?? fallback
} catch {
return fallback
}
}
const hairColor = computed(() => cssValue('hair', '#5a3a1a'))
const shirtColor = computed(() => cssValue('shirt', '#3b82f6'))
const pantsColor = computed(() => cssValue('pants', '#1e3a5f'))
const shoeColor = computed(() => cssValue('shoes', '#1e293b'))
</script>
<style scoped>
.character-wrapper {
position: relative;
display: inline-flex;
flex-direction: column;
align-items: center;
image-rendering: pixelated;
}
.character-svg {
width: 96px;
height: 144px;
image-rendering: pixelated;
transition: transform 0.15s;
}
.character-wrapper.celebrate .character-svg {
animation: bounce 0.5s ease infinite alternate;
}
@keyframes bounce {
from { transform: translateY(0); }
to { transform: translateY(-8px); }
}
.sparkles {
font-size: 1.5rem;
animation: fadein 0.3s ease;
}
@keyframes fadein { from { opacity: 0; } to { opacity: 1; } }
</style>
+42
View File
@@ -0,0 +1,42 @@
<template>
<div class="xp-bar-wrap">
<div class="xp-labels">
<span class="level-badge">Nv {{ level }}</span>
<span class="xp-text">{{ xp }} / {{ xpToNext }} XP</span>
</div>
<div class="xp-track">
<div class="xp-fill" :style="{ width: pct + '%' }" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
level: number
xp: number
xpToNext: number
}>()
const pct = computed(() => Math.min(100, Math.round((props.xp / props.xpToNext) * 100)))
</script>
<style scoped>
.xp-bar-wrap { display: flex; flex-direction: column; gap: 4px; }
.xp-labels { display: flex; justify-content: space-between; align-items: center; }
.level-badge {
background: #7c3aed; color: #fff; font-size: 0.75rem;
font-weight: 700; padding: 2px 8px; border-radius: 4px;
}
.xp-text { font-size: 0.75rem; color: var(--color-text-secondary, #888); }
.xp-track {
height: 10px; background: #e5e7eb; border-radius: 5px; overflow: hidden;
}
.xp-fill {
height: 100%;
background: linear-gradient(90deg, #7c3aed, #a855f7);
border-radius: 5px;
transition: width 0.4s ease;
}
</style>
+5
View File
@@ -29,6 +29,11 @@ const router = createRouter({
name: 'accounts',
component: () => import('../views/AccountsView.vue'),
},
{
path: '/personagem',
name: 'character',
component: () => import('../views/CharacterView.vue'),
},
{
path: '/configuracoes',
name: 'settings',
+80
View File
@@ -0,0 +1,80 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface PlayerProfile {
id: number
level: number
xp: number
xp_to_next: number
}
export interface PlayerQuest {
id: number
quest_id: number
title: string
quest_type: string
target_count: number | null
target_pct: number | null
xp_reward: number
period: string
current_count: number
current_pct: number
completed: boolean
claimed: boolean
}
export interface Achievement {
id: number
title: string
description: string
icon: string
xp_reward: number
unlock_condition: string
earned: boolean
earned_at: string
}
export interface Cosmetic {
id: number
name: string
type: string
unlock_level: number
css_data: string
unlocked: boolean
equipped: boolean
}
export interface GameSummary {
profile: PlayerProfile
active_quests: PlayerQuest[]
recent_achievements: Achievement[]
equipped_cosmetics: Cosmetic[]
}
export const useGameStore = defineStore('game', () => {
const summary = ref<GameSummary | null>(null)
const loading = ref(false)
async function fetchSummary() {
loading.value = true
try {
const res = await fetch('/api/game/summary')
if (!res.ok) throw new Error('failed')
summary.value = await res.json()
} finally {
loading.value = false
}
}
async function claimQuest(playerQuestId: number) {
await fetch(`/api/game/quests/${playerQuestId}/claim`, { method: 'POST' })
await fetchSummary()
}
async function equipCosmetic(cosmeticId: number) {
await fetch(`/api/game/cosmetics/${cosmeticId}/equip`, { method: 'POST' })
await fetchSummary()
}
return { summary, loading, fetchSummary, claimQuest, equipCosmetic }
})
+243
View File
@@ -0,0 +1,243 @@
<template>
<div class="character-page">
<h1 class="page-title">Personagem</h1>
<div v-if="store.loading" class="loading">Carregando...</div>
<template v-else-if="store.summary">
<!-- Profile panel -->
<div class="profile-panel card">
<div class="character-area">
<CharacterWidget
:level="store.summary.profile.level"
:cosmetics="allCosmetics"
:celebrate="justLeveledUp"
/>
</div>
<div class="profile-info">
<h2>Nível {{ store.summary.profile.level }}</h2>
<XPBar
:level="store.summary.profile.level"
:xp="store.summary.profile.xp"
:xp-to-next="store.summary.profile.xp_to_next"
/>
<p class="xp-hint">Continue registrando transações para ganhar XP!</p>
</div>
</div>
<!-- Quests -->
<section class="section">
<h2 class="section-title">Quests Ativas</h2>
<div v-if="store.summary.active_quests.length === 0" class="empty">Nenhuma quest ativa.</div>
<div class="quest-list">
<div
v-for="q in store.summary.active_quests"
:key="q.id"
class="quest-card card"
:class="{ completed: q.completed, claimed: q.claimed }"
>
<div class="quest-header">
<span class="quest-type-badge" :class="q.quest_type">{{ typeLabel(q.quest_type) }}</span>
<span class="quest-xp">+{{ q.xp_reward }} XP</span>
</div>
<p class="quest-title">{{ q.title }}</p>
<div class="quest-progress">
<template v-if="q.target_count !== null">
<div class="progress-track">
<div class="progress-fill" :style="{ width: countPct(q) + '%' }" />
</div>
<span class="progress-label">{{ q.current_count }} / {{ q.target_count }}</span>
</template>
<template v-else-if="q.target_pct !== null">
<div class="progress-track">
<div class="progress-fill" :style="{ width: Math.min(100, q.current_pct) + '%' }" />
</div>
<span class="progress-label">{{ q.current_pct.toFixed(1) }}% / {{ q.target_pct }}%</span>
</template>
</div>
<button
v-if="q.completed && !q.claimed"
class="btn-claim"
@click="claim(q.id)"
>Resgatar</button>
<span v-else-if="q.claimed" class="claimed-badge"> Resgatado</span>
</div>
</div>
</section>
<!-- Achievements -->
<section class="section">
<h2 class="section-title">Conquistas</h2>
<div class="achievement-list">
<div
v-for="a in achievements"
:key="a.id"
class="achievement-card card"
:class="{ earned: a.earned }"
>
<span class="achievement-icon">{{ a.icon || '🏆' }}</span>
<div class="achievement-info">
<p class="achievement-title">{{ a.title }}</p>
<p class="achievement-desc">{{ a.description }}</p>
<span v-if="a.earned" class="earned-tag">+{{ a.xp_reward }} XP conquistado</span>
<span v-else class="locked-tag">Bloqueado</span>
</div>
</div>
</div>
</section>
<!-- Cosmetics -->
<section class="section">
<h2 class="section-title">Cosméticos</h2>
<div class="cosmetic-list">
<div
v-for="c in allCosmetics"
:key="c.id"
class="cosmetic-card card"
:class="{ equipped: c.equipped, locked: !c.unlocked }"
@click="c.unlocked && !c.equipped && equip(c.id)"
>
<div class="cosmetic-swatch" :style="swatchStyle(c)" />
<p class="cosmetic-name">{{ c.name }}</p>
<p class="cosmetic-unlock">Nv {{ c.unlock_level }}</p>
<span v-if="c.equipped" class="equipped-tag">Equipado</span>
<span v-else-if="!c.unlocked" class="locked-tag">🔒</span>
</div>
</div>
</section>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useGameStore, type Achievement, type Cosmetic } from '@/stores/game'
import CharacterWidget from '@/components/CharacterWidget.vue'
import XPBar from '@/components/XPBar.vue'
const store = useGameStore()
const justLeveledUp = ref(false)
const achievements = ref<Achievement[]>([])
const allCosmetics = ref<Cosmetic[]>([])
onMounted(async () => {
await store.fetchSummary()
const [achRes, cosRes] = await Promise.all([
fetch('/api/game/achievements'),
fetch('/api/game/cosmetics'),
])
if (achRes.ok) achievements.value = await achRes.json()
if (cosRes.ok) allCosmetics.value = await cosRes.json()
})
function typeLabel(t: string) {
return { daily: 'Diária', weekly: 'Semanal', monthly: 'Mensal' }[t] ?? t
}
function countPct(q: { current_count: number; target_count: number | null }) {
if (!q.target_count) return 0
return Math.min(100, Math.round((q.current_count / q.target_count) * 100))
}
async function claim(id: number) {
await store.claimQuest(id)
justLeveledUp.value = true
setTimeout(() => (justLeveledUp.value = false), 2000)
}
async function equip(id: number) {
await store.equipCosmetic(id)
const res = await fetch('/api/game/cosmetics')
if (res.ok) allCosmetics.value = await res.json()
}
function swatchStyle(c: Cosmetic) {
try {
const data = JSON.parse(c.css_data)
return { background: data.color ?? '#ccc' }
} catch {
return { background: '#ccc' }
}
}
</script>
<style scoped>
.character-page { max-width: 860px; margin: 0 auto; padding: 1.5rem; }
.page-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 1.5rem; }
.loading { text-align: center; padding: 2rem; color: #888; }
.card {
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 12px;
padding: 1rem;
}
.profile-panel {
display: flex;
gap: 1.5rem;
align-items: center;
margin-bottom: 2rem;
}
.character-area { flex-shrink: 0; }
.profile-info { flex: 1; }
.profile-info h2 { font-size: 1.25rem; font-weight: 700; margin-bottom: 0.5rem; }
.xp-hint { font-size: 0.75rem; color: #888; margin-top: 0.5rem; }
.section { margin-bottom: 2rem; }
.section-title { font-size: 1.1rem; font-weight: 600; margin-bottom: 1rem; }
.empty { color: #888; font-size: 0.9rem; }
.quest-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 0.75rem; }
.quest-card { display: flex; flex-direction: column; gap: 0.5rem; }
.quest-card.claimed { opacity: 0.6; }
.quest-header { display: flex; justify-content: space-between; align-items: center; }
.quest-type-badge {
font-size: 0.65rem; font-weight: 700; text-transform: uppercase;
padding: 2px 6px; border-radius: 4px;
}
.quest-type-badge.daily { background: #fef3c7; color: #92400e; }
.quest-type-badge.weekly { background: #dbeafe; color: #1e40af; }
.quest-type-badge.monthly { background: #ede9fe; color: #5b21b6; }
.quest-xp { font-size: 0.75rem; font-weight: 600; color: #7c3aed; }
.quest-title { font-size: 0.9rem; font-weight: 500; }
.quest-progress { display: flex; flex-direction: column; gap: 4px; }
.progress-track { height: 8px; background: #e5e7eb; border-radius: 4px; overflow: hidden; }
.progress-fill { height: 100%; background: #7c3aed; border-radius: 4px; transition: width 0.3s; }
.progress-label { font-size: 0.7rem; color: #888; }
.btn-claim {
background: #7c3aed; color: #fff; border: none; border-radius: 6px;
padding: 6px 12px; font-size: 0.8rem; cursor: pointer; font-weight: 600;
}
.btn-claim:hover { background: #6d28d9; }
.claimed-badge { font-size: 0.75rem; color: #16a34a; font-weight: 600; }
.achievement-list {
display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 0.75rem;
}
.achievement-card {
display: flex; gap: 0.75rem; align-items: flex-start;
opacity: 0.5; filter: grayscale(1);
}
.achievement-card.earned { opacity: 1; filter: none; }
.achievement-icon { font-size: 1.75rem; flex-shrink: 0; }
.achievement-title { font-weight: 600; font-size: 0.9rem; }
.achievement-desc { font-size: 0.75rem; color: #888; margin-top: 2px; }
.earned-tag { font-size: 0.7rem; color: #7c3aed; font-weight: 600; }
.locked-tag { font-size: 0.7rem; color: #aaa; }
.cosmetic-list {
display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 0.75rem;
}
.cosmetic-card {
display: flex; flex-direction: column; align-items: center; gap: 0.5rem;
cursor: pointer; transition: border-color 0.2s;
}
.cosmetic-card:hover:not(.locked):not(.equipped) { border-color: #7c3aed; }
.cosmetic-card.equipped { border-color: #7c3aed; background: #ede9fe; }
.cosmetic-card.locked { opacity: 0.5; cursor: default; }
.cosmetic-swatch { width: 48px; height: 48px; border-radius: 8px; border: 2px solid #e5e7eb; }
.cosmetic-name { font-size: 0.8rem; font-weight: 500; text-align: center; }
.cosmetic-unlock { font-size: 0.7rem; color: #888; }
.equipped-tag { font-size: 0.65rem; color: #7c3aed; font-weight: 700; }
</style>
+16 -1
View File
@@ -1,11 +1,17 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useDashboardStore } from '@/stores/dashboard'
import { useGameStore } from '@/stores/game'
import XPBar from '@/components/XPBar.vue'
const store = useDashboardStore()
const gameStore = useGameStore()
const currentMonth = ref(new Date().toISOString().slice(0, 7))
onMounted(() => store.fetch(currentMonth.value))
onMounted(() => {
store.fetch(currentMonth.value)
gameStore.fetchSummary()
})
function changeMonth(delta: number) {
const [y, m] = currentMonth.value.split('-').map(Number)
@@ -83,6 +89,14 @@ const maxEvolution = computed(() => {
</div>
<div class="savings-target">todas as contas</div>
</div>
<RouterLink to="/personagem" class="card character-mini-card" v-if="gameStore.summary">
<div class="card-label">Personagem</div>
<XPBar
:level="gameStore.summary.profile.level"
:xp="gameStore.summary.profile.xp"
:xp-to-next="gameStore.summary.profile.xp_to_next"
/>
</RouterLink>
</div>
<!-- Bottom section: two columns on wide, stacked on mobile -->
@@ -194,6 +208,7 @@ h2 { font-size: 0.95rem; font-weight: 600; margin: 0 0 0.75rem; color: #374151;
.savings-ok .savings-pct { color: #059669; }
.savings-low .savings-pct { color: #dc2626; }
.savings-target { font-size: 0.7rem; color: #9ca3af; margin-top: 0.2rem; }
.character-mini-card { display: flex; flex-direction: column; gap: 0.5rem; text-decoration: none; }
/* Bottom grid */
.bottom-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-bottom: 0.75rem; }