diff --git a/activity/game-king/aslan.html b/activity/game-king/aslan.html new file mode 100644 index 0000000..73d34d6 --- /dev/null +++ b/activity/game-king/aslan.html @@ -0,0 +1,405 @@ + + + + + + + + Game King + + + + +
+
+ + +

+ Game King +

+ +
+ + +
+ +
+ + -- + D + + + -- + + + + -- + + + + -- + +
+
+ +
+ + +
+
+ + Loading... +
+ + + + +
+ + + + +
+ + + + + + + + +
+ + + + + + + diff --git a/activity/game-king/aslan/aslan.css b/activity/game-king/aslan/aslan.css new file mode 100644 index 0000000..2c95b3b --- /dev/null +++ b/activity/game-king/aslan/aslan.css @@ -0,0 +1,1704 @@ +:root { + --gk-page-width: 375px; + --gk-purple-950: #09063e; + --gk-purple-900: #140e64; + --gk-purple-700: #4b0bb5; + --gk-purple-500: #6954ec; + --gk-gold: #ffd66a; + --gk-text: #ffffff; + --gk-muted: rgba(255, 255, 255, 0.72); + --gk-panel: rgba(83, 89, 226, 0.92); + --gk-shadow: 0 16px 38px rgba(6, 2, 54, 0.34); +} + +* { + box-sizing: border-box; +} + +html { + min-width: 320px; + min-height: 100%; + background: var(--gk-purple-950); +} + +body { + min-width: 320px; + min-height: 100%; + margin: 0; + overflow-x: hidden; + background: var(--gk-purple-950); + color: var(--gk-text); + font-family: 'Baloo 2', 'SF Pro Display', 'Segoe UI', Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -webkit-tap-highlight-color: transparent; +} + +button, +input { + font: inherit; +} + +button { + color: inherit; +} + +button:focus-visible { + outline: 3px solid rgba(255, 238, 139, 0.84); + outline-offset: 2px; +} + +[hidden] { + display: none !important; +} + +.game-king-app { + position: relative; + width: min(100%, var(--gk-page-width)); + min-height: 100vh; + margin: 0 auto; + overflow: hidden; + background: + radial-gradient( + circle at 84% 14%, + rgba(87, 55, 246, 0.72), + transparent 26% + ), + linear-gradient(90deg, var(--gk-purple-900), var(--gk-purple-700)); + box-shadow: 0 0 60px rgba(0, 0, 0, 0.62); +} + +.hero { + position: relative; + z-index: 2; + width: 100%; + height: 501px; + /* `hidden` is a scroll container: focusing the intentionally off-canvas + Rule/Award art can set scrollLeft=45 and shift the whole Hero. `clip` + preserves Figma's crop without allowing focus-driven scrolling. */ + overflow: hidden; + overflow: clip; + background: + radial-gradient( + circle at 50% 18%, + rgba(113, 114, 255, 0.72), + transparent 25% + ), + radial-gradient( + circle at 16% 54%, + rgba(242, 89, 255, 0.3), + transparent 26% + ), + linear-gradient(180deg, #101064 0%, #2923a2 46%, #141069 100%); +} + +.hero-art { + position: absolute; + z-index: 3; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + pointer-events: none; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.hero::before, +.hero::after { + position: absolute; + right: 0; + left: 0; + content: ''; + pointer-events: none; +} + +.hero::before, +.hero::after { + display: none; +} + +.hero::before { + z-index: 0; + bottom: 0; + height: 188px; + background: + linear-gradient( + 156deg, + transparent 28%, + rgba(22, 15, 105, 0.72) 29% 42%, + transparent 43% + ) + 0 30px / 94px 135px repeat-x, + linear-gradient( + 204deg, + transparent 34%, + rgba(44, 33, 143, 0.82) 35% 53%, + transparent 54% + ) + 35px 0 / 116px 156px repeat-x, + linear-gradient(180deg, transparent, rgba(7, 7, 52, 0.92)); + opacity: 0.88; +} + +.hero::after { + z-index: 1; + bottom: -24px; + height: 132px; + background: + radial-gradient(ellipse at 8% 100%, #171052 0 34%, transparent 35%), + radial-gradient(ellipse at 28% 100%, #0d0b45 0 40%, transparent 41%), + radial-gradient(ellipse at 54% 100%, #19115a 0 42%, transparent 43%), + radial-gradient(ellipse at 78% 100%, #100b49 0 39%, transparent 40%), + radial-gradient(ellipse at 98% 100%, #181051 0 36%, transparent 37%); +} + +.hero-scene { + position: absolute; + z-index: 1; + inset: 0; + pointer-events: none; +} + +.hero-orbit { + position: absolute; + border: 2px solid rgba(191, 188, 255, 0.2); + border-radius: 50%; + transform: rotate(-18deg); +} + +.hero-orbit-one { + top: 27px; + left: -70px; + width: 510px; + height: 284px; +} + +.hero-orbit-two { + top: 103px; + left: 38px; + width: 302px; + height: 162px; + border-color: rgba(255, 214, 106, 0.18); + transform: rotate(14deg); +} + +.hero-diamond { + position: absolute; + top: 171px; + left: 50%; + width: 232px; + height: 168px; + background: linear-gradient( + 145deg, + rgba(119, 105, 255, 0.46), + rgba(29, 22, 126, 0.06) + ); + clip-path: polygon(50% 0, 100% 42%, 78% 100%, 22% 100%, 0 42%); + filter: drop-shadow(0 18px 24px rgba(17, 6, 89, 0.38)); + transform: translateX(-50%); +} + +.hero-brand { + position: absolute; + z-index: 3; + top: 29px; + right: 22px; + left: 22px; + text-align: center; + text-shadow: + 0 4px 0 #38208b, + 0 8px 18px rgba(8, 3, 66, 0.64); +} + +.hero-season { + display: inline-block; + padding: 2px 16px; + border: 1px solid rgba(255, 231, 145, 0.6); + border-radius: 999px; + background: rgba(41, 29, 142, 0.62); + color: #ffe78f; + font-size: 13px; + font-weight: 800; + letter-spacing: 0.12em; + line-height: 22px; + text-transform: uppercase; +} + +.hero-brand h1 { + display: flex; + margin: 2px 0 0; + align-items: center; + justify-content: center; + gap: 9px; + color: #fff; + font-family: 'Playball', 'Times New Roman', serif; + font-size: 62px; + font-style: italic; + font-weight: 800; + letter-spacing: -0.055em; + line-height: 0.98; +} + +.hero-brand h1 span:last-child { + color: #ffe36e; +} + +.hero-brand p { + margin: 5px 0 0; + color: rgba(255, 255, 255, 0.82); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.hero-gamepad { + position: absolute; + z-index: 2; + top: 209px; + left: 50%; + width: 238px; + height: 137px; + border: 5px solid rgba(236, 231, 255, 0.88); + border-radius: 68px 68px 46px 46px; + background: + radial-gradient( + circle at 50% 18%, + rgba(255, 255, 255, 0.82), + transparent 22% + ), + linear-gradient(160deg, #8d79ff, #3d31b6 55%, #291d84); + box-shadow: + inset 0 -13px 0 rgba(21, 13, 93, 0.34), + 0 18px 32px rgba(10, 3, 76, 0.48); + transform: translateX(-50%) rotate(-3deg); +} + +.gamepad-dpad, +.gamepad-dpad::before { + position: absolute; + width: 16px; + height: 54px; + border-radius: 5px; + background: #25215b; + content: ''; +} + +.gamepad-dpad { + top: 39px; + left: 48px; +} + +.gamepad-dpad::before { + transform: rotate(90deg); +} + +.gamepad-stick { + position: absolute; + bottom: 22px; + width: 28px; + height: 28px; + border: 5px solid #ddd8ff; + border-radius: 50%; + background: #332777; + box-shadow: 0 4px 0 rgba(17, 12, 69, 0.5); +} + +.gamepad-stick-left { + left: 83px; +} + +.gamepad-stick-right { + right: 80px; +} + +.gamepad-buttons { + position: absolute; + top: 32px; + right: 43px; + width: 58px; + height: 58px; +} + +.gamepad-buttons b { + position: absolute; + width: 17px; + height: 17px; + border: 3px solid rgba(255, 255, 255, 0.75); + border-radius: 50%; + background: #ff5dbe; + box-shadow: 0 3px 0 rgba(40, 15, 99, 0.55); +} + +.gamepad-buttons b:nth-child(1) { + top: 0; + left: 21px; +} + +.gamepad-buttons b:nth-child(2) { + top: 21px; + right: 0; + background: #63d6ff; +} + +.gamepad-buttons b:nth-child(3) { + bottom: 0; + left: 21px; + background: #ffd760; +} + +.gamepad-buttons b:nth-child(4) { + top: 21px; + left: 0; + background: #8cff8e; +} + +.side-actions { + position: absolute; + z-index: 4; + top: 327px; + right: -45px; + display: grid; + gap: 5px; +} + +.side-action { + position: relative; + width: 136px; + height: 43px; + padding: 0 48px 0 20px; + overflow: hidden; + border: 0; + background: url('./assets/layers/side-action-frame.png') right center / auto + 43px no-repeat; + color: #fff; + font-size: 19px; + font-weight: 700; + line-height: 43px; + text-align: center; + text-transform: capitalize; + text-shadow: 0 2px 4px #3839a7; + cursor: pointer; +} + +.side-action:active { + transform: translateX(3px); +} + +.countdown { + position: absolute; + z-index: 4; + top: 433px; + left: 50%; + display: flex; + height: 63px; + align-items: center; + gap: 8px; + transform: translateX(-50%); +} + +.countdown-cell { + position: relative; + display: flex; + width: 53px; + height: 63px; + align-items: center; + justify-content: center; + background: url('./assets/layers/countdown-cell.png') center / 53px 63px + no-repeat; + filter: drop-shadow(0 3px 5px rgba(18, 6, 105, 0.48)); +} + +.countdown-cell strong { + position: relative; + z-index: 1; + margin-top: 2px; + font-size: 23px; + font-weight: 800; + line-height: 1; + text-shadow: 0 2px 3px #3c4bcc; +} + +.countdown-cell small { + margin-left: 1px; + font-size: 17px; + font-weight: 800; +} + +.countdown > i { + width: 0; + margin: 0 -5px; + font-size: 23px; + font-style: normal; + font-weight: 800; + text-shadow: 0 2px 4px #342298; +} + +.event-stage { + position: relative; + min-height: calc(100vh - 501px); + padding: 5px 10px calc(10px + env(safe-area-inset-bottom)); + background: + radial-gradient( + circle at 12px 148px, + rgba(255, 255, 255, 0.72) 0 1px, + transparent 1.8px + ), + radial-gradient( + circle at 82% 330px, + rgba(211, 188, 255, 0.7) 0 1px, + transparent 2px + ), + linear-gradient( + 180deg, + rgba(100, 30, 203, 0.4), + rgba(27, 11, 132, 0.12) + ), + url('./assets/layers/stage-background.jpg') top center / 100% auto + repeat-y, + linear-gradient(90deg, var(--gk-purple-900), var(--gk-purple-700)); +} + +.primary-tabs { + position: relative; + z-index: 3; + display: grid; + width: 351px; + height: 34px; + margin: 0 auto 15px; + grid-template-columns: repeat(3, 1fr); + gap: 4px; +} + +.primary-tab { + position: relative; + min-width: 0; + height: 34px; + padding: 0 3px; + border: 0; + background: url('./assets/layers/tab-idle.png') center / 112px 34px + no-repeat; + color: rgba(255, 255, 255, 0.82); + font-family: 'Playball', 'Times New Roman', serif; + font-size: 16px; + font-style: italic; + font-weight: 700; + line-height: 34px; + white-space: nowrap; + text-align: center; + text-shadow: 0 1px 2px #1d2c7f; + cursor: pointer; +} + +.primary-tab.is-active { + background-image: url('./assets/layers/tab-active.png'); + color: #fff4fe; + font-size: 19px; +} + +.view-panel { + position: relative; + min-height: 540px; +} + +.section-state { + display: flex; + min-height: 360px; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + color: var(--gk-muted); + text-align: center; +} + +.section-state p { + max-width: 260px; + margin: 0; + font-size: 15px; + line-height: 1.5; +} + +.loading-spinner { + width: 32px; + height: 32px; + border: 3px solid rgba(255, 255, 255, 0.22); + border-top-color: var(--gk-gold); + border-radius: 50%; + animation: spinner 0.8s linear infinite; +} + +@keyframes spinner { + to { + transform: rotate(1turn); + } +} + +.retry-button { + min-width: 104px; + min-height: 40px; + padding: 8px 18px; + border: 1px solid rgba(255, 230, 124, 0.72); + border-radius: 20px; + background: rgba(67, 48, 178, 0.76); + box-shadow: var(--hy-theme-button-shadow); + color: #fff; + font-weight: 700; + cursor: pointer; +} + +.lottery-content { + display: flex; + /* The wheel starts at y=555 in Figma. Only the draw control overlaps the + wheel base; moving this whole group would pull all seven prize cells up. */ + margin-top: 0; + flex-direction: column; + align-items: center; +} + +.wheel { + position: relative; + width: 355px; + height: 417px; + margin: 0 auto; +} + +.wheel-frame { + position: absolute; + inset: 0; + width: 355px; + height: 417px; + object-fit: contain; + transform-origin: 50% 44%; + pointer-events: none; + filter: drop-shadow(0 12px 14px rgba(25, 4, 101, 0.42)); +} + +.wheel.is-spinning .wheel-frame { + animation: wheel-spin 1.55s cubic-bezier(0.12, 0.66, 0.16, 1) both; +} + +@keyframes wheel-spin { + from { + transform: rotate(0); + } + to { + transform: rotate(1080deg); + } +} + +.wheel-prizes { + position: absolute; + inset: 0; + z-index: 2; + margin: 0; + padding: 0; + list-style: none; + pointer-events: none; +} + +.wheel-prize { + position: absolute; + width: 68px; + min-height: 71px; + transform: translate(-50%, -50%); + color: #ba3320; + text-align: center; + transition: + filter 0.2s ease, + transform 0.2s ease; +} + +.wheel-prize:nth-child(1) { + top: 20%; + left: 50%; +} + +.wheel-prize:nth-child(2) { + top: 29%; + left: 73%; +} + +.wheel-prize:nth-child(3) { + top: 49%; + left: 80%; +} + +.wheel-prize:nth-child(4) { + top: 68%; + left: 64%; +} + +.wheel-prize:nth-child(5) { + top: 68%; + left: 40%; +} + +.wheel-prize:nth-child(6) { + top: 49%; + left: 20%; +} + +.wheel-prize:nth-child(7) { + top: 29%; + left: 27%; +} + +.wheel-prize img { + display: block; + width: 50px; + height: 50px; + margin: 0 auto -1px; + border-radius: 10px; + object-fit: cover; +} + +/* API media is optional while an operator is still configuring rewards. Keep the + * empty slot visually intentional; mock artwork is only used by ?mock=1. */ +.media-placeholder { + display: block; + width: 50px; + height: 50px; + margin: 0 auto -1px; + border: 1px dashed rgba(180, 51, 32, 0.45); + border-radius: 10px; + background: radial-gradient( + circle, + rgba(255, 255, 255, 0.78), + rgba(255, 207, 92, 0.22) + ); +} + +.wheel-prize span { + display: block; + overflow: hidden; + font-size: 10px; + font-weight: 700; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wheel-prize.is-winning { + z-index: 5; + filter: drop-shadow(0 0 11px #fffbd1) brightness(1.16); + transform: translate(-50%, -50%) scale(1.14); +} + +.spin-button { + position: absolute; + z-index: 4; + top: 44%; + left: 50%; + width: 102px; + height: 112px; + padding: 0; + transform: translate(-50%, -50%); + border: 0; + background: transparent; + color: #ba3320; + cursor: pointer; +} + +.spin-button img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; +} + +.spin-button strong { + position: absolute; + top: 47px; + right: 0; + left: 0; + font-size: 21px; + line-height: 1; +} + +.spin-button small { + position: absolute; + top: 76px; + left: 50%; + min-width: 36px; + height: 23px; + padding: 0 7px; + transform: translateX(-50%); + border: 1px solid #fb7185; + border-radius: 12px; + background: #e11d48; + color: #fff; + font-size: 12px; + font-weight: 800; + line-height: 21px; +} + +.spin-button:disabled { + cursor: not-allowed; + filter: grayscale(0.45) brightness(0.82); +} + +.lottery-progress { + /* The wheel centre already exposes the current draw count. Keeping this + live region in the DOM preserves the state/update contract without + adding the non-Figma progress card between the wheel and draw button. */ + display: none; + width: 335px; + margin: -20px auto 10px; + padding: 11px 13px 10px; + border: 1px solid rgba(244, 227, 255, 0.32); + border-radius: 14px; + background: rgba(35, 20, 130, 0.7); + box-shadow: inset 0 1px rgba(255, 255, 255, 0.14); + font-size: 13px; +} + +.lottery-progress > div:first-child { + display: flex; + align-items: center; + justify-content: space-between; +} + +.lottery-progress strong { + color: #fff5b2; + font-size: 19px; +} + +.records-link { + display: block; + margin: -21px 48px 4px auto; + padding: 0; + border: 0; + background: transparent; + color: #d9ddff; + font-size: 12px; + text-decoration: underline; + cursor: pointer; +} + +.progress-track { + height: 7px; + overflow: hidden; + border-radius: 999px; + background: rgba(4, 3, 75, 0.56); +} + +.progress-track span { + display: block; + width: 0; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #f7cc58, #fff5ac); + transition: width 0.3s ease; +} + +.lottery-progress p { + margin: 5px 0 0; + color: #dfe1ff; + font-size: 11px; + line-height: 1.35; + text-align: center; +} + +.draw-button { + position: relative; + width: 269px; + height: 48px; + /* Figma places the control at y=938 while the 417px wheel that starts at + y=555 ends at y=972, so the button intentionally overlaps by 34px. */ + margin-top: -34px; + padding: 0 25px; + border: 0; + background: url('./assets/layers/draw-button.png') center / 269px 48px + no-repeat; + color: #501b00; + font-size: 15px; + font-weight: 800; + text-transform: capitalize; + cursor: pointer; + filter: drop-shadow(0 6px 7px rgba(27, 2, 75, 0.34)); +} + +.draw-button:disabled { + cursor: not-allowed; + filter: grayscale(0.7) brightness(0.68); +} + +.rule-card { + width: 351px; + min-height: 172px; + margin: 15px auto 0; + padding: 11px 12px; + border-radius: 12px; + background: + linear-gradient(108deg, rgba(255, 255, 255, 0.13), transparent 45%), + var(--gk-panel); + box-shadow: var(--gk-shadow); +} + +.rule-card h2 { + margin: 0 0 3px; + font-size: 24px; + line-height: 1.35; +} + +.rule-card ol, +.rule-modal ol { + margin: 0; + padding-inline-start: 20px; +} + +.rule-card li { + margin: 3px 0; + font-size: 15px; + font-weight: 700; + line-height: 1.45; +} + +.ranking-panel { + min-height: 1180px; + /* The decorative self row reaches the 1827px frame edge; cancel only the + residual stage inset because the sticky row already honors safe area. */ + margin-bottom: calc(-7px - env(safe-area-inset-bottom)); +} + +.period-tabs { + position: relative; + display: grid; + width: 351px; + height: 87px; + margin: -6px auto 8px; + padding: 21px 22px 19px; + grid-template-columns: 1fr 1fr; + background: url('./assets/layers/rank-period-frame.png') center / 351px 87px + no-repeat; +} + +.period-tab { + position: relative; + z-index: 1; + min-width: 0; + border: 0; + background: transparent; + color: #fff; + font-size: 16px; + font-weight: 800; + white-space: nowrap; + cursor: pointer; +} + +.period-tab::before { + position: absolute; + z-index: -1; + top: 50%; + left: 50%; + width: 157px; + height: 47px; + content: ''; + opacity: 0; + transform: translate(-50%, -50%); + background: url('./assets/layers/rank-period-active.png') center / 157px + 47px no-repeat; + transition: opacity 0.15s ease; +} + +.period-tab.is-active { + color: #fff4a6; +} + +.period-tab.is-active::before { + opacity: 1; +} + +.rank-state { + min-height: 420px; +} + +.rank-empty, +.reward-empty, +.records-empty { + padding: 64px 20px; + color: var(--gk-muted); + text-align: center; +} + +.rank-empty span { + color: var(--gk-gold); + font-size: 42px; +} + +.rank-podium { + position: relative; + width: 351px; + height: 328px; + /* Figma: podium group y=647, first standard row y=987. */ + margin: 3px auto 12px; + padding: 0; + list-style: none; +} + +.podium-user { + position: absolute; + width: 116px; + color: #fff; + text-align: center; +} + +.podium-user[data-rank='1'] { + top: 0; + left: 50%; + width: 274px; + transform: translateX(-50%); +} + +.podium-user[data-rank='2'] { + top: 145px; + left: 0; + width: 117px; +} + +.podium-user[data-rank='3'] { + top: 145px; + right: 0; + width: 117px; +} + +.podium-ring { + position: relative; + width: 117px; + height: 182px; + margin: 0 auto; + background: url('./assets/layers/rank-top2-frame.png') center / contain + no-repeat; +} + +.podium-user[data-rank='1'] .podium-ring { + width: 274px; + height: 234px; + background-image: url('./assets/layers/rank-top1-frame.png'); +} + +.podium-user[data-rank='3'] .podium-ring { + background-image: url('./assets/layers/rank-top3-frame.png'); +} + +.podium-ring::before { + display: none; +} + +.podium-user[data-rank='2'] .podium-ring::before { + filter: hue-rotate(26deg) saturate(0.74) + drop-shadow(0 5px 8px rgba(24, 4, 106, 0.4)); +} + +.podium-user[data-rank='3'] .podium-ring::before { + filter: hue-rotate(314deg) saturate(1.2) + drop-shadow(0 5px 8px rgba(24, 4, 106, 0.4)); +} + +.podium-avatar { + position: absolute; + z-index: 1; + top: 20%; + left: 25%; + width: 50%; + height: 32%; + overflow: hidden; + border: 2px solid rgba(255, 246, 181, 0.92); + border-radius: 50%; + background: linear-gradient(145deg, #4a70ff, #e36fe8); + object-fit: cover; +} + +.avatar-fallback { + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(145deg, #506bff, #d866df); + color: #fff8bd; + font-size: 22px; + font-weight: 900; + text-shadow: 0 2px 3px rgba(26, 8, 99, 0.55); +} + +.podium-avatar.avatar-fallback { + position: absolute; +} + +.rank-avatar.avatar-fallback { + border: 2px solid #ffe86d; +} + +.podium-rank { + display: none; + position: absolute; + right: 50%; + bottom: 7px; + min-width: 54px; + height: 25px; + padding: 0 8px; + transform: translateX(50%); + border-radius: 13px; + background: linear-gradient(180deg, #fff7be, #f4b516); + box-shadow: 0 2px 4px rgba(92, 42, 0, 0.34); + color: #8e4d00; + font-size: 14px; + font-weight: 900; + line-height: 25px; +} + +.podium-name { + position: absolute; + z-index: 2; + top: 112px; + right: 13px; + bottom: auto; + left: 15px; + display: block; + max-width: none; + margin: 0; + overflow: hidden; + font-size: 12px; + font-weight: 800; + line-height: 17px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.podium-score { + position: absolute; + z-index: 2; + top: 130px; + right: 32px; + bottom: auto; + left: 32px; + display: block; + max-width: none; + margin: 0; + padding: 2px 4px; + overflow: hidden; + border: 1px solid #ffdf36; + border-radius: 11px; + background: rgba(238, 53, 126, 0.82); + font-size: 8px; + font-weight: 800; + line-height: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.podium-user[data-rank='1'] .podium-avatar { + top: 15%; + left: 30%; + width: 40%; + height: 47%; +} + +.podium-user[data-rank='1'] .podium-name { + top: 152px; + right: auto; + bottom: auto; + left: 92px; + width: 89px; + font-size: 16px; + line-height: 22px; +} + +.podium-user[data-rank='1'] .podium-score { + top: 178px; + right: auto; + bottom: auto; + left: 96px; + width: 82px; + font-size: 12px; + line-height: 18px; +} + +.rank-list { + display: grid; + width: 375px; + margin: 0 -10px; + padding: 0; + /* Each 84px row advances by 96px in the source frame. */ + gap: 12px; + list-style: none; +} + +.rank-row, +.my-rank { + position: relative; + display: grid; + width: 375px; + height: 84px; + align-items: center; + padding: 10px 25px 10px 20px; + grid-template-columns: 32px 54px minmax(0, 1fr) auto; + gap: 8px; + background: url('./assets/layers/rank-row.png') center / 375px 84px + no-repeat; +} + +.rank-number { + color: #fff6a6; + font-size: 17px; + font-weight: 900; + text-align: center; + text-shadow: 0 2px 3px #221295; +} + +.rank-avatar { + width: 54px; + height: 54px; + border: 2px solid #ffe86d; + border-radius: 50%; + background: linear-gradient(145deg, #4353d8, #ca63dc); + object-fit: cover; +} + +.rank-user-copy { + min-width: 0; +} + +.rank-user-copy strong, +.rank-user-copy small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rank-user-copy strong { + font-size: 13px; +} + +.rank-user-copy small { + margin-top: 4px; + color: #d9d8ff; + font-size: 10px; +} + +.rank-score { + max-width: 88px; + overflow: hidden; + color: #fff; + font-size: 13px; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + +.my-rank { + position: sticky; + z-index: 8; + bottom: env(safe-area-inset-bottom); + margin: 0 -10px; + background-image: url('./assets/layers/rank-self-row.png'); + box-shadow: 0 -8px 22px rgba(20, 7, 95, 0.32); +} + +.rewards-panel { + /* Frame 918:5732 ends at 2508px (501px Hero + 2007px stage). */ + min-height: 1992px; + padding-top: 0; +} + +/* Awards is entered from the Hero action rather than a primary section. The + * Figma view starts directly below the countdown with Back plus reward cards. */ +.game-king-app.is-rewards-view .primary-tabs, +.game-king-app.is-rewards-view .rewards-heading { + display: none; +} + +.game-king-app.is-rewards-view .rewards-panel { + padding-top: 3px; +} + +.rewards-back { + position: absolute; + z-index: 3; + top: -47px; + right: 16px; + width: 37px; + height: 37px; + border: 3px solid #ffe767; + border-radius: 50%; + background: #4d2bc1; + box-shadow: 0 3px 7px rgba(15, 4, 75, 0.45); + color: #fff88a; + font-size: 22px; + font-weight: 900; + line-height: 29px; + cursor: pointer; +} + +.rewards-heading { + display: flex; + width: 351px; + min-height: 42px; + align-items: center; + justify-content: center; + gap: 8px; + margin: -2px auto 8px; + border: 1px solid rgba(255, 227, 111, 0.6); + border-radius: 21px; + background: rgba(48, 28, 156, 0.76); + font-size: 18px; + font-weight: 800; +} + +.rewards-heading small { + color: #fff0a0; + font-size: 11px; +} + +.reward-tier-list { + display: grid; + /* First 351×317 card starts at y=516 in the Figma frame. */ + padding-top: 7px; + gap: 12px; +} + +.reward-tier { + position: relative; + width: 351px; + height: 317px; + margin: 0 auto; + overflow: hidden; + filter: drop-shadow(0 10px 13px rgba(22, 6, 93, 0.35)); +} + +/* The source art is a shared Figma image-fill whose top/middle/bottom slices + * make one card. Clipping the slices avoids treating the entire reward card as + * a fixed screen image while retaining the design's decorative geometry. */ +.reward-panel-layer { + position: absolute; + z-index: 0; + inset: 0; + display: grid; + grid-template-rows: 92px 1fr 67px; + pointer-events: none; +} + +.reward-panel-slice { + display: block; + background-image: url('./assets/layers/reward-panel.png'); + background-repeat: no-repeat; + background-size: 351px 317px; +} + +.reward-panel-slice.is-top { + background-position: center top; +} + +.reward-panel-slice.is-middle { + background-position: center -92px; +} + +.reward-panel-slice.is-bottom { + background-position: center bottom; +} + +.reward-tier h3 { + position: absolute; + z-index: 1; + top: 16px; + right: 42px; + left: 42px; + margin: 0; + overflow: hidden; + font-family: 'Playball', Georgia, serif; + font-size: 30px; + font-style: italic; + font-weight: 700; + line-height: 45px; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; + text-shadow: 0 2px 4px #20299f; +} + +.tier-rewards { + position: absolute; + z-index: 1; + top: 79px; + right: 13px; + left: 13px; + display: flex; + gap: 8px; + overflow-x: auto; + overscroll-behavior-inline: contain; + scrollbar-width: none; +} + +.tier-rewards::-webkit-scrollbar { + display: none; +} + +.tier-reward { + width: 100px; + min-width: 100px; + text-align: center; +} + +.tier-reward-media { + position: relative; + width: 100px; + height: 99px; + margin: 0 auto; + background: url('./assets/layers/reward-ring.png') center / 100px 99px + no-repeat; +} + +.tier-reward-media img { + position: absolute; + top: 19px; + left: 19px; + width: 62px; + height: 62px; + border-radius: 11px; + object-fit: cover; +} + +.tier-reward-media .media-placeholder { + position: absolute; + top: 19px; + left: 19px; + width: 62px; + height: 62px; + margin: 0; +} + +.tier-reward-count { + width: 100px; + height: 17px; + margin-top: 3px; + background: url('./assets/layers/reward-count-frame.png') center / 100px + 17px no-repeat; + color: #fff; + font-size: 11px; + font-weight: 800; + line-height: 17px; + text-align: center; +} + +.tier-reward-name { + display: none; + margin-top: 2px; + overflow: hidden; + color: #32318f; + font-size: 9px; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + +.reward-status { + position: absolute; + z-index: 1; + bottom: 34px; + left: 50%; + width: 200px; + height: 53px; + padding: 0 17px; + transform: translateX(-50%); + background: url('./assets/layers/reward-status-frame.png') center / 200px + 53px no-repeat; + color: #fff; + font-family: 'Playball', Georgia, serif; + overflow: hidden; + font-size: 14px; + font-style: italic; + font-weight: 700; + line-height: 53px; + text-align: center; + text-shadow: 0 2px 3px #a65b00; + text-overflow: ellipsis; + white-space: nowrap; +} + +.toast { + position: fixed; + z-index: 80; + bottom: calc(28px + env(safe-area-inset-bottom)); + left: 50%; + width: min(320px, calc(100vw - 32px)); + padding: 11px 16px; + transform: translateX(-50%); + border: 1px solid rgba(255, 229, 116, 0.42); + border-radius: 14px; + background: rgba(20, 10, 92, 0.94); + box-shadow: 0 14px 34px rgba(0, 0, 0, 0.38); + color: #fff; + font-size: 13px; + font-weight: 700; + line-height: 1.4; + text-align: center; +} + +.modal-backdrop { + position: fixed; + z-index: 70; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + background: rgba(7, 3, 43, 0.68); + backdrop-filter: blur(2px); +} + +.info-modal { + position: relative; + width: min(294px, calc(100vw - 40px)); + max-height: min(72vh, 520px); + overflow: auto; + padding: 24px 18px 20px; + border: 2px solid rgba(255, 255, 255, 0.84); + border-radius: 12px; + background: linear-gradient( + 150deg, + rgba(160, 166, 235, 0.98), + rgba(91, 89, 174, 0.98) + ); + box-shadow: var(--hy-theme-modal-shadow); +} + +.info-modal h2 { + margin: 0 0 10px; + font-size: 17px; + text-align: center; +} + +.rule-modal li { + margin: 5px 0; + font-size: 13px; + font-weight: 700; + line-height: 1.42; +} + +.modal-close { + position: absolute; + z-index: 2; + top: 10px; + right: 12px; + width: 28px; + height: 28px; + padding: 0; + border: 0; + background: transparent; + color: #fff; + font-size: 24px; + line-height: 26px; + cursor: pointer; +} + +.result-modal { + position: relative; + width: min(351px, calc(100vw - 20px)); + min-height: 317px; + padding: 37px 13px 22px; + background: url('./assets/layers/reward-panel.png') center / 100% 100% + no-repeat; + filter: drop-shadow(0 18px 26px rgba(12, 2, 66, 0.48)); + color: #fff; + text-align: center; +} + +.result-modal h2 { + position: absolute; + top: 17px; + right: 35px; + left: 35px; + margin: 0; + font-family: 'Playball', Georgia, serif; + font-size: 28px; + font-style: italic; + line-height: 42px; + text-shadow: 0 2px 3px #2e38a4; +} + +.result-rewards { + display: flex; + min-height: 115px; + align-items: flex-start; + justify-content: center; + gap: 5px; + margin-top: 37px; +} + +.result-reward { + width: 100px; +} + +.result-reward .tier-reward-media { + transform: scale(0.94); +} + +.result-reward strong { + display: block; + margin-top: -3px; + overflow: hidden; + color: #44409c; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.result-modal > p { + min-height: 34px; + margin: 0 15px 5px; + color: #5052a6; + font-family: Georgia, serif; + font-size: 11px; + font-style: italic; + font-weight: 700; + line-height: 1.35; +} + +.result-confirm { + width: 200px; + height: 53px; + padding: 0 20px; + border: 0; + background: url('./assets/layers/reward-status-frame.png') center / 200px + 53px no-repeat; + color: #fff; + font-family: 'Playball', Georgia, serif; + font-size: 25px; + font-style: italic; + font-weight: 700; + text-shadow: 0 2px 3px #9b5700; + cursor: pointer; +} + +.records-modal { + width: min(335px, calc(100vw - 32px)); + background: linear-gradient( + 160deg, + rgba(76, 65, 190, 0.98), + rgba(39, 27, 132, 0.98) + ); +} + +.records-loading { + display: flex; + min-height: 160px; + align-items: center; + justify-content: center; + gap: 10px; +} + +.records-loading .loading-spinner { + width: 24px; + height: 24px; +} + +.records-list { + display: grid; + margin: 0; + padding: 0; + gap: 8px; + list-style: none; +} + +.record-item { + display: grid; + min-height: 60px; + align-items: center; + padding: 7px 9px; + grid-template-columns: 44px minmax(0, 1fr); + gap: 9px; + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 10px; + background: rgba(255, 255, 255, 0.09); +} + +.record-item img { + width: 44px; + height: 44px; + border-radius: 8px; + object-fit: cover; +} + +.record-item .media-placeholder { + width: 44px; + height: 44px; + margin: 0; +} + +.record-item strong, +.record-item small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.record-item strong { + font-size: 13px; +} + +.record-item small { + margin-top: 4px; + color: #d9d8ff; + font-size: 10px; +} + +html[dir='rtl'] .side-actions { + right: auto; + left: -45px; + transform: scaleX(-1); +} + +html[dir='rtl'] .side-action span { + display: block; + transform: scaleX(-1); +} + +html[dir='rtl'] .modal-close { + right: auto; + left: 12px; +} + +html[dir='rtl'] .records-link { + margin-right: auto; + margin-left: 48px; +} + +html[dir='rtl'] .hero-brand h1 { + direction: ltr; +} + +@media (max-width: 374px) { + .game-king-app { + width: 375px; + margin-left: 50%; + transform: translateX(-50%) scale(calc(100vw / 375)); + transform-origin: top center; + } + + body { + min-height: calc(100vh * 375 / 320); + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/activity/game-king/aslan/aslan.js b/activity/game-king/aslan/aslan.js new file mode 100644 index 0000000..138a8fc --- /dev/null +++ b/activity/game-king/aslan/aslan.js @@ -0,0 +1,1517 @@ +(function () { + 'use strict'; + + var REQUEST_ID_KEY = 'aslan_game_king_pending_draw_request_id'; + var PRIZE_FALLBACK = './aslan/assets/layers/prize-fallback.png'; + var BOARD_TYPES = { tycoon: 'TYCOON', victorious: 'VICTORIOUS' }; + // The product settles exactly one six-tier reward board: total Tycoon + // consumption. Victorious remains a display leaderboard, never a second + // payout board, so the global Award entry must not follow the active tab. + var SETTLEMENT_BOARD_TYPE = 'TYCOON'; + var refs = {}; + var mockStore = null; + var toastTimer = null; + var state = { + overview: null, + view: 'lottery', + boardType: 'TYCOON', + period: 'DAILY', + rankings: {}, + drawBusy: false, + countdownTimer: null, + returnView: 'lottery', + }; + + function query(name) { + var params = new URLSearchParams(window.location.search || ''); + return params.get(name); + } + + function isMockMode() { + if (!/^(1|true|yes)$/i.test(String(query('mock') || ''))) return false; + var hostname = String(window.location.hostname || '').toLowerCase(); + var env = String(query('env') || '').toLowerCase(); + // Fixture data must never be enabled merely by adding ?mock=1 to a + // production activity URL. Allow it only on a local host or an explicit + // local/test environment used by the development WebView. + return ( + hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname === '::1' || + env === 'local' || + env === 'test' + ); + } + + function t(key, fallback) { + return window.HyAppI18n + ? window.HyAppI18n.t(key, fallback || key) + : fallback || key; + } + + function format(key, values, fallback) { + var text = t(key, fallback); + Object.keys(values || {}).forEach(function (name) { + text = text.replace( + new RegExp('\\{' + name + '\\}', 'g'), + String(values[name]) + ); + }); + return text; + } + + function toNumber(value, fallback) { + var parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback || 0; + } + + function asArray(value) { + if (Array.isArray(value)) return value; + if (value && Array.isArray(value.items)) return value.items; + if (value && Array.isArray(value.list)) return value.list; + if (value && Array.isArray(value.records)) return value.records; + return []; + } + + function firstValue(source, names, fallback) { + for (var index = 0; index < names.length; index += 1) { + var value = source && source[names[index]]; + if (value !== undefined && value !== null && value !== '') { + return value; + } + } + return fallback; + } + + function safeMediaURL(value) { + var source = String(value || '').trim(); + // Real prizes always come from the activity API. A production response without + // media must stay visibly empty instead of showing the mock Figma car as if it + // were a real reward; the local bitmap is reserved for ?mock=1 fixtures below. + if (!source) return ''; + if (isMockMode() && source === PRIZE_FALLBACK) return source; + if (/^(https?:)?\/\//i.test(source) || /^data:image\//i.test(source)) { + return source; + } + return ''; + } + + function prizeFrom(raw, index) { + var source = raw || {}; + return { + id: String( + firstValue( + source, + ['id', 'prizeId', 'rewardId'], + 'slot-' + index + ) + ), + name: String( + firstValue( + source, + ['name', 'prizeName', 'rewardName', 'resourceName'], + t('gameKing.prizePending', 'Prize pending') + ) + ), + imageUrl: safeMediaURL( + firstValue( + source, + [ + 'prizeImage', + 'imageUrl', + 'iconUrl', + 'resourceUrl', + 'coverUrl', + 'cover', + 'sourceUrl', + ], + '' + ) + ), + quantity: Math.max( + 1, + toNumber( + firstValue( + source, + ['quantity', 'count', 'amount', 'num'], + 1 + ), + 1 + ) + ), + sort: toNumber( + firstValue(source, ['sort', 'position', 'index'], index + 1), + index + 1 + ), + }; + } + + function normalizePrizes(raw) { + var source = asArray(raw) + .map(prizeFrom) + .sort(function (left, right) { + return left.sort - right.sort; + }) + .slice(0, 7); + var configuredCount = source.length; + while (source.length < 7) source.push(prizeFrom({}, source.length)); + source.configuredCount = configuredCount; + return source; + } + + function rewardFrom(raw, index) { + var source = raw || {}; + return { + id: String( + firstValue( + source, + ['id', 'rewardId', 'resourceId'], + 'reward-' + index + ) + ), + name: String( + firstValue( + source, + ['name', 'rewardName', 'resourceName'], + t('gameKing.reward', 'Reward') + ) + ), + imageUrl: safeMediaURL( + firstValue( + source, + [ + 'imageUrl', + 'iconUrl', + 'resourceUrl', + 'coverUrl', + 'cover', + 'sourceUrl', + ], + '' + ) + ), + quantity: Math.max( + 1, + toNumber( + firstValue( + source, + ['quantity', 'count', 'amount', 'num'], + 1 + ), + 1 + ) + ), + }; + } + + function rewardTierFrom(raw, index) { + var source = raw || {}; + var defaults = [ + [1, 1], + [2, 2], + [3, 3], + [4, 7], + [8, 10], + [11, 30], + ]; + var fallback = defaults[index] || [index + 1, index + 1]; + var start = toNumber( + firstValue( + source, + ['rankStart', 'startRank', 'minRank', 'from'], + fallback[0] + ), + fallback[0] + ); + var end = toNumber( + firstValue( + source, + ['rankEnd', 'endRank', 'maxRank', 'to'], + fallback[1] + ), + fallback[1] + ); + return { + start: start, + end: end, + rewards: asArray( + firstValue( + source, + ['rewards', 'resources', 'rewardItems', 'items'], + [] + ) + ).map(rewardFrom), + settlementStatus: String( + firstValue(source, ['settlementStatus', 'status'], 'PENDING') + ).toUpperCase(), + }; + } + + function normalizeRewardBoards(raw) { + var result = { TYCOON: [], VICTORIOUS: [] }; + var source = raw || {}; + if (Array.isArray(source)) { + source.forEach(function (item) { + var board = String( + firstValue( + item, + ['boardType', 'rankingType', 'type'], + 'TYCOON' + ) + ).toUpperCase(); + if (!result[board]) return; + result[board].push(rewardTierFrom(item, result[board].length)); + }); + } else { + Object.keys(result).forEach(function (board) { + var items = source[board] || source[board.toLowerCase()] || []; + result[board] = asArray(items).map(rewardTierFrom); + }); + } + return result; + } + + function normalizeOverview(raw) { + var envelope = raw || {}; + var activity = + envelope.activity || + envelope.overview || + envelope.current || + envelope; + // detail has public prize/rank configuration at its outer level, while + // activity/me carry different subsets. A flattened snapshot lets every + // renderer consume one server-authoritative object without dropping fields. + var source = + activity === envelope + ? envelope + : Object.assign({}, envelope, activity); + var prizes = normalizePrizes( + firstValue( + source, + ['prizes', 'lotteryPrizes', 'prizeList'], + envelope.prizes || [] + ) + ); + var rewards = firstValue( + source, + ['rankingRewards', 'rewardTiers', 'rankRewards'], + envelope.rankingRewards || envelope.rewardTiers || {} + ); + return { + id: String(firstValue(source, ['id', 'activityId'], '')), + name: String( + firstValue( + source, + ['name', 'activityName', 'title'], + 'Game King' + ) + ), + status: String( + firstValue(source, ['status', 'activityStatus'], 'ACTIVE') + ).toUpperCase(), + startTime: firstValue( + source, + ['startTime', 'beginTime', 'startedAt'], + '' + ), + endTime: firstValue( + source, + ['endTime', 'finishTime', 'endedAt'], + '' + ), + coinPerChance: Math.max( + 1, + toNumber( + firstValue( + source, + [ + 'coinPerChance', + 'coinPerDraw', + 'coinsPerChance', + 'consumeThreshold', + 'drawThreshold', + ], + 80000 + ), + 80000 + ) + ), + consumedCoins: Math.max( + 0, + toNumber( + firstValue( + source, + [ + 'consumedCoins', + 'totalConsumed', + 'totalConsumedCoins', + 'gameGoldConsumed', + 'totalSpent', + ], + 0 + ), + 0 + ) + ), + availableChances: Math.max( + 0, + toNumber( + firstValue( + source, + [ + 'availableChances', + 'availableDrawCount', + 'drawChances', + 'remainingChances', + ], + 0 + ), + 0 + ) + ), + dailyDrawLimit: Math.max( + 0, + toNumber( + firstValue(source, ['dailyDrawLimit', 'maxDrawsPerDay'], 0), + 0 + ) + ), + todayDrawCount: Math.max( + 0, + toNumber( + firstValue(source, ['todayDrawCount', 'drawnToday'], 0), + 0 + ) + ), + prizes: prizes, + configuredPrizeCount: prizes.configuredCount, + rules: asArray(firstValue(source, ['rules', 'ruleList'], [])), + rankingRewards: normalizeRewardBoards(rewards), + }; + } + + function normalizeRank(raw, index) { + var source = raw || {}; + return { + rank: Math.max( + 1, + toNumber( + firstValue( + source, + ['rank', 'ranking', 'position'], + index + 1 + ), + index + 1 + ) + ), + userId: String( + firstValue(source, ['userId', 'uid', 'id', 'account'], '') + ), + displayId: String( + firstValue( + source, + ['displayId', 'showId', 'shortId', 'account'], + '' + ) + ), + nickname: String( + firstValue( + source, + ['nickname', 'nickName', 'userName', 'name', 'account'], + t('gameKing.anonymous', 'Anonymous') + ) + ), + avatarUrl: String( + firstValue(source, ['avatarUrl', 'avatar', 'headUrl'], '') + ), + score: Math.max( + 0, + toNumber( + firstValue( + source, + ['score', 'value', 'coins', 'amount'], + 0 + ), + 0 + ) + ), + }; + } + + function normalizeRanking(raw) { + var source = raw || {}; + var list = asArray( + firstValue( + source, + ['entries', 'rankings', 'records', 'items', 'list'], + source + ) + ).map(normalizeRank); + var own = firstValue( + source, + ['my', 'myRank', 'self', 'currentUser'], + null + ); + return { + list: list, + myRank: own ? normalizeRank(own, list.length) : null, + }; + } + + function normalizeDraw(raw) { + var source = raw || {}; + // draw returns the delivery lifecycle inside record; keep the top-level + // fallback for older deployments without exposing failure diagnostics to UI. + var delivery = source.record || source.drawRecord || source; + var rewardSource = firstValue(source, ['rewards', 'rewardItems'], null); + var rewards = rewardSource + ? asArray(rewardSource).map(rewardFrom) + : [rewardFrom(firstValue(source, ['prize', 'reward'], source), 0)]; + return { + prizeId: String( + firstValue( + source, + ['prizeId', 'rewardId'], + rewards[0] ? rewards[0].id : '' + ) + ), + rewards: rewards, + remainingChances: firstValue( + source, + ['remainingChances', 'availableChances', 'availableDrawCount'], + null + ), + deliveryStatus: String( + firstValue(delivery, ['deliveryStatus', 'status'], 'SUCCESS') + ).toUpperCase(), + failureReason: String( + firstValue( + delivery, + ['failureReason', 'deliveryFailureReason'], + '' + ) + ), + }; + } + + function normalizeRecords(raw) { + return asArray(raw).map(function (item, index) { + var source = item || {}; + var prize = rewardFrom( + firstValue(source, ['prize', 'reward'], source), + index + ); + return { + id: String(firstValue(source, ['id', 'recordId'], index)), + name: prize.name, + imageUrl: prize.imageUrl, + quantity: prize.quantity, + createdAt: firstValue( + source, + ['createdAt', 'drawTime', 'createTime'], + '' + ), + deliveryStatus: String( + firstValue(source, ['deliveryStatus', 'status'], 'SUCCESS') + ).toUpperCase(), + failureReason: String( + firstValue( + source, + ['failureReason', 'deliveryFailureReason'], + '' + ) + ), + }; + }); + } + + function delay(value, milliseconds) { + return new Promise(function (resolve) { + window.setTimeout(function () { + resolve(value); + }, milliseconds || 180); + }); + } + + function buildMockStore() { + var icon = PRIZE_FALLBACK; + var prizes = [ + ['Nitro Rider', 1], + ['Speed King', 1], + ['Golden Car', 1], + ['Victory Badge', 3], + ['Lucky Coins', 1000], + ['Royal Frame', 1], + ['Arena Gift', 5], + ].map(function (item, index) { + return { + id: String(index + 1), + name: item[0], + quantity: item[1], + prizeImage: icon, + sort: index + 1, + }; + }); + var rewardNames = ['Royal Car', 'King Frame', 'Gold Coins']; + var ranges = [ + [1, 1], + [2, 2], + [3, 3], + [4, 7], + [8, 10], + [11, 30], + ]; + function tiers(multiplier) { + return ranges.map(function (range, tierIndex) { + return { + rankStart: range[0], + rankEnd: range[1], + rankingType: 'TYCOON', + resources: rewardNames.map(function (name, rewardIndex) { + return { + id: + String(multiplier) + + '-' + + tierIndex + + '-' + + rewardIndex, + name: name, + cover: icon, + quantity: Math.max( + 1, + multiplier * + (6 - tierIndex) * + (rewardIndex === 2 ? 500 : 1) + ), + }; + }), + }; + }); + } + function ranks(boardType, period) { + var names = [ + 'Kevin', + 'Dora', + 'Mina', + 'Alex', + 'Luna', + 'Ryan', + 'Elif', + 'Noah', + 'Sara', + 'Leo', + 'Arda', + ]; + return names.map(function (name, index) { + return { + rank: index + 1, + account: name + String(1635468 + index), + nickname: name, + avatarUrl: '', + score: + (boardType === 'TYCOON' ? 980000 : 720000) - + index * (period === 'DAILY' ? 53121 : 23121), + }; + }); + } + return { + overview: { + id: '2026-game-king-s2', + name: 'Game King Season 2', + activityStatus: 'ACTIVE', + startTime: new Date(Date.now() - 86400000).toISOString(), + endTime: new Date( + Date.now() + 6 * 86400000 + 23 * 3600000 + ).toISOString(), + coinPerDraw: 80000, + totalConsumed: 135000, + availableChances: 5, + dailyDrawLimit: 10, + todayDrawCount: 2, + prizes: prizes, + rankRewards: tiers(2), + }, + rankings: { + TYCOON_DAILY: ranks('TYCOON', 'DAILY'), + TYCOON_OVERALL: ranks('TYCOON', 'OVERALL'), + VICTORIOUS_DAILY: ranks('VICTORIOUS', 'DAILY'), + VICTORIOUS_OVERALL: ranks('VICTORIOUS', 'OVERALL'), + }, + records: [], + drawCursor: 0, + }; + } + + function activityApi() { + if (!isMockMode()) return window.AslanCommon.gameKing; + if (!mockStore) mockStore = buildMockStore(); + return { + current: function () { + return delay(mockStore.overview); + }, + ranking: function (activityId, boardType, period) { + var key = boardType + '_' + period; + return delay({ + entries: mockStore.rankings[key] || [], + my: { + rank: 18, + account: 'You2088666', + score: boardType === 'TYCOON' ? 118300 : 96400, + }, + }); + }, + draw: function (activityId, requestId) { + if (!mockStore.overview.availableChances) { + return Promise.reject(new Error('no_draw_chance')); + } + var prize = mockStore.overview.prizes[mockStore.drawCursor % 7]; + mockStore.drawCursor += 1; + mockStore.overview.availableChances -= 1; + mockStore.records.unshift({ + id: requestId, + prize: prize, + createdAt: new Date().toISOString(), + deliveryStatus: 'SUCCESS', + }); + return delay({ + prize: prize, + prizeId: prize.id, + remainingChances: mockStore.overview.availableChances, + record: { deliveryStatus: 'SUCCESS' }, + }); + }, + records: function () { + return delay({ records: mockStore.records }); + }, + }; + } + + function cacheRefs() { + [ + 'pageLoading', + 'pageError', + 'retryButton', + 'lotteryContent', + 'wheelPrizes', + 'spinButton', + 'drawButton', + 'spinChanceBadge', + 'chanceCount', + 'consumptionProgress', + 'progressCopy', + 'ruleSummary', + 'ruleList', + 'ruleButton', + 'awardButton', + 'recordsButton', + 'primaryTabs', + 'lotteryView', + 'rankingView', + 'rewardsView', + 'periodTabs', + 'rankLoading', + 'rankError', + 'rankRetryButton', + 'rankEmpty', + 'rankingContent', + 'rankPodium', + 'rankList', + 'myRank', + 'rewardTierList', + 'rewardEmpty', + 'rewardBoardName', + 'rewardsBackButton', + 'resultTitle', + 'resultRewards', + 'resultCopy', + 'recordsLoading', + 'recordsEmpty', + 'recordsList', + 'recordsModal', + 'toast', + ].forEach(function (id) { + refs[id] = document.getElementById(id); + }); + } + + function setPageState(name) { + refs.pageLoading.hidden = name !== 'loading'; + refs.pageError.hidden = name !== 'error'; + refs.lotteryContent.hidden = name !== 'ready'; + } + + function defaultRules() { + return [ + t( + 'gameKing.ruleEvent', + 'Play eligible games during the event for a chance to win rewards.' + ), + t( + 'gameKing.ruleBoards', + 'The event has Tycoon King and Victorious King leaderboards.' + ), + t( + 'gameKing.ruleTycoon', + 'Tycoon King is ranked by gold spent in eligible games.' + ), + t( + 'gameKing.ruleVictorious', + 'Victorious King is ranked by gold won in eligible games.' + ), + t( + 'gameKing.ruleSettlement', + 'Ranking rewards are settled after the activity ends.' + ), + ]; + } + + function drawRules() { + if (!state.overview) return []; + return [ + format( + 'gameKing.ruleChance', + { coins: numberText(state.overview.coinPerChance) }, + 'Receive 1 draw chance for every {coins} gold consumed in eligible games.' + ), + state.overview.dailyDrawLimit + ? format( + 'gameKing.ruleDailyLimit', + { count: state.overview.dailyDrawLimit }, + 'A maximum of {count} draws is available per day.' + ) + : t( + 'gameKing.ruleNoDailyLimit', + 'Unused chances remain available during the activity.' + ), + ]; + } + + function fillList(element, items) { + element.textContent = ''; + items.forEach(function (copy) { + var item = document.createElement('li'); + item.textContent = String(copy || ''); + element.appendChild(item); + }); + } + + function renderRules() { + fillList(refs.ruleSummary, drawRules()); + fillList( + refs.ruleList, + state.overview && state.overview.rules.length + ? state.overview.rules + : defaultRules() + ); + } + + function makeImage(url, alt, className) { + if (!url) { + var placeholder = document.createElement('span'); + placeholder.className = + (className ? className + ' ' : '') + 'media-placeholder'; + placeholder.setAttribute('role', 'img'); + placeholder.setAttribute('aria-label', alt || ''); + return placeholder; + } + var image = document.createElement('img'); + image.src = safeMediaURL(url); + image.alt = alt || ''; + if (className) image.className = className; + image.addEventListener('error', function () { + // Do not silently replace an unavailable production reward with fixture + // artwork; preserving the empty state avoids a misleading prize display. + image.replaceWith(makeImage('', alt, className)); + }); + return image; + } + + function renderWheel() { + refs.wheelPrizes.textContent = ''; + state.overview.prizes.forEach(function (prize) { + var item = document.createElement('li'); + item.className = 'wheel-prize'; + item.dataset.prizeId = prize.id; + item.appendChild(makeImage(prize.imageUrl, prize.name)); + var copy = document.createElement('span'); + copy.textContent = prize.name; + item.appendChild(copy); + refs.wheelPrizes.appendChild(item); + }); + } + + function numberText(value) { + try { + return new Intl.NumberFormat( + window.HyAppI18n ? window.HyAppI18n.lang() : 'en', + { maximumFractionDigits: 0 } + ).format(toNumber(value, 0)); + } catch (_) { + return String(toNumber(value, 0)); + } + } + + function updateLotteryStatus() { + if (!state.overview) return; + var overview = state.overview; + var remainder = overview.consumedCoins % overview.coinPerChance; + var ratio = Math.min(1, remainder / overview.coinPerChance); + refs.chanceCount.textContent = numberText(overview.availableChances); + refs.spinChanceBadge.textContent = + '×' + numberText(overview.availableChances); + refs.consumptionProgress.style.width = ratio * 100 + '%'; + if (overview.status === 'ENDED') { + refs.progressCopy.textContent = t( + 'gameKing.activityEnded', + 'The activity has ended.' + ); + } else { + refs.progressCopy.textContent = format( + 'gameKing.progressNext', + { coins: numberText(overview.coinPerChance - remainder) }, + 'Consume {coins} more gold to receive another chance.' + ); + } + var disabled = + state.drawBusy || + overview.status === 'ENDED' || + overview.availableChances < 1 || + overview.configuredPrizeCount !== 7; + refs.spinButton.disabled = disabled; + refs.drawButton.disabled = disabled; + } + + function renderOverview() { + renderWheel(); + renderRules(); + updateLotteryStatus(); + startCountdown(); + } + + function startCountdown() { + if (state.countdownTimer) window.clearInterval(state.countdownTimer); + function tick() { + var end = state.overview + ? new Date(state.overview.endTime).getTime() + : 0; + var remaining = Number.isFinite(end) + ? Math.max(0, end - Date.now()) + : 0; + var days = Math.floor(remaining / 86400000); + var hours = Math.floor((remaining % 86400000) / 3600000); + var minutes = Math.floor((remaining % 3600000) / 60000); + var seconds = Math.floor((remaining % 60000) / 1000); + var values = { + days: days, + hours: hours, + minutes: minutes, + seconds: seconds, + }; + Object.keys(values).forEach(function (unit) { + var node = document.querySelector( + '[data-countdown="' + unit + '"]' + ); + if (!node) return; + node.textContent = + unit === 'days' + ? values[unit] + : String(values[unit]).padStart(2, '0'); + }); + if (!remaining && state.overview) { + state.overview.status = 'ENDED'; + updateLotteryStatus(); + } + } + tick(); + state.countdownTimer = window.setInterval(tick, 1000); + } + + function loadOverview() { + setPageState('loading'); + return activityApi() + .current() + .then(function (data) { + state.overview = normalizeOverview(data); + renderOverview(); + setPageState('ready'); + }) + .catch(function () { + setPageState('error'); + }); + } + + function showView(view, boardType) { + state.view = view; + if (boardType) state.boardType = boardType; + // Awards is a Hero action with its own Figma composition, so hide the + // primary navigation only while preserving the normal lottery/ranking flow. + document + .getElementById('gameKingApp') + .classList.toggle('is-rewards-view', view === 'rewards'); + refs.lotteryView.hidden = view !== 'lottery'; + refs.rankingView.hidden = view !== 'ranking'; + refs.rewardsView.hidden = view !== 'rewards'; + refs.lotteryView.classList.toggle('is-active', view === 'lottery'); + refs.rankingView.classList.toggle('is-active', view === 'ranking'); + refs.rewardsView.classList.toggle('is-active', view === 'rewards'); + refs.primaryTabs + .querySelectorAll('[data-view]') + .forEach(function (button) { + var buttonView = button.dataset.view; + var active = + (view === 'lottery' && buttonView === 'lottery') || + (view !== 'lottery' && + BOARD_TYPES[buttonView] === state.boardType); + button.classList.toggle('is-active', active); + }); + if (view === 'ranking') loadRanking(); + if (view === 'rewards') renderRewards(); + } + + function rankingKey() { + return state.boardType + '_' + state.period; + } + + function setRankState(name) { + refs.rankLoading.hidden = name !== 'loading'; + refs.rankError.hidden = name !== 'error'; + refs.rankEmpty.hidden = name !== 'empty'; + refs.rankingContent.hidden = name !== 'ready'; + } + + function appendAvatar(parent, user, className) { + if (user.avatarUrl) { + parent.appendChild( + makeImage(user.avatarUrl, user.nickname, className) + ); + return; + } + var fallback = document.createElement('span'); + fallback.className = className + ' avatar-fallback'; + fallback.textContent = user.nickname.slice(0, 1).toUpperCase(); + fallback.setAttribute('aria-label', user.nickname); + parent.appendChild(fallback); + } + + function scoreLabel(score) { + return format( + 'gameKing.goldScore', + { score: numberText(score) }, + '{score} gold' + ); + } + + function renderPodium(users) { + refs.rankPodium.textContent = ''; + users.slice(0, 3).forEach(function (user) { + var item = document.createElement('li'); + item.className = 'podium-user'; + item.dataset.rank = user.rank; + var ring = document.createElement('div'); + ring.className = 'podium-ring'; + appendAvatar(ring, user, 'podium-avatar'); + var rank = document.createElement('span'); + rank.className = 'podium-rank'; + rank.textContent = format( + 'gameKing.topSingle', + { rank: user.rank }, + 'TOP {rank}' + ); + ring.appendChild(rank); + item.appendChild(ring); + var name = document.createElement('strong'); + name.className = 'podium-name'; + name.textContent = user.nickname; + item.appendChild(name); + var score = document.createElement('small'); + score.className = 'podium-score'; + // The Figma podium plate is intentionally compact. Keep the unit in + // the accessible label while rendering only the live numeric score. + score.textContent = numberText(user.score); + score.setAttribute('aria-label', scoreLabel(user.score)); + item.appendChild(score); + refs.rankPodium.appendChild(item); + }); + } + + function createRankRow(user) { + var item = document.createElement('li'); + item.className = 'rank-row'; + var rank = document.createElement('strong'); + rank.className = 'rank-number'; + rank.textContent = String(user.rank); + item.appendChild(rank); + appendAvatar(item, user, 'rank-avatar'); + var copy = document.createElement('span'); + copy.className = 'rank-user-copy'; + var name = document.createElement('strong'); + name.textContent = user.nickname; + var id = document.createElement('small'); + id.textContent = user.displayId + ? t('gameKing.idPrefix', 'ID:') + ' ' + user.displayId + : ''; + copy.appendChild(name); + copy.appendChild(id); + item.appendChild(copy); + var score = document.createElement('strong'); + score.className = 'rank-score'; + score.textContent = numberText(user.score); + item.appendChild(score); + return item; + } + + function renderRanking(data) { + renderPodium(data.list); + refs.rankList.textContent = ''; + data.list.slice(3).forEach(function (user) { + refs.rankList.appendChild(createRankRow(user)); + }); + refs.myRank.textContent = ''; + if (data.myRank) { + var own = createRankRow(data.myRank); + while (own.firstChild) refs.myRank.appendChild(own.firstChild); + refs.myRank.hidden = false; + } else { + refs.myRank.hidden = true; + } + } + + function loadRanking(force) { + var key = rankingKey(); + if (!force && state.rankings[key]) { + renderRanking(state.rankings[key]); + setRankState(state.rankings[key].list.length ? 'ready' : 'empty'); + return Promise.resolve(); + } + setRankState('loading'); + return activityApi() + .ranking(state.overview.id, state.boardType, state.period, 30) + .then(function (raw) { + var data = normalizeRanking(raw); + state.rankings[key] = data; + renderRanking(data); + setRankState(data.list.length ? 'ready' : 'empty'); + }) + .catch(function () { + setRankState('error'); + }); + } + + function tierLabel(tier) { + if (tier.start === tier.end) { + return format( + 'gameKing.topSingle', + { rank: tier.start }, + 'TOP {rank}' + ); + } + return format( + 'gameKing.topRange', + { start: tier.start, end: tier.end }, + 'TOP {start}-{end}' + ); + } + + function settlementLabel(status) { + if (status === 'SETTLED' || status === 'ISSUED') { + return t('gameKing.rewardsIssued', 'Rewards issued'); + } + if (status === 'PROCESSING') + return t('gameKing.settling', 'Settling rewards'); + return t('gameKing.settleAfterEnd', 'Settled after the activity ends'); + } + + function createRewardMedia(reward) { + var item = document.createElement('div'); + item.className = 'tier-reward'; + var media = document.createElement('div'); + media.className = 'tier-reward-media'; + media.appendChild(makeImage(reward.imageUrl, reward.name)); + item.appendChild(media); + var count = document.createElement('div'); + count.className = 'tier-reward-count'; + // The card shows the visual quantity only; the reward name remains on the + // image alt/accessible node so long API names cannot distort Figma spacing. + count.setAttribute('aria-label', reward.name); + count.textContent = '×' + numberText(reward.quantity); + item.appendChild(count); + return item; + } + + function renderRewards() { + refs.rewardBoardName.textContent = t( + 'gameKing.tycoonOverallRewards', + 'Tycoon King · Overall' + ); + refs.rewardTierList.textContent = ''; + var tiers = + state.overview && state.overview.rankingRewards + ? state.overview.rankingRewards[SETTLEMENT_BOARD_TYPE] || [] + : []; + refs.rewardEmpty.hidden = tiers.length > 0; + tiers.forEach(function (tier) { + var card = document.createElement('section'); + card.className = 'reward-tier'; + // The Figma card uses one shared image-fill sliced into top/middle/bottom; + // three clipped DOM layers preserve its corners without scaling a 317px + // panel as one bitmap over dynamic reward content. + var panelLayer = document.createElement('div'); + panelLayer.className = 'reward-panel-layer'; + ['top', 'middle', 'bottom'].forEach(function (part) { + var slice = document.createElement('i'); + slice.className = 'reward-panel-slice is-' + part; + slice.setAttribute('aria-hidden', 'true'); + panelLayer.appendChild(slice); + }); + card.appendChild(panelLayer); + var title = document.createElement('h3'); + title.textContent = tierLabel(tier); + card.appendChild(title); + var rewards = document.createElement('div'); + rewards.className = 'tier-rewards'; + tier.rewards.forEach(function (reward) { + rewards.appendChild(createRewardMedia(reward)); + }); + card.appendChild(rewards); + var status = document.createElement('div'); + status.className = 'reward-status'; + // Ranking prizes are settled automatically after the event. This is a + // static design label, never a button or a fabricated claim operation. + status.textContent = t('gameKing.receive', 'Receive'); + card.appendChild(status); + refs.rewardTierList.appendChild(card); + }); + } + + function stableRequestId() { + try { + var existing = window.sessionStorage.getItem(REQUEST_ID_KEY); + if (existing) return existing; + var created = + window.crypto && typeof window.crypto.randomUUID === 'function' + ? window.crypto.randomUUID() + : 'gk-' + + Date.now() + + '-' + + Math.random().toString(16).slice(2); + window.sessionStorage.setItem(REQUEST_ID_KEY, created); + return created; + } catch (_) { + return ( + 'gk-' + Date.now() + '-' + Math.random().toString(16).slice(2) + ); + } + } + + function clearStableRequestId() { + try { + window.sessionStorage.removeItem(REQUEST_ID_KEY); + } catch (_) {} + } + + function wait(milliseconds) { + return new Promise(function (resolve) { + window.setTimeout(resolve, milliseconds); + }); + } + + function isDeliveryPending(status) { + return ( + status === 'PROCESSING' || + status === 'PENDING' || + status === 'UNKNOWN' + ); + } + + function deliveryMessage(status, failureReason) { + if (status === 'FAILED') { + // failureReason is retained only for the record contract. It may carry + // internal operational diagnostics, so users receive safe localized copy. + return t( + 'gameKing.rewardDeliveryFailed', + 'Reward delivery failed. Please contact support.' + ); + } + if (status === 'UNKNOWN') { + return t( + 'gameKing.rewardVerification', + 'Reward status is being verified. Please contact support.' + ); + } + if (isDeliveryPending(status)) { + return t( + 'gameKing.rewardProcessing', + 'Your reward is being delivered.' + ); + } + return t( + 'gameKing.rewardIssued', + 'The reward has been automatically issued to your account.' + ); + } + + function renderResult(rewards, deliveryStatus, failureReason) { + var status = String(deliveryStatus || 'SUCCESS').toUpperCase(); + refs.resultTitle.textContent = + status === 'FAILED' + ? t( + 'gameKing.rewardDeliveryFailedTitle', + 'Reward delivery failed' + ) + : status === 'UNKNOWN' + ? t( + 'gameKing.rewardVerificationTitle', + 'Reward status is being verified' + ) + : isDeliveryPending(status) + ? t( + 'gameKing.rewardProcessingTitle', + 'Reward is processing' + ) + : t('gameKing.congratulations', 'Congratulations'); + refs.resultCopy.textContent = deliveryMessage(status, failureReason); + refs.resultRewards.textContent = ''; + rewards.forEach(function (reward) { + var item = document.createElement('div'); + item.className = 'result-reward'; + item.appendChild(createRewardMedia(reward).firstChild); + var name = document.createElement('strong'); + name.textContent = reward.name + ' ×' + numberText(reward.quantity); + item.appendChild(name); + refs.resultRewards.appendChild(item); + }); + openModal('resultModal'); + } + + function highlightPrize(prizeId) { + refs.wheelPrizes + .querySelectorAll('.wheel-prize') + .forEach(function (node) { + node.classList.toggle( + 'is-winning', + node.dataset.prizeId === String(prizeId) + ); + }); + } + + function performDraw() { + if (state.drawBusy || !state.overview) return; + if (state.overview.availableChances < 1) { + showToast(t('gameKing.noChances', 'No draw chances available.')); + return; + } + state.drawBusy = true; + updateLotteryStatus(); + document.querySelector('.wheel').classList.add('is-spinning'); + highlightPrize(''); + var requestId = stableRequestId(); + var animation = wait(1550); + activityApi() + .draw(state.overview.id, requestId) + .then(function (raw) { + var result = normalizeDraw(raw); + return animation.then(function () { + clearStableRequestId(); + if (result.remainingChances !== null) { + state.overview.availableChances = Math.max( + 0, + toNumber(result.remainingChances, 0) + ); + } else { + state.overview.availableChances = Math.max( + 0, + state.overview.availableChances - 1 + ); + } + highlightPrize(result.prizeId); + renderResult( + result.rewards, + result.deliveryStatus, + result.failureReason + ); + updateLotteryStatus(); + }); + }) + .catch(function (error) { + // 不清除 requestId:超时并不代表服务端未成功,用户重试必须复用同一幂等键。 + showToast( + error && error.message === 'no_draw_chance' + ? t('gameKing.noChances', 'No draw chances available.') + : t( + 'gameKing.drawFailed', + 'Draw failed. Tap again to retry safely.' + ) + ); + }) + .finally(function () { + document + .querySelector('.wheel') + .classList.remove('is-spinning'); + state.drawBusy = false; + updateLotteryStatus(); + }); + } + + function openModal(id) { + var modal = document.getElementById(id); + if (!modal) return; + modal.hidden = false; + document.body.style.overflow = 'hidden'; + var focusable = modal.querySelector('button'); + if (focusable) focusable.focus(); + } + + function closeModal(id) { + var modal = document.getElementById(id); + if (modal) modal.hidden = true; + if (!document.querySelector('.modal-backdrop:not([hidden])')) { + document.body.style.overflow = ''; + } + } + + function formatRecordTime(value) { + if (!value) return ''; + var date = new Date(value); + if (!Number.isFinite(date.getTime())) return String(value); + try { + return new Intl.DateTimeFormat( + window.HyAppI18n ? window.HyAppI18n.lang() : 'en', + { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + } + ).format(date); + } catch (_) { + return date.toLocaleString(); + } + } + + function loadRecords() { + refs.recordsLoading.hidden = false; + refs.recordsEmpty.hidden = true; + refs.recordsList.textContent = ''; + openModal('recordsModal'); + activityApi() + .records(state.overview ? state.overview.id : '', 50) + .then(function (raw) { + var records = normalizeRecords(raw); + refs.recordsEmpty.hidden = records.length > 0; + records.forEach(function (record) { + var item = document.createElement('li'); + item.className = 'record-item'; + item.appendChild(makeImage(record.imageUrl, record.name)); + var copy = document.createElement('span'); + var name = document.createElement('strong'); + name.textContent = + record.name + ' ×' + numberText(record.quantity); + var time = document.createElement('small'); + time.textContent = [ + formatRecordTime(record.createdAt), + deliveryMessage( + record.deliveryStatus, + record.failureReason + ), + ] + .filter(Boolean) + .join(' · '); + copy.appendChild(name); + copy.appendChild(time); + item.appendChild(copy); + refs.recordsList.appendChild(item); + }); + }) + .catch(function () { + refs.recordsEmpty.hidden = false; + showToast( + t('gameKing.recordsFailed', 'Unable to load draw records.') + ); + }) + .finally(function () { + refs.recordsLoading.hidden = true; + }); + } + + function showToast(message) { + if (toastTimer) window.clearTimeout(toastTimer); + refs.toast.textContent = message; + refs.toast.hidden = false; + toastTimer = window.setTimeout(function () { + refs.toast.hidden = true; + }, 2600); + } + + function bindEvents() { + refs.retryButton.addEventListener('click', loadOverview); + refs.rankRetryButton.addEventListener('click', function () { + loadRanking(true); + }); + refs.spinButton.addEventListener('click', performDraw); + refs.drawButton.addEventListener('click', performDraw); + refs.ruleButton.addEventListener('click', function () { + openModal('ruleModal'); + }); + refs.awardButton.addEventListener('click', function () { + state.returnView = state.view === 'ranking' ? 'ranking' : 'lottery'; + showView('rewards'); + }); + refs.rewardsBackButton.addEventListener('click', function () { + showView(state.returnView, state.boardType); + }); + refs.recordsButton.addEventListener('click', loadRecords); + refs.primaryTabs.addEventListener('click', function (event) { + var button = event.target.closest('[data-view]'); + if (!button) return; + var view = button.dataset.view; + if (view === 'lottery') showView('lottery'); + else showView('ranking', BOARD_TYPES[view]); + }); + refs.periodTabs.addEventListener('click', function (event) { + var button = event.target.closest('[data-period]'); + if (!button || button.dataset.period === state.period) return; + state.period = button.dataset.period; + refs.periodTabs + .querySelectorAll('[data-period]') + .forEach(function (item) { + item.classList.toggle('is-active', item === button); + }); + loadRanking(); + }); + document.addEventListener('click', function (event) { + var close = event.target.closest('[data-close-modal]'); + if (close) closeModal(close.dataset.closeModal); + if (event.target.classList.contains('modal-backdrop')) + closeModal(event.target.id); + }); + document.addEventListener('keydown', function (event) { + if (event.key !== 'Escape') return; + document + .querySelectorAll('.modal-backdrop:not([hidden])') + .forEach(function (modal) { + closeModal(modal.id); + }); + }); + window.addEventListener('hyapp:i18n-ready', function () { + if (!state.overview) return; + renderRules(); + updateLotteryStatus(); + if (state.view === 'ranking' && state.rankings[rankingKey()]) { + renderRanking(state.rankings[rankingKey()]); + } + if (state.view === 'rewards') renderRewards(); + }); + } + + function init() { + cacheRefs(); + bindEvents(); + loadOverview(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/activity/game-king/aslan/assets/layers/countdown-cell.png b/activity/game-king/aslan/assets/layers/countdown-cell.png new file mode 100644 index 0000000..8af7e7b Binary files /dev/null and b/activity/game-king/aslan/assets/layers/countdown-cell.png differ diff --git a/activity/game-king/aslan/assets/layers/draw-button.png b/activity/game-king/aslan/assets/layers/draw-button.png new file mode 100644 index 0000000..f7f4535 Binary files /dev/null and b/activity/game-king/aslan/assets/layers/draw-button.png differ diff --git a/activity/game-king/aslan/assets/layers/hero-game-king.png b/activity/game-king/aslan/assets/layers/hero-game-king.png new file mode 100644 index 0000000..bcf7617 Binary files /dev/null and b/activity/game-king/aslan/assets/layers/hero-game-king.png differ diff --git a/activity/game-king/aslan/assets/layers/manifest.json b/activity/game-king/aslan/assets/layers/manifest.json new file mode 100644 index 0000000..7bc954e --- /dev/null +++ b/activity/game-king/aslan/assets/layers/manifest.json @@ -0,0 +1,465 @@ +{ + "schemaVersion": 1, + "figma": { + "fileKey": "OTOg5cwbhVNWxkImGmzTJl", + "canvasNode": "646:1997", + "referenceFrames": [ + "918:4398", + "918:4508", + "918:4607", + "918:5732", + "918:12834" + ] + }, + "policy": { + "description": "Each runtime bitmap is one decorative layer only. Text, controls, dynamic reward media and user media remain DOM/API content.", + "forbiddenSourceNodes": [ + "646:1997", + "918:4398", + "918:4607", + "918:5732" + ], + "approvedStandaloneCompositeNodes": [ + "918:32244", + "918:4693", + "918:4681", + "918:4669" + ], + "forbiddenRuntimeTerms": [ + "whole-page", + "full-page", + "page-screenshot", + "root-frame", + "hero-composite" + ], + "maxDecorativeLayerPixels": 1600000 + }, + "assets": [ + { + "filename": "countdown-cell.png", + "sourceNode": "918:4415", + "sourceName": "图片 24 5", + "use": "Countdown cell decoration; time remains live DOM text.", + "kind": "single-decorative-frame", + "scope": "runtime", + "renderedDesignSize": { "width": 53, "height": 63 }, + "file": { + "format": "png", + "pixelWidth": 105, + "pixelHeight": 126, + "bytes": 23166 + }, + "sha256": "181c560ed0cffc4d4b73aaf241267284742ec935fa1d843d8e0f56ee1b8ea7a0", + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false, + "mockOnly": false + }, + { + "filename": "draw-button.png", + "sourceNode": "918:4489", + "sourceName": "图层 0 315", + "use": "Lucky draw button border; label stays in the button DOM.", + "kind": "single-decorative-frame", + "scope": "runtime", + "renderedDesignSize": { "width": 269, "height": 48 }, + "file": { + "format": "png", + "pixelWidth": 530, + "pixelHeight": 96, + "bytes": 67399 + }, + "sha256": "82404a50e6bd352c7b5b86a60c86ca98ca1ed601b49a59ef97804efa6a566090", + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false, + "mockOnly": false + }, + { + "filename": "hero-game-king.png", + "sourceNode": "918:32244", + "sourceName": "image 5068", + "use": "Approved standalone Figma header fill. It contains only the 375x501 crown, Game King title, decorative gamepad and city/crowd scene; countdown and actions remain DOM overlays.", + "kind": "approved-static-header-composite", + "scope": "runtime", + "renderedDesignSize": { "width": 375, "height": 501 }, + "naturalSize": { "width": 1085, "height": 1450 }, + "file": { + "format": "png", + "pixelWidth": 1085, + "pixelHeight": 1450, + "bytes": 2866290 + }, + "sha256": "e430b3407f9d7f10215f3af2c113aaac9e325c6ff6409db79b5f2d3c926fa732", + "containsText": true, + "containsControls": false, + "containsDynamicBusinessMedia": false, + "mockOnly": false + }, + { + "filename": "prize-fallback.png", + "sourceNode": "918:4464", + "sourceName": "缩略图 (1) 3", + "use": "Mock prize thumbnail only when ?mock=1; production prizes are API media.", + "kind": "single-fixture-media", + "scope": "fixture", + "renderedDesignSize": { "width": 50, "height": 50 }, + "file": { + "format": "png", + "pixelWidth": 100, + "pixelHeight": 100, + "bytes": 26391 + }, + "sha256": "11a65b70468495eb9ba1434f0829ff3af911694e0e27764ab5d10e398d4d29fa", + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": true, + "mockOnly": true + }, + { + "filename": "rank-period-active.png", + "sourceNode": "918:4663", + "sourceName": "啊啊撒啊大撒撒撒 1", + "use": "Selected ranking-period decoration; tab label remains DOM text.", + "kind": "single-decorative-frame", + "scope": "runtime", + "renderedDesignSize": { "width": 157, "height": 47 }, + "file": { + "format": "png", + "pixelWidth": 314, + "pixelHeight": 94, + "bytes": 49444 + }, + "sha256": "efa26c376d2acc9bab308af2a953e6f0056bfa7baec3a0120165454772704f19", + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false, + "mockOnly": false + }, + { + "filename": "rank-period-frame.png", + "sourceNode": "918:4661", + "sourceName": "sadasdsad 1", + "use": "Ranking-period switcher decoration; buttons remain DOM controls.", + "kind": "single-decorative-frame", + "scope": "runtime", + "renderedDesignSize": { "width": 351, "height": 87 }, + "file": { + "format": "png", + "pixelWidth": 702, + "pixelHeight": 174, + "bytes": 146732 + }, + "sha256": "9d7055b4d21bf55b54cb04574b55c89fcf54e63587c6278e415d8bfc667c0e80", + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false, + "mockOnly": false + }, + { + "filename": "rank-top1-frame.png", + "sourceNode": "918:4693", + "sourceName": "Top 1 frame", + "use": "Approved static Top 1 podium frame; avatar, account name and score are live DOM/API content within its transparent openings.", + "kind": "approved-static-podium-frame", + "scope": "runtime", + "renderedDesignSize": { "width": 274.219, "height": 234.375 }, + "naturalSize": { "width": 854, "height": 730 }, + "file": { + "format": "png", + "pixelWidth": 854, + "pixelHeight": 730, + "bytes": 564570 + }, + "sha256": "a9f2fc2aaf6818bbbfb86e64bbb40e53a47b642e677bc5543bcd0836f43051cc", + "containsText": true, + "containsControls": false, + "containsDynamicBusinessMedia": false, + "mockOnly": false + }, + { + "filename": "rank-top2-frame.png", + "sourceNode": "918:4681", + "sourceName": "Top 2 frame", + "use": "Approved static Top 2 podium frame; avatar, account name and score are live DOM/API content within its transparent openings.", + "kind": "approved-static-podium-frame", + "scope": "runtime", + "renderedDesignSize": { "width": 117.188, "height": 181.641 }, + "naturalSize": { "width": 546, "height": 847 }, + "file": { + "format": "png", + "pixelWidth": 546, + "pixelHeight": 847, + "bytes": 411924 + }, + "sha256": "bf0831484639a218404a03a2e6ec07839960a2c91d6ea54313049fd1c093c47f", + "containsText": true, + "containsControls": false, + "containsDynamicBusinessMedia": false, + "mockOnly": false + }, + { + "filename": "rank-top3-frame.png", + "sourceNode": "918:4669", + "sourceName": "Top 3 frame", + "use": "Approved static Top 3 podium frame; avatar, account name and score are live DOM/API content within its transparent openings.", + "kind": "approved-static-podium-frame", + "scope": "runtime", + "renderedDesignSize": { "width": 117.188, "height": 181.641 }, + "naturalSize": { "width": 546, "height": 847 }, + "file": { + "format": "png", + "pixelWidth": 546, + "pixelHeight": 847, + "bytes": 381162 + }, + "sha256": "2d57e948ecb742fea3d177d4d2d4f4dd5a341be9cfa94a5e84ac63bf57f5e754", + "containsText": true, + "containsControls": false, + "containsDynamicBusinessMedia": false, + "mockOnly": false + }, + { + "filename": "rank-row.png", + "sourceNode": "918:4706", + "sourceName": "萨达大厦 1", + "use": "Leaderboard row border; rank, avatar, user identity and score are live DOM/API content.", + "kind": "single-decorative-frame", + "scope": "runtime", + "renderedDesignSize": { "width": 375, "height": 84 }, + "file": { + "format": "png", + "pixelWidth": 750, + "pixelHeight": 168, + "bytes": 192238 + }, + "sha256": "f130b00221f2a5a7c98fc63841ea06103a3c618e252a7a25624eb2bb597a8d98", + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false, + "mockOnly": false + }, + { + "filename": "rank-self-row.png", + "sourceNode": "918:4739", + "sourceName": "撒大撒撒撒 1", + "use": "Current-user ranking row border; user data remains live DOM/API content.", + "kind": "single-decorative-frame", + "scope": "runtime", + "renderedDesignSize": { "width": 375, "height": 84 }, + "file": { + "format": "png", + "pixelWidth": 750, + "pixelHeight": 167, + "bytes": 199098 + }, + "sha256": "1d047edab697448aafa8cb49d893f6e452bdaa22d2bc57dbe3c0b8c50d17221b", + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false, + "mockOnly": false + }, + { + "filename": "reward-count-frame.png", + "sourceNode": "918:5944", + "sourceName": "dsfsd 2", + "use": "Reward quantity label decoration; reward name and quantity remain DOM/API content.", + "kind": "single-decorative-frame", + "scope": "runtime", + "renderedDesignSize": { "width": 100, "height": 17 }, + "file": { + "format": "png", + "pixelWidth": 200, + "pixelHeight": 34, + "bytes": 13545 + }, + "sha256": "6e4780ce10c58f3e2ee775bb20b9fe6f0f99205fe8e5b2b1fe7d2a21ecd77bc1", + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false, + "mockOnly": false + }, + { + "filename": "reward-panel.png", + "sourceNode": "918:5775", + "sourceName": "picsart-687df19b-6e40-4c2c-a38b-270043a2c8eb 1", + "use": "Ranking reward-card background slices only; Figma composes the card from this shared image-fill in a 92px top, stretchable middle and 67px bottom. Tier title, reward media and settlement state remain DOM/API content.", + "kind": "single-decorative-panel", + "scope": "runtime", + "renderedDesignSize": { "width": 351, "height": 317 }, + "naturalSize": { "width": 702, "height": 318 }, + "designUseSize": { "width": 351, "height": 317 }, + "file": { + "format": "png", + "pixelWidth": 702, + "pixelHeight": 318, + "bytes": 311440 + }, + "sha256": "fb8fc0bc0d0e7cef7833fc12608380d6853483e541fcd813b1ab56e6681379c6", + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false, + "mockOnly": false + }, + { + "filename": "reward-ring.png", + "sourceNode": "918:5940", + "sourceName": "图片 107 1", + "use": "Reward and podium icon surround; item images remain API media.", + "kind": "single-decorative-frame", + "scope": "runtime", + "renderedDesignSize": { "width": 100, "height": 99 }, + "file": { + "format": "png", + "pixelWidth": 200, + "pixelHeight": 197, + "bytes": 73676 + }, + "sha256": "0153c3b90ef9879ac45457f198a0ab580fc3e2f5f4618e2f867043b662a426f8", + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false, + "mockOnly": false + }, + { + "filename": "reward-status-frame.png", + "sourceNode": "918:5960", + "sourceName": "asdsad 1", + "use": "Reward settlement-status decoration; status stays live DOM text.", + "kind": "single-decorative-frame", + "scope": "runtime", + "renderedDesignSize": { "width": 200, "height": 53 }, + "file": { + "format": "png", + "pixelWidth": 400, + "pixelHeight": 106, + "bytes": 76836 + }, + "sha256": "813f96c2b8f500b64b7fb6a2a8c03ffe54d310431f3c0e67e435d6a8fc29c7c7", + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false, + "mockOnly": false + }, + { + "filename": "side-action-frame.png", + "sourceNode": "918:4432", + "sourceName": "图片 92 1", + "use": "Rule and awards side-button decoration; button text remains DOM/i18n content.", + "kind": "single-decorative-frame", + "scope": "runtime", + "renderedDesignSize": { "width": 136, "height": 43 }, + "file": { + "format": "png", + "pixelWidth": 421, + "pixelHeight": 86, + "bytes": 63072 + }, + "sha256": "24a168359e5238a67a2077be7fab3cea9f2bca4c708ff3e88a76337e1e1fae28", + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false, + "mockOnly": false + }, + { + "filename": "spin-center.png", + "sourceNode": "918:4462", + "sourceName": "图层 0 43", + "use": "Lottery spin-center decoration; button action, chance count and label remain DOM content.", + "kind": "single-decorative-frame", + "scope": "runtime", + "renderedDesignSize": { "width": 102, "height": 112 }, + "file": { + "format": "png", + "pixelWidth": 201, + "pixelHeight": 224, + "bytes": 64266 + }, + "sha256": "d38866cf0d5de9cd16f8e656944389c4b06dc0a33f910b7b2c5e392ebd5bf117", + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false, + "mockOnly": false + }, + { + "filename": "stage-background.jpg", + "sourceNode": "918:4554", + "sourceName": "8d9724ebcfedc7fe266dce4b22c1acd0c36d3eec106fb4-z5Hap7 2", + "use": "Pure decorative space-stage background only; all activity content stays DOM/API driven.", + "kind": "single-decorative-background", + "scope": "runtime", + "renderedDesignSize": { "width": 591, "height": 1280 }, + "file": { + "format": "jpeg", + "pixelWidth": 591, + "pixelHeight": 1280, + "bytes": 68918 + }, + "sha256": "156036a55c0aaaa6e65cb17cd65842f343ba8b4c20d78f5a753ce25568fca766", + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false, + "mockOnly": false + }, + { + "filename": "tab-active.png", + "sourceNode": "918:4448", + "sourceName": "sdasdasd 4", + "use": "Active primary-tab decoration; tab labels remain DOM/i18n content.", + "kind": "single-decorative-frame", + "scope": "runtime", + "renderedDesignSize": { "width": 112, "height": 34 }, + "file": { + "format": "png", + "pixelWidth": 224, + "pixelHeight": 68, + "bytes": 29835 + }, + "sha256": "82ad16cf0dea642cd96fd196caba44add87a7a08a3670f9967507fa081f0c5f1", + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false, + "mockOnly": false + }, + { + "filename": "tab-idle.png", + "sourceNode": "918:4452", + "sourceName": "sdasdasd 4", + "use": "Inactive primary-tab decoration; tab labels remain DOM/i18n content.", + "kind": "single-decorative-frame", + "scope": "runtime", + "renderedDesignSize": { "width": 112, "height": 34 }, + "file": { + "format": "png", + "pixelWidth": 224, + "pixelHeight": 68, + "bytes": 17086 + }, + "sha256": "accbdd006dd7adfbe8334ae1d2bd6c0596f4f8f7d3cbd71293e9b2c0299d5d8a", + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false, + "mockOnly": false + }, + { + "filename": "wheel-frame.png", + "sourceNode": "918:4461", + "sourceName": "大撒上的 1", + "use": "Lottery wheel decoration; seven prize cells and button stay DOM/API content.", + "kind": "single-decorative-frame", + "scope": "runtime", + "renderedDesignSize": { "width": 355, "height": 417 }, + "file": { + "format": "png", + "pixelWidth": 709, + "pixelHeight": 834, + "bytes": 941508 + }, + "sha256": "5ba68ed72edddeaa3287537c4698e7a5b2c2a7a45f1378a8f81a97dba096b3b9", + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false, + "mockOnly": false + } + ] +} diff --git a/activity/game-king/aslan/assets/layers/prize-fallback.png b/activity/game-king/aslan/assets/layers/prize-fallback.png new file mode 100644 index 0000000..3c5c2f0 Binary files /dev/null and b/activity/game-king/aslan/assets/layers/prize-fallback.png differ diff --git a/activity/game-king/aslan/assets/layers/rank-period-active.png b/activity/game-king/aslan/assets/layers/rank-period-active.png new file mode 100644 index 0000000..341e91b Binary files /dev/null and b/activity/game-king/aslan/assets/layers/rank-period-active.png differ diff --git a/activity/game-king/aslan/assets/layers/rank-period-frame.png b/activity/game-king/aslan/assets/layers/rank-period-frame.png new file mode 100644 index 0000000..37f136c Binary files /dev/null and b/activity/game-king/aslan/assets/layers/rank-period-frame.png differ diff --git a/activity/game-king/aslan/assets/layers/rank-row.png b/activity/game-king/aslan/assets/layers/rank-row.png new file mode 100644 index 0000000..314cd5e Binary files /dev/null and b/activity/game-king/aslan/assets/layers/rank-row.png differ diff --git a/activity/game-king/aslan/assets/layers/rank-self-row.png b/activity/game-king/aslan/assets/layers/rank-self-row.png new file mode 100644 index 0000000..2bea60c Binary files /dev/null and b/activity/game-king/aslan/assets/layers/rank-self-row.png differ diff --git a/activity/game-king/aslan/assets/layers/rank-top1-frame.png b/activity/game-king/aslan/assets/layers/rank-top1-frame.png new file mode 100644 index 0000000..a5af743 Binary files /dev/null and b/activity/game-king/aslan/assets/layers/rank-top1-frame.png differ diff --git a/activity/game-king/aslan/assets/layers/rank-top2-frame.png b/activity/game-king/aslan/assets/layers/rank-top2-frame.png new file mode 100644 index 0000000..a6b8e80 Binary files /dev/null and b/activity/game-king/aslan/assets/layers/rank-top2-frame.png differ diff --git a/activity/game-king/aslan/assets/layers/rank-top3-frame.png b/activity/game-king/aslan/assets/layers/rank-top3-frame.png new file mode 100644 index 0000000..45a2c4a Binary files /dev/null and b/activity/game-king/aslan/assets/layers/rank-top3-frame.png differ diff --git a/activity/game-king/aslan/assets/layers/reward-count-frame.png b/activity/game-king/aslan/assets/layers/reward-count-frame.png new file mode 100644 index 0000000..f8cf593 Binary files /dev/null and b/activity/game-king/aslan/assets/layers/reward-count-frame.png differ diff --git a/activity/game-king/aslan/assets/layers/reward-panel.png b/activity/game-king/aslan/assets/layers/reward-panel.png new file mode 100644 index 0000000..b54e944 Binary files /dev/null and b/activity/game-king/aslan/assets/layers/reward-panel.png differ diff --git a/activity/game-king/aslan/assets/layers/reward-ring.png b/activity/game-king/aslan/assets/layers/reward-ring.png new file mode 100644 index 0000000..266083b Binary files /dev/null and b/activity/game-king/aslan/assets/layers/reward-ring.png differ diff --git a/activity/game-king/aslan/assets/layers/reward-status-frame.png b/activity/game-king/aslan/assets/layers/reward-status-frame.png new file mode 100644 index 0000000..35ae9c9 Binary files /dev/null and b/activity/game-king/aslan/assets/layers/reward-status-frame.png differ diff --git a/activity/game-king/aslan/assets/layers/side-action-frame.png b/activity/game-king/aslan/assets/layers/side-action-frame.png new file mode 100644 index 0000000..400ea6b Binary files /dev/null and b/activity/game-king/aslan/assets/layers/side-action-frame.png differ diff --git a/activity/game-king/aslan/assets/layers/spin-center.png b/activity/game-king/aslan/assets/layers/spin-center.png new file mode 100644 index 0000000..2bba7e9 Binary files /dev/null and b/activity/game-king/aslan/assets/layers/spin-center.png differ diff --git a/activity/game-king/aslan/assets/layers/stage-background.jpg b/activity/game-king/aslan/assets/layers/stage-background.jpg new file mode 100644 index 0000000..061dc38 Binary files /dev/null and b/activity/game-king/aslan/assets/layers/stage-background.jpg differ diff --git a/activity/game-king/aslan/assets/layers/tab-active.png b/activity/game-king/aslan/assets/layers/tab-active.png new file mode 100644 index 0000000..07793ba Binary files /dev/null and b/activity/game-king/aslan/assets/layers/tab-active.png differ diff --git a/activity/game-king/aslan/assets/layers/tab-idle.png b/activity/game-king/aslan/assets/layers/tab-idle.png new file mode 100644 index 0000000..f8637c5 Binary files /dev/null and b/activity/game-king/aslan/assets/layers/tab-idle.png differ diff --git a/activity/game-king/aslan/assets/layers/wheel-frame.png b/activity/game-king/aslan/assets/layers/wheel-frame.png new file mode 100644 index 0000000..29e3ae8 Binary files /dev/null and b/activity/game-king/aslan/assets/layers/wheel-frame.png differ diff --git a/activity/gift-challenge/aslan.html b/activity/gift-challenge/aslan.html new file mode 100644 index 0000000..c3be1d4 --- /dev/null +++ b/activity/gift-challenge/aslan.html @@ -0,0 +1,340 @@ + + + + + + + + Gift Challenge + + + + +
+
+ + +

+ Gift Challenge +

+ +
+ + +
+
+ +
+
+ + -- + D + + + -- + + + + -- + + + + -- + +
+ + + +
+
+ + Loading... +
+ + +
+ + + + +
+
+ + + + + + + + + + + + + diff --git a/activity/gift-challenge/aslan/aslan.css b/activity/gift-challenge/aslan/aslan.css new file mode 100644 index 0000000..dbbd0ff --- /dev/null +++ b/activity/gift-challenge/aslan/aslan.css @@ -0,0 +1,984 @@ +:root { + --gift-red-950: #380400; + --gift-red-900: #590600; + --gift-red-800: #790d03; + --gift-red-700: #a71b06; + --gift-gold-100: #fff8c9; + --gift-gold-300: #ffe16b; + --gift-gold-500: #f6ad19; + --gift-gold-700: #a95a00; + --gift-panel: rgba(86, 0, 0, 0.94); + --gift-text: #fff8df; +} + +* { + box-sizing: border-box; +} + +html { + min-height: 100%; + background: #280200; + color-scheme: dark; +} + +body { + min-width: 320px; + min-height: 100%; + margin: 0; + overflow-x: hidden; + color: var(--gift-text); + background: + radial-gradient(circle at 50% 0, #a52d08 0, transparent 34rem), #280200; + font-family: + Inter, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + -webkit-tap-highlight-color: transparent; +} + +button, +input { + font: inherit; +} + +button { + color: inherit; +} + +button:focus-visible { + outline: 2px solid #fff4a4; + outline-offset: 2px; +} + +[hidden] { + display: none !important; +} + +.gift-challenge-app { + position: relative; + width: min(100%, 375px); + min-height: 100vh; + margin: 0 auto; + overflow: hidden; + background: var(--gift-red-900); + box-shadow: 0 0 38px rgba(0, 0, 0, 0.65); +} + +.hero { + position: relative; + z-index: 2; + width: 100%; + aspect-ratio: 1; + overflow: hidden; + background: #6a1600; +} + +.hero::after { + position: absolute; + right: 0; + bottom: -1px; + left: 0; + height: 34px; + background: linear-gradient(transparent, #7c1102); + content: ''; + pointer-events: none; +} + +.hero-art { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; +} + +.hero-title { + position: absolute; + z-index: 2; + right: 34px; + bottom: 23px; + left: 34px; + margin: 0; + overflow: hidden; + color: #ffeaa1; + font-family: Georgia, 'Times New Roman', serif; + font-size: clamp(20px, 6.8vw, 31px); + font-weight: 800; + line-height: 1.1; + text-align: center; + text-overflow: ellipsis; + text-shadow: + 0 2px 0 #6c0900, + 0 0 5px #ffea91; + white-space: nowrap; +} + +.side-actions { + position: absolute; + z-index: 3; + top: 126px; + right: 12px; + display: grid; + gap: 10px; +} + +.side-action { + display: grid; + width: 36px; + height: 36px; + padding: 5px; + place-items: center; + border: 2px solid #ffe76d; + border-radius: 50%; + background: linear-gradient(#aa240e, #6c0800); + box-shadow: + inset 0 0 0 2px #9b3500, + 0 2px 8px rgba(48, 0, 0, 0.55); +} + +.side-action img { + width: 24px; + height: 24px; +} + +.event-stage { + position: relative; + z-index: 1; + min-height: calc(100vh - 375px); + padding: 6px 0 90px; + background-image: + linear-gradient(rgba(123, 17, 1, 0.08), rgba(79, 5, 0, 0.32)), + url('./assets/layers/stage-background.jpg'); + background-position: center top; + background-size: 100% auto; +} + +.countdown { + --countdown-cell-size: clamp(60px, 19.2vw, 72px); + --countdown-cell-height: clamp(64px, 20.54vw, 77px); + --countdown-gap: clamp(3px, 1.4vw, 6px); + + display: grid; + grid-template-columns: + var(--countdown-cell-size) var(--countdown-cell-size) 4px + var(--countdown-cell-size) 4px var(--countdown-cell-size); + align-items: center; + justify-content: center; + width: 100%; + height: var(--countdown-cell-height); + margin: 0 auto 10px; + column-gap: var(--countdown-gap); +} + +.countdown-cell { + position: relative; + display: grid; + width: var(--countdown-cell-size); + height: var(--countdown-cell-height); + place-items: center; + background: url('./assets/layers/countdown-cell.png') center / 100% 100% + no-repeat; +} + +.countdown-cell strong { + margin-top: 6px; + color: #fff8cf; + font-family: Georgia, serif; + font-size: clamp(23px, 7.2vw, 27px); + line-height: 1; + text-shadow: + 0 2px 0 #600000, + 0 0 6px #ff9d00; +} + +.countdown-cell small { + position: absolute; + right: 9px; + bottom: 15px; + color: #fff2bd; + font-size: 9px; + font-weight: 800; +} + +.countdown > i { + color: #ffe976; + font-family: Georgia, serif; + font-size: 23px; + font-style: normal; + text-align: center; + text-shadow: 0 1px 3px #6c0000; +} + +.primary-tabs { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; + width: calc(100% - 24px); + margin: 0 auto 12px; +} + +.primary-tab { + position: relative; + min-width: 0; + height: 42px; + padding: 0 12px 4px; + overflow: hidden; + border: 0; + background: url('./assets/layers/section-tab-idle.png') center / 113px 42px + no-repeat; + color: #e5e5e5; + font-size: clamp(10px, 3vw, 12px); + font-weight: 800; + line-height: 1.05; + text-align: center; + text-shadow: 0 1px 2px #111; +} + +.primary-tab.is-active { + background-image: url('./assets/layers/section-tab-active.png'); + color: #fff3b0; + text-shadow: + 0 1px 1px #780000, + 0 0 4px #ffb800; +} + +.primary-tab span { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow-wrap: anywhere; +} + +.view-panel { + position: relative; + min-height: 420px; +} + +.section-state, +.ranking-state { + display: grid; + min-height: 260px; + padding: 30px; + place-items: center; + align-content: center; + gap: 14px; + color: #fff4cb; + text-align: center; +} + +.loading-spinner { + display: inline-block; + width: 28px; + height: 28px; + border: 3px solid rgba(255, 224, 104, 0.28); + border-top-color: #ffe571; + border-radius: 50%; + animation: gift-spin 0.8s linear infinite; +} + +@keyframes gift-spin { + to { + transform: rotate(360deg); + } +} + +.retry-button, +.ranking-state button { + min-width: 112px; + min-height: 38px; + border: 1px solid #ffe571; + border-radius: 18px; + background: linear-gradient(#d94011, #8c0800); + box-shadow: inset 0 0 7px #ffb936; +} + +.ornate-panel { + position: relative; + width: 100%; + min-height: 720px; + padding: 42px 21px 50px; +} + +.panel-decoration { + position: absolute; + z-index: 0; + inset: 0; + width: 100%; + height: 100%; + object-fit: fill; + pointer-events: none; +} + +.task-list { + position: relative; + z-index: 1; + display: grid; + gap: 20px; +} + +.task-card { + min-height: 180px; + padding: 0 0 12px; + border-bottom: 1px solid rgba(255, 203, 45, 0.28); +} + +.task-card:last-child { + border-bottom: 0; +} + +.task-heading { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 8px; + min-height: 48px; + margin-bottom: 7px; +} + +.task-title-wrap { + min-width: 0; +} + +.task-heading h3 { + display: -webkit-box; + margin: 0; + overflow: hidden; + color: #fff7e7; + font-size: 15px; + font-weight: 600; + line-height: 1.35; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow-wrap: anywhere; +} + +.task-progress { + display: block; + margin-top: 2px; + color: #ffd66b; + font-size: 11px; +} + +.task-action, +.confirm-button { + position: relative; + min-width: 72px; + min-height: 28px; + padding: 2px 12px 4px; + border: 0; + background: url('./assets/layers/action-button.png') center / 100% 100% + no-repeat; + color: #fffbe2; + font-size: 11px; + font-weight: 800; + text-shadow: 0 1px 2px #680000; +} + +.task-action:disabled, +.task-action.is-claimed { + filter: grayscale(1); + opacity: 0.78; +} + +.task-action.is-loading { + color: transparent; + pointer-events: none; +} + +.task-action.is-loading::after { + position: absolute; + top: 50%; + left: 50%; + width: 13px; + height: 13px; + margin: -7px; + border: 2px solid rgba(255, 255, 255, 0.35); + border-top-color: #fff; + border-radius: 50%; + animation: gift-spin 0.7s linear infinite; + content: ''; +} + +.reward-strip { + display: grid; + grid-template-columns: repeat(3, minmax(0, 79px)); + gap: 12px; +} + +.reward-slot { + position: relative; + width: 79px; + height: 92px; + overflow: hidden; + background: url('./assets/layers/task-reward-frame.png') center top / 79px + 80px no-repeat; +} + +.reward-slot img { + position: absolute; + top: 10px; + left: 10px; + width: 59px; + height: 56px; + object-fit: contain; +} + +.reward-placeholder { + position: absolute; + top: 13px; + left: 13px; + display: grid; + width: 53px; + height: 51px; + place-items: center; + color: #ffdc66; + font-family: Georgia, serif; + font-size: 26px; +} + +.reward-count { + position: absolute; + right: 0; + bottom: 0; + left: 0; + display: grid; + height: 30px; + place-items: center; + background: url('./assets/layers/reward-count-frame.png') center / 79px 30px + no-repeat; + color: #fff4a7; + font-size: 12px; + font-weight: 900; + text-shadow: 0 1px 2px #5f0000; +} + +.rank-period-tabs { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + width: calc(100% - 24px); + margin: 0 auto 12px; +} + +.rank-period { + height: 60px; + padding: 2px 18px 5px; + border: 0; + background-color: transparent; + background-image: url('./assets/layers/rank-period-sprite.png'); + background-position: right center; + background-size: 200% 100%; + color: #f0f0f0; + font-size: 13px; + font-weight: 800; + text-shadow: 0 1px 2px #222; +} + +.rank-period.is-active { + background-position: left center; + color: #fff0a0; + text-shadow: 0 1px 3px #7b0000; +} + +.ranking-shell { + position: relative; + width: 100%; + min-height: 980px; + padding: 85px 12px 34px; + overflow: hidden; +} + +.ranking-palace { + position: absolute; + z-index: 0; + top: 54px; + right: 10px; + left: 10px; + width: calc(100% - 20px); + height: 620px; + object-fit: cover; + border-radius: 12px; + opacity: 0.82; +} + +.ranking-frame { + position: absolute; + z-index: 1; + top: 0; + left: 0; + width: 100%; + height: 326px; + object-fit: fill; + pointer-events: none; +} + +.ranking-shell > h2, +.view-title { + position: absolute; + z-index: 2; + top: 25px; + right: 56px; + left: 56px; + margin: 0; + color: #fff7d4; + font-family: Georgia, serif; + font-size: 28px; + text-align: center; + text-shadow: + 0 2px #860000, + 0 0 6px #ffe389; +} + +.ranking-state, +.ranking-content { + position: relative; + z-index: 2; +} + +.podium { + display: grid; + grid-template-columns: repeat(3, 1fr); + align-items: end; + min-height: 215px; + margin: 0; + padding: 0 3px; + list-style: none; +} + +.podium-entry { + display: grid; + justify-items: center; + min-width: 0; + color: #fff8d7; + text-align: center; +} + +.podium-entry:nth-child(1) { + grid-column: 2; + grid-row: 1; + align-self: start; +} + +.podium-entry:nth-child(2) { + grid-column: 1; + grid-row: 1; +} + +.podium-entry:nth-child(3) { + grid-column: 3; + grid-row: 1; +} + +.podium-avatar-wrap { + position: relative; + display: grid; + width: 82px; + height: 82px; + place-items: center; + border: 5px solid #ffc83b; + border-radius: 50% 50% 45% 45%; + background: radial-gradient(#ffdd64, #7e1000 67%); + box-shadow: + 0 0 0 3px #a93d00, + 0 0 20px #ffbd1b; +} + +.podium-entry:nth-child(1) .podium-avatar-wrap { + width: 105px; + height: 105px; + border-color: #fff29d; +} + +.podium-avatar-wrap::before { + position: absolute; + top: -25px; + color: #ffdf5e; + font-family: Georgia, serif; + font-size: 18px; + font-weight: 900; + text-shadow: 0 2px #7b0000; + content: attr(data-rank-label); +} + +.podium-avatar, +.rank-avatar { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; +} + +.avatar-fallback { + display: grid; + width: 100%; + height: 100%; + place-items: center; + border-radius: 50%; + background: linear-gradient(135deg, #ffce50, #9b1800); + color: #fff6d1; + font-size: 24px; + font-weight: 900; +} + +.podium-name { + width: 105px; + margin-top: 8px; + overflow: hidden; + font-size: 13px; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + +.podium-score, +.rank-score { + display: inline-flex; + align-items: center; + gap: 3px; + color: #fff0a2; + font-size: 12px; + font-weight: 900; +} + +.podium-score img, +.rank-score img { + width: 18px; + height: 18px; +} + +.rank-list { + display: grid; + gap: 8px; + margin: 12px 0 0; + padding: 0; + list-style: none; +} + +.rank-entry, +.self-rank { + position: relative; + display: grid; + grid-template-columns: 28px 52px minmax(0, 1fr) auto; + align-items: center; + min-height: 88px; + padding: 12px 26px 12px 20px; + background: url('./assets/layers/rank-row.png') center / 100% 100% no-repeat; + color: #fff; +} + +.rank-number { + color: #ffe968; + font-family: Georgia, serif; + font-size: 18px; + font-weight: 900; + text-align: center; +} + +.rank-avatar-wrap { + width: 48px; + height: 48px; + overflow: hidden; + border: 2px solid #ffe078; + border-radius: 50%; + background: #701000; +} + +.rank-user { + min-width: 0; + padding: 0 8px; +} + +.rank-user strong, +.rank-user small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rank-user strong { + font-size: 13px; +} + +.rank-user small { + margin-top: 4px; + color: #fff0c5; + font-size: 10px; +} + +.self-rank { + position: sticky; + z-index: 4; + right: 0; + bottom: calc(8px + env(safe-area-inset-bottom)); + left: 0; + margin: 8px 10px 0; + filter: drop-shadow(0 5px 8px rgba(61, 0, 0, 0.55)); +} + +.rewards-view { + min-height: 940px; + padding: 58px 10px 20px; +} + +.reward-back { + position: absolute; + z-index: 3; + top: 2px; + right: 12px; + display: grid; + width: 38px; + height: 38px; + padding: 7px; + place-items: center; + border: 2px solid #ffe46b; + border-radius: 50%; + background: #8f1605; +} + +.reward-back img { + width: 24px; + height: 24px; +} + +.rewards-view > .view-title { + top: 7px; + font-size: 25px; +} + +.reward-tier-list { + display: grid; + gap: 14px; +} + +.reward-tier { + position: relative; + min-height: 305px; + padding: 70px 24px 30px; + overflow: hidden; + border: 3px solid #e79a15; + border-radius: 16px; + background: + linear-gradient(rgba(87, 0, 0, 0.44), rgba(87, 0, 0, 0.82)), + url('./assets/layers/ranking-palace.jpg') center / cover; + box-shadow: + inset 0 0 0 2px #ffe36a, + 0 8px 16px rgba(54, 0, 0, 0.38); +} + +.reward-tier h3 { + position: absolute; + top: 12px; + right: 24px; + left: 24px; + margin: 0; + padding: 9px 20px; + border: 2px solid #ffc83e; + border-radius: 8px; + background: linear-gradient(#c91d10, #710000); + color: #fff7d1; + font-family: Georgia, serif; + font-size: 25px; + text-align: center; + text-shadow: 0 2px #730000; +} + +.reward-tier .reward-strip { + grid-template-columns: repeat(3, 1fr); + justify-items: center; + gap: 16px 12px; +} + +.reward-tier .reward-slot { + transform: scale(1.15); + transform-origin: center top; + margin-bottom: 14px; +} + +.empty-copy { + position: relative; + z-index: 2; + margin: 36px auto; + padding: 20px; + color: #ffecc1; + text-align: center; +} + +.modal { + position: fixed; + z-index: 100; + inset: 0; + display: grid; + padding: 18px; + place-items: center; +} + +.modal-backdrop { + position: absolute; + inset: 0; + background: rgba(24, 0, 0, 0.76); + backdrop-filter: blur(3px); +} + +.modal-card { + position: relative; + width: min(100%, 355px); + max-height: min(82vh, 620px); + padding: 58px 28px 34px; + overflow-y: auto; + border: 4px solid #d88c11; + border-radius: 18px; + background: + linear-gradient(rgba(112, 0, 0, 0.86), rgba(55, 0, 0, 0.95)), + url('./assets/layers/ranking-palace.jpg') center / cover; + box-shadow: + inset 0 0 0 2px #ffe06a, + 0 18px 40px rgba(0, 0, 0, 0.52); +} + +.modal-card::before { + position: absolute; + top: 12px; + right: 36px; + left: 36px; + height: 42px; + border: 2px solid #ffc341; + border-radius: 7px; + background: linear-gradient(#c42314, #6d0000); + content: ''; +} + +.modal-card h2 { + position: absolute; + z-index: 2; + top: 19px; + right: 55px; + left: 55px; + margin: 0; + overflow: hidden; + color: #fff5c4; + font-family: Georgia, serif; + font-size: 22px; + line-height: 28px; + text-align: center; + text-overflow: ellipsis; + text-shadow: 0 1px #790000; + white-space: nowrap; +} + +.modal-close { + position: absolute; + z-index: 4; + top: 7px; + right: 7px; + width: 34px; + height: 34px; + border: 1px solid #ffe36b; + border-radius: 50%; + background: #8c0c02; + color: #fff0a5; + font-size: 24px; + line-height: 28px; +} + +.rule-copy h3 { + margin: 18px 0 6px; + color: #ffe078; + font-size: 16px; +} + +.rule-copy p { + margin: 0; + color: #fff3dd; + font-size: 13px; + line-height: 1.65; +} + +.result-card { + text-align: center; +} + +.result-rewards { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 10px; + min-height: 100px; + padding-top: 10px; +} + +.result-card p { + margin: 14px 0 18px; + color: #fff4de; + font-size: 12px; +} + +.confirm-button { + width: 216px; + height: 58px; + font-size: 20px; +} + +.toast { + position: fixed; + z-index: 140; + bottom: calc(24px + env(safe-area-inset-bottom)); + left: 50%; + width: max-content; + max-width: calc(100vw - 36px); + padding: 10px 16px; + border: 1px solid rgba(255, 223, 93, 0.58); + border-radius: 18px; + background: rgba(58, 0, 0, 0.92); + color: #fff4d0; + font-size: 13px; + text-align: center; + transform: translateX(-50%); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); +} + +[dir='rtl'] .side-actions { + right: auto; + left: 12px; +} + +[dir='rtl'] .reward-back { + right: auto; + left: 12px; + transform: scaleX(-1); +} + +[dir='rtl'] .rank-entry, +[dir='rtl'] .self-rank { + padding-right: 20px; + padding-left: 26px; +} + +@media (max-width: 340px) { + .task-heading { + min-height: 68px; + } + + .task-heading h3 { + -webkit-line-clamp: 3; + } +} + +@media (min-width: 600px) { + .gift-challenge-app { + min-height: 100vh; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} diff --git a/activity/gift-challenge/aslan/aslan.js b/activity/gift-challenge/aslan/aslan.js new file mode 100644 index 0000000..f44e02d --- /dev/null +++ b/activity/gift-challenge/aslan/aslan.js @@ -0,0 +1,1274 @@ +(function () { + 'use strict'; + + var RANKING_LIMIT = 30; + var DEFAULT_TIME_ZONE = 'Asia/Riyadh'; + var state = { + detail: null, + activityId: '', + currentView: 'tasks', + currentPeriod: 'DAILY', + serverOffsetMs: 0, + countdownTimer: null, + rankings: {}, + rankingLoading: {}, + claimRequestIds: {}, + claiming: {}, + toastTimer: null, + }; + + function byId(id) { + return document.getElementById(id); + } + + function all(selector, root) { + return Array.prototype.slice.call( + (root || document).querySelectorAll(selector) + ); + } + + function t(key, fallback, params) { + var translated = fallback || key; + if (window.HyAppI18n && window.HyAppI18n.t) { + translated = window.HyAppI18n.t(key, fallback || key); + } + // common/i18n.js 的 t 只负责取词,活动页在这里统一插值,避免把 params + // 误当 fallback 导致语言包尚未就绪时返回对象并触发运行时异常。 + translated = text(translated || fallback || key); + Object.keys(params || {}).forEach(function (name) { + translated = translated + .split('{' + name + '}') + .join(text(params[name])); + }); + return translated; + } + + function asArray(value) { + return Array.isArray(value) ? value : []; + } + + function firstDefined() { + for (var index = 0; index < arguments.length; index += 1) { + var value = arguments[index]; + if (value !== undefined && value !== null && value !== '') { + return value; + } + } + return undefined; + } + + function toNumber(value, fallback) { + var parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback || 0; + } + + function toTime(value) { + if (value === undefined || value === null || value === '') return 0; + if (typeof value === 'number') { + return value > 0 && value < 100000000000 ? value * 1000 : value; + } + var numeric = Number(value); + if (Number.isFinite(numeric)) return toTime(numeric); + var parsed = Date.parse(String(value)); + return Number.isFinite(parsed) ? parsed : 0; + } + + function text(value) { + return value === undefined || value === null ? '' : String(value); + } + + function createElement(tagName, className, content) { + var element = document.createElement(tagName); + if (className) element.className = className; + if (content !== undefined) element.textContent = text(content); + return element; + } + + function formatNumber(value) { + var number = toNumber(value, 0); + try { + return new Intl.NumberFormat( + document.documentElement.lang || 'en', + { + notation: + Math.abs(number) >= 1000000 ? 'compact' : 'standard', + maximumFractionDigits: 1, + } + ).format(number); + } catch (_) { + return String(number); + } + } + + function safeImageURL(value) { + var candidate = text(value).trim(); + if (!candidate) return ''; + try { + var parsed = new URL(candidate, window.location.href); + var loopback = ['127.0.0.1', 'localhost', '[::1]'].indexOf( + parsed.hostname + ); + // Production media must be HTTPS. HTTP is accepted only for loopback + // mock/local development so a remote response cannot inject mixed content. + if (parsed.protocol === 'https:' || loopback >= 0) { + return parsed.toString(); + } + } catch (_) { + return ''; + } + return ''; + } + + function query(name) { + return new URLSearchParams(window.location.search || '').get(name); + } + + function isMockMode() { + return query('mock') === '1'; + } + + function makeRequestId(scope) { + var prefix = 'gift-challenge:' + text(scope) + ':'; + var random = + window.crypto && window.crypto.randomUUID + ? window.crypto.randomUUID() + : Date.now().toString(36) + Math.random().toString(36).slice(2); + return prefix + random; + } + + function showToast(message) { + var toast = byId('toast'); + if (!toast || !message) return; + window.clearTimeout(state.toastTimer); + toast.textContent = message; + toast.hidden = false; + state.toastTimer = window.setTimeout(function () { + toast.hidden = true; + }, 2600); + } + + function getActivity(payload) { + return (payload && payload.activity) || payload || {}; + } + + function normalizeReward(reward, index) { + reward = reward || {}; + return { + id: text( + firstDefined( + reward.id, + reward.rewardId, + reward.resourceId, + 'reward-' + index + ) + ), + name: text( + firstDefined( + reward.name, + reward.rewardName, + reward.resourceName, + t('giftChallenge.reward', 'Reward') + ) + ), + quantity: Math.max( + 1, + toNumber( + firstDefined( + reward.quantity, + reward.count, + reward.amount, + reward.duration, + 1 + ), + 1 + ) + ), + imageUrl: safeImageURL( + firstDefined( + reward.imageUrl, + reward.iconUrl, + reward.resourceUrl, + reward.cover, + reward.sourceUrl, + reward.url + ) + ), + }; + } + + function normalizeTask(task, index) { + task = task || {}; + var target = Math.max( + 1, + toNumber(firstDefined(task.target, task.targetValue, 1), 1) + ); + var progress = Math.max( + 0, + toNumber( + firstDefined( + task.progress, + task.progressValue, + task.currentValue, + 0 + ), + 0 + ) + ); + var rawStatus = text( + firstDefined(task.deliveryStatus, task.status, task.taskStatus) + ).toUpperCase(); + var completed = + task.completed === true || + Boolean(task.completedTime) || + rawStatus === 'COMPLETED' || + progress >= target; + var claimed = + task.claimed === true || + rawStatus === 'SUCCESS' || + rawStatus === 'CLAIMED' || + rawStatus === 'RECEIVED'; + var claimable = + !claimed && + (rawStatus === '' || + rawStatus === 'NOT_CLAIMED' || + rawStatus === 'CLAIMABLE' || + rawStatus === 'COMPLETED') && + (task.claimable === true || completed); + var type = text( + firstDefined(task.taskType, task.type, task.code) + ).toUpperCase(); + var code = text( + firstDefined(task.taskCode, task.code, 'TASK_' + (index + 1)) + ); + return { + code: code, + type: type, + title: text( + firstDefined( + task.taskTitle, + task.title, + task.taskName, + task.name + ) + ), + sortOrder: Math.max( + 1, + toNumber( + firstDefined(task.sortOrder, task.sort, index + 1), + index + 1 + ) + ), + target: target, + progress: progress, + completed: completed, + claimed: claimed, + claimable: claimable, + deliveryStatus: rawStatus, + enabled: task.enabled !== false, + rewards: asArray( + firstDefined( + task.rewards, + task.rewardItems, + task.rewardList, + [] + ) + ).map(normalizeReward), + }; + } + + function normalizeTasks(tasks) { + return asArray(tasks) + .map(normalizeTask) + .filter(function (task) { + return task.enabled; + }) + .sort(function (left, right) { + return ( + left.sortOrder - right.sortOrder || + left.code.localeCompare(right.code) + ); + }); + } + + function normalizeTier(tier, index) { + tier = tier || {}; + var startRank = Math.max( + 1, + toNumber( + firstDefined( + tier.startRank, + tier.rankStart, + tier.fromRank, + index + 1 + ), + index + 1 + ) + ); + var endRank = Math.max( + startRank, + toNumber( + firstDefined( + tier.endRank, + tier.rankEnd, + tier.toRank, + startRank + ), + startRank + ) + ); + return { + id: text(firstDefined(tier.id, tier.ruleId, index)), + period: text( + firstDefined( + tier.periodType, + tier.period, + tier.rankingPeriod, + 'OVERALL' + ) + ).toUpperCase(), + startRank: startRank, + endRank: endRank, + rewards: asArray( + firstDefined( + tier.rewards, + tier.rewardItems, + tier.rewardList, + [] + ) + ).map(normalizeReward), + }; + } + + function normalizeRankRewards(payload) { + var raw = firstDefined( + payload && payload.rankRewards, + payload && payload.rankingRewards, + getActivity(payload).rankRewards, + [] + ); + var result = { DAILY: [], OVERALL: [] }; + if (Array.isArray(raw)) { + raw.map(normalizeTier).forEach(function (tier) { + var period = tier.period === 'DAILY' ? 'DAILY' : 'OVERALL'; + result[period].push(tier); + }); + } else if (raw && typeof raw === 'object') { + ['DAILY', 'OVERALL'].forEach(function (period) { + result[period] = asArray( + raw[period] || raw[period.toLowerCase()] + ).map(function (tier, index) { + return normalizeTier( + Object.assign({}, tier, { period: period }), + index + ); + }); + }); + } + Object.keys(result).forEach(function (period) { + result[period].sort(function (left, right) { + return left.startRank - right.startRank; + }); + }); + return result; + } + + function normalizeDetail(payload) { + payload = payload || {}; + var activity = getActivity(payload); + var tasks = firstDefined( + payload.tasks, + payload.taskList, + payload.me && payload.me.tasks, + activity.tasks, + [] + ); + var serverTime = toTime( + firstDefined(payload.serverTime, activity.serverTime, Date.now()) + ); + return { + raw: payload, + activityId: text( + firstDefined( + activity.activityId, + activity.id, + payload.activityId, + payload.id + ) + ), + name: text( + firstDefined( + activity.activityName, + activity.name, + t('giftChallenge.title', 'Gift Challenge') + ) + ), + status: text( + firstDefined(payload.activityStatus, activity.status, 'ACTIVE') + ).toUpperCase(), + timeZone: text( + firstDefined( + activity.timeZone, + payload.timeZone, + DEFAULT_TIME_ZONE + ) + ), + startTime: toTime( + firstDefined(activity.startTime, payload.startTime) + ), + endTime: toTime(firstDefined(activity.endTime, payload.endTime)), + serverTime: serverTime || Date.now(), + statDate: text( + firstDefined( + payload.statDate, + payload.currentDate, + activity.statDate + ) + ), + tasks: normalizeTasks(tasks), + rankRewards: normalizeRankRewards(payload), + }; + } + + function mockDetail() { + var now = Date.now(); + var rewardSet = [ + { id: 'gold', name: 'Gold', quantity: 99999 }, + { id: 'frame', name: 'Avatar Frame', quantity: 7 }, + { id: 'badge', name: 'Badge', quantity: 1 }, + ]; + return { + activity: { + id: 'mock-gift-challenge', + activityName: 'Gift Challenge', + timeZone: DEFAULT_TIME_ZONE, + startTime: now - 86400000, + endTime: now + 6 * 86400000, + status: 'ACTIVE', + }, + serverTime: now, + activityStatus: 'ACTIVE', + tasks: [ + { + id: 'mock-task-1', + taskCode: 'DAILY_ENTRY', + taskType: 'ENTER_PAGE', + taskTitle: 'Visit the event page daily.', + targetValue: 1, + enabled: true, + sortOrder: 1, + rewardItems: rewardSet, + }, + { + id: 'mock-task-2', + taskCode: 'SEND_GIFT_GOLD_1', + taskType: 'SEND_GIFT_GOLD', + taskTitle: 'Send 5,000 Gold worth of eligible gifts daily.', + targetValue: 5000, + enabled: true, + sortOrder: 2, + rewardItems: rewardSet, + }, + { + id: 'mock-task-3', + taskCode: 'SEND_GIFT_GOLD_2', + taskType: 'SEND_GIFT_GOLD', + taskTitle: + 'Send 10,000 Gold worth of eligible gifts daily.', + targetValue: 10000, + enabled: true, + sortOrder: 3, + rewardItems: rewardSet, + }, + ], + rankRewards: [ + { + periodType: 'DAILY', + startRank: 1, + endRank: 1, + rewardItems: rewardSet, + }, + { + periodType: 'DAILY', + startRank: 2, + endRank: 3, + rewardItems: rewardSet, + }, + { + periodType: 'OVERALL', + startRank: 1, + endRank: 1, + rewardItems: rewardSet, + }, + { + periodType: 'OVERALL', + startRank: 2, + endRank: 2, + rewardItems: rewardSet, + }, + { + periodType: 'OVERALL', + startRank: 3, + endRank: 3, + rewardItems: rewardSet, + }, + { + periodType: 'OVERALL', + startRank: 4, + endRank: 6, + rewardItems: rewardSet, + }, + { + periodType: 'OVERALL', + startRank: 7, + endRank: 10, + rewardItems: rewardSet, + }, + ], + }; + } + + function mockUserState() { + var rewardSet = mockDetail().rankRewards[0].rewardItems; + return { + activityId: 'mock-gift-challenge', + statDate: new Date().toISOString().slice(0, 10), + totalScore: 123456, + dailyScore: 123456, + overallRank: 4, + dailyRank: 4, + tasks: [ + { + taskCode: 'DAILY_ENTRY', + taskType: 'ENTER_PAGE', + taskTitle: 'Visit the event page daily.', + sortOrder: 1, + targetValue: 1, + progressValue: 1, + completed: true, + deliveryStatus: 'NOT_CLAIMED', + rewardItems: rewardSet, + }, + { + taskCode: 'SEND_GIFT_GOLD_1', + taskType: 'SEND_GIFT_GOLD', + taskTitle: 'Send 5,000 Gold worth of eligible gifts daily.', + sortOrder: 2, + targetValue: 5000, + progressValue: 2400, + completed: false, + deliveryStatus: 'NOT_CLAIMED', + rewardItems: rewardSet, + }, + { + taskCode: 'SEND_GIFT_GOLD_2', + taskType: 'SEND_GIFT_GOLD', + taskTitle: + 'Send 10,000 Gold worth of eligible gifts daily.', + sortOrder: 3, + targetValue: 10000, + progressValue: 10000, + completed: true, + deliveryStatus: 'SUCCESS', + rewardItems: rewardSet, + }, + ], + }; + } + + function mockRanking(period) { + var entries = Array.from({ length: 10 }, function (_, index) { + return { + rank: index + 1, + userId: 'mock-' + (index + 1), + account: String(1635468 + index), + nickname: ['Keven', 'Ayla', 'Leo', 'Nora'][index % 4], + score: (123456 - index * 8173) * (period === 'OVERALL' ? 6 : 1), + me: index === 3, + }; + }); + return { + entries: entries, + my: Object.assign({}, entries[3], { me: true }), + periodType: period, + settled: period === 'DAILY', + }; + } + + function mockApi() { + return { + current: function () { + return Promise.resolve(mockDetail()); + }, + enter: function () { + return Promise.resolve(mockUserState()); + }, + ranking: function (_, period) { + return Promise.resolve(mockRanking(period)); + }, + claimTask: function (_, taskCode) { + var userState = mockUserState(); + var task = userState.tasks.find(function (item) { + return item.taskCode === taskCode; + }); + if (task) task.deliveryStatus = 'SUCCESS'; + return Promise.resolve(userState); + }, + }; + } + + function api() { + if (isMockMode()) return mockApi(); + if (window.AslanCommon && window.AslanCommon.giftChallenge) { + return window.AslanCommon.giftChallenge; + } + throw new Error('gift_challenge_api_unavailable'); + } + + function taskTitle(task) { + if (task.type === 'ENTER_PAGE' || task.code === 'ENTER_PAGE') { + return t('giftChallenge.taskEnter', 'Visit the event page daily.'); + } + if (task.type === 'SEND_GIFT_GOLD') { + return t( + 'giftChallenge.taskSendGold', + 'Send {amount} Gold worth of eligible gifts daily.', + { amount: formatNumber(task.target) } + ).replace('{amount}', formatNumber(task.target)); + } + // 固定任务类型使用 common 四语文案;后台 taskTitle 只作为未知扩展类型兜底, + // 否则单一配置字符串会覆盖 ar/tr/es 并破坏页面多语言能力。 + if (task.title) return task.title; + return t('giftChallenge.untitledTask', 'Daily task'); + } + + function rewardSlot(reward) { + reward = normalizeReward(reward, 0); + var slot = createElement('div', 'reward-slot'); + slot.title = reward.name; + if (reward.imageUrl) { + var image = document.createElement('img'); + image.src = reward.imageUrl; + image.alt = reward.name; + image.loading = 'lazy'; + image.referrerPolicy = 'no-referrer'; + slot.appendChild(image); + } else { + var fallback = createElement('span', 'reward-placeholder', '✦'); + fallback.setAttribute('aria-label', reward.name); + slot.appendChild(fallback); + } + slot.appendChild( + createElement('span', 'reward-count', formatNumber(reward.quantity)) + ); + return slot; + } + + function renderTasks() { + var list = byId('taskList'); + var empty = byId('taskEmpty'); + if (!list || !state.detail) return; + list.replaceChildren(); + var tasks = state.detail.tasks; + empty.hidden = tasks.length > 0; + tasks.forEach(function (task) { + var card = createElement('article', 'task-card'); + card.dataset.taskCode = task.code; + var heading = createElement('div', 'task-heading'); + var titleWrap = createElement('div', 'task-title-wrap'); + titleWrap.appendChild(createElement('h3', '', taskTitle(task))); + titleWrap.appendChild( + createElement( + 'span', + 'task-progress', + formatNumber(Math.min(task.progress, task.target)) + + ' / ' + + formatNumber(task.target) + ) + ); + heading.appendChild(titleWrap); + + var action = createElement('button', 'task-action'); + action.type = 'button'; + action.dataset.taskCode = task.code; + if (state.claiming[task.code]) { + action.classList.add('is-loading'); + action.disabled = true; + action.textContent = t('giftChallenge.claiming', 'Claiming'); + } else if (task.claimed) { + action.classList.add('is-claimed'); + action.disabled = true; + action.textContent = t('giftChallenge.claimed', 'Claimed'); + } else if (task.claimable) { + action.dataset.action = 'claim'; + action.textContent = t('giftChallenge.receive', 'Receive'); + } else if (task.completed) { + // PENDING/PROCESSING 仍可能在下游发放,FAILED/UNKNOWN 则必须由运营 + // 核账;两类状态都禁用按钮,避免 H5 盲目重发已经到账的奖励。 + action.classList.add('is-claimed'); + action.disabled = true; + action.textContent = + task.deliveryStatus === 'PENDING' || + task.deliveryStatus === 'PROCESSING' + ? t('giftChallenge.processing', 'Processing') + : t('giftChallenge.underReview', 'Under review'); + } else { + action.dataset.action = 'go'; + action.textContent = t('giftChallenge.go', 'Go'); + } + heading.appendChild(action); + card.appendChild(heading); + + var strip = createElement('div', 'reward-strip'); + task.rewards.slice(0, 3).forEach(function (reward) { + strip.appendChild(rewardSlot(reward)); + }); + if (!task.rewards.length) { + strip.appendChild( + rewardSlot({ + name: t('giftChallenge.reward', 'Reward'), + quantity: 1, + }) + ); + } + card.appendChild(strip); + list.appendChild(card); + }); + } + + function rankingEntries(payload) { + return asArray( + firstDefined( + payload && payload.entries, + payload && payload.rankings, + payload && payload.list, + payload && payload.records, + [] + ) + ); + } + + function normalizeRankEntry(entry, index) { + entry = entry || {}; + var privacyHidden = + entry.hidden === true || + entry.anonymous === true || + text(entry.privacyMode).toUpperCase() === 'INVISIBLE'; + return { + rank: Math.max( + 1, + toNumber( + firstDefined(entry.rank, entry.ranking, index + 1), + index + 1 + ) + ), + userId: text(firstDefined(entry.userId, entry.uid, entry.id)), + displayId: text( + firstDefined( + entry.displayId, + entry.userDisplayId, + entry.shortId, + entry.account, + entry.uid + ) + ), + nickname: privacyHidden + ? t('giftChallenge.anonymous', 'Anonymous') + : text( + firstDefined( + entry.nickname, + entry.userName, + entry.name, + t('giftChallenge.anonymous', 'Anonymous') + ) + ), + avatarUrl: privacyHidden + ? '' + : safeImageURL( + firstDefined( + entry.avatarUrl, + entry.avatar, + entry.headImage + ) + ), + score: Math.max( + 0, + toNumber( + firstDefined(entry.score, entry.points, entry.goldAmount), + 0 + ) + ), + isSelf: entry.isSelf === true || entry.me === true, + }; + } + + function avatar(entry, className) { + var wrap = createElement('span', className || 'rank-avatar-wrap'); + if (entry.avatarUrl) { + var image = document.createElement('img'); + image.className = + className === 'podium-avatar-wrap' + ? 'podium-avatar' + : 'rank-avatar'; + image.src = entry.avatarUrl; + image.alt = entry.nickname; + image.loading = 'lazy'; + image.referrerPolicy = 'no-referrer'; + wrap.appendChild(image); + } else { + wrap.appendChild( + createElement( + 'span', + 'avatar-fallback', + (entry.nickname || '?').slice(0, 1).toUpperCase() + ) + ); + } + return wrap; + } + + function scoreNode(score, className) { + var wrapper = createElement('span', className || 'rank-score'); + var coin = document.createElement('img'); + coin.src = './aslan/assets/layers/coin.png'; + coin.alt = ''; + wrapper.appendChild(coin); + wrapper.appendChild(createElement('span', '', formatNumber(score))); + return wrapper; + } + + function rankRow(entry) { + var item = createElement('li', 'rank-entry'); + item.appendChild(createElement('span', 'rank-number', entry.rank)); + item.appendChild(avatar(entry, 'rank-avatar-wrap')); + var user = createElement('span', 'rank-user'); + user.appendChild(createElement('strong', '', entry.nickname)); + user.appendChild( + createElement( + 'small', + '', + t('giftChallenge.idPrefix', 'ID:') + + ' ' + + (entry.displayId || '--') + ) + ); + item.appendChild(user); + item.appendChild(scoreNode(entry.score, 'rank-score')); + return item; + } + + function podiumEntry(entry) { + var item = createElement('li', 'podium-entry'); + var avatarWrap = avatar(entry, 'podium-avatar-wrap'); + avatarWrap.dataset.rankLabel = 'TOP' + entry.rank; + item.appendChild(avatarWrap); + item.appendChild(createElement('span', 'podium-name', entry.nickname)); + item.appendChild(scoreNode(entry.score, 'podium-score')); + return item; + } + + function renderRanking(payload) { + var entries = rankingEntries(payload).map(normalizeRankEntry); + entries.sort(function (left, right) { + return left.rank - right.rank; + }); + var podium = byId('podium'); + var list = byId('rankList'); + var empty = byId('rankEmpty'); + podium.replaceChildren(); + list.replaceChildren(); + entries.slice(0, 3).forEach(function (entry) { + podium.appendChild(podiumEntry(entry)); + }); + entries.slice(3).forEach(function (entry) { + list.appendChild(rankRow(entry)); + }); + empty.hidden = entries.length > 0; + + var self = firstDefined( + payload && payload.my, + payload && payload.myRanking, + payload && payload.myRank, + payload && payload.me + ); + var selfRank = byId('selfRank'); + selfRank.replaceChildren(); + if (self && typeof self === 'object') { + var normalizedSelf = normalizeRankEntry(self, entries.length); + normalizedSelf.isSelf = true; + var row = rankRow(normalizedSelf); + while (row.firstChild) selfRank.appendChild(row.firstChild); + selfRank.hidden = false; + } else { + selfRank.hidden = true; + } + } + + function rankLabel(tier) { + if (tier.startRank === tier.endRank) { + return t('giftChallenge.topSingle', 'Top {rank}', { + rank: tier.startRank, + }).replace('{rank}', tier.startRank); + } + return t('giftChallenge.topRange', 'Top {start}-{end}', { + start: tier.startRank, + end: tier.endRank, + }) + .replace('{start}', tier.startRank) + .replace('{end}', tier.endRank); + } + + function renderRewards() { + var list = byId('rewardTierList'); + var empty = byId('rewardEmpty'); + if (!list || !state.detail) return; + list.replaceChildren(); + var tiers = state.detail.rankRewards.OVERALL.length + ? state.detail.rankRewards.OVERALL + : state.detail.rankRewards.DAILY; + empty.hidden = tiers.length > 0; + tiers.forEach(function (tier) { + var card = createElement('article', 'reward-tier'); + card.appendChild(createElement('h3', '', rankLabel(tier))); + var strip = createElement('div', 'reward-strip'); + tier.rewards.slice(0, 3).forEach(function (reward) { + strip.appendChild(rewardSlot(reward)); + }); + card.appendChild(strip); + list.appendChild(card); + }); + } + + function setRankingState(mode) { + byId('rankingLoading').hidden = mode !== 'loading'; + byId('rankingError').hidden = mode !== 'error'; + byId('rankingContent').hidden = mode !== 'ready'; + if (mode !== 'ready') byId('selfRank').hidden = true; + } + + function loadRanking(force) { + var period = state.currentPeriod; + if (state.rankingLoading[period]) return state.rankingLoading[period]; + if (!force && state.rankings[period]) { + renderRanking(state.rankings[period]); + setRankingState('ready'); + return Promise.resolve(state.rankings[period]); + } + setRankingState('loading'); + var request = Promise.resolve() + .then(function () { + return api().ranking( + state.activityId, + period, + period === 'DAILY' ? state.detail.statDate : '', + RANKING_LIMIT + ); + }) + .then(function (payload) { + state.rankings[period] = payload || {}; + if (state.currentPeriod === period) { + renderRanking(payload || {}); + setRankingState('ready'); + } + return payload; + }) + .catch(function (error) { + if (state.currentPeriod === period) setRankingState('error'); + throw error; + }) + .finally(function () { + delete state.rankingLoading[period]; + }); + state.rankingLoading[period] = request; + return request; + } + + function updatePrimaryTabs() { + all('[data-view]', byId('primaryTabs')).forEach(function (button) { + var active = button.dataset.view === state.currentView; + button.classList.toggle('is-active', active); + button.setAttribute('aria-selected', active ? 'true' : 'false'); + }); + all('[data-view-panel]').forEach(function (panel) { + var active = panel.dataset.viewPanel === state.currentView; + panel.classList.toggle('is-active', active); + panel.hidden = !active; + }); + } + + function switchView(view) { + if (['tasks', 'ranking', 'rewards'].indexOf(view) < 0) return; + state.currentView = view; + updatePrimaryTabs(); + if (view === 'ranking') { + loadRanking(false).catch(function () {}); + } + if (view === 'rewards') renderRewards(); + } + + function switchPeriod(period) { + period = period === 'OVERALL' ? 'OVERALL' : 'DAILY'; + state.currentPeriod = period; + all('[data-period]', byId('rankPeriodTabs')).forEach(function (button) { + var active = button.dataset.period === period; + button.classList.toggle('is-active', active); + button.setAttribute('aria-selected', active ? 'true' : 'false'); + }); + loadRanking(false).catch(function () {}); + } + + function updateCountdown() { + if (!state.detail) return; + var now = Date.now() + state.serverOffsetMs; + var target = + state.detail.startTime > now + ? state.detail.startTime + : state.detail.endTime; + var remaining = Math.max(0, target - now); + var seconds = Math.floor(remaining / 1000); + var values = { + days: Math.floor(seconds / 86400), + hours: Math.floor((seconds % 86400) / 3600), + minutes: Math.floor((seconds % 3600) / 60), + seconds: seconds % 60, + }; + Object.keys(values).forEach(function (key) { + var element = document.querySelector( + '[data-countdown="' + key + '"]' + ); + if (!element) return; + var value = values[key]; + // 天数单位由同格的 data-i18n small 节点负责,strong 只写数值,避免 + // 英文 D 重复且污染阿语、土语文案。 + element.textContent = + key === 'days' ? String(value) : String(value).padStart(2, '0'); + }); + } + + function startCountdown() { + window.clearInterval(state.countdownTimer); + updateCountdown(); + state.countdownTimer = window.setInterval(updateCountdown, 1000); + } + + function renderResultRewards(rewards) { + var target = byId('resultRewards'); + target.replaceChildren(); + asArray(rewards) + .slice(0, 3) + .forEach(function (reward) { + target.appendChild(rewardSlot(reward)); + }); + } + + function openModal(id) { + var modal = byId(id); + if (!modal) return; + modal.hidden = false; + document.body.style.overflow = 'hidden'; + var focusTarget = modal.querySelector('button'); + if (focusTarget) focusTarget.focus(); + } + + function closeModal(id) { + var modal = byId(id); + if (!modal) return; + modal.hidden = true; + if (!document.querySelector('.modal:not([hidden])')) { + document.body.style.overflow = ''; + } + } + + function refreshDetail() { + return Promise.resolve(api().current()).then(function (payload) { + var refreshed = normalizeDetail(payload); + // 排行请求与领奖幂等键都以活动 ID 为边界。刷新若意外返回另一活动, + // 直接替换完整状态并清空缓存,避免把旧活动的名次显示在新周期。 + if (state.activityId && refreshed.activityId !== state.activityId) { + state.rankings = {}; + } + state.detail = refreshed; + state.activityId = refreshed.activityId; + state.serverOffsetMs = refreshed.serverTime - Date.now(); + renderTasks(); + renderRewards(); + startCountdown(); + return refreshed; + }); + } + + function applyUserState(result) { + if (!state.detail || !result || typeof result !== 'object') return []; + var tasks = firstDefined(result.tasks, result.taskList); + if (Array.isArray(tasks)) { + state.detail.tasks = normalizeTasks(tasks); + } + if (result.statDate) state.detail.statDate = text(result.statDate); + renderTasks(); + return state.detail.tasks; + } + + function claimTask(taskCode) { + if (!state.detail || state.claiming[taskCode]) return; + var task = state.detail.tasks.find(function (item) { + return item.code === taskCode; + }); + if (!task || !task.claimable || task.claimed) return; + var requestId = + state.claimRequestIds[taskCode] || + makeRequestId('claim:' + taskCode); + // 同一次点击遇到超时后必须复用 requestId;只有服务端确认成功才删除, + // 从而把“用户意图重试”和“再次领取”区分开。 + state.claimRequestIds[taskCode] = requestId; + state.claiming[taskCode] = true; + renderTasks(); + Promise.resolve(api().claimTask(state.activityId, taskCode, requestId)) + .then(function (result) { + delete state.claimRequestIds[taskCode]; + // claim 返回完整 UserState;直接应用其中的用户任务快照,不能再用 + // /detail 的静态任务配置覆盖刚刚更新的进度和发奖状态。 + var updatedTasks = applyUserState(result); + var updatedTask = updatedTasks.find(function (item) { + return item.code === taskCode; + }); + if (updatedTask && updatedTask.claimed) { + renderResultRewards(updatedTask.rewards || task.rewards); + openModal('resultModal'); + } else { + showToast( + t( + 'giftChallenge.rewardPending', + 'Reward delivery is being verified.' + ) + ); + } + }) + .catch(function () { + showToast( + t( + 'giftChallenge.claimFailed', + 'Unable to claim the reward. Please try again.' + ) + ); + }) + .finally(function () { + delete state.claiming[taskCode]; + renderTasks(); + }); + } + + function goToTask() { + showToast( + t('giftChallenge.completeInApp', 'Complete this task in the app.') + ); + window.setTimeout(function () { + if (window.AslanCommon && window.AslanCommon.back) { + window.AslanCommon.back(); + } + }, 350); + } + + function recordDailyEntry() { + if (!state.activityId) return Promise.resolve(); + return Promise.resolve(api().enter(state.activityId)) + .then(function (result) { + var tasks = firstDefined( + result && result.tasks, + result && result.taskList + ); + if (Array.isArray(tasks)) { + applyUserState(result); + } else { + // enter 可能只返回成功标记;后台仍是任务状态唯一真相,轻量 + // 再读 detail 以免页面继续显示进入任务未完成。 + return refreshDetail(); + } + }) + .catch(function () { + // 进入上报失败不应让排行榜和奖励整页不可用;下次打开由服务端 + // 活动日任务唯一键继续幂等补记。 + }); + } + + function setPageState(mode) { + byId('pageLoading').hidden = mode !== 'loading'; + byId('pageError').hidden = mode !== 'error'; + byId('tasksContent').hidden = mode !== 'ready'; + } + + function loadPage() { + setPageState('loading'); + return refreshDetail() + .then(function () { + if (!state.activityId) throw new Error('activity_not_found'); + setPageState('ready'); + return recordDailyEntry(); + }) + .catch(function () { + setPageState('error'); + }); + } + + function bindEvents() { + byId('primaryTabs').addEventListener('click', function (event) { + var button = event.target.closest('[data-view]'); + if (button) switchView(button.dataset.view); + }); + byId('rankPeriodTabs').addEventListener('click', function (event) { + var button = event.target.closest('[data-period]'); + if (button) switchPeriod(button.dataset.period); + }); + byId('taskList').addEventListener('click', function (event) { + var button = event.target.closest('[data-action]'); + if (!button) return; + if (button.dataset.action === 'claim') { + claimTask(button.dataset.taskCode); + } else if (button.dataset.action === 'go') { + goToTask(); + } + }); + byId('ruleButton').addEventListener('click', function () { + openModal('ruleModal'); + }); + byId('rewardButton').addEventListener('click', function () { + switchView('rewards'); + }); + byId('rewardBack').addEventListener('click', function () { + switchView('ranking'); + }); + byId('retryButton').addEventListener('click', loadPage); + byId('rankingRetry').addEventListener('click', function () { + loadRanking(true).catch(function () {}); + }); + document.addEventListener('click', function (event) { + var closer = event.target.closest('[data-close-modal]'); + if (closer) closeModal(closer.dataset.closeModal); + }); + document.addEventListener('keydown', function (event) { + if (event.key !== 'Escape') return; + all('.modal:not([hidden])').forEach(function (modal) { + closeModal(modal.id); + }); + }); + window.addEventListener('hyapp:i18n-ready', function () { + renderTasks(); + renderRewards(); + Object.keys(state.rankings).forEach(function (period) { + if (period === state.currentPeriod) { + renderRanking(state.rankings[period]); + } + }); + }); + } + + function init() { + bindEvents(); + updatePrimaryTabs(); + loadPage(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init, { once: true }); + } else { + init(); + } +})(); diff --git a/activity/gift-challenge/aslan/assets/layers/action-button.png b/activity/gift-challenge/aslan/assets/layers/action-button.png new file mode 100644 index 0000000..c3be79e Binary files /dev/null and b/activity/gift-challenge/aslan/assets/layers/action-button.png differ diff --git a/activity/gift-challenge/aslan/assets/layers/back-icon.svg b/activity/gift-challenge/aslan/assets/layers/back-icon.svg new file mode 100644 index 0000000..047c209 --- /dev/null +++ b/activity/gift-challenge/aslan/assets/layers/back-icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/activity/gift-challenge/aslan/assets/layers/coin.png b/activity/gift-challenge/aslan/assets/layers/coin.png new file mode 100644 index 0000000..b5393e2 Binary files /dev/null and b/activity/gift-challenge/aslan/assets/layers/coin.png differ diff --git a/activity/gift-challenge/aslan/assets/layers/countdown-cell.png b/activity/gift-challenge/aslan/assets/layers/countdown-cell.png new file mode 100644 index 0000000..6e76107 Binary files /dev/null and b/activity/gift-challenge/aslan/assets/layers/countdown-cell.png differ diff --git a/activity/gift-challenge/aslan/assets/layers/hero-art.png b/activity/gift-challenge/aslan/assets/layers/hero-art.png new file mode 100644 index 0000000..810021a Binary files /dev/null and b/activity/gift-challenge/aslan/assets/layers/hero-art.png differ diff --git a/activity/gift-challenge/aslan/assets/layers/manifest.json b/activity/gift-challenge/aslan/assets/layers/manifest.json new file mode 100644 index 0000000..4878761 --- /dev/null +++ b/activity/gift-challenge/aslan/assets/layers/manifest.json @@ -0,0 +1,377 @@ +{ + "schemaVersion": 1, + "figma": { + "fileKey": "OTOg5cwbhVNWxkImGmzTJl", + "canvasNode": "646:1997", + "referenceFrames": [ + "918:6917", + "918:7827", + "918:8941", + "918:9851", + "918:10385" + ] + }, + "policy": { + "description": "Runtime assets are traceable decorative layers only. All copy, controls, user data, ranking data, task state and reward media remain DOM/API content.", + "forbiddenSourceNodes": [ + "646:1997", + "918:6917", + "918:7827", + "918:8941", + "918:9851", + "918:10385" + ], + "forbiddenRuntimeTerms": [ + "whole-page", + "full-page", + "page-screenshot", + "root-frame" + ], + "maxDecorativeLayerPixels": 1600000 + }, + "assets": [ + { + "filename": "action-button.png", + "sourceNode": "918:8734", + "use": "Task action-button chrome; the label, state and click target remain a DOM button.", + "kind": "single-decorative-frame", + "scope": "runtime", + "renderedDesignSize": { "width": 72, "height": 26 }, + "file": { + "format": "png", + "pixelWidth": 288, + "pixelHeight": 50, + "bytes": 27630 + }, + "sha256": "63372b8ba1ddc800de3467c86baa29de055ba46012d097830770dfac5eae5850", + "derived": false, + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false + }, + { + "filename": "back-icon.svg", + "sourceNode": "918:10383", + "use": "Back-arrow decoration inside the DOM navigation button.", + "kind": "single-decorative-icon", + "scope": "runtime", + "renderedDesignSize": { "width": 24, "height": 24 }, + "file": { + "format": "svg", + "pixelWidth": 24, + "pixelHeight": 24, + "bytes": 1277 + }, + "exportViewBox": { + "minX": 0, + "minY": 0, + "width": 24, + "height": 24 + }, + "sha256": "4bf8ae2499b884a4b7e069d4ddad2dd06d56230313b596c53a06c6f75cc535bc", + "derived": false, + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false + }, + { + "filename": "coin.png", + "sourceNode": "918:9739", + "use": "Gold-score icon; leaderboard values remain live API text.", + "kind": "single-decorative-icon", + "scope": "runtime", + "renderedDesignSize": { "width": 32, "height": 32 }, + "file": { + "format": "png", + "pixelWidth": 64, + "pixelHeight": 64, + "bytes": 6506 + }, + "sha256": "7f7fa561003ed8baf1e7a747637bbe6591bdb1543d7cb39ebe23cd6b322ce8da", + "derived": false, + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false + }, + { + "filename": "countdown-cell.png", + "sourceNode": "918:7887", + "use": "One countdown-number cell; days, hours, minutes and seconds remain live DOM text.", + "kind": "single-decorative-frame", + "scope": "runtime", + "renderedDesignSize": { "width": 72, "height": 77 }, + "file": { + "format": "png", + "pixelWidth": 144, + "pixelHeight": 154, + "bytes": 42992 + }, + "sha256": "3a11b703471aff658bf499496f59e45e186c390e492545e8f191b7abe840d148", + "derived": false, + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false + }, + { + "filename": "hero-art.png", + "sourceNode": "918:7884", + "use": "Trophy, lions and blank title plaque. The original embedded Gift Challenge title was removed so the localized title remains DOM text.", + "kind": "derived-decorative-hero", + "scope": "runtime", + "renderedDesignSize": { "width": 375, "height": 375 }, + "file": { + "format": "png", + "pixelWidth": 1000, + "pixelHeight": 1000, + "bytes": 2039585 + }, + "sha256": "36babcc651785d107f310010f255c4c8513976ee9d93e9cbe66e3fbfcd9f582a", + "derived": true, + "derivation": "Precise object edit removed only the embedded title from Figma node 918:7884 and reconstructed the blank plaque.", + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false + }, + { + "filename": "rank-period-sprite.png", + "sourceNode": "918:9020", + "use": "Selected and idle ranking-period chrome in one CSS sprite; labels and tab controls remain DOM elements.", + "kind": "decorative-state-sprite", + "scope": "runtime", + "renderedDesignSize": { "width": 169, "height": 60 }, + "file": { + "format": "png", + "pixelWidth": 676, + "pixelHeight": 116, + "bytes": 131835 + }, + "sha256": "48094db756f6909bed483acc4bcf09d57bdbd8f7536ddc309e276f8c785b2bce", + "derived": false, + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false + }, + { + "filename": "rank-row.png", + "sourceNode": "918:9728", + "use": "Leaderboard-row border; rank, avatar, user identity and score remain live DOM/API content.", + "kind": "single-decorative-frame", + "scope": "runtime", + "renderedDesignSize": { "width": 351, "height": 88 }, + "file": { + "format": "png", + "pixelWidth": 702, + "pixelHeight": 176, + "bytes": 198958 + }, + "sha256": "665dcdacad7bddeea9da79456088123cc50e1376be9fb329989f1265d981d174", + "derived": false, + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false + }, + { + "filename": "ranking-palace.jpg", + "sourceNode": "918:9029", + "use": "Palace, red-carpet and floor background behind the live leaderboard. Source watermarks were removed without adding content.", + "kind": "derived-decorative-background", + "scope": "runtime", + "renderedDesignSize": { "width": 359, "height": 637 }, + "file": { + "format": "jpeg", + "pixelWidth": 718, + "pixelHeight": 1276, + "bytes": 317466 + }, + "sha256": "5723b0f00739092da4bb58f29a7c491ce7547f18c08caeb839b50e40175221be", + "derived": true, + "derivation": "Precise object edit removed only the source watermarks from Figma node 918:9029 and reconstructed the palace background beneath them.", + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false + }, + { + "filename": "ranking-title-frame.png", + "sourceNode": "918:9031", + "use": "Leaderboard title and podium surround; heading, avatars, names and scores remain live DOM/API content.", + "kind": "single-decorative-frame", + "scope": "runtime", + "renderedDesignSize": { "width": 375, "height": 326 }, + "file": { + "format": "png", + "pixelWidth": 750, + "pixelHeight": 652, + "bytes": 265575 + }, + "sha256": "c26a575e22569903ec0ff0d7c74aa31e30cf3a82446b0c263e63deed39666679", + "derived": false, + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false + }, + { + "filename": "reward-count-frame.png", + "sourceNode": "918:8710", + "use": "Task reward-quantity frame; quantity remains live DOM/API text.", + "kind": "single-decorative-frame", + "scope": "runtime", + "renderedDesignSize": { "width": 79, "height": 30 }, + "file": { + "format": "png", + "pixelWidth": 158, + "pixelHeight": 60, + "bytes": 16175 + }, + "sha256": "89a2cbbb8eaea727123c417caf5fa3bff3c77c3cbfa8b57333b8495706110c26", + "derived": false, + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false + }, + { + "filename": "section-tab-active.png", + "sourceNode": "918:19524", + "use": "Selected primary-tab chrome; label and click target remain DOM content.", + "kind": "single-decorative-frame", + "scope": "runtime", + "renderedDesignSize": { "width": 113, "height": 42 }, + "file": { + "format": "png", + "pixelWidth": 452, + "pixelHeight": 168, + "bytes": 123940 + }, + "sha256": "f6df12b7c290706f83e009f5b4eeca8a51904d9f2d8992f8dbfdb931a3061b71", + "derived": false, + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false + }, + { + "filename": "section-tab-idle.png", + "sourceNode": "918:19527", + "use": "Idle primary-tab chrome; label and click target remain DOM content.", + "kind": "single-decorative-frame", + "scope": "runtime", + "renderedDesignSize": { "width": 113, "height": 42 }, + "file": { + "format": "png", + "pixelWidth": 452, + "pixelHeight": 168, + "bytes": 70971 + }, + "sha256": "0cb9954872b49c785df55fd59041f614731f362e9c7a02b4459bf1eea6ae9c85", + "derived": false, + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false + }, + { + "filename": "side-info.svg", + "sourceNode": "918:8934", + "use": "Rules icon inside the live DOM action button.", + "kind": "single-decorative-icon", + "scope": "runtime", + "renderedDesignSize": { "width": 24, "height": 24 }, + "file": { + "format": "svg", + "pixelWidth": 24, + "pixelHeight": 24, + "bytes": 1896 + }, + "exportViewBox": { + "minX": 0, + "minY": 0, + "width": 24, + "height": 24 + }, + "sha256": "2100697fb9232b99c3ff687e0b8b8231869f8cbe0516ad848939823b0fcfd27c", + "derived": false, + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false + }, + { + "filename": "side-rewards.svg", + "sourceNode": "918:8939", + "use": "Ranking-rewards icon inside the live DOM action button.", + "kind": "single-decorative-icon", + "scope": "runtime", + "renderedDesignSize": { "width": 24, "height": 24 }, + "file": { + "format": "svg", + "pixelWidth": 24, + "pixelHeight": 24, + "bytes": 6265 + }, + "exportViewBox": { + "minX": 0, + "minY": 0, + "width": 24, + "height": 24 + }, + "sha256": "734c29bf9b58ff7d6d400191e10b77f30b6578c7f3519ac47dbca08f84343dd8", + "derived": false, + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false + }, + { + "filename": "stage-background.jpg", + "sourceNode": "918:7830", + "use": "Pure red-and-gold event-stage background beneath all live sections.", + "kind": "single-decorative-background", + "scope": "runtime", + "renderedDesignSize": { "width": 375, "height": 667 }, + "file": { + "format": "jpeg", + "pixelWidth": 750, + "pixelHeight": 1333, + "bytes": 223745 + }, + "sha256": "c70f7d4b377894e84781dc4dff7520c7dd21aa6acf28c53f506d05029bcaff29", + "derived": false, + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false + }, + { + "filename": "task-panel.png", + "sourceNode": "918:7908", + "use": "Pure task-panel background behind three separately rendered DOM task cards.", + "kind": "single-decorative-background", + "scope": "runtime", + "renderedDesignSize": { "width": 375, "height": 996 }, + "file": { + "format": "png", + "pixelWidth": 375, + "pixelHeight": 996, + "bytes": 191882 + }, + "sha256": "4d6331d2450be1e917c143c84e429aa51e75c7dd8ec4b3cfe1421d22c9413b1c", + "derived": false, + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false + }, + { + "filename": "task-reward-frame.png", + "sourceNode": "918:8707", + "use": "Task reward-item surround; icon, reward name and quantity remain live API content.", + "kind": "single-decorative-frame", + "scope": "runtime", + "renderedDesignSize": { "width": 79, "height": 80 }, + "file": { + "format": "png", + "pixelWidth": 158, + "pixelHeight": 160, + "bytes": 29047 + }, + "sha256": "7e7c828e2eef258b6762fa5e0876a8a35731213de976909e29dc729ad856c232", + "derived": false, + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false + } + ] +} diff --git a/activity/gift-challenge/aslan/assets/layers/rank-period-sprite.png b/activity/gift-challenge/aslan/assets/layers/rank-period-sprite.png new file mode 100644 index 0000000..d40ef6b Binary files /dev/null and b/activity/gift-challenge/aslan/assets/layers/rank-period-sprite.png differ diff --git a/activity/gift-challenge/aslan/assets/layers/rank-row.png b/activity/gift-challenge/aslan/assets/layers/rank-row.png new file mode 100644 index 0000000..960b4c0 Binary files /dev/null and b/activity/gift-challenge/aslan/assets/layers/rank-row.png differ diff --git a/activity/gift-challenge/aslan/assets/layers/ranking-palace.jpg b/activity/gift-challenge/aslan/assets/layers/ranking-palace.jpg new file mode 100644 index 0000000..f49385d Binary files /dev/null and b/activity/gift-challenge/aslan/assets/layers/ranking-palace.jpg differ diff --git a/activity/gift-challenge/aslan/assets/layers/ranking-title-frame.png b/activity/gift-challenge/aslan/assets/layers/ranking-title-frame.png new file mode 100644 index 0000000..8dfc170 Binary files /dev/null and b/activity/gift-challenge/aslan/assets/layers/ranking-title-frame.png differ diff --git a/activity/gift-challenge/aslan/assets/layers/reward-count-frame.png b/activity/gift-challenge/aslan/assets/layers/reward-count-frame.png new file mode 100644 index 0000000..d2cffda Binary files /dev/null and b/activity/gift-challenge/aslan/assets/layers/reward-count-frame.png differ diff --git a/activity/gift-challenge/aslan/assets/layers/section-tab-active.png b/activity/gift-challenge/aslan/assets/layers/section-tab-active.png new file mode 100644 index 0000000..06ea8e9 Binary files /dev/null and b/activity/gift-challenge/aslan/assets/layers/section-tab-active.png differ diff --git a/activity/gift-challenge/aslan/assets/layers/section-tab-idle.png b/activity/gift-challenge/aslan/assets/layers/section-tab-idle.png new file mode 100644 index 0000000..05240e4 Binary files /dev/null and b/activity/gift-challenge/aslan/assets/layers/section-tab-idle.png differ diff --git a/activity/gift-challenge/aslan/assets/layers/side-info.svg b/activity/gift-challenge/aslan/assets/layers/side-info.svg new file mode 100644 index 0000000..c6693e4 --- /dev/null +++ b/activity/gift-challenge/aslan/assets/layers/side-info.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/activity/gift-challenge/aslan/assets/layers/side-rewards.svg b/activity/gift-challenge/aslan/assets/layers/side-rewards.svg new file mode 100644 index 0000000..2e17498 --- /dev/null +++ b/activity/gift-challenge/aslan/assets/layers/side-rewards.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/activity/gift-challenge/aslan/assets/layers/stage-background.jpg b/activity/gift-challenge/aslan/assets/layers/stage-background.jpg new file mode 100644 index 0000000..78f8897 Binary files /dev/null and b/activity/gift-challenge/aslan/assets/layers/stage-background.jpg differ diff --git a/activity/gift-challenge/aslan/assets/layers/task-panel.png b/activity/gift-challenge/aslan/assets/layers/task-panel.png new file mode 100644 index 0000000..3c16381 Binary files /dev/null and b/activity/gift-challenge/aslan/assets/layers/task-panel.png differ diff --git a/activity/gift-challenge/aslan/assets/layers/task-reward-frame.png b/activity/gift-challenge/aslan/assets/layers/task-reward-frame.png new file mode 100644 index 0000000..f044ff8 Binary files /dev/null and b/activity/gift-challenge/aslan/assets/layers/task-reward-frame.png differ diff --git a/common/locales/ar.json b/common/locales/ar.json index 879962f..4f514a5 100644 --- a/common/locales/ar.json +++ b/common/locales/ar.json @@ -965,5 +965,116 @@ "cp.rewardMicSeatIcon": "أيقونة مقعد الميكروفون", "cp.rewardMicSeatAnimation": "حركة مقعد الميكروفون", "cp.rewardEmojiPack": "حزمة رموز تعبيرية", - "cp.rewardVipTrialCard": "بطاقة VIP تجريبية" + "cp.rewardVipTrialCard": "بطاقة VIP تجريبية", + "gameKing.rule": "القواعد", + "gameKing.awards": "الجوائز", + "gameKing.award": "الجائزة", + "gameKing.countdown": "العد التنازلي للفعالية", + "gameKing.days": "ي", + "gameKing.sections": "أقسام ملك الألعاب", + "gameKing.tabLottery": "السحب", + "gameKing.tabTycoon": "ملك الثراء", + "gameKing.tabVictorious": "ملك الانتصار", + "gameKing.loading": "جارٍ التحميل...", + "gameKing.loadFailed": "تعذر تحميل هذه الفعالية.", + "gameKing.retry": "إعادة المحاولة", + "gameKing.draw": "اسحب جائزة", + "gameKing.spin": "تدوير", + "gameKing.myChances": "فرصي", + "gameKing.myRewards": "جوائزي", + "gameKing.startDraw": "ابدأ السحب المحظوظ", + "gameKing.dailyRankings": "الترتيب اليومي", + "gameKing.overallRankings": "الترتيب العام", + "gameKing.loadingRankings": "جارٍ تحميل الترتيب...", + "gameKing.rankFailed": "تعذر تحميل الترتيب.", + "gameKing.noRankings": "لا توجد بيانات ترتيب بعد.", + "gameKing.back": "رجوع", + "gameKing.rankingRewards": "جوائز الترتيب", + "gameKing.tycoonOverallRewards": "ملك الثراء · الإجمالي", + "gameKing.noRewards": "لم يتم إعداد الجوائز بعد.", + "gameKing.congratulations": "تهانينا", + "gameKing.rewardIssued": "تم إرسال الجائزة تلقائيًا إلى حسابك.", + "gameKing.rewardProcessingTitle": "الجائزة قيد المعالجة", + "gameKing.rewardProcessing": "يجري إرسال جائزتك.", + "gameKing.rewardVerificationTitle": "يجري التحقق من حالة الجائزة", + "gameKing.rewardVerification": "يجري التحقق من حالة الجائزة. يرجى التواصل مع الدعم.", + "gameKing.rewardDeliveryFailedTitle": "تعذر إرسال الجائزة", + "gameKing.rewardDeliveryFailed": "تعذر إرسال الجائزة. يرجى التواصل مع الدعم.", + "gameKing.confirm": "تأكيد", + "gameKing.receive": "استلام", + "gameKing.noDrawRecords": "لا توجد سجلات سحب بعد.", + "gameKing.close": "إغلاق", + "gameKing.title": "ملك الألعاب", + "gameKing.titleGame": "الألعاب", + "gameKing.titleKing": "الملك", + "gameKing.idPrefix": "المعرف:", + "gameKing.season": "الموسم 2", + "gameKing.subtitle": "العب. اربح. احكم الساحة.", + "gameKing.prizePending": "الجائزة قيد الإعداد", + "gameKing.reward": "جائزة", + "gameKing.anonymous": "مجهول", + "gameKing.ruleEvent": "العب الألعاب المؤهلة خلال الفعالية لتحصل على فرصة للفوز بالجوائز.", + "gameKing.ruleBoards": "تضم الفعالية ترتيبي ملك الثراء وملك الانتصار.", + "gameKing.ruleTycoon": "يعتمد ترتيب ملك الثراء على الذهب المنفق في الألعاب المؤهلة.", + "gameKing.ruleVictorious": "يعتمد ترتيب ملك الانتصار على الذهب الذي تم ربحه في الألعاب المؤهلة.", + "gameKing.ruleSettlement": "تتم تسوية جوائز الترتيب بعد انتهاء الفعالية.", + "gameKing.ruleChance": "احصل على فرصة سحب واحدة مقابل كل {coins} ذهب مستهلك في الألعاب المؤهلة.", + "gameKing.ruleDailyLimit": "يتوفر بحد أقصى {count} سحوبات يوميًا.", + "gameKing.ruleNoDailyLimit": "تبقى الفرص غير المستخدمة متاحة طوال الفعالية.", + "gameKing.activityEnded": "انتهت الفعالية.", + "gameKing.progressNext": "استهلك {coins} ذهب إضافي لتحصل على فرصة أخرى.", + "gameKing.goldScore": "{score} ذهب", + "gameKing.topSingle": "المركز {rank}", + "gameKing.topRange": "المراكز {start}-{end}", + "gameKing.rewardsIssued": "تم إرسال الجوائز", + "gameKing.settling": "جارٍ تسوية الجوائز", + "gameKing.settleAfterEnd": "تتم التسوية بعد انتهاء الفعالية", + "gameKing.noChances": "لا توجد فرص سحب متاحة.", + "gameKing.drawFailed": "فشل السحب. اضغط مجددًا لإعادة المحاولة بأمان.", + "gameKing.recordsFailed": "تعذر تحميل سجلات السحب.", + "giftChallenge.title": "تحدي الهدايا", + "giftChallenge.rewards": "المكافآت", + "giftChallenge.rules": "القواعد", + "giftChallenge.countdown": "العد التنازلي للفعالية", + "giftChallenge.days": "ي", + "giftChallenge.sections": "أقسام تحدي الهدايا", + "giftChallenge.tasks": "المهام", + "giftChallenge.ranking": "الترتيب", + "giftChallenge.loading": "جارٍ التحميل...", + "giftChallenge.loadFailed": "تعذر تحميل الفعالية.", + "giftChallenge.retry": "إعادة المحاولة", + "giftChallenge.noTasks": "لا توجد مهام متاحة.", + "giftChallenge.dailyRankings": "الترتيب اليومي", + "giftChallenge.overallRankings": "الترتيب العام", + "giftChallenge.loadingRankings": "جارٍ تحميل الترتيب...", + "giftChallenge.rankFailed": "تعذر تحميل الترتيب.", + "giftChallenge.noRankings": "لا توجد بيانات ترتيب بعد.", + "giftChallenge.back": "رجوع", + "giftChallenge.rankingRewards": "مكافآت الترتيب", + "giftChallenge.noRewards": "لم يتم إعداد مكافآت للترتيب.", + "giftChallenge.taskRules": "المهام", + "giftChallenge.ruleTasks": "تُعاد تهيئة المهام اليومية عند الساعة 00:00 حسب المنطقة الزمنية للفعالية. استلم مكافآت المهام المكتملة قبل التهيئة التالية.", + "giftChallenge.rankingRules": "لوحة المتصدرين", + "giftChallenge.ruleRanking": "تُحتسب الهدايا الذهبية المرسلة أثناء الفعالية في الترتيب اليومي والعام. لا تُحتسب هدايا الحظ وهدايا الحظ الفائق.", + "giftChallenge.close": "إغلاق", + "giftChallenge.congratulations": "تهانينا", + "giftChallenge.rewardIssued": "تم إرسال المكافأة إلى مخزونك.", + "giftChallenge.confirm": "تأكيد", + "giftChallenge.claiming": "جارٍ الاستلام", + "giftChallenge.claimed": "تم الاستلام", + "giftChallenge.processing": "جارٍ الإرسال", + "giftChallenge.underReview": "قيد المراجعة", + "giftChallenge.receive": "استلام", + "giftChallenge.go": "اذهب", + "giftChallenge.reward": "مكافأة", + "giftChallenge.taskEnter": "ادخل صفحة الفعالية يوميًا.", + "giftChallenge.taskSendGold": "أرسل يوميًا هدايا مؤهلة بقيمة {amount} من الذهب.", + "giftChallenge.untitledTask": "مهمة يومية", + "giftChallenge.anonymous": "مجهول", + "giftChallenge.idPrefix": "المعرّف:", + "giftChallenge.topSingle": "المركز {rank}", + "giftChallenge.topRange": "المراكز {start}-{end}", + "giftChallenge.claimFailed": "تعذر استلام المكافأة. حاول مرة أخرى.", + "giftChallenge.rewardPending": "جارٍ التحقق من إرسال المكافأة.", + "giftChallenge.completeInApp": "أكمل هذه المهمة داخل التطبيق." } diff --git a/common/locales/en.json b/common/locales/en.json index b0dcd4c..0db606f 100644 --- a/common/locales/en.json +++ b/common/locales/en.json @@ -971,5 +971,116 @@ "cp.rewardMicSeatIcon": "Mic seat icon", "cp.rewardMicSeatAnimation": "Mic seat animation", "cp.rewardEmojiPack": "Emoji pack", - "cp.rewardVipTrialCard": "VIP trial card" + "cp.rewardVipTrialCard": "VIP trial card", + "gameKing.rule": "Rule", + "gameKing.awards": "Awards", + "gameKing.award": "Award", + "gameKing.countdown": "Activity countdown", + "gameKing.days": "D", + "gameKing.sections": "Game King sections", + "gameKing.tabLottery": "Lottery", + "gameKing.tabTycoon": "Tycoon King", + "gameKing.tabVictorious": "Victorious King", + "gameKing.loading": "Loading...", + "gameKing.loadFailed": "Unable to load this activity.", + "gameKing.retry": "Retry", + "gameKing.draw": "Draw a prize", + "gameKing.spin": "Spin", + "gameKing.myChances": "My chances", + "gameKing.myRewards": "My rewards", + "gameKing.startDraw": "Start the lucky draw", + "gameKing.dailyRankings": "Daily Rankings", + "gameKing.overallRankings": "Overall Rankings", + "gameKing.loadingRankings": "Loading rankings...", + "gameKing.rankFailed": "Unable to load rankings.", + "gameKing.noRankings": "No ranking data yet.", + "gameKing.back": "Back", + "gameKing.rankingRewards": "Ranking rewards", + "gameKing.tycoonOverallRewards": "Tycoon King · Overall", + "gameKing.noRewards": "No reward configuration yet.", + "gameKing.congratulations": "Congratulations", + "gameKing.rewardIssued": "The reward has been automatically issued to your account.", + "gameKing.rewardProcessingTitle": "Reward is processing", + "gameKing.rewardProcessing": "Your reward is being delivered.", + "gameKing.rewardVerificationTitle": "Reward status is being verified", + "gameKing.rewardVerification": "Reward status is being verified. Please contact support.", + "gameKing.rewardDeliveryFailedTitle": "Reward delivery failed", + "gameKing.rewardDeliveryFailed": "Reward delivery failed. Please contact support.", + "gameKing.confirm": "Confirm", + "gameKing.receive": "Receive", + "gameKing.noDrawRecords": "No draw records yet.", + "gameKing.close": "Close", + "gameKing.title": "Game King", + "gameKing.titleGame": "Game", + "gameKing.titleKing": "King", + "gameKing.idPrefix": "ID:", + "gameKing.season": "Season 2", + "gameKing.subtitle": "Play. Win. Rule the arena.", + "gameKing.prizePending": "Prize pending", + "gameKing.reward": "Reward", + "gameKing.anonymous": "Anonymous", + "gameKing.ruleEvent": "Play eligible games during the event for a chance to win rewards.", + "gameKing.ruleBoards": "The event has Tycoon King and Victorious King leaderboards.", + "gameKing.ruleTycoon": "Tycoon King is ranked by gold spent in eligible games.", + "gameKing.ruleVictorious": "Victorious King is ranked by gold won in eligible games.", + "gameKing.ruleSettlement": "Ranking rewards are settled after the activity ends.", + "gameKing.ruleChance": "Automatically Receive 1 Lucky Draw Chance For Every {coins} Gold Coins Wagered In The Game.", + "gameKing.ruleDailyLimit": "A Maximum Of {count} Draws Are Available Per Day.", + "gameKing.ruleNoDailyLimit": "Unused chances remain available during the activity.", + "gameKing.activityEnded": "The activity has ended.", + "gameKing.progressNext": "Consume {coins} more gold to receive another chance.", + "gameKing.goldScore": "{score} gold", + "gameKing.topSingle": "TOP {rank}", + "gameKing.topRange": "TOP {start}-{end}", + "gameKing.rewardsIssued": "Rewards issued", + "gameKing.settling": "Settling rewards", + "gameKing.settleAfterEnd": "Settled after the activity ends", + "gameKing.noChances": "No draw chances available.", + "gameKing.drawFailed": "Draw failed. Tap again to retry safely.", + "gameKing.recordsFailed": "Unable to load draw records.", + "giftChallenge.title": "Gift Challenge", + "giftChallenge.rewards": "Rewards", + "giftChallenge.rules": "Rules", + "giftChallenge.countdown": "Activity countdown", + "giftChallenge.days": "D", + "giftChallenge.sections": "Gift Challenge sections", + "giftChallenge.tasks": "Tasks", + "giftChallenge.ranking": "Ranking", + "giftChallenge.loading": "Loading...", + "giftChallenge.loadFailed": "Unable to load the activity.", + "giftChallenge.retry": "Retry", + "giftChallenge.noTasks": "No tasks are available.", + "giftChallenge.dailyRankings": "Daily Rankings", + "giftChallenge.overallRankings": "Overall Rankings", + "giftChallenge.loadingRankings": "Loading rankings...", + "giftChallenge.rankFailed": "Unable to load rankings.", + "giftChallenge.noRankings": "No ranking data yet.", + "giftChallenge.back": "Back", + "giftChallenge.rankingRewards": "Ranking Rewards", + "giftChallenge.noRewards": "No ranking rewards are configured.", + "giftChallenge.taskRules": "Tasks", + "giftChallenge.ruleTasks": "Daily progress resets at 00:00 in the activity time zone. Claim completed task rewards before the next reset.", + "giftChallenge.rankingRules": "Leaderboard", + "giftChallenge.ruleRanking": "Gold gifts sent during the activity count toward the daily and overall rankings. Lucky Gifts and Super Lucky Gifts do not count.", + "giftChallenge.close": "Close", + "giftChallenge.congratulations": "Congratulations", + "giftChallenge.rewardIssued": "The reward has been sent to your inventory.", + "giftChallenge.confirm": "Confirm", + "giftChallenge.claiming": "Claiming", + "giftChallenge.claimed": "Claimed", + "giftChallenge.processing": "Processing", + "giftChallenge.underReview": "Under review", + "giftChallenge.receive": "Receive", + "giftChallenge.go": "Go", + "giftChallenge.reward": "Reward", + "giftChallenge.taskEnter": "Visit the event page daily.", + "giftChallenge.taskSendGold": "Send {amount} Gold worth of eligible gifts daily.", + "giftChallenge.untitledTask": "Daily task", + "giftChallenge.anonymous": "Anonymous", + "giftChallenge.idPrefix": "ID:", + "giftChallenge.topSingle": "Top {rank}", + "giftChallenge.topRange": "Top {start}-{end}", + "giftChallenge.claimFailed": "Unable to claim the reward. Please try again.", + "giftChallenge.rewardPending": "Reward delivery is being verified.", + "giftChallenge.completeInApp": "Complete this task in the app." } diff --git a/common/locales/es.json b/common/locales/es.json index ad86fc3..f81bdd2 100644 --- a/common/locales/es.json +++ b/common/locales/es.json @@ -965,5 +965,116 @@ "cp.rewardMicSeatIcon": "Icono de asiento de micrófono", "cp.rewardMicSeatAnimation": "Animación de asiento de micrófono", "cp.rewardEmojiPack": "Paquete de emojis", - "cp.rewardVipTrialCard": "Tarjeta de prueba VIP" + "cp.rewardVipTrialCard": "Tarjeta de prueba VIP", + "gameKing.rule": "Reglas", + "gameKing.awards": "Premios", + "gameKing.award": "Premio", + "gameKing.countdown": "Cuenta atrás del evento", + "gameKing.days": "D", + "gameKing.sections": "Secciones de Game King", + "gameKing.tabLottery": "Sorteo", + "gameKing.tabTycoon": "Rey Magnate", + "gameKing.tabVictorious": "Rey Victorioso", + "gameKing.loading": "Cargando...", + "gameKing.loadFailed": "No se pudo cargar este evento.", + "gameKing.retry": "Reintentar", + "gameKing.draw": "Sortear un premio", + "gameKing.spin": "Girar", + "gameKing.myChances": "Mis oportunidades", + "gameKing.myRewards": "Mis premios", + "gameKing.startDraw": "Iniciar el sorteo", + "gameKing.dailyRankings": "Clasificación diaria", + "gameKing.overallRankings": "Clasificación general", + "gameKing.loadingRankings": "Cargando clasificación...", + "gameKing.rankFailed": "No se pudo cargar la clasificación.", + "gameKing.noRankings": "Aún no hay datos de clasificación.", + "gameKing.back": "Volver", + "gameKing.rankingRewards": "Premios de clasificación", + "gameKing.tycoonOverallRewards": "Rey Magnate · General", + "gameKing.noRewards": "Aún no hay premios configurados.", + "gameKing.congratulations": "Felicidades", + "gameKing.rewardIssued": "El premio se envió automáticamente a tu cuenta.", + "gameKing.rewardProcessingTitle": "El premio se está procesando", + "gameKing.rewardProcessing": "Tu premio se está enviando.", + "gameKing.rewardVerificationTitle": "Se está verificando el estado del premio", + "gameKing.rewardVerification": "Se está verificando el estado del premio. Contacta con soporte.", + "gameKing.rewardDeliveryFailedTitle": "No se pudo enviar el premio", + "gameKing.rewardDeliveryFailed": "No se pudo enviar el premio. Contacta con soporte.", + "gameKing.confirm": "Confirmar", + "gameKing.receive": "Recibir", + "gameKing.noDrawRecords": "Aún no hay registros de sorteos.", + "gameKing.close": "Cerrar", + "gameKing.title": "Rey de los Juegos", + "gameKing.titleGame": "Juego", + "gameKing.titleKing": "Rey", + "gameKing.idPrefix": "ID:", + "gameKing.season": "Temporada 2", + "gameKing.subtitle": "Juega. Gana. Domina la arena.", + "gameKing.prizePending": "Premio pendiente", + "gameKing.reward": "Premio", + "gameKing.anonymous": "Anónimo", + "gameKing.ruleEvent": "Juega a los juegos válidos durante el evento para ganar premios.", + "gameKing.ruleBoards": "El evento incluye las clasificaciones Rey Magnate y Rey Victorioso.", + "gameKing.ruleTycoon": "Rey Magnate se ordena por el oro gastado en juegos válidos.", + "gameKing.ruleVictorious": "Rey Victorioso se ordena por el oro ganado en juegos válidos.", + "gameKing.ruleSettlement": "Los premios de clasificación se liquidan al finalizar el evento.", + "gameKing.ruleChance": "Recibe 1 oportunidad por cada {coins} de oro consumido en juegos válidos.", + "gameKing.ruleDailyLimit": "Hay un máximo de {count} sorteos al día.", + "gameKing.ruleNoDailyLimit": "Las oportunidades no usadas siguen disponibles durante el evento.", + "gameKing.activityEnded": "El evento ha finalizado.", + "gameKing.progressNext": "Consume {coins} de oro más para recibir otra oportunidad.", + "gameKing.goldScore": "{score} de oro", + "gameKing.topSingle": "TOP {rank}", + "gameKing.topRange": "TOP {start}-{end}", + "gameKing.rewardsIssued": "Premios enviados", + "gameKing.settling": "Liquidando premios", + "gameKing.settleAfterEnd": "Se liquida al finalizar el evento", + "gameKing.noChances": "No hay oportunidades de sorteo disponibles.", + "gameKing.drawFailed": "El sorteo falló. Toca de nuevo para reintentar de forma segura.", + "gameKing.recordsFailed": "No se pudieron cargar los registros de sorteos.", + "giftChallenge.title": "Desafío de Regalos", + "giftChallenge.rewards": "Premios", + "giftChallenge.rules": "Reglas", + "giftChallenge.countdown": "Cuenta atrás del evento", + "giftChallenge.days": "D", + "giftChallenge.sections": "Secciones del Desafío de Regalos", + "giftChallenge.tasks": "Tareas", + "giftChallenge.ranking": "Clasificación", + "giftChallenge.loading": "Cargando...", + "giftChallenge.loadFailed": "No se pudo cargar el evento.", + "giftChallenge.retry": "Reintentar", + "giftChallenge.noTasks": "No hay tareas disponibles.", + "giftChallenge.dailyRankings": "Clasificación Diaria", + "giftChallenge.overallRankings": "Clasificación General", + "giftChallenge.loadingRankings": "Cargando clasificación...", + "giftChallenge.rankFailed": "No se pudo cargar la clasificación.", + "giftChallenge.noRankings": "Aún no hay datos de clasificación.", + "giftChallenge.back": "Volver", + "giftChallenge.rankingRewards": "Premios de Clasificación", + "giftChallenge.noRewards": "No hay premios de clasificación configurados.", + "giftChallenge.taskRules": "Tareas", + "giftChallenge.ruleTasks": "El progreso diario se reinicia a las 00:00 en la zona horaria del evento. Recoge los premios completados antes del siguiente reinicio.", + "giftChallenge.rankingRules": "Clasificación", + "giftChallenge.ruleRanking": "Los regalos de Oro enviados durante el evento cuentan para las clasificaciones diaria y general. Los Regalos de la Suerte y Súper Suerte no cuentan.", + "giftChallenge.close": "Cerrar", + "giftChallenge.congratulations": "Enhorabuena", + "giftChallenge.rewardIssued": "El premio se ha enviado a tu inventario.", + "giftChallenge.confirm": "Confirmar", + "giftChallenge.claiming": "Recogiendo", + "giftChallenge.claimed": "Recogido", + "giftChallenge.processing": "Enviando", + "giftChallenge.underReview": "En revisión", + "giftChallenge.receive": "Recibir", + "giftChallenge.go": "Ir", + "giftChallenge.reward": "Premio", + "giftChallenge.taskEnter": "Visita la página del evento cada día.", + "giftChallenge.taskSendGold": "Envía cada día regalos válidos por valor de {amount} de Oro.", + "giftChallenge.untitledTask": "Tarea diaria", + "giftChallenge.anonymous": "Anónimo", + "giftChallenge.idPrefix": "ID:", + "giftChallenge.topSingle": "Top {rank}", + "giftChallenge.topRange": "Top {start}-{end}", + "giftChallenge.claimFailed": "No se pudo recoger el premio. Inténtalo de nuevo.", + "giftChallenge.rewardPending": "Se está verificando la entrega del premio.", + "giftChallenge.completeInApp": "Completa esta tarea en la aplicación." } diff --git a/common/locales/tr.json b/common/locales/tr.json index 7df8138..637ed00 100644 --- a/common/locales/tr.json +++ b/common/locales/tr.json @@ -932,5 +932,116 @@ "cp.rewardMicSeatIcon": "Mikrofon koltuğu simgesi", "cp.rewardMicSeatAnimation": "Mikrofon koltuğu animasyonu", "cp.rewardEmojiPack": "Emoji paketi", - "cp.rewardVipTrialCard": "VIP deneme kartı" + "cp.rewardVipTrialCard": "VIP deneme kartı", + "gameKing.rule": "Kurallar", + "gameKing.awards": "Ödüller", + "gameKing.award": "Ödül", + "gameKing.countdown": "Etkinlik geri sayımı", + "gameKing.days": "G", + "gameKing.sections": "Game King bölümleri", + "gameKing.tabLottery": "Çekiliş", + "gameKing.tabTycoon": "Zenginlik Kralı", + "gameKing.tabVictorious": "Zafer Kralı", + "gameKing.loading": "Yükleniyor...", + "gameKing.loadFailed": "Bu etkinlik yüklenemedi.", + "gameKing.retry": "Tekrar dene", + "gameKing.draw": "Ödül çek", + "gameKing.spin": "Çevir", + "gameKing.myChances": "Haklarım", + "gameKing.myRewards": "Ödüllerim", + "gameKing.startDraw": "Şanslı çekilişi başlat", + "gameKing.dailyRankings": "Günlük Sıralama", + "gameKing.overallRankings": "Genel Sıralama", + "gameKing.loadingRankings": "Sıralama yükleniyor...", + "gameKing.rankFailed": "Sıralama yüklenemedi.", + "gameKing.noRankings": "Henüz sıralama verisi yok.", + "gameKing.back": "Geri", + "gameKing.rankingRewards": "Sıralama ödülleri", + "gameKing.tycoonOverallRewards": "Zenginlik Kralı · Genel", + "gameKing.noRewards": "Henüz ödül yapılandırılmadı.", + "gameKing.congratulations": "Tebrikler", + "gameKing.rewardIssued": "Ödül otomatik olarak hesabınıza gönderildi.", + "gameKing.rewardProcessingTitle": "Ödül işleniyor", + "gameKing.rewardProcessing": "Ödülünüz gönderiliyor.", + "gameKing.rewardVerificationTitle": "Ödül durumu doğrulanıyor", + "gameKing.rewardVerification": "Ödül durumu doğrulanıyor. Lütfen destekle iletişime geçin.", + "gameKing.rewardDeliveryFailedTitle": "Ödül gönderilemedi", + "gameKing.rewardDeliveryFailed": "Ödül gönderilemedi. Lütfen destekle iletişime geçin.", + "gameKing.confirm": "Onayla", + "gameKing.receive": "Al", + "gameKing.noDrawRecords": "Henüz çekiliş kaydı yok.", + "gameKing.close": "Kapat", + "gameKing.title": "Oyun Kralı", + "gameKing.titleGame": "Oyun", + "gameKing.titleKing": "Kralı", + "gameKing.idPrefix": "ID:", + "gameKing.season": "Sezon 2", + "gameKing.subtitle": "Oyna. Kazan. Arenaya hükmet.", + "gameKing.prizePending": "Ödül bekleniyor", + "gameKing.reward": "Ödül", + "gameKing.anonymous": "Anonim", + "gameKing.ruleEvent": "Ödül kazanma şansı için etkinlik sırasında uygun oyunları oynayın.", + "gameKing.ruleBoards": "Etkinlikte Zenginlik Kralı ve Zafer Kralı sıralamaları bulunur.", + "gameKing.ruleTycoon": "Zenginlik Kralı, uygun oyunlarda harcanan altına göre sıralanır.", + "gameKing.ruleVictorious": "Zafer Kralı, uygun oyunlarda kazanılan altına göre sıralanır.", + "gameKing.ruleSettlement": "Sıralama ödülleri etkinlik bittikten sonra hesaplanır.", + "gameKing.ruleChance": "Uygun oyunlarda harcanan her {coins} altın için 1 çekiliş hakkı kazanın.", + "gameKing.ruleDailyLimit": "Günde en fazla {count} çekiliş yapılabilir.", + "gameKing.ruleNoDailyLimit": "Kullanılmayan haklar etkinlik boyunca kullanılabilir.", + "gameKing.activityEnded": "Etkinlik sona erdi.", + "gameKing.progressNext": "Yeni bir hak için {coins} altın daha harcayın.", + "gameKing.goldScore": "{score} altın", + "gameKing.topSingle": "İLK {rank}", + "gameKing.topRange": "İLK {start}-{end}", + "gameKing.rewardsIssued": "Ödüller gönderildi", + "gameKing.settling": "Ödüller hesaplanıyor", + "gameKing.settleAfterEnd": "Etkinlik sonunda hesaplanır", + "gameKing.noChances": "Kullanılabilir çekiliş hakkınız yok.", + "gameKing.drawFailed": "Çekiliş başarısız. Güvenle yeniden denemek için dokunun.", + "gameKing.recordsFailed": "Çekiliş kayıtları yüklenemedi.", + "giftChallenge.title": "Hediye Mücadelesi", + "giftChallenge.rewards": "Ödüller", + "giftChallenge.rules": "Kurallar", + "giftChallenge.countdown": "Etkinlik geri sayımı", + "giftChallenge.days": "G", + "giftChallenge.sections": "Hediye Mücadelesi bölümleri", + "giftChallenge.tasks": "Görevler", + "giftChallenge.ranking": "Sıralama", + "giftChallenge.loading": "Yükleniyor...", + "giftChallenge.loadFailed": "Etkinlik yüklenemedi.", + "giftChallenge.retry": "Yeniden dene", + "giftChallenge.noTasks": "Kullanılabilir görev yok.", + "giftChallenge.dailyRankings": "Günlük Sıralama", + "giftChallenge.overallRankings": "Genel Sıralama", + "giftChallenge.loadingRankings": "Sıralama yükleniyor...", + "giftChallenge.rankFailed": "Sıralama yüklenemedi.", + "giftChallenge.noRankings": "Henüz sıralama verisi yok.", + "giftChallenge.back": "Geri", + "giftChallenge.rankingRewards": "Sıralama Ödülleri", + "giftChallenge.noRewards": "Sıralama ödülü yapılandırılmadı.", + "giftChallenge.taskRules": "Görevler", + "giftChallenge.ruleTasks": "Günlük ilerleme, etkinlik saat diliminde 00:00'da sıfırlanır. Tamamlanan görev ödüllerini sonraki sıfırlamadan önce alın.", + "giftChallenge.rankingRules": "Liderlik Tablosu", + "giftChallenge.ruleRanking": "Etkinlik sırasında gönderilen Altın hediyeler günlük ve genel sıralamaya sayılır. Şanslı Hediyeler ve Süper Şanslı Hediyeler sayılmaz.", + "giftChallenge.close": "Kapat", + "giftChallenge.congratulations": "Tebrikler", + "giftChallenge.rewardIssued": "Ödül envanterinize gönderildi.", + "giftChallenge.confirm": "Onayla", + "giftChallenge.claiming": "Alınıyor", + "giftChallenge.claimed": "Alındı", + "giftChallenge.processing": "Gönderiliyor", + "giftChallenge.underReview": "İnceleniyor", + "giftChallenge.receive": "Al", + "giftChallenge.go": "Git", + "giftChallenge.reward": "Ödül", + "giftChallenge.taskEnter": "Etkinlik sayfasını her gün ziyaret et.", + "giftChallenge.taskSendGold": "Her gün {amount} Altın değerinde uygun hediye gönder.", + "giftChallenge.untitledTask": "Günlük görev", + "giftChallenge.anonymous": "Anonim", + "giftChallenge.idPrefix": "ID:", + "giftChallenge.topSingle": "İlk {rank}", + "giftChallenge.topRange": "İlk {start}-{end}", + "giftChallenge.claimFailed": "Ödül alınamadı. Lütfen tekrar deneyin.", + "giftChallenge.rewardPending": "Ödül teslimatı doğrulanıyor.", + "giftChallenge.completeInApp": "Bu görevi uygulamada tamamlayın." } diff --git a/common_aslan/aslan-common.js b/common_aslan/aslan-common.js index c9a6179..23d1156 100644 --- a/common_aslan/aslan-common.js +++ b/common_aslan/aslan-common.js @@ -5,6 +5,7 @@ var LOCAL_API_BASE = 'http://127.0.0.1:1100/'; var APP_CODE = 'aslan'; var DEFAULT_ORIGIN = 'ATYOU'; + var LOOPBACK_API_HOSTS = ['127.0.0.1', 'localhost', '[::1]']; var readyPromise = null; var headers = { authorization: '', @@ -46,17 +47,51 @@ return env === 'local' || env === 'test' || env === 'dev' ? env : ''; } + function nonProductionBaseURL(value) { + var config = window.AslanCommonConfig || {}; + var trustedHosts = Array.isArray(config.trustedApiHosts) + ? config.trustedApiHosts.map(function (host) { + return String(host || '') + .trim() + .toLowerCase(); + }) + : []; + var parsed; + try { + parsed = new URL(String(value || '')); + } catch (_) { + throw new Error('aslan_test_api_invalid'); + } + var host = String(parsed.hostname || '').toLowerCase(); + var isLoopback = LOOPBACK_API_HOSTS.indexOf(host) >= 0; + var isTrusted = trustedHosts.indexOf(host) >= 0; + // App authorization is attached to every request. Test/dev may use only a + // build-time configured HTTP(S) origin whose hostname is explicitly + // allowlisted; URL parameters are intentionally never an API override. + if ( + !/^https?:$/.test(parsed.protocol) || + !(isLoopback || (parsed.protocol === 'https:' && isTrusted)) + ) { + throw new Error('aslan_test_api_untrusted'); + } + return normalizeBaseURL(parsed.toString()); + } + function baseURL() { - var override = - query('apiBase') || - query('api_base') || - query('aslan_api_base') || - window.ASLAN_API_BASE_URL || - (window.AslanCommonConfig && window.AslanCommonConfig.apiBase); - if (override) return normalizeBaseURL(override); - return resolveEnv() === 'local' - ? normalizeBaseURL(LOCAL_API_BASE) - : normalizeBaseURL(PROD_API_BASE); + var env = resolveEnv(); + // Production must always use the compiled service origin: accepting an + // apiBase query here would forward the App bearer token to an attacker URL. + if (!env) return normalizeBaseURL(PROD_API_BASE); + if (env === 'local') return normalizeBaseURL(LOCAL_API_BASE); + if (env === 'test' || env === 'dev') { + var testBase = + window.ASLAN_TEST_API_BASE_URL || + (window.AslanCommonConfig && window.AslanCommonConfig.testApi); + // Test/dev 请求携带真实 App 登录态,未注入独立测试地址时必须失败, + // 不能把 `?env=test` 静默回落到生产域名并产生抽奖或领奖写入。 + if (!testBase) throw new Error('aslan_test_api_not_configured'); + return nonProductionBaseURL(testBase); + } } function buildURL(path, queryData) { @@ -519,5 +554,116 @@ }); }, }, + gameKing: { + current: function () { + return request('/activity/game/king/aslan/detail', { + method: 'GET', + }).then(function (detail) { + var activity = + detail && detail.activity + ? detail.activity + : detail || {}; + var activityId = String( + activity.activityId || + activity.id || + detail.activityId || + detail.id || + '' + ); + if (!activityId) return activity; + return request('/activity/game/king/aslan/me', { + method: 'GET', + query: { activityId: activityId }, + }).then(function (me) { + // detail 的 prizes/rankRewards/activityStatus 位于外层,而 + // activity 只携带基础配置;保留两个层级再叠加 me,避免页面 + // 因合并顺序丢掉七个奖项或榜单奖励配置。 + return Object.assign( + {}, + detail || {}, + activity, + me || {}, + { + activityId: activityId, + } + ); + }); + }); + }, + draw: function (activityId, requestId) { + // requestId 由页面为“一次用户意图”稳定生成;网络超时后重试仍携带同一值,避免重复扣减抽奖次数。 + return request('/activity/game/king/aslan/draw', { + method: 'POST', + body: { + activityId: String(activityId || ''), + requestId: String(requestId || ''), + }, + }); + }, + ranking: function (activityId, rankingType, period, limit) { + return request('/activity/game/king/aslan/ranking', { + method: 'GET', + query: { + activityId: String(activityId || ''), + rankingType: String(rankingType || 'TYCOON'), + period: String(period || 'DAILY'), + limit: Number(limit) || 30, + }, + }); + }, + records: function (activityId, limit) { + return request('/activity/game/king/aslan/draw-records', { + method: 'GET', + query: { + activityId: String(activityId || ''), + limit: Number(limit) || 50, + }, + }); + }, + }, + giftChallenge: { + current: function () { + return request('/activity/gift-challenge/aslan/detail', { + method: 'GET', + }); + }, + enter: function (activityId) { + // 进入任务按“活动 + 活动时区自然日 + 用户”由服务端幂等;请求体只传 + // 后端契约声明的 activityId,用户和统计日始终从网关上下文推导。 + return request('/activity/gift-challenge/aslan/enter', { + method: 'POST', + body: { + activityId: String(activityId || ''), + }, + }); + }, + ranking: function (activityId, period, date, limit) { + return request('/activity/gift-challenge/aslan/ranking', { + method: 'GET', + query: { + activityId: String(activityId || ''), + period: String(period || 'DAILY'), + statDate: date ? String(date) : '', + limit: Number(limit) || 30, + }, + }); + }, + claimTask: function (activityId, taskCode, requestId) { + // taskCode 进入 URL 前编码,避免后台配置值意外改变请求路径;用户 ID + // 从 App Bearer 登录态读取,页面从不接受或上送可伪造的 userId。 + return request( + '/activity/gift-challenge/aslan/tasks/' + + encodeURIComponent(String(taskCode || '')) + + '/claim', + { + method: 'POST', + body: { + activityId: String(activityId || ''), + requestId: String(requestId || ''), + }, + } + ); + }, + }, }; })(); diff --git a/package.json b/package.json index 3acc96d..af1e211 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,9 @@ "audit:huwaa-cp-layers": "node scripts/check-activity-cp-huwaa-layer-assets.mjs", "audit:fami-room-reward-layers": "node scripts/check-activity-room-reward-fami-layer-assets.mjs", "audit:recharge-reward-fami-layers": "node scripts/check-activity-recharge-reward-fami-layer-assets.mjs", - "audit:coin-seller-fami-layers": "node scripts/check-coin-seller-fami-layer-assets.mjs" + "audit:coin-seller-fami-layers": "node scripts/check-coin-seller-fami-layer-assets.mjs", + "audit:game-king-aslan-layers": "node scripts/check-activity-game-king-aslan-assets.mjs", + "audit:gift-challenge-aslan-layers": "node scripts/check-activity-gift-challenge-aslan-assets.mjs" }, "devDependencies": { "playwright-core": "^1.61.1", diff --git a/scripts/check-activity-game-king-aslan-assets.mjs b/scripts/check-activity-game-king-aslan-assets.mjs new file mode 100644 index 0000000..a598aed --- /dev/null +++ b/scripts/check-activity-game-king-aslan-assets.mjs @@ -0,0 +1,338 @@ +import { createHash } from 'node:crypto'; +import { readFile, readdir, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDirectory, '..'); +const pageDirectory = path.join(repoRoot, 'activity', 'game-king'); +const aslanDirectory = path.join(pageDirectory, 'aslan'); +const layerDirectory = path.join(aslanDirectory, 'assets', 'layers'); +const manifestPath = path.join(layerDirectory, 'manifest.json'); + +function invariant(condition, message) { + if (!condition) { + throw new Error(`[game-king-aslan-asset-audit] ${message}`); + } +} + +function pngDimensions(bytes, filename) { + invariant( + bytes.length >= 24 && bytes.toString('ascii', 1, 4) === 'PNG', + `${filename}: expected PNG signature` + ); + return { + width: bytes.readUInt32BE(16), + height: bytes.readUInt32BE(20), + }; +} + +function jpegDimensions(bytes, filename) { + invariant( + bytes.length >= 4 && bytes[0] === 0xff && bytes[1] === 0xd8, + `${filename}: expected JPEG signature` + ); + for (let offset = 2; offset + 9 < bytes.length; ) { + if (bytes[offset] !== 0xff) { + offset += 1; + continue; + } + const marker = bytes[offset + 1]; + const length = bytes.readUInt16BE(offset + 2); + if ( + marker >= 0xc0 && + marker <= 0xc3 && + length >= 7 && + offset + length + 2 <= bytes.length + ) { + return { + height: bytes.readUInt16BE(offset + 5), + width: bytes.readUInt16BE(offset + 7), + }; + } + invariant(length >= 2, `${filename}: invalid JPEG segment length`); + offset += length + 2; + } + throw new Error(`[game-king-aslan-asset-audit] ${filename}: no JPEG size`); +} + +function bitmapDimensions(bytes, asset) { + if (asset.file.format === 'png') + return pngDimensions(bytes, asset.filename); + if (asset.file.format === 'jpeg') + return jpegDimensions(bytes, asset.filename); + throw new Error( + `[game-king-aslan-asset-audit] ${asset.filename}: unsupported format` + ); +} + +const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); +invariant(manifest.schemaVersion === 1, 'unsupported manifest schema'); +invariant( + manifest.figma?.fileKey === 'OTOg5cwbhVNWxkImGmzTJl', + 'unexpected Figma file key' +); +const forbiddenNodes = new Set(manifest.policy?.forbiddenSourceNodes || []); +const approvedCompositeNodes = new Set( + manifest.policy?.approvedStandaloneCompositeNodes || [] +); +const assets = manifest.assets || []; +invariant(assets.length > 0, 'manifest must list the exported layers'); + +const byFile = new Map(); +for (const asset of assets) { + const isApprovedStaticComposite = + approvedCompositeNodes.has(asset.sourceNode) && + /^approved-static-(header-composite|podium-frame)$/.test( + asset.kind || '' + ); + invariant( + typeof asset.filename === 'string' && + path.basename(asset.filename) === asset.filename, + 'layer filename must be a basename' + ); + invariant( + !byFile.has(asset.filename), + `${asset.filename}: duplicate asset` + ); + byFile.set(asset.filename, asset); + invariant( + /^\d+:\d+$/.test(asset.sourceNode || ''), + `${asset.filename}: missing source node` + ); + invariant( + !forbiddenNodes.has(asset.sourceNode), + `${asset.filename}: root frame/canvas exports are forbidden` + ); + invariant( + asset.renderedDesignSize?.width > 0 && + asset.renderedDesignSize?.height > 0, + `${asset.filename}: design size is required` + ); + invariant( + /^[a-f0-9]{64}$/.test(asset.sha256 || ''), + `${asset.filename}: SHA-256 is required` + ); + for (const field of [ + 'containsText', + 'containsControls', + 'containsDynamicBusinessMedia', + 'mockOnly', + ]) { + invariant( + typeof asset[field] === 'boolean', + `${asset.filename}: ${field} must be boolean` + ); + } + invariant( + isApprovedStaticComposite || !asset.containsText, + `${asset.filename}: text must be live DOM/i18n content` + ); + invariant( + !asset.containsControls, + `${asset.filename}: controls must be DOM elements` + ); + if (asset.scope === 'runtime') { + invariant( + !asset.mockOnly, + `${asset.filename}: runtime layer cannot be mock-only` + ); + invariant( + !asset.containsDynamicBusinessMedia, + `${asset.filename}: runtime layer cannot contain dynamic business media` + ); + } else { + invariant( + asset.scope === 'fixture' && + asset.mockOnly && + asset.containsDynamicBusinessMedia, + `${asset.filename}: only an explicit mock fixture may contain business media` + ); + } + + const assetPath = path.join(layerDirectory, asset.filename); + const [bytes, info] = await Promise.all([ + readFile(assetPath), + stat(assetPath), + ]); + const dimensions = bitmapDimensions(bytes, asset); + invariant( + info.size === asset.file.bytes, + `${asset.filename}: byte size mismatch` + ); + invariant( + dimensions.width === asset.file.pixelWidth && + dimensions.height === asset.file.pixelHeight, + `${asset.filename}: pixel dimensions mismatch` + ); + if (asset.naturalSize) { + invariant( + dimensions.width === asset.naturalSize.width && + dimensions.height === asset.naturalSize.height, + `${asset.filename}: natural source dimensions mismatch` + ); + } + invariant( + dimensions.width * dimensions.height <= + manifest.policy.maxDecorativeLayerPixels, + `${asset.filename}: bitmap is too large for an atomic layer` + ); + invariant( + createHash('sha256').update(bytes).digest('hex') === asset.sha256, + `${asset.filename}: SHA-256 mismatch` + ); +} + +const diskFiles = new Set( + (await readdir(layerDirectory)).filter( + (filename) => filename !== 'manifest.json' + ) +); +invariant( + diskFiles.size === byFile.size && + [...diskFiles].every((filename) => byFile.has(filename)), + 'every layer file must have exactly one traceable manifest record' +); + +const [html, css, js, common, ...locales] = await Promise.all([ + readFile(path.join(pageDirectory, 'aslan.html'), 'utf8'), + readFile(path.join(aslanDirectory, 'aslan.css'), 'utf8'), + readFile(path.join(aslanDirectory, 'aslan.js'), 'utf8'), + readFile(path.join(repoRoot, 'common_aslan', 'aslan-common.js'), 'utf8'), + ...['en', 'ar', 'tr', 'es'].map((language) => + readFile( + path.join(repoRoot, 'common', 'locales', `${language}.json`), + 'utf8' + ) + ), +]); +const runtimeSource = `${html}\n${css}\n${js}`; + +for (const asset of assets) { + invariant( + runtimeSource.includes(asset.filename), + `${asset.filename}: layer is not referenced` + ); +} +for (const term of manifest.policy.forbiddenRuntimeTerms || []) { + invariant( + !runtimeSource.toLowerCase().includes(String(term).toLowerCase()), + `runtime source includes forbidden aggregate-image term: ${term}` + ); +} +for (const match of runtimeSource.matchAll( + /(?:\.\/assets\/layers\/)([^"')\s]+)/g +)) { + invariant( + byFile.has(match[1]), + `${match[1]}: untraced local bitmap reference` + ); +} +invariant( + html.includes('../../common/theme.css') && + html.includes('../../common/i18n.js'), + 'shared theme and i18n runtime are required' +); +invariant( + html.includes('data-hy-app-code="aslan"'), + 'Aslan app identity is required' +); +invariant( + /function isMockMode\(\)[\s\S]*query\('mock'\)/.test(js) && + /hostname === 'localhost'/.test(js) && + /hostname === '127\.0\.0\.1'/.test(js) && + /env === 'local'/.test(js) && + /env === 'test'/.test(js) && + /function buildMockStore\(\)/.test(js), + 'mock data must be query-gated and restricted to local/test environments' +); +invariant( + js.indexOf('var icon = PRIZE_FALLBACK;') > + js.indexOf('function buildMockStore()'), + 'fixture prize art must be confined to the mock builder' +); +for (const endpoint of [ + '/activity/game/king/aslan/detail', + '/activity/game/king/aslan/me', + '/activity/game/king/aslan/draw', + '/activity/game/king/aslan/ranking', + '/activity/game/king/aslan/draw-records', +]) { + invariant( + common.includes(endpoint), + `missing common_aslan endpoint: ${endpoint}` + ); +} +invariant( + common.includes("rankingType: String(rankingType || 'TYCOON')") && + common.includes("period: String(period || 'DAILY')"), + 'ranking contract must preserve TYCOON/VICTORIOUS and DAILY/OVERALL' +); +for (const dtoField of [ + "'prizeImage'", + "'coinPerDraw'", + "'totalConsumed'", + "'entries'", + "'my'", + "'account'", + "'cover'", + "'sourceUrl'", + "'deliveryStatus'", + "'failureReason'", +]) { + invariant( + js.includes(dtoField), + `missing Game King backend DTO mapping: ${dtoField}` + ); +} +invariant( + /Object\.assign\(\s*\{\},\s*detail \|\| \{\},\s*activity,\s*me \|\| \{\}/.test( + common + ), + 'detail config must be preserved when current-user fields are merged' +); +invariant( + js.includes("status === 'FAILED'") && + js.includes("status === 'PROCESSING'") && + js.includes("status === 'UNKNOWN'") && + js.includes('function deliveryMessage') && + js.includes('source.record || source.drawRecord || source'), + 'draw result and record UI must surface delivery success, processing and failure states' +); +invariant( + common.includes('function nonProductionBaseURL') && + common.includes('trustedApiHosts') && + common.includes('if (!env) return normalizeBaseURL(PROD_API_BASE)') && + !common.includes("query('apiBase')") && + !common.includes("query('api_base')") && + !common.includes("query('aslan_api_base')") && + common.includes("throw new Error('aslan_test_api_not_configured')"), + 'production API must be fixed; test/dev needs an explicit trusted build-time host' +); +invariant( + js.includes("var SETTLEMENT_BOARD_TYPE = 'TYCOON'") && + js.includes('rankingRewards[SETTLEMENT_BOARD_TYPE]'), + 'Award entry must show the single Tycoon overall settlement board' +); + +const baseLocale = JSON.parse(locales[0]); +const gameKingKeys = Object.keys(baseLocale).filter((key) => + key.startsWith('gameKing.') +); +invariant( + gameKingKeys.length >= 40, + 'English game-king i18n keys are incomplete' +); +for (const localeText of locales.slice(1)) { + const locale = JSON.parse(localeText); + for (const key of gameKingKeys) { + invariant( + typeof locale[key] === 'string' && locale[key], + `${key}: locale translation missing` + ); + } +} + +console.log( + `[game-king-aslan-asset-audit] passed: ${assets.length} atomic layers, 4 locales, common_aslan contract` +); diff --git a/scripts/check-activity-gift-challenge-aslan-assets.mjs b/scripts/check-activity-gift-challenge-aslan-assets.mjs new file mode 100644 index 0000000..f5a2318 --- /dev/null +++ b/scripts/check-activity-gift-challenge-aslan-assets.mjs @@ -0,0 +1,397 @@ +import { createHash } from 'node:crypto'; +import { readFile, readdir, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const auditLabel = 'gift-challenge-aslan-asset-audit'; +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDirectory, '..'); +const pageDirectory = path.join(repoRoot, 'activity', 'gift-challenge'); +const aslanDirectory = path.join(pageDirectory, 'aslan'); +const layerDirectory = path.join(aslanDirectory, 'assets', 'layers'); +const manifestPath = path.join(layerDirectory, 'manifest.json'); +const localeCodes = ['en', 'ar', 'tr', 'es']; + +// Keep the permanent red-line list in code as well as the manifest. Otherwise a +// later manifest edit could silently remove a root frame from its own denylist. +const canonicalForbiddenNodes = new Set([ + '646:1997', + '918:6917', + '918:7827', + '918:8941', + '918:9851', + '918:10385', +]); +const forbiddenAssetName = + /(?:whole[-_ ]?page|full[-_ ]?page|page[-_ ]?shot|page[-_ ]?screenshot|root[-_ ]?frame|screenshot)/i; + +function invariant(condition, message) { + if (!condition) throw new Error(`[${auditLabel}] ${message}`); +} + +function pngDimensions(bytes, filename) { + invariant( + bytes.length >= 24 && + bytes[0] === 0x89 && + bytes.toString('ascii', 1, 4) === 'PNG', + `${filename}: expected PNG signature` + ); + return { + width: bytes.readUInt32BE(16), + height: bytes.readUInt32BE(20), + }; +} + +function jpegDimensions(bytes, filename) { + invariant( + bytes.length >= 4 && bytes[0] === 0xff && bytes[1] === 0xd8, + `${filename}: expected JPEG signature` + ); + const sizeMarkers = new Set([ + 0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, + 0xcf, + ]); + for (let offset = 2; offset + 8 < bytes.length; ) { + if (bytes[offset] !== 0xff) { + offset += 1; + continue; + } + while (offset < bytes.length && bytes[offset] === 0xff) offset += 1; + const marker = bytes[offset]; + offset += 1; + if ( + marker === 0xd8 || + marker === 0xd9 || + marker === 0x01 || + (marker >= 0xd0 && marker <= 0xd7) + ) { + continue; + } + invariant(offset + 2 <= bytes.length, `${filename}: truncated JPEG`); + const segmentLength = bytes.readUInt16BE(offset); + invariant(segmentLength >= 2, `${filename}: invalid JPEG segment`); + if (sizeMarkers.has(marker)) { + invariant( + segmentLength >= 7 && offset + segmentLength <= bytes.length, + `${filename}: invalid JPEG size segment` + ); + return { + height: bytes.readUInt16BE(offset + 3), + width: bytes.readUInt16BE(offset + 5), + }; + } + offset += segmentLength; + } + throw new Error(`[${auditLabel}] ${filename}: JPEG dimensions not found`); +} + +function svgDimensions(bytes, asset) { + const source = bytes.toString('utf8'); + invariant(/ + Math.abs(value - expectedValues[index]) <= 0.001 + ), + `${asset.filename}: SVG viewBox does not match the manifest` + ); + invariant( + values[2] > 0 && values[3] > 0, + `${asset.filename}: SVG viewBox dimensions must be positive` + ); + return { width: values[2], height: values[3] }; +} + +function fileDimensions(bytes, asset) { + if (asset.file.format === 'png') { + return pngDimensions(bytes, asset.filename); + } + if (asset.file.format === 'jpeg') { + return jpegDimensions(bytes, asset.filename); + } + if (asset.file.format === 'svg') { + return svgDimensions(bytes, asset); + } + throw new Error( + `[${auditLabel}] ${asset.filename}: unsupported file format` + ); +} + +const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); +invariant(manifest.schemaVersion === 1, 'unsupported manifest schema'); +invariant( + manifest.figma?.fileKey === 'OTOg5cwbhVNWxkImGmzTJl', + 'unexpected Figma file key' +); +const manifestForbiddenNodes = new Set( + manifest.policy?.forbiddenSourceNodes || [] +); +for (const node of canonicalForbiddenNodes) { + invariant( + manifestForbiddenNodes.has(node), + `${node}: permanent forbidden node is missing from manifest policy` + ); +} +const maxLayerPixels = Number(manifest.policy?.maxDecorativeLayerPixels); +invariant( + Number.isFinite(maxLayerPixels) && maxLayerPixels > 0, + 'maxDecorativeLayerPixels must be a positive number' +); + +const assets = manifest.assets || []; +invariant(assets.length > 0, 'manifest must list exported layers'); +const assetsByFile = new Map(); + +for (const asset of assets) { + // basename-only paths prevent a manifest entry from escaping the audited + // directory while still appearing to represent a local layer. + invariant( + typeof asset.filename === 'string' && + path.basename(asset.filename) === asset.filename, + 'every layer filename must be a basename' + ); + invariant( + !assetsByFile.has(asset.filename), + `${asset.filename}: duplicate manifest entry` + ); + assetsByFile.set(asset.filename, asset); + invariant( + !forbiddenAssetName.test(asset.filename), + `${asset.filename}: filename indicates a forbidden whole-page asset` + ); + invariant( + /^\d+:\d+$/.test(asset.sourceNode || ''), + `${asset.filename}: source Figma node is required` + ); + invariant( + !canonicalForbiddenNodes.has(asset.sourceNode) && + !manifestForbiddenNodes.has(asset.sourceNode), + `${asset.filename}: root canvas/frame exports are forbidden` + ); + invariant( + typeof asset.use === 'string' && asset.use.trim(), + `${asset.filename}: usage description is required` + ); + invariant( + asset.scope === 'runtime', + `${asset.filename}: Gift Challenge layers must be runtime decoration` + ); + invariant( + typeof asset.derived === 'boolean', + `${asset.filename}: derived must be explicit` + ); + if (asset.derived) { + invariant( + typeof asset.derivation === 'string' && asset.derivation.trim(), + `${asset.filename}: derived assets must document their derivation` + ); + } + invariant( + Number(asset.renderedDesignSize?.width) > 0 && + Number(asset.renderedDesignSize?.height) > 0, + `${asset.filename}: positive Figma design size is required` + ); + for (const field of [ + 'containsText', + 'containsControls', + 'containsDynamicBusinessMedia', + ]) { + invariant( + typeof asset[field] === 'boolean', + `${asset.filename}: ${field} must be boolean` + ); + invariant( + asset[field] === false, + `${asset.filename}: text, controls and dynamic business media must remain DOM/API content` + ); + } + invariant( + /^[a-f0-9]{64}$/.test(asset.sha256 || ''), + `${asset.filename}: SHA-256 is required` + ); + invariant( + Number.isInteger(asset.file?.bytes) && asset.file.bytes > 0, + `${asset.filename}: byte size must be a positive integer` + ); + invariant( + Number(asset.file?.pixelWidth) > 0 && + Number(asset.file?.pixelHeight) > 0, + `${asset.filename}: positive exported dimensions are required` + ); + const extension = path.extname(asset.filename).toLowerCase(); + invariant( + (asset.file.format === 'png' && extension === '.png') || + (asset.file.format === 'jpeg' && + ['.jpg', '.jpeg'].includes(extension)) || + (asset.file.format === 'svg' && extension === '.svg'), + `${asset.filename}: extension does not match manifest format` + ); + + const assetPath = path.join(layerDirectory, asset.filename); + const [bytes, fileInfo] = await Promise.all([ + readFile(assetPath), + stat(assetPath), + ]); + const dimensions = fileDimensions(bytes, asset); + invariant( + fileInfo.isFile(), + `${asset.filename}: manifest entry must point to a file` + ); + invariant( + fileInfo.size === asset.file.bytes, + `${asset.filename}: byte size mismatch` + ); + invariant( + Math.abs(dimensions.width - Number(asset.file.pixelWidth)) <= 0.001 && + Math.abs(dimensions.height - Number(asset.file.pixelHeight)) <= + 0.001, + `${asset.filename}: exported dimensions mismatch` + ); + invariant( + dimensions.width * dimensions.height <= maxLayerPixels, + `${asset.filename}: layer exceeds the decorative pixel limit` + ); + invariant( + createHash('sha256').update(bytes).digest('hex') === asset.sha256, + `${asset.filename}: SHA-256 mismatch` + ); +} + +const diskFiles = new Set( + (await readdir(layerDirectory)).filter( + (filename) => filename !== 'manifest.json' + ) +); +invariant( + diskFiles.size === assetsByFile.size && + [...diskFiles].every((filename) => assetsByFile.has(filename)), + 'every layer file must have exactly one manifest record' +); + +const [html, css, js, ...localeTexts] = await Promise.all([ + readFile(path.join(pageDirectory, 'aslan.html'), 'utf8'), + readFile(path.join(aslanDirectory, 'aslan.css'), 'utf8'), + readFile(path.join(aslanDirectory, 'aslan.js'), 'utf8'), + ...localeCodes.map((locale) => + readFile( + path.join(repoRoot, 'common', 'locales', `${locale}.json`), + 'utf8' + ) + ), +]); +const runtimeSource = `${html}\n${css}\n${js}`; + +// Every runtime image reference must resolve back to this manifest. This checks +// the actual page path, not merely whether an approved collection exists on disk. +const runtimeLayerReferences = [ + ...runtimeSource.matchAll( + /(?:\.\/)?(?:aslan\/)?assets\/layers\/([^"')\s?#]+)/g + ), +].map((match) => match[1]); +invariant( + runtimeLayerReferences.length > 0, + 'page must reference audited layers' +); +for (const filename of runtimeLayerReferences) { + invariant( + assetsByFile.has(filename), + `${filename}: runtime layer reference is not present in the manifest` + ); + invariant( + !forbiddenAssetName.test(filename), + `${filename}: aslan.html/runtime references a forbidden whole-page asset` + ); +} +for (const asset of assets) { + invariant( + runtimeLayerReferences.includes(asset.filename), + `${asset.filename}: manifested layer is not used by the page` + ); +} + +const htmlImageSources = [ + ...html.matchAll(/]*\bsrc=["']([^"']+)["'][^>]*>/gi), +].map((match) => match[1]); +for (const source of htmlImageSources) { + invariant( + !forbiddenAssetName.test(source), + `${source}: aslan.html references a forbidden whole-page image` + ); + const layerMatch = source.match(/assets\/layers\/([^"')\s?#]+)/); + invariant( + layerMatch && assetsByFile.has(layerMatch[1]), + `${source}: every aslan.html image must be an audited layer` + ); + invariant( + !canonicalForbiddenNodes.has( + assetsByFile.get(layerMatch[1]).sourceNode + ), + `${source}: aslan.html image resolves to a forbidden root frame` + ); +} +invariant( + html.includes('../../common/theme.css') && + html.includes('../../common/i18n.js'), + 'shared theme and i18n runtime are required' +); + +const locales = localeTexts.map((source) => JSON.parse(source)); +const baseKeys = Object.keys(locales[0]) + .filter((key) => key.startsWith('giftChallenge.')) + .sort(); +invariant(baseKeys.length >= 40, 'English Gift Challenge locale is incomplete'); +const baseKeySet = new Set(baseKeys); +for (let index = 0; index < locales.length; index += 1) { + const localeCode = localeCodes[index]; + const locale = locales[index]; + const keys = Object.keys(locale) + .filter((key) => key.startsWith('giftChallenge.')) + .sort(); + const missing = baseKeys.filter((key) => !(key in locale)); + const extra = keys.filter((key) => !baseKeySet.has(key)); + invariant( + missing.length === 0 && extra.length === 0, + `${localeCode}: Gift Challenge key set differs (missing: ${missing.join(', ') || 'none'}; extra: ${extra.join(', ') || 'none'})` + ); + for (const key of keys) { + invariant( + typeof locale[key] === 'string' && locale[key].trim(), + `${localeCode}:${key}: translation must be a non-empty string` + ); + } +} + +const runtimeI18nKeys = new Set( + [...runtimeSource.matchAll(/giftChallenge\.[A-Za-z0-9_.-]+/g)].map( + (match) => match[0] + ) +); +for (const key of runtimeI18nKeys) { + invariant(baseKeySet.has(key), `${key}: runtime i18n key is missing`); +} + +console.log( + `[${auditLabel}] passed: ${assets.length} traceable layers, ${baseKeys.length} aligned keys across ${localeCodes.length} locales` +);