fix(profile): visual frame previews in shop + sidebar avatar sync

Shop items of type 'frame' now render a real avatar-sized preview with
the frame's CSS applied (instead of a generic lucide icon) so buyers
see exactly what they're paying for. Title items get a tag-shaped
preview in their color. The avatar-frames section above the shop also
shows the user's actual avatar inside the frame circles, not 'LS' text.

Sidebar nav-avatar now:
  • renders the uploaded avatar_url instead of always showing initials
    (LS.initPage + new LS.refreshNavAvatar helper)
  • picks up frame CSS on every page via applyCosmetics — previously
    only dashboard.html applied it
  • repaints immediately after picking/deleting an avatar preset
    (avPickPreset / avDelete now call LS.setUser + LS.refreshNavAvatar)

Backend getMyActive resolves avatar_frame to {id, css} for both
gamification frames ('fire', 'crown', ...) and shop-purchased frames
('shop_<id>'), so the client doesn't need a second round-trip to
look up the CSS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-29 15:04:27 +03:00
parent 8e39993bb0
commit 46d373752c
4 changed files with 124 additions and 9 deletions
+24 -5
View File
@@ -1,4 +1,24 @@
const db = require('../db/db');
const { AVATAR_FRAMES } = require('./gamification/_shared');
/* Resolve a frame slug to { id, css }. Slug can be either a gamification
frame id (e.g. 'fire', 'crown') or a shop-purchased frame stored as
'shop_<itemId>'. Returns null for 'default' / unknown. */
function resolveFrame(slug) {
if (!slug || slug === 'default') return null;
if (slug.startsWith('shop_')) {
const itemId = Number(slug.slice(5));
if (!Number.isFinite(itemId)) return null;
const item = db.prepare('SELECT data FROM shop_items WHERE id = ? AND type = ?').get(itemId, 'frame');
if (!item) return null;
try {
const data = JSON.parse(item.data || '{}');
return { id: slug, css: data.css || '' };
} catch { return { id: slug, css: '' }; }
}
const f = AVATAR_FRAMES.find(fr => fr.id === slug);
return f ? { id: f.id, css: f.css || '' } : null;
}
/* ═══════════════════════════════════════════════════════════════════════
Shop — Items, Purchases, Coins
@@ -71,11 +91,10 @@ function getMyActive(req, res) {
// Resolve full data for each active item
const result = { frame: null, title: null, effect: null };
// Frame from avatar_frame (gamification frames) — handled separately
// Shop frame override
if (u.avatar_frame && u.avatar_frame !== 'default') {
result.frame = { id: u.avatar_frame };
}
// Frame: resolve either a gamification frame ('fire', 'crown', ...) or
// a shop-purchased one ('shop_<id>') to { id, css } so applyCosmetics
// on the client can render it without an extra round-trip.
result.frame = resolveFrame(u.avatar_frame);
if (u.active_title) {
const item = db.prepare('SELECT data FROM shop_items WHERE id = ?').get(u.active_title);