polish(card): bigger/higher QR, theme-tinted scrim, profile-photo nudge

- QR 150->178px and moved up (bottom-right, above the identity block).
- Bottom scrim now fades into the THEME's dark background + an accent glow
  (was plain black) so the card reads as branded.
- Studio shows an 'add a profile photo' nudge when the account has no kind-0
  picture (the card's placeholder name was the tell).
This commit is contained in:
SkateSpots 2026-10-02 12:30:14 +07:00
parent 5ccc191b32
commit 00753b2769
4 changed files with 36 additions and 8 deletions

View file

@ -6385,6 +6385,8 @@ walletUi: {
derived: 'From your Skater profile', derived: 'From your Skater profile',
noSkaterProfile: 'No Skater profile yet —', noSkaterProfile: 'No Skater profile yet —',
editSkaterProfile: 'add your styles & level', editSkaterProfile: 'add your styles & level',
noPhoto: 'No profile photo yet —',
addPhoto: 'add one in your profile',
accent: 'Accent colour', accent: 'Accent colour',
accentHint: 'Overrides the theme accent', accentHint: 'Overrides the theme accent',
accentClear: 'Use theme colour', accentClear: 'Use theme colour',

View file

@ -6385,6 +6385,8 @@ walletUi: {
derived: 'Desde tu perfil de skater', derived: 'Desde tu perfil de skater',
noSkaterProfile: 'Aún no tienes perfil de skater —', noSkaterProfile: 'Aún no tienes perfil de skater —',
editSkaterProfile: 'añade tus estilos y nivel', editSkaterProfile: 'añade tus estilos y nivel',
noPhoto: 'Aún no tienes foto de perfil —',
addPhoto: 'añádela en tu perfil',
accent: 'Color de acento', accent: 'Color de acento',
accentHint: 'Sustituye el acento del tema', accentHint: 'Sustituye el acento del tema',
accentClear: 'Usar color del tema', accentClear: 'Usar color del tema',

View file

@ -73,10 +73,10 @@ export interface CanvasCardData {
// ─── geometry ───────────────────────────────────────────────────────────────── // ─── geometry ─────────────────────────────────────────────────────────────────
const X = 44; // left text margin const X = 44; // left text margin
const NAME_MAX = 560; // text column width (QR sits to the right) const NAME_MAX = 548; // text column width (QR sits to the right)
const QR_SIZE = 150; const QR_SIZE = 178;
const QR_X = CARD_W - X - QR_SIZE; // 662 const QR_X = CARD_W - X - QR_SIZE; // 634
const QR_Y = 322; const QR_Y = 232;
// ─── helpers ────────────────────────────────────────────────────────────────── // ─── helpers ──────────────────────────────────────────────────────────────────
async function loadImage(url: string): Promise<HTMLImageElement | null> { async function loadImage(url: string): Promise<HTMLImageElement | null> {
@ -104,6 +104,15 @@ function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: numbe
ctx.closePath(); ctx.closePath();
} }
/** '#rrggbb' (or '#rgb') → 'rgba(r, g, b, a)'. */
function hexToRgba(hex: string, a: number): string {
const h = hex.replace('#', '');
const v = h.length === 3 ? h.split('').map((c) => c + c).join('') : h;
const n = parseInt(v.slice(0, 6), 16);
if (Number.isNaN(n)) return `rgba(0,0,0,${a})`;
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${a})`;
}
function fitText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string { function fitText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string {
let t = text; let t = text;
while (ctx.measureText(t).width > maxWidth && t.length > 2) t = t.slice(0, -1); while (ctx.measureText(t).width > maxWidth && t.length > 2) t = t.slice(0, -1);
@ -421,12 +430,20 @@ export async function drawCardToCanvas(data: CanvasCardData, side: 'front' | 'ba
ctx.fillStyle = top; ctx.fillStyle = top;
ctx.fillRect(0, 0, CARD_W, 170); ctx.fillRect(0, 0, CARD_W, 170);
const bot = ctx.createLinearGradient(0, 210, 0, CARD_H); // Bottom scrim — tints with the THEME (not plain black) so the card feels
// branded: fades into the theme's dark background with an accent glow.
const bot = ctx.createLinearGradient(0, 200, 0, CARD_H);
bot.addColorStop(0, 'rgba(0,0,0,0)'); bot.addColorStop(0, 'rgba(0,0,0,0)');
bot.addColorStop(0.55, 'rgba(0,0,0,0.45)'); bot.addColorStop(0.5, hexToRgba(p.bg2, 0.55));
bot.addColorStop(1, 'rgba(0,0,0,0.86)'); bot.addColorStop(1, hexToRgba(p.bg1, 0.97));
ctx.fillStyle = bot; ctx.fillStyle = bot;
ctx.fillRect(0, 210, CARD_W, CARD_H - 210); ctx.fillRect(0, 200, CARD_W, CARD_H - 200);
const glow = ctx.createLinearGradient(0, CARD_H - 190, 0, CARD_H);
glow.addColorStop(0, 'rgba(0,0,0,0)');
glow.addColorStop(1, hexToRgba(p.accent, 0.20));
ctx.fillStyle = glow;
ctx.fillRect(0, CARD_H - 190, CARD_W, 190);
ctx.restore(); ctx.restore();
const logo = data.type === 'rider' && data.logoUrl ? await loadImage(data.logoUrl) : null; const logo = data.type === 'rider' && data.logoUrl ? await loadImage(data.logoUrl) : null;

View file

@ -807,6 +807,13 @@ export default function RiderCardPage() {
</div> </div>
</div> </div>
{!metadata?.picture && (
<p className="text-[11px] text-amber-400">
{t('cardUi.studio.noPhoto')}{' '}
<Link to="/settings?section=profile" className="underline">{t('cardUi.studio.addPhoto')}</Link>
</p>
)}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<Label className="mb-1.5 block text-[10px] uppercase tracking-widest text-gray-500">{t('labUi.rider.ridesFor')}</Label> <Label className="mb-1.5 block text-[10px] uppercase tracking-widest text-gray-500">{t('labUi.rider.ridesFor')}</Label>