Commit 6746a37a authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(game): rewards, shop, profile and settings feel like a game; coin payouts fixed

- Daily reward is a moment: a treasure chest on spinning light rays that
  wiggles when ready, a gold "open" button, the chest bursts with the reward
  jingle and coins fly to the purse; the week is a grid of reward cards with
  the chest on day seven.
- Shop: loot cards framed and ribboned by rarity (grey, green, blue, purple,
  gold), drawn previews per item type (mini boards in their colours, piece
  sets, framed avatars, effects), coin and gem price tags; Arabic item names
  (api/shop.php now sends name_ar).
- Achievements as sticker badges, grey with a lock until earned, gold when
  done; chunky gold progress bars. Leaderboard podium medals with ribbons.
  Settings: sticker tiles, a green game switch, the delete button is red, and
  a credits sheet for the CC BY artists. Profile: framed avatar, crown level,
  sticker stats and game marks; the Challenge button pushed a scene that does
  not exist and did nothing.
- Signed out there is no HUD or tab bar; login sits in a framed tray with a
  big gold "play as guest" button.
- HUD/tab crown uses the optimised crown.webp; wood and crown are preloaded.
- Server: no game had paid coins or XP since 2026-06-18 because award_coins
  wrote a transaction type the table's check constraint rejects; fixed in the
  database (migrations/2026-10-19-award-coins-type.sql). Online backgammon had
  no tables at all; created (migrations/2026-10-19-backgammon-online.sql), and
  the backgammon friend invite now writes the same shape as matchmaking.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent bfb16a6e
......@@ -35,3 +35,4 @@ tests/rpc.log
# Written by the branding admin on save; the real store is Supabase
public/assets/brand/theme.json
public/js/core/i18n.js.lock
......@@ -371,13 +371,23 @@ if ($method === 'POST') {
$whiteId = $isWhite ? $userId : $targetId;
$blackId = $isWhite ? $targetId : $userId;
// Same shape as a matchmade game (players, colors, current_turn as a
// seat index) plus the white/black ids the invite inbox reads.
$players = [$userId, $targetId];
$colors = [$isWhite ? 'white' : 'black', $isWhite ? 'black' : 'white'];
$match = $sdb->insert('backgammon_matches', [
'status' => 'waiting',
'players' => $players,
'colors' => $colors,
'white_player_id' => $whiteId,
'black_player_id' => $blackId,
'variant' => $variant,
'game_state' => ['invite_from' => $userId, 'invite_to' => $targetId, 'invite_t' => time()],
'current_turn' => 'white',
'match_length' => intval($input['match_length'] ?? 1) ?: 1,
'scores' => [0, 0],
'game_state' => ['invite_from' => $userId, 'invite_to' => $targetId, 'invite_t' => time(), 'variant' => $variant, 'game_number' => 1, 'cube_value' => 1, 'cube_owner' => null, 'crawford' => false],
'current_turn' => $isWhite ? 0 : 1,
'host_id' => $userId,
'created_by' => $userId,
'created_at' => gmdate('c')
]);
......
......@@ -15,7 +15,7 @@ $db = supabase($token);
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET' || ($_SERVER['REQUEST_METHOD'] === 'POST' && (getInput()['action'] ?? '') === 'list')) {
$items = $db->get('cosmetics', ['select' => 'id,name,type,rarity,price_coins,price_gems,description', 'order' => 'price_coins.asc']);
$items = $db->get('cosmetics', ['select' => 'id,name,name_ar,type,rarity,price_coins,price_gems,description', 'order' => 'price_coins.asc']);
jsonResponse(['items' => is_array($items) && !isset($items['error']) ? $items : []]);
}
......
# EL3AB: game UI brief (Royal Cafe)
Read this before changing any screen. It is the working contract for everyone
(people and agents) touching the player app.
## What El3ab is
**El3ab is a mobile game, not an app and not a website.** Arabic-first (RTL),
four classic games (chess, ludo, domino, backgammon), bots, online matchmaking,
friends, ratings, tournaments. Every screen must feel like a polished mobile
game: tactile, animated, loud with feedback, never a dashboard.
## The world: Royal Cafe
A cafe table at night. The ground is a warm walnut tabletop under a lamp (set
on `body`; never paint a full-screen background on a scene root). On it:
- **Leather trays** (`.rows`, `.card`, `.tray`): dark brown panels for lists and
settings. Cream text.
- **Ivory cards** (`.deck-card`): game objects: games, bots, results, rewards,
tournament tickets, the player card. Gold frame, brown-black `--ink`, red
`--red-ink`. `.face-down` = red cafe card back (locked / not yet), `.spent` =
punched corner (claimed).
- **Game buttons** (`.btn`): chunky, glossy, outlined, with depth; they push down.
`.btn-primary` green (play / confirm), `.btn-blue` / `.btn-card` blue (strong
second action), `.btn-gold` (rewards, premium), `.btn-red` / `.btn-danger`
(destructive), `.btn-purple`, `.btn-secondary` wood (ordinary), `.btn-ghost`.
Sizes `.btn-sm`, default 52px, `.btn-lg` 62px, `.btn-xl` 72px; `.btn-block`.
- **Ribbons**: `screenHead()` (ui.js) prints the title on a red ribbon with a
round blue back button. `.ribbon` (+ `.gold`, `.green`, `.blue`) anywhere else.
- **Chips** (`.chips` > `.chip`): wooden tabs; the chosen one (`.active` or
`aria-pressed="true"`) is gold with dark brown text.
- **Mode buttons**: `modeButton({ id, sticker, title, sub, colour })` (ui.js).
**Room hero**: `roomHero(gameKey)` (ui.js), the game's illustrated card.
- **Game art**: `gameArt(key)` (core/game-art.js), an SVG scene per game.
- **Badges**: `.badge` red bead with white rim.
- Colour per game: chess green board, ludo sunny orange, domino royal purple,
backgammon burgundy wood. Boards: wooden chess board, bright ludo board on
ivory, felt domino table, felt backgammon board with red and ivory points.
Type: `--font-display` (Lalezar) for titles, buttons, numbers that shout, with
`text-shadow: var(--text-outline)`; `--font-ar` (Baloo Bhaijaan 2) for reading.
Wrap numbers inside Arabic text in `<span class="num">`.
## Icons
- `gicon(name, size)` (core/icons.js): glossy game stickers. Use for anything
that is part of the game (tabs, purses, rewards, mode buttons, big buttons,
results). Names: `public/assets/game/gi/_names.json` (coin coins gem trophy
laurels bell settings user portrait friends podium cards chest gift medal swords
bot puzzle dice chess domino backgammon ludo flame lightning stopwatch hourglass
chat handshake shop bag star lock home search ticket calendar check cross plus
flag exit speaker mute edit trash scroll shield party history refresh live team
king sparkles heart spade diamond club hand up dicefire).
- `crown(size)`: the El3ab logo.
- `icon(name, size)`: thin line glyphs for small marks in text (chevrons, back,
x, check). Back arrows, chevrons and send mirror in RTL automatically.
- **Never** an emoji or a typed symbol standing in for an icon (no ✕ ✓ › ← ★
♟ ● ◆ 🎲 ...), in markup, strings or fallbacks.
## Feel: every touch answers
- `core/feel.js` already gives every `button`, `.deck-card`, `.chip`,
`[role=button]` a squash + tap sound + haptic on press and a spring on
release, and deals rows/cards in when a screen opens (`.deal-in` on items to
opt in). Don't hand-roll press effects or tap sounds. Mark tappable
non-buttons `role="button"`.
- `haptic(kind)` from feel.js: light, medium, heavy, success, error.
- `countUp(el, from, to, opts)` from feel.js: numbers that tick.
- `audio.play(name, category)`: recorded sounds: tap toggle deal swish flip fan
shuffle move capture castle place dice die sfx_dice_shake coins coin tick
notification open close error hit step win lose reward levelUp match_found
(+ the sfx_bg_* names). `audio.loop('shuffle')` returns a stop function.
- `juice.*` (core/juice.js): confetti, coinBurst, coinFlyTo(HUD purse), starBurst,
shake, countTo, slamIn, bounceIn, pulseElement, flash.
- CSS motion: `.dealt` (+ `--deal-rot`), `.flip-in`, `.slam-in`, `.pop-in`,
`.bounce-in`, `.wiggle`, `.float`, `.breathe-glow`; `--ease-pop` for overshoot.
- Big moments must land: wins (flip, stamp, confetti, fanfare, coins fly to the
purse), losses (shake, sad sting), captures, dice, turns, rank changes.
- Respect `prefers-reduced-motion` (core.css shortens animations globally).
## Hard rules
1. No emoji or typed symbols as icons. 2. RTL: logical CSS properties only
(`margin-inline-start`, `inset-inline-end`, `text-align: start`). 3. Colours
through `var(--token)`; canvas code reads `getColor('token_name', fallback)`.
4. Tap targets >= 44px, text >= 12px. 5. Every string through `t('key')`; add
strings with `python3 tools/i18n-add.py file.json` (`{"key": ["عربي", "English"]}`;
it locks the file). Natural Arabic. Never invent facts. 6. Keep behaviour and
contracts (ids, classes that code queries, exports, payloads) unless fixing a bug.
## Verifying
- Local server: `http://127.0.0.1:8080` (`PHP_CLI_SERVER_WORKERS=8 php -S 127.0.0.1:8080 tools/dev-server.php`).
- `node tools/shoot.mjs --out <dir> --only <ids>` or `--push <scene> --params '<json>'`
(signed-in throwaway account, deleted afterwards; prints console errors and
missing strings). Look at the PNGs.
- Delete any data your tests create (matches, queue rows, tournaments) the way
`tests/rehearsal.mjs` does.
......@@ -15,6 +15,8 @@ header('Expires: 0');
<link rel="icon" type="image/png" href="https://safe-supabase-kong.caprover.al-arcade.com/storage/v1/object/public/profile-images/branding/favicon.png?apikey=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjE4OTM0NTYwMDB9.31PF6PvP-pSrvRuQwLFptQoejR0W1A7o53lZhEbnz84">
<link rel="apple-touch-icon" href="https://safe-supabase-kong.caprover.al-arcade.com/storage/v1/object/public/profile-images/branding/logo_icon.png?apikey=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjE4OTM0NTYwMDB9.31PF6PvP-pSrvRuQwLFptQoejR0W1A7o53lZhEbnz84">
<link rel="manifest" href="/manifest.json">
<link rel="preload" as="image" href="/public/assets/game/wood.webp">
<link rel="preload" as="image" href="/public/assets/game/crown.webp">
<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=Baloo+Bhaijaan+2:wght@400;500;600;700;800&family=Lalezar&display=swap" rel="stylesheet">
......
-- 2026-10-19: award_coins wrote type 'reward', which economy_transactions' check
-- constraint rejects, so complete_match failed and no game paid coins or XP after
-- 2026-06-18. Use the allowed 'game_reward'.
CREATE OR REPLACE FUNCTION public.award_coins(p_player_id uuid, p_amount integer, p_reason text)
RETURNS integer
LANGUAGE plpgsql
AS $function$
DECLARE new_balance INT;
BEGIN
UPDATE profiles SET coins = coins + p_amount WHERE id = p_player_id
RETURNING coins INTO new_balance;
INSERT INTO economy_transactions (player_id, type, currency, amount, balance_after, reason)
VALUES (p_player_id, 'game_reward', 'coins', p_amount, new_balance, p_reason);
RETURN new_balance;
END;
$function$;
-- 2026-10-19: online backgammon had no tables in production; queue and invites wrote into nothing.
begin;
create table if not exists public.backgammon_queue (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references public.profiles(id) on delete cascade,
user_name text not null default '',
variant text not null default 'sheshbesh',
match_length integer not null default 3,
match_id uuid,
created_at timestamptz not null default now()
);
create unique index if not exists idx_backgammon_queue_user_unique on public.backgammon_queue (user_id) where match_id is null;
create index if not exists idx_backgammon_queue_open on public.backgammon_queue (variant, match_length, created_at) where match_id is null;
create table if not exists public.backgammon_matches (
id uuid primary key default gen_random_uuid(),
status text not null default 'in_progress',
players jsonb not null default '[]'::jsonb,
colors jsonb not null default '[]'::jsonb,
white_player_id uuid,
black_player_id uuid,
variant text not null default 'sheshbesh',
match_length integer not null default 1,
scores jsonb not null default '[0,0]'::jsonb,
current_turn integer not null default 0,
game_number integer not null default 1,
game_state jsonb not null default '{}'::jsonb,
cube_state jsonb,
last_emote jsonb,
host_id uuid,
created_by uuid,
winner_id uuid,
result text,
created_at timestamptz not null default now(),
updated_at timestamptz default now(),
last_activity timestamptz default now(),
completed_at timestamptz
);
create index if not exists idx_backgammon_matches_status on public.backgammon_matches (status);
create index if not exists idx_backgammon_last_activity on public.backgammon_matches (status, last_activity) where status = 'in_progress';
alter table public.backgammon_matches enable row level security;
alter table public.backgammon_queue enable row level security;
drop policy if exists backgammon_read on public.backgammon_matches;
create policy backgammon_read on public.backgammon_matches for select using (true);
drop policy if exists backgammon_write on public.backgammon_matches;
create policy backgammon_write on public.backgammon_matches using (true)
with check (host_id = auth.uid() or created_by = auth.uid() or white_player_id = auth.uid() or black_player_id = auth.uid() or (players)::text like ('%' || (auth.uid())::text || '%'));
drop policy if exists backgammon_queue_all on public.backgammon_queue;
create policy backgammon_queue_all on public.backgammon_queue using (true) with check (true);
grant select, insert, update, delete on public.backgammon_matches, public.backgammon_queue to anon, authenticated, service_role;
commit;
notify pgrst, 'reload schema';
......@@ -82,6 +82,8 @@ button { color: inherit; }
}
#scene-container.game-mode { top: 0; bottom: 0; }
html.no-chrome #scene-container { top: 0; bottom: 0; }
html.no-chrome #hud, #hud:empty { display: none; }
/* ── The HUD: a carved bar at the head of the table ─────────────────────── */
#hud {
......
......@@ -12,6 +12,7 @@ let isGuestMode = false;
export function init(guestMode = false) {
isGuestMode = guestMode;
document.documentElement.classList.remove('no-chrome');
hudEl = document.getElementById('hud');
renderHud();
renderTabBar();
......@@ -252,5 +253,6 @@ function updateTournamentBadge(pending) {
}
}
export function show() { hudEl?.classList.remove('hidden'); tabBar?.classList.remove('hidden'); }
export function hide() { hudEl?.classList.add('hidden'); tabBar?.classList.add('hidden'); }
// Signed out there is no HUD and no tab bar: the screen gets the whole phone.
export function show() { document.documentElement.classList.remove('no-chrome'); hudEl?.classList.remove('hidden'); tabBar?.classList.remove('hidden'); }
export function hide() { document.documentElement.classList.add('no-chrome'); hudEl?.classList.add('hidden'); tabBar?.classList.add('hidden'); }
......@@ -978,7 +978,11 @@ const strings = {
'puzzles.black_to_move': 'الأسود يلعب',
'puzzles.theme_mate': 'كش مات',
'puzzles.theme_capture': 'أخذ',
'puzzles.theme_center': 'المركز'
'puzzles.theme_center': 'المركز',
'settings.credits': 'حقوق الرسومات والأصوات',
'settings.credits_body': 'شكراً لمن صنعوا الأيقونات والأصوات والخامات التي تزين طاولة العب.',
'settings.credits_sounds': 'أصوات الورق والزهر والقطع',
'settings.credits_wood': 'خشب الطاولة'
},
en: {
'app.name': 'EL3AB',
......@@ -1957,7 +1961,11 @@ const strings = {
'puzzles.black_to_move': 'Black to move',
'puzzles.theme_mate': 'Checkmate',
'puzzles.theme_capture': 'Capture',
'puzzles.theme_center': 'Center'
'puzzles.theme_center': 'Center',
'settings.credits': 'Art and sound credits',
'settings.credits_body': 'Thanks to the people who made the icons, sounds and textures on El3ab\'s table.',
'settings.credits_sounds': 'card, dice and piece sounds',
'settings.credits_wood': 'the table wood'
}
};
......
......@@ -190,7 +190,7 @@ export function gicon(name, size = 32, opts = {}) {
/** The El3ab crown, the logo itself. */
export function crown(size = 40, opts = {}) {
const src = getAsset('logo') || '/public/assets/game/crown.webp';
const src = getAsset('icon_crown') || '/public/assets/game/crown.webp';
return `<img class="gi gi-crown-logo${opts.cls ? ' ' + opts.cls : ''}" src="${src}" width="${size}" height="${size}" alt="${opts.label || ''}"${opts.label ? '' : ' aria-hidden="true"'} draggable="false">`;
}
......
......@@ -6,7 +6,7 @@ import * as net from '../../../core/net.js';
import * as audio from '../../../core/audio.js';
import { t } from '../../../core/i18n.js';
import { assetImg } from '../../../core/theme.js';
import { icon } from '../../../core/icons.js';
import { icon, gicon } from '../../../core/icons.js';
import { esc } from '../../../core/ui.js';
import * as juice from '../../../core/juice.js';
import { SUPABASE_URL } from '../../../core/config.js';
......@@ -65,7 +65,7 @@ export function mountLogin(el) {
el.innerHTML = `
<div class="au">
<div class="au-inner">
<div class="au-crown">${crown(56)}</div>
<div class="au-crown">${crown(92)}</div>
<h1 class="au-title">${t('auth.login_title')}</h1>
<p class="au-sub">${t('auth.login_sub')}</p>
......@@ -88,8 +88,8 @@ export function mountLogin(el) {
<button class="btn btn-card btn-lg btn-block au-google" id="google-btn">
${GOOGLE_G}${t('auth.google_btn')}
</button>
<button class="btn btn-ghost btn-block" id="guest-btn">
${t('auth.guest_btn')}
<button class="btn btn-gold btn-lg btn-block" id="guest-btn">
${gicon('dice', 34)}${t('auth.guest_btn')}
</button>
</div>
......@@ -192,26 +192,36 @@ export const AUTH_STYLE = `
.au-crown {
display: flex;
justify-content: center;
margin-bottom: var(--s-4);
color: var(--gold);
margin-bottom: 4px;
}
.au-crown .gi { filter: drop-shadow(0 6px 6px rgba(0, 0, 0, 0.55)); animation: idleBob 3.2s ease-in-out infinite; }
.au-title {
font-family: var(--font-display);
font-size: var(--fs-2xl);
font-weight: 900;
line-height: 1.2;
line-height: 1.15;
text-align: center;
color: var(--text-primary);
text-shadow: var(--text-outline);
}
.au-sub {
margin-top: var(--s-2);
margin-top: 4px;
font-size: var(--fs-sm);
font-weight: 700;
text-align: center;
color: var(--text-secondary);
color: var(--gold-soft);
text-shadow: var(--text-shadow);
}
.au-form { display: flex; flex-direction: column; gap: var(--s-4); margin-top: var(--s-6); }
.au-form {
display: flex; flex-direction: column; gap: var(--s-4);
margin-top: var(--s-5);
padding: 18px 16px;
border-radius: 22px;
background: linear-gradient(180deg, rgba(48, 22, 9, 0.92), rgba(26, 11, 4, 0.94));
box-shadow: 0 0 0 2px rgba(20, 8, 2, 0.85), inset 0 0 0 1px rgba(255, 210, 140, 0.2), inset 0 3px 8px rgba(0, 0, 0, 0.5), 0 8px 18px rgba(0, 0, 0, 0.45);
}
.au-field { display: flex; flex-direction: column; gap: 6px; }
.au-label { font-size: var(--fs-sm); font-weight: 700; color: var(--text-primary); }
.au-label { font-family: var(--font-display); font-size: var(--fs-md); color: var(--text-primary); text-shadow: var(--text-shadow); }
.au-help { font-size: var(--fs-xs); line-height: 1.5; color: var(--text-muted); }
.au-field input[dir="ltr"] { text-align: start; }
.input[aria-invalid="true"] { border-color: var(--red); }
......
......@@ -8,7 +8,7 @@ import * as audio from '../../../core/audio.js';
import * as net from '../../../core/net.js';
import * as juice from '../../../core/juice.js';
import { t, setLang } from '../../../core/i18n.js';
import { icon } from '../../../core/icons.js';
import { icon, gicon } from '../../../core/icons.js';
import { screenHead, wireBack } from '../../../core/ui.js';
export function mountSettings(el) {
......@@ -22,14 +22,14 @@ export function mountSettings(el) {
<h2 class="section-title">${t('settings.sec_app')}</h2>
<div class="rows">
<div class="row st-row">
<span class="st-tile">${icon(audioOn ? 'volume' : 'mute', 20)}</span>
<span class="st-tile">${gicon(audioOn ? 'speaker' : 'mute', 34)}</span>
<span class="row-main"><span class="row-title" id="st-sound-label">${t('settings.sound')}</span></span>
<button class="st-switch" id="toggle-audio" role="switch" aria-checked="${audioOn}" aria-labelledby="st-sound-label">
<span class="st-knob"></span>
</button>
</div>
<button class="row st-row st-btn" id="toggle-lang">
<span class="st-tile">${icon('globe', 20)}</span>
<span class="st-tile">${gicon('chat', 34)}</span>
<span class="row-main"><span class="row-title">${t('settings.language')}</span></span>
<span class="st-value">${lang === 'ar' ? t('profile.lang_ar') : t('profile.lang_en')}</span>
</button>
......@@ -38,7 +38,7 @@ export function mountSettings(el) {
<h2 class="section-title">${t('settings.sec_account')}</h2>
<div class="rows">
<button class="row st-row st-btn settings-link" id="blocked-list-btn">
<span class="st-tile">${icon('ban', 20)}</span>
<span class="st-tile">${gicon('shield', 34)}</span>
<span class="row-main"><span class="row-title">${t('settings.blocked_list')}</span></span>
${icon('chevron-end', 18, { cls: 'st-end' })}
</button>
......@@ -47,15 +47,20 @@ export function mountSettings(el) {
<h2 class="section-title">${t('settings.sec_legal')}</h2>
<div class="rows">
<a class="row st-row st-btn" href="/privacy-policy" target="_blank" rel="noopener">
<span class="st-tile">${icon('lock', 20)}</span>
<span class="st-tile">${gicon('lock', 34)}</span>
<span class="row-main"><span class="row-title">${t('settings.privacy_policy')}</span></span>
${icon('external', 18, { cls: 'st-end' })}
</a>
<a class="row st-row st-btn" href="/terms" target="_blank" rel="noopener">
<span class="st-tile">${icon('document', 20)}</span>
<span class="st-tile">${gicon('scroll', 34)}</span>
<span class="row-main"><span class="row-title">${t('settings.terms')}</span></span>
${icon('external', 18, { cls: 'st-end' })}
</a>
<button class="row st-row st-btn" id="credits-btn">
<span class="st-tile">${gicon('star', 34)}</span>
<span class="row-main"><span class="row-title">${t('settings.credits')}</span></span>
${icon('chevron-end', 18, { cls: 'st-end' })}
</button>
</div>
<div class="st-danger-zone">
......@@ -64,6 +69,21 @@ export function mountSettings(el) {
</button>
</div>
<div id="credits-modal" class="st-modal" style="display:none;" role="dialog" aria-modal="true" aria-labelledby="st-cr-title">
<div class="st-sheet st-credits">
<span class="st-sheet-mark">${gicon('star', 56)}</span>
<h3 class="st-sheet-title" id="st-cr-title">${t('settings.credits')}</h3>
<p class="st-sheet-text">${t('settings.credits_body')}</p>
<ul class="st-credit-list">
<li><b>game-icons.net</b> (CC BY 3.0): Lorc, Delapouite, Skoll, Quoting, Carl Olsen, Sbed</li>
<li><b>Kenney.nl</b> (CC0): ${t('settings.credits_sounds')}</li>
<li><b>Poly Haven</b> (CC0): ${t('settings.credits_wood')}</li>
<li><b>Lucide</b> (ISC)</li>
</ul>
<button class="btn btn-primary btn-block" id="credits-close">${t('common.close')}</button>
</div>
</div>
<div id="delete-modal" class="st-modal" style="display:none;" role="dialog" aria-modal="true" aria-labelledby="st-del-title">
<div class="st-sheet">
<span class="st-sheet-mark">${icon('alert', 28)}</span>
......@@ -73,7 +93,7 @@ export function mountSettings(el) {
<div id="delete-error" class="st-error" role="alert"></div>
<div class="st-sheet-actions">
<button class="btn btn-secondary" id="delete-cancel">${t('common.cancel')}</button>
<button class="btn btn-primary" id="delete-confirm">${icon('trash', 18)}${t('settings.delete_confirm_btn')}</button>
<button class="btn btn-danger" id="delete-confirm">${icon('trash', 18)}${t('settings.delete_confirm_btn')}</button>
</div>
</div>
</div>
......@@ -83,6 +103,10 @@ export function mountSettings(el) {
wireBack(el);
const credits = el.querySelector('#credits-modal');
el.querySelector('#credits-btn').addEventListener('click', () => { audio.play('open'); credits.style.display = 'flex'; });
el.querySelector('#credits-close').addEventListener('click', () => { audio.play('close'); credits.style.display = 'none'; });
el.querySelector('#blocked-list-btn').addEventListener('click', () => {
audio.play('click');
scene.push('blocked-list');
......@@ -167,17 +191,17 @@ const STYLE = `
.st .section-title:first-of-type { margin-top: var(--s-2); }
.st-row { min-height: 60px; }
.st-tile {
width: 40px;
height: 40px;
width: 48px;
height: 48px;
flex-shrink: 0;
border-radius: var(--r-md);
background: var(--back-800);
border: 1px solid var(--back-line);
color: var(--text-secondary);
border-radius: 14px;
background: radial-gradient(circle at 50% 35%, rgba(255, 220, 150, 0.22), rgba(0, 0, 0, 0.4) 70%);
box-shadow: 0 0 0 2px rgba(20, 8, 2, 0.8), inset 0 0 0 1px rgba(255, 210, 140, 0.2);
display: flex;
align-items: center;
justify-content: center;
}
.st-row .row-title { font-family: var(--font-display); font-size: var(--fs-md); font-weight: 400; }
.st-btn {
width: 100%;
background: none;
......@@ -198,13 +222,14 @@ const STYLE = `
.st-switch {
position: relative;
flex-shrink: 0;
width: 52px;
height: 32px;
min-block-size: 32px;
width: 60px;
height: 34px;
min-block-size: 34px;
padding: 0;
border: none;
border: 2px solid var(--outline);
border-radius: var(--r-full);
background: var(--back-600);
background: linear-gradient(180deg, #4A2A17, #2A140A);
box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.6);
cursor: pointer;
transition: background var(--dur-normal) var(--ease-out);
}
......@@ -213,20 +238,23 @@ const STYLE = `
position: absolute;
inset: -6px -4px;
}
.st-switch[aria-checked="true"] { background: var(--red-ink); }
.st-switch[aria-checked="true"] { background: linear-gradient(180deg, var(--btn-green-1), var(--btn-green-2)); box-shadow: inset 0 2px 3px rgba(0, 0, 0, 0.25), 0 0 12px rgba(120, 230, 100, 0.45); }
.st-knob {
position: absolute;
top: 3px;
inset-inline-start: 3px;
top: 2px;
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--card);
box-shadow: var(--shadow-sm);
transition: transform var(--dur-normal) var(--ease-deal);
background: radial-gradient(circle at 40% 30%, #FFFFFF, #E9D8B6 70%);
box-shadow: 0 0 0 1.5px var(--outline), 0 2px 3px rgba(0, 0, 0, 0.5);
transition: transform 320ms var(--ease-pop);
}
.st-switch[aria-checked="true"] .st-knob { transform: translateX(20px); }
[dir="rtl"] .st-switch[aria-checked="true"] .st-knob { transform: translateX(-20px); }
.st-switch[aria-checked="true"] .st-knob { transform: translateX(26px); }
[dir="rtl"] .st-switch[aria-checked="true"] .st-knob { transform: translateX(-26px); }
.st-credit-list { list-style: none; display: flex; flex-direction: column; gap: 8px; text-align: start; font-size: var(--fs-sm); color: var(--ink-2); margin: 4px 0 10px; }
.st-credit-list b { color: var(--ink); }
.st-danger-zone {
margin-top: var(--s-8);
......
......@@ -13,7 +13,7 @@ import * as net from '../../../core/net.js';
import * as modal from '../../../core/modal.js';
import * as juice from '../../../core/juice.js';
import { t } from '../../../core/i18n.js';
import { icon, GAME_MARK } from '../../../core/icons.js';
import { icon, gicon, crown, GAME_MARK } from '../../../core/icons.js';
import { screenHead, wireBack, emptyState, esc } from '../../../core/ui.js';
import { countryName } from './edit.js';
......@@ -43,7 +43,7 @@ async function mountOwnProfile(el) {
<div class="screen pv">
${screenHead(t('profile.title'), {
back: false,
actions: `<button class="icon-btn" id="btn-settings" aria-label="${t('profile.settings')}">${icon('settings', 20)}</button>`,
actions: `<button class="icon-btn" id="btn-settings" aria-label="${t('profile.settings')}">${gicon('settings', 32)}</button>`,
})}
${playerCard(player, true)}
......@@ -60,7 +60,7 @@ async function mountOwnProfile(el) {
<h2 class="section-title">${t('profile.record_title')}</h2>
<div class="rows">
<button class="row pv-row pv-link" id="btn-achievements">
<span class="pv-tile pv-tile-gold">${icon('medal', 20)}</span>
<span class="pv-tile pv-tile-gold">${gicon('medal', 34)}</span>
<span class="row-main">
<span class="row-title">${t('profile.achievements')}</span>
<span class="row-sub">${t('profile.achievements_sub')}</span>
......@@ -68,7 +68,7 @@ async function mountOwnProfile(el) {
${icon('chevron-end', 18, { cls: 'pv-chev' })}
</button>
<button class="row pv-row pv-link" id="btn-history">
<span class="pv-tile">${icon('history', 20)}</span>
<span class="pv-tile">${gicon('history', 34)}</span>
<span class="row-main">
<span class="row-title">${t('game.my_games')}</span>
<span class="row-sub">${t('profile.history_sub')}</span>
......@@ -242,7 +242,7 @@ async function mountOtherProfile(el, playerId) {
if (challengeBtn) {
challengeBtn.addEventListener('click', () => {
audio.play('click');
scene.push('challenge', { friendId: playerId, friendName: player.display_name });
scene.push('challenge-friend', { friendId: playerId, friendName: player.display_name });
});
}
......@@ -402,12 +402,12 @@ function playerCard(p, own) {
const handle = p.username ? `<span class="pv-handle" dir="ltr">@${esc(p.username)}</span>` : '';
const place = [p.city ? esc(p.city) : '', p.country_code ? esc(countryName(p.country_code)) : ''].filter(Boolean).join('، ');
const bio = p.bio || p.bio_ar || '';
const index = `${icon('crown', 18)}<span class="num">${level}</span>`;
const index = `${crown(26)}<span class="num">${level}</span>`;
const initial = (p.display_name || p.username || '').trim().charAt(0);
const face = p.avatar_url
? `<img src="${esc(p.avatar_url)}" alt="">`
: `<span class="pv-initial">${initial ? esc(initial) : icon('user', 44)}</span>`;
: `<span class="pv-initial">${gicon('user', 64)}</span>`;
const avatarBlock = own
? `<button class="pv-avatar-wrap" id="avatar-wrap" aria-label="${t('profile.change_photo')}">
......@@ -420,7 +420,6 @@ function playerCard(p, own) {
return `
<section class="deck-card pv-card dealt" style="--deal-rot:-3deg" aria-label="${name}">
<span class="index top pv-index" role="img" aria-label="${t('common.level', { n: level })}">${index}</span>
<span class="index bottom pv-index" aria-hidden="true">${index}</span>
<div class="pv-face">
${avatarBlock}
......@@ -436,7 +435,7 @@ function playerCard(p, own) {
const red = GAME_MARK[k].ink === 'red';
return `
<div class="pv-game${red ? ' red' : ''}" role="listitem">
${icon(GAME_MARK[k].icon, 22)}
${gicon(k, 30)}
${r
? `<span class="num pv-game-rating">${r}</span>`
: `<span class="pv-game-rating pv-unrated">${icon('minus', 16)}<span class="sr-only">${t('profile.unrated')}</span></span>`}
......@@ -470,7 +469,7 @@ function statsSection(p) {
const row = (mark, label, value, sub = '') => `
<div class="row pv-row deal-in">
<span class="pv-tile">${icon(mark, 20)}</span>
<span class="pv-tile">${gicon(mark, 34)}</span>
<span class="row-main">
<span class="row-title">${label}</span>
${sub ? `<span class="row-sub">${sub}</span>` : ''}
......@@ -481,9 +480,9 @@ function statsSection(p) {
return `
<h2 class="section-title">${t('profile.stats')}</h2>
<div class="rows">
${row('cards', t('profile.stat_games'), games)}
${row('dice', t('profile.stat_games'), games)}
${row('trophy', t('profile.stat_wins'), wins, rate !== null ? t('profile.win_rate', { n: `<span class="num">${rate}%</span>` }) : '')}
${row('trending-down', t('profile.stat_losses'), losses)}
${row('cross', t('profile.stat_losses'), losses)}
${row('handshake', t('profile.stat_draws'), draws)}
${row('flame', t('profile.stat_best_streak'), best, current > 0 ? t('profile.current_streak', { n: `<span class="num">${current}</span>` }) : '')}
</div>`;
......@@ -651,20 +650,18 @@ const STYLE = `
}
span.pv-avatar-wrap { cursor: default; }
.pv-avatar {
width: 96px;
height: 96px;
border-radius: 50%;
width: 104px;
height: 104px;
border-radius: 26px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: var(--card-3);
color: var(--ink-2);
outline: 2px solid var(--ink);
outline-offset: 3px;
transition: transform var(--dur-fast) var(--ease-out);
background: linear-gradient(180deg, #5BA8F0, #2A64B8);
border: 4px solid var(--gold);
box-shadow: 0 0 0 2px var(--outline), inset 0 2px 0 rgba(255, 255, 255, 0.35), 0 8px 14px rgba(0, 0, 0, 0.4);
}
.pv-avatar img { width: 96px; height: 96px; object-fit: cover; }
.pv-avatar img:not(.gi) { width: 100%; height: 100%; object-fit: cover; }
.pv-initial { font-size: 40px; font-weight: 800; line-height: 1; display: flex; }
.pv-avatar-edit {
position: absolute;
......@@ -754,13 +751,12 @@ const STYLE = `
.pv-row-tight { min-height: 52px; }
.pv-row .row-main { display: flex; flex-direction: column; }
.pv-tile {
width: 40px;
height: 40px;
width: 48px;
height: 48px;
flex-shrink: 0;
border-radius: var(--r-md);
background: var(--back-800);
border: 1px solid var(--back-line);
color: var(--text-secondary);
border-radius: 14px;
background: radial-gradient(circle at 50% 35%, rgba(255, 220, 150, 0.22), rgba(0, 0, 0, 0.4) 70%);
box-shadow: 0 0 0 2px rgba(20, 8, 2, 0.8), inset 0 0 0 1px rgba(255, 210, 140, 0.2);
display: flex;
align-items: center;
justify-content: center;
......
......@@ -114,7 +114,7 @@ function renderList(container, players) {
/** The place a row holds: an index tile for the podium, a printed number after. */
export function rankIndex(rank) {
if (rank >= 1 && rank <= 3) {
return `<span class="rk-tile rk-${rank}">${rank === 1 ? icon('crown', 11) : ''}<span class="num">${rank}</span></span>`;
return `<span class="rk-medal rk-m${rank}"><span class="num">${rank}</span></span>`;
}
return `<span class="num rk-n">${rank}</span>`;
}
......@@ -305,6 +305,38 @@ export const RANK_STYLE = `
}
.rk-tile .i-crown { color: var(--ink); margin-bottom: 1px; }
/* The podium: medals with ribbons, gold, silver and bronze. */
.rk-medal {
position: relative;
width: 36px; height: 36px;
flex-shrink: 0;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 50%;
border: 2px solid var(--outline);
font-family: var(--font-display);
font-size: 18px;
color: #fff;
text-shadow: var(--text-outline);
box-shadow: inset 0 2px 0 rgba(255, 255, 255, 0.6), inset 0 -3px 0 rgba(0, 0, 0, 0.2), 0 3px 4px rgba(0, 0, 0, 0.45);
}
.rk-medal::before, .rk-medal::after {
content: '';
position: absolute;
bottom: -9px;
width: 10px; height: 14px;
background: #C8262C;
z-index: -1;
clip-path: polygon(0 0, 100% 0, 100% 100%, 50% 75%, 0 100%);
}
.rk-medal::before { inset-inline-start: 6px; transform: rotate(12deg); }
.rk-medal::after { inset-inline-end: 6px; transform: rotate(-12deg); }
.rk-m1 { background: radial-gradient(circle at 40% 30%, #FFF2A8, #F2B21C 60%, #B87C0A); animation: rkShine 3s ease-in-out infinite; }
.rk-m2 { background: radial-gradient(circle at 40% 30%, #FFFFFF, #B9C4CF 60%, #7D8894); }
.rk-m3 { background: radial-gradient(circle at 40% 30%, #FFD9B0, #C9803F 60%, #8A4F1F); }
.rk-m2::before, .rk-m2::after { background: #2A82E0; }
.rk-m3::before, .rk-m3::after { background: #35A83A; }
@keyframes rkShine { 0%, 100% { filter: brightness(1); } 50% { filter: brightness(1.2); } }
.rk-me {
flex-shrink: 0;
padding: 2px 8px;
......
......@@ -6,7 +6,7 @@ import * as audio from '../../../core/audio.js';
import * as scene from '../../../core/scene.js';
import * as juice from '../../../core/juice.js';
import { t } from '../../../core/i18n.js';
import { icon } from '../../../core/icons.js';
import { icon, gicon } from '../../../core/icons.js';
import { screenHead, wireBack, emptyState, loadingRows, esc } from '../../../core/ui.js';
const CATEGORY_LABELS = {
......@@ -22,6 +22,8 @@ const CATEGORY_MARK = {
progression: 'level-up',
collection: 'layers',
};
// The badge each kind of achievement wears.
const CATEGORY_STICKER = { gameplay: 'swords', social: 'friends', progression: 'up', collection: 'chest', tournament: 'trophy' };
export async function mountAchievements(el) {
el.innerHTML = `
......@@ -88,7 +90,7 @@ function render(el, achievements, stats, first = false) {
${screenHead(t('achievements.title'))}
<section class="ach-sum" aria-label="${t('achievements.summary')}">
<span class="ach-sum-mark">${icon('trophy', 26)}</span>
<span class="ach-sum-mark">${gicon('trophy', 56)}</span>
<div class="ach-sum-text">
<div class="ach-sum-count">${t('achievements.count_of', {
done: `<span class="num ach-big">${stats.completed}</span>`,
......@@ -104,7 +106,7 @@ function render(el, achievements, stats, first = false) {
${categories.length > 1 ? `
<div class="chips ach-chips" role="group" aria-label="${t('achievements.categories')}">
<button class="chip ach-filter active" data-cat="all" aria-pressed="true">${t('achievements.all')}</button>
${categories.map(c => `<button class="chip ach-filter" data-cat="${esc(c)}" aria-pressed="false">${icon(CATEGORY_MARK[c] || 'medal', 16)}${CATEGORY_LABELS[c] ? CATEGORY_LABELS[c]() : esc(c)}</button>`).join('')}
${categories.map(c => `<button class="chip ach-filter" data-cat="${esc(c)}" aria-pressed="false">${gicon(CATEGORY_STICKER[c] || 'medal', 22)}${CATEGORY_LABELS[c] ? CATEGORY_LABELS[c]() : esc(c)}</button>`).join('')}
</div>` : ''}
<div id="ach-list" class="rows">
......@@ -143,16 +145,16 @@ function renderList(achievements, category) {
const pct = a.target > 0 ? Math.min(100, Math.round((a.progress / a.target) * 100)) : 0;
const mark = a.icon_url
? `<img src="${esc(a.icon_url)}" alt="" width="24" height="24" style="width:24px;height:24px;object-fit:contain;">`
: icon(CATEGORY_MARK[a.category] || 'medal', 22);
: gicon(CATEGORY_STICKER[a.category] || 'medal', 40);
return `
<div class="row ach-row deal-in ${a.completed ? 'ach-done' : 'ach-locked'}" data-id="${esc(a.id)}">
<span class="ach-tile">${mark}</span>
<span class="ach-tile">${mark}${a.completed ? '' : `<span class="ach-lock">${gicon('lock', 20)}</span>`}</span>
<div class="row-main">
<div class="row-title ach-name">${esc(a.name)}</div>
<div class="row-sub ach-desc">${esc(a.description)}</div>
${a.completed ? `
<div class="ach-state">${icon('check', 14, { weight: 2.5 })}${t('achievements.complete')}</div>
<div class="ach-state">${gicon('check', 20)}${t('achievements.complete')}</div>
` : `
<div class="ach-prog">
<span class="ach-bar" role="progressbar" aria-valuemin="0" aria-valuemax="${a.target}" aria-valuenow="${a.progress}"><span style="width:${pct}%"></span></span>
......@@ -160,7 +162,7 @@ function renderList(achievements, category) {
</div>
`}
</div>
<span class="ach-reward" aria-label="${a.coins_reward} ${t('game.coins')}">${icon('coin', 16)}<span class="num">${a.coins_reward}</span></span>
<span class="ach-reward" aria-label="${a.coins_reward} ${t('game.coins')}">${gicon('coin', 24)}<span class="num">${a.coins_reward}</span></span>
</div>
`;
}).join('');
......@@ -181,37 +183,30 @@ const STYLE = `
background: var(--back-800);
border: 1px solid var(--back-line);
}
.ach-sum-mark {
width: 48px;
height: 48px;
border-radius: var(--r-md);
background: var(--back-950);
color: var(--gold);
display: flex;
align-items: center;
justify-content: center;
}
.ach-sum-count { font-size: var(--fs-base); font-weight: 600; color: var(--text-secondary); line-height: 1.2; }
.ach-big { font-size: var(--fs-2xl); font-weight: 900; color: var(--text-primary); }
.ach-sum-mark { display: flex; filter: drop-shadow(0 4px 4px rgba(0, 0, 0, 0.5)); animation: idleBob 3s ease-in-out infinite; }
.ach-sum-count { font-family: var(--font-display); font-size: var(--fs-lg); color: var(--text-secondary); line-height: 1.2; text-shadow: var(--text-outline); }
.ach-big { font-size: var(--fs-3xl); color: var(--gold-soft); }
.ach-sum-sub { font-size: var(--fs-sm); color: var(--text-muted); margin-top: 2px; }
.ach-bar-lg { grid-column: 1 / -1; }
.ach-bar {
display: block;
flex: 1;
height: 4px;
border-radius: var(--r-full);
background: var(--back-700);
height: 10px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.55);
box-shadow: inset 0 2px 3px rgba(0, 0, 0, 0.7), 0 1px 0 rgba(255, 220, 160, 0.2);
overflow: hidden;
}
.ach-bar > span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--gold);
background: linear-gradient(180deg, var(--btn-gold-1), var(--btn-gold-2));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
transition: width var(--dur-slow) var(--ease-out);
}
.ach-bar-lg { height: 6px; }
.ach-bar-lg { height: 14px; }
.ach-bar-lg > span { animation: achFill 800ms var(--ease-out) 200ms both; }
@keyframes achFill { from { width: 0; } }
......@@ -224,18 +219,20 @@ const STYLE = `
.ach-row { align-items: flex-start; min-height: 72px; padding-block: var(--s-3); }
.ach-row .row-main { display: flex; flex-direction: column; gap: 2px; }
.ach-tile {
width: 44px;
height: 44px;
position: relative;
width: 58px;
height: 58px;
flex-shrink: 0;
border-radius: var(--r-md);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--back-800);
border: 1px solid var(--back-line);
background: radial-gradient(circle at 50% 35%, rgba(255, 220, 150, 0.25), rgba(0, 0, 0, 0.45) 70%);
box-shadow: 0 0 0 2px rgba(20, 8, 2, 0.8), inset 0 0 0 2px rgba(255, 210, 140, 0.22);
}
.ach-done .ach-tile { color: var(--gold); background: var(--back-700); border-color: var(--back-line-strong); }
.ach-locked .ach-tile { color: var(--text-dim); background: transparent; }
.ach-done .ach-tile { background: radial-gradient(circle at 50% 35%, #FFE98A, #E39A14 75%); box-shadow: 0 0 0 2px var(--outline), 0 0 18px rgba(255, 200, 80, 0.55); }
.ach-locked .ach-tile .gi:first-child { filter: grayscale(1) brightness(0.7); opacity: 0.75; }
.ach-lock { position: absolute; bottom: -4px; inset-inline-end: -4px; display: flex; }
.ach-name { white-space: normal; }
.ach-done .ach-name { color: var(--text-primary); font-weight: 700; }
.ach-locked .ach-name { color: var(--text-secondary); }
......@@ -252,15 +249,16 @@ const STYLE = `
align-items: center;
gap: 4px;
flex-shrink: 0;
min-height: 28px;
padding: 0 8px;
min-height: 32px;
padding: 0 10px 0 4px;
margin-top: 2px;
border-radius: var(--r-sm);
background: var(--back-800);
border: 1px solid var(--back-line);
font-size: var(--fs-sm);
font-weight: 800;
color: var(--text-primary);
border-radius: 16px;
background: rgba(0, 0, 0, 0.5);
box-shadow: inset 0 2px 3px rgba(0, 0, 0, 0.6);
font-family: var(--font-display);
font-size: 16px;
color: #fff;
text-shadow: var(--text-outline);
}
.ach-locked .ach-reward { color: var(--text-secondary); }
`;
......@@ -8,7 +8,8 @@ import * as store from '../../../core/store.js';
import * as scene from '../../../core/scene.js';
import * as juice from '../../../core/juice.js';
import { t } from '../../../core/i18n.js';
import { icon } from '../../../core/icons.js';
import { icon, gicon } from '../../../core/icons.js';
import { haptic } from '../../../core/feel.js';
import { screenHead, wireBack } from '../../../core/ui.js';
const DAY_REWARDS = [50, 75, 100, 125, 150, 200, 300];
......@@ -18,7 +19,7 @@ export async function mountDaily(el) {
<div class="screen dl">
${screenHead(t('daily.title'))}
<div class="dl-week" aria-busy="true" aria-label="${t('common.loading')}">
${DAY_REWARDS.map(() => '<div class="skeleton dl-day dl-skel"></div>').join('')}
${DAY_REWARDS.map(() => '<div class="skeleton dl-skel"></div>').join('')}
</div>
</div>
<style>${STYLE}</style>`;
......@@ -73,31 +74,19 @@ function untilNext() {
function dayCard(coins, i, dayIndex, alreadyClaimed, opts) {
const claimed = i < dayIndex || (i === dayIndex && alreadyClaimed);
const isToday = i === dayIndex && !alreadyClaimed;
const day = `<span class="num">${i + 1}</span>`;
// On the first deal every card falls in; after a claim only today's card moves.
const last = i === 6;
const deal = opts.redeal ? ' dealt' : '';
const style = opts.redeal ? `--deal-rot:${(i % 2 ? 4 : -4)}deg;animation-delay:${i * 55}ms` : '';
if (claimed) {
const just = opts.justClaimed && i === dayIndex;
const style = opts.redeal ? `--deal-rot:${(i % 2 ? 5 : -5)}deg;animation-delay:${i * 60}ms` : '';
const state = claimed ? 'dl-claimed' : isToday ? 'dl-today' : 'dl-later';
const just = claimed && opts.justClaimed && i === dayIndex;
const label = claimed ? t('daily.taken') : isToday ? t('daily.today') : t('daily.day', { n: i + 1 });
return `
<div class="deck-card spent dl-day dl-claimed${just ? ' dl-just' : deal}" style="${just ? '' : style}" role="listitem" aria-label="${t('daily.day', { n: i + 1 })}، ${t('daily.taken')}">
${just ? '<span class="dl-punch" aria-hidden="true"></span>' : ''}
<span class="dl-day-n">${day}</span>
<span class="dl-day-mark">${icon('check', 20, { weight: 2.5 })}</span>
</div>`;
}
if (isToday) {
return `
<div class="deck-card dl-day dl-today${deal}" style="${style}" role="listitem" aria-current="date" aria-label="${t('daily.today')}، ${coins} ${t('common.coins')}">
<span class="dl-day-n">${t('daily.today')}</span>
<span class="dl-day-mark">${icon('coin', 20)}</span>
<div class="deck-card dl-day ${state}${last ? ' dl-big' : ''}${just ? ' dl-just' : deal}" style="${just ? '' : style}" role="listitem"
${isToday ? 'aria-current="date"' : ''} aria-label="${label}، ${coins} ${t('common.coins')}">
<span class="dl-day-n">${isToday ? t('daily.today') : `<span class="num">${i + 1}</span>`}</span>
<span class="dl-day-art">${gicon(last ? 'chest' : 'coins', last ? 46 : 34)}</span>
<span class="num dl-day-coins">${coins}</span>
</div>`;
}
return `
<div class="deck-card face-down dl-day dl-later${deal}" style="${style}" role="listitem" aria-label="${t('daily.day', { n: i + 1 })}">
<span class="dl-day-n">${day}</span>
${claimed ? `<span class="dl-tick">${gicon('check', 30)}</span>` : ''}
</div>`;
}
......@@ -123,28 +112,29 @@ function render(el, streak, alreadyClaimed, dayIndex, todayReward, opts = { rede
<div class="screen dl">
${screenHead(t('daily.title'))}
<h2 class="section-title dl-streak">
<span class="dl-streak-label">${icon('flame', 20)}${t('daily.streak_days', { n: `<span class="num">${streak}</span>` })}</span>
</h2>
<div class="dl-streak"><span class="ribbon gold">${gicon('flame', 26)}${t('daily.streak_days', { n: `<span class="num">${streak}</span>` })}</span></div>
<div class="dl-week" role="list" aria-label="${t('daily.week')}">
${DAY_REWARDS.map((coins, i) => dayCard(coins, i, dayIndex, alreadyClaimed, opts)).join('')}
<div class="dl-hero ${alreadyClaimed ? 'is-done' : 'is-ready'}" aria-hidden="true">
<span class="dl-rays"></span>
<span class="dl-chest">${gicon('chest', 150)}</span>
</div>
<div class="dl-today-panel">
${alreadyClaimed ? `
<div class="dl-done${opts.justClaimed ? ' dl-done-new' : ''}">
<span class="dl-done-mark">${icon('check-circle', 28)}</span>
<div class="dl-done-title">${t('daily.claimed_today')}</div>
<div class="dl-done-sub">${t('daily.next_opens', { n: `<span class="num">${nextReward}</span>`, time: untilNext() })}</div>
</div>
` : `
<div class="dl-amount-label">${t('daily.today_reward')}</div>
<div class="dl-amount">${icon('coin', 34)}<span class="num">${todayReward}</span></div>
<button class="btn btn-gold btn-lg btn-block" id="claim-btn">${icon('gift', 20)}${t('daily.claim_btn')}</button>
<div class="dl-amount">${gicon('coin', 46)}<span class="num" id="dl-amount">${todayReward}</span></div>
<button class="btn btn-gold btn-xl btn-block" id="claim-btn">${gicon('chest', 40)}${t('daily.claim_btn')}</button>
`}
</div>
<div class="dl-week" role="list" aria-label="${t('daily.week')}">
${DAY_REWARDS.map((coins, i) => dayCard(coins, i, dayIndex, alreadyClaimed, opts)).join('')}
</div>
<p class="dl-hint">${t('daily.hint_week', { n: `<span class="num">${DAY_REWARDS[6]}</span>` })}</p>
</div>
<style>${STYLE}</style>
......@@ -166,16 +156,21 @@ function render(el, streak, alreadyClaimed, dayIndex, todayReward, opts = { rede
return;
}
// The coins burst off today's card and fly into the purse.
// The chest bursts open: a flash, the reward jingle, coins spray out
// and fly into the purse.
const todayCard = el.querySelector('.dl-today');
const at = (todayCard || claimBtn).getBoundingClientRect();
const chest = el.querySelector('.dl-chest');
const at = (chest || todayCard || claimBtn).getBoundingClientRect();
const cx = at.left + at.width / 2;
const cy = at.top + at.height / 2;
audio.play('coin', 'reward');
juice.hapticSuccess();
juice.coinBurst(cx, cy, 10);
juice.coinFlyTo(cx, cy, '#hud-coins', 5);
el.querySelector('.dl-hero')?.classList.add('is-opening');
audio.play('reward', 'reward');
audio.play('coins', 'reward');
haptic('success');
juice.flash?.('rgba(255, 230, 150, 0.55)', 220);
juice.coinBurst(cx, cy, 18);
juice.coinFlyTo(cx, cy, '#hud-coins', 8);
// Update local store
const player = store.get('player') || {};
......@@ -215,120 +210,92 @@ function claimError(msg = '') {
}
const STYLE = `
.dl-streak { margin-top: var(--s-2); }
.dl-streak-label { display: inline-flex; align-items: center; gap: var(--s-2); }
.dl-streak-label .i-flame { color: var(--gold); }
/* The week: seven small cards in a row; on narrow phones, four over three. */
.dl-week {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 6px;
padding-top: 12px;
}
.dl-day {
flex: 0 0 calc((100% - 36px) / 7);
aspect-ratio: 5 / 7;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 6px 2px;
border-radius: 8px;
text-align: center;
}
.dl-day::before { inset: 3px; border-radius: 6px; }
.dl-skel { animation-duration: 1.6s; }
.dl-day-n { font-size: var(--fs-xs); font-weight: 800; line-height: 1.1; }
.dl-day-mark { display: flex; }
.dl-claimed { color: var(--ink-3); box-shadow: var(--shadow-sm); }
/* A smaller punch for a small card. The mask reads only alpha. */
.dl-day.spent {
-webkit-mask: radial-gradient(circle 5px at calc(100% - 9px) 9px, transparent 98%, var(--ink) 100%);
mask: radial-gradient(circle 5px at calc(100% - 9px) 9px, transparent 98%, var(--ink) 100%);
}
[dir="rtl"] .dl-day.spent {
-webkit-mask: radial-gradient(circle 5px at 9px 9px, transparent 98%, var(--ink) 100%);
mask: radial-gradient(circle 5px at 9px 9px, transparent 98%, var(--ink) 100%);
}
.dl-claimed .dl-day-mark { color: var(--win-ink); }
.dl-streak { display: flex; justify-content: center; margin: 2px 0 6px; }
.dl-streak .ribbon { gap: 8px; font-size: var(--fs-md); }
.dl-today {
background: var(--gold);
color: var(--ink);
box-shadow: var(--shadow-lg);
transform: translateY(-8px);
z-index: 1;
/* The chest on a burst of light: the moment of the day. */
.dl-hero { position: relative; height: 200px; display: flex; align-items: center; justify-content: center; }
.dl-rays {
position: absolute;
width: 300px; height: 300px;
border-radius: 50%;
background: repeating-conic-gradient(from 0deg, rgba(255, 214, 110, 0.32) 0deg 10deg, transparent 10deg 22deg);
-webkit-mask: radial-gradient(circle, #000 18%, transparent 68%);
mask: radial-gradient(circle, #000 18%, transparent 68%);
animation: dlSpin 18s linear infinite;
}
.dl-today::before { border-color: var(--gold-dark); }
.dl-today .i-coin { color: var(--ink); }
.dl-day-coins { font-size: var(--fs-sm); font-weight: 900; line-height: 1; }
.dl-today.dealt { animation-name: dlDealRaised; }
@keyframes dlDealRaised { from { opacity: 0; transform: translateY(-36px) rotate(var(--deal-rot, -6deg)) scale(0.96); } }
.dl-later .dl-day-n { color: var(--card); font-size: var(--fs-sm); }
/* The card just claimed: it turns back over, the tick is stamped and the
corner is punched. */
.dl-week { perspective: 700px; }
.dl-just { animation: dlFlipBack 300ms var(--ease-out) both; }
@keyframes dlFlipBack { from { transform: translateY(-14px) rotateY(-90deg); } }
.dl-just .dl-day-mark { animation: dlStamp 360ms cubic-bezier(.34, 1.56, .64, 1) 220ms both; }
@keyframes dlStamp { from { transform: scale(2.2); opacity: 0; } }
.dl-punch {
.dl-hero::after {
content: '';
position: absolute;
top: 2px;
inset-inline-end: 2px;
width: 14px;
height: 14px;
width: 170px; height: 170px;
border-radius: 50%;
border: 1.5px solid var(--card-line);
animation: dlPunch 320ms cubic-bezier(.34, 1.56, .64, 1) 420ms both;
background: radial-gradient(circle, rgba(255, 220, 120, 0.55), transparent 70%);
}
@keyframes dlPunch { from { transform: scale(2.4); opacity: 0; } }
.dl-done-new { animation: deal var(--dur-slow) var(--ease-deal) 160ms both; --deal-rot: 0deg; }
.dl-done-new .dl-done-mark { animation: dlStamp 380ms cubic-bezier(.34, 1.56, .64, 1) 380ms both; }
@media (max-width: 380px) {
.dl-week { gap: 8px; row-gap: 14px; }
.dl-day { flex-basis: calc((100% - 24px) / 4); max-width: 72px; border-radius: 10px; }
.dl-day-n { font-size: var(--fs-sm); }
@keyframes dlSpin { to { transform: rotate(360deg); } }
.dl-chest { position: relative; z-index: 1; display: flex; filter: drop-shadow(0 12px 10px rgba(0, 0, 0, 0.55)); }
.dl-hero.is-ready .dl-chest { animation: wiggle 2.4s ease-in-out infinite; }
.dl-hero.is-done .dl-rays, .dl-hero.is-done::after { opacity: 0.25; }
.dl-hero.is-done .dl-chest { filter: grayscale(0.7) brightness(0.8) drop-shadow(0 12px 10px rgba(0, 0, 0, 0.55)); }
.dl-hero.is-opening .dl-chest { animation: dlOpen 620ms var(--ease-pop) both; }
@keyframes dlOpen { 0% { transform: scale(1); } 25% { transform: scale(0.9) rotate(-6deg); } 45% { transform: scale(1.25) rotate(4deg); } 100% { transform: scale(1.1); } }
.dl-today-panel { display: flex; flex-direction: column; align-items: center; gap: 12px; margin-top: 4px; text-align: center; }
.dl-amount {
display: inline-flex; align-items: center; gap: 8px;
font-family: var(--font-display);
font-size: var(--fs-3xl);
line-height: 1;
color: var(--gold-soft);
text-shadow: var(--text-outline);
}
.dl-done { display: flex; flex-direction: column; align-items: center; gap: 6px; }
.dl-done-title { font-family: var(--font-display); font-size: var(--fs-xl); color: var(--text-primary); text-shadow: var(--text-outline); }
.dl-done-sub { font-size: var(--fs-sm); font-weight: 700; color: var(--text-secondary); max-width: 32ch; line-height: 1.6; text-shadow: var(--text-shadow); }
.dl-done-new { animation: bounceIn 520ms var(--ease-out) 200ms both; }
.dl-today-panel {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-2);
margin-top: var(--s-8);
/* The week: reward cards, four over three on phones; day seven is the chest. */
.dl-week {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
margin-top: 22px;
perspective: 700px;
}
.dl-day {
position: relative;
aspect-ratio: 4 / 5;
display: flex; flex-direction: column; align-items: center; justify-content: space-between;
padding: 8px 4px 9px;
border-radius: 14px;
text-align: center;
}
.dl-amount-label { font-size: var(--fs-base); font-weight: 700; color: var(--text-secondary); }
.dl-amount {
display: inline-flex;
align-items: center;
gap: var(--s-2);
margin-bottom: var(--s-4);
font-size: var(--fs-3xl);
font-weight: 900;
line-height: 1.1;
color: var(--gold);
.dl-day::before { inset: 4px; border-radius: 10px; }
.dl-big { grid-column: span 2; aspect-ratio: auto; }
.dl-day-n { font-family: var(--font-display); font-size: var(--fs-sm); color: var(--ink-2); line-height: 1; }
.dl-day-art { display: flex; }
.dl-day-coins { font-family: var(--font-display); font-size: var(--fs-md); color: var(--ink); line-height: 1; }
.dl-tick { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(40, 20, 6, 0.35); border-radius: inherit; }
.dl-tick .gi { filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.5)); }
.dl-claimed .dl-day-art, .dl-claimed .dl-day-coins, .dl-claimed .dl-day-n { opacity: 0.55; }
.dl-today {
background: linear-gradient(180deg, #FFF1B8, #F6C64C);
box-shadow: 0 0 0 2px var(--btn-gold-edge), 0 0 0 5px rgba(255, 219, 126, 0.55), 0 0 26px rgba(255, 190, 60, 0.7), 0 6px 0 rgba(120, 60, 0, 0.5);
animation: idleBob 2.2s ease-in-out infinite;
z-index: 1;
}
.dl-done { display: flex; flex-direction: column; align-items: center; gap: 6px; }
.dl-done-mark { color: var(--win); display: flex; }
.dl-done-title { font-size: var(--fs-md); font-weight: 800; color: var(--text-primary); }
.dl-done-sub { font-size: var(--fs-sm); color: var(--text-secondary); max-width: 32ch; line-height: 1.6; }
.dl-today .dl-day-n { color: var(--red-ink-deep); }
.dl-later { filter: saturate(0.6) brightness(0.92); }
.dl-just { animation: flipIn 600ms var(--ease-deal) both; }
.dl-just .dl-tick .gi { animation: slamIn 520ms var(--ease-out) 300ms both; }
.dl-skel { aspect-ratio: 4 / 5; border-radius: 14px; }
.dl-hint {
margin-top: var(--s-8);
padding-top: var(--s-4);
border-top: 1px solid var(--back-line);
margin-top: 20px;
font-size: var(--fs-sm);
font-weight: 600;
line-height: 1.6;
color: var(--text-muted);
text-align: center;
text-shadow: var(--text-shadow);
}
`;
......@@ -6,13 +6,51 @@ import * as audio from '../../../core/audio.js';
import * as store from '../../../core/store.js';
import * as bus from '../../../core/bus.js';
import { t } from '../../../core/i18n.js';
import { icon } from '../../../core/icons.js';
import { icon, gicon } from '../../../core/icons.js';
import { getLang } from '../../../core/i18n.js';
import { haptic } from '../../../core/feel.js';
import { screenHead, wireBack, emptyState, esc } from '../../../core/ui.js';
// The mark printed on each kind of item.
const TYPE_MARK = { avatar_frame: 'user', board_theme: 'grid', piece_set: 'chess-knight', trail_effect: 'sparkles' };
// The rarer pieces are printed in red ink.
const RED_RARITY = new Set(['epic', 'legendary']);
const RARITY_ORDER = ['common', 'uncommon', 'rare', 'epic', 'legendary'];
// Board themes, previewed as a little board in their own colours.
const BOARD_COLOURS = {
board_classic_wood: ['#F0D9B5', '#B58863'],
board_ocean_blue: ['#D6ECFF', '#3B7FC4'],
board_royal_gold: ['#FFF3C2', '#C9952C'],
};
// Piece sets and avatar frames get their own tint.
const TINT = {
pieces_classic_staunton: 'none', pieces_crystal: 'hue-rotate(160deg) saturate(1.6) brightness(1.05)', pieces_dragon: 'sepia(1) saturate(4) hue-rotate(-30deg)',
frame_bronze_circle: '#B87333', frame_silver_shield: '#C9D2DB', frame_golden_crown: '#F6BB3C',
};
function itemName(item) {
return (getLang() === 'ar' && item.name_ar) ? item.name_ar : (item.name || item.id);
}
/** A drawn preview of the item: what you would actually get. */
function preview(item, size = 84) {
if (item.type === 'board_theme') {
const [l, d] = BOARD_COLOURS[item.id] || ['#F0D9B5', '#B58863'];
let sq = '';
for (let r = 0; r < 4; r++) for (let c = 0; c < 4; c++) sq += `<rect x="${c * 10}" y="${r * 10}" width="10" height="10" fill="${(r + c) % 2 ? d : l}"/>`;
return `<svg width="${size}" height="${size}" viewBox="-3 -3 46 46" class="sh-board"><rect x="-3" y="-3" width="46" height="46" rx="4" fill="#3A1C0C"/>${sq}</svg>`;
}
if (item.type === 'piece_set') {
return `<span class="sh-tinted" style="filter:${TINT[item.id] || 'none'}">${gicon('chess', size)}</span>`;
}
if (item.type === 'avatar_frame') {
const c = TINT[item.id] || '#F6BB3C';
return `<span class="sh-frame" style="--frame:${c}">${gicon(item.id.includes('crown') ? 'king' : 'user', Math.round(size * 0.66))}</span>`;
}
if (item.type === 'trail_effect') return gicon(item.id.includes('fire') ? 'flame' : 'sparkles', size);
return gicon('bag', size);
}
function label(key, fallback) {
const s = t(key);
......@@ -90,25 +128,22 @@ function renderItems(el, items) {
function priceMarkup(item) {
const parts = [];
if (item.price_coins) parts.push(`<span class="sh-cost">${icon('coin', 15, { cls: 'sh-coin' })}<span class="num">${item.price_coins}</span></span>`);
if (item.price_gems) parts.push(`<span class="sh-cost">${icon('gem', 15, { cls: 'sh-gem' })}<span class="num">${item.price_gems}</span></span>`);
if (item.price_coins) parts.push(`<span class="sh-cost">${gicon('coin', 24)}<span class="num">${item.price_coins}</span></span>`);
if (item.price_gems) parts.push(`<span class="sh-cost">${gicon('gem', 22)}<span class="num">${item.price_gems}</span></span>`);
return parts.join('');
}
function itemCard(item, n) {
const red = RED_RARITY.has(item.rarity);
const rarity = item.rarity || 'common';
const name = esc(item.name || item.id);
const rarity = RARITY_ORDER.includes(item.rarity) ? item.rarity : 'common';
const name = esc(itemName(item));
return `
<button class="deck-card sh-item shop-item deal-in${red ? ' ink-red' : ''}${item.owned ? ' spent' : ''}"
<button class="deck-card sh-item shop-item deal-in sh-${rarity}${item.owned ? ' is-owned' : ''}"
data-id="${esc(item.id)}"
aria-label="${name}${item.owned ? '، ' + t('shop.owned') : ''}">
<span class="index top sh-index">
${item.owned ? `<span class="sh-owned">${icon('check', 15)}${t('shop.owned')}</span>` : priceMarkup(item)}
</span>
<span class="sh-art">${icon(TYPE_MARK[item.type] || 'bag', 46)}</span>
<span class="sh-ribbon">${label('shop.rarity_' + rarity, rarity)}</span>
<span class="sh-art">${preview(item)}</span>
<span class="sh-name" dir="auto">${name}</span>
<span class="sh-rarity">${label('shop.rarity_' + rarity, rarity)}</span>
<span class="sh-price">${item.owned ? `<span class="sh-owned">${gicon('check', 22)}${t('shop.owned')}</span>` : priceMarkup(item)}</span>
</button>`;
}
......@@ -121,13 +156,14 @@ async function purchasePrompt(el, item, onBought) {
const overlay = document.getElementById('overlay');
overlay.classList.add('active');
overlay.innerHTML = `
<div class="deck-card sh-sheet${RED_RARITY.has(item.rarity) ? ' ink-red' : ''}" role="dialog" aria-modal="true" aria-labelledby="sh-sheet-title">
<span class="sh-sheet-art">${icon(TYPE_MARK[item.type] || 'bag', 52)}</span>
<div class="sh-sheet-title" id="sh-sheet-title" dir="auto">${esc(item.name || item.id)}</div>
<div class="deck-card sh-sheet sh-${RARITY_ORDER.includes(item.rarity) ? item.rarity : 'common'}" role="dialog" aria-modal="true" aria-labelledby="sh-sheet-title">
<span class="sh-ribbon">${label('shop.rarity_' + (item.rarity || 'common'), item.rarity || '')}</span>
<span class="sh-sheet-art">${preview(item, 120)}</span>
<div class="sh-sheet-title" id="sh-sheet-title" dir="auto">${esc(itemName(item))}</div>
${item.description ? `<div class="sh-sheet-desc" dir="auto">${esc(item.description)}</div>` : ''}
<div class="sh-sheet-price">${priceMarkup(item) || `<span class="sh-cost"><span class="num">0</span></span>`}</div>
${canAfford
? `<button class="btn btn-primary btn-lg btn-block" id="buy-btn">${icon('bag', 18)}${t('shop.buy')}</button>`
? `<button class="btn btn-primary btn-xl btn-block" id="buy-btn">${gicon('bag', 34)}${t('shop.buy')}</button>`
: `<p class="sh-sheet-short">${icon('alert', 16)}${t('shop.insufficient_balance')}</p>`
}
<button class="btn btn-secondary btn-block" id="cancel-buy">${t('common.cancel')}</button>
......@@ -144,13 +180,15 @@ async function purchasePrompt(el, item, onBought) {
overlay.querySelector('#buy-btn').addEventListener('click', async () => {
try {
await net.post('shop.php', { action: 'purchase', cosmetic_id: item.id });
audio.play('coin', 'reward');
audio.play('coins', 'reward');
audio.play('reward', 'reward');
haptic('success');
store.set('player.coins', (player.coins || 0) - (item.price_coins || 0));
item.owned = true;
onBought?.();
overlay.innerHTML = `
<div class="deck-card sh-sheet flip-in">
<span class="sh-sheet-done">${icon('check-circle', 44)}</span>
<span class="sh-sheet-done">${gicon('party', 80)}</span>
<div class="sh-sheet-title">${t('shop.purchased')}</div>
</div>
<style>${SHEET_STYLE}</style>`;
......@@ -175,53 +213,81 @@ function purchaseError(msg = '') {
}
const STYLE = `
.sh-chips { margin-bottom: var(--s-4); }
.sh-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--s-3);
}
.sh-chips { margin-bottom: 12px; }
.sh-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.sh-span { grid-column: 1 / -1; }
.sh-skel { aspect-ratio: 4 / 5; border-radius: var(--r-card); }
.sh-skel { aspect-ratio: 3 / 4; border-radius: var(--r-card); }
/* Rarity: grey, green, blue, purple, gold, the way every game sorts loot. */
.sh-common { --rar: #8E9AA6; --rar-2: #5E6A75; }
.sh-uncommon { --rar: #5CC24C; --rar-2: #2E7D24; }
.sh-rare { --rar: #3D9BF0; --rar-2: #1D5FA8; }
.sh-epic { --rar: #B06BF0; --rar-2: #6A2FB0; }
.sh-legendary { --rar: #FFC23D; --rar-2: #C77A00; }
.sh-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
gap: 2px;
aspect-ratio: 4 / 5;
position: relative;
display: flex; flex-direction: column; align-items: center; justify-content: flex-start;
gap: 6px;
aspect-ratio: 3 / 4;
width: 100%;
padding: 48px 12px 16px;
padding: 36px 10px 12px;
text-align: center;
overflow: hidden;
box-shadow: 0 0 0 3px var(--rar-2), inset 0 0 0 3px #FFFDF6, 0 4px 0 rgba(50, 22, 6, 0.55), 0 14px 26px -8px rgba(10, 4, 0, 0.8);
}
.sh-index { align-items: flex-start; gap: 3px; font-size: 13px; }
.sh-cost { display: inline-flex; align-items: center; gap: 4px; color: var(--ink); }
.sh-cost .num { font-size: 15px; font-weight: 800; }
.sh-coin { color: var(--gold-ink); }
.sh-gem { color: var(--red-ink); }
.sh-owned { display: inline-flex; align-items: center; gap: 3px; color: var(--ink-2); font-size: var(--fs-xs); font-weight: 800; }
.sh-art {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--suit);
.sh-item::after {
content: '';
position: absolute;
inset: 7px 7px auto;
height: 58%;
border-radius: 10px;
background: radial-gradient(circle at 50% 45%, color-mix(in srgb, var(--rar) 55%, white), color-mix(in srgb, var(--rar) 80%, black) 120%);
z-index: -1;
}
.sh-legendary::after { animation: shRare 2.6s ease-in-out infinite; }
@keyframes shRare { 0%, 100% { filter: brightness(1); } 50% { filter: brightness(1.18); } }
.sh-ribbon {
position: absolute;
top: 0; inset-inline: 0;
height: 26px;
display: flex; align-items: center; justify-content: center;
background: linear-gradient(180deg, var(--rar), var(--rar-2));
font-family: var(--font-display);
font-size: 13px;
color: #fff;
text-shadow: var(--text-outline);
z-index: 1;
}
.sh-art { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; filter: drop-shadow(0 6px 6px rgba(0, 0, 0, 0.4)); }
.sh-board { border-radius: 6px; transform: rotate(-8deg); }
.sh-frame {
width: 76px; height: 76px;
display: flex; align-items: center; justify-content: center;
border-radius: 20px;
background: linear-gradient(180deg, #5BA8F0, #2A64B8);
border: 5px solid var(--frame);
box-shadow: 0 0 0 2px var(--outline), inset 0 0 0 2px rgba(255, 255, 255, 0.4);
}
.sh-name {
font-size: var(--fs-base);
font-weight: 800;
line-height: 1.25;
font-family: var(--font-display);
font-size: var(--fs-md);
line-height: 1.15;
color: var(--ink);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.sh-price {
display: flex; align-items: center; justify-content: center; gap: 8px;
min-height: 34px;
padding: 2px 12px;
border-radius: 17px;
background: rgba(40, 18, 6, 0.85);
box-shadow: inset 0 2px 3px rgba(0, 0, 0, 0.6);
}
.sh-rarity { font-size: var(--fs-xs); font-weight: 600; color: var(--ink-3); }
.sh-item.spent { box-shadow: var(--shadow-sm); }
.sh-item.spent .sh-art { color: var(--ink-3); }
.sh-cost { display: inline-flex; align-items: center; gap: 3px; color: #fff; font-family: var(--font-display); font-size: 17px; text-shadow: var(--text-outline); }
.sh-owned { display: inline-flex; align-items: center; gap: 4px; color: var(--win); font-family: var(--font-display); font-size: 15px; text-shadow: var(--text-outline); }
.sh-item.is-owned { filter: saturate(0.75); }
`;
const SHEET_STYLE = `
......@@ -238,11 +304,15 @@ const SHEET_STYLE = `
--deal-rot: -3deg;
}
.sh-sheet.flip-in { animation: flipIn 520ms var(--ease-deal) both; }
.sh-sheet-art { color: var(--suit); display: flex; }
.sh-sheet-title { font-size: var(--fs-lg); font-weight: 800; color: var(--ink); line-height: 1.3; }
.sh-sheet { position: relative; overflow: hidden; padding-top: 44px; box-shadow: 0 0 0 3px var(--rar-2, var(--card-edge)), inset 0 0 0 3px #FFFDF6, var(--shadow-lg); }
.sh-sheet .sh-ribbon { height: 30px; font-size: 15px; }
.sh-sheet-art { display: flex; filter: drop-shadow(0 8px 8px rgba(0, 0, 0, 0.4)); animation: bounceIn 520ms var(--ease-out) both; }
.sh-common { --rar: #8E9AA6; --rar-2: #5E6A75; } .sh-uncommon { --rar: #5CC24C; --rar-2: #2E7D24; } .sh-rare { --rar: #3D9BF0; --rar-2: #1D5FA8; } .sh-epic { --rar: #B06BF0; --rar-2: #6A2FB0; } .sh-legendary { --rar: #FFC23D; --rar-2: #C77A00; }
.sh-sheet-title { font-family: var(--font-display); font-size: var(--fs-xl); color: var(--ink); line-height: 1.2; }
.sh-sheet-desc { font-size: var(--fs-sm); color: var(--ink-2); line-height: 1.5; }
.sh-sheet-price { display: flex; gap: var(--s-4); justify-content: center; margin-bottom: var(--s-2); }
.sh-sheet-price .num { font-size: var(--fs-lg); }
.sh-sheet-price { padding: 6px 16px; border-radius: 20px; background: rgba(40, 18, 6, 0.85); }
.sh-sheet-price .sh-cost { font-size: 22px; }
.sh-sheet-short { display: inline-flex; align-items: center; gap: 6px; color: var(--red-ink); font-size: var(--fs-sm); font-weight: 700; }
.sh-sheet-done { color: var(--win-ink); display: flex; }
.sh-sheet-fail { color: var(--red-ink); display: flex; }
......
......@@ -129,7 +129,7 @@ export function gicon(name, size = 32, opts = {}) {
/** The El3ab crown, the logo itself. */
export function crown(size = 40, opts = {}) {
const src = getAsset('logo') || '/public/assets/game/crown.webp';
const src = getAsset('icon_crown') || '/public/assets/game/crown.webp';
return \`<img class="gi gi-crown-logo\${opts.cls ? ' ' + opts.cls : ''}" src="\${src}" width="\${size}" height="\${size}" alt="\${opts.label || ''}"\${opts.label ? '' : ' aria-hidden="true"'} draggable="false">\`;
}
......
"""Add or replace strings in public/js/core/i18n.js.
usage: python3 tools/i18n-add.py strings.json
strings.json = {"key": ["Arabic", "English"], ...}. Existing keys are replaced in place."""
import json, re, sys, os
import json, re, sys, os, fcntl
P = os.path.join(os.path.dirname(__file__), '..', 'public', 'js', 'core', 'i18n.js')
# Several people (and agents) add strings at once: hold a lock for the whole
# read-modify-write so nobody's strings are lost.
_lock = open(P + '.lock', 'w')
fcntl.flock(_lock, fcntl.LOCK_EX)
s = open(P, encoding='utf-8').read()
new = json.load(open(sys.argv[1], encoding='utf-8'))
ar_end = s.index("\n },\n en: {")
......
......@@ -25,6 +25,7 @@ const OUT = argOf('--out', './shots');
const W = +argOf('--width', 390), H = +argOf('--height', 844);
const ONLY = (argOf('--only', '') || '').split(',').filter(Boolean);
const GUEST = args.includes('--guest');
const ANON = args.includes('--anon'); // signed out: the first-run screens
const RAW_THEME = args.includes('--raw-theme');
const PUSH = argOf('--push', '');
const PARAMS = argOf('--params', '{}');
......@@ -118,7 +119,7 @@ async function goTo(page, s) {
fs.mkdirSync(OUT, { recursive: true });
let browser;
try {
await createAccount();
if (!ANON) await createAccount();
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-sandbox', '--font-render-hinting=none'] });
const page = await browser.newPage();
const errors = [];
......@@ -143,7 +144,8 @@ try {
// Seed the session before the app boots.
await page.goto(BASE + '/public/css/tokens.css');
await page.evaluate((a, guest) => {
if (ANON) await page.evaluate(() => localStorage.clear());
else await page.evaluate((a, guest) => {
const state = {
auth: { token: a.token, refreshToken: a.refresh, userId: a.id, isGuest: guest, guestId: guest ? a.id : null },
player: a.profile, language: 'ar', activeWorld: 'play',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment