diff --git a/.gitignore b/.gitignore index 40b878d..a9cc52e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ -node_modules/ \ No newline at end of file +node_modules/ +.waylog/ +**/.audit/ +.DS_Store +design-qa.md +**/design-qa.md diff --git a/AGENTS.md b/AGENTS.md index 8fe20bd..d9af99b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,3 +11,11 @@ H5 多语言能力放在 common/i18n.js 和 common/locales/\*.json,当前面 所有逻辑功能配置默认打开;只有密钥、地址、三方账号等必须由环境提供的敏感值使用占位符或环境配置,不在默认配置里伪造可用凭证。 所有业务逻辑、状态流转、权限判断、支付链路、异步流程都保持高密度逻辑注释,注释解释为什么这样处理和失败分支,不复述语法。 + +## Figma / 截图还原超级红线 + +- 全局永久禁止把整页截图、Figma 根 Frame、整段轮播、整块底栏或包含多个可独立元素的大区块直接作为页面图片实现。 +- 设计稿必须拆成独立视觉图层;文字、按钮、状态、列表、动态媒体与交互区域必须使用 DOM、CSS 和真实接口数据实现。 +- 导出的设计素材必须能追溯到具体的单一视觉节点,并记录 source node、用途、尺寸和哈希;禁止素材内嵌动态文字、控件或业务媒体。 +- 允许使用不包含文字、控件和动态业务内容的纯背景层,但它不能替代其上的任何可拆元素。 +- 验收必须按图层、元素边界、间距、样式和交互逐项核对;禁止用整页设计截图覆盖页面,也禁止把整页截图像素差分作为通过依据。 diff --git a/activity/cp-space/fami.html b/activity/cp-space/fami.html new file mode 100644 index 0000000..5f861ba --- /dev/null +++ b/activity/cp-space/fami.html @@ -0,0 +1,267 @@ + + + + + + Best Friend + + + + + + +
+
+
+ + + + +
+ +

Best Friend

+ +
+ + + +
+
+ +
+
+
+
+ + + + + + + + + + + + diff --git a/activity/cp-space/fami/assets/fonts/OFL.txt b/activity/cp-space/fami/assets/fonts/OFL.txt new file mode 100644 index 0000000..3ff0cca --- /dev/null +++ b/activity/cp-space/fami/assets/fonts/OFL.txt @@ -0,0 +1,96 @@ +Copyright 2014-2025 Adobe (http://www.adobe.com/), with Reserved Font +Name 'Source'. Source is a trademark of Adobe in the United States +and/or other countries. + +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/activity/cp-space/fami/assets/fonts/source-han-sans-sc-medium-latin.woff2 b/activity/cp-space/fami/assets/fonts/source-han-sans-sc-medium-latin.woff2 new file mode 100644 index 0000000..8519f51 Binary files /dev/null and b/activity/cp-space/fami/assets/fonts/source-han-sans-sc-medium-latin.woff2 differ diff --git a/activity/cp-space/fami/assets/layers/avatar-frame.png b/activity/cp-space/fami/assets/layers/avatar-frame.png new file mode 100644 index 0000000..9a7249f Binary files /dev/null and b/activity/cp-space/fami/assets/layers/avatar-frame.png differ diff --git a/activity/cp-space/fami/assets/layers/back.svg b/activity/cp-space/fami/assets/layers/back.svg new file mode 100644 index 0000000..d2935f3 --- /dev/null +++ b/activity/cp-space/fami/assets/layers/back.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/activity/cp-space/fami/assets/layers/battery.svg b/activity/cp-space/fami/assets/layers/battery.svg new file mode 100644 index 0000000..15c113d --- /dev/null +++ b/activity/cp-space/fami/assets/layers/battery.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/activity/cp-space/fami/assets/layers/bg-brother.png b/activity/cp-space/fami/assets/layers/bg-brother.png new file mode 100644 index 0000000..7a471d7 Binary files /dev/null and b/activity/cp-space/fami/assets/layers/bg-brother.png differ diff --git a/activity/cp-space/fami/assets/layers/bg-cp.png b/activity/cp-space/fami/assets/layers/bg-cp.png new file mode 100644 index 0000000..2d74a9f Binary files /dev/null and b/activity/cp-space/fami/assets/layers/bg-cp.png differ diff --git a/activity/cp-space/fami/assets/layers/bg-sister.png b/activity/cp-space/fami/assets/layers/bg-sister.png new file mode 100644 index 0000000..e6c18d9 Binary files /dev/null and b/activity/cp-space/fami/assets/layers/bg-sister.png differ diff --git a/activity/cp-space/fami/assets/layers/card-brother.png b/activity/cp-space/fami/assets/layers/card-brother.png new file mode 100644 index 0000000..230c1fb Binary files /dev/null and b/activity/cp-space/fami/assets/layers/card-brother.png differ diff --git a/activity/cp-space/fami/assets/layers/card-cp.png b/activity/cp-space/fami/assets/layers/card-cp.png new file mode 100644 index 0000000..efb405e Binary files /dev/null and b/activity/cp-space/fami/assets/layers/card-cp.png differ diff --git a/activity/cp-space/fami/assets/layers/card-sister.png b/activity/cp-space/fami/assets/layers/card-sister.png new file mode 100644 index 0000000..1297f16 Binary files /dev/null and b/activity/cp-space/fami/assets/layers/card-sister.png differ diff --git a/activity/cp-space/fami/assets/layers/cellular.svg b/activity/cp-space/fami/assets/layers/cellular.svg new file mode 100644 index 0000000..a58f453 --- /dev/null +++ b/activity/cp-space/fami/assets/layers/cellular.svg @@ -0,0 +1,3 @@ + + + diff --git a/activity/cp-space/fami/assets/layers/center-brother.png b/activity/cp-space/fami/assets/layers/center-brother.png new file mode 100644 index 0000000..4f9f2d6 Binary files /dev/null and b/activity/cp-space/fami/assets/layers/center-brother.png differ diff --git a/activity/cp-space/fami/assets/layers/center-cp.png b/activity/cp-space/fami/assets/layers/center-cp.png new file mode 100644 index 0000000..0e3c25a Binary files /dev/null and b/activity/cp-space/fami/assets/layers/center-cp.png differ diff --git a/activity/cp-space/fami/assets/layers/center-sister.png b/activity/cp-space/fami/assets/layers/center-sister.png new file mode 100644 index 0000000..78ceef0 Binary files /dev/null and b/activity/cp-space/fami/assets/layers/center-sister.png differ diff --git a/activity/cp-space/fami/assets/layers/help.svg b/activity/cp-space/fami/assets/layers/help.svg new file mode 100644 index 0000000..ca4c465 --- /dev/null +++ b/activity/cp-space/fami/assets/layers/help.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/activity/cp-space/fami/assets/layers/level-brother.png b/activity/cp-space/fami/assets/layers/level-brother.png new file mode 100644 index 0000000..b8688b8 Binary files /dev/null and b/activity/cp-space/fami/assets/layers/level-brother.png differ diff --git a/activity/cp-space/fami/assets/layers/level-cp.png b/activity/cp-space/fami/assets/layers/level-cp.png new file mode 100644 index 0000000..12570da Binary files /dev/null and b/activity/cp-space/fami/assets/layers/level-cp.png differ diff --git a/activity/cp-space/fami/assets/layers/level-sister.png b/activity/cp-space/fami/assets/layers/level-sister.png new file mode 100644 index 0000000..269dd83 Binary files /dev/null and b/activity/cp-space/fami/assets/layers/level-sister.png differ diff --git a/activity/cp-space/fami/assets/layers/manifest.json b/activity/cp-space/fami/assets/layers/manifest.json new file mode 100644 index 0000000..5b4876e --- /dev/null +++ b/activity/cp-space/fami/assets/layers/manifest.json @@ -0,0 +1,464 @@ +{ + "schemaVersion": 1, + "rule": "Layered restoration only. Root frames, full-page screenshots, dynamic text, controls, and production avatars are not implementation assets.", + "figma": { + "fileKey": "vaDp5wwn6AOQVlt9zk0f9r", + "page": { + "id": "1:27538", + "name": "yumi" + }, + "entryFrames": { + "cp": "859:2027", + "brother": "859:2247", + "sister": "859:2454" + } + }, + "baseFrame": { + "width": 375, + "height": 812 + }, + "assets": [ + { + "file": "bg-cp.png", + "sourceNode": "859:2028", + "equivalentNodes": [], + "sourceName": "Mask group", + "sourceType": "frame", + "purpose": "CP pure background layer", + "bounds": { "x": 0, "y": 0, "width": 375, "height": 812 }, + "exportFormat": "png", + "bytes": 685603, + "sha256": "a16b0d609a0485653e866b6f6cdaa22a8e22cf0d4bd2edd6c738d6e1b8e82747", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "bg-brother.png", + "sourceNode": "859:2248", + "equivalentNodes": [], + "sourceName": "Mask group", + "sourceType": "frame", + "purpose": "Brother pure background layer", + "bounds": { "x": 0, "y": 0, "width": 375, "height": 812 }, + "exportFormat": "png", + "bytes": 693082, + "sha256": "866c6b111bd91894dab45d23a96493cb83fb526e282ce9ce26a4f243f5896c5e", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "bg-sister.png", + "sourceNode": "859:2455", + "equivalentNodes": [], + "sourceName": "Mask group", + "sourceType": "frame", + "purpose": "Sister pure background layer", + "bounds": { "x": 0, "y": 0, "width": 375, "height": 812 }, + "exportFormat": "png", + "bytes": 718426, + "sha256": "701a6735476e4fe44c2c01a94968dc6704c94c72171f788bdf9be398dacbd554", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "tab-cp.png", + "sourceNode": "859:2059", + "equivalentNodes": [], + "sourceName": "Group 2090056311", + "sourceType": "frame", + "purpose": "Selected CP tab jewel without label text", + "bounds": { "x": 9, "y": 107, "width": 130, "height": 48 }, + "exportFormat": "png", + "bytes": 12416, + "sha256": "2f024464ea3b42311732118312817f92af4558e2abf73331b96951515f961de0", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "tab-brother.png", + "sourceNode": "859:2279", + "equivalentNodes": [], + "sourceName": "Group 2090056311", + "sourceType": "frame", + "purpose": "Selected Brother tab jewel without label text", + "bounds": { "x": 126, "y": 107, "width": 130, "height": 48 }, + "exportFormat": "png", + "bytes": 12330, + "sha256": "eb092fc233d2496860f8be984bb259da4c32357703edd3f4dec51517ea7ba66c", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "tab-sister.png", + "sourceNode": "859:2486", + "equivalentNodes": [], + "sourceName": "Group 2090056343", + "sourceType": "frame", + "purpose": "Selected Sister tab jewel without label text", + "bounds": { "x": 236, "y": 107, "width": 130, "height": 48 }, + "exportFormat": "png", + "bytes": 11938, + "sha256": "ed44cc60056760e216131dda2b932783a315de9c6ba88992fbafb6d210948f76", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "card-cp.png", + "sourceNode": "859:2065", + "equivalentNodes": [], + "sourceName": "Group 2090056340", + "sourceType": "frame", + "purpose": "CP decorative card skin without relationship data", + "bounds": { "x": 14, "y": 169, "width": 347, "height": 199 }, + "exportFormat": "png", + "bytes": 117918, + "sha256": "4f78f7349e7bdd2d501d7fd017a1fe955323d6d9d316b818f4d72b1645c84929", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "card-brother.png", + "sourceNode": "859:2287", + "equivalentNodes": [], + "sourceName": "Group 2090056325", + "sourceType": "frame", + "purpose": "Brother decorative card skin without relationship data", + "bounds": { "x": 14, "y": 169, "width": 347, "height": 199 }, + "exportFormat": "png", + "bytes": 123692, + "sha256": "81e6bbf780fbccb7796c160d4e8e6596bed2af6cfec0a2b378f8ebf1fd466e9b", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "card-sister.png", + "sourceNode": "859:2494", + "equivalentNodes": [], + "sourceName": "Group 2090056336", + "sourceType": "frame", + "purpose": "Sister decorative card skin without relationship data", + "bounds": { "x": 14, "y": 169, "width": 347, "height": 199 }, + "exportFormat": "png", + "bytes": 120470, + "sha256": "803644ee833188033a2c4f7a2b4f051228ca7138eb0d478e426a24056a461653", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "center-cp.png", + "sourceNode": "859:2189", + "equivalentNodes": [], + "sourceName": "image 4790", + "sourceType": "rounded-rectangle", + "purpose": "CP center heart ornament without days or EXP text", + "bounds": { "x": 120, "y": 175, "width": 136, "height": 120 }, + "exportFormat": "png", + "bytes": 20823, + "sha256": "5041a01b2856d7f28843601652da21791d47b81a6d0a22b08a80f83c487c5520", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "center-brother.png", + "sourceNode": "859:2409", + "equivalentNodes": [], + "sourceName": "image 4799", + "sourceType": "rounded-rectangle", + "purpose": "Brother center shield ornament without days or EXP text", + "bounds": { "x": 120, "y": 175, "width": 136, "height": 136 }, + "exportFormat": "png", + "bytes": 18728, + "sha256": "1765558831f54c2404464d5ae907ddf0011d094eecb8c927b5be47af53d8ee62", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "center-sister.png", + "sourceNode": "859:2618", + "equivalentNodes": [], + "sourceName": "image 4796", + "sourceType": "rounded-rectangle", + "purpose": "Sister floral ornament without days or EXP text", + "bounds": { "x": 120, "y": 175, "width": 136, "height": 136 }, + "exportFormat": "png", + "bytes": 34323, + "sha256": "eccd16b84a61a262953eac81cd5b511256bfb595c9c3a130785c5861dfb2fd43", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "avatar-frame.png", + "sourceNode": "859:2183", + "equivalentNodes": [ + "859:2211", + "859:2403", + "859:2418", + "859:2612", + "859:2627" + ], + "sourceName": "3 4456240", + "sourceType": "rounded-rectangle", + "purpose": "Decorative avatar ring shared by both relationship members", + "bounds": { "width": 66, "height": 66 }, + "exportFormat": "png", + "bytes": 4406, + "sha256": "a2d56dd2b81fafa471ca6a41f7f59df9d8aba2c29c9e90f5348d6818b4326e30", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "level-cp.png", + "sourceNode": "859:2218", + "equivalentNodes": ["859:2228"], + "sourceName": "image 4816", + "sourceType": "rounded-rectangle", + "purpose": "CP level badge background without level text", + "bounds": { "width": 24, "height": 13 }, + "exportFormat": "png", + "bytes": 1016, + "sha256": "36e0a7bbcf4cb5d39bd61153b8ca1cf4e76e8c3293c064127e5c097bf7a5d148", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "level-brother.png", + "sourceNode": "859:2425", + "equivalentNodes": ["859:2435"], + "sourceName": "image 4817", + "sourceType": "rounded-rectangle", + "purpose": "Brother level badge background without level text", + "bounds": { "width": 24, "height": 13 }, + "exportFormat": "png", + "bytes": 1017, + "sha256": "da0b413aab96eb7890af30f9a3daef16c2ea03d607767430f2b8c6fb483b4b4b", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "level-sister.png", + "sourceNode": "859:2634", + "equivalentNodes": ["859:2644"], + "sourceName": "image 4819", + "sourceType": "rounded-rectangle", + "purpose": "Sister level badge background without level text", + "bounds": { "width": 24, "height": 13 }, + "exportFormat": "png", + "bytes": 967, + "sha256": "ac5339c0505f194757e1ae0f67e3e69633f73a8c19f99355b956c0d1b038024c", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "pill-cp-left.png", + "sourceNode": "859:2191", + "equivalentNodes": [], + "sourceName": "Group 2085668693 2", + "sourceType": "rounded-rectangle", + "purpose": "Left CP EXP pill cap without dynamic text", + "bounds": { "width": 49, "height": 28 }, + "exportFormat": "png", + "bytes": 2196, + "sha256": "01a8328f9e1f2ebc0973f5cdecb104af634bb28c5d47f5b75d78d0837d7bafdd", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "pill-cp-center.png", + "sourceNode": "859:2192", + "equivalentNodes": [ + "859:2193", + "859:2194", + "859:2195", + "859:2196", + "859:2197", + "859:2198", + "859:2199", + "859:2200", + "859:2201", + "859:2202", + "859:2203", + "859:2204", + "859:2205" + ], + "sourceName": "Group 2085668693 4", + "sourceType": "rounded-rectangle", + "purpose": "Repeatable CP EXP pill center slice without dynamic text", + "bounds": { "width": 1, "height": 28 }, + "exportFormat": "png", + "bytes": 241, + "sha256": "e9940fdbe009e5db2c38992609d56bc162262da870bb5032cc08218aa979444c", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "pill-cp-right.png", + "sourceNode": "859:2206", + "equivalentNodes": [], + "sourceName": "Group 2085668693 3", + "sourceType": "rounded-rectangle", + "purpose": "Right CP EXP pill cap without dynamic text", + "bounds": { "width": 49, "height": 28 }, + "exportFormat": "png", + "bytes": 2236, + "sha256": "1ad302ea0c693b0544e666c0a0289a42eaf27cd1a3b210040cf1fe7fc839fa9d", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "pill-brother.png", + "sourceNode": "859:2411", + "equivalentNodes": [], + "sourceName": "Mask group", + "sourceType": "frame", + "purpose": "Brother EXP pill background without dynamic text", + "bounds": { "width": 112, "height": 28 }, + "exportFormat": "png", + "bytes": 5154, + "sha256": "90af1c3725f500489f84e25b1a025aa20ca6bc6edab56087f108a32dfeecccfc", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "pill-sister.png", + "sourceNode": "859:2620", + "equivalentNodes": [], + "sourceName": "Mask group", + "sourceType": "frame", + "purpose": "Sister EXP pill background without dynamic text", + "bounds": { "width": 112, "height": 28 }, + "exportFormat": "png", + "bytes": 5095, + "sha256": "b7587a4b7b2e1c40bbb9cc42689349351f55b933ab0a687d4ea4a4e2c65440d9", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "back.svg", + "sourceNode": "859:2051", + "equivalentNodes": ["859:2271", "859:2478"], + "sourceName": "icon_20_返回", + "sourceType": "frame", + "purpose": "Back glyph rendered inside a real DOM button", + "bounds": { "x": 16, "y": 54, "width": 24, "height": 24 }, + "exportFormat": "svg", + "bytes": 419, + "sha256": "50d06f24adb0019fcdfb3c3f5db012808c230cd8c2f06c61b775fcccdbf525d4", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "help.svg", + "sourceNode": "859:2055", + "equivalentNodes": ["859:2275", "859:2482"], + "sourceName": "help", + "sourceType": "frame", + "purpose": "Help glyph rendered inside a real DOM button", + "bounds": { "x": 335, "y": 54, "width": 24, "height": 24 }, + "exportFormat": "svg", + "bytes": 1535, + "sha256": "18087727ea208b257df9d875523dd45e29ccd0b05912a4504f72555cd15c045e", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "battery.svg", + "sourceNode": "859:2034", + "equivalentNodes": ["859:2254", "859:2461"], + "sourceName": "Battery", + "sourceType": "frame", + "purpose": "Static Figma status-bar battery glyph", + "bounds": { + "x": 336.67, + "y": 16.33, + "width": 24.33, + "height": 11.33 + }, + "exportFormat": "svg", + "bytes": 1055, + "sha256": "e4d18984f66b6e24e3ecf95ac7a893e3ac2e863a0b52c02ff075860b30a8b837", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "wifi.svg", + "sourceNode": "859:2038", + "equivalentNodes": ["859:2258", "859:2465"], + "sourceName": "Wifi", + "sourceType": "boolean-operation", + "purpose": "Static Figma status-bar Wi-Fi glyph", + "bounds": { + "x": 316.34, + "y": 16.33, + "width": 15.33, + "height": 11.01 + }, + "exportFormat": "svg", + "bytes": 1655, + "sha256": "d84c6cdf1a0f03a824a5b40174d8f326b62301a9c83e90a65600cfb04f1d9411", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "cellular.svg", + "sourceNode": "859:2042", + "equivalentNodes": ["859:2262", "859:2469"], + "sourceName": "Cellular Connection", + "sourceType": "boolean-operation", + "purpose": "Static Figma status-bar cellular glyph", + "bounds": { "x": 294.34, "y": 15, "width": 17, "height": 11.33 }, + "exportFormat": "svg", + "bytes": 1183, + "sha256": "f5df0579b8435fef2e8a8cd8db3998c72947a409f44d25a5486f91f8adb745f3", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "mock-avatar.png", + "sourceNode": "859:2181", + "equivalentNodes": [ + "859:2209", + "859:2401", + "859:2416", + "859:2610", + "859:2625" + ], + "sourceName": "image 4626", + "sourceType": "rounded-rectangle", + "purpose": "Figma reference avatar used only by the explicit mock visual-QA mode", + "bounds": { "width": 61.29, "height": 61.29 }, + "exportFormat": "png", + "bytes": 186219, + "sha256": "31f699f244b15bdace7b0c655f254ecd79de89428bcaaae7d8d8738a65b2dcea", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": true, + "testOnly": true + } + ] +} diff --git a/activity/cp-space/fami/assets/layers/mock-avatar.png b/activity/cp-space/fami/assets/layers/mock-avatar.png new file mode 100644 index 0000000..4e7409d Binary files /dev/null and b/activity/cp-space/fami/assets/layers/mock-avatar.png differ diff --git a/activity/cp-space/fami/assets/layers/pill-brother.png b/activity/cp-space/fami/assets/layers/pill-brother.png new file mode 100644 index 0000000..49cbcc1 Binary files /dev/null and b/activity/cp-space/fami/assets/layers/pill-brother.png differ diff --git a/activity/cp-space/fami/assets/layers/pill-cp-center.png b/activity/cp-space/fami/assets/layers/pill-cp-center.png new file mode 100644 index 0000000..d2fefa0 Binary files /dev/null and b/activity/cp-space/fami/assets/layers/pill-cp-center.png differ diff --git a/activity/cp-space/fami/assets/layers/pill-cp-left.png b/activity/cp-space/fami/assets/layers/pill-cp-left.png new file mode 100644 index 0000000..607459c Binary files /dev/null and b/activity/cp-space/fami/assets/layers/pill-cp-left.png differ diff --git a/activity/cp-space/fami/assets/layers/pill-cp-right.png b/activity/cp-space/fami/assets/layers/pill-cp-right.png new file mode 100644 index 0000000..f9a5182 Binary files /dev/null and b/activity/cp-space/fami/assets/layers/pill-cp-right.png differ diff --git a/activity/cp-space/fami/assets/layers/pill-sister.png b/activity/cp-space/fami/assets/layers/pill-sister.png new file mode 100644 index 0000000..32609fc Binary files /dev/null and b/activity/cp-space/fami/assets/layers/pill-sister.png differ diff --git a/activity/cp-space/fami/assets/layers/tab-brother.png b/activity/cp-space/fami/assets/layers/tab-brother.png new file mode 100644 index 0000000..a2f5e12 Binary files /dev/null and b/activity/cp-space/fami/assets/layers/tab-brother.png differ diff --git a/activity/cp-space/fami/assets/layers/tab-cp.png b/activity/cp-space/fami/assets/layers/tab-cp.png new file mode 100644 index 0000000..1054b19 Binary files /dev/null and b/activity/cp-space/fami/assets/layers/tab-cp.png differ diff --git a/activity/cp-space/fami/assets/layers/tab-sister.png b/activity/cp-space/fami/assets/layers/tab-sister.png new file mode 100644 index 0000000..e7284e1 Binary files /dev/null and b/activity/cp-space/fami/assets/layers/tab-sister.png differ diff --git a/activity/cp-space/fami/assets/layers/wifi.svg b/activity/cp-space/fami/assets/layers/wifi.svg new file mode 100644 index 0000000..e5802d2 --- /dev/null +++ b/activity/cp-space/fami/assets/layers/wifi.svg @@ -0,0 +1,3 @@ + + + diff --git a/activity/cp-space/fami/fami.css b/activity/cp-space/fami/fami.css new file mode 100644 index 0000000..d47aa88 --- /dev/null +++ b/activity/cp-space/fami/fami.css @@ -0,0 +1,681 @@ +@font-face { + font-family: 'Source Han Sans SC Fami'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('./assets/fonts/source-han-sans-sc-medium-latin.woff2') + format('woff2'); +} + +* { + box-sizing: border-box; + -webkit-tap-highlight-color: transparent; +} + +html, +body { + margin: 0; + width: 100%; + min-height: 100%; + overflow-x: hidden; + background: #440b23; + color: #fff; + font-family: + 'SF Pro Text', 'SF Pro Display', 'Noto Sans Arabic', Arial, + 'Helvetica Neue', Helvetica, sans-serif; +} + +button { + border: 0; + padding: 0; + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; +} + +[hidden] { + display: none !important; +} + +.fami-page { + display: flex; + justify-content: center; + width: 100%; + min-height: 100svh; + background: #440b23; +} + +.fami-page[data-tab='brother'] { + background: #080636; +} + +.fami-page[data-tab='sister'] { + background: #301008; +} + +.fami-stage { + --design-scale: 1; + --design-height: 812px; + --stage-height: 812px; + --accent: #ff0979; + --accent-soft: #ff5762; + --tab-bg: rgba(94, 13, 35, 0.8); + --tab-glow: #ff7948; + --tab-jewel-left: -7px; + --avatar-top: 35px; + --center-height: 120px; + --exp-top: 102px; + --progress-from: #ff1e37; + --progress-mid: #fe6a30; + --progress-to: #ffcf67; + position: relative; + flex: 0 0 auto; + width: min(100vw, 414px); + height: var(--stage-height); + overflow: hidden; + background: #440b23; +} + +.fami-page[data-tab='brother'] .fami-stage { + --accent: #5709ff; + --accent-soft: #5709ff; + --tab-bg: rgba(17, 13, 94, 0.8); + --tab-glow: #4872ff; + --tab-jewel-left: 110px; + --avatar-top: 41px; + --center-height: 136px; + --exp-top: 102px; + --progress-from: #471eff; + --progress-mid: #30bcfe; + --progress-to: #67acff; + background: #080636; +} + +.fami-page[data-tab='sister'] .fami-stage { + --accent: #ff4a09; + --accent-soft: #ff4a09; + --tab-bg: rgba(94, 29, 13, 0.8); + --tab-glow: #ff7948; + --tab-jewel-left: 220px; + --avatar-top: 41px; + --center-height: 136px; + --exp-top: 93px; + --progress-from: #ff5e1e; + --progress-mid: #fe6a30; + --progress-to: #ffb167; + background: #301008; +} + +.fami-design { + position: relative; + width: 375px; + height: var(--design-height); + transform: scale(var(--design-scale)); + transform-origin: left top; +} + +.fami-background, +.fami-card-skin, +.fami-tab-jewel, +.fami-center-art, +.fami-avatar-frame, +.fami-level-badge img, +.fami-status-bar img, +.fami-nav-button img { + display: block; + user-select: none; + -webkit-user-drag: none; + pointer-events: none; +} + +.fami-background { + position: absolute; + z-index: 0; + inset: 0; + width: 375px; + height: 812px; + object-fit: fill; + object-position: center top; +} + +.fami-status-bar { + position: absolute; + z-index: 5; + left: 0; + top: 0; + width: 375px; + height: 44px; +} + +.fami-page.has-native-status .fami-status-bar { + visibility: hidden; +} + +.fami-status-time { + position: absolute; + left: 33.87px; + top: 13px; + width: 54px; + height: 15px; + color: #fff; + font-family: Helvetica, Arial, sans-serif; + font-size: 15px; + font-weight: 400; + line-height: 15px; + text-align: center; +} + +.fami-status-cellular { + position: absolute; + left: 294.34px; + top: 15px; + width: 17px; + height: 11.33px; +} + +.fami-status-wifi { + position: absolute; + left: 316.34px; + top: 16.33px; + width: 15.33px; + height: 11.01px; +} + +.fami-status-battery { + position: absolute; + left: 336.67px; + top: 16.33px; + width: 24.33px; + height: 11.33px; +} + +.fami-nav { + position: absolute; + z-index: 5; + left: 0; + top: 44px; + width: 375px; + height: 44px; +} + +.fami-nav h1 { + position: absolute; + left: 139px; + top: 13px; + width: 97px; + height: 18px; + margin: 0; + color: #fff; + font-family: 'Source Han Sans SC Fami', 'Noto Sans Arabic', sans-serif; + font-size: 18px; + font-weight: 500; + line-height: 18px; + text-align: center; + white-space: nowrap; +} + +.fami-nav-button { + position: absolute; + top: 10px; + width: 24px; + height: 24px; +} + +.fami-nav-button--back { + left: 16px; +} + +.fami-nav-button--help { + left: 335px; +} + +.fami-nav-button img { + width: 24px; + height: 24px; + object-fit: contain; +} + +.fami-nav-button--help img { + width: 25.4px; + height: 25.4px; + transform: translate(-0.7px, -0.7px); +} + +.fami-tabs { + position: absolute; + z-index: 5; + left: 16px; + top: 109px; + display: grid; + grid-template-columns: repeat(3, 1fr); + width: 343px; + height: 37px; + border: 0.5px solid #f4b747; + border-radius: 32px; + background: var(--tab-bg); + box-shadow: inset 0 0 5.5px var(--tab-glow); + direction: ltr; +} + +.fami-tab-jewel { + position: absolute; + z-index: 0; + left: calc(var(--tab-jewel-left) - 0.5px); + top: -2.5px; + width: 130px; + height: 48px; + object-fit: fill; +} + +.fami-tabs button { + position: relative; + z-index: 1; + height: 37px; + color: rgba(255, 255, 255, 0.8); + font-family: 'SF Pro Text', Arial, sans-serif; + font-size: 18px; + font-weight: 400; + line-height: 18px; + text-align: center; +} + +.fami-tabs button[aria-selected='true'] { + color: #fff; + font-family: 'Source Han Sans SC Fami', 'Noto Sans Arabic', sans-serif; + font-weight: 500; +} + +.fami-tabs button[data-tab='brother'][aria-selected='false'], +.fami-tabs button[data-tab='sister'][aria-selected='false'] { + letter-spacing: 0.5px; +} + +.fami-tabs button[data-tab='brother'][aria-selected='false'] { + letter-spacing: 0.57px; +} + +.fami-tabs button[data-tab='cp'][aria-selected='true'] { + letter-spacing: 0.35px; +} + +.fami-tabs button[data-tab='brother'][aria-selected='true'] { + letter-spacing: -0.384px; +} + +.fami-tabs button[data-tab='sister'][aria-selected='true'] { + letter-spacing: 0.042px; +} + +.fami-tabs button span { + position: relative; + top: -1px; +} + +.fami-tabs button[data-tab='cp'][aria-selected='true'] span { + left: -0.5px; +} + +.fami-tabs button[data-tab='brother'] span, +.fami-tabs button[data-tab='sister'] span { + left: 1px; +} + +.fami-content { + position: absolute; + z-index: 3; + left: 0; + top: 0; + width: 375px; +} + +.fami-relation-list { + position: absolute; + left: 14px; + top: 169px; + display: flex; + flex-direction: column; + gap: 14px; + width: 347px; +} + +.fami-relation-card { + position: relative; + flex: 0 0 199px; + width: 347px; + height: 199px; + color: var(--accent); +} + +.fami-card-skin { + position: absolute; + z-index: 0; + inset: 0; + width: 347px; + height: 199px; + object-fit: fill; +} + +.fami-avatar { + position: absolute; + z-index: 2; + top: var(--avatar-top); + width: 66px; + height: 66px; + margin: 0; +} + +.fami-avatar--left { + left: 32px; +} + +.fami-avatar--right { + left: 250px; +} + +.fami-avatar-photo { + position: absolute; + z-index: 1; + left: 2.36px; + top: 2.36px; + display: grid; + place-items: center; + width: 61.29px; + height: 61.29px; + overflow: hidden; + border-radius: 50%; + background: rgba(255, 255, 255, 0.26); + color: rgba(255, 255, 255, 0.9); + font-size: 18px; + font-weight: 600; +} + +.fami-avatar-photo img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.fami-avatar-frame { + position: absolute; + z-index: 2; + inset: 0; + width: 66px; + height: 66px; + object-fit: contain; +} + +.fami-center-art-wrap { + position: absolute; + z-index: 1; + left: 106px; + top: 6px; + width: 136px; + height: var(--center-height); + text-align: center; +} + +.fami-center-art { + position: absolute; + z-index: 0; + inset: 0; + width: 136px; + height: var(--center-height); + object-fit: fill; +} + +.fami-days-value { + position: absolute; + z-index: 1; + left: 50%; + top: 45px; + color: var(--accent); + font-size: 18px; + font-family: 'SF Pro Text', Arial, sans-serif; + font-weight: 400; + line-height: 18px; + text-shadow: + 0 1px 0 #fff, + 0 0 4px rgba(255, 255, 255, 0.75); + transform: translateX(-50%); +} + +.fami-days-unit { + position: absolute; + z-index: 1; + left: 50%; + top: 70px; + color: var(--accent); + font-family: 'SF Pro Text', Arial, sans-serif; + font-size: 8px; + font-weight: 400; + line-height: 8px; + text-shadow: 0 1px 0 #fff; + transform: translateX(-50%); +} + +.fami-exp-pill { + position: absolute; + z-index: 3; + left: 119px; + top: var(--exp-top); + display: grid; + place-items: center; + width: 112px; + height: 28px; +} + +.fami-exp-pill-skin { + position: absolute; + z-index: 0; + inset: 0; + display: none; + width: 112px; + height: 28px; + pointer-events: none; +} + +.fami-page[data-tab='cp'] .fami-exp-pill-skin--cp, +.fami-page[data-tab='brother'] .fami-exp-pill-skin--brother, +.fami-page[data-tab='sister'] .fami-exp-pill-skin--sister { + display: block; +} + +.fami-exp-pill-skin--cp img { + position: absolute; + top: 0; + display: block; + height: 28px; + pointer-events: none; +} + +.fami-exp-pill-skin--cp img:nth-child(1) { + left: 0; + width: 49px; +} + +.fami-exp-pill-skin--cp img:nth-child(2) { + left: 49px; + width: 14px; +} + +.fami-exp-pill-skin--cp img:nth-child(3) { + left: 63px; + width: 49px; +} + +.fami-exp-pill [data-value='exp-away'] { + position: relative; + z-index: 1; + color: var(--accent); + font-family: 'SF Pro Text', Arial, sans-serif; + font-size: 8px; + font-weight: 400; + line-height: 8px; + text-align: center; + text-shadow: + 0 1px 0 rgba(255, 255, 255, 0.95), + 0 0 2px rgba(255, 255, 255, 0.85); + white-space: nowrap; +} + +.fami-progress-block { + position: absolute; + z-index: 3; + left: 32px; + top: 134px; + width: 289px; + height: 35px; +} + +.fami-level-badge { + position: absolute; + top: 1.5px; + width: 24px; + height: 13px; +} + +.fami-level-badge--left { + left: 0; +} + +.fami-level-badge--right { + right: 0; +} + +.fami-level-badge img { + position: absolute; + inset: 0; + width: 24px; + height: 13px; + object-fit: fill; +} + +.fami-level-badge span { + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: #fff; + font-family: 'SF Pro Text', Arial, sans-serif; + font-size: 9px; + font-weight: 500; + line-height: 9px; +} + +.fami-progress-track { + position: absolute; + left: 28px; + top: 0; + width: 233px; + height: 16px; + overflow: hidden; + border-radius: 74px; + background: rgba(255, 255, 255, 0.3); + backdrop-filter: blur(6px); +} + +.fami-progress-fill { + position: absolute; + left: 1px; + top: 1px; + width: 1px; + height: 14px; + overflow: hidden; + border-radius: 7px; + background: linear-gradient( + 90deg, + var(--progress-from) 0%, + var(--progress-mid) 50%, + var(--progress-to) 100% + ); + box-shadow: + inset -4px 0 3px #fffef8, + inset 0 2px 2px rgba(255, 254, 239, 0.48), + inset 0 -2px 2px rgba(255, 255, 255, 0.45); +} + +.fami-progress-fill::after { + position: absolute; + inset: 1px 2px; + border-radius: inherit; + background: radial-gradient( + circle, + rgba(255, 255, 255, 0.95) 0 1px, + transparent 2px + ) + 0 0 / 7px 7px; + content: ''; + mix-blend-mode: screen; + opacity: 0.9; +} + +.fami-progress-text { + position: absolute; + left: 50%; + top: 2px; + color: #fff; + font-size: 14px; + font-weight: 700; + line-height: 14px; + text-align: center; + white-space: nowrap; + transform: translateX(-50%); +} + +.fami-time-text { + position: absolute; + left: 0; + top: 20px; + width: 289px; + margin: 0; + color: var(--accent-soft); + font-family: 'SF Pro Text', Arial, sans-serif; + font-size: 11px; + font-weight: 400; + line-height: 15px; + text-align: center; + text-shadow: + 0 1px 0 rgba(255, 255, 255, 0.82), + 0 0 2px rgba(255, 255, 255, 0.72); +} + +.fami-state { + position: absolute; + left: 14px; + top: 169px; + display: grid; + place-items: center; + width: 347px; + min-height: 199px; + padding: 24px; + border: 1px solid rgba(255, 255, 255, 0.45); + border-radius: 12px; + background: rgba(20, 8, 35, 0.76); + color: #fff; + font-size: 14px; + line-height: 20px; + text-align: center; +} + +.fami-state[data-retry='true'] { + cursor: pointer; +} + +.fami-nav-button:focus-visible, +.fami-tabs button:focus-visible, +.fami-state:focus-visible { + outline: 2px solid var(--hy-theme-focus-ring, rgba(255, 255, 255, 0.75)); + outline-offset: 2px; +} + +[dir='rtl'] .fami-nav-button--back img { + transform: scaleX(-1); +} + +@media (prefers-reduced-motion: reduce) { + * { + scroll-behavior: auto !important; + } +} diff --git a/activity/cp-space/fami/fami.js b/activity/cp-space/fami/fami.js new file mode 100644 index 0000000..3010582 --- /dev/null +++ b/activity/cp-space/fami/fami.js @@ -0,0 +1,673 @@ +(function () { + 'use strict'; + + var VALID_TABS = ['cp', 'brother', 'sister']; + var LAYER_BASE = './fami/assets/layers/'; + var THEMES = { + cp: { + background: 'bg-cp.png', + tab: 'tab-cp.png', + card: 'card-cp.png', + center: 'center-cp.png', + level: 'level-cp.png', + }, + brother: { + background: 'bg-brother.png', + tab: 'tab-brother.png', + card: 'card-brother.png', + center: 'center-brother.png', + level: 'level-brother.png', + }, + sister: { + background: 'bg-sister.png', + tab: 'tab-sister.png', + card: 'card-sister.png', + center: 'center-sister.png', + level: 'level-sister.png', + }, + }; + + var page = document.getElementById('famiPage'); + var stage = document.getElementById('famiStage'); + var design = document.getElementById('famiDesign'); + var background = document.getElementById('famiBackground'); + var tabJewel = document.getElementById('famiTabJewel'); + var relationList = document.getElementById('famiRelationList'); + var stateButton = document.getElementById('famiStateButton'); + var content = document.getElementById('famiContent'); + var template = document.getElementById('famiRelationTemplate'); + var backButton = document.getElementById('famiBackButton'); + var helpButton = document.getElementById('famiHelpButton'); + var tabButtons = Array.prototype.slice.call( + document.querySelectorAll('.fami-tabs [data-tab]') + ); + var params = new URLSearchParams(window.location.search || ''); + var resizeObserver = null; + + var state = { + activeTab: normalizeTab(params.get('tab')) || 'cp', + mock: params.get('mock') === '1', + tabs: { + cp: createTabState(), + brother: createTabState(), + sister: createTabState(), + }, + }; + + function createTabState() { + return { + loading: false, + loaded: false, + error: '', + relationships: [], + requestId: 0, + }; + } + + function normalizeTab(value) { + var tab = String(value || '') + .trim() + .toLowerCase(); + return VALID_TABS.indexOf(tab) >= 0 ? tab : ''; + } + + function readNumber(value, fallback) { + var number = Number(value); + return Number.isFinite(number) ? number : fallback || 0; + } + + function readString(value) { + return String(value == null ? '' : value).trim(); + } + + function firstValue() { + for (var index = 0; index < arguments.length; index += 1) { + var value = arguments[index]; + if (value !== undefined && value !== null && value !== '') { + return value; + } + } + return undefined; + } + + function hasValue(value) { + return value !== undefined && value !== null && value !== ''; + } + + function t(key, fallback) { + return window.HyAppI18n && window.HyAppI18n.t + ? window.HyAppI18n.t(key, fallback) + : fallback || key; + } + + function formatNumber(value) { + var number = Math.max(0, Math.floor(readNumber(value, 0))); + return String(number).replace(/\B(?=(\d{3})+(?!\d))/g, ','); + } + + function layer(fileName) { + return LAYER_BASE + fileName; + } + + function setSource(node, fileName) { + if (!node) return; + var source = layer(fileName); + if (node.getAttribute('src') !== source) { + node.setAttribute('src', source); + } + } + + function profileFromData(raw) { + var profile = raw || {}; + return { + id: readString(profile.user_id || profile.userId || profile.id), + displayId: readString( + profile.display_user_id || profile.displayUserId + ), + name: readString( + profile.username || + profile.nickname || + profile.display_name || + profile.name + ), + avatar: readString( + profile.avatar || + profile.avatar_url || + profile.avatarUrl || + profile.avatar_path + ), + }; + } + + function daysFromTimestamp(value) { + var formedAt = readNumber(value, 0); + if (formedAt <= 0) return 0; + if (formedAt < 100000000000) formedAt *= 1000; + if (Date.now() <= formedAt) return 1; + return Math.floor((Date.now() - formedAt) / 86400000) + 1; + } + + function normalizeLevel(value) { + var level = Math.floor(readNumber(value, 1)); + return Math.max(1, Math.min(99, level)); + } + + function normalizeRelationship(raw, tab) { + var item = raw || {}; + var level = normalizeLevel(item.level || item.current_level); + var intimacy = Math.max( + 0, + readNumber(item.intimacy_value || item.intimacyValue, 0) + ); + var currentThreshold = Math.max( + 0, + readNumber( + item.current_level_threshold || item.currentLevelThreshold, + 0 + ) + ); + var nextThreshold = Math.max( + currentThreshold, + readNumber(item.next_level_threshold || item.nextLevelThreshold, 0) + ); + var currentExp = hasValue(firstValue(item.current_exp, item.currentExp)) + ? Math.max( + 0, + readNumber(firstValue(item.current_exp, item.currentExp)) + ) + : Math.max(0, intimacy - currentThreshold); + var nextExp = hasValue(firstValue(item.next_exp, item.nextExp)) + ? Math.max(0, readNumber(firstValue(item.next_exp, item.nextExp))) + : Math.max(0, nextThreshold - currentThreshold); + if (!nextExp && intimacy > 0) nextExp = intimacy; + var neededExp = hasValue( + firstValue(item.needed_for_next_level, item.neededExp) + ) + ? Math.max( + 0, + readNumber( + firstValue(item.needed_for_next_level, item.neededExp) + ) + ) + : Math.max(0, nextExp - currentExp); + var explicitProgress = firstValue( + item.level_progress_percent, + item.progress_percent, + item.progressPercent + ); + var progressPercent = hasValue(explicitProgress) + ? readNumber(explicitProgress, 0) + : nextExp > 0 + ? (currentExp / nextExp) * 100 + : item.max_level + ? 100 + : 0; + var days = Math.max( + 0, + readNumber( + firstValue( + item.days, + item.relationship_days, + daysFromTimestamp(item.formed_at_ms || item.formedAtMS) + ), + 0 + ) + ); + + return { + id: readString( + item.relationship_id || item.relationshipId || item.id + ), + relationType: + normalizeTab(item.relation_type || item.relationType) || tab, + status: readString(item.status || 'active').toLowerCase(), + level: level, + nextLevel: normalizeLevel( + firstValue(item.next_level, item.nextLevel, level + 1) + ), + days: days, + togetherDays: Math.max( + 0, + readNumber( + firstValue( + item.together_days, + item.togetherDays, + item.time_spent_days, + days + ), + days + ) + ), + currentExp: currentExp, + nextExp: nextExp, + neededExp: neededExp, + progressPercent: Math.max(0, Math.min(100, progressPercent)), + maxLevel: Boolean(item.max_level || item.maxLevel), + me: profileFromData(item.me || item.me_profile), + partner: profileFromData( + item.partner || item.partner_profile || item.cp_user + ), + }; + } + + function activeRelationships(payload, tab) { + var data = payload || {}; + var rows = Array.isArray(data) + ? data + : data.relationships || data.items || []; + return (Array.isArray(rows) ? rows : []) + .map(function (item) { + return normalizeRelationship(item, tab); + }) + .filter(function (item) { + return ( + item.relationType === tab && + (!item.status || item.status === 'active') + ); + }); + } + + function mockRelationship(tab) { + return normalizeRelationship( + { + relationship_id: 'figma-' + tab, + relation_type: tab, + status: 'active', + level: 1, + next_level: 2, + days: 999, + together_days: 4, + current_exp: 1000, + next_exp: 200000, + needed_for_next_level: 888, + level_progress_percent: 22.3175965665, + me: { + user_id: 'figma-left', + username: 'Fami', + avatar: layer('mock-avatar.png'), + }, + partner: { + user_id: 'figma-right', + username: 'Fami', + avatar: layer('mock-avatar.png'), + }, + }, + tab + ); + } + + function renderAvatar(node, profile) { + if (!node) return; + node.replaceChildren(); + var data = profile || {}; + if (data.avatar) { + var image = document.createElement('img'); + image.src = data.avatar; + image.alt = data.name || ''; + image.addEventListener('error', function () { + image.remove(); + node.textContent = (data.name || data.displayId || '?') + .charAt(0) + .toUpperCase(); + }); + node.appendChild(image); + return; + } + node.textContent = (data.name || data.displayId || '?') + .charAt(0) + .toUpperCase(); + } + + function renderRelationship(item, tab) { + var theme = THEMES[tab]; + var fragment = template.content.cloneNode(true); + var card = fragment.querySelector('.fami-relation-card'); + var levelText = t('cpSpace.levelValue', 'Lv.{level}').replace( + '{level}', + formatNumber(item.level) + ); + var nextLevel = item.maxLevel ? item.level : item.nextLevel; + var expAwayText = item.maxLevel + ? t('cpSpace.maxLevelExp', '{exp} EXP').replace( + '{exp}', + formatNumber(item.currentExp) + ) + : t('cpSpace.expAway', '{exp} Exp away from Lv. {level}') + .replace('{exp}', formatNumber(item.neededExp)) + .replace('{level}', formatNumber(nextLevel)); + + setSource(card.querySelector('[data-layer="card"]'), theme.card); + setSource(card.querySelector('[data-layer="center"]'), theme.center); + card.querySelectorAll('[data-layer="level"]').forEach(function (node) { + setSource(node, theme.level); + }); + renderAvatar(card.querySelector('[data-avatar="me"]'), item.me); + renderAvatar( + card.querySelector('[data-avatar="partner"]'), + item.partner + ); + card.querySelector('[data-value="days"]').textContent = formatNumber( + item.days + ); + card.querySelector('[data-value="exp-away"]').textContent = expAwayText; + // 两侧徽标在 Figma 中表示这段关系当前等级,不是“当前/下一等级”;下一等级只出现在经验提示里。 + card.querySelector('[data-value="level-left"]').textContent = levelText; + card.querySelector('[data-value="level-right"]').textContent = + levelText; + card.querySelector('[data-value="progress-text"]').textContent = + String(Math.max(0, Math.floor(item.currentExp))) + + '/' + + String(Math.max(0, Math.floor(item.nextExp))); + card.querySelector('[data-value="progress"]').style.width = + Math.max(1, Math.min(233, (item.progressPercent / 100) * 233)) + + 'px'; + card.querySelector('[data-value="time"]').textContent = t( + 'cpSpace.timeTogether', + 'Time spent together: {days} days' + ).replace('{days}', formatNumber(item.togetherDays)); + card.setAttribute( + 'aria-label', + t('cpSpace.levelCard', 'Best friend level {level}').replace( + '{level}', + formatNumber(item.level) + ) + ); + return fragment; + } + + function renderTheme() { + var tab = state.activeTab; + var theme = THEMES[tab]; + page.setAttribute('data-tab', tab); + setSource(background, theme.background); + setSource(tabJewel, theme.tab); + tabButtons.forEach(function (button) { + var selected = button.getAttribute('data-tab') === tab; + button.setAttribute('aria-selected', String(selected)); + button.tabIndex = selected ? 0 : -1; + if (selected) content.setAttribute('aria-labelledby', button.id); + }); + } + + function setStateMessage(message, retry) { + stateButton.hidden = !message; + stateButton.textContent = message || ''; + stateButton.setAttribute('data-retry', String(Boolean(retry))); + stateButton.disabled = !retry; + } + + function updateDesignHeight(cardCount) { + var count = Math.max(1, Number(cardCount || 0)); + var designHeight = Math.max( + 812, + 169 + count * 199 + (count - 1) * 14 + 40 + ); + design.style.setProperty('--design-height', designHeight + 'px'); + stage.style.setProperty('--design-height', designHeight + 'px'); + syncScale(); + } + + function render() { + renderTheme(); + var tabState = state.tabs[state.activeTab]; + content.setAttribute('aria-busy', String(tabState.loading)); + relationList.replaceChildren(); + + if (tabState.loading && !tabState.loaded) { + setStateMessage(t('cpSpace.loading', 'Loading...'), false); + updateDesignHeight(0); + return; + } + if (tabState.error) { + setStateMessage( + tabState.error || + t('cpSpace.loadFailed', 'Load failed. Tap to retry.'), + true + ); + updateDesignHeight(0); + return; + } + if (!tabState.relationships.length) { + setStateMessage( + t('cpSpace.empty', 'No relationship data yet.'), + false + ); + updateDesignHeight(0); + return; + } + + setStateMessage('', false); + tabState.relationships.forEach(function (item) { + relationList.appendChild(renderRelationship(item, state.activeTab)); + }); + updateDesignHeight(tabState.relationships.length); + } + + function syncScale() { + if (!stage) return; + var width = stage.getBoundingClientRect().width || window.innerWidth; + var scale = Math.max(0.1, width / 375); + var designHeight = parseFloat( + stage.style.getPropertyValue('--design-height') || '812' + ); + stage.style.setProperty('--design-scale', String(scale)); + stage.style.setProperty('--stage-height', designHeight * scale + 'px'); + } + + function bindScale() { + syncScale(); + if (window.ResizeObserver) { + resizeObserver = new ResizeObserver(syncScale); + resizeObserver.observe(stage); + return; + } + window.addEventListener('resize', syncScale); + window.addEventListener('orientationchange', syncScale); + } + + function syncURL(tab) { + if (!window.history || !window.history.replaceState) return; + var url = new URL(window.location.href); + url.searchParams.set('tab', tab); + // tab 只属于本页视觉和查询状态;replaceState 避免每次切换都污染 App WebView 的返回栈。 + window.history.replaceState(null, '', url.toString()); + } + + function loadTab(tab, force) { + var target = normalizeTab(tab) || 'cp'; + var tabState = state.tabs[target]; + if (!force && (tabState.loading || tabState.loaded)) { + render(); + return Promise.resolve(tabState.relationships); + } + var requestId = ++tabState.requestId; + tabState.loading = true; + tabState.error = ''; + render(); + + if (state.mock) { + tabState.relationships = [mockRelationship(target)]; + tabState.loaded = true; + tabState.loading = false; + render(); + return Promise.resolve(tabState.relationships); + } + + if ( + !window.HyAppAPI || + !window.HyAppAPI.cpSpace || + !window.HyAppAPI.cpSpace.relationships + ) { + tabState.loading = false; + tabState.error = t( + 'cpSpace.loadFailed', + 'Load failed. Tap to retry.' + ); + render(); + return Promise.reject(new Error('cp_space_api_unavailable')); + } + + // 关系类型和分页值原样交给 gateway;页面不猜测用户关系,也不把一个 tab 的结果复制到另一个 tab。 + return window.HyAppAPI.cpSpace + .relationships({ + relation_type: target, + page: 1, + page_size: target === 'cp' ? 1 : 20, + }) + .then(function (payload) { + if (requestId !== tabState.requestId) return []; + tabState.relationships = activeRelationships(payload, target); + tabState.loaded = true; + return tabState.relationships; + }) + .catch(function (error) { + if (requestId !== tabState.requestId) return []; + tabState.error = + (error && error.message) || + t('cpSpace.loadFailed', 'Load failed. Tap to retry.'); + return []; + }) + .then(function (rows) { + if (requestId !== tabState.requestId) return rows; + tabState.loading = false; + render(); + return rows; + }); + } + + function setActiveTab(tab, options) { + var target = normalizeTab(tab) || 'cp'; + state.activeTab = target; + render(); + if (!options || options.syncURL !== false) syncURL(target); + return loadTab(target, Boolean(options && options.force)); + } + + function hasNativeBridge() { + return Boolean( + (window.flutter_inappwebview && + window.flutter_inappwebview.callHandler) || + (window.HyAppBridgeChannel && + window.HyAppBridgeChannel.postMessage) || + (window.FlutterBridge && window.FlutterBridge.postMessage) || + (window.webkit && + window.webkit.messageHandlers && + window.webkit.messageHandlers.HyAppBridge) + ); + } + + function contextualURL(path) { + var url = new URL(path, window.location.href); + ['token', 'access_token', 'env', 'language', 'lang', 'locale'].forEach( + function (key) { + if (params.has(key)) url.searchParams.set(key, params.get(key)); + } + ); + url.searchParams.set('app_code', 'fami'); + return url.toString(); + } + + function applyExternalData(payload) { + var data = payload || {}; + VALID_TABS.forEach(function (tab) { + if (!data[tab]) return; + state.tabs[tab].relationships = activeRelationships( + Array.isArray(data[tab]) + ? { relationships: data[tab] } + : data[tab], + tab + ); + state.tabs[tab].loaded = true; + state.tabs[tab].loading = false; + state.tabs[tab].error = ''; + }); + if (normalizeTab(data.activeTab)) state.activeTab = data.activeTab; + render(); + } + + function bindEvents() { + tabButtons.forEach(function (button) { + button.addEventListener('click', function () { + setActiveTab(button.getAttribute('data-tab')); + }); + button.addEventListener('keydown', function (event) { + var current = VALID_TABS.indexOf( + button.getAttribute('data-tab') + ); + var next = current; + if (event.key === 'ArrowLeft') next = current - 1; + else if (event.key === 'ArrowRight') next = current + 1; + else if (event.key === 'Home') next = 0; + else if (event.key === 'End') next = VALID_TABS.length - 1; + else return; + event.preventDefault(); + // 标签视觉顺序固定为从左到右;键盘同样按可见顺序循环,RTL 只翻转返回箭头而不倒置关系类型。 + next = (next + VALID_TABS.length) % VALID_TABS.length; + var targetButton = tabButtons[next]; + targetButton.focus(); + setActiveTab(targetButton.getAttribute('data-tab')); + }); + }); + + stateButton.addEventListener('click', function () { + if (stateButton.getAttribute('data-retry') === 'true') { + loadTab(state.activeTab, true); + } + }); + + backButton.addEventListener('click', function () { + if ( + window.FlutterPageControl && + window.FlutterPageControl.postMessage + ) { + window.FlutterPageControl.postMessage('close_page'); + return; + } + if (hasNativeBridge() && window.HyAppBridge) { + window.HyAppBridge.back(); + return; + } + window.history.back(); + }); + + helpButton.addEventListener('click', function () { + if (hasNativeBridge() && window.HyAppBridge) { + window.HyAppBridge.post('cpSpaceHelpTap', { + page: 'fami-cp-space', + relation_type: state.activeTab, + }); + return; + } + window.location.href = contextualURL('../../cp-rules/'); + }); + + window.addEventListener('hyapp:i18n-ready', render); + window.addEventListener('hyapp:cp-space-fami-data', function (event) { + applyExternalData(event.detail || {}); + }); + window.addEventListener('hyapp:cp-space-data', function (event) { + applyExternalData(event.detail || {}); + }); + } + + if (hasNativeBridge() && params.get('figma_status') !== '1') { + page.classList.add('has-native-status'); + } + bindScale(); + bindEvents(); + if (window.HyAppCPSpaceFamiData) { + applyExternalData(window.HyAppCPSpaceFamiData); + setActiveTab(state.activeTab, { syncURL: false }); + } else { + setActiveTab(state.activeTab, { syncURL: false }); + } + + if (window.HyAppBridge && window.HyAppBridge.ready) { + window.HyAppBridge.ready({ + page: 'fami-cp-space', + app_code: 'fami', + tabs: VALID_TABS.slice(), + mock: state.mock, + }); + } +})(); diff --git a/activity/cp-space/fami/product.js b/activity/cp-space/fami/product.js new file mode 100644 index 0000000..1e07d42 --- /dev/null +++ b/activity/cp-space/fami/product.js @@ -0,0 +1,8 @@ +(function () { + 'use strict'; + + // common/api.js 在未显式传 app_code 时读取页面默认值;必须在 API 脚本执行前声明,避免 Fami 页面误落到 Lalu 租户。 + window.HyAppDefaultAppCode = 'fami'; + window.HyAppI18nSupported = ['en', 'ar', 'tr', 'es']; + document.documentElement.setAttribute('data-hy-app-code', 'fami'); +})(); diff --git a/activity/cp/huwaa.html b/activity/cp/huwaa.html new file mode 100644 index 0000000..90c8a37 --- /dev/null +++ b/activity/cp/huwaa.html @@ -0,0 +1,205 @@ + + + + + + Last Weekly CP + + + + + +
+
+
+
+ + +
+ +

+ Last Weekly CP +

+
+ +
+
+ + 00 + D +
+
+ + 00 + H +
+
+ + 00 + M +
+
+ + 00 + S +
+
+ + + +
+ +
+ The copyright of this activity belongs to Shila +
+

+
+
+
+
+ + + + + + + + diff --git a/activity/cp/huwaa/assets/layers/bg-texture.webp b/activity/cp/huwaa/assets/layers/bg-texture.webp new file mode 100644 index 0000000..1140070 Binary files /dev/null and b/activity/cp/huwaa/assets/layers/bg-texture.webp differ diff --git a/activity/cp/huwaa/assets/layers/countdown-heart.webp b/activity/cp/huwaa/assets/layers/countdown-heart.webp new file mode 100644 index 0000000..040dc0a Binary files /dev/null and b/activity/cp/huwaa/assets/layers/countdown-heart.webp differ diff --git a/activity/cp/huwaa/assets/layers/crown-bronze.webp b/activity/cp/huwaa/assets/layers/crown-bronze.webp new file mode 100644 index 0000000..12bb65b Binary files /dev/null and b/activity/cp/huwaa/assets/layers/crown-bronze.webp differ diff --git a/activity/cp/huwaa/assets/layers/crown-gold.webp b/activity/cp/huwaa/assets/layers/crown-gold.webp new file mode 100644 index 0000000..8cd6b8f Binary files /dev/null and b/activity/cp/huwaa/assets/layers/crown-gold.webp differ diff --git a/activity/cp/huwaa/assets/layers/crown-silver.webp b/activity/cp/huwaa/assets/layers/crown-silver.webp new file mode 100644 index 0000000..494d6d0 Binary files /dev/null and b/activity/cp/huwaa/assets/layers/crown-silver.webp differ diff --git a/activity/cp/huwaa/assets/layers/deco-line-left.webp b/activity/cp/huwaa/assets/layers/deco-line-left.webp new file mode 100644 index 0000000..46c513b Binary files /dev/null and b/activity/cp/huwaa/assets/layers/deco-line-left.webp differ diff --git a/activity/cp/huwaa/assets/layers/deco-line-right.webp b/activity/cp/huwaa/assets/layers/deco-line-right.webp new file mode 100644 index 0000000..9d403c0 Binary files /dev/null and b/activity/cp/huwaa/assets/layers/deco-line-right.webp differ diff --git a/activity/cp/huwaa/assets/layers/gift-plane.webp b/activity/cp/huwaa/assets/layers/gift-plane.webp new file mode 100644 index 0000000..f9fa674 Binary files /dev/null and b/activity/cp/huwaa/assets/layers/gift-plane.webp differ diff --git a/activity/cp/huwaa/assets/layers/hero-art-text-free.webp b/activity/cp/huwaa/assets/layers/hero-art-text-free.webp new file mode 100644 index 0000000..7099bda Binary files /dev/null and b/activity/cp/huwaa/assets/layers/hero-art-text-free.webp differ diff --git a/activity/cp/huwaa/assets/layers/manifest.json b/activity/cp/huwaa/assets/layers/manifest.json new file mode 100644 index 0000000..b24916d --- /dev/null +++ b/activity/cp/huwaa/assets/layers/manifest.json @@ -0,0 +1,424 @@ +{ + "schemaVersion": 1, + "figma": { + "fileKey": "OTOg5cwbhVNWxkImGmzTJl", + "canvas": { + "id": "543:1998", + "name": "Huwaa运营" + }, + "entryFrames": { + "plaza": "831:3320", + "rank": "831:4048", + "rewards": "831:3913" + }, + "designSizes": { + "plaza": { + "width": 1080, + "height": 4283 + }, + "rank": { + "width": 1080, + "height": 3733 + }, + "rewards": { + "width": 1080, + "height": 4782 + } + }, + "forbiddenAggregateNodes": [ + "543:1998", + "823:3005", + "831:3320", + "831:4048", + "831:3913", + "831:3323", + "831:3326", + "831:3339", + "831:3349", + "831:3642", + "831:4381", + "831:3942" + ] + }, + "assets": [ + { + "file": "bg-texture.webp", + "sourceNode": "831:3321", + "sourceName": "花瓣素材_情人节白色情人节七夕520梦幻浪漫感粉丝爱心i底纹背景元素素材_193406578 1", + "sourceType": "RECTANGLE", + "purpose": "Pure petal texture positioned at x=-487, y=489 over the independent #e98c9b stage fill with Figma opacity 0.5 and screen blend mode", + "bounds": { + "width": 2415, + "height": 4293 + }, + "original": { + "width": 1242, + "height": 2208 + }, + "exportFormat": "webp", + "bytes": 711912, + "sha256": "e0c9a81e2bb893731f8c70cf25f322693da52fe12e8a48513d4d7de8bd6d182c", + "containsText": false, + "containsDynamicText": false, + "containsControls": false, + "containsDynamicMedia": false, + "derivedFromSha256": "9b13664bd6b77c64075da4891a95acef6116cc2055f3c9d9fda794493a8b6ede", + "transform": "Figma raw image fill encoded as WebP quality 94; runtime CSS preserves the source node bounds, opacity and screen blend over the frame fill" + }, + { + "file": "hero-art-text-free.webp", + "sourceNode": "831:3325", + "sourceName": "c7ffd4a5-20dc-41c9-93f6-7721e460aa70 2", + "sourceType": "RECTANGLE", + "purpose": "Text-free event illustration behind the independent localized DOM wordmark; countdown, tabs, state and business data remain separate DOM layers", + "bounds": { + "width": 1080, + "height": 1333 + }, + "original": { + "width": 1080, + "height": 1333 + }, + "exportFormat": "webp", + "bytes": 356714, + "sha256": "33620a230108ca0559062de12263356349c26b0b18ffbb863e5966ea23d6e194", + "containsText": false, + "containsDynamicText": false, + "containsControls": false, + "containsDynamicMedia": false, + "derivedFromSha256": "ad52de31140cb9d72415f86b570c44aff121b0000e1aef88470b28472c1c4137", + "transform": "Localized inpainting was limited to the embedded wordmark region; before WebP encoding, normalized source pixels above y=830 and below y=1275 were preserved, while the title was moved to independent localized DOM text", + "preEncodingPixelExactCoverage": 0.6981 + }, + { + "file": "countdown-heart.webp", + "sourceNode": "831:3327", + "sourceName": "ffutuft 3", + "sourceType": "RECTANGLE", + "purpose": "Empty countdown heart skin behind live DOM values", + "bounds": { + "width": 163, + "height": 106 + }, + "original": { + "width": 163, + "height": 106 + }, + "exportFormat": "webp-lossless", + "bytes": 20946, + "sha256": "d0a83f2b9695c2d15330d25203947dad171c9f03afbfb9d053e444765f0220fb", + "containsText": false, + "containsDynamicText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "tab-active.webp", + "sourceNode": "831:3347", + "sourceName": "图层 0 34", + "sourceType": "RECTANGLE", + "purpose": "Active tab skin without its DOM label or interaction target", + "bounds": { + "width": 303, + "height": 126 + }, + "original": { + "width": 303, + "height": 126 + }, + "exportFormat": "webp-lossless", + "bytes": 40482, + "sha256": "dcf5231b062cfc09529493285ee42e35b7f3b0e1c1295b5fd8432573cdf9a811", + "containsText": false, + "containsDynamicText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "tab-inactive.webp", + "sourceNode": "831:3344", + "sourceName": "图层 0 34", + "sourceType": "RECTANGLE", + "purpose": "Inactive tab skin without its DOM label or interaction target", + "bounds": { + "width": 303, + "height": 126 + }, + "original": { + "width": 303, + "height": 126 + }, + "exportFormat": "webp-lossless", + "bytes": 37228, + "sha256": "6aa5d9166eac1894fd64f014004a58afbb0a0930aa3967562cb82fb829315e91", + "containsText": false, + "containsDynamicText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "panel-skin.webp", + "sourceNode": "831:3350", + "sourceName": "想吃vxcvxvdsfsdf 1", + "sourceType": "RECTANGLE", + "purpose": "Reusable empty pink-and-gold panel skin rendered with CSS nine-slicing", + "bounds": { + "width": 960, + "height": 671 + }, + "original": { + "width": 1254, + "height": 1254 + }, + "exportFormat": "webp-lossless", + "bytes": 865498, + "sha256": "b3011ae288d78d5f92a5e8d610eb889088798ab9061b739b403ad95e2ce1e5b4", + "containsText": false, + "containsDynamicText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "gift-plane.webp", + "sourceNode": "831:3568", + "sourceName": "06黄金小飞机缩略图 (1) 1", + "sourceType": "RECTANGLE", + "purpose": "Decorative gift icon between CP partners in the plaza list", + "bounds": { + "width": 152, + "height": 152 + }, + "original": { + "width": 152, + "height": 152 + }, + "exportFormat": "webp-lossless", + "bytes": 40268, + "sha256": "880f57ca597bd1625700aaf3869fba1daeaefdf921c0807ff6a242695bc2e714", + "containsText": false, + "containsDynamicText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "deco-line-right.webp", + "sourceNode": "831:3583", + "sourceName": "520 (431) 1", + "sourceType": "RECTANGLE", + "purpose": "Right-side heartbeat line between the CP avatar and center icon", + "bounds": { + "width": 159, + "height": 30 + }, + "original": { + "width": 159, + "height": 30 + }, + "exportFormat": "webp-lossless", + "bytes": 1600, + "sha256": "f2ecdcfa3a07d3fe9ac5b19de4d9de9c3aa802093a62f8e8227ce6a79333b23d", + "containsText": false, + "containsDynamicText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "deco-line-left.webp", + "sourceNode": "831:3584", + "sourceName": "520 (431) 2", + "sourceType": "RECTANGLE", + "purpose": "Left-side heartbeat line between the CP avatar and center icon", + "bounds": { + "width": 159, + "height": 30 + }, + "original": { + "width": 159, + "height": 30 + }, + "exportFormat": "webp-lossless", + "bytes": 1612, + "sha256": "5e8afa2c2ab1b6117961bc9ae1162ff50213eb4be2b034293eb7421c69b246b6", + "containsText": false, + "containsDynamicText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "score-heart.webp", + "sourceNode": "831:3849", + "sourceName": "ffutuft 2", + "sourceType": "RECTANGLE", + "purpose": "Empty score heart behind live DOM rank values", + "bounds": { + "width": 220, + "height": 143 + }, + "original": { + "width": 220, + "height": 143 + }, + "exportFormat": "webp-lossless", + "bytes": 35024, + "sha256": "4e9052c29e375553b9fecf77dbba8bfaab51b94fba30c00b500b31c776a1496b", + "containsText": false, + "containsDynamicText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "podium-gold.webp", + "sourceNode": "831:4421", + "sourceName": "图层 0 31", + "sourceType": "RECTANGLE", + "purpose": "First-place double-avatar podium frame without avatars or names", + "bounds": { + "width": 716, + "height": 434 + }, + "original": { + "width": 716, + "height": 434 + }, + "exportFormat": "webp-lossless", + "bytes": 278480, + "sha256": "01cab5779b58c65256b25d2dd7784a73d777c5b9a2f75d4dc22df0ea062e3a5d", + "containsText": false, + "containsDynamicText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "podium-silver.webp", + "sourceNode": "831:4426", + "sourceName": "04144 1", + "sourceType": "RECTANGLE", + "purpose": "Second-place double-avatar podium frame without avatars or names", + "bounds": { + "width": 500, + "height": 284 + }, + "original": { + "width": 500, + "height": 284 + }, + "exportFormat": "webp-lossless", + "bytes": 125178, + "sha256": "13dd006d1840d1fd0ed5545446bd2a493293a3a64e8b333b9b88e3e95cbb3e57", + "containsText": false, + "containsDynamicText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "podium-bronze.webp", + "sourceNode": "831:4437", + "sourceName": "轻微我去恶 1", + "sourceType": "RECTANGLE", + "purpose": "Third-place double-avatar podium frame without avatars or names", + "bounds": { + "width": 500, + "height": 286 + }, + "original": { + "width": 500, + "height": 286 + }, + "exportFormat": "webp-lossless", + "bytes": 137328, + "sha256": "060b52606840c41717664c9dc5b5721d3f69c10305d79b2f935d1f20353e65a8", + "containsText": false, + "containsDynamicText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "reward-frame.webp", + "sourceNode": "831:3950", + "sourceName": "8d9ca1c38fb3b780ff6b5d6d3cd318b8f108e7b83f3cd-59aVD7 4", + "sourceType": "RECTANGLE", + "purpose": "Empty reward wreath placed around live reward cover images", + "bounds": { + "width": 200, + "height": 200 + }, + "original": { + "width": 200, + "height": 200 + }, + "exportFormat": "webp-lossless", + "bytes": 39558, + "sha256": "e26e9f171b44296471cd9fd0c84fed4f0835c30f54e7d48435a18162aa62bfe7", + "containsText": false, + "containsDynamicText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "crown-gold.webp", + "sourceNode": "831:3946", + "sourceName": "冠军 1", + "sourceType": "RECTANGLE", + "purpose": "Gold reward-tier crown", + "bounds": { + "width": 162, + "height": 144 + }, + "original": { + "width": 162, + "height": 144 + }, + "exportFormat": "webp-lossless", + "bytes": 24612, + "sha256": "b9c26c70a5a2f22273639bcd68c4af63cd87bc104cc93676b5f6feaaf7eacaaa", + "containsText": false, + "containsDynamicText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "crown-silver.webp", + "sourceNode": "831:3981", + "sourceName": "冠军 2", + "sourceType": "RECTANGLE", + "purpose": "Silver reward-tier crown", + "bounds": { + "width": 162, + "height": 144 + }, + "original": { + "width": 162, + "height": 144 + }, + "exportFormat": "webp-lossless", + "bytes": 23164, + "sha256": "21a95c439ca10aa615ab1b03db6f3c23f68fd8b22ddbd1690d723baf27be53f3", + "containsText": false, + "containsDynamicText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "crown-bronze.webp", + "sourceNode": "831:4016", + "sourceName": "冠军 2", + "sourceType": "RECTANGLE", + "purpose": "Bronze reward-tier crown", + "bounds": { + "width": 162, + "height": 144 + }, + "original": { + "width": 162, + "height": 144 + }, + "exportFormat": "webp-lossless", + "bytes": 25124, + "sha256": "5dbba6acd0eb8f92ac9278b9720a3aba8fcaef97716a26d8db47b68ee4201dca", + "containsText": false, + "containsDynamicText": false, + "containsControls": false, + "containsDynamicMedia": false + } + ] +} diff --git a/activity/cp/huwaa/assets/layers/panel-skin.webp b/activity/cp/huwaa/assets/layers/panel-skin.webp new file mode 100644 index 0000000..8c073c4 Binary files /dev/null and b/activity/cp/huwaa/assets/layers/panel-skin.webp differ diff --git a/activity/cp/huwaa/assets/layers/podium-bronze.webp b/activity/cp/huwaa/assets/layers/podium-bronze.webp new file mode 100644 index 0000000..0b8c8e7 Binary files /dev/null and b/activity/cp/huwaa/assets/layers/podium-bronze.webp differ diff --git a/activity/cp/huwaa/assets/layers/podium-gold.webp b/activity/cp/huwaa/assets/layers/podium-gold.webp new file mode 100644 index 0000000..0a12bc5 Binary files /dev/null and b/activity/cp/huwaa/assets/layers/podium-gold.webp differ diff --git a/activity/cp/huwaa/assets/layers/podium-silver.webp b/activity/cp/huwaa/assets/layers/podium-silver.webp new file mode 100644 index 0000000..a4c30b5 Binary files /dev/null and b/activity/cp/huwaa/assets/layers/podium-silver.webp differ diff --git a/activity/cp/huwaa/assets/layers/reward-frame.webp b/activity/cp/huwaa/assets/layers/reward-frame.webp new file mode 100644 index 0000000..8940286 Binary files /dev/null and b/activity/cp/huwaa/assets/layers/reward-frame.webp differ diff --git a/activity/cp/huwaa/assets/layers/score-heart.webp b/activity/cp/huwaa/assets/layers/score-heart.webp new file mode 100644 index 0000000..9c6c82e Binary files /dev/null and b/activity/cp/huwaa/assets/layers/score-heart.webp differ diff --git a/activity/cp/huwaa/assets/layers/tab-active.webp b/activity/cp/huwaa/assets/layers/tab-active.webp new file mode 100644 index 0000000..270ca56 Binary files /dev/null and b/activity/cp/huwaa/assets/layers/tab-active.webp differ diff --git a/activity/cp/huwaa/assets/layers/tab-inactive.webp b/activity/cp/huwaa/assets/layers/tab-inactive.webp new file mode 100644 index 0000000..0bc66bc Binary files /dev/null and b/activity/cp/huwaa/assets/layers/tab-inactive.webp differ diff --git a/activity/cp/huwaa/huwaa.css b/activity/cp/huwaa/huwaa.css new file mode 100644 index 0000000..b9880f9 --- /dev/null +++ b/activity/cp/huwaa/huwaa.css @@ -0,0 +1,832 @@ +* { + box-sizing: border-box; + -webkit-tap-highlight-color: transparent; +} + +:root { + --huwaa-cp-page-width: 375px; + --huwaa-cp-design-scale: 0.3472222222; + --huwaa-cp-brand: var(--hy-theme-primary-strong, #c52d45); + --huwaa-cp-brand-deep: #a1152f; + --huwaa-cp-text: #a11626; + --huwaa-cp-muted: #dd708d; + --huwaa-cp-row: rgba(197, 45, 69, 0.13); + --huwaa-cp-page-bg: var(--hy-theme-bg, #f8c7d5); +} + +html, +body { + width: 100%; + min-height: 100%; + margin: 0; + overflow-x: hidden; + scrollbar-width: none; + background: var(--huwaa-cp-page-bg); + color: var(--hy-theme-text, #21182b); + font-family: + Inter, Arial, 'Helvetica Neue', Helvetica, 'Noto Sans', sans-serif; +} + +html::-webkit-scrollbar, +body::-webkit-scrollbar { + display: none; +} + +button { + padding: 0; + border: 0; + outline: none; + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; +} + +img { + display: block; +} + +.huwaa-cp-sr-only { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; +} + +.huwaa-cp-viewport, +.huwaa-cp-page, +.huwaa-cp-stage-wrap { + position: relative; + width: var(--huwaa-cp-page-width); + max-width: 768px; + min-height: 1297px; + margin: 0 auto; + overflow: hidden; + background: var(--huwaa-cp-page-bg); +} + +.huwaa-cp-stage-wrap { + min-height: 0; +} + +.huwaa-cp-stage { + position: absolute; + top: 0; + left: 0; + width: 1080px; + min-height: 3733px; + overflow: hidden; + direction: ltr; + transform: scale(var(--huwaa-cp-design-scale)); + transform-origin: top left; + background: #e98c9b; +} + +.huwaa-cp-stage > * { + position: absolute; +} + +.huwaa-cp-background { + top: 489px; + left: -487px; + z-index: 0; + width: 2415px; + height: 4293px; + opacity: 0.5; + mix-blend-mode: screen; + object-fit: cover; + pointer-events: none; +} + +.huwaa-cp-hero { + top: 0; + left: 0; + z-index: 1; + width: 1080px; + height: 1333px; + overflow: hidden; + pointer-events: none; +} + +.huwaa-cp-hero > img { + width: 1080px; + height: 1333px; + object-fit: cover; + -webkit-mask-image: linear-gradient( + to bottom, + #000 0, + #000 88.93%, + transparent 100% + ); + mask-image: linear-gradient( + to bottom, + #000 0, + #000 88.93%, + transparent 100% + ); +} + +.huwaa-cp-wordmark { + position: absolute; + top: 856px; + left: 20px; + z-index: 2; + display: grid; + width: 1040px; + min-height: 370px; + margin: 0; + place-items: center; + color: #ffe0b2; + font-family: Georgia, 'Times New Roman', serif; + font-size: 181px; + font-style: italic; + font-weight: 800; + letter-spacing: -8px; + line-height: 0.82; + overflow-wrap: normal; + paint-order: stroke fill; + text-align: center; + text-shadow: + 0 8px 0 #7f1d17, + 0 13px 22px rgba(91, 10, 25, 0.66), + 0 0 12px rgba(255, 248, 212, 0.78); + text-wrap: balance; + -webkit-text-stroke: 7px #b75f31; + unicode-bidi: plaintext; +} + +.huwaa-cp-countdown { + top: 1333px; + left: 52px; + z-index: 3; + width: 974px; + height: 106px; +} + +.huwaa-cp-time-unit { + position: absolute; + top: 0; + width: 223px; + height: 106px; +} + +.huwaa-cp-time-unit:nth-child(1) { + left: 0; +} + +.huwaa-cp-time-unit:nth-child(2) { + left: 250px; +} + +.huwaa-cp-time-unit:nth-child(3) { + left: 501px; +} + +.huwaa-cp-time-unit:nth-child(4) { + left: 756px; +} + +.huwaa-cp-time-unit img { + position: absolute; + top: 0; + left: 0; + width: 163px; + height: 106px; +} + +.huwaa-cp-time-unit strong { + position: absolute; + top: 25px; + left: 0; + z-index: 1; + width: 163px; + color: #fff; + font-size: 40px; + font-weight: 900; + line-height: 40px; + text-align: center; + text-shadow: + 0 2px 0 rgba(176, 47, 78, 0.52), + 0 0 12px rgba(255, 255, 255, 0.78); +} + +.huwaa-cp-time-unit span { + position: absolute; + top: 25px; + left: 194px; + width: 31px; + color: #fff; + font-size: 40px; + font-weight: 900; + line-height: 40px; + text-align: center; + text-shadow: 0 2px 0 rgba(176, 47, 78, 0.52); +} + +.huwaa-cp-tabs { + top: 1495px; + left: 39px; + z-index: 5; + display: grid; + width: 1000px; + height: 126px; + grid-template-columns: repeat(3, 303px); + column-gap: 46px; +} + +.huwaa-cp-tab { + position: relative; + width: 303px; + height: 126px; + color: rgba(161, 22, 52, 0.34); + font-size: 40px; + font-weight: 800; + line-height: 40px; + text-align: center; + text-shadow: 0 2px 1px rgba(255, 255, 255, 0.78); +} + +.huwaa-cp-tab img { + position: absolute; + inset: 0; + width: 303px; + height: 126px; + pointer-events: none; +} + +.huwaa-cp-tab span { + position: absolute; + top: 34px; + left: 20px; + width: 263px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + unicode-bidi: plaintext; +} + +html[lang='es'] .huwaa-cp-tab[data-tab='rewards'] { + font-size: 32px; +} + +.huwaa-cp-tab::after { + position: absolute; + inset: -12px 0; + content: ''; +} + +.huwaa-cp-tab.is-active { + color: #ff4766; + text-shadow: + 0 2px 0 rgba(255, 255, 255, 0.9), + 0 0 10px rgba(255, 139, 166, 0.36); +} + +.huwaa-cp-tab:focus-visible { + border-radius: 58px; + box-shadow: 0 0 0 8px rgba(255, 255, 255, 0.78); +} + +.huwaa-cp-tab-content { + top: 0; + left: 0; + z-index: 4; + width: 1080px; +} + +.huwaa-cp-copyright { + left: 114px; + z-index: 4; + width: 850px; + height: 30px; + color: #fff; + font-size: 24px; + font-weight: 600; + line-height: 30px; + text-align: center; + text-shadow: 0 2px 8px rgba(163, 48, 76, 0.3); +} + +.huwaa-cp-panel { + position: absolute; + left: 60px; + width: 960px; + border: 84px solid transparent; + border-image-source: url('./assets/layers/panel-skin.webp'); + border-image-slice: 110 fill; + border-image-width: 84px; + border-image-repeat: stretch; + color: var(--huwaa-cp-text); +} + +.huwaa-cp-panel-body { + position: absolute; + inset: -84px; +} + +.huwaa-cp-panel-title, +.huwaa-cp-panel-subtitle { + position: absolute; + left: 55px; + width: 850px; + margin: 0; + text-align: center; + unicode-bidi: plaintext; +} + +.huwaa-cp-panel-title { + top: 75px; + min-height: 45px; + color: #b82e2d; + font-size: 32px; + font-weight: 800; + line-height: 36px; +} + +.huwaa-cp-panel-subtitle { + top: 140px; + min-height: 27px; + color: var(--huwaa-cp-muted); + font-size: 24px; + font-weight: 600; + line-height: 30px; +} + +.huwaa-cp-panel.is-gift-panel .huwaa-cp-panel-title { + top: 75px; + line-height: 36px; +} + +.huwaa-cp-panel.is-gift-panel .huwaa-cp-panel-subtitle { + top: 166px; +} + +.huwaa-cp-list { + position: absolute; + top: 203px; + left: 60px; + display: grid; + width: 840px; + gap: 24px; +} + +.huwaa-cp-panel.is-gift-panel .huwaa-cp-list { + top: 229px; +} + +.huwaa-cp-row { + position: relative; + width: 840px; + height: 199px; + overflow: hidden; + border-radius: 30px; + background: var(--huwaa-cp-row); +} + +.huwaa-cp-avatar { + position: absolute; + z-index: 2; + display: grid; + width: 113px; + height: 113px; + place-items: center; + overflow: hidden; + border: 5px solid rgba(255, 235, 207, 0.96); + border-radius: 50%; + background: #f2b9c6; + box-shadow: + 0 0 0 3px rgba(183, 81, 64, 0.48), + 0 6px 14px rgba(143, 25, 52, 0.18); + color: #fff; + font-size: 34px; + font-weight: 800; +} + +.huwaa-cp-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.huwaa-cp-row .huwaa-cp-avatar.is-left { + top: 26px; + left: 58px; +} + +.huwaa-cp-row .huwaa-cp-avatar.is-right { + top: 26px; + left: 669px; +} + +.huwaa-cp-name { + position: absolute; + top: 147px; + z-index: 3; + width: 144px; + overflow: hidden; + color: var(--huwaa-cp-text); + font-size: 26px; + font-weight: 700; + line-height: 30px; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; + unicode-bidi: plaintext; +} + +.huwaa-cp-name.is-left { + left: 42px; +} + +.huwaa-cp-name.is-right { + left: 653px; +} + +.huwaa-cp-deco-line { + position: absolute; + top: 71px; + z-index: 1; + width: 159px; + height: 30px; +} + +.huwaa-cp-deco-line.is-left { + left: 336px; +} + +.huwaa-cp-deco-line.is-right { + left: 504px; +} + +.huwaa-cp-gift { + position: absolute; + top: 15px; + left: 344px; + z-index: 2; + width: 152px; + height: 152px; + object-fit: contain; +} + +.huwaa-cp-score-heart { + position: absolute; + top: 28px; + left: 308px; + z-index: 2; + width: 220px; + height: 143px; +} + +.huwaa-cp-score { + position: absolute; + top: 71px; + left: 359px; + z-index: 3; + width: 120px; + overflow: hidden; + color: #ff4460; + font-size: 30px; + font-weight: 800; + line-height: 36px; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; +} + +.huwaa-cp-state { + position: absolute; + top: 50%; + left: 110px; + width: 740px; + transform: translateY(-50%); + color: var(--huwaa-cp-muted); + font-size: 30px; + font-weight: 700; + line-height: 42px; + text-align: center; + unicode-bidi: plaintext; +} + +.huwaa-cp-state.is-loading::before { + display: inline-block; + width: 20px; + height: 20px; + margin-right: 14px; + border: 4px solid rgba(197, 45, 69, 0.2); + border-top-color: var(--huwaa-cp-brand); + border-radius: 50%; + content: ''; + animation: huwaa-cp-spin 0.8s linear infinite; + vertical-align: -2px; +} + +.huwaa-cp-retry { + position: relative; + display: block; + min-width: 200px; + height: 64px; + margin: 22px auto 0; + padding: 0 28px; + border-radius: 32px; + background: var(--hy-theme-button, #a868dc); + color: #fff; + font-size: 26px; + font-weight: 800; +} + +.huwaa-cp-retry::after { + position: absolute; + inset: -43px -20px; + content: ''; +} + +.huwaa-cp-retry:focus-visible { + box-shadow: 0 0 0 8px rgba(255, 255, 255, 0.82); +} + +.huwaa-cp-rank-podium { + position: absolute; + top: 1677px; + left: 25px; + width: 1030px; + height: 751px; +} + +.huwaa-cp-podium-art, +.huwaa-cp-podium-avatar, +.huwaa-cp-podium-name { + position: absolute; +} + +.huwaa-cp-podium-art { + z-index: 2; + pointer-events: none; +} + +.huwaa-cp-podium-avatar { + z-index: 1; + display: grid; + place-items: center; + overflow: hidden; + border-radius: 50%; + background: #efb2c1; + color: #fff; + font-weight: 900; + text-align: center; +} + +.huwaa-cp-podium-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.huwaa-cp-podium-name { + z-index: 3; + overflow: hidden; + color: #fff; + font-weight: 700; + text-align: center; + text-overflow: ellipsis; + text-shadow: 0 2px 4px rgba(144, 33, 59, 0.36); + white-space: nowrap; + unicode-bidi: plaintext; +} + +.huwaa-cp-podium.is-gold .huwaa-cp-podium-art { + top: 0; + left: 157px; + width: 716px; + height: 434px; +} + +.huwaa-cp-podium.is-gold .huwaa-cp-podium-avatar { + top: 119px; + width: 223px; + height: 223px; + font-size: 54px; +} + +.huwaa-cp-podium.is-gold .huwaa-cp-podium-avatar.is-left { + left: 236px; +} + +.huwaa-cp-podium.is-gold .huwaa-cp-podium-avatar.is-right { + left: 557px; +} + +.huwaa-cp-podium.is-gold .huwaa-cp-podium-name { + top: 386px; + width: 315px; + height: 50px; + background: rgba(188, 30, 65, 0.26); + font-size: 35px; + line-height: 49px; +} + +.huwaa-cp-podium.is-gold .huwaa-cp-podium-name.is-left { + left: 219px; +} + +.huwaa-cp-podium.is-gold .huwaa-cp-podium-name.is-right { + left: 497px; +} + +.huwaa-cp-podium.is-silver .huwaa-cp-podium-art, +.huwaa-cp-podium.is-bronze .huwaa-cp-podium-art { + top: 467px; + width: 500px; + height: 284px; +} + +.huwaa-cp-podium.is-silver .huwaa-cp-podium-art { + left: 0; +} + +.huwaa-cp-podium.is-bronze .huwaa-cp-podium-art { + top: 465px; + left: 530px; + height: 286px; +} + +.huwaa-cp-podium.is-silver .huwaa-cp-podium-avatar, +.huwaa-cp-podium.is-bronze .huwaa-cp-podium-avatar { + top: 550px; + width: 153px; + height: 153px; + font-size: 40px; +} + +.huwaa-cp-podium.is-silver .huwaa-cp-podium-avatar.is-left { + left: 62px; +} + +.huwaa-cp-podium.is-silver .huwaa-cp-podium-avatar.is-right { + left: 281px; +} + +.huwaa-cp-podium.is-bronze .huwaa-cp-podium-avatar.is-left { + left: 598px; +} + +.huwaa-cp-podium.is-bronze .huwaa-cp-podium-avatar.is-right { + left: 812px; +} + +.huwaa-cp-podium.is-silver .huwaa-cp-podium-name, +.huwaa-cp-podium.is-bronze .huwaa-cp-podium-name { + top: 716px; + width: 172px; + height: 34px; + background: rgba(188, 30, 65, 0.22); + font-size: 24px; + line-height: 34px; +} + +.huwaa-cp-podium.is-silver .huwaa-cp-podium-name.is-left { + left: 94px; +} + +.huwaa-cp-podium.is-silver .huwaa-cp-podium-name.is-right { + left: 281px; +} + +.huwaa-cp-podium.is-bronze .huwaa-cp-podium-name.is-left { + left: 624px; +} + +.huwaa-cp-podium.is-bronze .huwaa-cp-podium-name.is-right { + left: 811px; +} + +.huwaa-cp-rank-empty { + position: absolute; + top: 1990px; + left: 170px; + z-index: 4; + width: 740px; + color: #fff; + font-size: 30px; + font-weight: 800; + line-height: 42px; + text-align: center; + text-shadow: 0 2px 8px rgba(161, 21, 47, 0.42); +} + +.huwaa-cp-reward-panel .huwaa-cp-panel-body { + overflow: hidden; +} + +.huwaa-cp-reward-crown { + position: absolute; + top: 75px; + left: 399px; + width: 162px; + height: 144px; + object-fit: contain; +} + +.huwaa-cp-reward-grid { + position: absolute; + top: 260px; + left: 81px; + display: grid; + width: 800px; + height: 604px; + overflow-x: hidden; + overflow-y: auto; + grid-template-columns: repeat(3, 200px); + grid-auto-rows: 272px; + column-gap: 100px; + row-gap: 60px; + scrollbar-width: none; +} + +.huwaa-cp-reward-grid::-webkit-scrollbar { + display: none; +} + +.huwaa-cp-reward-card { + position: relative; + width: 200px; + height: 272px; + color: var(--huwaa-cp-text); + text-align: center; +} + +.huwaa-cp-reward-icon { + position: relative; + width: 200px; + height: 200px; + overflow: hidden; + border-radius: 26px; + background: rgba(197, 45, 69, 0.11); +} + +.huwaa-cp-reward-frame, +.huwaa-cp-reward-cover { + position: absolute; + object-fit: contain; +} + +.huwaa-cp-reward-frame { + inset: 0; + z-index: 2; + width: 200px; + height: 200px; +} + +.huwaa-cp-reward-cover { + top: 48px; + left: 48px; + z-index: 1; + width: 104px; + height: 104px; + border-radius: 18px; +} + +.huwaa-cp-reward-name { + display: -webkit-box; + max-height: 60px; + min-height: 36px; + margin-top: 19px; + overflow-wrap: anywhere; + font-size: 24px; + font-weight: 700; + line-height: 30px; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + unicode-bidi: plaintext; +} + +.huwaa-cp-reward-note { + display: -webkit-box; + max-height: 44px; + margin-top: -2px; + color: var(--huwaa-cp-muted); + font-size: 20px; + font-weight: 600; + line-height: 22px; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + unicode-bidi: plaintext; +} + +@keyframes huwaa-cp-spin { + to { + transform: rotate(1turn); + } +} + +@media (prefers-reduced-motion: reduce) { + .huwaa-cp-state.is-loading::before { + animation: none; + } +} diff --git a/activity/cp/huwaa/huwaa.js b/activity/cp/huwaa/huwaa.js new file mode 100644 index 0000000..2df86dd --- /dev/null +++ b/activity/cp/huwaa/huwaa.js @@ -0,0 +1,964 @@ +(function () { + 'use strict'; + + var DESIGN_WIDTH = 1080; + var MAX_PAGE_WIDTH = 768; + var ASSET_BASE = './huwaa/assets/layers/'; + var PODIUM_ASSETS = { + gold: 'podium-gold.webp', + silver: 'podium-silver.webp', + bronze: 'podium-bronze.webp', + }; + var CROWN_ASSETS = { + 1: 'crown-gold.webp', + 2: 'crown-silver.webp', + 3: 'crown-bronze.webp', + }; + var STAGE_HEIGHTS = { + plaza: 4283, + rank: 3733, + rewards: 4782, + }; + var PANEL_LAYOUT = { + plazaFirstTop: 1677, + plazaFirstHeight: 1168, + plazaSecondTop: 2904, + plazaSecondHeight: 1142, + rankPanelTop: 2484, + rankPanelHeight: 1142, + rewardFirstTop: 1677, + rewardPanelHeight: 960, + rewardPanelGap: 56, + }; + + var viewport = document.getElementById('huwaaCpViewport'); + var page = document.getElementById('huwaaCpPage'); + var stage = document.getElementById('huwaaCpStage'); + var stageWrap = document.getElementById('huwaaCpStageWrap'); + var tabContent = document.getElementById('huwaaCpTabContent'); + var copyright = document.querySelector('.huwaa-cp-copyright'); + var announcement = document.getElementById('huwaaCpAnnouncement'); + var tabs = Array.prototype.slice.call( + document.querySelectorAll('.huwaa-cp-tab') + ); + + var requestGeneration = 0; + var countdownEndAt = 0; + var countdownTimer = 0; + var serverTimeOffsetMS = 0; + var activeTab = resolveInitialTab(); + var activityData = { + statusState: 'loading', + plazaState: 'loading', + rank: [], + previousRows: [], + plazaRows: [], + rewards: [], + enabled: true, + }; + + function t(key, fallback) { + if (window.HyAppI18n && window.HyAppI18n.t) { + return window.HyAppI18n.t(key, fallback); + } + return fallback || key; + } + + function escapeHTML(value) { + return String(value == null ? '' : value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function safeImageURL(value) { + var source = String(value || '').trim(); + if (!source) return ''; + if (/^(https?:|data:image\/|blob:|\.\.?\/)/i.test(source)) { + return escapeHTML(source); + } + return ''; + } + + function resolveInitialTab() { + try { + var requested = new URLSearchParams(window.location.search).get( + 'tab' + ); + if (['plaza', 'rank', 'rewards'].indexOf(requested) >= 0) { + return requested; + } + } catch (_) { + // 无 URLSearchParams 的旧 WebView 使用设计稿默认的 CP Rank,不影响核心接口加载。 + } + return 'rank'; + } + + function isLocalRuntime() { + try { + var params = new URLSearchParams(window.location.search); + var hostname = String(window.location.hostname || '').toLowerCase(); + return ( + hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname === '::1' || + params.get('env') === 'local' + ); + } catch (_) { + return false; + } + } + + function isExplicitMockMode() { + try { + return ( + isLocalRuntime() && + new URLSearchParams(window.location.search).get('mock') === '1' + ); + } catch (_) { + return false; + } + } + + function twoDigits(value) { + return String(Math.max(0, Number(value) || 0)).padStart(2, '0'); + } + + function resolveExplicitEndAt() { + if (!isLocalRuntime()) return 0; + try { + var params = new URLSearchParams(window.location.search); + var parsed = Date.parse( + params.get('end_at') || params.get('endAt') || '' + ); + return Number.isFinite(parsed) && parsed > Date.now() ? parsed : 0; + } catch (_) { + return 0; + } + } + + function nowMS() { + return Date.now() + serverTimeOffsetMS; + } + + function getTimeParts() { + var diff = + countdownEndAt > 0 ? Math.max(0, countdownEndAt - nowMS()) : 0; + var totalSeconds = Math.floor(diff / 1000); + return { + days: Math.floor(totalSeconds / 86400), + hours: Math.floor((totalSeconds % 86400) / 3600), + minutes: Math.floor((totalSeconds % 3600) / 60), + seconds: totalSeconds % 60, + }; + } + + function updateCountdown() { + var parts = getTimeParts(); + Object.keys(parts).forEach(function (key) { + document + .querySelectorAll('[data-count="' + key + '"]') + .forEach(function (node) { + node.textContent = twoDigits(parts[key]); + }); + }); + } + + function initials(name) { + var normalized = String(name || '').trim(); + return normalized ? normalized.charAt(0).toUpperCase() : 'CP'; + } + + function renderAvatar(className, avatar, name) { + var imageURL = safeImageURL(avatar); + return [ + '', + ].join(''); + } + + function renderPodiumAvatar(className, avatar, name) { + var imageURL = safeImageURL(avatar); + return [ + '', + ].join(''); + } + + function renderState(state, retryScope) { + var key = + state === 'error' + ? 'cp.error' + : state === 'loading' + ? 'cp.loading' + : state === 'disabled' + ? 'cp.disabled' + : state === 'unavailable' + ? 'cp.unavailable' + : 'cp.empty'; + var fallback = + state === 'error' + ? 'Unable to load activity data.' + : state === 'loading' + ? 'Loading...' + : state === 'disabled' + ? 'This activity is not available.' + : state === 'unavailable' + ? 'No data source is available yet.' + : 'No data yet.'; + return [ + '
', + '', + escapeHTML(t(key, fallback)), + '', + retryScope + ? '' + : '', + '
', + ].join(''); + } + + function renderPanel(options) { + var classes = ['huwaa-cp-panel']; + if (options.className) classes.push(options.className); + return [ + '
', + '
', + options.body, + '
', + '
', + ].join(''); + } + + function renderPanelHeader( + titleKey, + titleFallback, + subtitleKey, + subtitleFallback + ) { + return [ + '

', + escapeHTML(t(titleKey, titleFallback)), + '

', + subtitleKey + ? '

' + + escapeHTML(t(subtitleKey, subtitleFallback)) + + '

' + : '', + ].join(''); + } + + function renderCPRow(row) { + var center = + row.type === 'gift' + ? '' + : [ + '', + '', + escapeHTML(row.score), + '', + ].join(''); + return [ + '
', + renderAvatar('is-left', row.leftAvatar, row.leftName), + renderAvatar('is-right', row.rightAvatar, row.rightName), + '', + escapeHTML(row.leftName), + '', + '', + escapeHTML(row.rightName), + '', + '', + '', + center, + '
', + ].join(''); + } + + function rowsOrState(rows, state, retryScope) { + if (rows.length) { + return ( + '
' + + rows.map(renderCPRow).join('') + + '
' + ); + } + return renderState( + state === 'ready' ? 'empty' : state, + state === 'error' ? retryScope : '' + ); + } + + function renderPreviousPanel(top, height) { + var body = + renderPanelHeader( + 'cp.previousWeekTopTitle', + 'Previous Top 3', + 'cp.previousWeekTopSubtitle', + '(Top CPs from the previous completed week.)' + ) + + rowsOrState( + activityData.previousRows, + activityData.statusState, + 'status' + ); + return renderPanel({ top: top, height: height, body: body }); + } + + function renderPlaza() { + var firstBody = + renderPanelHeader( + 'cp.huwaaPlazaGiftTitle', + 'Gifts exchanged between CPs ≥ 5000 gold will be displayed here', + 'cp.plazaGiftSubtitle', + '(Display the last 10 records, sorted in ascending order.)' + ) + + rowsOrState(activityData.plazaRows, activityData.plazaState, ''); + return [ + renderPanel({ + top: PANEL_LAYOUT.plazaFirstTop, + height: PANEL_LAYOUT.plazaFirstHeight, + className: 'is-gift-panel', + body: firstBody, + }), + renderPreviousPanel( + PANEL_LAYOUT.plazaSecondTop, + PANEL_LAYOUT.plazaSecondHeight + ), + ].join(''); + } + + function renderPodiumPair(item, place) { + if (!item) return ''; + var level = place === 1 ? 'gold' : place === 2 ? 'silver' : 'bronze'; + var accessibleLabel = [ + t('cp.rankPlace', 'Rank'), + String(place), + item.leftName, + item.rightName, + ].join(' '); + return [ + '
', + renderPodiumAvatar('is-left', item.leftAvatar, item.leftName), + renderPodiumAvatar('is-right', item.rightAvatar, item.rightName), + '', + '', + escapeHTML(item.leftName), + '', + '', + escapeHTML(item.rightName), + '', + '
', + ].join(''); + } + + function renderRank() { + var podium = ''; + if (activityData.rank.length) { + var rankAt = function (place) { + return activityData.rank.find(function (item) { + return item.place === place; + }); + }; + podium = [ + '
', + renderPodiumPair(rankAt(1), 1), + renderPodiumPair(rankAt(2), 2), + renderPodiumPair(rankAt(3), 3), + '
', + ].join(''); + } else { + var rankStateKey = + activityData.statusState === 'error' + ? 'cp.error' + : activityData.statusState === 'loading' + ? 'cp.loading' + : activityData.statusState === 'disabled' + ? 'cp.disabled' + : 'cp.empty'; + var rankStateFallback = + activityData.statusState === 'error' + ? 'Unable to load activity data.' + : activityData.statusState === 'loading' + ? 'Loading...' + : activityData.statusState === 'disabled' + ? 'This activity is not available.' + : 'No data yet.'; + podium = [ + '

', + escapeHTML(t(rankStateKey, rankStateFallback)), + '

', + ].join(''); + } + return ( + podium + + renderPreviousPanel( + PANEL_LAYOUT.rankPanelTop, + PANEL_LAYOUT.rankPanelHeight + ) + ); + } + + function rewardName(item) { + var resourceName = + item.resourceName || + item.name || + item.resource_name || + localizedResourceType(item.resource_type || item.resourceType) || + t('cp.reward', 'Reward'); + var duration = Number(item.duration_days || item.durationDays || 0); + var quantity = Number(item.quantity || 0); + if (duration > 0) { + return ( + resourceName + '*' + duration + ' ' + t('cp.daysLong', 'days') + ); + } + if (quantity > 0) return resourceName + '*' + quantity; + return resourceName; + } + + function localizedResourceType(value) { + var type = String(value || '') + .trim() + .toLowerCase(); + var keys = { + avatar_frame: ['cp.rewardAvatarFrame', 'Avatar frame'], + profile_card: ['cp.rewardProfileCard', 'Profile card'], + coin: ['cp.rewardCoins', 'Coins'], + vehicle: ['cp.rewardVehicle', 'Vehicle'], + chat_bubble: ['cp.rewardChatBubble', 'Chat bubble'], + badge: ['cp.rewardBadge', 'Badge'], + floating_screen: ['cp.rewardFloatingScreen', 'Floating screen'], + gift: ['cp.rewardGift', 'Gift'], + mic_seat_icon: ['cp.rewardMicSeatIcon', 'Mic seat icon'], + mic_seat_animation: [ + 'cp.rewardMicSeatAnimation', + 'Mic seat animation', + ], + emoji_pack: ['cp.rewardEmojiPack', 'Emoji pack'], + vip_trial_card: ['cp.rewardVipTrialCard', 'VIP trial card'], + }; + return keys[type] ? t(keys[type][0], keys[type][1]) : ''; + } + + function rewardNote(item) { + return String(item.description || item.note || '').trim(); + } + + function renderRewardItem(item) { + var cover = safeImageURL(item.coverUrl || item.cover_url); + return [ + '
', + '
', + cover + ? '' + : '', + '', + '
', + '
', + escapeHTML(item.displayName || rewardName(item)), + '
', + item.note || rewardNote(item) + ? '
' + + escapeHTML(item.note || rewardNote(item)) + + '
' + : '', + '
', + ].join(''); + } + + function renderRewardPanel(section, index) { + var tierNo = Number(section.rankNo || index + 1); + if (tierNo < 1 || tierNo > 3) tierNo = index + 1; + var top = + PANEL_LAYOUT.rewardFirstTop + + index * + (PANEL_LAYOUT.rewardPanelHeight + PANEL_LAYOUT.rewardPanelGap); + var items = Array.isArray(section.items) ? section.items : []; + var tierLabels = { + 1: t('cp.rewardTierGold', 'First-place rewards'), + 2: t('cp.rewardTierSilver', 'Second-place rewards'), + 3: t('cp.rewardTierBronze', 'Third-place rewards'), + }; + var body = [ + '', + items.length + ? '
' + + items.map(renderRewardItem).join('') + + '
' + : renderState('empty', ''), + ].join(''); + return renderPanel({ + top: top, + height: PANEL_LAYOUT.rewardPanelHeight, + className: 'huwaa-cp-reward-panel', + ariaLabel: tierLabels[tierNo], + body: body, + }); + } + + function renderRewards() { + if (!activityData.rewards.length) { + var state = + activityData.statusState === 'ready' + ? 'empty' + : activityData.statusState; + return renderPanel({ + top: PANEL_LAYOUT.rewardFirstTop, + height: PANEL_LAYOUT.rewardPanelHeight, + className: 'huwaa-cp-reward-panel', + body: renderState(state, state === 'error' ? 'status' : ''), + }); + } + return [1, 2, 3] + .map(function (rankNo) { + return ( + activityData.rewards.find(function (section) { + return section.rankNo === rankNo; + }) || { rankNo: rankNo, items: [] } + ); + }) + .map(renderRewardPanel) + .join(''); + } + + function formatScore(value) { + var number = Number(value || 0); + if (!Number.isFinite(number)) return '0'; + try { + return number.toLocaleString(document.documentElement.lang || 'en'); + } catch (_) { + return String(number); + } + } + + function normalizePairUser(user) { + var source = user || {}; + var userID = source.user_id || source.userId || ''; + var displayID = source.display_user_id || source.displayUserId || ''; + var name = source.username || source.name || displayID || userID || ''; + if (!name && !source.avatar) return null; + return { + name: name || 'CP', + avatar: source.avatar || source.avatar_url || '', + }; + } + + function normalizeRankEntry(entry, fallbackPlace) { + var source = entry || {}; + var userA = normalizePairUser(source.user_a || source.userA); + var userB = normalizePairUser(source.user_b || source.userB); + if (!userA || !userB) return null; + return { + type: 'score', + place: Number(source.rank || fallbackPlace || 0), + score: formatScore(source.score || source.intimacy_value || 0), + leftName: userA.name, + rightName: userB.name, + leftAvatar: userA.avatar, + rightAvatar: userB.avatar, + }; + } + + function normalizeRewardSection(section, fallbackRankNo) { + var source = section || {}; + var group = source.resource_group || source.resourceGroup || {}; + var groupItems = Array.isArray(group.items) ? group.items : []; + var items = Array.isArray(source.items) ? source.items : []; + return { + rankNo: Number( + source.rank_no || source.rankNo || fallbackRankNo || 0 + ), + items: items.map(function (item, index) { + var groupItem = groupItems[index] || {}; + var resource = groupItem.resource || {}; + return { + resourceName: String(resource.name || '').trim(), + resource_type: + item.resource_type || + item.resourceType || + resource.resource_type || + resource.resourceType || + '', + duration_days: item.duration_days || item.durationDays || 0, + quantity: item.quantity || groupItem.quantity || 0, + coverUrl: + item.cover_url || + item.coverUrl || + resource.preview_url || + resource.previewUrl || + resource.asset_url || + resource.assetUrl || + '', + }; + }), + }; + } + + function applyActivityStatus(status) { + var source = status || {}; + var serverTimeMS = Number( + source.server_time_ms || source.serverTimeMS || 0 + ); + if (serverTimeMS > 0) { + serverTimeOffsetMS = serverTimeMS - Date.now(); + } + activityData.enabled = source.enabled !== false; + if (!activityData.enabled) { + countdownEndAt = 0; + activityData.rank = []; + activityData.previousRows = []; + activityData.rewards = []; + activityData.statusState = 'disabled'; + return; + } + var endMS = Number( + (source.period && (source.period.end_ms || source.period.endMS)) || + source.end_ms || + source.endMS || + 0 + ); + countdownEndAt = endMS > nowMS() ? endMS : 0; + + activityData.rank = Array.isArray(source.entries) + ? source.entries + .map(function (entry, index) { + return normalizeRankEntry(entry, index + 1); + }) + .filter(Boolean) + .sort(function (left, right) { + return left.place - right.place; + }) + .slice(0, 3) + : []; + activityData.previousRows = Array.isArray(source.previous_entries) + ? source.previous_entries + .map(function (entry, index) { + return normalizeRankEntry(entry, index + 1); + }) + .filter(Boolean) + .slice(0, 3) + : []; + activityData.rewards = Array.isArray(source.rewards) + ? source.rewards + .map(function (section, index) { + return normalizeRewardSection(section, index + 1); + }) + .filter(function (section) { + return section.rankNo >= 1 && section.rankNo <= 3; + }) + .sort(function (left, right) { + return left.rankNo - right.rankNo; + }) + : []; + activityData.statusState = 'ready'; + } + + function applyMockData() { + var pairs = [ + ['Avery', 'Noor', 126580], + ['Mina', 'Elif', 118420], + ['Lina', 'Sofia', 109730], + ['Dara', 'Maya', 98760], + ]; + function pair(row, index, type) { + return { + type: type || 'score', + place: index + 1, + score: formatScore(row[2]), + rawScore: row[2], + leftName: row[0], + rightName: row[1], + leftAvatar: '', + rightAvatar: '', + }; + } + activityData.rank = pairs.slice(0, 3).map(function (row, index) { + return pair(row, index, 'score'); + }); + activityData.previousRows = pairs + .slice(0, 3) + .map(function (row, index) { + return pair(row, index, 'score'); + }); + activityData.plazaRows = pairs.map(function (row, index) { + return pair(row, index, 'gift'); + }); + activityData.rewards = ['gold', 'silver', 'bronze'].map( + function (_, tier) { + return { + rankNo: tier + 1, + items: [ + { + displayName: t('cp.rewardMedal7', 'Medal*7 days'), + note: + tier === 0 + ? t( + 'cp.rewardMedalNote', + '(Permanent medal after getting 3 times)' + ) + : '', + }, + { + displayName: t( + 'cp.rewardHonorBanner7', + 'Honor banner*7 days' + ), + }, + { + displayName: t( + 'cp.rewardHeadwear7', + 'Headwear*7days' + ), + }, + { displayName: t('cp.rewardMount7', 'Mount*7days') }, + { + displayName: t( + 'cp.rewardCoins20000', + 'Coins*20000' + ), + }, + { displayName: t('cp.rewardDuke7', 'Duke*7days') }, + ], + }; + } + ); + activityData.statusState = 'ready'; + activityData.plazaState = 'ready'; + activityData.enabled = true; + serverTimeOffsetMS = 0; + countdownEndAt = Date.now() + 2 * 86400000 + 2 * 3600000 + 2 * 60000; + } + + function loadData(scope) { + var generation = ++requestGeneration; + if (isExplicitMockMode()) { + applyMockData(); + render(); + return Promise.resolve(); + } + + if (!scope || scope === 'status') { + activityData.statusState = 'loading'; + activityData.rank = []; + activityData.previousRows = []; + activityData.rewards = []; + } + // 设计稿的 Plaza 是“单笔 CP 送礼事件流”;当前公开 intimacy-leaderboard 是累计亲密榜, + // 两者排序和业务含义不同。在后端事件 feed 上线前保持明确不可用态,绝不把累计榜伪装成送礼记录。 + activityData.plazaState = 'unavailable'; + activityData.plazaRows = []; + render(); + + var statusRequest = + window.HyAppAPI && window.HyAppAPI.cpWeeklyRank + ? window.HyAppAPI.cpWeeklyRank.status({ limit: 3 }) + : Promise.reject(new Error('cp_weekly_rank_api_unavailable')); + + // allSettled 统一承接未来可并行加入的 Plaza 事件 feed;generation 防止重试后的旧周榜响应覆盖新状态。 + return Promise.allSettled([statusRequest]).then(function (results) { + if (generation !== requestGeneration) return; + if (results[0].status === 'fulfilled') { + applyActivityStatus(results[0].value); + } else { + activityData.statusState = 'error'; + } + render(); + }); + } + + function applyTabState() { + tabs.forEach(function (tab) { + var isActive = tab.getAttribute('data-tab') === activeTab; + tab.classList.toggle('is-active', isActive); + tab.setAttribute('aria-selected', String(isActive)); + tab.setAttribute('tabindex', isActive ? '0' : '-1'); + var image = tab.querySelector('img'); + if (image) { + image.src = + ASSET_BASE + + (isActive ? 'tab-active.webp' : 'tab-inactive.webp'); + } + }); + var activeButton = tabs.find(function (tab) { + return tab.getAttribute('data-tab') === activeTab; + }); + tabContent.setAttribute( + 'aria-labelledby', + activeButton ? activeButton.id : 'huwaaCpTabRank' + ); + page.setAttribute('data-tab', activeTab); + } + + function getPageWidth() { + var viewportWidth = + window.innerWidth || + document.documentElement.clientWidth || + DESIGN_WIDTH; + return Math.min(Math.max(viewportWidth, 1), MAX_PAGE_WIDTH); + } + + function updateStageHeight() { + var designHeight = STAGE_HEIGHTS[activeTab] || STAGE_HEIGHTS.rank; + // Rewards 的完整 4782px 高度仅对应三档奖励都存在的设计态。加载失败、活动关闭或确实为空时 + // 只有首块状态面板;按面板底部收拢页面,避免把不存在的二、三档内容伪装成长段空白。 + if (activeTab === 'rewards' && !activityData.rewards.length) { + designHeight = + PANEL_LAYOUT.rewardFirstTop + + PANEL_LAYOUT.rewardPanelHeight + + 160; + } + var pageWidth = getPageWidth(); + var designScale = pageWidth / DESIGN_WIDTH; + stage.style.height = designHeight + 'px'; + stage.style.minHeight = designHeight + 'px'; + document.documentElement.style.setProperty( + '--huwaa-cp-page-width', + pageWidth + 'px' + ); + document.documentElement.style.setProperty( + '--huwaa-cp-design-scale', + String(designScale) + ); + stageWrap.style.height = Math.ceil(designHeight * designScale) + 'px'; + stageWrap.style.minHeight = stageWrap.style.height; + copyright.style.top = designHeight - 77 + 'px'; + document.body.style.minHeight = stageWrap.style.height; + viewport.style.minHeight = stageWrap.style.height; + page.style.minHeight = stageWrap.style.height; + } + + function render() { + document.title = t('cp.pageTitle', 'Last Weekly CP'); + tabContent.innerHTML = + activeTab === 'plaza' + ? renderPlaza() + : activeTab === 'rewards' + ? renderRewards() + : renderRank(); + applyTabState(); + updateStageHeight(); + updateCountdown(); + } + + function updateURLTab() { + try { + var nextURL = new URL(window.location.href); + nextURL.searchParams.set('tab', activeTab); + window.history.replaceState(null, '', nextURL.toString()); + } catch (_) { + // tab 切换本身不依赖 URL;history 被宿主禁用时仅放弃可分享状态参数。 + } + } + + function selectTab(nextTab, shouldFocus) { + if (['plaza', 'rank', 'rewards'].indexOf(nextTab) < 0) return; + activeTab = nextTab; + updateURLTab(); + render(); + var activeButton = tabs.find(function (tab) { + return tab.getAttribute('data-tab') === activeTab; + }); + if (shouldFocus && activeButton) activeButton.focus(); + announcement.textContent = + activeButton && activeButton.querySelector('span') + ? activeButton.querySelector('span').textContent + : ''; + } + + function bindInteractions() { + tabs.forEach(function (tab, index) { + tab.addEventListener('click', function () { + selectTab(tab.getAttribute('data-tab') || 'rank', false); + }); + tab.addEventListener('keydown', function (event) { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') + return; + event.preventDefault(); + var delta = event.key === 'ArrowRight' ? 1 : -1; + var nextIndex = (index + delta + tabs.length) % tabs.length; + selectTab(tabs[nextIndex].getAttribute('data-tab'), true); + }); + }); + tabContent.addEventListener('click', function (event) { + var retry = event.target.closest('[data-retry]'); + if (retry) loadData(retry.getAttribute('data-retry') || 'status'); + }); + } + + function init() { + countdownEndAt = resolveExplicitEndAt(); + bindInteractions(); + render(); + loadData(); + countdownTimer = window.setInterval(updateCountdown, 1000); + window.addEventListener('resize', updateStageHeight); + window.addEventListener('hyapp:i18n-ready', function () { + if (isExplicitMockMode()) applyMockData(); + render(); + }); + } + + window.addEventListener('beforeunload', function () { + requestGeneration += 1; + if (countdownTimer) window.clearInterval(countdownTimer); + }); + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/activity/invite/huwaa.html b/activity/invite/huwaa.html new file mode 100644 index 0000000..10eca05 --- /dev/null +++ b/activity/invite/huwaa.html @@ -0,0 +1,424 @@ + + + + + + Cash Wheel + + + + + +
+
+ + + + +
+ +

Cash Wheel

+
+ +
+ + + + + +
+ + 89,094 +
+ ≈$8.9 +

+ Points Left To Withdraw: + 1,000 +

+ +
+ +
+ + + + +
+ + + + + + + +
+ + +
+ + + +
+ +
+
+ + + + +
+
+ + + + + + + + + + + + + diff --git a/activity/invite/huwaa/assets/layers/bg-palace-mask.svg b/activity/invite/huwaa/assets/layers/bg-palace-mask.svg new file mode 100644 index 0000000..458fbff --- /dev/null +++ b/activity/invite/huwaa/assets/layers/bg-palace-mask.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/activity/invite/huwaa/assets/layers/bg-palace.webp b/activity/invite/huwaa/assets/layers/bg-palace.webp new file mode 100644 index 0000000..8ad8add Binary files /dev/null and b/activity/invite/huwaa/assets/layers/bg-palace.webp differ diff --git a/activity/invite/huwaa/assets/layers/button-awesome.webp b/activity/invite/huwaa/assets/layers/button-awesome.webp new file mode 100644 index 0000000..7bbd01f Binary files /dev/null and b/activity/invite/huwaa/assets/layers/button-awesome.webp differ diff --git a/activity/invite/huwaa/assets/layers/button-invite.webp b/activity/invite/huwaa/assets/layers/button-invite.webp new file mode 100644 index 0000000..d009890 Binary files /dev/null and b/activity/invite/huwaa/assets/layers/button-invite.webp differ diff --git a/activity/invite/huwaa/assets/layers/button-withdraw.webp b/activity/invite/huwaa/assets/layers/button-withdraw.webp new file mode 100644 index 0000000..11d7c2f Binary files /dev/null and b/activity/invite/huwaa/assets/layers/button-withdraw.webp differ diff --git a/activity/invite/huwaa/assets/layers/chest-blue.webp b/activity/invite/huwaa/assets/layers/chest-blue.webp new file mode 100644 index 0000000..f57704c Binary files /dev/null and b/activity/invite/huwaa/assets/layers/chest-blue.webp differ diff --git a/activity/invite/huwaa/assets/layers/chest-gold.webp b/activity/invite/huwaa/assets/layers/chest-gold.webp new file mode 100644 index 0000000..b9e1cbd Binary files /dev/null and b/activity/invite/huwaa/assets/layers/chest-gold.webp differ diff --git a/activity/invite/huwaa/assets/layers/chest-green.webp b/activity/invite/huwaa/assets/layers/chest-green.webp new file mode 100644 index 0000000..49f9798 Binary files /dev/null and b/activity/invite/huwaa/assets/layers/chest-green.webp differ diff --git a/activity/invite/huwaa/assets/layers/chest-orange.webp b/activity/invite/huwaa/assets/layers/chest-orange.webp new file mode 100644 index 0000000..bfde91b Binary files /dev/null and b/activity/invite/huwaa/assets/layers/chest-orange.webp differ diff --git a/activity/invite/huwaa/assets/layers/chest-purple.webp b/activity/invite/huwaa/assets/layers/chest-purple.webp new file mode 100644 index 0000000..8cda74c Binary files /dev/null and b/activity/invite/huwaa/assets/layers/chest-purple.webp differ diff --git a/activity/invite/huwaa/assets/layers/chest-red.webp b/activity/invite/huwaa/assets/layers/chest-red.webp new file mode 100644 index 0000000..ccc2561 Binary files /dev/null and b/activity/invite/huwaa/assets/layers/chest-red.webp differ diff --git a/activity/invite/huwaa/assets/layers/chest-white-gold.webp b/activity/invite/huwaa/assets/layers/chest-white-gold.webp new file mode 100644 index 0000000..c5734f9 Binary files /dev/null and b/activity/invite/huwaa/assets/layers/chest-white-gold.webp differ diff --git a/activity/invite/huwaa/assets/layers/icon-convert.svg b/activity/invite/huwaa/assets/layers/icon-convert.svg new file mode 100644 index 0000000..f82936a --- /dev/null +++ b/activity/invite/huwaa/assets/layers/icon-convert.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/activity/invite/huwaa/assets/layers/icon-finance.svg b/activity/invite/huwaa/assets/layers/icon-finance.svg new file mode 100644 index 0000000..089ab90 --- /dev/null +++ b/activity/invite/huwaa/assets/layers/icon-finance.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/activity/invite/huwaa/assets/layers/icon-infinity.svg b/activity/invite/huwaa/assets/layers/icon-infinity.svg new file mode 100644 index 0000000..df2ca28 --- /dev/null +++ b/activity/invite/huwaa/assets/layers/icon-infinity.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/activity/invite/huwaa/assets/layers/manifest.json b/activity/invite/huwaa/assets/layers/manifest.json new file mode 100644 index 0000000..b56363c --- /dev/null +++ b/activity/invite/huwaa/assets/layers/manifest.json @@ -0,0 +1,430 @@ +{ + "schemaVersion": 1, + "figma": { + "fileKey": "OTOg5cwbhVNWxkImGmzTJl", + "canvas": { + "id": "543:1998", + "name": "Huwaa运营" + }, + "entryFrames": { + "default": "850:232", + "win": "850:431", + "estimatedRewards": "850:638" + }, + "designSize": { + "width": 375, + "height": 1114 + }, + "forbiddenAggregateNodes": [ + "543:1998", + "850:232", + "850:431", + "850:638", + "850:237", + "850:255", + "850:262", + "850:291", + "850:314", + "850:425", + "850:628", + "850:835" + ] + }, + "assets": [ + { + "file": "bg-palace-mask.svg", + "sourceNode": "850:234", + "sourceName": "image 5044", + "sourceType": "ROUNDED_RECTANGLE", + "purpose": "Bottom fade mask for the palace-only background layer", + "bounds": { "width": 541, "height": 812 }, + "original": { "width": 541, "height": 812 }, + "exportFormat": "svg", + "bytes": 542, + "sha256": "f99c7cf545fa81ef21174ea786fa32485a2efa0bc0e0b223b04f13e29255c8cf", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "bg-palace.webp", + "sourceNode": "850:235", + "sourceNodes": ["850:235", "850:236"], + "sourceName": "image 5046", + "sourceType": "ROUNDED_RECTANGLE", + "purpose": "Palace-only decorative background reused by both half-opacity Figma layers", + "bounds": { "width": 541, "height": 812 }, + "original": { "width": 1024, "height": 1536 }, + "exportFormat": "webp-lossless", + "bytes": 2406896, + "sha256": "fb80cada233c7ebcf37aa5a0fa16c92f836db8aa7a05e7b8782426273563ddb6", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "button-awesome.webp", + "sourceNode": "850:636", + "sourceName": "图层 1 9", + "sourceType": "RECTANGLE", + "purpose": "Cropped AWESOME button skin without its DOM label", + "bounds": { "width": 250, "height": 53 }, + "original": { "width": 250, "height": 53 }, + "exportFormat": "webp-lossless", + "bytes": 13646, + "sha256": "a40f8c77ed9f78921e857dc530e032997d964e396207cff931934e46f9f93888", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "button-invite.webp", + "sourceNode": "850:426", + "sourceName": "图层 0 315", + "sourceType": "RECTANGLE", + "purpose": "Invite CTA skin without its DOM label", + "bounds": { "width": 280, "height": 50 }, + "original": { "width": 1990, "height": 360 }, + "exportFormat": "webp-lossless", + "bytes": 417966, + "sha256": "538d9833e1e8f8bf7bfbfbf7c6032afa18425a9fff0a661dd83d09f082b1f00b", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "button-withdraw.webp", + "sourceNode": "850:312", + "sourceName": "图层 1 7", + "sourceType": "RECTANGLE", + "purpose": "Wallet withdrawal button skin without its DOM label", + "bounds": { "width": 214, "height": 34 }, + "original": { "width": 2055, "height": 329 }, + "exportFormat": "webp-lossless", + "bytes": 374810, + "sha256": "e566ebfbe19d15b667a3238df600a7cc1225d3e7ec35903443f82008760239f9", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "chest-blue.webp", + "sourceNode": "850:267", + "sourceName": "03 2 1", + "sourceType": "RECTANGLE", + "purpose": "Blue wheel reward chest", + "bounds": { "width": 57, "height": 55 }, + "original": { "width": 354, "height": 336 }, + "exportFormat": "webp-lossless", + "bytes": 149466, + "sha256": "0551c01bc09d311d20a8a6f1323629b7bb3898d446b3b22c6e947a2c0b04b272", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "chest-gold.webp", + "sourceNode": "850:270", + "sourceName": "01 2 1", + "sourceType": "RECTANGLE", + "purpose": "Gold wheel reward chest", + "bounds": { "width": 57, "height": 51 }, + "original": { "width": 355, "height": 316 }, + "exportFormat": "webp-lossless", + "bytes": 142820, + "sha256": "0ba668e4312b24377ead80d73b0fb87bd778dcaf8bf444a9a176e703fe88cd36", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "chest-green.webp", + "sourceNode": "850:283", + "sourceName": "04 2 1", + "sourceType": "RECTANGLE", + "purpose": "Green wheel reward chest", + "bounds": { "width": 57, "height": 53 }, + "original": { "width": 354, "height": 331 }, + "exportFormat": "webp-lossless", + "bytes": 164498, + "sha256": "5b3e74a47979ac5f4459a4de0ceb66473d272fbb0484f9aa84807a0c630b3209", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "chest-orange.webp", + "sourceNode": "850:279", + "sourceName": "07 2 1", + "sourceType": "RECTANGLE", + "purpose": "Orange wheel reward chest", + "bounds": { "width": 57, "height": 53 }, + "original": { "width": 354, "height": 325 }, + "exportFormat": "webp-lossless", + "bytes": 178720, + "sha256": "3e7ffe20e4309dee33cbde633597794f68a77e3b07beaff4d5e63f830dbb33e9", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "chest-purple.webp", + "sourceNode": "850:276", + "sourceName": "05 2 1", + "sourceType": "RECTANGLE", + "purpose": "Purple wheel reward chest", + "bounds": { "width": 57, "height": 55 }, + "original": { "width": 354, "height": 334 }, + "exportFormat": "webp-lossless", + "bytes": 162400, + "sha256": "f6f782ee6440d7349fabf6955f81d71345f71a70a9d3151a20417cb40893703e", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "chest-red.webp", + "sourceNode": "850:273", + "sourceNodes": ["850:273", "850:429", "850:430"], + "sourceName": "02 2 1", + "sourceType": "RECTANGLE", + "purpose": "Red wheel reward chest reused by the estimated-rewards entry", + "bounds": { "width": 57, "height": 52 }, + "original": { "width": 353, "height": 318 }, + "exportFormat": "webp-lossless", + "bytes": 153572, + "sha256": "89fba58f5b4ad0aebf4fe193eabbec83070eeb6246f11f5fa41e580f150e4e39", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "chest-white-gold.webp", + "sourceNode": "850:286", + "sourceName": "图层 1 6", + "sourceType": "RECTANGLE", + "purpose": "White-gold wheel reward chest", + "bounds": { "width": 57, "height": 55 }, + "original": { "width": 355, "height": 341 }, + "exportFormat": "webp-lossless", + "bytes": 174792, + "sha256": "ed3f81ff4f21ad9a8ca3d32c42a11953b5a2e8bf9bd9ddeb1918d64a4220946b", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "icon-convert.svg", + "sourceNode": "850:306", + "sourceName": "转换_转换_copy 1", + "sourceType": "INSTANCE", + "purpose": "Points conversion arrows", + "bounds": { "width": 12.521, "height": 14 }, + "original": { "width": 14, "height": 12.5205 }, + "exportFormat": "svg", + "bytes": 2095, + "sha256": "df2b2c6f97d7ae52bd61240846a9168281d2406b61bc1a39b5676061a6484ebb", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "icon-finance.svg", + "sourceNode": "850:308", + "sourceName": "Finance 1", + "sourceType": "INSTANCE", + "purpose": "Wallet finance icon", + "bounds": { "width": 14, "height": 14 }, + "original": { "width": 14, "height": 14 }, + "exportFormat": "svg", + "bytes": 2884, + "sha256": "86e054e618747a8d6ec1c248fc74bc1781eeb96654cceacc4b4274db24c07be4", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "icon-infinity.svg", + "sourceNode": "850:304", + "sourceName": "符号_无限 1", + "sourceType": "INSTANCE", + "purpose": "Infinite points indicator", + "bounds": { "width": 14, "height": 14 }, + "original": { "width": 14, "height": 14 }, + "exportFormat": "svg", + "bytes": 1472, + "sha256": "58b7569f3a7f018c492bcfb1633d58c73b4fc807cb9e4a86ff5a03845dd2f841", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "nav-back.svg", + "sourceNode": "850:257", + "sourceName": "icon_20_返回", + "sourceType": "INSTANCE", + "purpose": "Navigation back icon", + "bounds": { "width": 24, "height": 24 }, + "original": { "width": 24, "height": 24 }, + "exportFormat": "svg", + "bytes": 419, + "sha256": "50d06f24adb0019fcdfb3c3f5db012808c230cd8c2f06c61b775fcccdbf525d4", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "panel-gold-emerald.webp", + "sourceNode": "850:292", + "sourceNodes": ["850:292", "850:315", "850:836"], + "sourceName": "图层 0 46", + "sourceType": "RECTANGLE", + "purpose": "Reusable gold-and-emerald panel skin reconstructed without Figma one-pixel slices", + "bounds": { "width": 351, "height": 205 }, + "original": { "width": 1502, "height": 879 }, + "exportFormat": "webp-lossless", + "bytes": 722510, + "sha256": "6780e103ca19992a5fa3b1e6be5f0d598dab26d803fb9463f9e01fc2fce49789", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "points-a.webp", + "sourceNode": "850:297", + "sourceNodes": ["850:297", "850:302"], + "sourceName": "图层 0 47", + "sourceType": "RECTANGLE", + "purpose": "Huwaa A points icon", + "bounds": { "width": 23, "height": 24 }, + "original": { "width": 868, "height": 892 }, + "exportFormat": "webp-lossless", + "bytes": 514910, + "sha256": "5e3b941cc0f6224ccb17017fad201a508d96230ff70d3f1268d2765636eb2240", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "reward-calendar-check.webp", + "sourceNode": "850:394", + "sourceNodes": ["850:394", "850:405", "850:416", "850:1085"], + "sourceName": "image 5049", + "sourceType": "RECTANGLE", + "purpose": "Task icon and reward-list fallback icon", + "bounds": { "width": 42, "height": 45 }, + "original": { "width": 658, "height": 701 }, + "exportFormat": "webp-lossless", + "bytes": 182252, + "sha256": "79b06b912c2e64ff3487bf8d35b848f493df4134583ba16c34d35ce0427ba81b", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "reward-modal-panel.webp", + "sourceNode": "850:629", + "sourceName": "图层 0 316", + "sourceType": "RECTANGLE", + "purpose": "Win dialog decorative panel without reward media or text", + "bounds": { "width": 327, "height": 415 }, + "original": { "width": 994, "height": 1262 }, + "exportFormat": "webp-lossless", + "bytes": 960078, + "sha256": "26ded67cb3b3f917d68e517627a3328dbb02c223f3590f2ac3ad9609e510627f", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "status-battery.svg", + "sourceNode": "850:240", + "sourceName": "Battery", + "sourceType": "INSTANCE", + "purpose": "Design status-bar battery icon", + "bounds": { "width": 24.33, "height": 11.3333 }, + "original": { "width": 24.33, "height": 11.3333 }, + "exportFormat": "svg", + "bytes": 1005, + "sha256": "62fd408926ad465cc16e1fb57634dc5ff2205b4ab11a6b0edc110440e83f0f2b", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "status-cellular.svg", + "sourceNode": "850:248", + "sourceName": "Cellular Connection", + "sourceType": "INSTANCE", + "purpose": "Design status-bar cellular icon", + "bounds": { "width": 17, "height": 11.3334 }, + "original": { "width": 17.0001, "height": 11.3334 }, + "exportFormat": "svg", + "bytes": 1147, + "sha256": "112f067ba1b97345f39df94cba9e080a46606a4387fe913344a2b2e7e7aee13e", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "status-wifi.svg", + "sourceNode": "850:244", + "sourceName": "Wifi", + "sourceType": "INSTANCE", + "purpose": "Design status-bar Wi-Fi icon", + "bounds": { "width": 15.3333, "height": 11.01 }, + "original": { "width": 15.3333, "height": 11.01 }, + "exportFormat": "svg", + "bytes": 1633, + "sha256": "22cf5fae5ae340219fbe1ed699d3a7d1562f6f4993c24df5502c640f907a1ec5", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "wallet-inner-panel.webp", + "sourceNode": "850:293", + "sourceName": "成v下 1", + "sourceType": "RECTANGLE", + "purpose": "Wallet inner texture behind live balance values", + "bounds": { "width": 327, "height": 120 }, + "original": { "width": 1464, "height": 622 }, + "exportFormat": "webp-lossless", + "bytes": 459290, + "sha256": "e8fe298ba89673bb8058668481dee7023b30e4445f83ae3363d8202796750f1c", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "wheel-base.webp", + "sourceNode": "850:264", + "sourceName": "图层 0 49", + "sourceType": "RECTANGLE", + "purpose": "Empty wheel frame, stage, and pedestal without prizes or controls", + "bounds": { "width": 370, "height": 437.464 }, + "original": { "width": 1038, "height": 1227 }, + "exportFormat": "webp-lossless", + "bytes": 1118800, + "sha256": "abb8c248b21d09716eb7022aa991fd18c0452ec7a2f832792104dbeb533b58bb", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "wheel-spin-center.webp", + "sourceNode": "850:265", + "sourceName": "图层 0 43", + "sourceType": "RECTANGLE", + "purpose": "Wheel center and pointer skin without Spin or cost text", + "bounds": { "width": 106, "height": 117 }, + "original": { "width": 756, "height": 840 }, + "exportFormat": "webp-lossless", + "bytes": 407758, + "sha256": "09659e624c1cc7979ca847142f00e5c322653ad531f7752878d7fe9cc90c6506", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + } + ] +} diff --git a/activity/invite/huwaa/assets/layers/nav-back.svg b/activity/invite/huwaa/assets/layers/nav-back.svg new file mode 100644 index 0000000..d2935f3 --- /dev/null +++ b/activity/invite/huwaa/assets/layers/nav-back.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/activity/invite/huwaa/assets/layers/panel-gold-emerald.webp b/activity/invite/huwaa/assets/layers/panel-gold-emerald.webp new file mode 100644 index 0000000..bff3714 Binary files /dev/null and b/activity/invite/huwaa/assets/layers/panel-gold-emerald.webp differ diff --git a/activity/invite/huwaa/assets/layers/points-a.webp b/activity/invite/huwaa/assets/layers/points-a.webp new file mode 100644 index 0000000..230d10e Binary files /dev/null and b/activity/invite/huwaa/assets/layers/points-a.webp differ diff --git a/activity/invite/huwaa/assets/layers/reward-calendar-check.webp b/activity/invite/huwaa/assets/layers/reward-calendar-check.webp new file mode 100644 index 0000000..2e287dd Binary files /dev/null and b/activity/invite/huwaa/assets/layers/reward-calendar-check.webp differ diff --git a/activity/invite/huwaa/assets/layers/reward-modal-panel.webp b/activity/invite/huwaa/assets/layers/reward-modal-panel.webp new file mode 100644 index 0000000..eb6637d Binary files /dev/null and b/activity/invite/huwaa/assets/layers/reward-modal-panel.webp differ diff --git a/activity/invite/huwaa/assets/layers/status-battery.svg b/activity/invite/huwaa/assets/layers/status-battery.svg new file mode 100644 index 0000000..4b0c570 --- /dev/null +++ b/activity/invite/huwaa/assets/layers/status-battery.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/activity/invite/huwaa/assets/layers/status-cellular.svg b/activity/invite/huwaa/assets/layers/status-cellular.svg new file mode 100644 index 0000000..9a5e1ff --- /dev/null +++ b/activity/invite/huwaa/assets/layers/status-cellular.svg @@ -0,0 +1,3 @@ + + + diff --git a/activity/invite/huwaa/assets/layers/status-wifi.svg b/activity/invite/huwaa/assets/layers/status-wifi.svg new file mode 100644 index 0000000..4ee1faa --- /dev/null +++ b/activity/invite/huwaa/assets/layers/status-wifi.svg @@ -0,0 +1,3 @@ + + + diff --git a/activity/invite/huwaa/assets/layers/wallet-inner-panel.webp b/activity/invite/huwaa/assets/layers/wallet-inner-panel.webp new file mode 100644 index 0000000..b049dba Binary files /dev/null and b/activity/invite/huwaa/assets/layers/wallet-inner-panel.webp differ diff --git a/activity/invite/huwaa/assets/layers/wheel-base.webp b/activity/invite/huwaa/assets/layers/wheel-base.webp new file mode 100644 index 0000000..70bb6c0 Binary files /dev/null and b/activity/invite/huwaa/assets/layers/wheel-base.webp differ diff --git a/activity/invite/huwaa/assets/layers/wheel-spin-center.webp b/activity/invite/huwaa/assets/layers/wheel-spin-center.webp new file mode 100644 index 0000000..674f4f7 Binary files /dev/null and b/activity/invite/huwaa/assets/layers/wheel-spin-center.webp differ diff --git a/activity/invite/huwaa/huwaa.css b/activity/invite/huwaa/huwaa.css new file mode 100644 index 0000000..528b480 --- /dev/null +++ b/activity/invite/huwaa/huwaa.css @@ -0,0 +1,1013 @@ +:root { + --huwaa-page-width: min(100vw, 430px); + --huwaa-unit: calc(var(--huwaa-page-width) / 375); + --huwaa-bg: #07211f; + --huwaa-panel: #043c2b; + --huwaa-green: #259c72; + --huwaa-gold: #fbbf24; + --huwaa-gold-light: #ffce5c; + --huwaa-white: #fff; +} + +* { + box-sizing: border-box; +} + +html, +body { + min-height: 100%; + margin: 0; + background: #020b0a; + color: var(--huwaa-white); + font-family: + -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', + 'Helvetica Neue', Arial, sans-serif; +} + +button { + margin: 0; + border: 0; + padding: 0; + color: inherit; + font: inherit; + background: transparent; + -webkit-tap-highlight-color: transparent; +} + +button:focus-visible { + outline: calc(2 * var(--huwaa-unit)) solid #fff; + outline-offset: calc(2 * var(--huwaa-unit)); +} + +img { + display: block; + max-width: none; + user-select: none; + -webkit-user-drag: none; +} + +[hidden] { + display: none !important; +} + +.huwaa-page { + position: relative; + width: var(--huwaa-page-width); + min-height: calc(1114 * var(--huwaa-unit)); + margin: 0 auto; + overflow: hidden; + background: var(--huwaa-bg); +} + +.huwaa-stage { + position: relative; + width: 100%; + height: calc(1114 * var(--huwaa-unit)); + overflow: hidden; + background: var(--huwaa-bg); + isolation: isolate; +} + +.huwaa-palace { + position: absolute; + z-index: 0; + top: calc(28 * var(--huwaa-unit)); + left: calc(-83 * var(--huwaa-unit)); + width: calc(541 * var(--huwaa-unit)); + height: calc(812 * var(--huwaa-unit)); + -webkit-mask: url('./assets/layers/bg-palace-mask.svg') center / 100% 100% + no-repeat; + mask: url('./assets/layers/bg-palace-mask.svg') center / 100% 100% no-repeat; +} + +.huwaa-palace img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0.5; + object-fit: cover; + mix-blend-mode: screen; +} + +.huwaa-status-bar { + position: absolute; + z-index: 12; + top: 0; + left: 0; + width: 100%; + height: calc(44 * var(--huwaa-unit)); + background: #000; +} + +.huwaa-status-time { + position: absolute; + top: calc(13 * var(--huwaa-unit)); + left: calc(33.87 * var(--huwaa-unit)); + width: calc(54 * var(--huwaa-unit)); + height: calc(15 * var(--huwaa-unit)); + color: #fff; + font-family: Helvetica, Arial, sans-serif; + font-size: calc(15 * var(--huwaa-unit)); + font-weight: 400; + line-height: calc(15 * var(--huwaa-unit)); + text-align: center; +} + +.huwaa-status-cellular { + position: absolute; + top: calc(15 * var(--huwaa-unit)); + left: calc(294.34 * var(--huwaa-unit)); + width: calc(17 * var(--huwaa-unit)); + height: calc(11.333 * var(--huwaa-unit)); +} + +.huwaa-status-wifi { + position: absolute; + top: calc(16.33 * var(--huwaa-unit)); + left: calc(316.34 * var(--huwaa-unit)); + width: calc(15.333 * var(--huwaa-unit)); + height: calc(11.01 * var(--huwaa-unit)); +} + +.huwaa-status-battery { + position: absolute; + top: calc(16.34 * var(--huwaa-unit)); + left: calc(336.67 * var(--huwaa-unit)); + width: calc(24.33 * var(--huwaa-unit)); + height: calc(11.333 * var(--huwaa-unit)); +} + +.huwaa-nav { + position: absolute; + z-index: 11; + top: calc(44 * var(--huwaa-unit)); + left: 0; + width: 100%; + height: calc(44 * var(--huwaa-unit)); + background: #000; +} + +.huwaa-nav h1 { + position: absolute; + top: calc(10 * var(--huwaa-unit)); + left: 50%; + margin: 0; + transform: translateX(-50%); + color: #fff; + font-size: calc(18 * var(--huwaa-unit)); + font-weight: 500; + line-height: calc(18 * var(--huwaa-unit)); + white-space: nowrap; +} + +.huwaa-nav-back { + position: absolute; + z-index: 1; + top: 0; + left: calc(6 * var(--huwaa-unit)); + width: calc(44 * var(--huwaa-unit)); + height: calc(44 * var(--huwaa-unit)); + cursor: pointer; +} + +.huwaa-nav-back img { + position: absolute; + top: calc(10 * var(--huwaa-unit)); + left: calc(10 * var(--huwaa-unit)); + width: calc(24 * var(--huwaa-unit)); + height: calc(24 * var(--huwaa-unit)); +} + +.huwaa-wallet-card { + position: absolute; + z-index: 4; + top: calc(111 * var(--huwaa-unit)); + left: calc(12 * var(--huwaa-unit)); + width: calc(351 * var(--huwaa-unit)); + height: calc(205 * var(--huwaa-unit)); +} + +.huwaa-wallet-skin { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.huwaa-wallet-texture { + position: absolute; + top: calc(32 * var(--huwaa-unit)); + left: calc(12 * var(--huwaa-unit)); + width: calc(327 * var(--huwaa-unit)); + height: calc(120 * var(--huwaa-unit)); + overflow: hidden; + border: calc(1 * var(--huwaa-unit)) solid #facd61; + border-radius: calc(8 * var(--huwaa-unit)); +} + +.huwaa-wallet-texture img { + position: absolute; + top: -8.5%; + left: -0.29%; + width: 100.58%; + height: 117%; +} + +.huwaa-wallet-rate { + position: absolute; + top: calc(45 * var(--huwaa-unit)); + left: calc(120 * var(--huwaa-unit)); + display: flex; + height: calc(16 * var(--huwaa-unit)); + align-items: center; + gap: calc(4 * var(--huwaa-unit)); +} + +.huwaa-wallet-rate > img:first-child { + width: calc(14 * var(--huwaa-unit)); + height: calc(15 * var(--huwaa-unit)); + object-fit: contain; +} + +.huwaa-wallet-rate strong { + color: #fff; + font-size: calc(14 * var(--huwaa-unit)); + font-weight: 700; + line-height: 1.4; +} + +.huwaa-wallet-rate .huwaa-rate-exchange { + width: calc(14 * var(--huwaa-unit)); + height: calc(12.521 * var(--huwaa-unit)); + margin-left: calc(20 * var(--huwaa-unit)); + transform: rotate(90deg); +} + +.huwaa-wallet-rate .huwaa-rate-finance, +.huwaa-wallet-rate .huwaa-rate-infinity { + width: calc(14 * var(--huwaa-unit)); + height: calc(14 * var(--huwaa-unit)); +} + +.huwaa-wallet-balance { + position: absolute; + top: calc(67 * var(--huwaa-unit)); + left: calc(80 * var(--huwaa-unit)); + display: flex; + align-items: center; +} + +.huwaa-wallet-balance img { + width: calc(23 * var(--huwaa-unit)); + height: calc(24 * var(--huwaa-unit)); + margin-right: calc(5 * var(--huwaa-unit)); + object-fit: contain; +} + +.huwaa-wallet-balance strong { + color: #fff; + font-size: calc(32 * var(--huwaa-unit)); + font-weight: 700; + line-height: 1.4; + white-space: nowrap; +} + +.huwaa-wallet-cash { + position: absolute; + top: calc(87 * var(--huwaa-unit)); + left: calc(230 * var(--huwaa-unit)); + color: var(--huwaa-gold); + font-size: calc(14 * var(--huwaa-unit)); + font-weight: 700; + line-height: 1.4; + white-space: nowrap; +} + +.huwaa-wallet-remaining { + position: absolute; + top: calc(112 * var(--huwaa-unit)); + left: 50%; + margin: 0; + transform: translateX(-50%); + font-size: calc(10 * var(--huwaa-unit)); + font-weight: 600; + line-height: 1.4; + white-space: nowrap; +} + +.huwaa-wallet-remaining span { + color: #8cdfab; +} + +.huwaa-wallet-remaining strong { + color: var(--huwaa-gold); + font-weight: 600; +} + +.huwaa-withdraw-button { + position: absolute; + top: calc(161 * var(--huwaa-unit)); + left: calc(69 * var(--huwaa-unit)); + width: calc(214 * var(--huwaa-unit)); + height: calc(34 * var(--huwaa-unit)); + cursor: pointer; +} + +.huwaa-withdraw-button::before { + position: absolute; + inset: calc(-5 * var(--huwaa-unit)) 0; + content: ''; +} + +.huwaa-withdraw-button img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.huwaa-withdraw-button span { + position: absolute; + inset: calc(7 * var(--huwaa-unit)) 0 auto; + color: var(--huwaa-gold-light); + font-size: calc(14 * var(--huwaa-unit)); + font-weight: 600; + line-height: 1.4; + text-align: center; + white-space: nowrap; +} + +.huwaa-wheel-region { + position: absolute; + z-index: 3; + top: calc(331 * var(--huwaa-unit)); + left: 0; + width: calc(375 * var(--huwaa-unit)); + height: calc(468 * var(--huwaa-unit)); +} + +.huwaa-wheel-base { + position: absolute; + top: calc(18.768 * var(--huwaa-unit)); + left: calc(2.5 * var(--huwaa-unit)); + width: calc(370 * var(--huwaa-unit)); + height: calc(437.464 * var(--huwaa-unit)); +} + +.huwaa-reward-entry { + position: absolute; + z-index: 6; + top: calc(-3 * var(--huwaa-unit)); + left: calc(308 * var(--huwaa-unit)); + width: calc(44 * var(--huwaa-unit)); + height: calc(44 * var(--huwaa-unit)); + cursor: pointer; +} + +.huwaa-reward-entry img { + position: absolute; + top: calc(3 * var(--huwaa-unit)); + left: calc(1 * var(--huwaa-unit)); + width: calc(42 * var(--huwaa-unit)); + height: calc(38 * var(--huwaa-unit)); + object-fit: contain; +} + +.huwaa-reward-entry img:last-child { + mix-blend-mode: screen; +} + +.huwaa-wheel-prizes { + position: absolute; + inset: 0; +} + +.huwaa-prize { + position: absolute; + z-index: 4; + width: calc(57 * var(--huwaa-unit)); + height: calc(79 * var(--huwaa-unit)); + cursor: pointer; + transition: + filter 120ms ease, + transform 120ms ease; +} + +.huwaa-prize img { + position: absolute; + top: 0; + left: 0; + width: calc(57 * var(--huwaa-unit)); + object-fit: contain; +} + +.huwaa-prize span { + position: absolute; + top: calc(49 * var(--huwaa-unit)); + left: calc(4 * var(--huwaa-unit)); + width: calc(44 * var(--huwaa-unit)); + height: calc(26 * var(--huwaa-unit)); + overflow: visible; + color: #ba3320; + font-size: calc(11.187 * var(--huwaa-unit)); + font-weight: 600; + line-height: calc(25.171 * var(--huwaa-unit)); + text-align: center; + white-space: nowrap; +} + +.huwaa-prize.is-active { + z-index: 5; + filter: brightness(1.45) + drop-shadow(0 0 calc(7 * var(--huwaa-unit)) #fff09a); + transform: scale(1.08); +} + +.huwaa-prize[data-index='0'] { + top: calc(68 * var(--huwaa-unit)); + left: calc(159 * var(--huwaa-unit)); +} + +.huwaa-prize[data-index='0'] img, +.huwaa-prize[data-index='1'] img { + height: calc(53 * var(--huwaa-unit)); +} + +.huwaa-prize[data-index='1'] { + top: calc(103 * var(--huwaa-unit)); + left: calc(74 * var(--huwaa-unit)); +} + +.huwaa-prize[data-index='2'] { + top: calc(108 * var(--huwaa-unit)); + left: calc(241 * var(--huwaa-unit)); +} + +.huwaa-prize[data-index='2'] img, +.huwaa-prize[data-index='3'] img, +.huwaa-prize[data-index='4'] img { + height: calc(55 * var(--huwaa-unit)); +} + +.huwaa-prize[data-index='3'] { + top: calc(196 * var(--huwaa-unit)); + left: calc(55 * var(--huwaa-unit)); +} + +.huwaa-prize[data-index='4'] { + top: calc(196 * var(--huwaa-unit)); + left: calc(265 * var(--huwaa-unit)); +} + +.huwaa-prize[data-index='5'] { + top: calc(268 * var(--huwaa-unit)); + left: calc(119 * var(--huwaa-unit)); +} + +.huwaa-prize[data-index='5'] img { + height: calc(52 * var(--huwaa-unit)); +} + +.huwaa-prize[data-index='6'] { + top: calc(267 * var(--huwaa-unit)); + left: calc(201 * var(--huwaa-unit)); +} + +.huwaa-prize[data-index='6'] img { + height: calc(51 * var(--huwaa-unit)); +} + +.huwaa-spin-button { + position: absolute; + z-index: 7; + top: calc(146 * var(--huwaa-unit)); + left: calc(135 * var(--huwaa-unit)); + width: calc(106 * var(--huwaa-unit)); + height: calc(117 * var(--huwaa-unit)); + cursor: pointer; +} + +.huwaa-spin-button > img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.huwaa-spin-label { + position: absolute; + top: calc(40 * var(--huwaa-unit)); + left: 50%; + width: calc(74 * var(--huwaa-unit)); + transform: translateX(-50%); + color: #ba3320; + font-size: calc(22.375 * var(--huwaa-unit)); + font-weight: 600; + line-height: calc(26 * var(--huwaa-unit)); + text-align: center; +} + +.huwaa-spin-cost { + position: absolute; + top: calc(76 * var(--huwaa-unit)); + left: 50%; + display: flex; + width: calc(38 * var(--huwaa-unit)); + height: calc(24 * var(--huwaa-unit)); + align-items: center; + justify-content: center; + transform: translateX(-50%); + border: calc(1.02 * var(--huwaa-unit)) solid #fb7185; + border-radius: calc(11.735 * var(--huwaa-unit)); + color: #fff; + font-size: calc(12.246 * var(--huwaa-unit)); + font-weight: 600; + line-height: calc(14.286 * var(--huwaa-unit)); + background: #e11d48; +} + +.huwaa-spin-button[disabled] { + cursor: wait; +} + +.huwaa-invite-button { + position: absolute; + z-index: 8; + top: calc(749 * var(--huwaa-unit)); + left: calc(48 * var(--huwaa-unit)); + width: calc(280 * var(--huwaa-unit)); + height: calc(50 * var(--huwaa-unit)); + cursor: pointer; +} + +.huwaa-invite-button img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.huwaa-invite-button span { + position: absolute; + top: calc(14 * var(--huwaa-unit)); + left: 0; + width: 100%; + color: #501b00; + font-size: calc(16 * var(--huwaa-unit)); + font-weight: 700; + line-height: 1.4; + text-align: center; + white-space: nowrap; +} + +.huwaa-extendable-panel { + position: absolute; + overflow: hidden; +} + +.huwaa-extendable-skin { + position: absolute; + inset: 0; + overflow: hidden; + pointer-events: none; +} + +.huwaa-skin-top, +.huwaa-skin-middle, +.huwaa-skin-bottom { + position: absolute; + left: 0; + width: 100%; +} + +.huwaa-skin-top { + top: 0; + height: calc(117 * var(--huwaa-unit)); + background: url('./assets/layers/panel-gold-emerald.webp') top center / 100% + auto no-repeat; +} + +.huwaa-skin-middle { + top: calc(117 * var(--huwaa-unit)); + bottom: calc(91.538 * var(--huwaa-unit)); + background: linear-gradient( + 90deg, + #011f14 0%, + #02261b 6%, + #033a26 27%, + #064a2f 50%, + #043724 74%, + #02261a 94%, + #011f14 100% + ); + box-shadow: + inset calc(3 * var(--huwaa-unit)) 0 #e5a938, + inset calc(-3 * var(--huwaa-unit)) 0 #e5a938; +} + +.huwaa-skin-bottom { + bottom: 0; + height: calc(91.538 * var(--huwaa-unit)); + background: url('./assets/layers/panel-gold-emerald.webp') bottom center / + 100% auto no-repeat; +} + +.huwaa-task-panel { + z-index: 5; + top: calc(810 * var(--huwaa-unit)); + left: calc(9 * var(--huwaa-unit)); + width: calc(357 * var(--huwaa-unit)); + height: calc(282.504 * var(--huwaa-unit)); +} + +.huwaa-task-list { + position: absolute; + z-index: 1; + top: calc(31 * var(--huwaa-unit)); + left: calc(15 * var(--huwaa-unit)); + display: grid; + width: calc(327 * var(--huwaa-unit)); + gap: calc(12 * var(--huwaa-unit)); +} + +.huwaa-task-card { + position: relative; + display: flex; + width: 100%; + height: calc(69 * var(--huwaa-unit)); + align-items: center; + border: calc(1 * var(--huwaa-unit)) solid var(--huwaa-green); + border-radius: calc(7 * var(--huwaa-unit)); + background: rgba(8, 43, 50, 0.2); +} + +.huwaa-task-icon { + position: absolute; + top: calc(12 * var(--huwaa-unit)); + left: calc(12 * var(--huwaa-unit)); + width: calc(42 * var(--huwaa-unit)); + height: calc(45 * var(--huwaa-unit)); + object-fit: cover; +} + +.huwaa-task-copy { + position: absolute; + top: calc(12 * var(--huwaa-unit)); + left: calc(62 * var(--huwaa-unit)); + display: flex; + width: calc(167 * var(--huwaa-unit)); + flex-direction: column; + gap: calc(4 * var(--huwaa-unit)); +} + +.huwaa-task-copy strong { + overflow: hidden; + color: #fff; + font-size: calc(12 * var(--huwaa-unit)); + font-weight: 400; + line-height: calc(12 * var(--huwaa-unit)); + text-overflow: ellipsis; + white-space: nowrap; +} + +.huwaa-task-copy span { + display: -webkit-box; + width: calc(151 * var(--huwaa-unit)); + overflow: hidden; + color: rgba(255, 255, 255, 0.6); + font-size: calc(10 * var(--huwaa-unit)); + font-weight: 400; + line-height: calc(10 * var(--huwaa-unit)); + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.huwaa-task-reward { + position: absolute; + top: calc(10 * var(--huwaa-unit)); + right: calc(12 * var(--huwaa-unit)); + color: var(--huwaa-gold); + font-size: calc(14 * var(--huwaa-unit)); + font-weight: 500; + line-height: calc(12 * var(--huwaa-unit)); +} + +.huwaa-task-status { + position: absolute; + right: calc(12 * var(--huwaa-unit)); + bottom: calc(10 * var(--huwaa-unit)); + display: flex; + min-width: calc(82 * var(--huwaa-unit)); + height: calc(22 * var(--huwaa-unit)); + align-items: center; + justify-content: center; + border: calc(1 * var(--huwaa-unit)) solid #3ce89b; + border-radius: calc(34 * var(--huwaa-unit)); + padding: 0 calc(10 * var(--huwaa-unit)); + color: #fff; + font-size: calc(10 * var(--huwaa-unit)); + font-weight: 400; + line-height: calc(12 * var(--huwaa-unit)); + background: #143322; + box-shadow: + inset calc(-1.867 * var(--huwaa-unit)) calc(-1.867 * var(--huwaa-unit)) + calc(1.867 * var(--huwaa-unit)) rgba(0, 0, 0, 0.2), + inset calc(1.867 * var(--huwaa-unit)) calc(1.867 * var(--huwaa-unit)) + calc(1.867 * var(--huwaa-unit)) rgba(0, 0, 0, 0.2), + inset 0 0 calc(9.333 * var(--huwaa-unit)) rgba(47, 182, 122, 0.2), + inset 0 0 calc(28 * var(--huwaa-unit)) rgba(96, 219, 164, 0.75), + 0 0 calc(4.667 * var(--huwaa-unit)) rgba(47, 182, 122, 0.25); +} + +.huwaa-task-card.is-pending .huwaa-task-status { + color: #501b00; + border-color: #f6c34e; + cursor: pointer; + background: #fbbf24; + box-shadow: none; +} + +.huwaa-modal-scrim { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + cursor: pointer; + background: rgba(0, 0, 0, 0.4); +} + +.huwaa-win-modal, +.huwaa-reward-drawer { + position: fixed; + z-index: 30; + top: 0; + left: 50%; + width: var(--huwaa-page-width); + height: min(100dvh, calc(1114 * var(--huwaa-unit))); + transform: translateX(-50%); +} + +.huwaa-win-dialog { + position: absolute; + top: calc(350 * var(--huwaa-unit)); + left: calc(24 * var(--huwaa-unit)); + width: calc(327 * var(--huwaa-unit)); + height: calc(415 * var(--huwaa-unit)); +} + +.huwaa-win-skin { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.huwaa-win-media { + position: absolute; + top: calc(37.5 * var(--huwaa-unit)); + left: calc(102.03 * var(--huwaa-unit)); + display: flex; + width: calc(132 * var(--huwaa-unit)); + height: calc(128 * var(--huwaa-unit)); + align-items: center; + justify-content: center; + overflow: hidden; + background: rgba(0, 0, 0, 0.2); +} + +.huwaa-win-media img { + width: calc(112 * var(--huwaa-unit)); + height: calc(102 * var(--huwaa-unit)); + object-fit: contain; +} + +.huwaa-win-dialog h2 { + position: absolute; + top: calc(179 * var(--huwaa-unit)); + left: 50%; + width: calc(312 * var(--huwaa-unit)); + margin: 0; + transform: translateX(-50%); + color: #fcf488; + font-size: calc(28 * var(--huwaa-unit)); + font-weight: 700; + line-height: 1.4; + text-align: center; + white-space: nowrap; + background: linear-gradient( + 180deg, + #fcf488 0%, + #fce295 49.519%, + #ffbe1b 100% + ); + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + -webkit-text-stroke: calc(1.25 * var(--huwaa-unit)) #351700; + paint-order: stroke fill; + text-shadow: 0 calc(1 * var(--huwaa-unit)) 0 #fff3a1; +} + +.huwaa-win-kicker { + position: absolute; + top: calc(233 * var(--huwaa-unit)); + left: 50%; + margin: 0; + transform: translateX(-50%); + color: #fce295; + font-size: calc(16 * var(--huwaa-unit)); + font-weight: 700; + line-height: 1.4; + white-space: nowrap; + background: linear-gradient( + 180deg, + #fcf488 0%, + #fce295 49.519%, + #ffbe1b 100% + ); + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + -webkit-text-stroke: calc(0.5 * var(--huwaa-unit)) #6f4700; +} + +.huwaa-win-reward { + position: absolute; + top: calc(260 * var(--huwaa-unit)); + left: 50%; + display: flex; + width: calc(253 * var(--huwaa-unit)); + margin: 0; + transform: translateX(-50%); + flex-direction: column; + color: #025333; + font-size: calc(20 * var(--huwaa-unit)); + font-weight: 700; + line-height: 1.4; + text-align: center; +} + +.huwaa-win-reward strong, +.huwaa-win-reward span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.huwaa-awesome-button { + position: absolute; + top: calc(331 * var(--huwaa-unit)); + left: calc(37 * var(--huwaa-unit)); + width: calc(250 * var(--huwaa-unit)); + height: calc(53 * var(--huwaa-unit)); + cursor: pointer; +} + +.huwaa-awesome-button img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.huwaa-awesome-button span { + position: absolute; + top: calc(16 * var(--huwaa-unit)); + left: 0; + width: 100%; + color: var(--huwaa-gold-light); + font-size: calc(16 * var(--huwaa-unit)); + font-weight: 600; + line-height: 1.4; + text-align: center; +} + +.huwaa-reward-drawer .huwaa-modal-scrim { + background: rgba(0, 0, 0, 0.4); +} + +.huwaa-reward-sheet { + position: absolute; + bottom: 0; + left: 0; + width: calc(375 * var(--huwaa-unit)); + height: calc(367 * var(--huwaa-unit)); +} + +.huwaa-reward-sheet .huwaa-skin-top { + height: calc(123 * var(--huwaa-unit)); +} + +.huwaa-reward-sheet .huwaa-skin-middle { + top: calc(123 * var(--huwaa-unit)); + bottom: 0; +} + +.huwaa-reward-sheet h2 { + position: absolute; + z-index: 1; + top: calc(34 * var(--huwaa-unit)); + left: 50%; + margin: 0; + transform: translateX(-50%); + color: var(--huwaa-gold); + font-size: calc(20 * var(--huwaa-unit)); + font-weight: 700; + line-height: 1.4; + text-align: center; + white-space: nowrap; +} + +.huwaa-win-dialog h2:focus, +.huwaa-reward-sheet h2:focus { + outline: none; +} + +.huwaa-estimated-grid { + position: absolute; + z-index: 1; + top: calc(77 * var(--huwaa-unit)); + left: calc(12 * var(--huwaa-unit)); + display: grid; + width: calc(351 * var(--huwaa-unit)); + grid-template-columns: repeat(4, calc(71 * var(--huwaa-unit))); + column-gap: calc(22.333 * var(--huwaa-unit)); + row-gap: calc(24 * var(--huwaa-unit)); + max-height: calc(290 * var(--huwaa-unit)); + overflow-y: auto; + overscroll-behavior: contain; + padding-bottom: calc(12 * var(--huwaa-unit)); + scrollbar-width: none; +} + +.huwaa-estimated-grid::-webkit-scrollbar { + display: none; +} + +.huwaa-estimated-item { + position: relative; + width: calc(71 * var(--huwaa-unit)); + height: calc(95 * var(--huwaa-unit)); +} + +.huwaa-estimated-icon { + display: flex; + width: calc(71 * var(--huwaa-unit)); + height: calc(71 * var(--huwaa-unit)); + align-items: center; + justify-content: center; + border: calc(1 * var(--huwaa-unit)) solid var(--huwaa-green); + border-radius: 50%; + background: rgba(8, 43, 50, 0.2); +} + +.huwaa-estimated-icon img { + width: calc(44 * var(--huwaa-unit)); + height: calc(47 * var(--huwaa-unit)); + object-fit: cover; +} + +.huwaa-estimated-item strong { + position: absolute; + top: calc(83 * var(--huwaa-unit)); + left: 0; + width: 100%; + overflow: hidden; + color: #fff; + font-size: calc(12 * var(--huwaa-unit)); + font-weight: 400; + line-height: calc(12 * var(--huwaa-unit)); + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; +} + +.huwaa-stage.is-busy .huwaa-spin-button { + pointer-events: none; +} + +html.huwaa-modal-open, +html.huwaa-modal-open body { + overflow: hidden; +} + +@media (prefers-reduced-motion: reduce) { + .huwaa-prize { + transition: none; + } +} + +[dir='rtl'] .huwaa-nav-back img { + transform: scaleX(-1); +} + +@media (min-width: 431px) { + body { + padding-bottom: 24px; + } + + .huwaa-page { + box-shadow: 0 0 40px rgba(0, 0, 0, 0.45); + } +} diff --git a/activity/invite/huwaa/huwaa.js b/activity/invite/huwaa/huwaa.js new file mode 100644 index 0000000..9f0fdeb --- /dev/null +++ b/activity/invite/huwaa/huwaa.js @@ -0,0 +1,1009 @@ +(function () { + 'use strict'; + + var ASSET_BASE = './huwaa/assets/layers/'; + var params = new URLSearchParams(window.location.search || ''); + var useMock = params.get('mock') === '1'; + var DESIGN_PRIZES = [ + { + id: 'medium-orange', + name: 'Medium', + image: ASSET_BASE + 'chest-orange.webp', + points: 500, + }, + { + id: 'medium-green', + name: 'Medium', + image: ASSET_BASE + 'chest-green.webp', + points: 500, + }, + { + id: 'medium-purple', + name: 'Medium', + image: ASSET_BASE + 'chest-white-gold.webp', + points: 500, + }, + { + id: 'medium-teal', + name: 'Medium', + image: ASSET_BASE + 'chest-blue.webp', + points: 500, + }, + { + id: 'medium-gold', + name: 'Medium', + image: ASSET_BASE + 'chest-purple.webp', + points: 500, + }, + { + id: 'medium-red', + name: 'Medium', + image: ASSET_BASE + 'chest-red.webp', + points: 500, + }, + { + id: 'medium-brown', + name: 'Medium', + image: ASSET_BASE + 'chest-gold.webp', + points: 500, + }, + ]; + var DESIGN_TASKS = [ + { + id: 'microphone-1', + title: 'Use the microphone for 1 min', + description: 'Friend completes biometric authentication', + titleKey: 'huwaa_invite.task_title', + descriptionKey: 'huwaa_invite.task_description', + reward: 1, + completed: true, + }, + { + id: 'microphone-2', + title: 'Use the microphone for 1 min', + description: 'Friend completes biometric authentication', + titleKey: 'huwaa_invite.task_title', + descriptionKey: 'huwaa_invite.task_description', + reward: 1, + completed: true, + }, + { + id: 'microphone-3', + title: 'Use the microphone for 1 min', + description: 'Friend completes biometric authentication', + titleKey: 'huwaa_invite.task_title', + descriptionKey: 'huwaa_invite.task_description', + reward: 1, + completed: true, + }, + ]; + var state = { + wheelID: params.get('wheel_id') || params.get('wheelId') || 'cash', + busy: false, + pointBalance: useMock ? 89094 : 0, + cashValue: useMock ? 8.9 : 0, + ratePoints: '90K', + withdrawalRemaining: useMock ? 1000 : 0, + drawCost: 5, + prizes: DESIGN_PRIZES.slice(), + tasks: DESIGN_TASKS.slice(), + estimatedRewards: [], + inviteCode: '', + inviteLink: '', + spinCount: 0, + }; + + function $(selector) { + return document.querySelector(selector); + } + + function $all(selector) { + return Array.prototype.slice.call(document.querySelectorAll(selector)); + } + + function t(key, fallback) { + if (window.HyAppI18n && window.HyAppI18n.t) { + return window.HyAppI18n.t(key, fallback); + } + return fallback || key; + } + + function toast(message) { + var target = window.HyToast || window.HyAppToast; + if (target && target.show) { + target.show(message); + return; + } + window.alert(message); + } + + function text(value) { + return String( + value === undefined || value === null ? '' : value + ).trim(); + } + + function number(value, fallback) { + var result = Number(value); + return Number.isFinite(result) ? result : Number(fallback || 0); + } + + function first(source, keys) { + source = source || {}; + for (var index = 0; index < keys.length; index += 1) { + var value = source[keys[index]]; + if (value !== undefined && value !== null && value !== '') { + return value; + } + } + return ''; + } + + function envelope(payload) { + if (payload && Object.prototype.hasOwnProperty.call(payload, 'data')) { + return payload.data; + } + return payload || {}; + } + + function parseJSON(value) { + if (!value || typeof value !== 'string') return {}; + try { + return JSON.parse(value); + } catch (error) { + return {}; + } + } + + function formatNumber(value) { + var lang = + window.HyAppI18n && window.HyAppI18n.lang + ? window.HyAppI18n.lang() + : 'en'; + return new Intl.NumberFormat(lang).format(number(value)); + } + + function resolveAssetURL(value) { + value = text(value); + if (!value) return ''; + if ( + /^(https?:)?\/\//i.test(value) || + /^(data|blob):/i.test(value) || + value.charAt(0) === '/' || + value.indexOf('./') === 0 || + value.indexOf('../') === 0 + ) { + return value; + } + // 旧版资源组只返回 wheel/assets/other 下的裸文件名;统一补齐路径,避免把配置内容误当当前 invite 目录的相对文件。 + return new URL('../wheel/assets/other/' + value, window.location.href) + .href; + } + + function normalizeReward(item, fallback, index) { + item = item || {}; + fallback = fallback || DESIGN_PRIZES[index % DESIGN_PRIZES.length]; + var metadata = parseJSON( + first(item, ['metadata_json', 'metadataJson']) + ); + var image = + first(metadata, [ + 'preview_url', + 'previewUrl', + 'asset_url', + 'assetUrl', + ]) || + first(item, [ + 'cover_url', + 'coverUrl', + 'cover', + 'resource_cover', + 'resourceCover', + 'preview_url', + 'previewUrl', + 'image_url', + 'imageUrl', + 'icon', + 'avatar', + ]); + var name = + first(item, [ + 'display_name', + 'displayName', + 'reward_name', + 'rewardName', + 'name', + ]) || fallback.name; + var points = number( + first(item, [ + 'display_value', + 'displayValue', + 'reward_value', + 'rewardValue', + 'reward_coins', + 'rewardCoins', + 'points', + 'value', + 'amount', + ]), + fallback.points + ); + return { + id: + text( + first(item, [ + 'id', + 'tier_id', + 'tierId', + 'reward_id', + 'rewardId', + 'resource_id', + 'resourceId', + ]) + ) || fallback.id, + name: text(name) || fallback.name, + image: resolveAssetURL(image) || fallback.image, + points: points, + raw: item, + }; + } + + function rewardItems(payload) { + var data = envelope(payload); + var values = Array.isArray(data) + ? data + : data.rewards || + data.tiers || + data.items || + (data.config && (data.config.rewards || data.config.tiers)) || + []; + if (!Array.isArray(values)) return []; + return values.map(function (item, index) { + return normalizeReward(item, DESIGN_PRIZES[index], index); + }); + } + + function normalizeTasks(payload) { + var data = envelope(payload); + var values = + data.tasks || + data.invite_tasks || + data.inviteTasks || + (data.config && data.config.tasks) || + []; + if (!Array.isArray(values)) return []; + return values.slice(0, 3).map(function (item, index) { + var status = text(first(item, ['status', 'state'])).toLowerCase(); + return { + id: + text(first(item, ['id', 'task_id', 'taskId'])) || + 'task-' + index, + title: + text(first(item, ['title', 'name'])) || + DESIGN_TASKS[index].title, + description: + text( + first(item, ['description', 'desc', 'subtitle', 'hint']) + ) || DESIGN_TASKS[index].description, + reward: number( + first(item, [ + 'reward_spins', + 'rewardSpins', + 'reward', + 'value', + ]), + 1 + ), + completed: + first(item, [ + 'completed', + 'is_completed', + 'isCompleted', + ]) === true || + status === 'completed' || + status === 'claimed', + action: first(item, ['action', 'action_url', 'actionUrl']), + raw: item, + }; + }); + } + + function normalizeWalletBalance(payload) { + var data = envelope(payload); + var values = Array.isArray(data) + ? data + : data.balances || data.values || data.list || []; + if (!Array.isArray(values)) return 0; + var wallet = values.find(function (item) { + return ( + text( + first(item, [ + 'asset_type', + 'assetType', + 'wallet_type', + 'walletType', + 'type', + ]) + ).toUpperCase() === 'COIN' + ); + }); + if (!wallet) return 0; + return number( + first(wallet, [ + 'available_amount', + 'availableAmount', + 'balance', + 'amount', + ]) + ); + } + + function createCommandID() { + var random = Math.random().toString(36).slice(2); + if (window.crypto && window.crypto.getRandomValues) { + var values = new Uint32Array(2); + window.crypto.getRandomValues(values); + random = values[0].toString(36) + values[1].toString(36); + } + return 'huwaa_cash_' + Date.now().toString(36) + '_' + random; + } + + function setText(selector, value) { + var node = $(selector); + if (node) node.textContent = value; + } + + function renderWallet() { + setText('#huwaaRatePoints', state.ratePoints); + setText('#huwaaPointBalance', formatNumber(state.pointBalance)); + setText( + '#huwaaCashValue', + '≈$' + + number(state.cashValue, state.pointBalance / 10000).toFixed(1) + ); + setText('#huwaaPointsLeft', formatNumber(state.withdrawalRemaining)); + setText('#huwaaSpinCost', '*' + formatNumber(state.drawCost)); + } + + function renderPrizes() { + $all('.huwaa-prize').forEach(function (button, index) { + var prize = state.prizes[index] || DESIGN_PRIZES[index]; + var image = button.querySelector('img'); + var label = button.querySelector('span'); + image.src = prize.image; + image.onerror = function () { + image.onerror = null; + image.src = DESIGN_PRIZES[index].image; + }; + var prizeName = prize.raw + ? prize.name + : t('huwaa_invite.medium', 'Medium'); + label.textContent = prizeName; + button.setAttribute( + 'aria-label', + prizeName + + (prize.points + ? ', ' + formatNumber(prize.points) + ' points' + : '') + ); + }); + } + + function renderTasks() { + var list = $('#huwaaTaskList'); + var template = $('#huwaaTaskTemplate'); + if (!list || !template) return; + list.textContent = ''; + state.tasks.slice(0, 3).forEach(function (task) { + var fragment = template.content.cloneNode(true); + var card = fragment.querySelector('.huwaa-task-card'); + var status = fragment.querySelector('[data-task-status]'); + fragment.querySelector('[data-task-title]').textContent = + task.titleKey ? t(task.titleKey, task.title) : task.title; + fragment.querySelector('[data-task-description]').textContent = + task.descriptionKey + ? t(task.descriptionKey, task.description) + : task.description; + fragment.querySelector('[data-task-reward]').textContent = + '+' + formatNumber(task.reward); + card.classList.toggle('is-pending', !task.completed); + if (task.completed) { + status.textContent = t('huwaa_invite.completed', 'COMPLETED'); + } else { + var action = document.createElement('button'); + action.className = status.className; + action.type = 'button'; + action.textContent = t('huwaa_invite.go', 'GO'); + action.addEventListener('click', function () { + openTask(task); + }); + status.replaceWith(action); + } + list.appendChild(fragment); + }); + } + + function defaultEstimatedRewards() { + var values = []; + for (var index = 0; index < 12; index += 1) { + values.push({ + id: 'estimated-' + index, + name: useMock ? 'DDDDD' : t('huwaa_invite.reward', 'Reward'), + image: ASSET_BASE + 'reward-calendar-check.webp', + points: 0, + }); + } + return values; + } + + function renderEstimatedRewards() { + var grid = $('#huwaaEstimatedGrid'); + var template = $('#huwaaEstimatedTemplate'); + if (!grid || !template) return; + var rewards = state.estimatedRewards.length + ? state.estimatedRewards + : defaultEstimatedRewards(); + grid.textContent = ''; + rewards.slice(0, 12).forEach(function (reward) { + var fragment = template.content.cloneNode(true); + var image = fragment.querySelector('img'); + image.src = + reward.image || ASSET_BASE + 'reward-calendar-check.webp'; + image.onerror = function () { + image.onerror = null; + image.src = ASSET_BASE + 'reward-calendar-check.webp'; + }; + fragment.querySelector('strong').textContent = + reward.name || t('huwaa_invite.reward', 'Reward'); + grid.appendChild(fragment); + }); + } + + function renderAll() { + renderWallet(); + renderPrizes(); + renderTasks(); + renderEstimatedRewards(); + } + + function setBusy(busy) { + state.busy = Boolean(busy); + $('#huwaaStage').classList.toggle('is-busy', state.busy); + $('#huwaaSpinButton').disabled = state.busy; + $('#huwaaStage').setAttribute('aria-busy', String(state.busy)); + } + + function setActivePrize(index) { + $all('.huwaa-prize').forEach(function (node, nodeIndex) { + node.classList.toggle('is-active', nodeIndex === index); + }); + } + + function wait(milliseconds) { + return new Promise(function (resolve) { + window.setTimeout(resolve, milliseconds); + }); + } + + function animateSpin(targetIndex) { + var total = state.prizes.length || DESIGN_PRIZES.length; + var loops = total * 2 + targetIndex + 1; + var chain = Promise.resolve(); + for (var step = 0; step < loops; step += 1) { + (function (currentStep) { + chain = chain.then(function () { + setActivePrize(currentStep % total); + return wait(60 + Math.max(0, currentStep - loops + 6) * 25); + }); + })(step); + } + return chain.then(function () { + setActivePrize(targetIndex); + }); + } + + function rewardTargetIndex(reward) { + if (!reward) return 0; + var id = text(reward.id).toLowerCase(); + var name = text(reward.name).toLowerCase(); + var points = number(reward.points); + var index = state.prizes.findIndex(function (prize) { + return id && text(prize.id).toLowerCase() === id; + }); + if (index >= 0) return index; + index = state.prizes.findIndex(function (prize) { + return ( + name && + text(prize.name).toLowerCase() === name && + (!points || number(prize.points) === points) + ); + }); + return index >= 0 ? index : 0; + } + + function normalizeDrawResult(payload) { + var data = envelope(payload); + var rawRewards = + data.rewards || + data.records || + data.items || + data.reward_items || + []; + var firstReward = Array.isArray(rawRewards) + ? rawRewards[0] + : rawRewards; + var normalized = normalizeReward( + firstReward || data, + state.prizes[0], + 0 + ); + var balanceAfter = first(data, [ + 'coin_balance_after', + 'coinBalanceAfter', + 'balance_after', + 'balanceAfter', + ]); + return { + reward: normalized, + balanceAfter: + balanceAfter === '' ? undefined : number(balanceAfter), + }; + } + + function mockDrawResult() { + var index = state.spinCount % state.prizes.length; + state.spinCount += 1; + return Promise.resolve({ + reward: Object.assign({}, state.prizes[index], { + name: 'Medium Reward', + points: 500, + }), + // 视觉夹具保持 Figma 三个状态使用同一余额;真实环境只接受服务端返回的扣减后余额。 + balanceAfter: undefined, + }); + } + + function requestDraw() { + if (useMock) return mockDrawResult(); + var api = window.HyAppAPI && window.HyAppAPI.activityWheel; + var hasToken = + window.HyAppAPI && + window.HyAppAPI.getAccessToken && + window.HyAppAPI.getAccessToken(); + if (!api || !api.draw || !hasToken) { + return Promise.reject( + new Error( + t('huwaa_invite.login_required', 'Please log in first') + ) + ); + } + // 抽奖金额和 command_id 必须一次性发给服务端;页面不在失败时本地补发奖励,避免网络错误造成视觉中奖但账本未入账。 + return api + .draw(state.wheelID, { + command_id: createCommandID(), + amount: state.drawCost, + times: 1, + }) + .then(normalizeDrawResult); + } + + function showWin(reward) { + reward = reward || state.prizes[0]; + var image = $('#huwaaWinImage'); + image.src = reward.image || state.prizes[0].image; + image.onerror = function () { + image.onerror = null; + image.src = state.prizes[0].image; + }; + setText( + '#huwaaWinReward', + reward.raw + ? reward.name + : t('huwaa_invite.medium_reward', 'Medium Reward') + ); + setText( + '#huwaaWinPoints', + '(' + + formatNumber(reward.points || 0) + + ' ' + + t('huwaa_invite.points_label', 'Points') + + ')' + ); + $('#huwaaWinModal').hidden = false; + syncModalLock(); + $('#huwaaWinTitle').focus({ preventScroll: true }); + } + + function closeWin() { + $('#huwaaWinModal').hidden = true; + syncModalLock(); + setActivePrize(-1); + $('#huwaaSpinButton').focus({ preventScroll: true }); + } + + function startSpin() { + if (state.busy) return; + setBusy(true); + requestDraw() + .then(function (result) { + var target = rewardTargetIndex(result.reward); + return animateSpin(target).then(function () { + if (result.balanceAfter !== undefined) { + state.pointBalance = result.balanceAfter; + state.cashValue = state.pointBalance / 10000; + renderWallet(); + } + showWin(result.reward); + if (!useMock) loadEstimatedRewards(); + }); + }) + .catch(function (error) { + setActivePrize(-1); + toast( + (error && error.message) || + t('huwaa_invite.request_failed', 'Request failed') + ); + }) + .then(function () { + setBusy(false); + }); + } + + function openRewardDrawer() { + renderEstimatedRewards(); + $('#huwaaRewardDrawer').hidden = false; + syncModalLock(); + $('#huwaaRewardTitle').focus + ? $('#huwaaRewardTitle').focus({ preventScroll: true }) + : undefined; + } + + function closeRewardDrawer() { + $('#huwaaRewardDrawer').hidden = true; + syncModalLock(); + $('#huwaaRewardEntry').focus({ preventScroll: true }); + } + + function syncModalLock() { + document.documentElement.classList.toggle( + 'huwaa-modal-open', + !$('#huwaaWinModal').hidden || !$('#huwaaRewardDrawer').hidden + ); + } + + function openTask(task) { + if (task.completed) return; + if (task.action) { + window.location.href = task.action; + return; + } + if (window.HyAppBridge && window.HyAppBridge.post) { + window.HyAppBridge.post('huwaaInviteTaskAction', { + task_id: task.id, + }); + return; + } + toast(t('huwaa_invite.open_app_task', 'Open this task in Huwaa')); + } + + function currentInviteLandingURL() { + return new URL('../invite-landing/', window.location.href); + } + + function buildInviteLink() { + if (!state.inviteCode) return ''; + var url = currentInviteLandingURL(); + ['env', 'lang', 'app_code', 'appCode'].forEach(function (key) { + var value = text(params.get(key)); + if (value) url.searchParams.set(key, value); + }); + url.searchParams.set('app_code', 'huwaa'); + url.searchParams.set('invite_code', state.inviteCode); + url.searchParams.set('inviteCode', state.inviteCode); + url.searchParams.set('code', state.inviteCode); + return url.toString(); + } + + function copyText(value) { + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(value); + } + return new Promise(function (resolve, reject) { + var input = document.createElement('textarea'); + input.value = value; + input.setAttribute('readonly', 'readonly'); + input.style.position = 'fixed'; + input.style.left = '-9999px'; + document.body.appendChild(input); + input.select(); + var copied = false; + try { + copied = document.execCommand('copy'); + } catch (error) { + copied = false; + } + input.remove(); + copied ? resolve() : reject(new Error('copy failed')); + }); + } + + function shareInvite() { + var link = state.inviteLink || buildInviteLink(); + if (!link) { + toast( + t( + 'huwaa_invite.invite_unavailable', + 'Invitation link is not available yet' + ) + ); + return; + } + if (navigator.share) { + navigator + .share({ + title: t('huwaa_invite.invite_now', 'Invite Friends Now'), + url: link, + }) + .catch(function (error) { + if (error && error.name === 'AbortError') return; + copyInviteLink(link); + }); + return; + } + copyInviteLink(link); + } + + function copyInviteLink(link) { + copyText(link) + .then(function () { + toast(t('huwaa_invite.copied', 'Copied')); + }) + .catch(function () { + toast(t('huwaa_invite.copy_failed', 'Copy failed')); + }); + } + + function withdraw() { + var walletURL = text( + params.get('wallet_url') || params.get('walletUrl') + ); + if (walletURL) { + window.location.href = walletURL; + return; + } + if (window.HyAppBridge && window.HyAppBridge.post) { + window.HyAppBridge.post('huwaaCashWheelWithdraw', { + points: state.pointBalance, + }); + return; + } + toast( + t('huwaa_invite.open_wallet', 'Open the Huwaa wallet to withdraw') + ); + } + + function goBack() { + if (window.HyAppBridge && window.HyAppBridge.back) { + window.HyAppBridge.back(); + return; + } + if (window.history.length > 1) { + window.history.back(); + } + } + + function applyWheelConfig(payload) { + var data = envelope(payload); + var prizes = rewardItems(data); + var tasks = normalizeTasks(data); + var drawCost = number( + first(data, [ + 'draw_price_coins', + 'drawPriceCoins', + 'draw_price', + 'drawPrice', + 'spin_cost', + 'spinCost', + ]) + ); + if (prizes.length) { + state.prizes = prizes.slice(0, 7); + while (state.prizes.length < 7) { + state.prizes.push(DESIGN_PRIZES[state.prizes.length]); + } + if (!state.estimatedRewards.length) { + state.estimatedRewards = prizes.slice(0, 12); + } + } + if (tasks.length) state.tasks = tasks; + if (drawCost > 0) state.drawCost = drawCost; + var threshold = number( + first(data, [ + 'withdraw_threshold', + 'withdrawThreshold', + 'withdraw_points', + 'withdrawPoints', + ]) + ); + if (threshold > 0) { + state.ratePoints = + threshold >= 1000 + ? Math.round(threshold / 1000) + 'K' + : formatNumber(threshold); + state.withdrawalRemaining = Math.max( + 0, + threshold - state.pointBalance + ); + } + renderAll(); + } + + function loadWheelConfig() { + var api = window.HyAppAPI && window.HyAppAPI.activityWheel; + if (useMock || !api || !api.config) return Promise.resolve(); + return api + .config(state.wheelID) + .then(applyWheelConfig) + .catch(function () { + // 配置缺失时保留 Figma 的独立本地视觉层,但抽奖仍走服务端且会失败提示,不在前端伪造奖励。 + }); + } + + function loadWallet() { + var api = window.HyAppAPI && window.HyAppAPI.wallet; + var hasToken = + window.HyAppAPI && + window.HyAppAPI.getAccessToken && + window.HyAppAPI.getAccessToken(); + if (useMock || !api || !api.balances || !hasToken) { + return Promise.resolve(); + } + return api + .balances('COIN') + .then(function (payload) { + state.pointBalance = normalizeWalletBalance(payload); + state.cashValue = state.pointBalance / 10000; + renderWallet(); + }) + .catch(function () { + // 钱包失败不能阻塞页面结构;余额保持 0,实际抽奖仍由服务端余额校验兜底。 + }); + } + + function normalizeHistoryRewards(payload) { + var data = envelope(payload); + var records = data.records || data.items || data.values || []; + if (!Array.isArray(records)) return []; + var rewards = []; + records.forEach(function (record) { + var values = record.rewards || + record.items || + record.reward_items || [record]; + if (!Array.isArray(values)) values = [values]; + values.forEach(function (value) { + rewards.push( + normalizeReward( + value, + DESIGN_PRIZES[rewards.length % DESIGN_PRIZES.length], + rewards.length + ) + ); + }); + }); + return rewards.slice(0, 12); + } + + function loadEstimatedRewards() { + var api = window.HyAppAPI && window.HyAppAPI.activityWheel; + var hasToken = + window.HyAppAPI && + window.HyAppAPI.getAccessToken && + window.HyAppAPI.getAccessToken(); + if (useMock || !api || !api.history || !hasToken) { + renderEstimatedRewards(); + return Promise.resolve(); + } + return api + .history(state.wheelID, { pageNo: 1, pageSize: 12 }) + .then(function (payload) { + var rewards = normalizeHistoryRewards(payload); + if (rewards.length) state.estimatedRewards = rewards; + renderEstimatedRewards(); + }) + .catch(function () { + renderEstimatedRewards(); + }); + } + + function applyInviteOverview(payload) { + var data = envelope(payload); + var invite = data.invite || (data.profile && data.profile.invite) || {}; + state.inviteCode = text( + first(invite, [ + 'my_invite_code', + 'myInviteCode', + 'invite_code', + 'inviteCode', + ]) || + first(data, [ + 'my_invite_code', + 'myInviteCode', + 'invite_code', + 'inviteCode', + ]) + ); + state.inviteLink = buildInviteLink(); + } + + function loadInviteOverview() { + if (useMock) { + state.inviteCode = 'HUWAA2026'; + state.inviteLink = buildInviteLink(); + return Promise.resolve(); + } + var api = window.HyAppAPI; + var hasToken = api && api.getAccessToken && api.getAccessToken(); + if (!api || !api.get || !hasToken) return Promise.resolve(); + return api + .get('/api/v1/users/me/invite-overview') + .then(applyInviteOverview) + .catch(function () { + // 分享按钮在邀请数据缺失时保持不可分享状态,绝不拼接伪邀请码。 + }); + } + + function bindEvents() { + $('#huwaaBackButton').addEventListener('click', goBack); + $('#huwaaWithdrawButton').addEventListener('click', withdraw); + $('#huwaaSpinButton').addEventListener('click', startSpin); + $('#huwaaInviteButton').addEventListener('click', shareInvite); + $('#huwaaRewardEntry').addEventListener('click', openRewardDrawer); + $('#huwaaAwesomeButton').addEventListener('click', closeWin); + $('[data-close-win]').addEventListener('click', closeWin); + $('[data-close-rewards]').addEventListener('click', closeRewardDrawer); + $all('.huwaa-prize').forEach(function (button) { + button.addEventListener('click', openRewardDrawer); + }); + document.addEventListener('keydown', function (event) { + if (event.key !== 'Escape') return; + if (!$('#huwaaWinModal').hidden) closeWin(); + if (!$('#huwaaRewardDrawer').hidden) closeRewardDrawer(); + }); + } + + function init() { + bindEvents(); + renderAll(); + $('#huwaaPage').dataset.state = 'loading'; + Promise.all([ + loadWallet(), + loadWheelConfig(), + loadEstimatedRewards(), + loadInviteOverview(), + ]).then(function () { + $('#huwaaPage').dataset.state = 'ready'; + }); + if (window.HyAppBridge && window.HyAppBridge.ready) { + window.HyAppBridge.ready({ + page: 'huwaa-invite-cash-wheel', + app_code: 'huwaa', + }); + } + } + + window.addEventListener('hyapp:i18n-ready', function () { + renderAll(); + }); + + window.__HuwaaInvite = { + state: state, + openRewards: openRewardDrawer, + spin: startSpin, + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/activity/recharge-reward/fami.html b/activity/recharge-reward/fami.html new file mode 100644 index 0000000..f99285e --- /dev/null +++ b/activity/recharge-reward/fami.html @@ -0,0 +1,171 @@ + + + + + + Recharge Reward + + + + + +
+
+
+ + +
+ + + + + + Days + Hours + Minutes + Seconds +
+ +
+ + +

+

+

+
+ +
+ +
+
+
+ + + + + + + diff --git a/activity/recharge-reward/fami/assets/background-source-844-7068.png b/activity/recharge-reward/fami/assets/background-source-844-7068.png new file mode 100644 index 0000000..fb664aa Binary files /dev/null and b/activity/recharge-reward/fami/assets/background-source-844-7068.png differ diff --git a/activity/recharge-reward/fami/assets/countdown-frame-822-2954.png b/activity/recharge-reward/fami/assets/countdown-frame-822-2954.png new file mode 100644 index 0000000..a9ec701 Binary files /dev/null and b/activity/recharge-reward/fami/assets/countdown-frame-822-2954.png differ diff --git a/activity/recharge-reward/fami/assets/fonts/source-han-sans-sc-medium-latin.woff2 b/activity/recharge-reward/fami/assets/fonts/source-han-sans-sc-medium-latin.woff2 new file mode 100644 index 0000000..8519f51 Binary files /dev/null and b/activity/recharge-reward/fami/assets/fonts/source-han-sans-sc-medium-latin.woff2 differ diff --git a/activity/recharge-reward/fami/assets/fonts/source-han-sans-sc-regular-latin.woff2 b/activity/recharge-reward/fami/assets/fonts/source-han-sans-sc-regular-latin.woff2 new file mode 100644 index 0000000..66eacd1 Binary files /dev/null and b/activity/recharge-reward/fami/assets/fonts/source-han-sans-sc-regular-latin.woff2 differ diff --git a/activity/recharge-reward/fami/assets/mock-avatar-822-2960.png b/activity/recharge-reward/fami/assets/mock-avatar-822-2960.png new file mode 100644 index 0000000..fe7706b Binary files /dev/null and b/activity/recharge-reward/fami/assets/mock-avatar-822-2960.png differ diff --git a/activity/recharge-reward/fami/assets/mock-reward-822-2971.png b/activity/recharge-reward/fami/assets/mock-reward-822-2971.png new file mode 100644 index 0000000..cde45a5 Binary files /dev/null and b/activity/recharge-reward/fami/assets/mock-reward-822-2971.png differ diff --git a/activity/recharge-reward/fami/assets/profile-frame-840-4737.png b/activity/recharge-reward/fami/assets/profile-frame-840-4737.png new file mode 100644 index 0000000..088d592 Binary files /dev/null and b/activity/recharge-reward/fami/assets/profile-frame-840-4737.png differ diff --git a/activity/recharge-reward/fami/assets/receive-skin-822-2990.png b/activity/recharge-reward/fami/assets/receive-skin-822-2990.png new file mode 100644 index 0000000..2d8b099 Binary files /dev/null and b/activity/recharge-reward/fami/assets/receive-skin-822-2990.png differ diff --git a/activity/recharge-reward/fami/assets/reward-card-822-2969.png b/activity/recharge-reward/fami/assets/reward-card-822-2969.png new file mode 100644 index 0000000..2470005 Binary files /dev/null and b/activity/recharge-reward/fami/assets/reward-card-822-2969.png differ diff --git a/activity/recharge-reward/fami/assets/reward-panel-822-2967.png b/activity/recharge-reward/fami/assets/reward-panel-822-2967.png new file mode 100644 index 0000000..97f30b3 Binary files /dev/null and b/activity/recharge-reward/fami/assets/reward-panel-822-2967.png differ diff --git a/activity/recharge-reward/fami/fami.css b/activity/recharge-reward/fami/fami.css new file mode 100644 index 0000000..56542e0 --- /dev/null +++ b/activity/recharge-reward/fami/fami.css @@ -0,0 +1,437 @@ +@font-face { + font-family: 'Fami Source Han Sans SC'; + src: url('./assets/fonts/source-han-sans-sc-regular-latin.woff2') + format('woff2'); + font-style: normal; + font-weight: 400; + font-display: swap; +} + +@font-face { + font-family: 'Fami Source Han Sans SC'; + src: url('./assets/fonts/source-han-sans-sc-medium-latin.woff2') + format('woff2'); + font-style: normal; + font-weight: 500; + font-display: swap; +} + +* { + box-sizing: border-box; + -webkit-tap-highlight-color: transparent; +} + +:root { + --fami-design-width: 1080; + --fami-stage-scale: 0.3472222222; + --fami-stage-height: 3273px; + --fami-background-bottom-top: 2695px; +} + +html, +body { + margin: 0; + width: 100%; + min-height: 100%; + overflow-x: hidden; + background: #080116; + color: #f6e0b2; + font-family: 'Fami Source Han Sans SC', Arial, sans-serif; +} + +body { + display: flex; + justify-content: center; +} + +img { + display: block; +} + +.fami-recharge-app { + width: min(100vw, 430px); + min-height: calc(3273px * var(--fami-stage-scale)); + overflow: hidden; + background: #080116; +} + +.stage-wrap { + position: relative; + width: 100%; + height: calc(3273px * var(--fami-stage-scale)); + overflow: hidden; +} + +.stage { + position: absolute; + left: 0; + top: 0; + width: 1080px; + height: var(--fami-stage-height); + overflow: hidden; + transform: scale(var(--fami-stage-scale)); + transform-origin: top left; + background: #080116; +} + +.activity-background, +.reward-sections { + position: absolute; + inset: 0; +} + +.activity-background { + z-index: 0; + pointer-events: none; +} + +.background-segment { + position: absolute; + left: 0; + width: 1080px; + overflow: hidden; +} + +.background-segment img { + position: absolute; + left: 0; + width: 1080px; + height: 1887px; + max-width: none; + pointer-events: none; +} + +/* Figma 用同一背景节点的三段蒙版拼接长页;这里保留原始裁切坐标,而不是把根 Frame 当整页图片。 */ +.background-segment-mid { + top: 1311px; + height: 405px; + z-index: 0; +} + +.background-segment-mid img { + top: -1168px; +} + +.background-segment-top { + top: 0; + height: 1588px; + z-index: 1; +} + +.background-segment-top img { + top: 0; +} + +.background-segment-bottom { + top: var(--fami-background-bottom-top); + height: 578px; + z-index: 2; +} + +.background-segment-bottom img { + top: -1333px; +} + +.countdown, +.profile-card, +.reward-sections, +.page-status { + position: absolute; + z-index: 3; +} + +.countdown { + left: 90px; + top: 696px; + width: 900px; + height: 393px; +} + +.countdown-frame { + position: absolute; + inset: 0; + width: 900px; + height: 393px; + pointer-events: none; +} + +.count-label, +.count-value { + position: absolute; + margin: 0; + transform: translateX(-50%); + text-align: center; + white-space: nowrap; +} + +.count-label { + top: 280px; + width: 180px; + height: 24px; + color: #050300; + font-family: 'Fami Source Han Sans SC', Arial, sans-serif; + font-size: 24px; + font-weight: 400; + line-height: 24px; + text-transform: capitalize; +} + +.count-value { + display: none; + top: 142px; + width: 150px; + height: 92px; + color: #f6e0b2; + font-size: 68px; + font-weight: 500; + line-height: 92px; + text-shadow: 0 3px 8px rgba(0, 0, 0, 0.72); +} + +.countdown.has-values .count-value { + display: block; +} + +.count-label-days, +.count-value-days { + left: 145px; +} + +.count-label-hours, +.count-value-hours { + left: 349px; +} + +.count-label-minutes, +.count-value-minutes { + left: 553.5px; +} + +.count-label-seconds, +.count-value-seconds { + left: 753.5px; +} + +.profile-card { + left: 90px; + top: 1089px; + width: 900px; + height: 679px; +} + +.profile-frame { + position: absolute; + inset: 0; + width: 900px; + height: 679px; + pointer-events: none; +} + +.profile-avatar { + position: absolute; + left: 309px; + top: 134px; + width: 282px; + height: 270px; + border-radius: 50%; + object-fit: cover; +} + +.profile-name, +.profile-id, +.profile-recharge { + position: absolute; + left: 100px; + width: 700px; + margin: 0; + overflow: hidden; + color: #f6e0b2; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; +} + +.profile-name { + top: 425px; + height: 36px; + font-family: 'Fami Source Han Sans SC', Arial, sans-serif; + font-size: 36px; + font-weight: 400; + line-height: 36px; +} + +.profile-id { + top: 476px; + height: 36px; + font-family: 'Fami Source Han Sans SC', Arial, sans-serif; + font-size: 24px; + font-weight: 400; + line-height: 36px; +} + +.profile-recharge { + top: 520px; + height: 40px; + font-size: 36px; + font-weight: 500; + line-height: 36px; +} + +.reward-sections { + width: 1080px; + height: var(--fami-stage-height); +} + +.reward-section { + position: absolute; + left: 0; + top: var(--section-top); + width: 1080px; + height: 720px; +} + +.reward-panel { + position: absolute; + left: 90px; + top: 0; + width: 900px; + height: 689px; + pointer-events: none; +} + +.reward-title { + position: absolute; + left: 529.5px; + top: 95px; + margin: 0; + transform: translateX(-50%); + color: #f6e0b2; + font-size: 40px; + font-weight: 500; + line-height: 40px; + text-align: center; + white-space: nowrap; +} + +.reward-section.is-following .reward-title { + left: 559.5px; + top: 99px; +} + +.reward-card { + position: absolute; + width: 229px; + height: 258px; + color: #f6e0b2; + text-align: center; +} + +.reward-card-frame { + position: absolute; + inset: 0; + width: 229px; + height: 258px; + pointer-events: none; +} + +.reward-media { + position: absolute; + left: 18px; + top: 19px; + width: 193px; + height: 193px; + object-fit: contain; +} + +.reward-section.is-following .reward-media { + left: 18.5px; + top: 21px; +} + +.reward-price { + position: absolute; + left: 0; + top: 202px; + width: 229px; + height: 32px; + overflow: hidden; + color: #f6e0b2; + font-size: 32px; + font-weight: 500; + line-height: 32px; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; +} + +.reward-action { + position: absolute; + left: 341px; + top: 591px; + width: 399px; + height: 129px; +} + +.reward-section.is-following .reward-action { + top: 604px; +} + +.reward-action-skin { + position: absolute; + inset: 0; + width: 399px; + height: 129px; + pointer-events: none; +} + +.reward-action-label { + position: absolute; + left: 0; + top: 45px; + width: 399px; + height: 40px; + overflow: hidden; + color: #f6e0b2; + font-family: + 'SF Pro Display', + 'SF Pro Text', + -apple-system, + BlinkMacSystemFont, + Arial, + sans-serif; + font-size: 40px; + font-weight: 510; + font-variation-settings: + 'wght' 510, + 'wdth' 100; + line-height: 40px; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; +} + +.page-status { + left: 190px; + top: 1730px; + width: 700px; + margin: 0; + color: #f6e0b2; + font-size: 28px; + line-height: 40px; + text-align: center; +} + +@media (min-width: 431px) { + html, + body { + background: #05000e; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + } +} diff --git a/activity/recharge-reward/fami/fami.js b/activity/recharge-reward/fami/fami.js new file mode 100644 index 0000000..3e7986c --- /dev/null +++ b/activity/recharge-reward/fami/fami.js @@ -0,0 +1,495 @@ +(function () { + 'use strict'; + + var DESIGN_WIDTH = 1080; + var BASE_STAGE_HEIGHT = 3273; + var SECTION_STEP = 720; + var MAX_VIEWPORT_WIDTH = 430; + var STATUS_PATH = '/api/v1/activities/cumulative-recharge-reward'; + + var appViewport = document.getElementById('appViewport'); + var stageWrap = document.getElementById('stageWrap'); + var stage = document.getElementById('stage'); + var countdown = document.getElementById('countdown'); + var rewardSections = document.getElementById('rewardSections'); + var profileName = document.getElementById('profileName'); + var profileID = document.getElementById('profileID'); + var profileRecharge = document.getElementById('profileRecharge'); + var profileAvatar = document.getElementById('profileAvatar'); + var pageStatus = document.getElementById('pageStatus'); + var countNodes = { + days: document.querySelector('[data-count="days"]'), + hours: document.querySelector('[data-count="hours"]'), + minutes: document.querySelector('[data-count="minutes"]'), + seconds: document.querySelector('[data-count="seconds"]'), + }; + + var currentData = null; + var designHeight = BASE_STAGE_HEIGHT; + var countdownEndAt = 0; + var timeOffsetMS = 0; + var countdownTimer = 0; + + function queryParams() { + return new URLSearchParams(window.location.search || ''); + } + + function shouldUseMock(params) { + var value = String(params.get('mock') || '').toLowerCase(); + // 设计数据只允许显式 mock=1/true;生产接口失败时不得回退到看似可用的虚假奖励。 + return value === '1' || value === 'true'; + } + + function t(key, fallback) { + return window.HyAppI18n && typeof window.HyAppI18n.t === 'function' + ? window.HyAppI18n.t(key, fallback) + : fallback || key; + } + + function escapeHTML(value) { + return String(value == null ? '' : value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function firstValue(source, keys, fallback) { + source = source && typeof source === 'object' ? source : {}; + for (var index = 0; index < keys.length; index += 1) { + var value = source[keys[index]]; + if (value !== undefined && value !== null && value !== '') + return value; + } + return fallback; + } + + function numberValue(source, keys, fallback) { + var value = Number(firstValue(source, keys, fallback)); + return Number.isFinite(value) ? value : Number(fallback || 0); + } + + function wholeUSD(minor) { + var value = Math.max(0, Number(minor || 0)) / 100; + return value % 1 === 0 + ? String(value) + : value.toFixed(2).replace(/0+$/, '').replace(/\.$/, ''); + } + + function tierAmount(minor) { + return '$' + wholeUSD(minor); + } + + function twoDigits(value) { + return String(Math.max(0, Math.floor(value))).padStart(2, '0'); + } + + function updateScale() { + var viewportWidth = Math.min( + window.innerWidth || 375, + MAX_VIEWPORT_WIDTH + ); + var scale = viewportWidth / DESIGN_WIDTH; + var renderedHeight = designHeight * scale; + document.documentElement.style.setProperty( + '--fami-stage-scale', + String(scale) + ); + document.documentElement.style.setProperty( + '--fami-stage-height', + designHeight + 'px' + ); + document.documentElement.style.setProperty( + '--fami-background-bottom-top', + designHeight - 578 + 'px' + ); + appViewport.style.minHeight = renderedHeight + 'px'; + stageWrap.style.height = renderedHeight + 'px'; + stage.style.height = designHeight + 'px'; + } + + function mockData(params) { + var rechargeValue = Number(params.get('recharge') || 5998); + var explicitEnd = Date.parse( + params.get('end_at') || params.get('endAt') || '' + ); + var rewardItem = { + item_type: 'resource', + resource: { + name: 'Figma reward fixture', + preview_url: './fami/assets/mock-reward-822-2971.png', + }, + }; + return { + enabled: true, + user: { + username: params.get('name') || 'nanfangjian', + display_user_id: + params.get('uid') || params.get('id') || '12345678', + avatar: + params.get('avatar') || + './fami/assets/mock-avatar-822-2960.png', + }, + period: { + // Figma 节点没有倒计时数字;只有显式 end_at 才展示动态数字,避免凭空猜一个验收值。 + end_ms: Number.isFinite(explicitEnd) ? explicitEnd : 0, + }, + progress: { + total_usd_minor: Math.max(0, Math.round(rechargeValue * 100)), + }, + tiers: [ + { + tier_id: 1, + threshold_usd_minor: 10000, + reached: true, + resource_group: { items: [rewardItem, rewardItem] }, + }, + { + tier_id: 2, + threshold_usd_minor: 10000, + reached: true, + resource_group: { + items: [rewardItem, rewardItem, rewardItem], + }, + }, + ], + grants: [], + server_time_ms: Date.now(), + }; + } + + function resourcePreview(item) { + var resource = (item && item.resource) || {}; + // preview_url 是 gateway 明确下发给静态 H5 的封面;asset_url 可能是动画,仅作为旧数据兜底。 + return String( + firstValue( + resource, + [ + 'preview_url', + 'previewUrl', + 'asset_url', + 'assetUrl', + 'animation_url', + 'animationUrl', + ], + '' + ) || '' + ); + } + + function normalizeData(payload) { + payload = payload && payload.status ? payload.status : payload || {}; + var config = payload.config || {}; + var rawTiers = Array.isArray(payload.tiers) + ? payload.tiers + : Array.isArray(config.tiers) + ? config.tiers + : []; + var grants = Array.isArray(payload.grants) ? payload.grants : []; + var grantByTier = {}; + grants.forEach(function (grant) { + var tierID = String(firstValue(grant, ['tier_id', 'tierId'], '')); + if (tierID) grantByTier[tierID] = grant; + }); + + var tiers = rawTiers + .slice() + .sort(function (a, b) { + return ( + numberValue(a, ['sort_order', 'sortOrder'], 0) - + numberValue(b, ['sort_order', 'sortOrder'], 0) + ); + }) + .map(function (tier) { + var group = tier.resource_group || tier.resourceGroup || {}; + var items = Array.isArray(group.items) ? group.items : []; + var tierID = String( + firstValue(tier, ['tier_id', 'tierId'], '') + ); + var grant = tier.grant || grantByTier[tierID] || null; + return { + tierID: tierID, + thresholdUSDMinor: numberValue( + tier, + ['threshold_usd_minor', 'thresholdUsdMinor'], + 0 + ), + reached: Boolean(firstValue(tier, ['reached'], false)), + grant: grant, + cards: items.slice(0, 3).map(function (item) { + return { + name: firstValue( + (item && item.resource) || {}, + ['name'], + '' + ), + preview: resourcePreview(item), + }; + }), + }; + }); + + return { + enabled: + firstValue( + payload, + ['enabled'], + firstValue(config, ['enabled'], true) + ) !== false, + user: payload.user || {}, + period: payload.period || {}, + progress: payload.progress || {}, + serverTimeMS: numberValue( + payload, + ['server_time_ms', 'serverTimeMs'], + Date.now() + ), + tiers: tiers, + }; + } + + function grantWasIssued(grant) { + if (!grant) return false; + var status = String(firstValue(grant, ['status'], '')).toLowerCase(); + return ( + ['granted', 'success', 'succeeded', 'completed'].indexOf(status) >= + 0 + ); + } + + function cardPositions(count) { + if (count <= 1) return [425.5]; + if (count === 2) return [201, 650]; + return [160.5, 429.5, 698.5]; + } + + function renderCard(card, section, cardIndex, left) { + var media = card.preview + ? '' +
+              escapeHTML(card.name) +
+              '' + : ''; + return [ + '
', + '', + media, + '', + escapeHTML(tierAmount(section.thresholdUSDMinor)), + '', + '
', + ].join(''); + } + + function renderSections(tiers) { + designHeight = Math.max( + BASE_STAGE_HEIGHT, + BASE_STAGE_HEIGHT + Math.max(0, tiers.length - 2) * SECTION_STEP + ); + rewardSections.innerHTML = tiers + .map(function (tier, sectionIndex) { + var section = { + thresholdUSDMinor: tier.thresholdUSDMinor, + cardTop: sectionIndex === 0 ? 293 : 278, + }; + var positions = cardPositions(tier.cards.length); + var actionKey = grantWasIssued(tier.grant) + ? 'rechargeReward.claimed' + : 'rechargeReward.receive'; + var actionFallback = grantWasIssued(tier.grant) + ? 'Claimed' + : 'Receive'; + return [ + '
', + '', + '

', + escapeHTML( + t( + 'rechargeReward.rechargeTitle', + 'Recharge {amount}' + ).replace( + '{amount}', + tierAmount(tier.thresholdUSDMinor) + ) + ), + '

', + tier.cards + .map(function (card, cardIndex) { + return renderCard( + card, + section, + cardIndex, + positions[cardIndex] + ); + }) + .join(''), + // 累充奖励由 wallet 充值事件自动发放,gateway 没有 claim RPC;设计中的 Receive 仅显示服务端状态,禁止伪造领取请求。 + '
', + '', + '', + escapeHTML(t(actionKey, actionFallback)), + '', + '
', + '
', + ].join(''); + }) + .join(''); + updateScale(); + } + + function renderProfile(data) { + var user = data.user || {}; + var displayID = firstValue( + user, + ['display_user_id', 'displayUserId', 'user_id', 'userId'], + '' + ); + var name = firstValue(user, ['username', 'name'], ''); + var avatar = String( + firstValue(user, ['avatar', 'avatar_url', 'avatarUrl'], '') || '' + ); + var totalMinor = numberValue( + data.progress || {}, + ['total_usd_minor', 'totalUsdMinor'], + 0 + ); + + profileName.textContent = name; + profileID.textContent = displayID + ? t('rechargeReward.idPrefix', 'ID:') + ' ' + displayID + : ''; + profileRecharge.textContent = + t('rechargeReward.accumulatedRecharge', 'Accumulated Recharge:') + + wholeUSD(totalMinor); + if (avatar) { + profileAvatar.src = avatar; + profileAvatar.hidden = false; + } else { + profileAvatar.removeAttribute('src'); + profileAvatar.hidden = true; + } + } + + function updateCountdown() { + if (!countdownEndAt) { + countdown.classList.remove('has-values'); + Object.keys(countNodes).forEach(function (key) { + countNodes[key].textContent = ''; + }); + return; + } + var remaining = Math.max( + 0, + countdownEndAt - (Date.now() - timeOffsetMS) + ); + var totalSeconds = Math.floor(remaining / 1000); + countNodes.days.textContent = twoDigits(totalSeconds / 86400); + countNodes.hours.textContent = twoDigits((totalSeconds % 86400) / 3600); + countNodes.minutes.textContent = twoDigits((totalSeconds % 3600) / 60); + countNodes.seconds.textContent = twoDigits(totalSeconds % 60); + countdown.classList.add('has-values'); + } + + function render(data) { + currentData = normalizeData(data); + renderProfile(currentData); + renderSections(currentData.tiers); + timeOffsetMS = Date.now() - currentData.serverTimeMS; + countdownEndAt = numberValue( + currentData.period, + ['end_ms', 'endMs'], + 0 + ); + updateCountdown(); + if (countdownTimer) window.clearInterval(countdownTimer); + if (countdownEndAt) + countdownTimer = window.setInterval(updateCountdown, 1000); + pageStatus.hidden = true; + pageStatus.textContent = ''; + stage.setAttribute('aria-busy', 'false'); + } + + function loadStatus(params) { + if (shouldUseMock(params)) return Promise.resolve(mockData(params)); + if (!window.HyAppAPI || typeof window.HyAppAPI.get !== 'function') { + return Promise.reject(new Error('api_unavailable')); + } + return window.HyAppAPI.get(STATUS_PATH); + } + + function showLoadError(error) { + console.warn('[recharge-reward/fami] status load failed', error); + currentData = null; + rewardSections.innerHTML = ''; + profileName.textContent = ''; + profileID.textContent = ''; + profileRecharge.textContent = ''; + profileAvatar.hidden = true; + pageStatus.textContent = t('rechargeReward.loadFailed', 'Load failed'); + pageStatus.hidden = false; + stage.setAttribute('aria-busy', 'false'); + } + + function init() { + var params = queryParams(); + document.title = t('rechargeReward.pageTitle', 'Recharge Reward'); + updateScale(); + loadStatus(params).then(render).catch(showLoadError); + + window.addEventListener('resize', updateScale); + window.addEventListener('hyapp:i18n-ready', function () { + document.title = t('rechargeReward.pageTitle', 'Recharge Reward'); + if (currentData) { + // 语言包只影响 DOM 文案;复用已归一化的业务状态,不能再次按原始 API 结构解析而丢失奖励卡。 + renderProfile(currentData); + renderSections(currentData.tiers); + updateCountdown(); + } else if (!pageStatus.hidden) + pageStatus.textContent = t( + 'rechargeReward.loadFailed', + 'Load failed' + ); + }); + + // 远程头像或奖励封面失效时只隐藏业务媒体,装饰框和真实金额状态仍保留,避免回退到伪造素材。 + stage.addEventListener( + 'error', + function (event) { + if ( + event.target === profileAvatar || + (event.target && + event.target.classList.contains('reward-media')) + ) { + event.target.hidden = true; + } + }, + true + ); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init, { once: true }); + } else { + init(); + } +})(); diff --git a/activity/recharge-reward/fami/manifest.json b/activity/recharge-reward/fami/manifest.json new file mode 100644 index 0000000..ae74248 --- /dev/null +++ b/activity/recharge-reward/fami/manifest.json @@ -0,0 +1,103 @@ +{ + "figma": { + "fileKey": "OTOg5cwbhVNWxkImGmzTJl", + "page": "Fami运营", + "rootNode": "822:2928", + "rootSize": { + "width": 1080, + "height": 3273 + } + }, + "rules": { + "rootFrameUsedAsAsset": false, + "dynamicTextRenderedByDOM": true, + "productionBusinessMediaFromAPI": true, + "receiveControlMode": "read-only grant status; backend grants automatically and exposes no claim RPC" + }, + "assets": [ + { + "path": "assets/background-source-844-7068.png", + "sourceNode": "844:7068", + "purpose": "single-node decorative background source, clipped into the three Figma mask ranges by CSS", + "width": 1080, + "height": 1887, + "sha256": "1ee73b4ba3a47cd8cafbc6d42bd7350cdcfc25237f022991d7d190d7d8b22212", + "mockOnly": false + }, + { + "path": "assets/countdown-frame-822-2954.png", + "sourceNode": "822:2954", + "purpose": "countdown ornament and empty slots; labels and values remain DOM", + "width": 900, + "height": 393, + "sha256": "fe4cccc3747f37d9c2cf127e704d68a1e453df61c5f6241b187d29f0291d525a", + "mockOnly": false + }, + { + "path": "assets/profile-frame-840-4737.png", + "sourceNode": "840:4737", + "purpose": "profile card ornament; avatar, name, id and recharge amount remain independent DOM/data", + "width": 900, + "height": 679, + "sha256": "15946e593bdd4341e78fadf1a7b63202d54b38e650aabba6ed2397428e3f6377", + "mockOnly": false + }, + { + "path": "assets/reward-panel-822-2967.png", + "sourceNode": "822:2967", + "purpose": "reward tier panel ornament; tier title, cards and state remain DOM", + "width": 900, + "height": 689, + "sha256": "2464ef72797d6f30730a2d960bbdf9da0a89cab86144937c23614dfdcbdf508f", + "mockOnly": false + }, + { + "path": "assets/reward-card-822-2969.png", + "sourceNode": "822:2969", + "purpose": "single reward-card skin; resource preview and amount remain independent", + "width": 229, + "height": 258, + "sha256": "a7029c276f2b6bc05a89c26c89db1f654f587d8be90347b9f29cd36a41381c69", + "mockOnly": false + }, + { + "path": "assets/receive-skin-822-2990.png", + "sourceNode": "822:2990", + "purpose": "status skin only; Receive or Claimed is DOM text and is not a fake claim control", + "width": 399, + "height": 129, + "sha256": "a32fbee534c2a0ae98f16bc3624b6e575b78493bebdc72fd026d2172415f8126", + "mockOnly": false + }, + { + "path": "assets/mock-avatar-822-2960.png", + "sourceNode": "822:2960", + "purpose": "explicit mock=1 Figma fixture only; production avatar comes from status.user.avatar", + "width": 282, + "height": 270, + "sha256": "97c96761e348811f982cd8216b8e8f312f908337a692cccaca4212d189df2b09", + "mockOnly": true + }, + { + "path": "assets/mock-reward-822-2971.png", + "sourceNode": "822:2971", + "purpose": "explicit mock=1 Figma fixture only; production previews come from resource.preview_url", + "width": 193, + "height": 193, + "sha256": "32e56831590c49ab34b1f06c5aaaeffadd370564beab4742fabf3a06323370b3", + "mockOnly": true + } + ], + "fonts": [ + { + "path": "assets/fonts/source-han-sans-sc-regular-latin.woff2", + "source": "https://github.com/adobe-fonts/source-han-sans/tree/release/OTF/SimplifiedChinese/SourceHanSansSC-Regular.otf", + "sha256": "5a1880101a54dfbf9a4fce716eae6f4e9e84e3d1fd7b465613eee00dc22ee03c" + }, + { + "path": "assets/fonts/source-han-sans-sc-medium-latin.woff2", + "source": "activity/cp-space/fami/assets/fonts/source-han-sans-sc-medium-latin.woff2", + "sha256": "0c5b80890935bdf1ab542338c7f5ebe5c4fc678c76873c843a9c3be1c6b23e57" + } + ] +} diff --git a/activity/room-reward/fami.html b/activity/room-reward/fami.html new file mode 100644 index 0000000..8c835f9 --- /dev/null +++ b/activity/room-reward/fami.html @@ -0,0 +1,178 @@ + + + + + + Room Reward Event + + + + + + +
+
+
+ + +
+ +
+ + +
+
+ +
+ + + + +
+
+ +

+ Loading... +

+
+
+
+ + + + + + + + + + diff --git a/activity/room-reward/fami/assets/layers/avatar-mock.webp b/activity/room-reward/fami/assets/layers/avatar-mock.webp new file mode 100644 index 0000000..97691e8 Binary files /dev/null and b/activity/room-reward/fami/assets/layers/avatar-mock.webp differ diff --git a/activity/room-reward/fami/assets/layers/coin-target.webp b/activity/room-reward/fami/assets/layers/coin-target.webp new file mode 100644 index 0000000..bbd49af Binary files /dev/null and b/activity/room-reward/fami/assets/layers/coin-target.webp differ diff --git a/activity/room-reward/fami/assets/layers/coin-tier.webp b/activity/room-reward/fami/assets/layers/coin-tier.webp new file mode 100644 index 0000000..3da61ee Binary files /dev/null and b/activity/room-reward/fami/assets/layers/coin-tier.webp differ diff --git a/activity/room-reward/fami/assets/layers/frame-purple-gold.webp b/activity/room-reward/fami/assets/layers/frame-purple-gold.webp new file mode 100644 index 0000000..2471a88 Binary files /dev/null and b/activity/room-reward/fami/assets/layers/frame-purple-gold.webp differ diff --git a/activity/room-reward/fami/assets/layers/hero-fade-mask.svg b/activity/room-reward/fami/assets/layers/hero-fade-mask.svg new file mode 100644 index 0000000..05a1348 --- /dev/null +++ b/activity/room-reward/fami/assets/layers/hero-fade-mask.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/activity/room-reward/fami/assets/layers/hero-room-reward.webp b/activity/room-reward/fami/assets/layers/hero-room-reward.webp new file mode 100644 index 0000000..b5ba82c Binary files /dev/null and b/activity/room-reward/fami/assets/layers/hero-room-reward.webp differ diff --git a/activity/room-reward/fami/assets/layers/pill-disabled.webp b/activity/room-reward/fami/assets/layers/pill-disabled.webp new file mode 100644 index 0000000..b507561 Binary files /dev/null and b/activity/room-reward/fami/assets/layers/pill-disabled.webp differ diff --git a/activity/room-reward/fami/assets/layers/table-border-ornament.webp b/activity/room-reward/fami/assets/layers/table-border-ornament.webp new file mode 100644 index 0000000..f5d7fc9 Binary files /dev/null and b/activity/room-reward/fami/assets/layers/table-border-ornament.webp differ diff --git a/activity/room-reward/fami/assets/manifest.json b/activity/room-reward/fami/assets/manifest.json new file mode 100644 index 0000000..e0cd6e3 --- /dev/null +++ b/activity/room-reward/fami/assets/manifest.json @@ -0,0 +1,112 @@ +{ + "schemaVersion": 1, + "figma": { + "fileKey": "OTOg5cwbhVNWxkImGmzTJl", + "frameNode": "822:2775", + "frameName": "room reward event", + "designSize": [1080, 1985] + }, + "policy": { + "forbiddenNodes": ["822:2775", "822:2783"], + "forbiddenRuntimeAssets": [".audit/figma-default.png"], + "rule": "Only single visual layers are assets. Text, table rows, state, controls, and dynamic media are DOM/CSS/API driven." + }, + "assets": [ + { + "file": "hero-room-reward.webp", + "nodes": ["822:2778"], + "role": "hero illustration", + "kind": "single-raster-illustration", + "design": [1080, 810], + "note": "Single Figma image-fill artwork; dynamic page content is not embedded.", + "sha256": "e26e31a04e42a1562aecd7671c8c3d59f54e3b462b1b16d73dcc075f4ce89653", + "sourceSha256": "f8236a4fe58aadc228993f415146eb70832e7edfa9291e31736731d7cf8ab8ae", + "pixelSize": [1080, 810], + "sourceFile": "hero-source.bin" + }, + { + "file": "hero-fade-mask.svg", + "nodes": ["822:2777"], + "role": "hero bottom fade mask", + "kind": "single-mask", + "design": [1080, 810], + "note": "Mask for the hero illustration only.", + "sha256": "a2f81f0beaabab653ba390bfa0d8f0fa3bfcc7ad01a0c3e9744d727163c069b6", + "sourceSha256": "a2f81f0beaabab653ba390bfa0d8f0fa3bfcc7ad01a0c3e9744d727163c069b6", + "pixelSize": [1080, 810], + "sourceFile": "hero-mask.bin" + }, + { + "file": "frame-purple-gold.webp", + "nodes": ["822:2779", "822:2911"], + "role": "target and active value ornamental frame", + "kind": "single-ornamental-frame", + "design": [900, 289], + "note": "Reused at 215x69 for the value pill; all numbers remain DOM text.", + "sha256": "3e4dbc62c88914cf7dd9b06daf67afd8a9fcbb7af12cd40f4fbaacd88eac53bb", + "sourceSha256": "98c9dfbea98bca32e7cf97cf4f6e62185f472b7ca7bcfa4ff494aab7695b1994", + "pixelSize": [900, 289], + "sourceFile": "purple-frame.bin" + }, + { + "file": "table-border-ornament.webp", + "nodes": ["822:2789", "822:2795", "822:2830"], + "role": "table top and side border ornament", + "kind": "single-ornamental-border-source", + "design": [1008, 1003], + "note": "CSS clips one source into top/left/right decorative rails; rows and labels remain DOM.", + "sha256": "912ab4da6b606cbe75c618e37123c30538e87302d1d9e137690f4c0ef57fdf17", + "sourceSha256": "3d854ee220f85b25a7525f2e4255e9f741f7aaf100853f5661b6dce58128572c", + "pixelSize": [1008, 1003], + "sourceFile": "border-source.bin" + }, + { + "file": "coin-target.webp", + "nodes": ["822:2782"], + "role": "target currency icon", + "kind": "single-icon", + "design": [44, 44], + "note": "Decorative currency icon beside a DOM value.", + "sha256": "3c78529eaf1c85ccb56ceb5af17ed7b1ba95f61229d7d085e10ed11e2941a0b9", + "sourceSha256": "28f2737ecf85a13858b02b45b447c132ea2bee6cc32d6eceec95f17c85ac0d0c", + "pixelSize": [44, 44], + "sourceFile": "coin-large.bin" + }, + { + "file": "coin-tier.webp", + "nodes": ["822:2869"], + "role": "tier reward currency icon", + "kind": "single-icon", + "design": [38, 38], + "note": "Repeated icon only; reward values remain DOM text.", + "sha256": "796d13d982fd4d96f30312a61eddc65a683d6d32a508db766df00298f3364009", + "sourceSha256": "f0ac90be6a9dc4fb471d6b6fe9a03a0e465b8ddcb11b932f6e7cd10b5d1a0251", + "pixelSize": [38, 38], + "sourceFile": "coin-small.bin" + }, + { + "file": "pill-disabled.webp", + "nodes": ["822:2913"], + "role": "disabled reward value ornamental frame", + "kind": "single-ornamental-frame", + "design": [215, 69], + "note": "Display frame only; not a claim control.", + "sha256": "a6caf2d3311b4d99d32fbd9ff63eb6debe1bb9d208ad45ec9551cf69190b221e", + "sourceSha256": "d93927d705a527ba7528d1399fed3eac3afc649530c3fda024f8b6e994e7bf46", + "pixelSize": [215, 69], + "sourceFile": "button-disabled.bin" + }, + { + "file": "avatar-mock.webp", + "nodes": ["822:2904"], + "role": "Figma QA mock avatar", + "kind": "single-dynamic-media-fixture", + "design": [119, 119], + "note": "Used only when ?mock=1; production uses API user.avatar/room.cover_url.", + "sha256": "6297d792db71672c9eb39103ebc1cad51257f6243ce115bd2a11bb51ad57dc7f", + "sourceSha256": "42f8c005b3c77796b55dac4cfe5096fbe2773d46c9d78d1114b59a41c45b5a68", + "pixelSize": [384, 512], + "sourceFile": "avatar-source.bin" + } + ] +} diff --git a/activity/room-reward/fami/fami.css b/activity/room-reward/fami/fami.css new file mode 100644 index 0000000..e5128cc --- /dev/null +++ b/activity/room-reward/fami/fami.css @@ -0,0 +1,468 @@ +@font-face { + font-family: 'RR Source Han Sans'; + src: url('../../cp-space/fami/assets/fonts/source-han-sans-sc-medium-latin.woff2') + format('woff2'); + font-style: normal; + font-weight: 500; + font-display: swap; +} + +* { + box-sizing: border-box; + -webkit-tap-highlight-color: transparent; +} + +:root { + --rr-scale: 0.3472222222; + --rr-page-width: 375px; + --rr-stage-height: 1985px; + --rr-purple-bg: #160029; + --rr-row-dark: #0e0119; + --rr-footer-bg: #320b55; +} + +html, +body { + margin: 0; + width: 100%; + min-height: 100%; + overflow-x: hidden; + background: var(--rr-purple-bg, var(--hy-theme-bg)); + color: #fff; + font-family: 'RR Source Han Sans', Arial, 'Helvetica Neue', sans-serif; +} + +body { + min-height: 100vh; +} + +img { + display: block; + user-select: none; + -webkit-user-drag: none; +} + +[hidden] { + display: none !important; +} + +.rr-app { + position: relative; + width: var(--rr-page-width); + min-height: 100vh; + margin: 0 auto; + overflow: visible; + background: var(--rr-purple-bg); +} + +.rr-stage-shell { + position: relative; + width: var(--rr-page-width); + height: 689.236px; + overflow: hidden; + background: var(--rr-purple-bg); +} + +.rr-stage { + position: absolute; + top: 0; + left: 0; + width: 1080px; + height: var(--rr-stage-height); + overflow: hidden; + transform: scale(var(--rr-scale)); + transform-origin: top left; + background: var(--rr-purple-bg); +} + +.rr-hero { + position: absolute; + z-index: 1; + top: 0; + left: 0; + width: 1080px; + height: 810px; + pointer-events: none; + -webkit-mask-image: url('./assets/layers/hero-fade-mask.svg'); + -webkit-mask-position: 0 0; + -webkit-mask-repeat: no-repeat; + -webkit-mask-size: 1080px 810px; + mask-image: url('./assets/layers/hero-fade-mask.svg'); + mask-position: 0 0; + mask-repeat: no-repeat; + mask-size: 1080px 810px; +} + +.rr-hero img { + width: 1080px; + height: 810px; + object-fit: fill; +} + +.rr-target { + position: absolute; + z-index: 3; + top: 665px; + left: 90px; + width: 900px; + height: 289px; +} + +.rr-target-skin { + position: absolute; + inset: 0; + width: 900px; + height: 289px; + object-fit: fill; + pointer-events: none; +} + +.rr-target-value { + position: absolute; + top: 105px; + left: 330px; + display: flex; + align-items: center; + width: 241px; + height: 80px; + gap: 14px; + color: #ffe6b9; + white-space: nowrap; +} + +.rr-target-value img { + flex: 0 0 auto; + width: 44px; + height: 44px; + object-fit: contain; + position: relative; + top: 8px; +} + +.rr-target-value strong { + display: block; + width: 183px; + min-width: 0; + overflow: hidden; + color: #ffe6b9; + font-size: 80px; + font-weight: 500; + line-height: 80px; + letter-spacing: 0; + text-overflow: ellipsis; +} + +.rr-table { + position: absolute; + z-index: 2; + top: 1005px; + left: 36px; + width: 1008px; + height: 980px; + overflow: hidden; +} + +.rr-table-top-ornament { + position: absolute; + z-index: 4; + top: 0; + left: 0; + width: 1008px; + height: 15px; + overflow: hidden; + pointer-events: none; +} + +.rr-table-top-ornament img { + position: absolute; + top: 0; + left: 0; + width: 1008px; + height: 1003px; + max-width: none; +} + +.rr-table-side-ornament { + position: absolute; + z-index: 4; + top: 15px; + width: 20px; + height: 965px; + overflow: hidden; + pointer-events: none; +} + +.rr-table-side-ornament img { + position: absolute; + top: -15px; + width: 1008px; + height: 1003px; + max-width: none; +} + +.rr-table-side-left { + left: 0; +} + +.rr-table-side-left img { + left: 0; +} + +.rr-table-side-right { + right: 0; +} + +.rr-table-side-right img { + right: 0; +} + +.rr-table-divider { + position: absolute; + z-index: 3; + top: 15px; + left: 503px; + width: 2px; + height: 824px; + background: rgba(255, 255, 255, 0.92); + pointer-events: none; +} + +.rr-table-content { + position: absolute; + z-index: 2; + top: 15px; + left: 20px; + width: 968px; + min-height: 824px; +} + +.rr-tier-row { + display: grid; + grid-template-columns: 484px 484px; + width: 968px; + height: 103px; + color: #fff; + font-size: 30px; + font-weight: 500; + line-height: 32px; + letter-spacing: 0; +} + +.rr-tier-row:nth-child(odd) { + background: var(--rr-row-dark); +} + +.rr-tier-row:nth-child(even) { + background: var(--rr-purple-bg); +} + +.rr-tier-cell { + display: flex; + align-items: center; + justify-content: center; + min-width: 0; + height: 103px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.rr-tier-row-head .rr-tier-cell { + padding-top: 2px; +} + +.rr-tier-reward { + gap: 8px; + color: #d7a8ff; +} + +.rr-tier-reward img { + flex: 0 0 auto; + width: 38px; + height: 38px; + object-fit: contain; +} + +.rr-tier-placeholder { + color: rgba(255, 255, 255, 0.42); +} + +.rr-footer-shell { + position: fixed; + z-index: 80; + bottom: 0; + left: 50%; + width: var(--rr-page-width); + height: 58.681px; + overflow: visible; + transform: translateX(-50%); +} + +.rr-user-panel { + position: absolute; + top: 0; + left: 0; + width: 1080px; + height: 169px; + overflow: hidden; + transform: scale(var(--rr-scale)); + transform-origin: top left; + border-radius: 24px 24px 0 0; + background: var(--rr-footer-bg); +} + +.rr-avatar-shell { + position: absolute; + top: 25px; + left: 46px; + display: block; + width: 119px; + height: 119px; + overflow: hidden; + border: 3px solid #e3bbff; + border-radius: 50%; + background: #21053b; +} + +.rr-avatar-shell img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: 50% 32%; +} + +html.rr-mock .rr-avatar-shell img { + position: absolute; + top: -17px; + left: -41px; + width: 185px; + height: 246px; + max-width: none; + object-fit: fill; +} + +.rr-user-name { + position: absolute; + top: 36px; + left: 189px; + width: 288px; + overflow: hidden; + color: #fff; + font-size: 40px; + font-weight: 500; + line-height: 44px; + white-space: nowrap; + text-overflow: ellipsis; +} + +.rr-user-id { + position: absolute; + top: 102px; + left: 189px; + width: 292px; + overflow: hidden; + color: rgba(255, 255, 255, 0.7); + font-size: 30px; + font-weight: 500; + line-height: 36px; + white-space: nowrap; + text-overflow: ellipsis; +} + +.rr-metric-label { + position: absolute; + overflow: hidden; + color: #fff; + font-size: 28px; + font-weight: 500; + line-height: 34px; + white-space: nowrap; + text-overflow: ellipsis; +} + +.rr-contribution-label { + top: 42px; + left: 494px; + width: 340px; +} + +.rr-rewards-label { + top: 110px; + left: 552px; + width: 280px; +} + +.rr-value-pill { + position: absolute; + display: block; + width: 215px; + height: 69px; + margin: 0; + padding: 0; + overflow: visible; + border: 0; + color: #fff; + font: inherit; +} + +.rr-value-pill > img { + position: absolute; + inset: 0; + width: 215px; + height: 69px; + object-fit: fill; + pointer-events: none; +} + +.rr-value-pill > span { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 0 28px; + color: #fff; + font-size: 24px; + font-weight: 500; + line-height: 28px; + white-space: nowrap; + text-overflow: ellipsis; +} + +.rr-value-pill-active { + top: 19px; + left: 838px; +} + +.rr-value-pill-disabled { + top: 87px; + left: 837px; +} + +.rr-sr-only { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; +} + +html[dir='rtl'] .rr-user-name, +html[dir='rtl'] .rr-user-id, +html[dir='rtl'] .rr-metric-label { + direction: rtl; + text-align: right; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + } +} diff --git a/activity/room-reward/fami/fami.js b/activity/room-reward/fami/fami.js new file mode 100644 index 0000000..e4a4267 --- /dev/null +++ b/activity/room-reward/fami/fami.js @@ -0,0 +1,433 @@ +(function () { + 'use strict'; + + var DESIGN_WIDTH = 1080; + var DESIGN_HEIGHT = 1985; + var TABLE_TOP = 1005; + var TABLE_CONTENT_TOP = 1020; + var ROW_HEIGHT = 103; + var MIN_TIER_SLOTS = 7; + var TABLE_BOTTOM_SPACE = 141; + var MAX_PAGE_WIDTH = 430; + var ASSET_BASE = './fami/assets/layers/'; + + var params = new URLSearchParams(window.location.search || ''); + var useMock = params.get('mock') === '1'; + document.documentElement.classList.toggle('rr-mock', useMock); + var stageHeight = DESIGN_HEIGHT; + var currentData = null; + var loadState = 'loading'; + var resizeFrame = 0; + + var app = document.getElementById('rrApp'); + var stageShell = document.getElementById('rrStageShell'); + var stage = document.getElementById('rrStage'); + var table = document.getElementById('rrTable'); + var tierTable = document.getElementById('rrTierTable'); + var tableDivider = document.getElementById('rrTableDivider'); + var tableSides = Array.prototype.slice.call( + document.querySelectorAll('.rr-table-side-ornament') + ); + var footerShell = document.getElementById('rrFooterShell'); + var statusNode = document.getElementById('rrStatus'); + var avatar = document.getElementById('rrUserAvatar'); + + var mockData = { + targetReward: 1000, + user: { + name: 'Namename...', + id: '123456789', + avatar: ASSET_BASE + 'avatar-mock.webp', + }, + currentCoinSpent: 100, + expectedReward: 100, + tiers: Array.from({ length: 7 }, function (_, index) { + return { + id: 'figma-tier-' + String(index + 1), + turnover: 7000000, + reward: 350000, + }; + }), + }; + + function t(key, fallback) { + if (window.HyAppI18n && window.HyAppI18n.t) { + return window.HyAppI18n.t(key, fallback); + } + return fallback || key; + } + + function first(source, keys) { + source = source || {}; + for (var index = 0; index < keys.length; index += 1) { + var value = source[keys[index]]; + if (value !== undefined && value !== null && value !== '') { + return value; + } + } + return ''; + } + + function number(value, fallback) { + var parsed = Number(value); + return Number.isFinite(parsed) ? parsed : Number(fallback || 0); + } + + function unwrap(payload) { + if ( + payload && + typeof payload === 'object' && + Object.prototype.hasOwnProperty.call(payload, 'data') + ) { + return payload.data || {}; + } + return payload || {}; + } + + function trimFixed(value) { + var fixed = value >= 10 ? value.toFixed(0) : value.toFixed(1); + return fixed.replace(/\.0$/, ''); + } + + function compactNumber(value) { + var amount = number(value, 0); + if (amount >= 1000000000) return trimFixed(amount / 1000000000) + 'B'; + if (amount >= 1000000) return trimFixed(amount / 1000000) + 'M'; + if (amount >= 1000) return trimFixed(amount / 1000) + 'k'; + return String(Math.floor(amount)); + } + + function plainWholeNumber(value) { + return String(Math.floor(number(value, 0))); + } + + function wholeNumber(value) { + var lang = + window.HyAppI18n && window.HyAppI18n.lang + ? window.HyAppI18n.lang() + : 'en'; + return new Intl.NumberFormat(lang, { + maximumFractionDigits: 0, + }).format(Math.floor(number(value, 0))); + } + + function normalizeTier(tier, index) { + tier = tier || {}; + return { + id: + first(tier, ['tier_id', 'tierId', 'id', 'tier_code']) || + 'tier-' + String(index + 1), + turnover: number( + first(tier, [ + 'threshold_coin_spent', + 'thresholdCoinSpent', + 'turnover', + ]), + 0 + ), + reward: number( + first(tier, [ + 'reward_coin_amount', + 'rewardCoinAmount', + 'reward', + ]), + 0 + ), + }; + } + + function normalizeAPIData(payload) { + var data = unwrap(payload); + var user = data.user || {}; + var room = data.room || {}; + var currentCoinSpent = number( + first(data, ['current_coin_spent', 'currentCoinSpent']), + 0 + ); + var expectedReward = number( + first(data, [ + 'expected_reward_coin_amount', + 'expectedRewardCoinAmount', + ]), + 0 + ); + var tiers = Array.isArray(data.tiers) ? data.tiers : []; + + return { + enabled: data.enabled !== false, + targetReward: expectedReward, + currentCoinSpent: currentCoinSpent, + expectedReward: expectedReward, + user: { + name: + first(user, ['username', 'name']) || first(room, ['title']), + id: + first(user, [ + 'display_user_id', + 'displayUserId', + 'user_id', + 'userId', + ]) || first(room, ['owner_user_id', 'ownerUserId']), + avatar: + first(user, ['avatar']) || + first(room, ['cover_url', 'coverUrl']), + }, + tiers: tiers.map(normalizeTier), + }; + } + + function createCell(className, text) { + var cell = document.createElement('div'); + cell.className = 'rr-tier-cell' + (className ? ' ' + className : ''); + cell.textContent = text; + return cell; + } + + function createHeaderRow() { + var row = document.createElement('div'); + var turnover = createCell( + '', + t('roomReward.turnover', 'Room turnover') + ); + var reward = createCell( + '', + t('roomReward.receiveReward', 'Receive a reward') + ); + row.className = 'rr-tier-row rr-tier-row-head'; + turnover.setAttribute('data-i18n', 'roomReward.turnover'); + reward.setAttribute('data-i18n', 'roomReward.receiveReward'); + row.appendChild(turnover); + row.appendChild(reward); + return row; + } + + function createTierRow(tier) { + var row = document.createElement('div'); + row.className = 'rr-tier-row'; + + if (!tier) { + row.classList.add('rr-tier-placeholder'); + row.appendChild(createCell('', '—')); + row.appendChild(createCell('', '—')); + return row; + } + + row.appendChild(createCell('', compactNumber(tier.turnover))); + var rewardCell = createCell('rr-tier-reward', ''); + var amount = document.createElement('span'); + var coin = document.createElement('img'); + amount.textContent = compactNumber(tier.reward); + coin.src = ASSET_BASE + 'coin-tier.webp'; + coin.alt = ''; + coin.setAttribute('aria-hidden', 'true'); + coin.draggable = false; + rewardCell.appendChild(amount); + rewardCell.appendChild(coin); + row.appendChild(rewardCell); + return row; + } + + function renderTable(tiers) { + tiers = Array.isArray(tiers) ? tiers : []; + var slotCount = Math.max(tiers.length, MIN_TIER_SLOTS); + var fragment = document.createDocumentFragment(); + fragment.appendChild(createHeaderRow()); + + for (var index = 0; index < slotCount; index += 1) { + fragment.appendChild(createTierRow(tiers[index] || null)); + } + + tierTable.replaceChildren(fragment); + tierTable.setAttribute( + 'aria-busy', + loadState === 'loading' ? 'true' : 'false' + ); + syncTableLayout(slotCount + 1); + } + + function syncTableLayout(rowCount) { + var rowsHeight = Math.max(rowCount, 1) * ROW_HEIGHT; + // The Figma frame leaves 141 design pixels after the final row. Reusing that + // relationship for extra API tiers keeps the fixed footer overlap identical + // instead of hiding an arbitrary amount of the last dynamic row. + stageHeight = Math.max( + DESIGN_HEIGHT, + TABLE_CONTENT_TOP + rowsHeight + TABLE_BOTTOM_SPACE + ); + + var tableHeight = stageHeight - TABLE_TOP; + var sideHeight = tableHeight - 15; + var sideImageHeight = Math.max(1003, tableHeight + 23); + + document.documentElement.style.setProperty( + '--rr-stage-height', + stageHeight + 'px' + ); + stage.style.height = stageHeight + 'px'; + table.style.height = tableHeight + 'px'; + tierTable.style.minHeight = rowsHeight + 'px'; + tableDivider.style.height = rowsHeight + 'px'; + tableSides.forEach(function (side) { + side.style.height = sideHeight + 'px'; + var image = side.querySelector('img'); + if (image) image.style.height = sideImageHeight + 'px'; + }); + syncViewport(); + } + + function userID(value) { + return t('roomReward.idPrefix', 'ID:') + ' ' + (value || '—'); + } + + function setAvatar(source) { + source = String(source || '').trim(); + if (!source) { + avatar.hidden = true; + avatar.removeAttribute('src'); + return; + } + avatar.hidden = false; + avatar.src = source; + } + + function renderUser(data) { + var user = data && data.user ? data.user : {}; + document.getElementById('rrTargetValue').textContent = data + ? plainWholeNumber(data.targetReward) + : '—'; + document.getElementById('rrUserName').textContent = data + ? user.name || t('roomReward.roomOwner', 'Room owner') + : '—'; + document.getElementById('rrUserId').textContent = userID( + data ? user.id : '' + ); + document.getElementById('rrContributionValue').textContent = data + ? wholeNumber(data.currentCoinSpent) + : '—'; + document.getElementById('rrRewardValue').textContent = data + ? wholeNumber(data.expectedReward) + : '—'; + setAvatar(data ? user.avatar : ''); + } + + function setStatus(nextState) { + loadState = nextState; + var key = ''; + var fallback = ''; + if (nextState === 'loading') { + key = 'roomReward.loading'; + fallback = 'Loading...'; + } else if (nextState === 'empty') { + key = 'roomReward.empty'; + fallback = 'No reward tiers yet'; + } else if (nextState === 'error') { + key = 'roomReward.loadFailed'; + fallback = 'Load failed'; + } + + if (key) { + statusNode.setAttribute('data-i18n', key); + statusNode.textContent = t(key, fallback); + } else { + statusNode.removeAttribute('data-i18n'); + statusNode.textContent = ''; + } + tierTable.setAttribute( + 'aria-busy', + nextState === 'loading' ? 'true' : 'false' + ); + app.setAttribute('data-state', nextState); + } + + function render(data) { + currentData = data; + var hasTiers = Boolean(data && data.tiers && data.tiers.length); + setStatus(hasTiers ? 'ready' : 'empty'); + renderTable(data ? data.tiers : []); + renderUser(data); + } + + function loadData() { + if (useMock) return Promise.resolve(mockData); + + // Mock data is opt-in only. A production API failure must remain visible as + // an error state; silently substituting Figma fixture numbers would present + // fabricated room turnover and reward balances to the user. + if (!window.HyAppAPI || !window.HyAppAPI.get) { + return Promise.reject(new Error('room_reward_api_unavailable')); + } + + return window.HyAppAPI.get( + '/api/v1/activities/room-turnover-reward' + ).then(normalizeAPIData); + } + + function syncViewport() { + var viewportWidth = + document.documentElement.clientWidth || window.innerWidth || 375; + var pageWidth = Math.min(viewportWidth, MAX_PAGE_WIDTH); + var scale = pageWidth / DESIGN_WIDTH; + var shellHeight = stageHeight * scale; + var footerHeight = 169 * scale; + + document.documentElement.style.setProperty('--rr-scale', String(scale)); + document.documentElement.style.setProperty( + '--rr-page-width', + pageWidth + 'px' + ); + stageShell.style.width = pageWidth + 'px'; + stageShell.style.height = shellHeight + 'px'; + footerShell.style.width = pageWidth + 'px'; + footerShell.style.height = footerHeight + 'px'; + } + + function scheduleViewportSync() { + if (resizeFrame) window.cancelAnimationFrame(resizeFrame); + resizeFrame = window.requestAnimationFrame(function () { + resizeFrame = 0; + syncViewport(); + }); + } + + function syncLanguageText() { + document.title = t('roomReward.pageTitle', 'Room Reward Event'); + if (currentData) renderUser(currentData); + if (loadState !== 'ready') setStatus(loadState); + } + + function init() { + setStatus('loading'); + renderTable([]); + renderUser(null); + syncViewport(); + + avatar.addEventListener('error', function () { + // Broken remote media must not fall through to the Figma mock avatar in + // production; hiding it keeps the identity state honest and stable. + avatar.hidden = true; + }); + window.addEventListener('resize', scheduleViewportSync, { + passive: true, + }); + window.addEventListener('orientationchange', scheduleViewportSync, { + passive: true, + }); + window.addEventListener('hyapp:i18n-ready', syncLanguageText); + + loadData() + .then(render) + .catch(function (error) { + console.warn('[fami-room-reward] load failed', error); + currentData = null; + setStatus('error'); + renderTable([]); + renderUser(null); + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/activity/week-star/fami.html b/activity/week-star/fami.html new file mode 100644 index 0000000..eee95f8 --- /dev/null +++ b/activity/week-star/fami.html @@ -0,0 +1,422 @@ + + + + + + Weekly Star + + + + + + +
+
+
+ + +

+ Weekly star +

+ +

+ Note: Send the following three gifts to participate in + the ranking. Users who rank in the top 3 of the + leaderboard will receive rewards. +

+ +
+ + + +
+
+

----

+
+
+
+ + + + + + +
+
+
+ + + + + + + + + + + + + + + + + + diff --git a/activity/week-star/fami/assets/fixtures/mock-avatar-top.webp b/activity/week-star/fami/assets/fixtures/mock-avatar-top.webp new file mode 100644 index 0000000..955ec9b Binary files /dev/null and b/activity/week-star/fami/assets/fixtures/mock-avatar-top.webp differ diff --git a/activity/week-star/fami/assets/fixtures/mock-avatar-user.webp b/activity/week-star/fami/assets/fixtures/mock-avatar-user.webp new file mode 100644 index 0000000..ef8adb9 Binary files /dev/null and b/activity/week-star/fami/assets/fixtures/mock-avatar-user.webp differ diff --git a/activity/week-star/fami/assets/fixtures/mock-gift.webp b/activity/week-star/fami/assets/fixtures/mock-gift.webp new file mode 100644 index 0000000..a1177bf Binary files /dev/null and b/activity/week-star/fami/assets/fixtures/mock-gift.webp differ diff --git a/activity/week-star/fami/assets/fonts/source-han-sans-sc-medium-latin.woff2 b/activity/week-star/fami/assets/fonts/source-han-sans-sc-medium-latin.woff2 new file mode 100644 index 0000000..8519f51 Binary files /dev/null and b/activity/week-star/fami/assets/fonts/source-han-sans-sc-medium-latin.woff2 differ diff --git a/activity/week-star/fami/assets/layers/body-watermark.webp b/activity/week-star/fami/assets/layers/body-watermark.webp new file mode 100644 index 0000000..1930c04 Binary files /dev/null and b/activity/week-star/fami/assets/layers/body-watermark.webp differ diff --git a/activity/week-star/fami/assets/layers/gift-frame.webp b/activity/week-star/fami/assets/layers/gift-frame.webp new file mode 100644 index 0000000..08c91a6 Binary files /dev/null and b/activity/week-star/fami/assets/layers/gift-frame.webp differ diff --git a/activity/week-star/fami/assets/layers/gift-glow.webp b/activity/week-star/fami/assets/layers/gift-glow.webp new file mode 100644 index 0000000..f4be325 Binary files /dev/null and b/activity/week-star/fami/assets/layers/gift-glow.webp differ diff --git a/activity/week-star/fami/assets/layers/manifest.json b/activity/week-star/fami/assets/layers/manifest.json new file mode 100644 index 0000000..2d1debc --- /dev/null +++ b/activity/week-star/fami/assets/layers/manifest.json @@ -0,0 +1,632 @@ +{ + "schemaVersion": 2, + "figma": { + "fileKey": "OTOg5cwbhVNWxkImGmzTJl", + "canvas": { + "id": "721:2016", + "name": "Fami运营" + }, + "entryFrames": { + "history": { + "id": "837:4472", + "name": "历史前三", + "width": 1080, + "height": 2651 + }, + "current": { + "id": "837:4545", + "name": "历史前三", + "width": 1080, + "height": 2460 + }, + "rewards": { + "id": "837:4615", + "name": "历史前三", + "width": 1080, + "height": 3532 + } + }, + "references": { + "hero": { + "groupNode": "837:4721", + "maskNode": "837:4722", + "imageNode": "837:4723", + "bounds": { + "x": 0, + "y": 0, + "width": 1080, + "height": 1080 + }, + "runtimeExported": false, + "purpose": "Trace the Figma hero and bottom fade only. The runtime header is decomposed into the body watermark plus DOM/CSS title, date, note, and controls." + } + } + }, + "policy": { + "rule": "Only isolated single visual nodes are shippable assets. Root canvases, complete state frames, multi-element regions, text, controls, leaderboard rows, reward grids, and dynamic business media are never runtime screenshots.", + "forbiddenAggregateNodes": [ + "721:2016", + "837:4472", + "837:4545", + "837:4615", + "837:4475", + "837:4491", + "837:4507", + "837:4548", + "837:4564", + "837:4579", + "837:4618", + "837:4634", + "837:4649", + "837:4721" + ], + "allowedSourceTypes": ["RECTANGLE"], + "forbiddenRuntimeNamePatterns": [ + "hero", + "header", + "full-page", + "whole-page", + "leaderboard", + "reward-grid", + "frame-837" + ] + }, + "compositions": { + "pageBackground": { + "layers": ["body-watermark.webp"], + "dynamicSlots": [] + }, + "giftCard": { + "layers": ["gift-frame.webp", "gift-glow.webp"], + "dynamicSlots": ["gift.media"] + }, + "tabs": { + "layers": ["tab-active.webp", "tab-inactive.webp"], + "dynamicSlots": ["tab.label", "tab.state"] + }, + "leaderboard": { + "layers": ["medal-1.webp", "medal-2.webp", "medal-3.webp"], + "dynamicSlots": [ + "user.avatar.top", + "user.avatar", + "user.displayName", + "user.score" + ] + }, + "rewardPanel": { + "layers": [ + "reward-panel.webp", + "reward-heading.webp", + "gift-frame.webp", + "gift-glow.webp" + ], + "dynamicSlots": [ + "reward.rank", + "reward.giftMedia", + "reward.duration" + ] + } + }, + "fixtureBindings": [ + { + "slot": "gift.media", + "file": "../fixtures/mock-gift.webp", + "mockOnly": true + }, + { + "slot": "user.avatar.top", + "file": "../fixtures/mock-avatar-top.webp", + "mockOnly": true + }, + { + "slot": "user.avatar", + "file": "../fixtures/mock-avatar-user.webp", + "mockOnly": true + } + ], + "assets": [ + { + "file": "body-watermark.webp", + "scope": "runtime", + "kind": "single-raster-image-fill", + "sourceNode": "837:4544", + "equivalentNodes": ["837:4614", "837:4718"], + "sourceName": "image 5022", + "sourceType": "RECTANGLE", + "purpose": "Text-free palace background reused by the decomposed Hero and as a low-opacity watermark behind DOM leaderboard and reward content", + "bounds": { + "relativeTo": "837:4472", + "x": -65, + "y": 987, + "width": 1210, + "height": 2689 + }, + "original": { + "width": 841, + "height": 1870 + }, + "exported": { + "width": 841, + "height": 1870 + }, + "figmaOpacity": 0.1, + "exportFormat": "webp-lossy", + "quality": 88, + "alphaQuality": null, + "hasAlpha": false, + "bytes": 605038, + "sha256": "b09813ba3587478745f587dd2f7c599cf7e35abf8ca957525d7c1e8fbb8f9e8b", + "dependencies": [], + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false, + "mockOnly": false, + "dynamic": false + }, + { + "file": "gift-frame.webp", + "scope": "runtime", + "kind": "single-raster-ornamental-frame", + "sourceNode": "837:4479", + "equivalentNodes": ["837:4661"], + "sourceName": "058 1", + "sourceType": "RECTANGLE", + "purpose": "Green-gold gift frame under live gift media", + "bounds": { + "relativeTo": "837:4472", + "x": 61, + "y": 1247, + "width": 257, + "height": 258 + }, + "original": { + "width": 1169, + "height": 1175 + }, + "exported": { + "width": 514, + "height": 516 + }, + "figmaOpacity": 1, + "exportFormat": "webp-lossy", + "quality": 86, + "alphaQuality": 100, + "hasAlpha": true, + "bytes": 40204, + "sha256": "37842036b84402bb1b43a091b023a00a4221a442c0b0b9c882f864dc034af980", + "dependencies": [], + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false, + "mockOnly": false, + "dynamic": false + }, + { + "file": "gift-glow.webp", + "scope": "runtime", + "kind": "single-raster-ornamental-overlay", + "sourceNode": "837:4662", + "equivalentNodes": [], + "sourceName": "电商双十一通用弹窗广告_设计页-稿定设计 1", + "sourceType": "RECTANGLE", + "purpose": "Soft radial glow layered between the gift frame and live gift media", + "bounds": { + "relativeTo": "837:4615", + "x": 153.5, + "y": 2041, + "width": 219, + "height": 219 + }, + "original": { + "width": 1183, + "height": 1183 + }, + "exported": { + "width": 438, + "height": 438 + }, + "figmaOpacity": 0.34, + "exportFormat": "webp-lossy", + "quality": 84, + "alphaQuality": 100, + "hasAlpha": true, + "bytes": 33306, + "sha256": "06a7cb9da121e77a8e938c98c35ccfacdf5542cac92ec64dcc32bd2eec729c16", + "dependencies": ["gift-frame.webp"], + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false, + "mockOnly": false, + "dynamic": false + }, + { + "file": "tab-active.webp", + "scope": "runtime", + "kind": "single-raster-tab-skin", + "sourceNode": "837:4493", + "equivalentNodes": ["837:4569", "837:4642"], + "sourceName": "图层 0 36", + "sourceType": "RECTANGLE", + "purpose": "Selected tab skin; tab labels and click targets remain DOM", + "bounds": { + "relativeTo": "837:4472", + "x": 21, + "y": 1557, + "width": 335, + "height": 149 + }, + "original": { + "width": 1567, + "height": 697 + }, + "exported": { + "width": 672, + "height": 298 + }, + "figmaOpacity": 1, + "exportFormat": "webp-lossy", + "quality": 86, + "alphaQuality": 100, + "hasAlpha": true, + "bytes": 32938, + "sha256": "34d4346275416d33c5cf670245f873f710d120531bd9a94d773cca43f3d79a97", + "dependencies": [], + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false, + "mockOnly": false, + "dynamic": false + }, + { + "file": "tab-inactive.webp", + "scope": "runtime", + "kind": "single-raster-tab-skin", + "sourceNode": "837:4496", + "equivalentNodes": [ + "837:4499", + "837:4566", + "837:4572", + "837:4636", + "837:4639" + ], + "sourceName": "二二 1", + "sourceType": "RECTANGLE", + "purpose": "Unselected tab skin; tab labels and click targets remain DOM", + "bounds": { + "relativeTo": "837:4472", + "x": 374, + "y": 1559, + "width": 336, + "height": 149 + }, + "original": { + "width": 1024, + "height": 455 + }, + "exported": { + "width": 672, + "height": 298 + }, + "figmaOpacity": 1, + "paintAdjustments": { + "saturation": -0.49 + }, + "exportFormat": "webp-lossy", + "quality": 86, + "alphaQuality": 100, + "hasAlpha": true, + "bytes": 22864, + "sha256": "07603c2c22b4fdc22efe1554049928de593f47ccd0cfb0c78b3d268e4c190b9e", + "dependencies": [], + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false, + "mockOnly": false, + "dynamic": false + }, + { + "file": "medal-1.webp", + "scope": "runtime", + "kind": "single-raster-rank-medal", + "sourceNode": "837:4519", + "equivalentNodes": ["837:4589"], + "sourceName": "图层 2 1", + "sourceType": "RECTANGLE", + "purpose": "Rank-one medal beside live leaderboard identity data", + "bounds": { + "relativeTo": "837:4472", + "x": 45, + "y": 1939, + "width": 69, + "height": 61 + }, + "original": { + "width": 625, + "height": 553 + }, + "exported": { + "width": 207, + "height": 183 + }, + "figmaOpacity": 1, + "exportFormat": "webp-lossy", + "quality": 90, + "alphaQuality": 100, + "hasAlpha": true, + "bytes": 11526, + "sha256": "16122fdee746330822feeb64abe143100c25ccb706e50eb6cbea5a0c197fb87a", + "dependencies": [], + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false, + "mockOnly": false, + "dynamic": false + }, + { + "file": "medal-2.webp", + "scope": "runtime", + "kind": "single-raster-rank-medal", + "sourceNode": "837:4524", + "equivalentNodes": ["837:4594"], + "sourceName": "图层 2 2", + "sourceType": "RECTANGLE", + "purpose": "Rank-two medal beside live leaderboard identity data", + "bounds": { + "relativeTo": "837:4472", + "x": 45, + "y": 2079, + "width": 69, + "height": 61 + }, + "original": { + "width": 642, + "height": 582 + }, + "exported": { + "width": 207, + "height": 188 + }, + "figmaOpacity": 1, + "exportFormat": "webp-lossy", + "quality": 90, + "alphaQuality": 100, + "hasAlpha": true, + "bytes": 10310, + "sha256": "6fd3956dcace1ff7646a3207ac68a23a33dd095ca597e7b62df5b60be07552b7", + "dependencies": [], + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false, + "mockOnly": false, + "dynamic": false + }, + { + "file": "medal-3.webp", + "scope": "runtime", + "kind": "single-raster-rank-medal", + "sourceNode": "837:4531", + "equivalentNodes": [], + "sourceName": "图层 4 1", + "sourceType": "RECTANGLE", + "purpose": "Rank-three medal beside live leaderboard identity data", + "bounds": { + "relativeTo": "837:4472", + "x": 47, + "y": 2219, + "width": 63, + "height": 61 + }, + "original": { + "width": 619, + "height": 595 + }, + "exported": { + "width": 207, + "height": 199 + }, + "figmaOpacity": 1, + "exportFormat": "webp-lossy", + "quality": 90, + "alphaQuality": 100, + "hasAlpha": true, + "bytes": 11086, + "sha256": "2a5984f4166e0b832b74f4753db52b5e6dd59061a16698710e10aa9f020876c1", + "dependencies": [], + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false, + "mockOnly": false, + "dynamic": false + }, + { + "file": "reward-panel.webp", + "scope": "runtime", + "kind": "single-raster-panel-skin", + "sourceNode": "837:4650", + "equivalentNodes": [], + "sourceName": "图层 0 37", + "sourceType": "RECTANGLE", + "purpose": "Empty reward panel skin behind DOM reward groups", + "bounds": { + "relativeTo": "837:4615", + "x": 24, + "y": 1760, + "width": 1033, + "height": 1689 + }, + "original": { + "width": 931, + "height": 1522 + }, + "exported": { + "width": 931, + "height": 1522 + }, + "figmaOpacity": 1, + "exportFormat": "webp-lossy", + "quality": 85, + "alphaQuality": 100, + "hasAlpha": true, + "bytes": 98298, + "sha256": "96f8029823e50297c46cec97809ec255ae040879df58a32c9d5a6f28acd56983", + "dependencies": [], + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false, + "mockOnly": false, + "dynamic": false + }, + { + "file": "reward-heading.webp", + "scope": "runtime", + "kind": "single-raster-node-crop", + "sourceNode": "837:4653", + "equivalentNodes": ["837:4675", "837:4697"], + "sourceName": "2323a16b-9cbe-4acd-b676-8fd51cd1db70 1", + "sourceType": "RECTANGLE", + "purpose": "Cropped empty reward rank heading bar; TOP labels remain DOM text", + "bounds": { + "relativeTo": "837:4615", + "x": 225, + "y": 1876, + "width": 632, + "height": 98 + }, + "original": { + "width": 632, + "height": 98 + }, + "exported": { + "width": 632, + "height": 98 + }, + "figmaOpacity": 1, + "exportFormat": "webp-lossy", + "quality": 90, + "alphaQuality": 100, + "hasAlpha": true, + "bytes": 11798, + "sha256": "41fb59bae1e6800e95a712772179c49697129590fc99bb9a43f7e781a8c5c9de", + "dependencies": ["reward-panel.webp"], + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false, + "mockOnly": false, + "dynamic": false + }, + { + "file": "../fixtures/mock-gift.webp", + "scope": "fixture", + "kind": "single-dynamic-media-fixture", + "sourceNode": "837:4480", + "equivalentNodes": ["837:4663"], + "sourceName": "10", + "sourceType": "RECTANGLE", + "purpose": "Figma sample gift used only by an explicit mock or visual-QA mode", + "bounds": { + "relativeTo": "837:4472", + "x": 96, + "y": 1288, + "width": 188, + "height": 188 + }, + "original": { + "width": 144, + "height": 144 + }, + "exported": { + "width": 144, + "height": 144 + }, + "figmaOpacity": 1, + "exportFormat": "webp-lossless", + "quality": null, + "alphaQuality": null, + "hasAlpha": true, + "bytes": 16014, + "sha256": "6c222a35801df12a90627e69eaf3945e268003be34b745d81357f22b1cf98c75", + "dependencies": [], + "containsText": false, + "containsControls": false, + "containsDynamicMedia": true, + "mockOnly": true, + "dynamic": true + }, + { + "file": "../fixtures/mock-avatar-top.webp", + "scope": "fixture", + "kind": "single-dynamic-media-fixture", + "sourceNode": "837:4518", + "equivalentNodes": [], + "sourceName": "Rectangle 34", + "sourceType": "RECTANGLE", + "purpose": "Figma first-place avatar used only by an explicit mock or visual-QA mode", + "bounds": { + "relativeTo": "837:4472", + "x": 160, + "y": 1913, + "width": 113, + "height": 113 + }, + "original": { + "width": 1200, + "height": 1200 + }, + "exported": { + "width": 1200, + "height": 1200 + }, + "figmaOpacity": 1, + "exportFormat": "webp-lossless", + "quality": null, + "alphaQuality": null, + "hasAlpha": false, + "bytes": 1476722, + "sha256": "3740a7decc5288288b1b1e52393ea7a76a633ac7832764a06dab36cd44d66af0", + "dependencies": [], + "containsText": false, + "containsControls": false, + "containsDynamicMedia": true, + "mockOnly": true, + "dynamic": true + }, + { + "file": "../fixtures/mock-avatar-user.webp", + "scope": "fixture", + "kind": "single-dynamic-media-fixture", + "sourceNode": "837:4523", + "equivalentNodes": [], + "sourceName": "Rectangle 35", + "sourceType": "RECTANGLE", + "purpose": "Figma leaderboard avatar used only by an explicit mock or visual-QA mode", + "bounds": { + "relativeTo": "837:4472", + "x": 160, + "y": 2053, + "width": 113, + "height": 113 + }, + "original": { + "width": 768, + "height": 1024 + }, + "exported": { + "width": 768, + "height": 1024 + }, + "figmaOpacity": 1, + "exportFormat": "webp-lossless", + "quality": null, + "alphaQuality": null, + "hasAlpha": false, + "bytes": 911586, + "sha256": "b23c230ada8cbc217ae6b238dd2df2efb9d46d79ea4b9b7e8a36e7f4230eaf3b", + "dependencies": [], + "containsText": false, + "containsControls": false, + "containsDynamicMedia": true, + "mockOnly": true, + "dynamic": true + } + ] +} diff --git a/activity/week-star/fami/assets/layers/medal-1.webp b/activity/week-star/fami/assets/layers/medal-1.webp new file mode 100644 index 0000000..b668897 Binary files /dev/null and b/activity/week-star/fami/assets/layers/medal-1.webp differ diff --git a/activity/week-star/fami/assets/layers/medal-2.webp b/activity/week-star/fami/assets/layers/medal-2.webp new file mode 100644 index 0000000..64c95fc Binary files /dev/null and b/activity/week-star/fami/assets/layers/medal-2.webp differ diff --git a/activity/week-star/fami/assets/layers/medal-3.webp b/activity/week-star/fami/assets/layers/medal-3.webp new file mode 100644 index 0000000..16d598d Binary files /dev/null and b/activity/week-star/fami/assets/layers/medal-3.webp differ diff --git a/activity/week-star/fami/assets/layers/reward-heading.webp b/activity/week-star/fami/assets/layers/reward-heading.webp new file mode 100644 index 0000000..368429e Binary files /dev/null and b/activity/week-star/fami/assets/layers/reward-heading.webp differ diff --git a/activity/week-star/fami/assets/layers/reward-panel.webp b/activity/week-star/fami/assets/layers/reward-panel.webp new file mode 100644 index 0000000..21bacb1 Binary files /dev/null and b/activity/week-star/fami/assets/layers/reward-panel.webp differ diff --git a/activity/week-star/fami/assets/layers/tab-active.webp b/activity/week-star/fami/assets/layers/tab-active.webp new file mode 100644 index 0000000..f9e3e1a Binary files /dev/null and b/activity/week-star/fami/assets/layers/tab-active.webp differ diff --git a/activity/week-star/fami/assets/layers/tab-inactive.webp b/activity/week-star/fami/assets/layers/tab-inactive.webp new file mode 100644 index 0000000..d3a62a8 Binary files /dev/null and b/activity/week-star/fami/assets/layers/tab-inactive.webp differ diff --git a/activity/week-star/fami/fami.css b/activity/week-star/fami/fami.css new file mode 100644 index 0000000..7b8ff2d --- /dev/null +++ b/activity/week-star/fami/fami.css @@ -0,0 +1,925 @@ +@font-face { + font-family: 'Source Han Sans SC Fami Week Star'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('./assets/fonts/source-han-sans-sc-medium-latin.woff2') + format('woff2'); +} + +* { + box-sizing: border-box; + -webkit-tap-highlight-color: transparent; +} + +:root { + --fami-week-page-width: min(100vw, 430px); +} + +html, +body { + margin: 0; + width: 100%; + min-height: 100%; + overflow-x: hidden; + background: var(--hy-theme-bg, #f7f3ff); + color: #fff; + font-family: + 'Source Han Sans SC Fami Week Star', 'Source Han Sans SC', + 'Noto Sans Arabic', Arial, 'Helvetica Neue', Helvetica, sans-serif; +} + +body { + background: #032b29; +} + +button { + margin: 0; + border: 0; + padding: 0; + color: inherit; + font: inherit; + background: transparent; + cursor: pointer; +} + +button:focus-visible { + outline: 6px solid #fff; + outline-offset: -10px; +} + +img { + display: block; + max-width: none; + user-select: none; + -webkit-user-drag: none; +} + +[hidden] { + display: none !important; +} + +.fami-visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; +} + +.fami-week-star { + display: flex; + justify-content: center; + width: 100%; + min-height: 100svh; + overflow: hidden; + background: #032b29; +} + +.fami-stage-wrap { + --design-scale: 0.3472222222; + position: relative; + flex: 0 0 auto; + width: var(--fami-week-page-width); + height: 921px; + overflow: hidden; + background: #053633; +} + +.fami-design { + position: absolute; + isolation: isolate; + top: 0; + left: 0; + width: 1080px; + height: 3532px; + overflow: hidden; + transform: scale(var(--design-scale)); + transform-origin: left top; + background: #053633; +} + +.fami-hero { + position: absolute; + z-index: 2; + top: 0; + left: 0; + width: 1080px; + height: 1080px; + overflow: hidden; + background: #053633; +} + +.fami-hero::after { + position: absolute; + z-index: 1; + inset: 0; + background: linear-gradient( + 180deg, + rgba(1, 35, 32, 0.02) 0%, + rgba(1, 35, 32, 0.05) 70%, + #053633 100% + ); + content: ''; + pointer-events: none; +} + +.fami-hero-background { + position: absolute; + z-index: 0; + top: 0; + left: 0; + width: 1080px; + height: 2402px; + object-fit: fill; + object-position: center top; + filter: saturate(1.08) brightness(1.03); +} + +/* Figma 的 Hero 是压平的大区块,不能作为运行时截图;这里只复用无文字背景,星徽、标题、猫头鹰和日期牌全部独立构建。 */ +.fami-hero-star, +.fami-hero-star::before, +.fami-hero-star::after, +.fami-hero-star-core, +.fami-hero-star-core::before { + position: absolute; + display: block; + clip-path: polygon( + 50% 0, + 61.5% 34%, + 98% 35%, + 68.5% 56.5%, + 79.5% 93%, + 50% 71.5%, + 20.5% 93%, + 31.5% 56.5%, + 2% 35%, + 38.5% 34% + ); +} + +.fami-hero-star { + z-index: 2; + top: 75px; + left: 221px; + width: 638px; + height: 600px; + filter: drop-shadow(0 10px 4px rgba(72, 35, 0, 0.82)) + drop-shadow(0 0 30px rgba(255, 213, 89, 0.92)); +} + +.fami-hero-star::before { + z-index: 0; + inset: 0; + background: conic-gradient( + from -32deg at 50% 48%, + #7a3c00, + #ffe88d 8%, + #e38b09 18%, + #fff1a7 29%, + #a84d00 43%, + #ffd85a 58%, + #8b4200 72%, + #fff0a0 88%, + #7a3c00 + ); + content: ''; +} + +.fami-hero-star::after { + z-index: 1; + inset: 28px; + background: linear-gradient( + 145deg, + #fff5a5, + #bb6100 35%, + #ffdb55 70%, + #713100 + ); + content: ''; +} + +.fami-hero-star-core { + z-index: 2; + inset: 57px; + background: url('./assets/layers/body-watermark.webp') -523px -683px / + 1573px 3497px no-repeat; + filter: saturate(1.08) contrast(1.05); +} + +.fami-hero-star-core::before { + inset: 0; + background: + linear-gradient( + 90deg, + transparent 39%, + rgba(255, 246, 164, 0.08) 44%, + rgba(255, 255, 235, 0.88) 49.5%, + #fff 50%, + rgba(255, 255, 235, 0.88) 50.5%, + rgba(255, 246, 164, 0.08) 56%, + transparent 61% + ), + linear-gradient( + 0deg, + transparent 39%, + rgba(255, 246, 164, 0.08) 44%, + rgba(255, 255, 235, 0.88) 49.5%, + #fff 50%, + rgba(255, 255, 235, 0.88) 50.5%, + rgba(255, 246, 164, 0.08) 56%, + transparent 61% + ), + radial-gradient( + circle at 50% 50%, + #fff 0 1.5%, + #fffbd2 2% 4%, + rgba(255, 236, 122, 0.95) 5% 8%, + rgba(63, 255, 226, 0.48) 9% 21%, + transparent 37% + ); + content: ''; +} + +.fami-title-plaque { + position: absolute; + z-index: 3; + top: 574px; + left: 57px; + width: 966px; + height: 252px; + border: 14px solid #e7ac31; + border-radius: 114px 114px 72px 72px; + background: + radial-gradient( + circle at 50% 110%, + rgba(37, 255, 227, 0.25), + transparent 44% + ), + linear-gradient(180deg, #0a776b 0%, #07594f 45%, #033b36 100%); + box-shadow: + inset 0 0 0 7px #fff0a4, + inset 0 0 0 15px #865206, + inset 0 0 42px rgba(0, 255, 220, 0.34), + 0 8px 0 #6d3e00, + 0 0 40px rgba(255, 202, 73, 0.65); +} + +.fami-title-plaque::before, +.fami-title-plaque::after, +.fami-title-gem, +.fami-date-plaque::after { + position: absolute; + width: 48px; + height: 48px; + border: 7px solid #f9cf61; + background: linear-gradient(135deg, #64fff1, #00a99a 55%, #006a63); + box-shadow: + inset 0 0 0 5px #0a6d65, + 0 0 18px rgba(67, 255, 231, 0.72); + content: ''; + transform: rotate(45deg); +} + +.fami-title-plaque::before { + top: 86px; + left: -32px; +} + +.fami-title-plaque::after { + top: 86px; + right: -32px; +} + +.fami-title-gem { + bottom: -32px; + left: calc(50% - 24px); +} + +.fami-wordmark { + position: absolute; + z-index: 6; + top: 618px; + left: 115px; + width: 850px; + height: 152px; + margin: 0; + color: #ffe08a; + font-family: Georgia, 'Times New Roman', serif; + font-size: 124px; + font-weight: 700; + line-height: 152px; + text-align: center; + text-shadow: + 0 4px 0 #7b4300, + 0 8px 7px rgba(48, 25, 0, 0.75), + 0 0 18px #fff1a4, + 0 0 30px #ffbe36; + letter-spacing: -3px; + white-space: nowrap; + -webkit-text-stroke: 2px #fff0ad; +} + +:lang(tr) .fami-wordmark, +:lang(es) .fami-wordmark { + font-size: 98px; + letter-spacing: -2px; +} + +.fami-owl { + position: absolute; + z-index: 6; + top: 705px; + left: 125px; + width: 286px; + height: 286px; + filter: drop-shadow(0 12px 10px rgba(0, 0, 0, 0.34)); +} + +.fami-date-plaque { + position: absolute; + z-index: 5; + top: 850px; + left: 357px; + width: 643px; + height: 104px; + border: 10px solid #e8af2d; + border-radius: 50px; + background: + radial-gradient( + circle at 50% 130%, + rgba(68, 255, 228, 0.3), + transparent 52% + ), + linear-gradient(180deg, #0a756a, #04433e); + box-shadow: + inset 0 0 0 5px #fff0a4, + inset 0 0 0 11px #784507, + 0 7px 0 #714000, + 0 0 24px rgba(255, 206, 76, 0.52); +} + +.fami-date-plaque::after { + top: 21px; + right: -29px; + width: 40px; + height: 40px; +} + +.fami-cycle-range { + position: absolute; + z-index: 7; + top: 889px; + left: 405px; + width: 445px; + height: 34px; + overflow: visible; + color: #dffbff; + font-family: 'SF Pro Text', 'Helvetica Neue', Arial, sans-serif; + font-size: 30px; + font-weight: 500; + line-height: 30px; + text-align: left; + white-space: nowrap; + text-shadow: 0 2px 7px rgba(0, 0, 0, 0.5); +} + +.fami-note { + position: absolute; + z-index: 6; + top: 1069px; + left: 44px; + width: 952px; + height: 132px; + margin: 0; + overflow: hidden; + color: #e8fffd; + font-size: 36px; + font-weight: 500; + line-height: 42px; + text-align: left; +} + +[dir='rtl'] .fami-note { + text-align: right; +} + +.fami-gifts { + position: absolute; + z-index: 6; + top: 1247px; + left: 61px; + display: grid; + grid-template-columns: repeat(3, 257px); + column-gap: 93px; + width: 957px; + height: 258px; +} + +.fami-gifts.has-state { + display: flex; + align-items: center; + justify-content: center; +} + +.fami-current-state, +.fami-reward-state { + display: flex; + align-items: center; + justify-content: center; + padding: 36px 72px; + color: rgba(232, 255, 253, 0.86); + font-size: 34px; + font-weight: 500; + line-height: 46px; + text-align: center; + background: rgba(10, 65, 62, 0.88); +} + +.fami-current-state { + width: 957px; + height: 180px; +} + +.fami-gift-card { + position: relative; + width: 257px; + height: 258px; +} + +.fami-gift-frame, +.fami-reward-frame { + position: absolute; + z-index: 2; + inset: 0; + width: 257px; + height: 258px; + object-fit: fill; + pointer-events: none; +} + +.fami-gift-media, +.fami-reward-media { + position: absolute; + z-index: 3; + top: 41px; + left: 35px; + display: grid; + place-items: center; + width: 188px; + height: 188px; +} + +.fami-gift-media img, +.fami-reward-media img { + position: relative; + z-index: 2; + width: 188px; + height: 188px; + object-fit: contain; +} + +.fami-gift-media img:not([src]), +.fami-reward-media img:not([src]) { + display: none; +} + +.fami-media-placeholder { + position: absolute; + z-index: 1; + top: 50%; + left: 50%; + width: 74px; + height: 74px; + border: 8px solid rgba(255, 224, 117, 0.76); + background: linear-gradient(135deg, #62fff0, #038e84); + box-shadow: 0 0 24px rgba(82, 255, 233, 0.72); + transform: translate(-50%, -50%) rotate(45deg); +} + +.fami-gift-media img[src] + .fami-media-placeholder, +.fami-reward-media img[src] + .fami-media-placeholder { + display: none; +} + +.fami-tabs { + position: absolute; + z-index: 8; + top: 1557px; + left: 21px; + display: grid; + grid-template-columns: repeat(3, 336px); + column-gap: 17px; + width: 1042px; + height: 151px; +} + +.fami-tab { + position: relative; + width: 336px; + height: 151px; + overflow: hidden; + color: rgba(255, 255, 255, 0.6); +} + +.fami-tab-skin { + position: absolute; + z-index: 0; + top: 0; + left: 0; + width: 336px; + height: 149px; + object-fit: fill; + pointer-events: none; +} + +.fami-tab-skin--active { + display: none; +} + +.fami-tab.is-active { + color: #fff; +} + +.fami-tab.is-active .fami-tab-skin--active { + display: block; +} + +.fami-tab.is-active .fami-tab-skin--inactive { + display: none; +} + +.fami-tab span { + position: absolute; + z-index: 2; + top: 53px; + left: 32px; + display: flex; + align-items: center; + justify-content: center; + width: 270px; + height: 80px; + font-size: 32px; + font-weight: 500; + line-height: 34px; + text-align: center; + white-space: normal; + text-shadow: 0 2px 6px rgba(0, 0, 0, 0.45); +} + +:lang(tr) .fami-tab span, +:lang(es) .fami-tab span { + top: 47px; + height: 90px; + font-size: 28px; + line-height: 30px; +} + +.fami-panel { + position: absolute; + z-index: 5; + top: 0; + left: 0; + width: 1080px; +} + +.fami-history-heading-band { + position: absolute; + top: 1760px; + left: 0; + width: 1080px; + height: 140px; + background: #0a413e; +} + +.fami-history-heading-band h2 { + position: absolute; + top: 47px; + left: 120px; + width: 840px; + height: 56px; + margin: 0; + overflow: hidden; + color: #56ffdd; + font-size: 46px; + font-weight: 500; + line-height: 46px; + text-align: center; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.4); + white-space: nowrap; +} + +.fami-rank-list { + position: absolute; + left: 0; + width: 1080px; +} + +.fami-rank-list--history { + top: 1900px; +} + +.fami-rank-list--weekly { + top: 1760px; +} + +.fami-rank-row { + position: relative; + width: 1080px; + height: 140px; + color: #fff; +} + +.fami-rank-list--history .fami-rank-row:nth-child(odd) { + background: rgba(0, 203, 179, 0.2); +} + +.fami-rank-list--history .fami-rank-row:nth-child(even), +.fami-rank-list--weekly .fami-rank-row:nth-child(odd) { + background: #0a413e; +} + +.fami-rank-list--weekly .fami-rank-row:nth-child(even) { + background: rgba(11, 47, 59, 0.2); +} + +.fami-rank-position { + position: absolute; + top: 0; + left: 0; + width: 137px; + height: 140px; +} + +.fami-rank-medal { + position: absolute; + top: 39px; + left: 45px; + width: 69px; + height: 61px; + object-fit: contain; +} + +.fami-rank-medal:not([src]) { + display: none; +} + +.fami-rank-number { + position: absolute; + top: 49px; + left: 69px; + min-width: 42px; + height: 48px; + font-size: 36px; + font-weight: 500; + line-height: 42px; + text-align: left; +} + +.fami-rank-medal[src] + .fami-rank-number { + display: none; +} + +.fami-rank-avatar { + position: absolute; + top: 13px; + left: 160px; + width: 113px; + height: 113px; + overflow: hidden; + border-radius: 50%; + background: linear-gradient(145deg, #0d7b70, #042e2b); + box-shadow: inset 0 0 0 3px rgba(126, 255, 235, 0.35); +} + +.fami-rank-avatar img { + position: absolute; + z-index: 2; + inset: 0; + width: 113px; + height: 113px; + object-fit: cover; +} + +.fami-rank-avatar img:not([src]) { + display: none; +} + +.fami-avatar-placeholder { + position: absolute; + z-index: 1; + inset: 0; + display: grid; + place-items: center; + color: rgba(232, 255, 253, 0.85); + font-size: 42px; + font-weight: 500; + text-transform: uppercase; +} + +.fami-rank-name, +.fami-rank-score { + position: absolute; + top: 49px; + height: 48px; + overflow: hidden; + color: #fff; + font-size: 36px; + font-weight: 500; + line-height: 42px; + white-space: nowrap; + text-overflow: ellipsis; +} + +.fami-rank-name { + left: 313px; + width: 440px; + text-align: start; +} + +.fami-rank-score { + left: 846px; + width: 180px; + text-align: left; +} + +.fami-list-state { + display: flex; + align-items: center; + justify-content: center; + width: 1080px; + height: 280px; + padding: 40px 90px; + color: rgba(232, 255, 253, 0.82); + font-size: 34px; + font-weight: 500; + line-height: 46px; + text-align: center; + background: #0a413e; +} + +.fami-panel--rewards { + height: 3532px; +} + +.fami-reward-panel-skin { + position: absolute; + top: 1760px; + left: 24px; + width: 1033px; + height: 1689px; + object-fit: fill; + pointer-events: none; +} + +.fami-reward-groups { + position: absolute; + top: 1760px; + left: 0; + width: 1080px; + height: 1689px; +} + +.fami-reward-state { + position: absolute; + top: 300px; + left: 90px; + width: 900px; + min-height: 200px; +} + +.fami-reward-group { + position: absolute; + top: 116px; + left: 0; + width: 1080px; + height: 461px; +} + +.fami-reward-group:nth-child(2) { + top: 614px; +} + +.fami-reward-group:nth-child(3) { + top: 1112px; +} + +.fami-reward-heading { + position: absolute; + top: 0; + left: 225px; + width: 632px; + height: 98px; +} + +.fami-reward-heading img { + position: absolute; + inset: 0; + width: 632px; + height: 98px; + object-fit: fill; + pointer-events: none; +} + +.fami-reward-heading h2 { + position: absolute; + top: 43px; + left: 120px; + width: 392px; + height: 45px; + margin: 0; + color: #dffcff; + font-family: 'SF Pro Text', 'Helvetica Neue', Arial, sans-serif; + font-size: 40px; + font-weight: 500; + line-height: 40px; + text-align: center; + text-shadow: 0 2px 5px rgba(0, 0, 0, 0.5); +} + +.fami-reward-items { + position: absolute; + top: 135px; + left: 134px; + display: grid; + grid-template-columns: repeat(3, 257px); + column-gap: 21px; + width: 813px; + height: 326px; +} + +.fami-reward-items[data-count='1'] { + left: 412px; + grid-template-columns: 257px; + width: 257px; +} + +.fami-reward-items[data-count='2'] { + left: 273px; + grid-template-columns: repeat(2, 257px); + width: 535px; +} + +.fami-reward-item { + position: relative; + width: 257px; + height: 326px; +} + +.fami-reward-card { + position: absolute; + top: 0; + left: 0; + width: 257px; + height: 258px; +} + +.fami-reward-glow { + position: absolute; + z-index: 1; + top: 30px; + left: 19px; + width: 219px; + height: 219px; + object-fit: contain; + opacity: 0.34; + pointer-events: none; +} + +.fami-reward-label { + position: absolute; + top: 290px; + left: 3px; + width: 251px; + height: 45px; + overflow: hidden; + color: #fff; + font-size: 36px; + font-weight: 500; + line-height: 36px; + text-align: center; + white-space: nowrap; + text-overflow: ellipsis; +} + +.fami-watermark { + position: absolute; + z-index: 1; + top: 987px; + left: -65px; + width: 1210px; + height: 2689px; + object-fit: fill; + opacity: 0.1; + pointer-events: none; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + } +} diff --git a/activity/week-star/fami/fami.js b/activity/week-star/fami/fami.js new file mode 100644 index 0000000..a0aa7e6 --- /dev/null +++ b/activity/week-star/fami/fami.js @@ -0,0 +1,872 @@ +(function () { + 'use strict'; + + var DESIGN_WIDTH = 1080; + var DESIGN_HEIGHTS = { + history: 2651, + weekly: 2460, + rewards: 3532, + }; + // Weekly Star 的公开 DTO 只提供 UTC epoch,不携带展示时区;Fami 设计与产品文案明确固定 UTC+3, + // 因此集中维护 +180 分钟展示规则,不能根据浏览器本地时区推断,否则同一周期会在不同国家显示不同日期。 + var DISPLAY_OFFSET_MINUTES = 180; + var MAX_VISIBLE_RANKS = 5; + var ASSET_BASE = './fami/assets/'; + var LAYER_BASE = ASSET_BASE + 'layers/'; + var FIXTURE_BASE = ASSET_BASE + 'fixtures/'; + + var root = document.getElementById('famiWeekStar'); + var stageWrap = document.getElementById('famiStageWrap'); + var cycleRangeNode = document.getElementById('famiCycleRange'); + var historyRangeNode = document.getElementById('famiHistoryRange'); + var giftListNode = document.getElementById('famiGiftList'); + var weeklyListNode = document.getElementById('famiWeeklyList'); + var historyListNode = document.getElementById('famiHistoryList'); + var rewardGroupsNode = document.getElementById('famiRewardGroups'); + var tabs = array(document.querySelectorAll('[data-tab-target]')); + var panels = array(document.querySelectorAll('[role="tabpanel"]')); + var giftTemplate = document.getElementById('famiGiftTemplate'); + var rankTemplate = document.getElementById('famiRankTemplate'); + var rewardGroupTemplate = document.getElementById( + 'famiRewardGroupTemplate' + ); + var rewardItemTemplate = document.getElementById('famiRewardItemTemplate'); + var params = new URLSearchParams(window.location.search || ''); + // 设计示例头像/礼物只在显式 mock=1 的验收入口使用;生产接口失败必须展示错误态,不能拿示例业务媒体伪装真实数据。 + var useMock = params.get('mock') === '1'; + + var state = { + tab: normalizeTab(params.get('tab') || window.location.hash.slice(1)), + currentLoading: true, + weeklyLoading: true, + historyLoading: true, + current: null, + weeklyEntries: [], + historyCycles: [], + currentError: '', + historyError: '', + }; + + function array(value) { + return Array.prototype.slice.call(value || []); + } + + function text(value) { + return String( + value === undefined || value === null ? '' : value + ).trim(); + } + + function number(value, fallback) { + var result = Number(value); + return Number.isFinite(result) ? result : Number(fallback || 0); + } + + function first(source, keys, fallback) { + source = source || {}; + for (var index = 0; index < keys.length; index += 1) { + var value = source[keys[index]]; + if (value !== undefined && value !== null && value !== '') { + return value; + } + } + return fallback; + } + + function firstObject(source, keys) { + var value = first(source, keys, null); + return value && typeof value === 'object' ? value : {}; + } + + function firstArray(source, keys) { + var value = first(source, keys, null); + return Array.isArray(value) ? value : []; + } + + function t(key, fallback) { + if (window.HyAppI18n && window.HyAppI18n.t) { + return window.HyAppI18n.t(key, fallback); + } + return fallback || key; + } + + function formatMessage(key, fallback, values) { + var result = t(key, fallback); + Object.keys(values || {}).forEach(function (name) { + result = result.replace( + new RegExp('\\{' + name + '\\}', 'g'), + String(values[name]) + ); + }); + return result; + } + + function currentLocale() { + return window.HyAppI18n && window.HyAppI18n.lang + ? window.HyAppI18n.lang() + : 'en'; + } + + function formatNumber(value) { + try { + return new Intl.NumberFormat(currentLocale()).format( + number(value, 0) + ); + } catch (error) { + return String(number(value, 0)); + } + } + + function formatScore(value) { + // 设计稿的榜单分数是不带千分位的原始整数;不能复用奖励数量的本地化格式,否则会变成 337,700。 + return String(Math.trunc(number(value, 0))); + } + + function pad(value) { + return String(value).padStart(2, '0'); + } + + function shiftedDate(value) { + var timestamp = number(value, 0); + if (!timestamp) return null; + return new Date(timestamp + DISPLAY_OFFSET_MINUTES * 60 * 1000); + } + + function formatCycleRange(start, end) { + var from = shiftedDate(start); + var to = shiftedDate(end); + if (!from || !to) return '--.-- --:-----.-- --:--(utc+3)'; + return ( + pad(from.getUTCMonth() + 1) + + '.' + + pad(from.getUTCDate()) + + ' ' + + pad(from.getUTCHours()) + + ':' + + pad(from.getUTCMinutes()) + + '-' + + pad(to.getUTCMonth() + 1) + + '.' + + pad(to.getUTCDate()) + + ' ' + + pad(to.getUTCHours()) + + ':' + + pad(to.getUTCMinutes()) + + '(utc+3)' + ); + } + + function formatHistoryRange(start, end) { + var from = shiftedDate(start); + var to = shiftedDate(end); + if (!from || !to) return '----/--/-------/--/--'; + return ( + from.getUTCFullYear() + + '/' + + pad(from.getUTCMonth() + 1) + + '/' + + pad(from.getUTCDate()) + + '-' + + to.getUTCFullYear() + + '/' + + pad(to.getUTCMonth() + 1) + + '/' + + pad(to.getUTCDate()) + ); + } + + function normalizeTab(value) { + if (value === 'weekly') return 'weekly'; + if (value === 'reward' || value === 'rewards') return 'rewards'; + return 'history'; + } + + function updateStageSize() { + var scale = stageWrap.clientWidth / DESIGN_WIDTH; + var height = DESIGN_HEIGHTS[state.tab] || DESIGN_HEIGHTS.history; + stageWrap.style.setProperty('--design-scale', String(scale)); + stageWrap.style.height = Math.ceil(height * scale) + 'px'; + document.body.style.minHeight = stageWrap.offsetHeight + 'px'; + } + + function updateURL(tab) { + if (!window.history || !window.history.replaceState) return; + try { + var url = new URL(window.location.href); + url.searchParams.set('tab', tab); + url.hash = ''; + window.history.replaceState(null, '', url.toString()); + } catch (error) { + // 受限 WebView 可能禁用 History API;Tab 本身仍可正常切换,不让 URL 同步失败阻断页面。 + } + } + + function setTab(nextTab, syncURL) { + var next = normalizeTab(nextTab); + state.tab = next; + root.setAttribute('data-tab', next); + tabs.forEach(function (tab) { + var active = tab.getAttribute('data-tab-target') === next; + tab.classList.toggle('is-active', active); + tab.setAttribute('aria-selected', active ? 'true' : 'false'); + tab.setAttribute('tabindex', active ? '0' : '-1'); + }); + panels.forEach(function (panel) { + panel.hidden = + panel.id !== + 'famiPanel' + next.charAt(0).toUpperCase() + next.slice(1); + }); + updateStageSize(); + if (syncURL) updateURL(next); + } + + function imageURL(source) { + var resource = firstObject(source, ['resource']); + return text( + first( + source, + [ + 'image_url', + 'imageUrl', + 'cover_url', + 'coverUrl', + 'preview_url', + 'previewUrl', + 'asset_url', + 'assetUrl', + 'animation_url', + 'animationUrl', + 'icon_url', + 'iconUrl', + 'image', + ], + first( + resource, + [ + 'preview_url', + 'previewUrl', + 'asset_url', + 'assetUrl', + 'animation_url', + 'animationUrl', + 'image_url', + 'imageUrl', + ], + '' + ) + ) + ); + } + + function setImage(node, url, alt) { + node.alt = text(alt); + if (!text(url)) { + node.removeAttribute('src'); + return; + } + node.src = text(url); + node.onerror = function () { + // 资源 CDN 失败时移除坏图标,由同一独立卡位内的 CSS 占位层接管,不能回退到设计示例业务媒体。 + node.removeAttribute('src'); + }; + } + + function initial(value) { + var normalized = text(value); + return normalized ? normalized.charAt(0) : '?'; + } + + function normalizeEntry(entry, index) { + entry = entry || {}; + var user = firstObject(entry, ['user']); + return { + rank: number( + first(entry, ['rank_no', 'rankNo', 'rank'], index + 1), + index + 1 + ), + name: text( + first( + user, + ['username', 'display_user_id', 'displayUserId'], + first(entry, ['user_id', 'userId'], '') + ) + ), + score: number(first(entry, ['score'], 0), 0), + avatar: text( + first(user, ['avatar', 'avatar_url', 'avatarUrl'], '') + ), + }; + } + + function normalizeEntries(entries) { + return (Array.isArray(entries) ? entries : []).map(normalizeEntry); + } + + function rankMedal(rank) { + if (rank === 1) return LAYER_BASE + 'medal-1.webp'; + if (rank === 2) return LAYER_BASE + 'medal-2.webp'; + if (rank === 3) return LAYER_BASE + 'medal-3.webp'; + return ''; + } + + function renderListState(target, message) { + target.innerHTML = ''; + var stateNode = document.createElement('div'); + stateNode.className = 'fami-list-state'; + stateNode.textContent = message; + target.appendChild(stateNode); + } + + function renderRankList( + target, + entries, + emptyMessage, + errorMessage, + loading + ) { + if (loading) { + renderListState(target, t('fami_week_star.loading', 'Loading...')); + return; + } + if (errorMessage) { + renderListState( + target, + t('fami_week_star.load_failed', 'Unable to load rankings.') + ); + return; + } + + var rows = normalizeEntries(entries).slice(0, MAX_VISIBLE_RANKS); + if (!rows.length) { + renderListState(target, emptyMessage); + return; + } + + target.innerHTML = ''; + rows.forEach(function (entry) { + var fragment = rankTemplate.content.cloneNode(true); + var row = fragment.querySelector('.fami-rank-row'); + var medal = fragment.querySelector('.fami-rank-medal'); + var rankNumber = fragment.querySelector('.fami-rank-number'); + var avatar = fragment.querySelector('.fami-rank-avatar img'); + var avatarPlaceholder = fragment.querySelector( + '.fami-avatar-placeholder' + ); + var name = fragment.querySelector('.fami-rank-name'); + var score = fragment.querySelector('.fami-rank-score'); + var displayName = + entry.name || t('fami_week_star.unknown', 'Unknown'); + + setImage( + medal, + rankMedal(entry.rank), + formatMessage('fami_week_star.rank_aria', 'Rank {rank}', { + rank: entry.rank, + }) + ); + rankNumber.textContent = String(entry.rank); + setImage(avatar, entry.avatar, displayName); + avatarPlaceholder.textContent = initial(displayName); + name.textContent = displayName; + score.textContent = formatScore(entry.score); + row.setAttribute( + 'aria-label', + formatMessage( + 'fami_week_star.entry_aria', + 'Rank {rank}, {name}, {score}', + { + rank: entry.rank, + name: displayName, + score: formatScore(entry.score), + } + ) + ); + target.appendChild(fragment); + }); + } + + function normalizeGift(gift) { + gift = gift || {}; + var resource = firstObject(gift, ['resource']); + return { + name: text( + first( + gift, + ['name'], + first(resource, ['name'], t('fami_week_star.gift', 'Gift')) + ) + ), + image: imageURL(gift), + }; + } + + function renderGifts(gifts, stateMessage) { + var normalized = (Array.isArray(gifts) ? gifts : []) + .map(normalizeGift) + .slice(0, 3); + giftListNode.innerHTML = ''; + giftListNode.classList.toggle('has-state', Boolean(stateMessage)); + + if (stateMessage) { + var stateNode = document.createElement('div'); + stateNode.className = 'fami-current-state'; + stateNode.setAttribute('role', 'status'); + stateNode.textContent = stateMessage; + giftListNode.appendChild(stateNode); + return; + } + + for (var index = 0; index < 3; index += 1) { + var gift = normalized[index] || { + name: t('fami_week_star.gift', 'Gift'), + image: '', + }; + var fragment = giftTemplate.content.cloneNode(true); + var card = fragment.querySelector('.fami-gift-card'); + var image = fragment.querySelector('.fami-gift-media img'); + fragment.querySelector('[data-gift-name]').textContent = gift.name; + setImage(image, gift.image, gift.name); + card.setAttribute('aria-label', gift.name); + giftListNode.appendChild(fragment); + } + } + + function rewardImage(item) { + return imageURL(item); + } + + function rewardLabel(item, group) { + item = item || {}; + group = group || {}; + var resource = firstObject(item, ['resource']); + var walletType = text( + first(item, ['wallet_asset_type', 'walletAssetType'], '') + ).toUpperCase(); + var label = text( + first( + item, + ['label', 'name'], + first(resource, ['name'], first(group, ['name'], '')) + ) + ); + if (!label && walletType === 'COIN') { + label = t('fami_week_star.coins', 'Coins'); + } + if (!label) label = t('fami_week_star.reward', 'Reward'); + + var durationMS = number( + first(item, ['duration_ms', 'durationMs'], 0), + 0 + ); + var days = Math.round(durationMS / 86400000); + if (durationMS > 0 && days > 0) { + label = formatMessage( + 'fami_week_star.reward_days', + '{name} ({days} days)', + { name: label, days: days } + ); + } + + var quantity = number(first(item, ['quantity'], 0), 0); + var walletAmount = number( + first(item, ['wallet_asset_amount', 'walletAssetAmount'], 0), + 0 + ); + if (quantity > 1) label += ' ×' + formatNumber(quantity); + else if (walletAmount > 0) label += ' ×' + formatNumber(walletAmount); + return label; + } + + function normalizeRewardGroups(rewards) { + var source = Array.isArray(rewards) ? rewards : []; + return [1, 2, 3].map(function (rank) { + var reward = source.find(function (candidate) { + return ( + number( + first(candidate, ['rank_no', 'rankNo', 'rank'], 0), + 0 + ) === rank + ); + }); + reward = reward || {}; + var group = firstObject(reward, [ + 'resource_group', + 'resourceGroup', + 'reward_resource_group', + 'rewardResourceGroup', + ]); + var items = firstArray(reward, ['items', 'resources']); + if (!items.length) + items = firstArray(group, ['items', 'resources']); + return { + rank: rank, + group: group, + items: items.slice(0, 3), + }; + }); + } + + function renderRewards(rewards, stateMessage) { + rewardGroupsNode.innerHTML = ''; + var groups = normalizeRewardGroups(rewards); + if ( + !stateMessage && + !groups.some(function (groupData) { + return groupData.items.length > 0; + }) + ) { + stateMessage = t( + 'fami_week_star.empty_rewards', + 'No reward data yet.' + ); + } + if (stateMessage) { + var stateNode = document.createElement('div'); + stateNode.className = 'fami-reward-state'; + stateNode.setAttribute('role', 'status'); + stateNode.textContent = stateMessage; + rewardGroupsNode.appendChild(stateNode); + return; + } + groups.forEach(function (groupData) { + var groupFragment = rewardGroupTemplate.content.cloneNode(true); + var groupNode = groupFragment.querySelector('.fami-reward-group'); + var heading = groupFragment.querySelector( + '.fami-reward-heading h2' + ); + var itemsNode = groupFragment.querySelector('.fami-reward-items'); + heading.textContent = 'TOP' + groupData.rank; + itemsNode.setAttribute( + 'data-count', + String(groupData.items.length) + ); + + groupData.items.forEach(function (item) { + var itemFragment = rewardItemTemplate.content.cloneNode(true); + var itemNode = itemFragment.querySelector('.fami-reward-item'); + var image = itemFragment.querySelector( + '.fami-reward-media img' + ); + var label = rewardLabel(item, groupData.group); + setImage(image, rewardImage(item), label); + itemFragment.querySelector('.fami-reward-label').textContent = + label; + itemNode.setAttribute('aria-label', label); + itemsNode.appendChild(itemFragment); + }); + groupNode.setAttribute( + 'aria-label', + formatMessage( + 'fami_week_star.top_rewards_aria', + 'Top {rank} rewards', + { rank: groupData.rank } + ) + ); + rewardGroupsNode.appendChild(groupFragment); + }); + } + + function cycleOf(value) { + return firstObject(value, ['cycle']); + } + + function render() { + var current = state.current || {}; + var cycle = cycleOf(current); + var history = state.historyCycles[0] || {}; + var historyCycle = cycleOf(history); + var currentGifts = firstArray(cycle, ['gifts']); + var currentRewards = firstArray(cycle, ['rewards']); + var historyEntries = firstArray(history, [ + 'top_entries', + 'topEntries', + 'entries', + ]); + var hasCycle = Boolean( + text(first(cycle, ['cycle_id', 'cycleId'], '')) || + number(first(cycle, ['start_ms', 'startMs'], 0), 0) + ); + var currentStateMessage = ''; + if (state.currentLoading) { + currentStateMessage = t('fami_week_star.loading', 'Loading...'); + } else if (state.currentError) { + currentStateMessage = t( + 'fami_week_star.activity_load_failed', + 'Unable to load Weekly Star.' + ); + } else if (!hasCycle) { + currentStateMessage = t( + 'fami_week_star.no_active_cycle', + 'No active cycle.' + ); + } + var rewardStateMessage = currentStateMessage; + if (!rewardStateMessage && !currentRewards.length) { + rewardStateMessage = t( + 'fami_week_star.empty_rewards', + 'No reward data yet.' + ); + } + + cycleRangeNode.textContent = formatCycleRange( + first(cycle, ['start_ms', 'startMs'], 0), + first(cycle, ['end_ms', 'endMs'], 0) + ); + historyRangeNode.textContent = formatHistoryRange( + first(historyCycle, ['start_ms', 'startMs'], 0), + first(historyCycle, ['end_ms', 'endMs'], 0) + ); + renderGifts(currentGifts, currentStateMessage); + renderRankList( + weeklyListNode, + state.weeklyEntries, + t('fami_week_star.empty_weekly', 'No ranking data yet.'), + state.currentError, + state.weeklyLoading + ); + renderRankList( + historyListNode, + historyEntries, + t('fami_week_star.empty_history', 'No previous ranking records.'), + state.historyError, + state.historyLoading + ); + renderRewards(currentRewards, rewardStateMessage); + } + + function mockData() { + var start = Date.UTC(2026, 4, 27, 21, 0, 0); + var end = Date.UTC(2026, 5, 5, 20, 59, 0); + var weekly = [ + { + rank_no: 1, + score: 337700, + user: { + username: 'hanneem', + avatar: FIXTURE_BASE + 'mock-avatar-top.webp', + }, + }, + { + rank_no: 2, + score: 337700, + user: { + username: 'hanneem', + avatar: FIXTURE_BASE + 'mock-avatar-user.webp', + }, + }, + { + rank_no: 3, + score: 337700, + user: { + username: 'hanneem', + avatar: FIXTURE_BASE + 'mock-avatar-user.webp', + }, + }, + { + rank_no: 4, + score: 337700, + user: { + username: 'hanneem', + avatar: FIXTURE_BASE + 'mock-avatar-user.webp', + }, + }, + { + rank_no: 5, + score: 337700, + user: { + username: 'hanneem', + avatar: FIXTURE_BASE + 'mock-avatar-user.webp', + }, + }, + ]; + var rewardItems = [1, 2, 3].map(function () { + return { + name: t('fami_week_star.mock_reward_name', 'Frame'), + duration_ms: 7 * 86400000, + image: FIXTURE_BASE + 'mock-gift.webp', + }; + }); + return { + current: { + cycle: { + cycle_id: 'mock-fami-week-star', + start_ms: start, + end_ms: end, + gifts: [1, 2, 3].map(function () { + return { + name: t('fami_week_star.gift', 'Gift'), + image: FIXTURE_BASE + 'mock-gift.webp', + }; + }), + rewards: [1, 2, 3].map(function (rank) { + return { + rank_no: rank, + resource_group: { items: rewardItems }, + }; + }), + }, + top_entries: weekly, + server_time_ms: Date.now(), + }, + weekly: weekly, + history: [ + { + cycle: { + start_ms: Date.UTC(2025, 11, 6, 21, 0, 0), + end_ms: Date.UTC(2025, 11, 14, 20, 59, 0), + }, + top_entries: weekly, + }, + ], + }; + } + + function errorText(error) { + return text(error && error.message) || 'request_failed'; + } + + function loadMock() { + var mock = mockData(); + state.currentLoading = false; + state.weeklyLoading = false; + state.historyLoading = false; + state.current = mock.current; + state.weeklyEntries = mock.weekly; + state.historyCycles = mock.history; + state.currentError = ''; + state.historyError = ''; + render(); + } + + function loadReal() { + var api = window.HyAppAPI && window.HyAppAPI.weeklyStar; + state.currentLoading = true; + state.weeklyLoading = true; + state.historyLoading = true; + state.currentError = ''; + state.historyError = ''; + render(); + + if (!api) { + state.currentLoading = false; + state.weeklyLoading = false; + state.historyLoading = false; + state.currentError = 'api_unavailable'; + state.historyError = 'api_unavailable'; + render(); + return; + } + + // history 与 current/leaderboard 使用独立 loading/error 状态;慢榜单不能阻塞已经成功的历史记录,任一接口失败也不清空另一块数据。 + api.history(10).then( + function (data) { + state.historyCycles = firstArray(data || {}, ['cycles']); + state.historyError = ''; + state.historyLoading = false; + render(); + }, + function (error) { + state.historyCycles = []; + state.historyError = errorText(error); + state.historyLoading = false; + render(); + } + ); + + api.current() + .then(function (data) { + state.current = data || {}; + state.currentError = ''; + state.currentLoading = false; + // current 一到就先展示周期、礼物和奖励,不等待只影响本周榜的 leaderboard。 + render(); + + var embedded = firstArray(state.current, [ + 'top_entries', + 'topEntries', + ]); + var cycle = cycleOf(state.current); + var cycleID = text(first(cycle, ['cycle_id', 'cycleId'], '')); + if (!cycleID) return embedded; + + // current.top_entries 可直接兜底;leaderboard 额外请求只取设计允许展示的前 5 名。 + // 排行接口独立失败时保留 current 数据,而不是把整个活动判为不可用。 + return api + .leaderboard({ + cycle_id: cycleID, + page_size: MAX_VISIBLE_RANKS, + }) + .then(function (data) { + var entries = firstArray(data || {}, ['entries']); + return entries.length ? entries : embedded; + }) + .catch(function () { + return embedded; + }); + }) + .then(function (entries) { + state.weeklyEntries = Array.isArray(entries) ? entries : []; + state.weeklyLoading = false; + render(); + }) + .catch(function (error) { + // current 失败时周期、礼物、本周榜和奖励统一进入明确错误态;历史请求仍独立完成并保留可用结果。 + state.current = null; + state.currentLoading = false; + state.weeklyLoading = false; + state.currentError = errorText(error); + state.weeklyEntries = []; + render(); + }); + } + + function bindTabs() { + tabs.forEach(function (tab, index) { + tab.addEventListener('click', function () { + setTab(tab.getAttribute('data-tab-target'), true); + }); + tab.addEventListener('keydown', function (event) { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') { + return; + } + event.preventDefault(); + var direction = event.key === 'ArrowRight' ? 1 : -1; + if (document.documentElement.dir === 'rtl') direction *= -1; + var nextIndex = (index + direction + tabs.length) % tabs.length; + tabs[nextIndex].focus(); + setTab(tabs[nextIndex].getAttribute('data-tab-target'), true); + }); + }); + } + + function bindResize() { + var scheduled = false; + function schedule() { + if (scheduled) return; + scheduled = true; + window.requestAnimationFrame(function () { + scheduled = false; + updateStageSize(); + }); + } + window.addEventListener('resize', schedule); + window.addEventListener('orientationchange', schedule); + if (window.ResizeObserver) { + new ResizeObserver(schedule).observe(stageWrap); + } + } + + function init() { + bindTabs(); + bindResize(); + setTab(state.tab, false); + window.addEventListener('hyapp:i18n-ready', function () { + // common/i18n 已更新静态 data-i18n 节点;这里只重算动态 API 文案与数字,避免重复 apply 触发事件循环。 + render(); + }); + if (useMock) loadMock(); + else loadReal(); + } + + init(); +})(); diff --git a/activity/week-star/fami/product.js b/activity/week-star/fami/product.js new file mode 100644 index 0000000..d68ff2d --- /dev/null +++ b/activity/week-star/fami/product.js @@ -0,0 +1,31 @@ +(function () { + 'use strict'; + + var APP_CODE = 'fami'; + + window.HyAppDefaultAppCode = APP_CODE; + window.HyAppI18nSupported = ['en', 'ar', 'tr', 'es']; + document.documentElement.setAttribute('data-hy-app-code', APP_CODE); + + // 该入口只属于 Fami。common/api.js 的请求租户以 app_code query 为最高优先级,若宿主误带其他 + // App 参数,仅设置页面默认值仍会串到错误活动周期;在公共脚本初始化前统一 URL,保留 token/env 等参数。 + try { + var url = new URL(window.location.href); + var changed = url.searchParams.get('app_code') !== APP_CODE; + url.searchParams.set('app_code', APP_CODE); + ['app', 'appCode'].forEach(function (key) { + if ( + url.searchParams.has(key) && + url.searchParams.get(key) !== APP_CODE + ) { + url.searchParams.set(key, APP_CODE); + changed = true; + } + }); + if (changed && window.history && window.history.replaceState) { + window.history.replaceState(null, '', url.toString()); + } + } catch (error) { + // 极少数受限 WebView 不允许构造或替换 URL;页面默认 App 仍能覆盖“未传 app_code”的正常入口。 + } +})(); diff --git a/activity/week-star/huwaa.html b/activity/week-star/huwaa.html new file mode 100644 index 0000000..61c70c0 --- /dev/null +++ b/activity/week-star/huwaa.html @@ -0,0 +1,254 @@ + + + + + + + Weekly Star + + + + +
+
+
+ + +
+ +

+ Weekly Star +

+
+ +
+ + +
+ +

+ Note: Send the following three gifts to participate in + the ranking. Users who rank in the top 3 of the + leaderboard will receive rewards. +

+ +
+ + + +
+

+ — +

+
+
+ + + + + +
+
+
+
+ + + + + + + + + diff --git a/activity/week-star/huwaa/assets/layers/background-stars.webp b/activity/week-star/huwaa/assets/layers/background-stars.webp new file mode 100644 index 0000000..c0c2ec9 Binary files /dev/null and b/activity/week-star/huwaa/assets/layers/background-stars.webp differ diff --git a/activity/week-star/huwaa/assets/layers/date-ribbon.webp b/activity/week-star/huwaa/assets/layers/date-ribbon.webp new file mode 100644 index 0000000..48ee57e Binary files /dev/null and b/activity/week-star/huwaa/assets/layers/date-ribbon.webp differ diff --git a/activity/week-star/huwaa/assets/layers/gift-frame.webp b/activity/week-star/huwaa/assets/layers/gift-frame.webp new file mode 100644 index 0000000..31bf21d Binary files /dev/null and b/activity/week-star/huwaa/assets/layers/gift-frame.webp differ diff --git a/activity/week-star/huwaa/assets/layers/hero-background.webp b/activity/week-star/huwaa/assets/layers/hero-background.webp new file mode 100644 index 0000000..d0a127d Binary files /dev/null and b/activity/week-star/huwaa/assets/layers/hero-background.webp differ diff --git a/activity/week-star/huwaa/assets/layers/manifest.json b/activity/week-star/huwaa/assets/layers/manifest.json new file mode 100644 index 0000000..75d089c --- /dev/null +++ b/activity/week-star/huwaa/assets/layers/manifest.json @@ -0,0 +1,503 @@ +{ + "schemaVersion": 1, + "figma": { + "fileKey": "OTOg5cwbhVNWxkImGmzTJl", + "canvas": { + "id": "543:1998", + "name": "Huwaa运营" + }, + "entryFrames": { + "history": { + "id": "818:1999", + "name": "历史前三", + "width": 1080, + "height": 2309 + }, + "weekly": { + "id": "818:2066", + "name": "本周榜单", + "width": 1080, + "height": 2309 + }, + "rewards": { + "id": "818:2132", + "name": "奖励说明", + "width": 1080, + "height": 3138 + } + } + }, + "policy": { + "rule": "Only independently traced visual layers may ship. Localized text, controls, state, lists, and production business media stay in DOM/CSS/API data.", + "forbiddenSourceNodes": [ + "543:1998", + "818:1999", + "818:2066", + "818:2132" + ], + "forbiddenFilenamePatterns": [ + "whole-page", + "full-page", + "page-screenshot", + "screen-shot", + "root-frame", + "entry-frame", + "figma-frame", + "aggregate" + ], + "forbiddenRuntimeReferences": [ + ".audit/", + "hero-source.webp", + "hero-original.webp", + "hero-with-text.webp", + "hero-exact-fill.webp" + ], + "maxReasonableFileLayer": { + "width": 1800, + "height": 2400, + "pixels": 3200000 + } + }, + "assets": [ + { + "filename": "background-stars.webp", + "scope": "runtime", + "kind": "single-background-image-fill", + "sourceNode": "818:2003", + "sourceNodes": ["818:2003", "818:2070", "818:2136"], + "sourceName": "已生成图片 3", + "sourceType": "RECTANGLE", + "sourceImageHash": "ff72a2aa619bf8f79c544ae3e976a015e9b91583", + "use": "Pure star-palace background layer behind DOM content", + "renderedDesignSize": { + "width": 1300, + "height": 2309 + }, + "file": { + "format": "webp", + "pixelWidth": 941, + "pixelHeight": 1672, + "bytes": 151446 + }, + "sha256": "ae690d230d418f234cf51b5645dacfe892052eca91a3b41097ee1e8102963f36", + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false, + "mockOnly": false + }, + { + "filename": "hero-background.webp", + "scope": "runtime", + "kind": "single-derived-background", + "sourceNode": "818:2006", + "sourceNodes": ["818:2006", "818:2073", "818:2139"], + "sourceName": "图层 1 5", + "sourceType": "RECTANGLE", + "sourceImageHash": "cbc4013cd84d895e3a3ebf260e42b1013066c6ae", + "use": "Decorative hero background only; title and date are live DOM/i18n/API content", + "renderedDesignSize": { + "width": 1080, + "height": 1080 + }, + "file": { + "format": "webp", + "pixelWidth": 1254, + "pixelHeight": 1254, + "bytes": 553084 + }, + "sha256": "143b0ceee3c3c484b47f11c87c35d3985eea04aac2d784ac3df154dece5353b6", + "derivedFrom": { + "kind": "exact-figma-image-fill", + "sourceNodes": ["818:2006", "818:2073", "818:2139"], + "sourceImageHash": "cbc4013cd84d895e3a3ebf260e42b1013066c6ae", + "tool": "built-in image_gen", + "operation": "Removed only the embedded readable title/date text from the exact image fill; retained the decorative background artwork and empty ornamental plaques", + "sourceContainedReadableText": true + }, + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false, + "mockOnly": false + }, + { + "filename": "date-ribbon.webp", + "scope": "runtime", + "kind": "single-ornamental-label-skin", + "sourceNode": "818:2080", + "sourceNodes": ["818:2080", "818:2140"], + "sourceName": "长 2", + "sourceType": "RECTANGLE", + "sourceImageHash": "cb0526584cbce4322618c38e8c3aa2ca0acba3bf", + "use": "Empty date ribbon behind the live activity period", + "renderedDesignSize": { + "width": 672, + "height": 95 + }, + "file": { + "format": "webp", + "pixelWidth": 672, + "pixelHeight": 95, + "bytes": 8882 + }, + "sha256": "ca001aab298b3d86c5ea23ff928076a2955f45c6b586fa275a959650597c0964", + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false, + "mockOnly": false + }, + { + "filename": "gift-frame.webp", + "scope": "runtime", + "kind": "single-ornamental-frame", + "sourceNode": "818:2011", + "sourceNodes": [ + "818:2011", + "818:2012", + "818:2016", + "818:2017", + "818:2021", + "818:2022", + "818:2085", + "818:2086", + "818:2090", + "818:2091", + "818:2095", + "818:2096", + "818:2145", + "818:2146", + "818:2150", + "818:2151", + "818:2155", + "818:2156" + ], + "sourceName": "图层 0 24", + "sourceType": "RECTANGLE", + "sourceImageHash": "2a93765147074c2df60a94c592dc153987231c1d", + "use": "Decorative gift frame around live API gift media", + "renderedDesignSize": { + "width": 257, + "height": 257 + }, + "file": { + "format": "webp", + "pixelWidth": 257, + "pixelHeight": 257, + "bytes": 15066 + }, + "sha256": "37180db49b3d8d7c0d1ca71ec3df7c64df708a5c6f576577e1c0c2978f3c3dcc", + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false, + "mockOnly": false + }, + { + "filename": "sample-gift.webp", + "scope": "fixture", + "kind": "single-dynamic-media-fixture", + "sourceNode": "818:2013", + "sourceNodes": [ + "818:2013", + "818:2018", + "818:2023", + "818:2087", + "818:2092", + "818:2097", + "818:2147", + "818:2152", + "818:2157", + "818:2181", + "818:2186", + "818:2191", + "818:2203", + "818:2208", + "818:2213", + "818:2225", + "818:2230", + "818:2235" + ], + "sourceName": "10", + "sourceType": "RECTANGLE", + "sourceImageHash": "b046fadadba4b9ef6f75b336460388fe074f48e9", + "use": "Figma sample gift used only by the explicit ?mock=1 visual fixture; production media comes from the API", + "renderedDesignSize": { + "width": 188, + "height": 188 + }, + "file": { + "format": "webp", + "pixelWidth": 144, + "pixelHeight": 144, + "bytes": 15186 + }, + "sha256": "55e86aec9054813a6d6277cc9c9b69cf0e39a1303caf2c90255e67c21c8cf1c6", + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": true, + "mockOnly": true + }, + { + "filename": "tab-active.webp", + "scope": "runtime", + "kind": "single-ornamental-tab-skin", + "sourceNode": "818:2024", + "sourceNodes": ["818:2024", "818:2098", "818:2165"], + "sourceName": "图层 0 25", + "sourceType": "RECTANGLE", + "sourceImageHash": "f01e6d52e89d3e454c92c63eebf799237e92897b", + "use": "Active tab skin under a real DOM button and localized label", + "renderedDesignSize": { + "width": 335, + "height": 149 + }, + "file": { + "format": "webp", + "pixelWidth": 335, + "pixelHeight": 149, + "bytes": 13332 + }, + "sha256": "6438c7dbb4fbb1763201645f9a154d19a240ca78ac1ff30934c4517cf58b909d", + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false, + "mockOnly": false + }, + { + "filename": "tab-inactive.webp", + "scope": "runtime", + "kind": "single-ornamental-tab-skin", + "sourceNode": "818:2025", + "sourceNodes": [ + "818:2025", + "818:2026", + "818:2099", + "818:2100", + "818:2159", + "818:2162" + ], + "sourceName": "阿斯顿撒大 1", + "sourceType": "RECTANGLE", + "sourceImageHash": "327f41c4ed551df8dd3075beb57a2191b68ded43", + "use": "Inactive tab skin under a real DOM button and localized label", + "renderedDesignSize": { + "width": 335, + "height": 146 + }, + "file": { + "format": "webp", + "pixelWidth": 335, + "pixelHeight": 146, + "bytes": 10908 + }, + "sha256": "ddfb87104d838ac72b3167614edb34af7f260f16a9b6a111e3a32b1b7b576440", + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false, + "mockOnly": false + }, + { + "filename": "rank-1.webp", + "scope": "runtime", + "kind": "single-static-rank-icon", + "sourceNode": "818:2041", + "sourceNodes": ["818:2041", "818:2107"], + "sourceName": "图层 2 1", + "sourceType": "RECTANGLE", + "sourceImageHash": "81c55bce70ac66a5f8aada3131aec4b8de553fe4", + "use": "Fixed rank-one trophy icon beside live leaderboard identity and score data", + "renderedDesignSize": { + "width": 69, + "height": 61 + }, + "file": { + "format": "webp", + "pixelWidth": 69, + "pixelHeight": 61, + "bytes": 2896 + }, + "sha256": "5e1ec9de4a8b3b1da05382f668400e3f69bb842089d550d379b7b1d84d3fcbf0", + "containsText": false, + "containsFixedOrdinalGlyph": true, + "containsControls": false, + "containsDynamicBusinessMedia": false, + "mockOnly": false + }, + { + "filename": "rank-2.webp", + "scope": "runtime", + "kind": "single-static-rank-icon", + "sourceNode": "818:2046", + "sourceNodes": ["818:2046", "818:2112"], + "sourceName": "图层 2 2", + "sourceType": "RECTANGLE", + "sourceImageHash": "d65314646fb6a249672b27987cf8c0dd4bc42b4e", + "use": "Fixed rank-two trophy icon beside live leaderboard identity and score data", + "renderedDesignSize": { + "width": 69, + "height": 61 + }, + "file": { + "format": "webp", + "pixelWidth": 69, + "pixelHeight": 61, + "bytes": 2630 + }, + "sha256": "6919bc82a2449b17a985b668365bf251d5984b4a3af1624d0fd4b8974fff1ec8", + "containsText": false, + "containsFixedOrdinalGlyph": true, + "containsControls": false, + "containsDynamicBusinessMedia": false, + "mockOnly": false + }, + { + "filename": "rank-3.webp", + "scope": "runtime", + "kind": "single-static-rank-icon", + "sourceNode": "818:2053", + "sourceNodes": ["818:2053", "818:2119"], + "sourceName": "图层 4 1", + "sourceType": "RECTANGLE", + "sourceImageHash": "5e47a256c75d5b5a54e7b59e5338bd1dff47f596", + "use": "Fixed rank-three trophy icon beside live leaderboard identity and score data", + "renderedDesignSize": { + "width": 63, + "height": 61 + }, + "file": { + "format": "webp", + "pixelWidth": 63, + "pixelHeight": 61, + "bytes": 2606 + }, + "sha256": "af998a71318314016ec8ebb8aba15e59224cb0c054b0390eabcfd51e6eb8ed71", + "containsText": false, + "containsFixedOrdinalGlyph": true, + "containsControls": false, + "containsDynamicBusinessMedia": false, + "mockOnly": false + }, + { + "filename": "reward-panel.webp", + "scope": "runtime", + "kind": "single-ornamental-frame", + "sourceNode": "818:2168", + "sourceNodes": ["818:2168"], + "sourceName": "中小城镇 1", + "sourceType": "RECTANGLE", + "sourceImageHash": "6b120df5508b85ec04f80772bc0a92fa75d5ced3", + "use": "Empty reward-section border around DOM headings and reward rows", + "renderedDesignSize": { + "width": 1033, + "height": 1669 + }, + "file": { + "format": "webp", + "pixelWidth": 1033, + "pixelHeight": 1669, + "bytes": 73126 + }, + "sha256": "18f31785c7656b76942ef435f7fd2027b62faac357ceafd658e21c2f79fa9ec0", + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false, + "mockOnly": false + }, + { + "filename": "reward-heading.webp", + "scope": "runtime", + "kind": "single-ornamental-label-skin", + "sourceNode": "818:2170", + "sourceNodes": ["818:2170", "818:2193", "818:2215"], + "sourceName": "勋章呈现出 1", + "sourceType": "RECTANGLE", + "sourceImageHash": "11ba387b94c67d28068e40088dffd4078c26e785", + "use": "Empty reward-tier heading behind localized DOM text", + "renderedDesignSize": { + "width": 626, + "height": 98 + }, + "file": { + "format": "webp", + "pixelWidth": 626, + "pixelHeight": 98, + "bytes": 11140 + }, + "sha256": "3d34d3420d76dd1394b1080a0f82d910547aeb9c0dc8209e849412a231c91573", + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false, + "mockOnly": false + }, + { + "filename": "reward-item-frame.webp", + "scope": "runtime", + "kind": "single-ornamental-frame", + "sourceNode": "818:2179", + "sourceNodes": [ + "818:2179", + "818:2184", + "818:2189", + "818:2201", + "818:2206", + "818:2211", + "818:2223", + "818:2228", + "818:2233" + ], + "sourceName": "图层 1 1", + "sourceType": "RECTANGLE", + "sourceImageHash": "652e633725bc472c5892142e3eb4686bfb87a304", + "use": "Reward item frame around live reward media and localized DOM labels", + "renderedDesignSize": { + "width": 257, + "height": 270 + }, + "file": { + "format": "webp", + "pixelWidth": 257, + "pixelHeight": 270, + "bytes": 11644 + }, + "sha256": "1ff7fbaf2b14d17fc1eb9135c1136504fa96919d7adea996b115df8447d66b1e", + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false, + "mockOnly": false + }, + { + "filename": "reward-item-glow.webp", + "scope": "runtime", + "kind": "single-ornamental-overlay", + "sourceNode": "818:2180", + "sourceNodes": [ + "818:2180", + "818:2185", + "818:2190", + "818:2202", + "818:2207", + "818:2212", + "818:2224", + "818:2229", + "818:2234" + ], + "sourceName": "电商双十一通用弹窗广告_设计页-稿定设计 1", + "sourceType": "RECTANGLE", + "sourceImageHash": "edd5cb329ba1408850f5ba3dd4fc04519b3ae272", + "use": "Decorative glow between each reward frame and live reward media", + "renderedDesignSize": { + "width": 219, + "height": 219 + }, + "file": { + "format": "webp", + "pixelWidth": 219, + "pixelHeight": 219, + "bytes": 11144 + }, + "sha256": "ad79af7942577e5e0a683af447b423c8a35838957c51d4f9b3ab9a31eb02db6a", + "containsText": false, + "containsControls": false, + "containsDynamicBusinessMedia": false, + "mockOnly": false + } + ] +} diff --git a/activity/week-star/huwaa/assets/layers/rank-1.webp b/activity/week-star/huwaa/assets/layers/rank-1.webp new file mode 100644 index 0000000..805db5b Binary files /dev/null and b/activity/week-star/huwaa/assets/layers/rank-1.webp differ diff --git a/activity/week-star/huwaa/assets/layers/rank-2.webp b/activity/week-star/huwaa/assets/layers/rank-2.webp new file mode 100644 index 0000000..bfd8da8 Binary files /dev/null and b/activity/week-star/huwaa/assets/layers/rank-2.webp differ diff --git a/activity/week-star/huwaa/assets/layers/rank-3.webp b/activity/week-star/huwaa/assets/layers/rank-3.webp new file mode 100644 index 0000000..95aae5e Binary files /dev/null and b/activity/week-star/huwaa/assets/layers/rank-3.webp differ diff --git a/activity/week-star/huwaa/assets/layers/reward-heading.webp b/activity/week-star/huwaa/assets/layers/reward-heading.webp new file mode 100644 index 0000000..d5f9446 Binary files /dev/null and b/activity/week-star/huwaa/assets/layers/reward-heading.webp differ diff --git a/activity/week-star/huwaa/assets/layers/reward-item-frame.webp b/activity/week-star/huwaa/assets/layers/reward-item-frame.webp new file mode 100644 index 0000000..f7d3f9b Binary files /dev/null and b/activity/week-star/huwaa/assets/layers/reward-item-frame.webp differ diff --git a/activity/week-star/huwaa/assets/layers/reward-item-glow.webp b/activity/week-star/huwaa/assets/layers/reward-item-glow.webp new file mode 100644 index 0000000..2434016 Binary files /dev/null and b/activity/week-star/huwaa/assets/layers/reward-item-glow.webp differ diff --git a/activity/week-star/huwaa/assets/layers/reward-panel.webp b/activity/week-star/huwaa/assets/layers/reward-panel.webp new file mode 100644 index 0000000..4fa2e74 Binary files /dev/null and b/activity/week-star/huwaa/assets/layers/reward-panel.webp differ diff --git a/activity/week-star/huwaa/assets/layers/sample-gift.webp b/activity/week-star/huwaa/assets/layers/sample-gift.webp new file mode 100644 index 0000000..bc7b004 Binary files /dev/null and b/activity/week-star/huwaa/assets/layers/sample-gift.webp differ diff --git a/activity/week-star/huwaa/assets/layers/tab-active.webp b/activity/week-star/huwaa/assets/layers/tab-active.webp new file mode 100644 index 0000000..d7bd6d1 Binary files /dev/null and b/activity/week-star/huwaa/assets/layers/tab-active.webp differ diff --git a/activity/week-star/huwaa/assets/layers/tab-inactive.webp b/activity/week-star/huwaa/assets/layers/tab-inactive.webp new file mode 100644 index 0000000..b156dba Binary files /dev/null and b/activity/week-star/huwaa/assets/layers/tab-inactive.webp differ diff --git a/activity/week-star/huwaa/huwaa.css b/activity/week-star/huwaa/huwaa.css new file mode 100644 index 0000000..e62e4e9 --- /dev/null +++ b/activity/week-star/huwaa/huwaa.css @@ -0,0 +1,722 @@ +:root { + --huwaa-week-star-width: min(100vw, 430px); + --huwaa-week-star-scale: 0.3472222222; + /* These page-local colors are measured from Figma nodes 818:1999/2066/2132. */ + --huwaa-week-star-bg: #1c0536; + --huwaa-week-star-row: #1b0a41; + --huwaa-week-star-row-alt: rgba(103, 0, 203, 0.2); + --huwaa-week-star-text: #f8e8ff; + --huwaa-week-star-history: #b856ff; +} + +* { + box-sizing: border-box; + -webkit-tap-highlight-color: transparent; +} + +html, +body { + width: 100%; + min-height: 100%; + margin: 0; + overflow-x: hidden; + background: #0b0219; + color: var(--huwaa-week-star-text); + font-family: + 'Source Han Sans SC', + 'Noto Sans CJK SC', + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Arial, + sans-serif; +} + +button { + margin: 0; + border: 0; + padding: 0; + color: inherit; + font: inherit; + background: transparent; +} + +button:focus-visible { + outline: 6px solid var(--hy-theme-primary, #cf6fff); + outline-offset: -8px; +} + +img { + display: block; + max-width: none; + user-select: none; + -webkit-user-drag: none; +} + +[hidden] { + display: none !important; +} + +.week-star-page { + position: relative; + width: var(--huwaa-week-star-width); + min-height: 0; + margin: 0 auto; + overflow: hidden; + background: var(--huwaa-week-star-bg); + direction: ltr; +} + +.week-star-stage-wrap { + position: relative; + width: var(--huwaa-week-star-width); + height: auto; + aspect-ratio: 1080 / 2309; + overflow: hidden; +} + +.week-star-stage { + position: absolute; + top: 0; + left: 0; + width: 1080px; + height: 2309px; + overflow: hidden; + background: var(--huwaa-week-star-bg); + transform: scale(var(--huwaa-week-star-scale)); + transform-origin: top left; +} + +.week-star-background { + position: absolute; + top: 0; + left: -110px; + width: 1300px; + height: 2309px; + opacity: 0.06; + object-fit: cover; + pointer-events: none; +} + +.week-star-hero, +.week-star-hero-mask { + position: absolute; + top: 0; + left: 0; + width: 1080px; + height: 1022px; + pointer-events: none; +} + +.week-star-hero-mask { + overflow: hidden; + -webkit-mask-image: linear-gradient( + to bottom, + #000 0%, + #000 65%, + transparent 100% + ); + mask-image: linear-gradient(to bottom, #000 0%, #000 65%, transparent 100%); +} + +.week-star-hero-image { + position: absolute; + top: -24px; + left: 0; + width: 1080px; + height: 1080px; + object-fit: cover; +} + +.week-star-title { + position: absolute; + top: 622px; + left: 145px; + width: 790px; + height: 126px; + margin: 0; + overflow: hidden; + background: linear-gradient( + 180deg, + #fff9d0 0%, + #f8cf7d 36%, + #fff3c4 52%, + #b97828 100% + ); + background-clip: text; + -webkit-background-clip: text; + color: transparent; + font-family: Georgia, 'Times New Roman', serif; + font-size: 102px; + font-weight: 700; + line-height: 126px; + text-align: center; + text-shadow: + 0 4px 0 rgba(67, 20, 7, 0.66), + 0 9px 14px rgba(0, 0, 0, 0.72); + white-space: nowrap; +} + +.week-star-date { + position: absolute; + top: 778px; + left: 204px; + width: 672px; + height: 95px; +} + +.week-star-page:not([data-tab='history']) .week-star-date { + top: 726px; +} + +.week-star-date-ribbon { + position: absolute; + inset: 0; + width: 672px; + height: 95px; + object-fit: fill; +} + +.week-star-page[data-tab='history'] .week-star-date-ribbon { + visibility: hidden; +} + +.week-star-date-text { + position: absolute; + top: 19px; + left: 0; + width: 672px; + height: 42px; + overflow: hidden; + color: #fff; + font-size: 30px; + font-style: normal; + font-weight: 500; + line-height: 30px; + text-align: center; + text-shadow: + 0 2px 2px rgba(31, 0, 72, 0.8), + 0 0 10px rgba(255, 120, 255, 0.55); + white-space: nowrap; +} + +.week-star-note { + position: absolute; + top: 861px; + left: 64px; + width: 952px; + height: 126px; + margin: 0; + overflow: hidden; + color: var(--huwaa-week-star-text); + font-size: 36px; + font-weight: 500; + line-height: 42px; + text-align: left; + text-shadow: 0 2px 6px rgba(0, 0, 0, 0.36); +} + +.week-star-gifts { + position: absolute; + top: 1027px; + left: 0; + width: 1080px; + height: 257px; +} + +.week-star-gift { + position: absolute; + top: 0; + left: var(--gift-left); + width: 257px; + height: 257px; + overflow: hidden; +} + +.week-star-gift-frame { + position: absolute; + inset: 0; + width: 257px; + height: 257px; + object-fit: fill; + pointer-events: none; +} + +.week-star-gift-frame-glow { + mix-blend-mode: screen; +} + +.week-star-gift-image, +.week-star-gift-placeholder { + position: absolute; + top: 41px; + left: 35px; + width: 188px; + height: 188px; +} + +.week-star-gift-image { + object-fit: contain; +} + +.week-star-gift-placeholder { + display: flex; + align-items: center; + justify-content: center; + color: rgba(255, 255, 255, 0.62); + font-size: 62px; + font-weight: 700; +} + +.week-star-gift.has-image .week-star-gift-placeholder { + display: none; +} + +.week-star-tabs { + position: absolute; + z-index: 10; + top: 1316px; + left: 21px; + display: grid; + grid-template-columns: repeat(3, 335px); + column-gap: 18px; + width: 1041px; + height: 153px; +} + +.week-star-tab { + position: relative; + width: 335px; + height: 153px; + cursor: pointer; +} + +.week-star-tab-skin { + position: absolute; + left: 0; + width: 335px; + object-fit: fill; + pointer-events: none; +} + +.week-star-tab-skin-active { + top: 4px; + height: 149px; + visibility: hidden; +} + +.week-star-tab-skin-inactive { + top: 6px; + height: 146px; +} + +.week-star-tab.is-active .week-star-tab-skin-active { + visibility: visible; +} + +.week-star-tab.is-active .week-star-tab-skin-inactive { + visibility: hidden; +} + +.week-star-tab span { + position: absolute; + top: 57px; + left: 32px; + display: flex; + width: 270px; + height: 77px; + align-items: center; + justify-content: center; + overflow: hidden; + color: rgba(255, 255, 255, 0.6); + font-size: 32px; + font-weight: 500; + line-height: 34px; + text-align: center; + text-shadow: 0 2px 5px rgba(0, 0, 0, 0.42); +} + +.week-star-tab.is-active span { + color: #fff; +} + +.week-star-panel { + position: absolute; + inset: 0; + width: 1080px; +} + +.week-star-history-date { + position: absolute; + top: 1514px; + left: 291px; + width: 508px; + height: 46px; + margin: 0; + overflow: hidden; + color: var(--huwaa-week-star-history); + font-size: 46px; + font-weight: 500; + line-height: 46px; + text-align: center; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.4); + white-space: nowrap; +} + +.week-star-history-panel::before { + position: absolute; + top: 1467px; + left: 0; + width: 1080px; + height: 140px; + background: var(--huwaa-week-star-row); + content: ''; +} + +.week-star-rank-row { + position: absolute; + left: 0; + width: 1080px; + height: 140px; + overflow: hidden; + color: #fff; + text-align: left; + cursor: default; +} + +.week-star-rank-row[data-user-id] { + cursor: pointer; +} + +.week-star-rank-row.is-dark { + background: var(--huwaa-week-star-row); +} + +.week-star-rank-row.is-purple { + background: var(--huwaa-week-star-row-alt); +} + +.week-star-rank-medal, +.week-star-rank-number { + position: absolute; + top: 39px; + left: 45px; + width: 69px; + height: 61px; +} + +.week-star-rank-medal { + object-fit: contain; +} + +.week-star-rank-medal.is-rank-three { + left: 47px; + width: 63px; +} + +.week-star-rank-number { + top: 49px; + left: 69px; + width: 45px; + height: 42px; + font-size: 36px; + font-weight: 500; + line-height: 42px; +} + +.week-star-rank-avatar { + position: absolute; + top: 13px; + left: 160px; + width: 113px; + height: 113px; + overflow: hidden; + border: 3px solid rgba(255, 255, 255, 0.22); + border-radius: 50%; + background: rgba(184, 86, 255, 0.3); +} + +.week-star-rank-avatar img { + position: absolute; + inset: 0; + z-index: 1; + width: 100%; + height: 100%; + object-fit: cover; +} + +.week-star-rank-avatar span { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 42px; + font-weight: 700; + line-height: 1; + text-transform: uppercase; +} + +.week-star-rank-avatar.is-fallback img { + display: none; +} + +.week-star-rank-name, +.week-star-rank-score { + position: absolute; + top: 49px; + height: 42px; + overflow: hidden; + color: #fff; + font-size: 36px; + font-weight: 500; + line-height: 42px; + white-space: nowrap; + text-overflow: ellipsis; +} + +.week-star-rank-name { + left: 313px; + width: 420px; +} + +.week-star-rank-score { + left: 846px; + width: 190px; + text-align: left; +} + +.week-star-state { + position: absolute; + left: 70px; + display: flex; + width: 940px; + min-height: 140px; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 26px; + padding: 20px 40px; + color: rgba(255, 255, 255, 0.8); + font-size: 34px; + font-weight: 500; + line-height: 46px; + text-align: center; + text-shadow: 0 2px 6px rgba(0, 0, 0, 0.5); +} + +.week-star-state-retry { + min-width: 260px; + min-height: 76px; + border: 3px solid rgba(255, 255, 255, 0.38); + border-radius: 38px; + padding: 12px 38px; + background: var(--hy-theme-button, #be73ed); + color: #fff; + font-size: 30px; + font-weight: 700; + line-height: 42px; + cursor: pointer; +} + +.week-star-reward-panel-skin { + position: absolute; + top: 1531px; + left: 24px; + width: 1033px; + height: 1669px; + object-fit: fill; + pointer-events: none; +} + +.week-star-reward-group { + position: absolute; + left: 91px; + width: 900px; + height: 424px; +} + +.week-star-reward-heading { + position: absolute; + top: 0; + left: 136px; + width: 626px; + height: 98px; + margin: 0; +} + +.week-star-reward-heading img { + position: absolute; + inset: 0; + width: 626px; + height: 98px; + object-fit: fill; +} + +.week-star-reward-heading span { + position: absolute; + top: 43px; + left: 0; + width: 626px; + height: 40px; + overflow: hidden; + color: #fff; + font-family: + -apple-system, BlinkMacSystemFont, 'SF Pro Display', Arial, sans-serif; + font-size: 40px; + font-weight: 500; + line-height: 40px; + text-align: center; + text-shadow: + 0 2px 4px rgba(0, 0, 0, 0.45), + 0 0 9px rgba(255, 93, 255, 0.55); +} + +.week-star-reward-items { + position: absolute; + top: 200px; + left: 0; + display: flex; + width: 900px; + height: 326px; + align-items: flex-start; + justify-content: center; + gap: 65px; +} + +.week-star-reward-item { + position: relative; + width: 257px; + height: 326px; + flex: 0 0 257px; +} + +.week-star-reward-item-frame { + position: absolute; + top: 0; + left: 0; + width: 257px; + height: 270px; + object-fit: fill; +} + +.week-star-reward-item-glow { + position: absolute; + top: 30px; + left: 19px; + width: 219px; + height: 219px; + opacity: 0.34; + object-fit: contain; +} + +.week-star-reward-item-image, +.week-star-reward-item-placeholder { + position: absolute; + top: 41px; + left: 35px; + width: 188px; + height: 188px; +} + +.week-star-reward-item-image { + object-fit: contain; +} + +.week-star-reward-item-placeholder { + display: flex; + align-items: center; + justify-content: center; + color: rgba(255, 255, 255, 0.6); + font-size: 58px; + font-weight: 700; +} + +.week-star-reward-item.has-image .week-star-reward-item-placeholder { + display: none; +} + +.week-star-reward-label { + position: absolute; + top: 290px; + left: 3px; + width: 251px; + height: 36px; + overflow: hidden; + color: #fff; + font-size: 36px; + font-weight: 500; + line-height: 36px; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; +} + +.week-star-reward-empty { + position: absolute; + top: 1705px; + left: 90px; +} + +.week-star-reward-inline-empty { + display: flex; + width: 900px; + height: 140px; + align-items: center; + justify-content: center; + color: rgba(255, 255, 255, 0.8); + font-size: 34px; + font-weight: 500; + line-height: 46px; + text-align: center; +} + +.week-star-announcer { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +html[dir='rtl'] .week-star-note { + direction: rtl; + text-align: right; +} + +html[dir='rtl'] .week-star-tab span, +html[dir='rtl'] .week-star-state, +html[dir='rtl'] .week-star-reward-label { + direction: rtl; +} + +html[lang='ar'] .week-star-title, +html[lang='tr'] .week-star-title, +html[lang='es'] .week-star-title { + font-family: 'Source Han Sans SC', Arial, sans-serif; + font-size: 82px; +} + +html[lang='tr'] .week-star-tab span, +html[lang='es'] .week-star-tab span { + font-size: 28px; + line-height: 31px; +} + +@media (min-width: 1080px) { + :root { + --huwaa-week-star-width: 430px; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition: none !important; + } +} diff --git a/activity/week-star/huwaa/huwaa.js b/activity/week-star/huwaa/huwaa.js new file mode 100644 index 0000000..63b5513 --- /dev/null +++ b/activity/week-star/huwaa/huwaa.js @@ -0,0 +1,1196 @@ +(function () { + 'use strict'; + + var ASSET_BASE = './huwaa/assets/layers/'; + var RANK_LIMIT = 5; + var DAY_MS = 24 * 60 * 60 * 1000; + var DESIGN_HEIGHT = { history: 2309, weekly: 2309, rewards: 3200 }; + var DESIGN_FRAME = { + history: '818:1999', + weekly: '818:2066', + rewards: '818:2132', + }; + var params = currentParams(); + var useMock = params.get('mock') === '1'; + var page = document.getElementById('weekStarPage'); + var stage = document.getElementById('weekStarStage'); + var stageWrap = document.getElementById('weekStarStageWrap'); + var tabs = all('[data-tab-target]'); + var panels = all('[data-panel]'); + var state = { + // Once the user switches sections, the hash is the canonical tab state. It + // must win over a stale entry `?tab=` value while the hash-query token stays intact. + tab: normalizeTab(tabFromHash() || params.get('tab')), + current: null, + weeklyEntries: [], + historyCycles: [], + loadingCurrent: true, + loadingWeekly: true, + loadingHistory: true, + currentError: '', + weeklyError: '', + historyError: '', + requestSerial: 0, + }; + + function all(selector, root) { + return Array.prototype.slice.call( + (root || document).querySelectorAll(selector) + ); + } + + function currentParams() { + if ( + window.HyAppParams && + window.HyAppParams.current && + window.HyAppParams.current.raw + ) { + return window.HyAppParams.current.raw; + } + var result = new URLSearchParams(window.location.search || ''); + var hash = window.location.hash || ''; + if (hash.indexOf('?') >= 0) { + new URLSearchParams(hash.split('?').slice(1).join('?')).forEach( + function (value, key) { + if (!result.has(key)) result.set(key, value); + } + ); + } + return result; + } + + function text(value) { + return String( + value === undefined || value === null ? '' : value + ).trim(); + } + + function number(value, fallback) { + var result = Number(value); + return Number.isFinite(result) ? result : Number(fallback || 0); + } + + function first(source, keys) { + source = source || {}; + for (var index = 0; index < keys.length; index += 1) { + var value = source[keys[index]]; + if (value !== undefined && value !== null && value !== '') { + return value; + } + } + return ''; + } + + function envelope(payload) { + if (payload && Object.prototype.hasOwnProperty.call(payload, 'data')) { + return payload.data || {}; + } + return payload || {}; + } + + function parseJSON(value) { + if (!value || typeof value !== 'string') return {}; + try { + return JSON.parse(value); + } catch (error) { + return {}; + } + } + + function t(key, fallback, values) { + var translated = + window.HyAppI18n && window.HyAppI18n.t + ? window.HyAppI18n.t(key, fallback) + : fallback || key; + Object.keys(values || {}).forEach(function (name) { + translated = translated.replace( + new RegExp('\\{' + name + '\\}', 'g'), + String(values[name]) + ); + }); + return translated; + } + + function escapeHTML(value) { + return String(value === undefined || value === null ? '' : value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function escapeAttr(value) { + return escapeHTML(value); + } + + function resolveAssetURL(value) { + var raw = text(value); + if (!raw) return ''; + if ( + /^(https?:)?\/\//i.test(raw) || + /^(data|blob):/i.test(raw) || + raw.charAt(0) === '/' || + raw.indexOf('./') === 0 || + raw.indexOf('../') === 0 + ) { + return raw; + } + // Backend resource records may return a path relative to the API host. Resolving + // against the API base avoids silently requesting that path from this H5 directory. + try { + var base = + window.HyAppAPI && window.HyAppAPI.baseURL + ? window.HyAppAPI.baseURL() + : window.location.origin + '/'; + return new URL(raw, base).toString(); + } catch (error) { + return ''; + } + } + + function formatNumber(value) { + var lang = + window.HyAppI18n && window.HyAppI18n.lang + ? window.HyAppI18n.lang() + : 'en'; + return new Intl.NumberFormat(lang).format(number(value)); + } + + function formatScore(value) { + var raw = text(value); + if (!raw) return '0'; + var numeric = Number(raw); + return Number.isFinite(numeric) ? String(numeric) : raw; + } + + function pad(value) { + return String(value).padStart(2, '0'); + } + + function timezoneOffsetHours(cycle) { + var explicit = first(cycle, [ + 'utc_offset_hours', + 'utcOffsetHours', + 'timezone_offset_hours', + 'timezoneOffsetHours', + ]); + if (explicit !== '') return number(explicit, 3); + var label = text(first(cycle, ['timezone', 'time_zone', 'timeZone'])); + var match = label.match(/(?:utc|gmt)\s*([+-]\d+(?:\.\d+)?)/i); + // The Figma campaign explicitly uses UTC+3. The API can override this through + // its cycle metadata; the design offset is only the fallback display contract. + return match ? number(match[1], 3) : 3; + } + + function shiftedDate(value, offsetHours) { + var timestamp = number(value); + if (!timestamp) return null; + return new Date(timestamp + offsetHours * 60 * 60 * 1000); + } + + function formatDateTime(value, offsetHours) { + var date = shiftedDate(value, offsetHours); + if (!date) return '—'; + return [ + date.getUTCFullYear(), + '-', + pad(date.getUTCMonth() + 1), + '-', + pad(date.getUTCDate()), + ' ', + pad(date.getUTCHours()), + ':', + pad(date.getUTCMinutes()), + ].join(''); + } + + function formatDate(value, offsetHours) { + var date = shiftedDate(value, offsetHours); + if (!date) return '—'; + return [ + date.getUTCFullYear(), + '/', + pad(date.getUTCMonth() + 1), + '/', + pad(date.getUTCDate()), + ].join(''); + } + + function formatOffset(offsetHours) { + return (offsetHours >= 0 ? '+' : '') + String(offsetHours); + } + + function cycleRange(cycle) { + cycle = cycle || {}; + var custom = text( + first(cycle, [ + 'display_time', + 'displayTime', + 'time_range', + 'timeRange', + ]) + ); + if (custom) return custom; + var offset = timezoneOffsetHours(cycle); + return ( + formatDateTime(first(cycle, ['start_ms', 'startMs']), offset) + + '-' + + formatDateTime(first(cycle, ['end_ms', 'endMs']), offset) + + '(utc' + + formatOffset(offset) + + ')' + ); + } + + function historyRange(cycle) { + cycle = cycle || {}; + var offset = timezoneOffsetHours(cycle); + return ( + formatDate(first(cycle, ['start_ms', 'startMs']), offset) + + '-' + + formatDate(first(cycle, ['end_ms', 'endMs']), offset) + ); + } + + function currentCycle() { + var data = envelope(state.current); + return data.cycle || data.current_cycle || data.currentCycle || null; + } + + function errorKey(error) { + var status = number( + first(error, ['status', 'status_code', 'statusCode']) + ); + var code = text( + first(error, ['code', 'error_code', 'errorCode']) + ).toUpperCase(); + var message = text(error && error.message).toLowerCase(); + if ( + status === 401 || + code === 'UNAUTHORIZED' || + message.indexOf('unauthor') >= 0 + ) { + return 'huwaa_weekly_star.login_required'; + } + if (code === 'PROFILE_REQUIRED') { + return 'huwaa_weekly_star.profile_required'; + } + return 'huwaa_weekly_star.request_failed'; + } + + function normalizeGift(item) { + item = item || {}; + var resource = item.resource || item.gift || {}; + var metadata = parseJSON( + first(item, ['metadata_json', 'metadataJson']) || + first(resource, ['metadata_json', 'metadataJson']) + ); + return { + id: text( + first(item, [ + 'gift_id', + 'giftId', + 'resource_id', + 'resourceId', + 'id', + ]) || first(resource, ['id', 'resource_id', 'resourceId']) + ), + name: text( + first(item, ['display_name', 'displayName', 'name']) || + first(resource, ['display_name', 'displayName', 'name']) + ), + image: resolveAssetURL( + first(metadata, [ + 'preview_url', + 'previewUrl', + 'asset_url', + 'assetUrl', + ]) || + first(item, [ + 'image_url', + 'imageUrl', + 'cover_url', + 'coverUrl', + 'preview_url', + 'previewUrl', + 'icon_url', + 'iconUrl', + 'asset_url', + 'assetUrl', + ]) || + first(resource, [ + 'preview_url', + 'previewUrl', + 'cover_url', + 'coverUrl', + 'asset_url', + 'assetUrl', + ]) + ), + }; + } + + function normalizeEntries(entries) { + if (!Array.isArray(entries)) return []; + return entries.slice(0, RANK_LIMIT).map(function (entry, index) { + entry = entry || {}; + var user = entry.user || entry.profile || {}; + var rank = number( + first(entry, ['rank_no', 'rankNo', 'rank']), + index + 1 + ); + var name = text( + first(user, [ + 'username', + 'nickname', + 'display_name', + 'displayName', + 'display_user_id', + 'displayUserId', + ]) || first(entry, ['username', 'name', 'user_id', 'userId']) + ); + return { + rank: rank || index + 1, + userID: text( + first(entry, ['user_id', 'userId']) || + first(user, ['user_id', 'userId', 'id']) + ), + name: name || t('huwaa_weekly_star.unknown_user', 'User'), + score: first(entry, ['score', 'points', 'value']) || 0, + avatar: resolveAssetURL( + first(user, [ + 'avatar', + 'avatar_url', + 'avatarUrl', + 'profile_image', + 'profileImage', + ]) || first(entry, ['avatar', 'avatar_url', 'avatarUrl']) + ), + }; + }); + } + + function rewardImage(item, resource, metadata) { + return resolveAssetURL( + first(metadata, [ + 'preview_url', + 'previewUrl', + 'asset_url', + 'assetUrl', + ]) || + first(item, [ + 'image', + 'image_url', + 'imageUrl', + 'cover_url', + 'coverUrl', + 'preview_url', + 'previewUrl', + 'icon_url', + 'iconUrl', + 'asset_url', + 'assetUrl', + ]) || + first(resource, [ + 'preview_url', + 'previewUrl', + 'cover_url', + 'coverUrl', + 'asset_url', + 'assetUrl', + 'animation_url', + 'animationUrl', + ]) + ); + } + + function normalizeRewardItem(item, group) { + item = item || {}; + group = group || {}; + var resource = item.resource || {}; + var metadata = parseJSON( + first(item, ['metadata_json', 'metadataJson']) || + first(resource, ['metadata_json', 'metadataJson']) + ); + var walletType = text( + first(item, ['wallet_asset_type', 'walletAssetType']) || + first(resource, ['wallet_asset_type', 'walletAssetType']) + ).replace(/_/g, ' '); + var walletAmount = number( + first(item, [ + 'wallet_asset_amount', + 'walletAssetAmount', + 'amount', + ]) || first(resource, ['wallet_asset_amount', 'walletAssetAmount']) + ); + var quantity = number(first(item, ['quantity'])); + var durationMS = number(first(item, ['duration_ms', 'durationMs'])); + var durationDays = number( + first(item, [ + 'duration_days', + 'durationDays', + 'valid_days', + 'validDays', + ]) || first(resource, ['duration_days', 'durationDays']) + ); + if (!durationDays && durationMS > 0) { + // Gateway resource-group items expose validity as duration_ms. Keep the + // conversion aligned with the backend's whole-day reward contract. + durationDays = Math.floor(durationMS / DAY_MS); + } + var labelKey = text(first(item, ['i18n_key', 'i18nKey'])); + var label = labelKey + ? t(labelKey, '') + : text( + first(item, [ + 'display_name', + 'displayName', + 'label', + 'name', + ]) || + first(resource, [ + 'display_name', + 'displayName', + 'name', + ]) || + walletType || + first(group, ['display_name', 'displayName', 'name']) + ); + if (durationDays > 0) { + label = t('huwaa_weekly_star.reward_days', '{name} ({days} days)', { + name: label || t('huwaa_weekly_star.reward', 'Reward'), + days: durationDays, + }); + } + if (quantity > 1) { + label = + (label || t('huwaa_weekly_star.reward', 'Reward')) + + ' ×' + + formatNumber(quantity); + } else if (walletAmount > 0) { + label = + (label || + walletType || + t('huwaa_weekly_star.reward', 'Reward')) + + ' ×' + + formatNumber(walletAmount); + } + return { + label: label || t('huwaa_weekly_star.reward', 'Reward'), + image: rewardImage(item, resource, metadata), + }; + } + + function normalizeRewardGroups(cycle) { + cycle = cycle || {}; + var source = + cycle.rewards || + cycle.reward_groups || + cycle.rewardGroups || + cycle.rank_rewards || + cycle.rankRewards || + []; + if (!Array.isArray(source)) return []; + return source + .map(function (group, index) { + group = group || {}; + var nested = + group.resource_group || + group.resourceGroup || + group.reward_resource_group || + group.rewardResourceGroup || + {}; + var items = + group.items || + group.resources || + nested.items || + nested.resources || + []; + // Some endpoints flatten a single visual resource onto the rank reward. + // It is only promoted when an actual image/name exists; a bare group ID + // must remain an empty state rather than a fabricated usable reward. + if (!Array.isArray(items)) items = []; + if ( + !items.length && + (rewardImage(group, group.resource || {}, {}) || + first(group, ['display_name', 'displayName', 'name'])) + ) { + items = [group]; + } + return { + rank: number( + first(group, ['rank_no', 'rankNo', 'rank']), + index + 1 + ), + items: items.slice(0, 3).map(function (item) { + return normalizeRewardItem(item, group); + }), + }; + }) + .filter(function (group) { + return group.rank >= 1 && group.rank <= 3; + }) + .sort(function (left, right) { + return left.rank - right.rank; + }); + } + + function renderGifts(cycle) { + var source = cycle && Array.isArray(cycle.gifts) ? cycle.gifts : []; + var gifts = source.slice(0, 3).map(normalizeGift); + var lefts = [61, 411, 761]; + var root = document.getElementById('weekStarGifts'); + root.innerHTML = lefts + .map(function (left, index) { + var gift = gifts[index] || {}; + var label = + gift.name || + t('huwaa_weekly_star.gift_label', 'Activity gift {index}', { + index: index + 1, + }); + return [ + '
', + '', + '', + gift.image + ? '' + : '', + '', + '
', + ].join(''); + }) + .join(''); + bindImageFallbacks(root); + } + + function medalForRank(rank) { + if (rank === 1) return ASSET_BASE + 'rank-1.webp'; + if (rank === 2) return ASSET_BASE + 'rank-2.webp'; + if (rank === 3) return ASSET_BASE + 'rank-3.webp'; + return ''; + } + + function rankInitial(name) { + var characters = Array.from(text(name)); + return characters.length ? characters[0] : '?'; + } + + function stateHTML(top, message, retry) { + return [ + '
', + '', + escapeHTML(message), + '', + retry + ? '' + : '', + '
', + ].join(''); + } + + function isRetryable(error) { + return error === 'huwaa_weekly_star.request_failed'; + } + + function renderRankList(options) { + var root = document.getElementById(options.rootID); + var entries = normalizeEntries(options.entries); + if (!entries.length) { + var message = options.loading + ? t('huwaa_weekly_star.loading', 'Loading…') + : options.error + ? t(options.error, 'Unable to load the ranking.') + : t(options.emptyKey, options.emptyFallback); + root.innerHTML = stateHTML( + options.top, + message, + !options.loading && isRetryable(options.error) + ); + return; + } + root.innerHTML = entries + .map(function (entry, index) { + var medal = medalForRank(entry.rank); + var tag = entry.userID ? 'button' : 'article'; + var className = + index % 2 === (options.firstDark ? 0 : 1) + ? 'is-dark' + : 'is-purple'; + var aria = t( + 'huwaa_weekly_star.rank_label', + 'Rank {rank}, {name}, {score}', + { + rank: entry.rank, + name: entry.name, + score: formatScore(entry.score), + } + ); + return [ + '<', + tag, + tag === 'button' ? ' type="button"' : '', + ' class="week-star-rank-row ', + className, + '" style="top:', + options.top + index * 140, + 'px"', + entry.userID + ? ' data-user-id="' + escapeAttr(entry.userID) + '"' + : '', + ' aria-label="', + escapeAttr(aria), + '">', + medal + ? '' + : '', + '', + entry.avatar + ? '' + : '', + '', + '', + '', + escapeHTML(entry.name), + '', + '', + escapeHTML(formatScore(entry.score)), + '', + '', + ].join(''); + }) + .join(''); + bindImageFallbacks(root); + } + + function historyCycleData() { + var record = state.historyCycles[0] || {}; + return { + cycle: record.cycle || record, + entries: + record.top_entries || + record.topEntries || + record.entries || + record.leaderboard || + [], + }; + } + + function renderHistory() { + var history = historyCycleData(); + document.getElementById('weekStarHistoryDate').textContent = + history.cycle && + first(history.cycle, ['start_ms', 'startMs']) && + first(history.cycle, ['end_ms', 'endMs']) + ? historyRange(history.cycle) + : t( + 'huwaa_weekly_star.previous_top_three', + 'Previous Top 3 Records' + ); + renderRankList({ + rootID: 'weekStarHistoryList', + top: 1607, + entries: history.entries, + loading: state.loadingHistory, + error: state.historyError, + emptyKey: 'huwaa_weekly_star.no_history', + emptyFallback: 'No previous ranking records.', + firstDark: false, + }); + } + + function renderWeekly() { + renderRankList({ + rootID: 'weekStarWeeklyList', + top: 1496, + entries: state.weeklyEntries, + loading: state.loadingWeekly, + error: state.weeklyError, + emptyKey: 'huwaa_weekly_star.no_ranking', + emptyFallback: 'No ranking data yet.', + firstDark: true, + }); + } + + function renderRewardItem(item) { + return [ + '
', + '', + '', + item.image + ? '' + : '', + '', + '', + escapeHTML(item.label), + '', + '
', + ].join(''); + } + + function renderRewards(cycle) { + var root = document.getElementById('weekStarRewardGroups'); + if (state.loadingCurrent) { + root.innerHTML = stateHTML( + 1705, + t('huwaa_weekly_star.loading', 'Loading…'), + false + ); + return; + } + if (state.currentError) { + root.innerHTML = stateHTML( + 1705, + t(state.currentError, 'Unable to load rewards.'), + isRetryable(state.currentError) + ); + return; + } + var groups = normalizeRewardGroups(cycle); + if (!groups.length) { + root.innerHTML = stateHTML( + 1705, + t('huwaa_weekly_star.no_rewards', 'No reward data yet.'), + false + ); + return; + } + var positions = { + 1: { heading: 1505, items: 1705 }, + 2: { heading: 2068, items: 2203 }, + 3: { heading: 2566, items: 2701 }, + }; + root.innerHTML = groups + .map(function (group) { + var position = positions[group.rank]; + var itemsHTML = group.items.length + ? group.items.map(renderRewardItem).join('') + : '
' + + escapeHTML( + t( + 'huwaa_weekly_star.no_rank_reward', + 'No reward data.' + ) + ) + + '
'; + return [ + '
', + '

', + '', + 'TOP', + group.rank, + '', + '

', + '
', + itemsHTML, + '
', + '
', + ].join(''); + }) + .join(''); + bindImageFallbacks(root); + } + + function renderHeader() { + var cycle = currentCycle(); + var date = document.getElementById('weekStarDate'); + if (state.loadingCurrent) { + date.textContent = '—'; + } else if (state.currentError) { + date.textContent = '—'; + } else if (cycle) { + date.textContent = cycleRange(cycle); + } else { + date.textContent = t( + 'huwaa_weekly_star.no_active_cycle', + 'No active cycle' + ); + } + renderGifts(cycle || {}); + renderRewards(cycle || {}); + } + + function bindImageFallbacks(root) { + all('[data-image-fallback]', root).forEach(function (image) { + if (image.dataset.fallbackBound === 'true') return; + image.dataset.fallbackBound = 'true'; + function fallback() { + var type = image.getAttribute('data-image-fallback'); + var parent = image.parentElement; + if (type === 'avatar' && parent) { + parent.classList.add('is-fallback'); + } else if (parent) { + parent.classList.remove('has-image'); + } + image.hidden = true; + } + image.addEventListener('error', fallback, { once: true }); + if (image.complete && image.naturalWidth === 0) fallback(); + }); + } + + function renderAll() { + renderHeader(); + renderHistory(); + renderWeekly(); + setTab(state.tab, false, false); + } + + function tabFromHash() { + var hash = text((window.location.hash || '').replace(/^#/, '')); + if (!hash) return ''; + var route = hash.split('?')[0].replace(/^\/+|\/+$/g, ''); + var pieces = route.split('/'); + return pieces[pieces.length - 1]; + } + + function normalizeTab(value) { + return value === 'weekly' || value === 'rewards' || value === 'history' + ? value + : 'history'; + } + + function replaceTabHash(tab) { + if (!window.history || !window.history.replaceState) return; + var hash = window.location.hash || ''; + var hashQuery = + hash.indexOf('?') >= 0 + ? '?' + hash.split('?').slice(1).join('?') + : ''; + // Native containers sometimes inject the token after `#?...`; preserving that + // suffix is mandatory because replacing the entire hash would log the user out. + var next = + window.location.pathname + + window.location.search + + '#' + + tab + + hashQuery; + window.history.replaceState(null, '', next); + } + + function updateStageHeight() { + var viewportWidth = Math.min(window.innerWidth, 430); + var height = DESIGN_HEIGHT[state.tab] || DESIGN_HEIGHT.history; + var scaledHeight = Math.ceil((height * viewportWidth) / 1080); + document.documentElement.style.setProperty( + '--huwaa-week-star-scale', + String(viewportWidth / 1080) + ); + stage.style.height = height + 'px'; + stageWrap.style.height = scaledHeight + 'px'; + page.style.minHeight = scaledHeight + 'px'; + } + + function setTab(tab, updateURL, announce) { + state.tab = normalizeTab(tab); + page.setAttribute('data-tab', state.tab); + stage.setAttribute('data-node-id', DESIGN_FRAME[state.tab]); + tabs.forEach(function (button) { + var active = button.getAttribute('data-tab-target') === state.tab; + button.classList.toggle('is-active', active); + button.setAttribute('aria-selected', active ? 'true' : 'false'); + button.tabIndex = active ? 0 : -1; + }); + panels.forEach(function (panel) { + panel.hidden = panel.getAttribute('data-panel') !== state.tab; + }); + updateStageHeight(); + if (updateURL) replaceTabHash(state.tab); + if (announce) { + var selected = tabs.find(function (button) { + return button.getAttribute('data-tab-target') === state.tab; + }); + document.getElementById('weekStarAnnouncer').textContent = selected + ? selected.textContent.trim() + : ''; + } + } + + function mockState() { + var sample = ASSET_BASE + 'sample-gift.webp'; + var mockEntries = [1, 2, 3, 4, 5].map(function (rank) { + return { + rank_no: rank, + score: 337700, + user: { + id: 'mock-user-' + rank, + username: rank === 1 ? 'hanneem' : 'star' + rank, + avatar: '', + }, + }; + }); + var rewardGroups = [1, 2, 3].map(function (rank) { + return { + rank_no: rank, + items: [1, 2, 3].map(function () { + return { + i18n_key: 'huwaa_weekly_star.frame', + duration_days: 7, + image_url: sample, + }; + }), + }; + }); + return { + current: { + cycle: { + cycle_id: 'figma-mock', + start_ms: Date.UTC(2025, 11, 27, 21, 0, 0), + end_ms: Date.UTC(2026, 0, 3, 20, 59, 0), + utc_offset_hours: 3, + gifts: [ + { image_url: sample }, + { image_url: sample }, + { image_url: sample }, + ], + rewards: rewardGroups, + }, + }, + entries: mockEntries, + history: [ + { + cycle: { + start_ms: Date.UTC(2025, 11, 6, 21, 0, 0), + end_ms: Date.UTC(2025, 11, 14, 20, 59, 0), + utc_offset_hours: 3, + }, + top_entries: mockEntries, + }, + ], + }; + } + + function loadMock() { + var mock = mockState(); + state.current = mock.current; + state.weeklyEntries = mock.entries; + state.historyCycles = mock.history; + state.loadingCurrent = false; + state.loadingWeekly = false; + state.loadingHistory = false; + state.currentError = ''; + state.weeklyError = ''; + state.historyError = ''; + renderAll(); + bridgeReady(); + } + + function tokenAvailable() { + return !!( + window.HyAppAPI && + window.HyAppAPI.getAccessToken && + window.HyAppAPI.getAccessToken() + ); + } + + function loadReal() { + var serial = ++state.requestSerial; + state.loadingCurrent = true; + state.loadingWeekly = true; + state.loadingHistory = true; + state.currentError = ''; + state.weeklyError = ''; + state.historyError = ''; + renderAll(); + + if (!window.HyAppAPI || !window.HyAppAPI.weeklyStar) { + state.loadingCurrent = false; + state.loadingWeekly = false; + state.loadingHistory = false; + state.currentError = 'huwaa_weekly_star.request_failed'; + state.weeklyError = state.currentError; + state.historyError = state.currentError; + renderAll(); + bridgeReady(); + return; + } + + if (!tokenAvailable()) { + // Production weekly-star endpoints require the authenticated user context. + // Do not send three doomed requests or silently replace the response with mock data. + state.loadingCurrent = false; + state.loadingWeekly = false; + state.loadingHistory = false; + state.currentError = 'huwaa_weekly_star.login_required'; + state.weeklyError = state.currentError; + state.historyError = state.currentError; + renderAll(); + bridgeReady(); + return; + } + + // Current/leaderboard and history are intentionally isolated. A history outage + // must not erase a successfully loaded current leaderboard, and vice versa. + var currentFlow = window.HyAppAPI.weeklyStar + .current() + .then(function (payload) { + if (serial !== state.requestSerial) return; + var data = envelope(payload); + state.current = data; + state.weeklyEntries = data.top_entries || data.topEntries || []; + state.loadingCurrent = false; + state.loadingWeekly = false; + state.currentError = ''; + state.weeklyError = ''; + renderHeader(); + renderWeekly(); + }) + .catch(function (error) { + if (serial !== state.requestSerial) return; + state.current = null; + state.weeklyEntries = []; + state.loadingCurrent = false; + state.loadingWeekly = false; + state.currentError = errorKey(error); + state.weeklyError = state.currentError; + renderHeader(); + renderWeekly(); + }); + + var historyFlow = window.HyAppAPI.weeklyStar + .history(1) + .then(function (payload) { + if (serial !== state.requestSerial) return; + var data = envelope(payload); + state.historyCycles = Array.isArray(data) + ? data + : data.cycles || data.items || data.list || []; + state.historyError = ''; + }) + .catch(function (error) { + if (serial !== state.requestSerial) return; + state.historyCycles = []; + state.historyError = errorKey(error); + }) + .then(function () { + if (serial !== state.requestSerial) return; + state.loadingHistory = false; + renderHistory(); + }); + + Promise.all([currentFlow, historyFlow]).then(function () { + if (serial === state.requestSerial) bridgeReady(); + }); + } + + function bridgeReady() { + if (!window.HyAppBridge || !window.HyAppBridge.ready) return; + window.HyAppBridge.ready({ + page: 'huwaa_weekly_star', + app_code: 'huwaa', + mock: useMock, + }); + } + + function activateRelativeTab(currentIndex, direction) { + var nextIndex = (currentIndex + direction + tabs.length) % tabs.length; + var next = tabs[nextIndex]; + setTab(next.getAttribute('data-tab-target'), true, true); + next.focus(); + } + + function bindEvents() { + tabs.forEach(function (button, index) { + button.addEventListener('click', function () { + setTab(button.getAttribute('data-tab-target'), true, true); + }); + button.addEventListener('keydown', function (event) { + if (event.key === 'ArrowRight' || event.key === 'ArrowDown') { + event.preventDefault(); + activateRelativeTab(index, 1); + } else if ( + event.key === 'ArrowLeft' || + event.key === 'ArrowUp' + ) { + event.preventDefault(); + activateRelativeTab(index, -1); + } else if (event.key === 'Home') { + event.preventDefault(); + setTab('history', true, true); + tabs[0].focus(); + } else if (event.key === 'End') { + event.preventDefault(); + setTab('rewards', true, true); + tabs[tabs.length - 1].focus(); + } + }); + }); + + document.addEventListener('click', function (event) { + var retry = event.target.closest('[data-retry]'); + if (retry) { + loadReal(); + return; + } + var row = event.target.closest('[data-user-id]'); + if (row && window.HyAppBridge && window.HyAppBridge.openUser) { + window.HyAppBridge.openUser(row.getAttribute('data-user-id')); + } + }); + + window.addEventListener('resize', updateStageHeight); + window.addEventListener('hashchange', function () { + setTab(normalizeTab(tabFromHash()), false, false); + }); + window.addEventListener('hyapp:i18n-ready', function () { + // Dynamic API rows are not covered by common/i18n.js's static DOM pass. + // Re-rendering keeps empty/error/reward labels synchronized after locale load. + renderAll(); + }); + } + + function init() { + bindEvents(); + setTab(state.tab, false, false); + if (useMock) loadMock(); + else loadReal(); + } + + init(); +})(); diff --git a/coin-seller/assets/fami/layers/contact-button-skin.webp b/coin-seller/assets/fami/layers/contact-button-skin.webp new file mode 100644 index 0000000..57138eb Binary files /dev/null and b/coin-seller/assets/fami/layers/contact-button-skin.webp differ diff --git a/coin-seller/assets/fami/layers/flag-ae.webp b/coin-seller/assets/fami/layers/flag-ae.webp new file mode 100644 index 0000000..39f32cf Binary files /dev/null and b/coin-seller/assets/fami/layers/flag-ae.webp differ diff --git a/coin-seller/assets/fami/layers/hero.webp b/coin-seller/assets/fami/layers/hero.webp new file mode 100644 index 0000000..15593d9 Binary files /dev/null and b/coin-seller/assets/fami/layers/hero.webp differ diff --git a/coin-seller/assets/fami/layers/manifest.json b/coin-seller/assets/fami/layers/manifest.json new file mode 100644 index 0000000..ea68b58 --- /dev/null +++ b/coin-seller/assets/fami/layers/manifest.json @@ -0,0 +1,112 @@ +{ + "schemaVersion": 1, + "figma": { + "fileKey": "OTOg5cwbhVNWxkImGmzTJl", + "rootFrame": { + "id": "827:3011", + "name": "代理充值" + }, + "designSize": { + "width": 1080, + "height": 2626, + "cssWidth": 360, + "cssHeight": 875.3333333333 + }, + "forbiddenAggregateNodes": [ + "827:3011", + "827:3012", + "827:3027", + "827:3042", + "827:3057", + "827:3072", + "827:3087", + "827:3014", + "827:3022" + ] + }, + "runtimeRules": { + "referenceScreenshot": "coin-seller/.audit/figma-827-3011.png", + "referenceScreenshotRuntimeUse": false, + "heroMask": { + "sourceNode": "827:3089", + "sourceStops": [65, 100], + "renderedStops": [83.8, 94.7], + "purpose": "Reconstruct the transformed Figma mask as CSS; the root-frame screenshot remains QA-only" + }, + "dynamicFields": [ + "username", + "display_user_id", + "avatar", + "country_code", + "country_display_name", + "whatsapp_url" + ] + }, + "assets": [ + { + "file": "hero.webp", + "sourceNode": "827:3090", + "sourceName": "image 5017", + "sourceType": "RECTANGLE_IMAGE_FILL", + "purpose": "Single Figma hero illustration layer; its bottom fade is reconstructed with CSS from node 827:3089", + "bounds": { "width": 1080, "height": 1541 }, + "original": { "width": 1080, "height": 1541 }, + "exportFormat": "webp-quality-94", + "bytes": 479656, + "sha256": "b191502302320036ad220a5e3498fe3931079fcd4da71417193f7598a50d4016", + "containsDynamicText": false, + "containsDecorativeWordmark": true, + "containsControls": false, + "containsBusinessMedia": false + }, + { + "file": "seller-card-skin.webp", + "sourceNode": "827:3013", + "sourceComponent": "368:1935", + "sourceName": "Group 45", + "sourceType": "INSTANCE", + "purpose": "Decorative gold seller-card border only; all seller content remains DOM/API driven", + "bounds": { "width": 1008, "height": 183 }, + "original": { "width": 1008, "height": 183 }, + "exportFormat": "webp-lossless", + "bytes": 38382, + "sha256": "5a442b3d72dc63e03d76941349360395150c87054daa6de3cbeb3ab0bdb48e86", + "containsDynamicText": false, + "containsDecorativeWordmark": false, + "containsControls": false, + "containsBusinessMedia": false + }, + { + "file": "contact-button-skin.webp", + "sourceNode": "827:3023", + "sourceName": "按钮", + "sourceType": "GROUP", + "purpose": "Contact button visual skin without the DOM label or interaction target", + "bounds": { "width": 212, "height": 58 }, + "original": { "width": 212, "height": 58 }, + "exportFormat": "webp-lossless", + "bytes": 8298, + "sha256": "7871d8bf5072f9ae99b0c62570261e32fbd5b08005228cd707c75751d30c6c6e", + "containsDynamicText": false, + "containsDecorativeWordmark": false, + "containsControls": false, + "containsBusinessMedia": false + }, + { + "file": "flag-ae.webp", + "sourceNode": "827:3019", + "sourceName": "139b9f108330acd1d276942106a294740330946d1121-pVYoL2 1", + "sourceType": "RECTANGLE_IMAGE_FILL", + "purpose": "UAE flag fallback selected only when the API country_code is AE", + "bounds": { "width": 49, "height": 33 }, + "original": { "width": 1060, "height": 640 }, + "exportFormat": "webp-lossless", + "bytes": 220, + "sha256": "e94cadaeef3baab8bfcc8baa520d5d45d42fd11cf8cac52173a4f4552f8db518", + "containsDynamicText": false, + "containsDecorativeWordmark": false, + "containsControls": false, + "containsBusinessMedia": false + } + ] +} diff --git a/coin-seller/assets/fami/layers/seller-card-skin.webp b/coin-seller/assets/fami/layers/seller-card-skin.webp new file mode 100644 index 0000000..81b244d Binary files /dev/null and b/coin-seller/assets/fami/layers/seller-card-skin.webp differ diff --git a/coin-seller/fami.css b/coin-seller/fami.css new file mode 100644 index 0000000..d15f7c4 --- /dev/null +++ b/coin-seller/fami.css @@ -0,0 +1,457 @@ +:root { + color-scheme: dark; + --coin-seller-bg: #06221b; + --coin-seller-text: #fff; + --coin-seller-muted: rgba(255, 255, 255, 0.7); + --coin-seller-gold: #ffebbb; + --coin-seller-button-text: #773924; +} + +* { + box-sizing: border-box; +} + +html, +body { + width: 100%; + min-height: 100%; + margin: 0; +} + +body { + min-height: 100svh; + overflow-x: hidden; + background: #020b09; + color: var(--coin-seller-text); + font-family: + 'Source Han Sans SC', + 'Noto Sans SC', + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Arial, + sans-serif; + -webkit-font-smoothing: antialiased; + text-rendering: geometricPrecision; +} + +button, +a { + font: inherit; + -webkit-tap-highlight-color: transparent; +} + +[hidden] { + display: none !important; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.coin-seller-page { + width: min(100%, 480px); + min-height: 100svh; + margin: 0 auto; + overflow: clip; + background: var(--coin-seller-bg); + container-type: inline-size; +} + +.coin-seller-stage { + position: relative; + min-height: max(100svh, 243.1481cqw); + isolation: isolate; +} + +.coin-seller-hero { + position: relative; + z-index: 0; + width: 100%; + height: 142.5cqw; + overflow: hidden; + pointer-events: none; + -webkit-mask-image: linear-gradient( + to bottom, + #000 0%, + #000 83.8%, + transparent 94.7%, + transparent 100% + ); + mask-image: linear-gradient( + to bottom, + #000 0%, + #000 83.8%, + transparent 94.7%, + transparent 100% + ); +} + +.coin-seller-hero img { + position: absolute; + inset: 0 auto auto 0; + display: block; + width: 100%; + height: auto; + user-select: none; +} + +.coin-seller-region { + position: relative; + z-index: 1; + width: 100%; + margin-top: -7.5cqw; + padding: 0 3.3333cqw 11.5741cqw; +} + +.seller-list { + display: flex; + flex-direction: column; + gap: 2.963cqw; + width: 100%; + padding: 0; + margin: 0; + list-style: none; +} + +.seller-card, +.seller-skeleton-card { + position: relative; + width: 100%; + aspect-ratio: 1008 / 183; + overflow: visible; +} + +.seller-card-skin, +.seller-skeleton-skin { + position: absolute; + z-index: 0; + inset: 0; + display: block; + width: 100%; + height: 100%; + pointer-events: none; + user-select: none; +} + +.seller-avatar-shell { + position: absolute; + z-index: 1; + left: 3.6706%; + top: 17.4863%; + width: 11.8056%; + aspect-ratio: 1; + overflow: hidden; + border: 0.2778cqw solid var(--coin-seller-gold); + border-radius: 50%; + background: #0e4136; +} + +.seller-avatar, +.seller-avatar-fallback { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.seller-avatar { + z-index: 1; + inset: auto; + left: -17.83%; + top: -8.53%; + display: block; + width: 123.26%; + height: 164.36%; + object-fit: cover; + opacity: 0; + transition: opacity 120ms ease; +} + +.seller-avatar.is-loaded { + opacity: 1; +} + +.seller-avatar-fallback { + display: grid; + place-items: center; + color: rgba(255, 235, 187, 0.92); + background: #0f5548; + font-size: 4.1667cqw; + font-weight: 700; + line-height: 1; + text-transform: uppercase; +} + +.seller-country { + position: absolute; + z-index: 1; + left: 17.8571%; + top: 25.6831%; + width: 4.8611%; + height: 18.0328%; + overflow: hidden; + border-radius: 0.2778cqw; +} + +.seller-flag, +.seller-flag-code { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.seller-flag { + z-index: 1; + display: block; + object-fit: cover; + opacity: 0; +} + +.seller-flag.is-loaded { + opacity: 1; +} + +.seller-flag-code { + display: grid; + place-items: center; + overflow: hidden; + color: #fff; + background: #17483d; + font-size: 1.3889cqw; + font-weight: 800; + line-height: 1; +} + +.seller-name, +.seller-display-id { + position: absolute; + z-index: 1; + display: block; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + direction: auto; +} + +.seller-name { + left: 24.3056%; + top: 24.0437%; + right: 27.4802%; + color: var(--coin-seller-text); + font-size: 3.7037cqw; + font-weight: 500; + line-height: 3.3333cqw; +} + +.seller-display-id { + left: 17.8571%; + top: 56.2842%; + right: 34%; + color: var(--coin-seller-muted); + font-size: 2.7778cqw; + font-weight: 400; + line-height: 3.3333cqw; +} + +.seller-contact, +.seller-retry { + position: absolute; + z-index: 2; + display: grid; + place-items: center; + overflow: hidden; + padding: 0; + border: 0; + color: var(--coin-seller-button-text); + background: transparent; + text-align: center; + text-decoration: none; + cursor: pointer; +} + +.seller-contact { + left: 74.0079%; + top: 33.8798%; + width: 21.0317%; + height: 31.694%; + border-radius: 999px; + font-size: 2.7778cqw; + font-weight: 700; + line-height: 3.3333cqw; +} + +.seller-contact img, +.seller-retry img { + position: absolute; + z-index: -1; + inset: 0; + display: block; + width: 100%; + height: 100%; + pointer-events: none; + user-select: none; +} + +.seller-contact span, +.seller-retry span { + position: relative; + z-index: 1; + max-width: 90%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.seller-contact[aria-disabled='true'] { + cursor: default; + filter: saturate(0.42); + opacity: 0.58; +} + +.seller-contact:focus-visible, +.seller-retry:focus-visible { + outline: 0.5556cqw solid #fff; + outline-offset: 0.5556cqw; +} + +.seller-message { + display: grid; + min-height: 33.8889cqw; + place-items: center; + padding: 6.6667cqw; + border: 0.2778cqw solid rgba(255, 235, 187, 0.78); + background: rgba(3, 38, 30, 0.94); + box-shadow: inset 0 0 0 0.8333cqw rgba(141, 64, 34, 0.44); + text-align: center; +} + +.seller-message p { + max-width: 78%; + margin: 0; + color: rgba(255, 255, 255, 0.88); + font-size: 3.3333cqw; + font-weight: 500; + line-height: 1.45; +} + +.seller-error { + padding-bottom: 14.4444cqw; +} + +.seller-retry { + left: 50%; + bottom: 5.5556cqw; + width: 21.0317cqw; + height: 5.3704cqw; + transform: translateX(-50%); + border-radius: 999px; + font-size: 2.7778cqw; + font-weight: 700; + line-height: 3.3333cqw; +} + +.seller-skeleton-card { + overflow: hidden; +} + +.seller-skeleton-avatar, +.seller-skeleton-line, +.seller-skeleton-button { + position: absolute; + z-index: 1; + overflow: hidden; + background: rgba(255, 255, 255, 0.12); +} + +.seller-skeleton-avatar::after, +.seller-skeleton-line::after, +.seller-skeleton-button::after { + position: absolute; + inset: 0; + content: ''; + background: linear-gradient( + 100deg, + transparent 15%, + rgba(255, 235, 187, 0.32) 50%, + transparent 85% + ); + transform: translateX(-110%); + animation: seller-shimmer 1.4s ease-in-out infinite; +} + +.seller-skeleton-avatar { + left: 3.6706%; + top: 17.4863%; + width: 11.8056%; + aspect-ratio: 1; + border: 0.2778cqw solid rgba(255, 235, 187, 0.62); + border-radius: 50%; +} + +.seller-skeleton-line { + left: 17.8571%; + height: 12%; + border-radius: 999px; +} + +.seller-skeleton-name { + top: 27%; + width: 43%; +} + +.seller-skeleton-id { + top: 58%; + width: 26%; +} + +.seller-skeleton-button { + left: 74.0079%; + top: 33.8798%; + width: 21.0317%; + height: 31.694%; + border-radius: 999px; +} + +@keyframes seller-shimmer { + to { + transform: translateX(110%); + } +} + +@media (prefers-reduced-motion: reduce) { + .seller-avatar { + transition: none; + } + + .seller-skeleton-avatar::after, + .seller-skeleton-line::after, + .seller-skeleton-button::after { + animation: none; + } +} + +@supports not (container-type: inline-size) { + .coin-seller-stage { + min-height: max(100svh, 243.1481vw); + } + + .coin-seller-hero { + height: 142.5vw; + } + + .coin-seller-region { + margin-top: -7.5vw; + padding-inline: 3.3333vw; + padding-bottom: 11.5741vw; + } +} diff --git a/coin-seller/fami.html b/coin-seller/fami.html new file mode 100644 index 0000000..dec5260 --- /dev/null +++ b/coin-seller/fami.html @@ -0,0 +1,151 @@ + + + + + + Coins Seller + + + + + +
+
+ + +
+
+ Loading coin sellers + +
+ + + + + + +
+
+
+ + + + + + + + + + + diff --git a/coin-seller/fami.js b/coin-seller/fami.js new file mode 100644 index 0000000..38fa782 --- /dev/null +++ b/coin-seller/fami.js @@ -0,0 +1,368 @@ +(function () { + 'use strict'; + + var page = document.getElementById('coinSellerPage'); + var region = document.getElementById('coinSellerRegion'); + var loadingView = document.getElementById('sellerLoading'); + var emptyView = document.getElementById('sellerEmpty'); + var errorView = document.getElementById('sellerError'); + var retryButton = document.getElementById('sellerRetry'); + var skeletonList = document.getElementById('sellerSkeletonList'); + var sellerList = document.getElementById('sellerList'); + var sellerTemplate = document.getElementById('sellerCardTemplate'); + var params = + (window.HyAppParams && + window.HyAppParams.current && + window.HyAppParams.current.raw) || + new URLSearchParams(window.location.search); + var requestSequence = 0; + var currentItems = []; + var currentView = 'loading'; + + if (!page || !region || !sellerList || !sellerTemplate) return; + + function t(key, fallback) { + return window.HyAppI18n && window.HyAppI18n.t + ? window.HyAppI18n.t(key, fallback) + : fallback; + } + + function format(message, values) { + return String(message || '').replace( + /\{([a-zA-Z0-9_]+)\}/g, + function (_, key) { + return Object.prototype.hasOwnProperty.call(values || {}, key) + ? String(values[key]) + : ''; + } + ); + } + + function stringValue(value) { + return value === undefined || value === null + ? '' + : String(value).trim(); + } + + function safeMediaURL(value) { + var raw = stringValue(value); + if (!raw) return ''; + try { + var parsed = new URL(raw, window.location.href); + return parsed.protocol === 'https:' || parsed.protocol === 'http:' + ? parsed.href + : ''; + } catch (_) { + return ''; + } + } + + function safeWhatsAppURL(value) { + var raw = stringValue(value); + if (!raw) return ''; + try { + var parsed = new URL(raw, window.location.href); + if (parsed.protocol === 'whatsapp:') return parsed.href; + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + return ''; + } + var host = parsed.hostname.toLowerCase(); + return host === 'wa.me' || host === 'api.whatsapp.com' + ? parsed.href + : ''; + } catch (_) { + return ''; + } + } + + function isVisualFixture() { + return params.get('mock') === '1'; + } + + function visualFixturePayload() { + var requestedCount = Number(params.get('mock_count') || 5); + var count = Number.isFinite(requestedCount) + ? Math.max(0, Math.min(20, Math.floor(requestedCount))) + : 5; + var avatar = safeMediaURL(params.get('mock_avatar')); + var contactEnabled = params.get('mock_contact') !== '0'; + var items = []; + + // 该分支只服务显式 mock=1 的设计验收。生产请求失败时绝不进入这里, + // 否则示例币商会掩盖鉴权、区域或上游故障并被用户误认为真实联系人。 + for (var index = 0; index < count; index += 1) { + items.push({ + user_id: String(8800000 + index), + display_user_id: String(123456789 + index), + username: params.get('mock_name') || 'Namenamenamenamename', + avatar: avatar, + country_code: 'AE', + country_name: 'United Arab Emirates', + country_display_name: 'United Arab Emirates', + whatsapp_phone: contactEnabled ? '+971500000000' : '', + whatsapp_url: contactEnabled + ? 'https://wa.me/971500000000' + : '', + }); + } + return { items: items, total: items.length }; + } + + function normalizePayload(payload) { + if (!payload || !Array.isArray(payload.items)) { + throw new Error('coin_seller_response_invalid'); + } + return payload.items.map(function (item) { + var source = item && typeof item === 'object' ? item : {}; + return { + userId: stringValue(source.user_id), + displayUserId: stringValue(source.display_user_id), + username: stringValue(source.username), + avatar: stringValue(source.avatar), + countryCode: stringValue(source.country_code).toUpperCase(), + countryName: stringValue( + source.country_display_name || source.country_name + ), + whatsappPhone: stringValue(source.whatsapp_phone), + whatsappURL: stringValue(source.whatsapp_url), + }; + }); + } + + function sellerName(item) { + return ( + item.username || + item.displayUserId || + item.userId || + t('coin_seller_fami.unknown_seller', 'Seller') + ); + } + + function firstVisibleCharacter(value) { + var text = stringValue(value); + if (!text) return ''; + if (window.Intl && window.Intl.Segmenter) { + var segments = new window.Intl.Segmenter(undefined, { + granularity: 'grapheme', + }).segment(text); + var first = segments[Symbol.iterator]().next(); + return first.done ? '' : first.value.segment; + } + return Array.from(text)[0] || ''; + } + + function flagURL(countryCode) { + var code = stringValue(countryCode).toUpperCase(); + if (!/^[A-Z]{2}$/.test(code)) return ''; + if (code === 'AE') return './assets/fami/layers/flag-ae.webp'; + // Figma 只提供 AE 叶子素材;其他国家必须按接口 country_code 请求真实国旗, + // 不能错误复用 AE 图。失败时保留 ISO code 作为有意义的单卡降级。 + return 'https://flagcdn.com/w80/' + code.toLowerCase() + '.png'; + } + + function bindAvatar(card, item, name) { + var image = card.querySelector('.seller-avatar'); + var fallback = card.querySelector('.seller-avatar-fallback'); + var source = safeMediaURL(item.avatar); + fallback.textContent = firstVisibleCharacter(name); + image.alt = format( + t('coin_seller_fami.avatar_label', '{name} avatar'), + { name: name } + ); + image.addEventListener( + 'load', + function () { + image.classList.add('is-loaded'); + }, + { once: true } + ); + image.addEventListener( + 'error', + function () { + image.classList.remove('is-loaded'); + image.removeAttribute('src'); + }, + { once: true } + ); + if (source) image.src = source; + } + + function bindFlag(card, item) { + var image = card.querySelector('.seller-flag'); + var fallback = card.querySelector('.seller-flag-code'); + var code = item.countryCode; + var source = flagURL(code); + var country = item.countryName || code; + fallback.textContent = code; + image.alt = country + ? format(t('coin_seller_fami.country_flag', '{country} flag'), { + country: country, + }) + : ''; + image.addEventListener( + 'load', + function () { + image.classList.add('is-loaded'); + }, + { once: true } + ); + image.addEventListener( + 'error', + function () { + image.classList.remove('is-loaded'); + image.removeAttribute('src'); + }, + { once: true } + ); + if (source) image.src = source; + } + + function bindContact(card, item, name) { + var contact = card.querySelector('.seller-contact'); + var label = contact.querySelector('span'); + var target = safeWhatsAppURL(item.whatsappURL); + var contactText = t('coin_seller_fami.contact', 'Contact'); + label.textContent = contactText; + + if (target) { + contact.href = target; + contact.target = '_blank'; + contact.removeAttribute('aria-disabled'); + contact.removeAttribute('tabindex'); + contact.setAttribute( + 'aria-label', + format(t('coin_seller_fami.contact_seller', 'Contact {name}'), { + name: name, + }) + ); + return; + } + + // 空联系方式来自服务端的稳定 schema。控件必须真实禁用,而不是仍然 + // 导航到空页或由客户端根据电话号码猜测一个 WhatsApp 地址。 + contact.removeAttribute('href'); + contact.removeAttribute('target'); + contact.setAttribute('aria-disabled', 'true'); + contact.setAttribute('tabindex', '-1'); + contact.setAttribute( + 'aria-label', + t('coin_seller_fami.contact_unavailable', 'Contact unavailable') + ); + contact.addEventListener('click', function (event) { + event.preventDefault(); + }); + } + + function createSellerCard(item, index) { + var fragment = sellerTemplate.content.cloneNode(true); + var card = fragment.querySelector('.seller-card'); + var name = sellerName(item); + var visibleID = item.displayUserId || item.userId; + + card.dataset.userId = item.userId; + card.dataset.index = String(index); + card.querySelector('.seller-name').textContent = name; + card.querySelector('.seller-display-id').textContent = format( + t('coin_seller_fami.seller_id', 'ID: {id}'), + { id: visibleID } + ); + + bindAvatar(card, item, name); + bindFlag(card, item); + bindContact(card, item, name); + return fragment; + } + + function renderSellers(items) { + var fragment = document.createDocumentFragment(); + sellerList.replaceChildren(); + items.forEach(function (item, index) { + fragment.appendChild(createSellerCard(item, index)); + }); + sellerList.appendChild(fragment); + } + + function createSkeletonCard() { + var item = document.createElement('li'); + item.className = 'seller-skeleton-card'; + item.innerHTML = + '' + + '' + + '' + + '' + + ''; + return item; + } + + function renderSkeletons() { + var fragment = document.createDocumentFragment(); + for (var index = 0; index < 5; index += 1) { + fragment.appendChild(createSkeletonCard()); + } + skeletonList.replaceChildren(fragment); + } + + function setView(view) { + currentView = view; + loadingView.hidden = view !== 'loading'; + emptyView.hidden = view !== 'empty'; + errorView.hidden = view !== 'error'; + sellerList.hidden = view !== 'list'; + region.setAttribute('aria-busy', String(view === 'loading')); + } + + function requestCoinSellers() { + if (isVisualFixture()) { + return Promise.resolve(visualFixturePayload()); + } + if ( + !window.HyAppAPI || + !window.HyAppAPI.wallet || + !window.HyAppAPI.wallet.listCoinSellers + ) { + return Promise.reject(new Error('coin_seller_api_unavailable')); + } + return window.HyAppAPI.wallet.listCoinSellers(); + } + + function loadCoinSellers() { + var sequence = ++requestSequence; + currentItems = []; + setView('loading'); + + requestCoinSellers() + .then(function (payload) { + // Retry 或后续刷新可能先于旧请求完成;只允许最新序列提交 UI, + // 防止慢失败覆盖已经成功的新列表。 + if (sequence !== requestSequence) return; + currentItems = normalizePayload(payload); + if (!currentItems.length) { + setView('empty'); + return; + } + renderSellers(currentItems); + setView('list'); + }) + .catch(function (error) { + if (sequence !== requestSequence) return; + currentItems = []; + setView('error'); + if (window.console && window.console.warn) { + window.console.warn('[coin-seller:fami]', error); + } + }); + } + + retryButton.addEventListener('click', loadCoinSellers); + + window.addEventListener('hyapp:i18n-ready', function () { + // DOM 模板里的动态 ID 与可访问标签不由 data-i18n 自动插值;语言切换后 + // 重新渲染当前成功列表即可保持视觉与读屏文案一致,不重新发业务请求。 + if (currentView === 'list' && currentItems.length) { + renderSellers(currentItems); + } + }); + + renderSkeletons(); + loadCoinSellers(); +})(); diff --git a/common/api.js b/common/api.js index 7e92451..f9314f9 100644 --- a/common/api.js +++ b/common/api.js @@ -518,6 +518,17 @@ var default_api = 'https://api.global-interaction.com/'; body: payload || {}, }); }, + settings: function () { + return request('/api/v1/vip/settings', { method: 'GET' }); + }, + updateSettings: function (payload) { + // PATCH 只发送用户实际修改的开关;省略字段必须由 wallet-service 保留原值, + // 不能在 H5 端用默认值覆盖另一个端刚写入的设置。 + return request('/api/v1/vip/settings', { + method: 'PATCH', + body: payload || {}, + }); + }, }; var userAPI = { @@ -617,6 +628,13 @@ var default_api = 'https://api.global-interaction.com/'; query: params || {}, }); }, + listCoinSellers: function () { + // 区域边界只能由 gateway 根据 JWT 当前用户解析;H5 不传 region_id, + // 避免页面参数被篡改后读取到其他区域的币商联系方式。 + return request('/api/v1/wallet/coin-sellers', { + method: 'GET', + }); + }, transferCoinFromSeller: function (payload) { return request('/api/v1/wallet/coin-seller/transfer', { method: 'POST', diff --git a/common/locales/ar.json b/common/locales/ar.json index db08d05..879962f 100644 --- a/common/locales/ar.json +++ b/common/locales/ar.json @@ -55,6 +55,9 @@ "roomReward.idPrefix": "المعرّف:", "roomReward.roomOwner": "مالك الغرفة", "roomReward.changeLanguage": "تغيير اللغة", + "roomReward.loading": "جارٍ التحميل...", + "roomReward.empty": "لا توجد مستويات مكافآت بعد", + "roomReward.loadFailed": "فشل التحميل", "rechargeReward.pageTitle": "مكافأة الشحن التراكمي", "rechargeReward.pageLabel": "فعالية مكافأة الشحن التراكمي", "rechargeReward.changeLanguage": "تغيير اللغة", @@ -66,6 +69,7 @@ "rechargeReward.accumulatedRecharge": "إجمالي الشحن:", "rechargeReward.rechargeTitle": "اشحن {amount}", "rechargeReward.claim": "استلام", + "rechargeReward.receive": "استلام", "rechargeReward.claiming": "جار الاستلام...", "rechargeReward.claimed": "تم الاستلام", "rechargeReward.claimSuccess": "تم الاستلام", @@ -534,6 +538,17 @@ "cp.rewardCoins10000": "عملات*10000", "cp.rewardCount7": "عدّاد*7 أيام", "cp.copyright": "حقوق هذا النشاط تعود إلى Shila", + "cp.countdownLabel": "العد التنازلي للنشاط", + "cp.huwaaPlazaGiftTitle": "ستظهر هنا الهدايا المتبادلة بين ثنائيات CP بقيمة 5000 ذهب أو أكثر", + "cp.previousNo1PositionTitle": "سجلات المركز الأول السابقة", + "cp.previousNo1Subtitle": "(عرض آخر 10 سجلات مرتبة تصاعديًا.)", + "cp.rankPodiumLabel": "ترتيب أفضل ثنائيات CP", + "cp.loading": "جارٍ التحميل...", + "cp.empty": "لا توجد بيانات بعد.", + "cp.error": "تعذر تحميل بيانات النشاط.", + "cp.retry": "إعادة المحاولة", + "cp.reward": "مكافأة", + "cp.daysLong": "أيام", "recharge.pageTitle": "Recharge Center", "recharge.pageSubtitle": "اختر باقة عملات وأكمل الدفع.", "recharge.changeLanguage": "تغيير اللغة", @@ -800,5 +815,155 @@ "fami_rank.network_error": "تعذر تحميل التصنيف. حاول مرة أخرى.", "fami_rank.unknown": "غير معروف", "huwaa_rank.page_title": "تصنيف هوا", - "huwaa_rank.game": "اللعبة" + "huwaa_rank.game": "اللعبة", + "fami_vip.back": "رجوع", + "fami_vip.settings": "إعدادات VIP", + "fami_vip.help": "مساعدة VIP", + "fami_vip.levels": "مستويات VIP", + "fami_vip.exclusive_outfit": "المظهر الحصري", + "fami_vip.vip_privileges": "امتيازات VIP", + "fami_vip.no_outfits": "لا توجد مظاهر حصرية لهذا المستوى.", + "fami_vip.no_privileges": "لا توجد امتيازات لهذا المستوى.", + "fami_vip.outfit": "مظهر VIP", + "fami_vip.loading": "جارٍ التحميل...", + "fami_vip.retry": "إعادة المحاولة", + "fami_vip.purchase_region": "شراء VIP", + "fami_vip.close": "إغلاق", + "fami_vip.help_title": "مساعدة VIP", + "fami_vip.help_body": "يتم توفير الامتيازات وإمكانية الشراء للمستوى المحدد من خدمة VIP.", + "fami_vip.settings_title": "إعدادات VIP", + "fami_vip.room_entry_notice": "إشعار دخول الغرفة", + "fami_vip.online_notice": "إشعار الظهور العام", + "fami_vip.unlocked": "مفتوح", + "fami_vip.locked": "مقفل", + "fami_vip.days": "{count} يومًا", + "fami_vip.no_expiry": "بلا انتهاء", + "fami_vip.trial_level": "المستوى التجريبي: {level}", + "fami_vip.unlocked_level": "المستوى المفتوح: {level}", + "fami_vip.valid_until": "صالح حتى: {date}", + "fami_vip.renew_vip": "تجديد VIP", + "fami_vip.upgrade_vip": "ترقية VIP", + "fami_vip.activate_vip": "تفعيل VIP", + "fami_vip.unavailable": "غير متاح", + "fami_vip.current_vip_higher": "لا يمكن شراء مستوى VIP أقل أثناء تفعيل مستوى أعلى.", + "fami_vip.processing": "جارٍ التنفيذ...", + "fami_vip.purchase_success": "تم الشراء بنجاح.", + "fami_vip.purchase_failed": "فشل الشراء.", + "fami_vip.version_changed": "تم تحديث إعدادات VIP. أعد المحاولة.", + "fami_vip.load_failed": "تعذر تحميل بيانات VIP.", + "fami_vip.settings_saved": "تم حفظ الإعدادات.", + "fami_vip.settings_failed": "تعذر حفظ الإعدادات.", + "huwaa_invite.page_title": "عجلة النقود", + "huwaa_invite.page_aria": "نشاط دعوة عجلة النقود في هوا", + "huwaa_invite.back": "رجوع", + "huwaa_invite.wallet_aria": "نقاط عجلة النقود", + "huwaa_invite.points_left": "النقاط المتبقية للسحب:", + "huwaa_invite.withdraw": "سحب إلى المحفظة", + "huwaa_invite.wheel_aria": "عجلة النقود", + "huwaa_invite.estimated_rewards": "المكافآت المتوقعة", + "huwaa_invite.medium": "متوسط", + "huwaa_invite.spin": "تدوير", + "huwaa_invite.invite_now": "ادعُ الأصدقاء الآن", + "huwaa_invite.tasks_aria": "مهام الدعوة", + "huwaa_invite.close": "إغلاق", + "huwaa_invite.congratulations": "تهانينا!", + "huwaa_invite.you_won": "لقد ربحت", + "huwaa_invite.awesome": "رائع", + "huwaa_invite.completed": "مكتمل", + "huwaa_invite.go": "اذهب", + "huwaa_invite.reward": "مكافأة", + "huwaa_invite.login_required": "يرجى تسجيل الدخول أولاً", + "huwaa_invite.request_failed": "فشل الطلب", + "huwaa_invite.open_app_task": "افتح هذه المهمة في هوا", + "huwaa_invite.invite_unavailable": "رابط الدعوة غير متاح بعد", + "huwaa_invite.copied": "تم النسخ", + "huwaa_invite.copy_failed": "فشل النسخ", + "huwaa_invite.open_wallet": "افتح محفظة هوا للسحب", + "huwaa_invite.task_title": "استخدم الميكروفون لمدة دقيقة", + "huwaa_invite.task_description": "يكمل الصديق المصادقة الحيوية", + "huwaa_invite.medium_reward": "مكافأة متوسطة", + "huwaa_invite.points_label": "نقطة", + "huwaa_weekly_star.page_title": "نجم الأسبوع", + "huwaa_weekly_star.page_aria": "فعالية نجم الأسبوع في هوا", + "huwaa_weekly_star.activity_note": "ملاحظة: أرسل الهدايا الثلاث التالية للمشاركة في التصنيف. سيحصل المستخدمون ضمن المراكز الثلاثة الأولى على مكافآت.", + "huwaa_weekly_star.gifts_aria": "هدايا الفعالية", + "huwaa_weekly_star.gift_label": "هدية الفعالية {index}", + "huwaa_weekly_star.tabs_aria": "أقسام نجم الأسبوع", + "huwaa_weekly_star.previous_top_three": "سجلات الثلاثة الأوائل السابقة", + "huwaa_weekly_star.this_week_rank": "تصنيف هذا الأسبوع", + "huwaa_weekly_star.reward_description": "تفاصيل المكافآت", + "huwaa_weekly_star.loading": "جارٍ التحميل…", + "huwaa_weekly_star.no_active_cycle": "لا توجد دورة نشطة", + "huwaa_weekly_star.no_history": "لا توجد سجلات تصنيف سابقة.", + "huwaa_weekly_star.no_ranking": "لا توجد بيانات تصنيف حتى الآن.", + "huwaa_weekly_star.no_rewards": "لا توجد بيانات مكافآت حتى الآن.", + "huwaa_weekly_star.no_rank_reward": "لا توجد بيانات مكافأة.", + "huwaa_weekly_star.login_required": "سجّل الدخول لعرض نجم الأسبوع.", + "huwaa_weekly_star.profile_required": "أكمل ملفك الشخصي لعرض نجم الأسبوع.", + "huwaa_weekly_star.request_failed": "تعذر تحميل نجم الأسبوع. أعد المحاولة.", + "huwaa_weekly_star.retry": "إعادة المحاولة", + "huwaa_weekly_star.unknown_user": "مستخدم", + "huwaa_weekly_star.rank_label": "الترتيب {rank}، {name}، {score}", + "huwaa_weekly_star.reward": "مكافأة", + "huwaa_weekly_star.frame": "إطار", + "huwaa_weekly_star.reward_days": "{name} ({days} أيام)", + "coin_seller_fami.page_title": "بائعو العملات", + "coin_seller_fami.page_aria": "قائمة بائعي العملات", + "coin_seller_fami.list_aria": "بائعو العملات المتاحون", + "coin_seller_fami.loading": "جارٍ تحميل بائعي العملات", + "coin_seller_fami.empty": "لا يوجد بائعو عملات متاحون في منطقتك.", + "coin_seller_fami.load_failed": "تعذر تحميل بائعي العملات.", + "coin_seller_fami.retry": "إعادة المحاولة", + "coin_seller_fami.contact": "تواصل", + "coin_seller_fami.seller_id": "المعرّف: {id}", + "coin_seller_fami.unknown_seller": "بائع", + "coin_seller_fami.avatar_label": "الصورة الشخصية لـ {name}", + "coin_seller_fami.country_flag": "علم {country}", + "coin_seller_fami.contact_seller": "تواصل مع {name}", + "coin_seller_fami.contact_unavailable": "التواصل غير متاح", + "fami_week_star.page_title": "نجمة الأسبوع", + "fami_week_star.page_aria": "فعالية نجمة الأسبوع من Fami", + "fami_week_star.hero_title": "نجمة الأسبوع", + "fami_week_star.note": "ملاحظة: أرسل الهدايا الثلاث التالية للمشاركة في التصنيف. سيحصل المستخدمون الذين يحتلون المراكز الثلاثة الأولى في لوحة الصدارة على مكافآت.", + "fami_week_star.gifts_aria": "هدايا الفعالية", + "fami_week_star.tabs_aria": "أقسام نجمة الأسبوع", + "fami_week_star.tab_history": "سجلات أفضل 3 السابقة", + "fami_week_star.tab_weekly": "تصنيف هذا الأسبوع", + "fami_week_star.tab_rewards": "وصف المكافآت", + "fami_week_star.loading": "جارٍ التحميل...", + "fami_week_star.load_failed": "تعذر تحميل التصنيف.", + "fami_week_star.unknown": "غير معروف", + "fami_week_star.rank_aria": "المرتبة {rank}", + "fami_week_star.entry_aria": "المرتبة {rank}، {name}، {score}", + "fami_week_star.empty_weekly": "لا توجد بيانات تصنيف بعد.", + "fami_week_star.empty_history": "لا توجد سجلات تصنيف سابقة.", + "fami_week_star.gift": "هدية", + "fami_week_star.coins": "عملات", + "fami_week_star.reward": "مكافأة", + "fami_week_star.reward_days": "{name} ({days} أيام)", + "fami_week_star.top_rewards_aria": "مكافآت المركز {rank}", + "fami_week_star.activity_load_failed": "تعذر تحميل نجمة الأسبوع.", + "fami_week_star.no_active_cycle": "لا توجد دورة نشطة.", + "fami_week_star.empty_rewards": "لا توجد بيانات مكافآت بعد.", + "fami_week_star.mock_reward_name": "إطار", + "cp.disabled": "هذا النشاط غير متاح.", + "cp.unavailable": "مصدر البيانات غير متاح بعد.", + "cp.previousWeekTopTitle": "أفضل 3 سابقًا", + "cp.previousWeekTopSubtitle": "(أفضل ثنائيات CP من الأسبوع المكتمل السابق.)", + "cp.rankPlace": "الترتيب", + "cp.rewardTierGold": "جوائز المركز الأول", + "cp.rewardTierSilver": "جوائز المركز الثاني", + "cp.rewardTierBronze": "جوائز المركز الثالث", + "cp.rewardAvatarFrame": "إطار الصورة الرمزية", + "cp.rewardProfileCard": "بطاقة الملف الشخصي", + "cp.rewardCoins": "عملات", + "cp.rewardVehicle": "مركبة", + "cp.rewardChatBubble": "فقاعة دردشة", + "cp.rewardBadge": "شارة", + "cp.rewardFloatingScreen": "شاشة عائمة", + "cp.rewardGift": "هدية", + "cp.rewardMicSeatIcon": "أيقونة مقعد الميكروفون", + "cp.rewardMicSeatAnimation": "حركة مقعد الميكروفون", + "cp.rewardEmojiPack": "حزمة رموز تعبيرية", + "cp.rewardVipTrialCard": "بطاقة VIP تجريبية" } diff --git a/common/locales/en.json b/common/locales/en.json index 5666231..b0dcd4c 100644 --- a/common/locales/en.json +++ b/common/locales/en.json @@ -55,6 +55,9 @@ "roomReward.idPrefix": "ID:", "roomReward.roomOwner": "Room owner", "roomReward.changeLanguage": "Change language", + "roomReward.loading": "Loading...", + "roomReward.empty": "No reward tiers yet", + "roomReward.loadFailed": "Load failed", "rechargeReward.pageTitle": "Recharge Reward", "rechargeReward.pageLabel": "Recharge Reward activity", "rechargeReward.changeLanguage": "Change language", @@ -66,6 +69,7 @@ "rechargeReward.accumulatedRecharge": "Accumulated Recharge:", "rechargeReward.rechargeTitle": "Recharge {amount}", "rechargeReward.claim": "Claim", + "rechargeReward.receive": "Receive", "rechargeReward.claiming": "Claiming...", "rechargeReward.claimed": "Claimed", "rechargeReward.claimSuccess": "Claimed", @@ -540,6 +544,17 @@ "cp.rewardCoins10000": "Coins*10000", "cp.rewardCount7": "Count*7days", "cp.copyright": "The copyright of this activity belongs to Shila", + "cp.countdownLabel": "Activity countdown", + "cp.huwaaPlazaGiftTitle": "Gifts exchanged between CPs >= 5000 gold will be displayed here", + "cp.previousNo1PositionTitle": "The previous number one position", + "cp.previousNo1Subtitle": "(Display the last 10 records, sorted in ascending order.)", + "cp.rankPodiumLabel": "Top CP ranking", + "cp.loading": "Loading...", + "cp.empty": "No data yet.", + "cp.error": "Unable to load activity data.", + "cp.retry": "Retry", + "cp.reward": "Reward", + "cp.daysLong": "days", "recharge.pageTitle": "Recharge Center", "recharge.pageSubtitle": "Choose a coin package and pay.", "recharge.changeLanguage": "Change language", @@ -806,5 +821,155 @@ "fami_rank.network_error": "Unable to load the ranking. Please try again.", "fami_rank.unknown": "Unknown", "huwaa_rank.page_title": "Huwaa Ranking", - "huwaa_rank.game": "Game" + "huwaa_rank.game": "Game", + "fami_vip.back": "Back", + "fami_vip.settings": "VIP settings", + "fami_vip.help": "VIP help", + "fami_vip.levels": "VIP levels", + "fami_vip.exclusive_outfit": "Exclusive Outfit", + "fami_vip.vip_privileges": "VIP Privileges", + "fami_vip.no_outfits": "No exclusive outfits for this level.", + "fami_vip.no_privileges": "No privileges for this level.", + "fami_vip.outfit": "VIP outfit", + "fami_vip.loading": "Loading...", + "fami_vip.retry": "Retry", + "fami_vip.purchase_region": "VIP purchase", + "fami_vip.close": "Close", + "fami_vip.help_title": "VIP Help", + "fami_vip.help_body": "Benefits and purchase availability are supplied by the VIP service for the selected level.", + "fami_vip.settings_title": "VIP Settings", + "fami_vip.room_entry_notice": "Room entry notice", + "fami_vip.online_notice": "Online global notice", + "fami_vip.unlocked": "Unlocked", + "fami_vip.locked": "Locked", + "fami_vip.days": "{count} Days", + "fami_vip.no_expiry": "No expiry", + "fami_vip.trial_level": "Trial Level: {level}", + "fami_vip.unlocked_level": "Unlocked Level: {level}", + "fami_vip.valid_until": "Valid until: {date}", + "fami_vip.renew_vip": "Renew VIP", + "fami_vip.upgrade_vip": "Upgrade VIP", + "fami_vip.activate_vip": "Activate VIP", + "fami_vip.unavailable": "Unavailable", + "fami_vip.current_vip_higher": "A lower VIP level cannot be purchased while a higher level is active.", + "fami_vip.processing": "Processing...", + "fami_vip.purchase_success": "Purchase successful.", + "fami_vip.purchase_failed": "Purchase failed.", + "fami_vip.version_changed": "VIP configuration changed. Please retry.", + "fami_vip.load_failed": "Unable to load VIP data.", + "fami_vip.settings_saved": "Settings saved.", + "fami_vip.settings_failed": "Unable to save settings.", + "huwaa_invite.page_title": "Cash Wheel", + "huwaa_invite.page_aria": "Huwaa cash wheel invite activity", + "huwaa_invite.back": "Back", + "huwaa_invite.wallet_aria": "Cash wheel points", + "huwaa_invite.points_left": "Points Left To Withdraw:", + "huwaa_invite.withdraw": "WITHDRAW TO WALLET", + "huwaa_invite.wheel_aria": "Cash wheel", + "huwaa_invite.estimated_rewards": "ESTIMATED REWARDS", + "huwaa_invite.medium": "Medium", + "huwaa_invite.spin": "Spin", + "huwaa_invite.invite_now": "Invite Friends Now", + "huwaa_invite.tasks_aria": "Invitation tasks", + "huwaa_invite.close": "Close", + "huwaa_invite.congratulations": "CONGRATULATIONS!", + "huwaa_invite.you_won": "You Have Won", + "huwaa_invite.awesome": "AWESOME", + "huwaa_invite.completed": "COMPLETED", + "huwaa_invite.go": "GO", + "huwaa_invite.reward": "Reward", + "huwaa_invite.login_required": "Please log in first", + "huwaa_invite.request_failed": "Request failed", + "huwaa_invite.open_app_task": "Open this task in Huwaa", + "huwaa_invite.invite_unavailable": "Invitation link is not available yet", + "huwaa_invite.copied": "Copied", + "huwaa_invite.copy_failed": "Copy failed", + "huwaa_invite.open_wallet": "Open the Huwaa wallet to withdraw", + "huwaa_invite.task_title": "Use the microphone for 1 min", + "huwaa_invite.task_description": "Friend completes biometric authentication", + "huwaa_invite.medium_reward": "Medium Reward", + "huwaa_invite.points_label": "Points", + "huwaa_weekly_star.page_title": "Weekly Star", + "huwaa_weekly_star.page_aria": "Huwaa Weekly Star activity", + "huwaa_weekly_star.activity_note": "Note: Send the following three gifts to participate in the ranking. Users who rank in the top 3 of the leaderboard will receive rewards.", + "huwaa_weekly_star.gifts_aria": "Activity gifts", + "huwaa_weekly_star.gift_label": "Activity gift {index}", + "huwaa_weekly_star.tabs_aria": "Weekly Star sections", + "huwaa_weekly_star.previous_top_three": "Previous Top 3 Records", + "huwaa_weekly_star.this_week_rank": "This Week's rank list", + "huwaa_weekly_star.reward_description": "Reward Description", + "huwaa_weekly_star.loading": "Loading…", + "huwaa_weekly_star.no_active_cycle": "No active cycle", + "huwaa_weekly_star.no_history": "No previous ranking records.", + "huwaa_weekly_star.no_ranking": "No ranking data yet.", + "huwaa_weekly_star.no_rewards": "No reward data yet.", + "huwaa_weekly_star.no_rank_reward": "No reward data.", + "huwaa_weekly_star.login_required": "Please log in to view Weekly Star.", + "huwaa_weekly_star.profile_required": "Complete your profile to view Weekly Star.", + "huwaa_weekly_star.request_failed": "Unable to load Weekly Star. Please try again.", + "huwaa_weekly_star.retry": "Retry", + "huwaa_weekly_star.unknown_user": "User", + "huwaa_weekly_star.rank_label": "Rank {rank}, {name}, {score}", + "huwaa_weekly_star.reward": "Reward", + "huwaa_weekly_star.frame": "Frame", + "huwaa_weekly_star.reward_days": "{name} ({days} days)", + "coin_seller_fami.page_title": "Coins Seller", + "coin_seller_fami.page_aria": "Coin seller list", + "coin_seller_fami.list_aria": "Available coin sellers", + "coin_seller_fami.loading": "Loading coin sellers", + "coin_seller_fami.empty": "No coin sellers are available in your region.", + "coin_seller_fami.load_failed": "Unable to load coin sellers.", + "coin_seller_fami.retry": "Retry", + "coin_seller_fami.contact": "Contact", + "coin_seller_fami.seller_id": "ID: {id}", + "coin_seller_fami.unknown_seller": "Seller", + "coin_seller_fami.avatar_label": "{name} avatar", + "coin_seller_fami.country_flag": "{country} flag", + "coin_seller_fami.contact_seller": "Contact {name}", + "coin_seller_fami.contact_unavailable": "Contact unavailable", + "fami_week_star.page_title": "Weekly Star", + "fami_week_star.page_aria": "Fami Weekly Star activity", + "fami_week_star.hero_title": "Weekly star", + "fami_week_star.note": "Note: Send the following three gifts to participate in the ranking. Users who rank in the top 3 of the leaderboard will receive rewards.", + "fami_week_star.gifts_aria": "Activity gifts", + "fami_week_star.tabs_aria": "Weekly Star sections", + "fami_week_star.tab_history": "Previous Top 3 Records", + "fami_week_star.tab_weekly": "This Week's rank list", + "fami_week_star.tab_rewards": "Reward Description", + "fami_week_star.loading": "Loading...", + "fami_week_star.load_failed": "Unable to load rankings.", + "fami_week_star.unknown": "Unknown", + "fami_week_star.rank_aria": "Rank {rank}", + "fami_week_star.entry_aria": "Rank {rank}, {name}, {score}", + "fami_week_star.empty_weekly": "No ranking data yet.", + "fami_week_star.empty_history": "No previous ranking records.", + "fami_week_star.gift": "Gift", + "fami_week_star.coins": "Coins", + "fami_week_star.reward": "Reward", + "fami_week_star.reward_days": "{name} ({days} days)", + "fami_week_star.top_rewards_aria": "Top {rank} rewards", + "fami_week_star.activity_load_failed": "Unable to load Weekly Star.", + "fami_week_star.no_active_cycle": "No active cycle.", + "fami_week_star.empty_rewards": "No reward data yet.", + "fami_week_star.mock_reward_name": "Frame", + "cp.disabled": "This activity is not available.", + "cp.unavailable": "No data source is available yet.", + "cp.previousWeekTopTitle": "Previous Top 3", + "cp.previousWeekTopSubtitle": "(Top CPs from the previous completed week.)", + "cp.rankPlace": "Rank", + "cp.rewardTierGold": "First-place rewards", + "cp.rewardTierSilver": "Second-place rewards", + "cp.rewardTierBronze": "Third-place rewards", + "cp.rewardAvatarFrame": "Avatar frame", + "cp.rewardProfileCard": "Profile card", + "cp.rewardCoins": "Coins", + "cp.rewardVehicle": "Vehicle", + "cp.rewardChatBubble": "Chat bubble", + "cp.rewardBadge": "Badge", + "cp.rewardFloatingScreen": "Floating screen", + "cp.rewardGift": "Gift", + "cp.rewardMicSeatIcon": "Mic seat icon", + "cp.rewardMicSeatAnimation": "Mic seat animation", + "cp.rewardEmojiPack": "Emoji pack", + "cp.rewardVipTrialCard": "VIP trial card" } diff --git a/common/locales/es.json b/common/locales/es.json index 231d40b..ad86fc3 100644 --- a/common/locales/es.json +++ b/common/locales/es.json @@ -55,6 +55,9 @@ "roomReward.idPrefix": "ID:", "roomReward.roomOwner": "Anfitrión de la sala", "roomReward.changeLanguage": "Cambiar idioma", + "roomReward.loading": "Cargando...", + "roomReward.empty": "Aún no hay niveles de recompensa", + "roomReward.loadFailed": "Error al cargar", "rechargeReward.pageTitle": "Recompensa de recarga acumulada", "rechargeReward.pageLabel": "Actividad de recompensa de recarga acumulada", "rechargeReward.changeLanguage": "Cambiar idioma", @@ -66,6 +69,7 @@ "rechargeReward.accumulatedRecharge": "Recarga acumulada:", "rechargeReward.rechargeTitle": "Recarga {amount}", "rechargeReward.claim": "Reclamar", + "rechargeReward.receive": "Recibir", "rechargeReward.claiming": "Reclamando...", "rechargeReward.claimed": "Reclamado", "rechargeReward.claimSuccess": "Reclamado", @@ -534,6 +538,17 @@ "cp.rewardCoins10000": "Monedas*10000", "cp.rewardCount7": "Contador*7 días", "cp.copyright": "Los derechos de esta actividad pertenecen a Shila", + "cp.countdownLabel": "Cuenta regresiva de la actividad", + "cp.huwaaPlazaGiftTitle": "Aquí se mostrarán los regalos intercambiados entre parejas CP por un valor de 5000 monedas de oro o más", + "cp.previousNo1PositionTitle": "Registros anteriores del primer puesto", + "cp.previousNo1Subtitle": "(Muestra los últimos 10 registros ordenados de forma ascendente.)", + "cp.rankPodiumLabel": "Clasificación de las mejores parejas CP", + "cp.loading": "Cargando...", + "cp.empty": "Aún no hay datos.", + "cp.error": "No se pudieron cargar los datos de la actividad.", + "cp.retry": "Reintentar", + "cp.reward": "Recompensa", + "cp.daysLong": "días", "recharge.pageTitle": "Recharge Center", "recharge.pageSubtitle": "Elige un paquete de monedas y completa el pago.", "recharge.changeLanguage": "Cambiar idioma", @@ -800,5 +815,155 @@ "fami_rank.network_error": "No se pudo cargar la clasificación. Inténtalo de nuevo.", "fami_rank.unknown": "Desconocido", "huwaa_rank.page_title": "Clasificación Huwaa", - "huwaa_rank.game": "Juego" + "huwaa_rank.game": "Juego", + "fami_vip.back": "Atrás", + "fami_vip.settings": "Ajustes VIP", + "fami_vip.help": "Ayuda VIP", + "fami_vip.levels": "Niveles VIP", + "fami_vip.exclusive_outfit": "Estilo exclusivo", + "fami_vip.vip_privileges": "Privilegios VIP", + "fami_vip.no_outfits": "No hay estilos exclusivos para este nivel.", + "fami_vip.no_privileges": "No hay privilegios para este nivel.", + "fami_vip.outfit": "Estilo VIP", + "fami_vip.loading": "Cargando...", + "fami_vip.retry": "Reintentar", + "fami_vip.purchase_region": "Comprar VIP", + "fami_vip.close": "Cerrar", + "fami_vip.help_title": "Ayuda VIP", + "fami_vip.help_body": "Los privilegios y la disponibilidad de compra del nivel seleccionado proceden del servicio VIP.", + "fami_vip.settings_title": "Ajustes VIP", + "fami_vip.room_entry_notice": "Aviso al entrar en la sala", + "fami_vip.online_notice": "Aviso global al conectarse", + "fami_vip.unlocked": "Desbloqueado", + "fami_vip.locked": "Bloqueado", + "fami_vip.days": "{count} días", + "fami_vip.no_expiry": "Sin caducidad", + "fami_vip.trial_level": "Nivel de prueba: {level}", + "fami_vip.unlocked_level": "Nivel desbloqueado: {level}", + "fami_vip.valid_until": "Válido hasta: {date}", + "fami_vip.renew_vip": "Renovar VIP", + "fami_vip.upgrade_vip": "Mejorar VIP", + "fami_vip.activate_vip": "Activar VIP", + "fami_vip.unavailable": "No disponible", + "fami_vip.current_vip_higher": "No se puede comprar un nivel VIP inferior mientras haya uno superior activo.", + "fami_vip.processing": "Procesando...", + "fami_vip.purchase_success": "Compra realizada.", + "fami_vip.purchase_failed": "Error en la compra.", + "fami_vip.version_changed": "La configuración VIP cambió. Reinténtalo.", + "fami_vip.load_failed": "No se pudieron cargar los datos VIP.", + "fami_vip.settings_saved": "Ajustes guardados.", + "fami_vip.settings_failed": "No se pudieron guardar los ajustes.", + "huwaa_invite.page_title": "Ruleta de efectivo", + "huwaa_invite.page_aria": "Actividad de invitación de la ruleta de Huwaa", + "huwaa_invite.back": "Volver", + "huwaa_invite.wallet_aria": "Puntos de la ruleta", + "huwaa_invite.points_left": "Puntos restantes para retirar:", + "huwaa_invite.withdraw": "RETIRAR A LA CARTERA", + "huwaa_invite.wheel_aria": "Ruleta de efectivo", + "huwaa_invite.estimated_rewards": "RECOMPENSAS ESTIMADAS", + "huwaa_invite.medium": "Medio", + "huwaa_invite.spin": "Girar", + "huwaa_invite.invite_now": "Invita amigos ahora", + "huwaa_invite.tasks_aria": "Tareas de invitación", + "huwaa_invite.close": "Cerrar", + "huwaa_invite.congratulations": "¡FELICIDADES!", + "huwaa_invite.you_won": "Has ganado", + "huwaa_invite.awesome": "GENIAL", + "huwaa_invite.completed": "COMPLETADO", + "huwaa_invite.go": "IR", + "huwaa_invite.reward": "Recompensa", + "huwaa_invite.login_required": "Inicia sesión primero", + "huwaa_invite.request_failed": "Error en la solicitud", + "huwaa_invite.open_app_task": "Abre esta tarea en Huwaa", + "huwaa_invite.invite_unavailable": "El enlace de invitación aún no está disponible", + "huwaa_invite.copied": "Copiado", + "huwaa_invite.copy_failed": "Error al copiar", + "huwaa_invite.open_wallet": "Abre la cartera de Huwaa para retirar", + "huwaa_invite.task_title": "Usa el micrófono durante 1 min", + "huwaa_invite.task_description": "El amigo completa la autenticación biométrica", + "huwaa_invite.medium_reward": "Recompensa media", + "huwaa_invite.points_label": "Puntos", + "huwaa_weekly_star.page_title": "Estrella semanal", + "huwaa_weekly_star.page_aria": "Actividad Estrella semanal de Huwaa", + "huwaa_weekly_star.activity_note": "Nota: Envía los tres regalos siguientes para participar en la clasificación. Los usuarios que queden entre los 3 primeros recibirán recompensas.", + "huwaa_weekly_star.gifts_aria": "Regalos de la actividad", + "huwaa_weekly_star.gift_label": "Regalo de actividad {index}", + "huwaa_weekly_star.tabs_aria": "Secciones de Estrella semanal", + "huwaa_weekly_star.previous_top_three": "Registros anteriores del Top 3", + "huwaa_weekly_star.this_week_rank": "Clasificación de esta semana", + "huwaa_weekly_star.reward_description": "Descripción de recompensas", + "huwaa_weekly_star.loading": "Cargando…", + "huwaa_weekly_star.no_active_cycle": "No hay un ciclo activo", + "huwaa_weekly_star.no_history": "No hay registros de clasificación anteriores.", + "huwaa_weekly_star.no_ranking": "Aún no hay datos de clasificación.", + "huwaa_weekly_star.no_rewards": "Aún no hay datos de recompensas.", + "huwaa_weekly_star.no_rank_reward": "No hay datos de recompensa.", + "huwaa_weekly_star.login_required": "Inicia sesión para ver Estrella semanal.", + "huwaa_weekly_star.profile_required": "Completa tu perfil para ver Estrella semanal.", + "huwaa_weekly_star.request_failed": "No se pudo cargar Estrella semanal. Inténtalo de nuevo.", + "huwaa_weekly_star.retry": "Reintentar", + "huwaa_weekly_star.unknown_user": "Usuario", + "huwaa_weekly_star.rank_label": "Puesto {rank}, {name}, {score}", + "huwaa_weekly_star.reward": "Recompensa", + "huwaa_weekly_star.frame": "Marco", + "huwaa_weekly_star.reward_days": "{name} ({days} días)", + "coin_seller_fami.page_title": "Vendedores de monedas", + "coin_seller_fami.page_aria": "Lista de vendedores de monedas", + "coin_seller_fami.list_aria": "Vendedores de monedas disponibles", + "coin_seller_fami.loading": "Cargando vendedores de monedas", + "coin_seller_fami.empty": "No hay vendedores de monedas disponibles en tu región.", + "coin_seller_fami.load_failed": "No se pudieron cargar los vendedores.", + "coin_seller_fami.retry": "Reintentar", + "coin_seller_fami.contact": "Contactar", + "coin_seller_fami.seller_id": "ID: {id}", + "coin_seller_fami.unknown_seller": "Vendedor", + "coin_seller_fami.avatar_label": "Avatar de {name}", + "coin_seller_fami.country_flag": "Bandera de {country}", + "coin_seller_fami.contact_seller": "Contactar a {name}", + "coin_seller_fami.contact_unavailable": "Contacto no disponible", + "fami_week_star.page_title": "Estrella semanal", + "fami_week_star.page_aria": "Actividad Estrella semanal de Fami", + "fami_week_star.hero_title": "Estrella semanal", + "fami_week_star.note": "Nota: Envía los siguientes tres regalos para participar en la clasificación. Los usuarios que estén entre los 3 primeros de la tabla recibirán recompensas.", + "fami_week_star.gifts_aria": "Regalos de la actividad", + "fami_week_star.tabs_aria": "Secciones de Estrella semanal", + "fami_week_star.tab_history": "Registros anteriores del Top 3", + "fami_week_star.tab_weekly": "Clasificación de esta semana", + "fami_week_star.tab_rewards": "Descripción de recompensas", + "fami_week_star.loading": "Cargando...", + "fami_week_star.load_failed": "No se pudo cargar la clasificación.", + "fami_week_star.unknown": "Desconocido", + "fami_week_star.rank_aria": "Puesto {rank}", + "fami_week_star.entry_aria": "Puesto {rank}, {name}, {score}", + "fami_week_star.empty_weekly": "Aún no hay datos de clasificación.", + "fami_week_star.empty_history": "No hay registros de clasificación anteriores.", + "fami_week_star.gift": "Regalo", + "fami_week_star.coins": "Monedas", + "fami_week_star.reward": "Recompensa", + "fami_week_star.reward_days": "{name} ({days} días)", + "fami_week_star.top_rewards_aria": "Recompensas del puesto {rank}", + "fami_week_star.activity_load_failed": "No se pudo cargar Estrella semanal.", + "fami_week_star.no_active_cycle": "No hay un ciclo activo.", + "fami_week_star.empty_rewards": "Aún no hay datos de recompensas.", + "fami_week_star.mock_reward_name": "Marco", + "cp.disabled": "Esta actividad no está disponible.", + "cp.unavailable": "La fuente de datos aún no está disponible.", + "cp.previousWeekTopTitle": "Top 3 anterior", + "cp.previousWeekTopSubtitle": "(Mejores parejas CP de la semana completada anterior.)", + "cp.rankPlace": "Puesto", + "cp.rewardTierGold": "Recompensas del primer puesto", + "cp.rewardTierSilver": "Recompensas del segundo puesto", + "cp.rewardTierBronze": "Recompensas del tercer puesto", + "cp.rewardAvatarFrame": "Marco de avatar", + "cp.rewardProfileCard": "Tarjeta de perfil", + "cp.rewardCoins": "Monedas", + "cp.rewardVehicle": "Vehículo", + "cp.rewardChatBubble": "Burbuja de chat", + "cp.rewardBadge": "Insignia", + "cp.rewardFloatingScreen": "Pantalla flotante", + "cp.rewardGift": "Regalo", + "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" } diff --git a/common/locales/id.json b/common/locales/id.json index a6dcc5a..c6b5ca3 100644 --- a/common/locales/id.json +++ b/common/locales/id.json @@ -22,6 +22,9 @@ "roomReward.idPrefix": "ID:", "roomReward.roomOwner": "Pemilik room", "roomReward.changeLanguage": "Ganti bahasa", + "roomReward.loading": "Memuat...", + "roomReward.empty": "Belum ada level hadiah", + "roomReward.loadFailed": "Gagal memuat", "rechargeReward.pageTitle": "Hadiah Recharge Akumulatif", "rechargeReward.pageLabel": "Event Hadiah Recharge Akumulatif", "rechargeReward.changeLanguage": "Ganti bahasa", @@ -33,6 +36,7 @@ "rechargeReward.accumulatedRecharge": "Recharge akumulatif:", "rechargeReward.rechargeTitle": "Recharge {amount}", "rechargeReward.claim": "Klaim", + "rechargeReward.receive": "Terima", "rechargeReward.claiming": "Mengklaim...", "rechargeReward.claimed": "Diklaim", "rechargeReward.claimSuccess": "Diklaim", @@ -692,5 +696,86 @@ "manager_center.send_badge": "Kirim Lencana", "manager_center.send_vip": "Kirim VIP", "manager_center.user_level": "Level Pengguna", - "manager_center.transfer_user_country": "Pindahkan Negara Pengguna" + "manager_center.transfer_user_country": "Pindahkan Negara Pengguna", + "fami_vip.back": "Kembali", + "fami_vip.settings": "Pengaturan VIP", + "fami_vip.help": "Bantuan VIP", + "fami_vip.levels": "Level VIP", + "fami_vip.exclusive_outfit": "Tampilan Eksklusif", + "fami_vip.vip_privileges": "Hak Istimewa VIP", + "fami_vip.no_outfits": "Tidak ada tampilan eksklusif untuk level ini.", + "fami_vip.no_privileges": "Tidak ada hak istimewa untuk level ini.", + "fami_vip.outfit": "Tampilan VIP", + "fami_vip.loading": "Memuat...", + "fami_vip.retry": "Coba lagi", + "fami_vip.purchase_region": "Beli VIP", + "fami_vip.close": "Tutup", + "fami_vip.help_title": "Bantuan VIP", + "fami_vip.help_body": "Hak istimewa dan ketersediaan pembelian untuk level yang dipilih diberikan oleh layanan VIP.", + "fami_vip.settings_title": "Pengaturan VIP", + "fami_vip.room_entry_notice": "Notifikasi masuk room", + "fami_vip.online_notice": "Notifikasi online global", + "fami_vip.unlocked": "Terbuka", + "fami_vip.locked": "Terkunci", + "fami_vip.days": "{count} Hari", + "fami_vip.no_expiry": "Tanpa kedaluwarsa", + "fami_vip.trial_level": "Level Uji Coba: {level}", + "fami_vip.unlocked_level": "Level Terbuka: {level}", + "fami_vip.valid_until": "Berlaku hingga: {date}", + "fami_vip.renew_vip": "Perpanjang VIP", + "fami_vip.upgrade_vip": "Tingkatkan VIP", + "fami_vip.activate_vip": "Aktifkan VIP", + "fami_vip.unavailable": "Tidak tersedia", + "fami_vip.current_vip_higher": "Level VIP yang lebih rendah tidak dapat dibeli saat level yang lebih tinggi aktif.", + "fami_vip.processing": "Memproses...", + "fami_vip.purchase_success": "Pembelian berhasil.", + "fami_vip.purchase_failed": "Pembelian gagal.", + "fami_vip.version_changed": "Konfigurasi VIP berubah. Coba lagi.", + "fami_vip.load_failed": "Data VIP tidak dapat dimuat.", + "fami_vip.settings_saved": "Pengaturan disimpan.", + "fami_vip.settings_failed": "Pengaturan tidak dapat disimpan.", + "huwaa_invite.page_title": "Roda Tunai", + "huwaa_invite.page_aria": "Aktivitas undangan roda tunai Huwaa", + "huwaa_invite.back": "Kembali", + "huwaa_invite.wallet_aria": "Poin roda tunai", + "huwaa_invite.points_left": "Poin tersisa untuk ditarik:", + "huwaa_invite.withdraw": "TARIK KE DOMPET", + "huwaa_invite.wheel_aria": "Roda tunai", + "huwaa_invite.estimated_rewards": "PERKIRAAN HADIAH", + "huwaa_invite.medium": "Sedang", + "huwaa_invite.spin": "Putar", + "huwaa_invite.invite_now": "Undang Teman Sekarang", + "huwaa_invite.tasks_aria": "Tugas undangan", + "huwaa_invite.close": "Tutup", + "huwaa_invite.congratulations": "SELAMAT!", + "huwaa_invite.you_won": "Kamu menang", + "huwaa_invite.awesome": "KEREN", + "huwaa_invite.completed": "SELESAI", + "huwaa_invite.go": "BUKA", + "huwaa_invite.reward": "Hadiah", + "huwaa_invite.login_required": "Silakan masuk terlebih dahulu", + "huwaa_invite.request_failed": "Permintaan gagal", + "huwaa_invite.open_app_task": "Buka tugas ini di Huwaa", + "huwaa_invite.invite_unavailable": "Tautan undangan belum tersedia", + "huwaa_invite.copied": "Disalin", + "huwaa_invite.copy_failed": "Gagal menyalin", + "huwaa_invite.open_wallet": "Buka dompet Huwaa untuk menarik", + "huwaa_invite.task_title": "Gunakan mikrofon selama 1 menit", + "huwaa_invite.task_description": "Teman menyelesaikan autentikasi biometrik", + "huwaa_invite.medium_reward": "Hadiah Sedang", + "huwaa_invite.points_label": "Poin", + "coin_seller_fami.page_title": "Penjual Koin", + "coin_seller_fami.page_aria": "Daftar penjual koin", + "coin_seller_fami.list_aria": "Penjual koin yang tersedia", + "coin_seller_fami.loading": "Memuat penjual koin", + "coin_seller_fami.empty": "Tidak ada penjual koin di wilayah Anda.", + "coin_seller_fami.load_failed": "Penjual koin tidak dapat dimuat.", + "coin_seller_fami.retry": "Coba Lagi", + "coin_seller_fami.contact": "Hubungi", + "coin_seller_fami.seller_id": "ID: {id}", + "coin_seller_fami.unknown_seller": "Penjual", + "coin_seller_fami.avatar_label": "Avatar {name}", + "coin_seller_fami.country_flag": "Bendera {country}", + "coin_seller_fami.contact_seller": "Hubungi {name}", + "coin_seller_fami.contact_unavailable": "Kontak tidak tersedia" } diff --git a/common/locales/tr.json b/common/locales/tr.json index b54e18b..7df8138 100644 --- a/common/locales/tr.json +++ b/common/locales/tr.json @@ -22,6 +22,9 @@ "roomReward.idPrefix": "ID:", "roomReward.roomOwner": "Oda sahibi", "roomReward.changeLanguage": "Dili değiştir", + "roomReward.loading": "Yükleniyor...", + "roomReward.empty": "Henüz ödül kademesi yok", + "roomReward.loadFailed": "Yükleme başarısız", "rechargeReward.pageTitle": "Kümülatif Yükleme Ödülü", "rechargeReward.pageLabel": "Kümülatif Yükleme Ödülü etkinliği", "rechargeReward.changeLanguage": "Dili değiştir", @@ -33,6 +36,7 @@ "rechargeReward.accumulatedRecharge": "Toplam yükleme:", "rechargeReward.rechargeTitle": "{amount} yükle", "rechargeReward.claim": "Al", + "rechargeReward.receive": "Al", "rechargeReward.claiming": "Alınıyor...", "rechargeReward.claimed": "Alındı", "rechargeReward.claimSuccess": "Alındı", @@ -501,6 +505,17 @@ "cp.rewardCoins10000": "Coin*10000", "cp.rewardCount7": "Sayaç*7 gün", "cp.copyright": "Bu etkinliğin telif hakkı Shila'ya aittir", + "cp.countdownLabel": "Etkinlik geri sayımı", + "cp.huwaaPlazaGiftTitle": "CP'ler arasında takas edilen 5000 veya daha fazla altın değerindeki hediyeler burada gösterilir", + "cp.previousNo1PositionTitle": "Önceki birincilik kayıtları", + "cp.previousNo1Subtitle": "(Son 10 kayıt artan sırada gösterilir.)", + "cp.rankPodiumLabel": "En iyi CP sıralaması", + "cp.loading": "Yükleniyor...", + "cp.empty": "Henüz veri yok.", + "cp.error": "Etkinlik verileri yüklenemedi.", + "cp.retry": "Tekrar dene", + "cp.reward": "Ödül", + "cp.daysLong": "gün", "recharge.pageTitle": "Recharge Center", "recharge.pageSubtitle": "Coin paketi seçin ve ödemeyi tamamlayın.", "recharge.changeLanguage": "Dili değiştir", @@ -767,5 +782,155 @@ "fami_rank.network_error": "Sıralama yüklenemedi. Lütfen tekrar deneyin.", "fami_rank.unknown": "Bilinmiyor", "huwaa_rank.page_title": "Huwaa Sıralaması", - "huwaa_rank.game": "Oyun" + "huwaa_rank.game": "Oyun", + "fami_vip.back": "Geri", + "fami_vip.settings": "VIP ayarları", + "fami_vip.help": "VIP yardımı", + "fami_vip.levels": "VIP seviyeleri", + "fami_vip.exclusive_outfit": "Özel Görünüm", + "fami_vip.vip_privileges": "VIP Ayrıcalıkları", + "fami_vip.no_outfits": "Bu seviyede özel görünüm yok.", + "fami_vip.no_privileges": "Bu seviyede ayrıcalık yok.", + "fami_vip.outfit": "VIP görünümü", + "fami_vip.loading": "Yükleniyor...", + "fami_vip.retry": "Tekrar dene", + "fami_vip.purchase_region": "VIP satın al", + "fami_vip.close": "Kapat", + "fami_vip.help_title": "VIP Yardımı", + "fami_vip.help_body": "Seçilen seviyenin ayrıcalıkları ve satın alma durumu VIP hizmeti tarafından sağlanır.", + "fami_vip.settings_title": "VIP Ayarları", + "fami_vip.room_entry_notice": "Odaya giriş bildirimi", + "fami_vip.online_notice": "Çevrim içi genel bildirim", + "fami_vip.unlocked": "Açık", + "fami_vip.locked": "Kilitli", + "fami_vip.days": "{count} Gün", + "fami_vip.no_expiry": "Süresiz", + "fami_vip.trial_level": "Deneme Seviyesi: {level}", + "fami_vip.unlocked_level": "Açık Seviye: {level}", + "fami_vip.valid_until": "Geçerlilik: {date}", + "fami_vip.renew_vip": "VIP Yenile", + "fami_vip.upgrade_vip": "VIP Yükselt", + "fami_vip.activate_vip": "VIP Etkinleştir", + "fami_vip.unavailable": "Kullanılamıyor", + "fami_vip.current_vip_higher": "Daha yüksek bir VIP seviyesi etkinken daha düşük seviye satın alınamaz.", + "fami_vip.processing": "İşleniyor...", + "fami_vip.purchase_success": "Satın alma başarılı.", + "fami_vip.purchase_failed": "Satın alma başarısız.", + "fami_vip.version_changed": "VIP yapılandırması değişti. Tekrar deneyin.", + "fami_vip.load_failed": "VIP verileri yüklenemedi.", + "fami_vip.settings_saved": "Ayarlar kaydedildi.", + "fami_vip.settings_failed": "Ayarlar kaydedilemedi.", + "huwaa_invite.page_title": "Nakit Çarkı", + "huwaa_invite.page_aria": "Huwaa nakit çarkı davet etkinliği", + "huwaa_invite.back": "Geri", + "huwaa_invite.wallet_aria": "Nakit çarkı puanları", + "huwaa_invite.points_left": "Çekim İçin Kalan Puan:", + "huwaa_invite.withdraw": "CÜZDANA ÇEK", + "huwaa_invite.wheel_aria": "Nakit çarkı", + "huwaa_invite.estimated_rewards": "TAHMİNİ ÖDÜLLER", + "huwaa_invite.medium": "Orta", + "huwaa_invite.spin": "Çevir", + "huwaa_invite.invite_now": "Arkadaşlarını Şimdi Davet Et", + "huwaa_invite.tasks_aria": "Davet görevleri", + "huwaa_invite.close": "Kapat", + "huwaa_invite.congratulations": "TEBRİKLER!", + "huwaa_invite.you_won": "Kazandın", + "huwaa_invite.awesome": "HARİKA", + "huwaa_invite.completed": "TAMAMLANDI", + "huwaa_invite.go": "GİT", + "huwaa_invite.reward": "Ödül", + "huwaa_invite.login_required": "Lütfen önce giriş yapın", + "huwaa_invite.request_failed": "İstek başarısız", + "huwaa_invite.open_app_task": "Bu görevi Huwaa'da aç", + "huwaa_invite.invite_unavailable": "Davet bağlantısı henüz mevcut değil", + "huwaa_invite.copied": "Kopyalandı", + "huwaa_invite.copy_failed": "Kopyalama başarısız", + "huwaa_invite.open_wallet": "Çekmek için Huwaa cüzdanını aç", + "huwaa_invite.task_title": "Mikrofonu 1 dakika kullan", + "huwaa_invite.task_description": "Arkadaş biyometrik doğrulamayı tamamlar", + "huwaa_invite.medium_reward": "Orta Ödül", + "huwaa_invite.points_label": "Puan", + "huwaa_weekly_star.page_title": "Haftanın Yıldızı", + "huwaa_weekly_star.page_aria": "Huwaa Haftanın Yıldızı etkinliği", + "huwaa_weekly_star.activity_note": "Not: Sıralamaya katılmak için aşağıdaki üç hediyeyi gönderin. Liderlik tablosunda ilk 3'e giren kullanıcılar ödül kazanır.", + "huwaa_weekly_star.gifts_aria": "Etkinlik hediyeleri", + "huwaa_weekly_star.gift_label": "Etkinlik hediyesi {index}", + "huwaa_weekly_star.tabs_aria": "Haftanın Yıldızı bölümleri", + "huwaa_weekly_star.previous_top_three": "Önceki İlk 3 Kayıtları", + "huwaa_weekly_star.this_week_rank": "Bu Haftanın Sıralaması", + "huwaa_weekly_star.reward_description": "Ödül Açıklaması", + "huwaa_weekly_star.loading": "Yükleniyor…", + "huwaa_weekly_star.no_active_cycle": "Aktif dönem yok", + "huwaa_weekly_star.no_history": "Önceki sıralama kaydı yok.", + "huwaa_weekly_star.no_ranking": "Henüz sıralama verisi yok.", + "huwaa_weekly_star.no_rewards": "Henüz ödül verisi yok.", + "huwaa_weekly_star.no_rank_reward": "Ödül verisi yok.", + "huwaa_weekly_star.login_required": "Haftanın Yıldızı'nı görmek için giriş yapın.", + "huwaa_weekly_star.profile_required": "Haftanın Yıldızı'nı görmek için profilinizi tamamlayın.", + "huwaa_weekly_star.request_failed": "Haftanın Yıldızı yüklenemedi. Tekrar deneyin.", + "huwaa_weekly_star.retry": "Tekrar Dene", + "huwaa_weekly_star.unknown_user": "Kullanıcı", + "huwaa_weekly_star.rank_label": "Sıra {rank}, {name}, {score}", + "huwaa_weekly_star.reward": "Ödül", + "huwaa_weekly_star.frame": "Çerçeve", + "huwaa_weekly_star.reward_days": "{name} ({days} gün)", + "coin_seller_fami.page_title": "Coin Satıcıları", + "coin_seller_fami.page_aria": "Coin satıcıları listesi", + "coin_seller_fami.list_aria": "Mevcut coin satıcıları", + "coin_seller_fami.loading": "Coin satıcıları yükleniyor", + "coin_seller_fami.empty": "Bölgenizde kullanılabilir coin satıcısı yok.", + "coin_seller_fami.load_failed": "Coin satıcıları yüklenemedi.", + "coin_seller_fami.retry": "Tekrar Dene", + "coin_seller_fami.contact": "İletişim", + "coin_seller_fami.seller_id": "ID: {id}", + "coin_seller_fami.unknown_seller": "Satıcı", + "coin_seller_fami.avatar_label": "{name} avatarı", + "coin_seller_fami.country_flag": "{country} bayrağı", + "coin_seller_fami.contact_seller": "{name} ile iletişime geç", + "coin_seller_fami.contact_unavailable": "İletişim kullanılamıyor", + "fami_week_star.page_title": "Haftanın Yıldızı", + "fami_week_star.page_aria": "Fami Haftanın Yıldızı etkinliği", + "fami_week_star.hero_title": "Haftanın yıldızı", + "fami_week_star.note": "Not: Sıralamaya katılmak için aşağıdaki üç hediyeyi gönderin. Liderlik tablosunda ilk 3'e giren kullanıcılar ödül kazanır.", + "fami_week_star.gifts_aria": "Etkinlik hediyeleri", + "fami_week_star.tabs_aria": "Haftanın Yıldızı bölümleri", + "fami_week_star.tab_history": "Önceki İlk 3 Kayıt", + "fami_week_star.tab_weekly": "Bu Haftanın Sıralaması", + "fami_week_star.tab_rewards": "Ödül Açıklaması", + "fami_week_star.loading": "Yükleniyor...", + "fami_week_star.load_failed": "Sıralama yüklenemedi.", + "fami_week_star.unknown": "Bilinmiyor", + "fami_week_star.rank_aria": "Sıra {rank}", + "fami_week_star.entry_aria": "Sıra {rank}, {name}, {score}", + "fami_week_star.empty_weekly": "Henüz sıralama verisi yok.", + "fami_week_star.empty_history": "Önceki sıralama kaydı yok.", + "fami_week_star.gift": "Hediye", + "fami_week_star.coins": "Jetonlar", + "fami_week_star.reward": "Ödül", + "fami_week_star.reward_days": "{name} ({days} gün)", + "fami_week_star.top_rewards_aria": "İlk {rank} ödülleri", + "fami_week_star.activity_load_failed": "Haftanın Yıldızı yüklenemedi.", + "fami_week_star.no_active_cycle": "Aktif dönem yok.", + "fami_week_star.empty_rewards": "Henüz ödül verisi yok.", + "fami_week_star.mock_reward_name": "Çerçeve", + "cp.disabled": "Bu etkinlik kullanılamıyor.", + "cp.unavailable": "Veri kaynağı henüz kullanılamıyor.", + "cp.previousWeekTopTitle": "Önceki İlk 3", + "cp.previousWeekTopSubtitle": "(Önceki tamamlanan haftanın en iyi CP çiftleri.)", + "cp.rankPlace": "Sıra", + "cp.rewardTierGold": "Birincilik ödülleri", + "cp.rewardTierSilver": "İkincilik ödülleri", + "cp.rewardTierBronze": "Üçüncülük ödülleri", + "cp.rewardAvatarFrame": "Avatar çerçevesi", + "cp.rewardProfileCard": "Profil kartı", + "cp.rewardCoins": "Jeton", + "cp.rewardVehicle": "Araç", + "cp.rewardChatBubble": "Sohbet balonu", + "cp.rewardBadge": "Rozet", + "cp.rewardFloatingScreen": "Kayan ekran", + "cp.rewardGift": "Hediye", + "cp.rewardMicSeatIcon": "Mikrofon koltuğu simgesi", + "cp.rewardMicSeatAnimation": "Mikrofon koltuğu animasyonu", + "cp.rewardEmojiPack": "Emoji paketi", + "cp.rewardVipTrialCard": "VIP deneme kartı" } diff --git a/common/locales/zh.json b/common/locales/zh.json index 15a11bc..1298467 100644 --- a/common/locales/zh.json +++ b/common/locales/zh.json @@ -22,6 +22,9 @@ "roomReward.idPrefix": "ID:", "roomReward.roomOwner": "房主", "roomReward.changeLanguage": "切换语言", + "roomReward.loading": "加载中...", + "roomReward.empty": "暂无奖励档位", + "roomReward.loadFailed": "加载失败", "rechargeReward.pageTitle": "累充奖励", "rechargeReward.pageLabel": "累充奖励活动", "rechargeReward.changeLanguage": "切换语言", @@ -33,6 +36,7 @@ "rechargeReward.accumulatedRecharge": "累计充值:", "rechargeReward.rechargeTitle": "充值 {amount}", "rechargeReward.claim": "领取", + "rechargeReward.receive": "领取", "rechargeReward.claiming": "领取中...", "rechargeReward.claimed": "已领取", "rechargeReward.claimSuccess": "领取成功", @@ -698,5 +702,86 @@ "manager_center.send_badge": "赠送徽章", "manager_center.send_vip": "赠送 VIP", "manager_center.user_level": "用户等级", - "manager_center.transfer_user_country": "转移用户国家" + "manager_center.transfer_user_country": "转移用户国家", + "fami_vip.back": "返回", + "fami_vip.settings": "VIP 设置", + "fami_vip.help": "VIP 帮助", + "fami_vip.levels": "VIP 等级", + "fami_vip.exclusive_outfit": "专属装扮", + "fami_vip.vip_privileges": "VIP 权益", + "fami_vip.no_outfits": "该等级暂无专属装扮。", + "fami_vip.no_privileges": "该等级暂无权益。", + "fami_vip.outfit": "VIP 装扮", + "fami_vip.loading": "加载中...", + "fami_vip.retry": "重试", + "fami_vip.purchase_region": "购买 VIP", + "fami_vip.close": "关闭", + "fami_vip.help_title": "VIP 帮助", + "fami_vip.help_body": "当前等级的权益和购买资格均由 VIP 服务返回。", + "fami_vip.settings_title": "VIP 设置", + "fami_vip.room_entry_notice": "进房高亮通知", + "fami_vip.online_notice": "上线全服通知", + "fami_vip.unlocked": "已解锁", + "fami_vip.locked": "未解锁", + "fami_vip.days": "{count} 天", + "fami_vip.no_expiry": "无到期时间", + "fami_vip.trial_level": "体验等级:{level}", + "fami_vip.unlocked_level": "已解锁等级:{level}", + "fami_vip.valid_until": "有效期至:{date}", + "fami_vip.renew_vip": "续费 VIP", + "fami_vip.upgrade_vip": "升级 VIP", + "fami_vip.activate_vip": "开通 VIP", + "fami_vip.unavailable": "暂不可用", + "fami_vip.current_vip_higher": "高等级 VIP 生效期间不能购买更低等级。", + "fami_vip.processing": "处理中...", + "fami_vip.purchase_success": "购买成功。", + "fami_vip.purchase_failed": "购买失败。", + "fami_vip.version_changed": "VIP 配置已更新,请重试。", + "fami_vip.load_failed": "VIP 数据加载失败。", + "fami_vip.settings_saved": "设置已保存。", + "fami_vip.settings_failed": "设置保存失败。", + "huwaa_invite.page_title": "现金转盘", + "huwaa_invite.page_aria": "Huwaa 现金转盘邀请活动", + "huwaa_invite.back": "返回", + "huwaa_invite.wallet_aria": "现金转盘积分", + "huwaa_invite.points_left": "距离提现还差:", + "huwaa_invite.withdraw": "提现到钱包", + "huwaa_invite.wheel_aria": "现金转盘", + "huwaa_invite.estimated_rewards": "预计奖励", + "huwaa_invite.medium": "中等奖励", + "huwaa_invite.spin": "抽奖", + "huwaa_invite.invite_now": "立即邀请好友", + "huwaa_invite.tasks_aria": "邀请任务", + "huwaa_invite.close": "关闭", + "huwaa_invite.congratulations": "恭喜!", + "huwaa_invite.you_won": "你已获得", + "huwaa_invite.awesome": "太棒了", + "huwaa_invite.completed": "已完成", + "huwaa_invite.go": "去完成", + "huwaa_invite.reward": "奖励", + "huwaa_invite.login_required": "请先登录", + "huwaa_invite.request_failed": "请求失败", + "huwaa_invite.open_app_task": "请在 Huwaa 中完成该任务", + "huwaa_invite.invite_unavailable": "邀请链接暂不可用", + "huwaa_invite.copied": "已复制", + "huwaa_invite.copy_failed": "复制失败", + "huwaa_invite.open_wallet": "请打开 Huwaa 钱包提现", + "huwaa_invite.task_title": "使用麦克风 1 分钟", + "huwaa_invite.task_description": "好友完成生物识别认证", + "huwaa_invite.medium_reward": "中等奖励", + "huwaa_invite.points_label": "积分", + "coin_seller_fami.page_title": "金币商家", + "coin_seller_fami.page_aria": "金币商家列表", + "coin_seller_fami.list_aria": "可联系的金币商家", + "coin_seller_fami.loading": "正在加载金币商家", + "coin_seller_fami.empty": "你所在区域暂无可用金币商家。", + "coin_seller_fami.load_failed": "金币商家加载失败。", + "coin_seller_fami.retry": "重试", + "coin_seller_fami.contact": "联系", + "coin_seller_fami.seller_id": "ID:{id}", + "coin_seller_fami.unknown_seller": "商家", + "coin_seller_fami.avatar_label": "{name}的头像", + "coin_seller_fami.country_flag": "{country}国旗", + "coin_seller_fami.contact_seller": "联系{name}", + "coin_seller_fami.contact_unavailable": "暂时无法联系" } diff --git a/common/params.js b/common/params.js index e49387e..5b3ee67 100644 --- a/common/params.js +++ b/common/params.js @@ -38,7 +38,9 @@ function decodeBase64URLJSON(value) { try { - var normalized = String(value || '').replace(/-/g, '+').replace(/_/g, '/'); + var normalized = String(value || '') + .replace(/-/g, '+') + .replace(/_/g, '/'); while (normalized.length % 4) normalized += '='; return JSON.parse(window.atob(normalized)); } catch (_) { @@ -60,6 +62,11 @@ var token = normalizeToken(first(params, TOKEN_KEYS)); var appCode = first(params, APP_CODE_KEYS) || + // 多产品入口会在 common 脚本执行前声明页面默认租户;它必须优先于历史 localStorage, + // 否则用户先访问 Lalu 后再打开无 app_code 的 Fami 深链会把请求错误地切回 Lalu。 + String(window.HyAppDefaultAppCode || '') + .trim() + .toLowerCase() || window.localStorage.getItem('hyapp_app_code') || 'lalu'; diff --git a/guild/fami/README.md b/guild/fami/README.md index 8a9e41b..d1c635f 100644 --- a/guild/fami/README.md +++ b/guild/fami/README.md @@ -1,12 +1,17 @@ # Fami guild page entries -This product will expose four independent static entries: +This product exposes four independent static entries: - `host-center/` - `agency-center/` - `bd-center/` - `wallet/` -Entries are intentionally thin. Shared business logic belongs in `guild/packages//`; Fami-specific configuration and visual overrides belong in `guild/products/fami/`. +The Figma-restored Center shell, traced layer assets, and shared page runtime live in +`guild/products/fami/centers/`. Business API contracts remain in +`guild/packages//`, while Fami product configuration and product locales +remain in `guild/products/fami/`. -Do not copy scripts from `gonghui/` wholesale. Reuse existing backend semantics through a feature adapter and keep any Fami response differences additive and explicit. +`gonghui/` is a separate Lalu product surface and must not be used as the Fami +runtime route. Reuse backend semantics through the feature adapters and keep any +Fami response differences additive and explicit. diff --git a/guild/fami/agency-center/index.html b/guild/fami/agency-center/index.html index 6178b81..5d9826d 100644 --- a/guild/fami/agency-center/index.html +++ b/guild/fami/agency-center/index.html @@ -1,422 +1,325 @@ - + - Agency Center + Agency - + - - -
-
+
+ + +
+

+ agency +

+ -
-
-
- - -
-
- - Agency ID: — - Created: — +
+
+ +
+ - + User ID: - + Welcome -
-
- -
- WhatsApp -
- -
+ -
-
- Diamond balance - +
+ Gold Coins : +
+ + 0
-
-
-

- Agency data -

- -
-
- - -
-
+ - - - - - - -
- + Gold + + +
-
-
-
- Total earnings -
-
- Gift income -
-
- Share income -
-
- Total hosts -
-
- Gifted hosts -
-
-
+
+
+
+ +
-
- -
-

Edit WhatsApp

- -
+ + + + + - -
- - -
-

- Custom date range -

- - -
- -
-
-
- - -
-

Add host

-

- Enter the host display ID. The host joins after - accepting the invitation. -

- -
- -
-
-
- - -
-

Remove host

-

-
- -
-
-
+ + +
+
- + + + - - + diff --git a/guild/fami/agency-center/script.js b/guild/fami/agency-center/script.js index f8185b8..3b784a6 100644 --- a/guild/fami/agency-center/script.js +++ b/guild/fami/agency-center/script.js @@ -1,437 +1,618 @@ (function () { - 'use strict'; - + var ui = window.FamiCenterUI; + var fallbackAvatar = + '../../products/fami/centers/assets/fami/agency-avatar.png'; var state = { - range: null, - data: null, - tab: 'agency', - loading: false, - mutating: false, - removingHost: null, + loading: true, + profile: null, + owner: null, + balance: 0, + stats: null, + hosts: [], + dailyHosts: {}, + activeAsset: 'diamond', + activeDate: '', + view: 'summary', + inviteProfile: null, + inviting: false, + statsRequestSequence: 0, + dailyRequestSequence: 0, + }; + var dates = []; + var contactController = null; + + var summaryDefinitions = [ + { + key: 'total_earnings', + label: 'fami.total_earnings', + fallback: 'Total Earnings', + }, + { + key: 'gift_income', + label: 'fami.gift_income', + fallback: 'Gift Income', + }, + { + key: 'share_income', + label: 'fami.share_income', + fallback: 'Share Income', + }, + { + key: 'total_hosts', + label: 'fami.total_hosts', + fallback: 'Total Hosts', + }, + { + key: 'gifted_host_count', + label: 'fami.gifted_hosts', + fallback: 'Gifted Hosts', + }, + ]; + + var mockHosts = Array.from({ length: 5 }, function (_, index) { + return { + membership_id: 'membership-' + index, + host_user_id: 'host-' + index, + display_user_id: '12345' + (index + 10), + username: 'host ' + (index + 1), + avatar: fallbackAvatar, + diamond_earnings: 123214, + }; + }); + + var mock = { + user: { + user_id: 'agency-owner-1', + display_user_id: '123456789', + username: 'konghuimingc', + avatar: fallbackAvatar, + contact_info: '+65 1234 5678', + }, + overview: { + agency: { + agency_id: '123456789', + name: 'konghuimingc', + avatar: fallbackAvatar, + created_at_ms: Date.UTC(2099, 8, 9), + }, + }, + coins: { + balances: [{ asset_type: 'COIN', available_amount: 1000000000 }], + }, + stats: { + summary: { + total_earnings: 999999, + gift_income: 999999, + share_income: 999999, + total_hosts: 999999, + gifted_host_count: 999999, + }, + hosts: mockHosts, + }, }; - function $(id) { - return document.getElementById(id); + function profileForOverview(payload, owner) { + var data = ui.unwrap(payload) || {}; + var agency = data.agency || {}; + return { + id: String(agency.agency_id || ''), + displayId: (owner && owner.displayId) || '', + name: String(agency.name || 'Agency'), + avatar: String(agency.avatar || ''), + createdAt: agency.created_at_ms || 0, + }; } - function t(key, fallback) { - return window.HyAppI18n && window.HyAppI18n.t - ? window.HyAppI18n.t(key, fallback) - : fallback || key; + function normalizeHost(item) { + item = item || {}; + return { + id: String(item.host_user_id || ''), + membershipId: String(item.membership_id || ''), + displayId: String(item.display_user_id || ''), + name: String(item.username || 'Host'), + avatar: String(item.avatar || ''), + diamondEarnings: ui.number(item.diamond_earnings), + }; } - function toast(message) { - if (window.HyAppToast) window.HyAppToast.show(message); - } - - function count(value) { - if (value === null || value === undefined || !Number.isFinite(value)) { - return '—'; - } - return Number(value).toLocaleString(undefined, { - maximumFractionDigits: 0, + function summaryValues() { + var summary = (state.stats && state.stats.summary) || {}; + return summaryDefinitions.map(function (definition) { + return ui.number(summary[definition.key]); }); } - function duration(value) { - if (!Number.isFinite(value)) return '—'; - var minutes = Math.max(0, Math.floor(Number(value) / 60000)); - var hours = Math.floor(minutes / 60); - var rest = minutes % 60; - if (!hours) return rest + t('fami_unit.minute_short', 'm'); - return ( - hours + - t('fami_unit.hour_short', 'h') + - (rest ? ' ' + rest + t('fami_unit.minute_short', 'm') : '') + function applyStats(payload, date) { + var stats = ui.unwrap(payload) || { summary: {}, hosts: [] }; + var hosts = Array.isArray(stats.hosts) + ? stats.hosts.map(normalizeHost) + : []; + state.stats = stats; + state.hosts = hosts; + if (date) state.dailyHosts[date] = hosts; + } + + function hostValue(host, index) { + var date = dates[index] && dates[index].value; + var daily = state.dailyHosts[date] || []; + var item = daily.find(function (candidate) { + return candidate.id === host.id; + }); + return item ? item.diamondEarnings : 0; + } + + function renderProfile() { + var profile = state.profile || {}; + var owner = state.owner || {}; + ui.setAvatar(ui.$('agencyAvatar'), profile.avatar, fallbackAvatar); + ui.$('agencyName').textContent = profile.name || '-'; + ui.$('agencyUserId').textContent = ui + .t('fami.user_id', 'User ID:{id}') + .replace('{id}', profile.displayId || '-'); + var welcomeDate = profile.createdAt + ? new Date(profile.createdAt) + : null; + var welcome = ''; + if (welcomeDate && !Number.isNaN(welcomeDate.getTime())) { + // Figma 固定使用点分隔日期;字段值仍来自 agency.created_at_ms,不由客户端推断公会成立时间。 + welcome = [ + welcomeDate.getFullYear(), + String(welcomeDate.getMonth() + 1).padStart(2, '0'), + String(welcomeDate.getDate()).padStart(2, '0'), + ].join('.'); + } + if (ui.isMock()) welcome = '2099.99.99'; + ui.$('agencyWelcome').textContent = ui + .t('fami.welcome_date', 'Welcome {date}') + .replace('{date}', welcome || '-'); + ui.$('contactName').textContent = owner.name || profile.name || '-'; + ui.$('contactUserId').textContent = ui + .t('fami.user_id', 'User ID:{id}') + .replace('{id}', owner.displayId || profile.displayId || '-'); + if (contactController) + contactController.setContact(owner.contact || ''); + } + + function renderDates() { + dates = ui.dateWindow(9); + if (!state.activeDate && dates.length) + state.activeDate = dates[0].value; + ui.renderDateStrip( + ui.$('dateStrip'), + dates.slice(0, 7), + state.activeDate, + selectDate ); } - function dateTime(value) { - if (!Number(value)) return '—'; - return new Date(Number(value)).toLocaleString(undefined, { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - }); - } - - function renderIdentity() { - if (!state.data) return; - var agency = state.data.agency || {}; - $('agencyName').textContent = agency.name || '—'; - $('agencyID').textContent = - t('fami_agency.id_prefix', 'Agency ID:') + - ' ' + - (agency.shortId || agency.id || '—'); - $('createdAt').textContent = - t('fami_agency.created_prefix', 'Created:') + - ' ' + - dateTime(agency.createdAtMS); - $('agencyInitial').textContent = String(agency.name || 'A') - .trim() - .charAt(0) - .toUpperCase(); - $('agencyAvatar').hidden = !agency.avatar; - $('agencyInitial').hidden = !!agency.avatar; - if (agency.avatar) $('agencyAvatar').src = agency.avatar; - $('contactValue').textContent = state.data.contact || '—'; - $('diamondBalance').textContent = count(state.data.pointBalance); - } - - function renderSummary() { - var summary = - state.data && state.data.stats - ? state.data.stats.summary || {} - : {}; - $('totalEarnings').textContent = count(summary.total_earnings); - $('giftIncome').textContent = count(summary.gift_income); - $('shareIncome').textContent = count(summary.share_income); - $('totalHosts').textContent = count(summary.total_hosts); - $('giftedHosts').textContent = count(summary.gifted_host_count); - var statsError = state.data && state.data.errors.stats; - $('statsStatus').hidden = !statsError; - if (statsError) { - $('statsStatus').textContent = t( - 'fami_common.stats_unavailable', - 'Statistics are temporarily unavailable.' - ); - } - } - - function hostAvatar(host) { - var shell = document.createElement('span'); - shell.className = 'host-avatar'; - if (host.avatar) { - var image = document.createElement('img'); - image.src = host.avatar; - image.alt = ''; - shell.appendChild(image); - } else { - shell.textContent = String(host.name || 'H') - .charAt(0) - .toUpperCase(); - } - return shell; - } - - function textCell(row, value) { - var cell = document.createElement('td'); - cell.textContent = value; - row.appendChild(cell); - } - - function renderHosts() { - var hosts = - state.data && state.data.stats ? state.data.stats.hosts || [] : []; - var body = $('hostTableBody'); - body.textContent = ''; - hosts.forEach(function (host) { - var row = document.createElement('tr'); - var identityCell = document.createElement('td'); - var identity = document.createElement('div'); - identity.className = 'host-identity'; - identity.appendChild(hostAvatar(host)); - var copy = document.createElement('span'); - copy.className = 'host-copy'; - var name = document.createElement('strong'); - name.textContent = host.name || '—'; - var id = document.createElement('span'); - id.textContent = - t('fami_common.uid_prefix', 'UID:') + - ' ' + - (host.displayUserId || host.userId || '—'); - copy.appendChild(name); - copy.appendChild(id); - identity.appendChild(copy); - identityCell.appendChild(identity); - row.appendChild(identityCell); - textCell(row, count(host.diamondEarnings)); - textCell(row, count(host.diamondExchanged)); - textCell(row, count(host.giftSenders)); - textCell(row, duration(host.onlineDurationMS)); - textCell(row, duration(host.validMicDurationMS)); - textCell(row, count(host.validMicDays)); - textCell(row, dateTime(host.joinedAtMS)); - var actionCell = document.createElement('td'); - var remove = document.createElement('button'); - remove.type = 'button'; - remove.className = 'remove-button'; - if (host.removable) { - remove.setAttribute('data-remove-host', host.userId); - remove.textContent = t('fami_agency.remove', 'Remove'); - } else { - remove.disabled = true; - remove.textContent = t('fami_agency.owner', 'Owner'); + function renderMetrics() { + var grid = ui.$('metricGrid'); + grid.replaceChildren(); + summaryValues().forEach(function (value, index) { + var card = document.createElement('div'); + card.className = 'agency-metric'; + var valueRow = document.createElement('div'); + valueRow.className = 'agency-metric__value'; + if (index < 3) { + var icon = document.createElement('img'); + icon.src = + '../../products/fami/centers/assets/fami/diamond.png'; + icon.alt = ''; + valueRow.appendChild(icon); } - actionCell.appendChild(remove); - row.appendChild(actionCell); - body.appendChild(row); + var number = document.createElement('strong'); + number.textContent = ui.formatNumber(value).replace(/,/g, ''); + valueRow.appendChild(number); + var definition = summaryDefinitions[index]; + var label = document.createElement('span'); + label.textContent = ui.isMock() + ? ui.t('fami.total_return', 'Total Return') + : ui.t(definition.label, definition.fallback); + card.appendChild(valueRow); + card.appendChild(label); + grid.appendChild(card); }); - $('hostEmpty').hidden = hosts.length > 0; - $('hostCountLabel').textContent = t( - 'fami_agency.host_count', - '{count} hosts' - ).replace('{count}', count(hosts.length)); - } - - function renderRange() { - document.querySelectorAll('[data-range]').forEach(function (button) { - button.setAttribute( - 'aria-pressed', - button.getAttribute('data-range') === state.range.preset - ? 'true' - : 'false' - ); - button.disabled = state.loading; - }); - $('rangeCaption').textContent = - state.range.startDate === state.range.endDate - ? state.range.startDate - : state.range.startDate + ' – ' + state.range.endDate; } function renderTabs() { - document.querySelectorAll('[data-tab]').forEach(function (button) { - button.setAttribute( - 'aria-selected', - button.getAttribute('data-tab') === state.tab ? 'true' : 'false' + document.querySelectorAll('.agency-tab').forEach(function (tab) { + var active = tab.getAttribute('data-type') === state.activeAsset; + tab.classList.toggle('is-active', active); + tab.setAttribute('aria-selected', String(active)); + }); + } + + function renderHostTable() { + var hostTable = ui.$('hostTable'); + // mock 模式固定复现 Figma 已输入的搜索态,但不能让占位 ID 把设计稿中的示例行过滤掉;真实入口仍按用户提交值筛选。 + var query = ui.isMock() + ? '' + : String(ui.$('hostSearchInput').value || '') + .trim() + .toLowerCase(); + var hosts = state.hosts.filter(function (host) { + if (!query) return true; + return ( + host.displayId.toLowerCase().includes(query) || + host.name.toLowerCase().includes(query) ); }); - $('agencyPanel').hidden = state.tab !== 'agency'; - $('hostsPanel').hidden = state.tab !== 'hosts'; - $('dataTitle').textContent = - state.tab === 'agency' - ? t('fami_agency.tab_agency', 'Agency data') - : t('fami_agency.tab_hosts', 'Host data'); + var grid = document.createElement('div'); + grid.className = 'agency-host-table__grid'; + var header = document.createElement('div'); + header.className = 'agency-host-table__header'; + dates.slice(0, 9).forEach(function (date) { + var head = document.createElement('span'); + head.className = 'agency-host-table__head'; + head.textContent = ui.isMock() + ? ui.t('fami.today', 'Today') + : date.label; + head.setAttribute('aria-label', date.label + ' ' + date.value); + header.appendChild(head); + }); + var rows = document.createElement('div'); + rows.className = 'agency-host-table__rows'; + hosts.forEach(function (host) { + var avatar = document.createElement('img'); + avatar.className = 'agency-host-table__avatar'; + avatar.alt = ''; + ui.setAvatar(avatar, host.avatar, fallbackAvatar); + rows.appendChild(avatar); + dates.slice(0, 8).forEach(function (_, index) { + var value = document.createElement('span'); + value.className = 'agency-host-table__value'; + value.textContent = ui + .formatNumber(hostValue(host, index)) + .replace(/,/g, ''); + rows.appendChild(value); + }); + }); + grid.appendChild(header); + grid.appendChild(rows); + hostTable.replaceChildren(grid); } - function renderAll() { - renderIdentity(); - renderSummary(); - renderHosts(); - renderRange(); + function renderView() { + ui.$('summaryView').hidden = state.view !== 'summary'; + ui.$('hostView').hidden = state.view !== 'hosts'; + if (state.view === 'hosts') renderHostTable(); + } + + function render() { + renderProfile(); + ui.$('goldBalance').textContent = ui.formatNumber(state.balance); renderTabs(); + renderDates(); + renderMetrics(); + renderView(); } - function setLoading(value) { - state.loading = value; - document.body.classList.toggle('is-loading', value); - renderRange(); + function applyPayloads( + userPayload, + overviewPayload, + coinPayload, + statsPayload + ) { + state.owner = ui.profileFrom(userPayload, 'Agency'); + state.profile = profileForOverview(overviewPayload, state.owner); + state.balance = ui.walletAvailable(coinPayload, 'COIN'); + applyStats(statsPayload, state.activeDate); + state.loading = false; + render(); } - function setMutating(value) { - state.mutating = value; - document.body.classList.toggle('is-mutating', value); + function selectDate(value) { + state.activeDate = value; + renderDates(); + if (ui.isMock()) { + applyStats(mock.stats, value); + renderMetrics(); + return; + } + var api = window.HyAppAPI && window.HyAppAPI.agencyCenter; + if (!api || !api.stats) return; + var sequence = ++state.statsRequestSequence; + // 汇总卡必须与当前选中 UTC 自然日一致;迟到响应只写入其日期缓存,不覆盖后来选择。 + api.stats({ start_date: value, end_date: value }) + .then(function (payload) { + if (sequence !== state.statsRequestSequence) return; + applyStats(payload, value); + renderMetrics(); + }) + .catch(function (error) { + if (sequence !== state.statsRequestSequence) return; + ui.toast( + (error && error.message) || + ui.t( + 'agency_center.load_failed', + 'Load failed. Try again later.' + ) + ); + }); + } + + function loadDailyMatrix() { + if (ui.isMock()) { + dates.slice(0, 8).forEach(function (date) { + state.dailyHosts[date.value] = mockHosts.map(normalizeHost); + }); + renderHostTable(); + return; + } + var api = window.HyAppAPI && window.HyAppAPI.agencyCenter; + if (!api || !api.stats) return; + var sequence = ++state.dailyRequestSequence; + // 矩阵每列是一个自然日,现有 stats 契约按区间聚合;并行读取 8 个单日且独立容错,绝不把某日聚合值复制到其他日期。 + Promise.all( + dates.slice(0, 8).map(function (date) { + return ui.safe('agency-stats-' + date.value, function () { + return api.stats({ + start_date: date.value, + end_date: date.value, + }); + }); + }) + ).then(function (payloads) { + if (sequence !== state.dailyRequestSequence) return; + payloads.forEach(function (payload, index) { + var day = dates[index] && dates[index].value; + var data = ui.unwrap(payload) || {}; + state.dailyHosts[day] = Array.isArray(data.hosts) + ? data.hosts.map(normalizeHost) + : []; + }); + renderHostTable(); + }); } function load() { - setLoading(true); - return window.HyGuild.agencyCenter - .load(state.range) - .then(function (data) { - state.data = data; - renderAll(); - }) - .catch(function () { - toast( - t( - 'fami_agency.load_failed', - 'Unable to load Agency Center.' - ) - ); - $('statsStatus').hidden = false; - $('statsStatus').textContent = t( - 'fami_agency.load_failed', - 'Unable to load Agency Center.' - ); - }) - .finally(function () { - setLoading(false); - }); - } - - function selectRange(preset) { - if (preset === 'custom') { - $('rangeStart').value = state.range.startDate; - $('rangeEnd').value = state.range.endDate; - $('rangeDialog').showModal(); + dates = ui.dateWindow(9); + if (!state.activeDate && dates.length) + state.activeDate = dates[0].value; + if (ui.isMock()) { + applyPayloads(mock.user, mock.overview, mock.coins, mock.stats); return; } - state.range = window.HyGuild.dateRange.create(preset); - renderRange(); - load(); - } - - function applyCustomRange(event) { - event.preventDefault(); - try { - state.range = window.HyGuild.dateRange.create('custom', { - start: $('rangeStart').value, - end: $('rangeEnd').value, - }); - } catch (_) { - toast(t('fami_range.invalid', 'Choose a valid date range.')); + var api = window.HyAppAPI; + if (!api || !api.agencyCenter || !api.agencyCenter.stats) { + state.loading = false; + render(); + ui.toast( + ui.t('agency_center.api_not_ready', 'API module is not ready.') + ); return; } - $('rangeDialog').close(); - load(); - } - function saveContact(event) { - event.preventDefault(); - if (state.mutating) return; - var value = String($('contactInput').value || '').trim(); - if (!value) { - toast(t('fami_agency.contact_required', 'Enter a contact number.')); - return; - } - setMutating(true); - window.HyGuild.agencyCenter - .saveContact(value) - .then(function () { - state.data.contact = value; - $('contactDialog').close(); - renderIdentity(); - toast(t('fami_agency.contact_saved', 'Contact saved.')); - }) - .catch(function () { - toast( - t( - 'fami_agency.contact_save_failed', - 'Unable to save contact.' + // 公会资料、负责人、COIN 余额和日统计来自明确接口;旧 salary 字段只代表 USD 工资,不能继续冒充 Gold Coins。 + Promise.all([ + ui.safe('agency-owner', function () { + return api.user.me(); + }), + ui.safe('agency-overview', function () { + return api.agencyCenter.overview(); + }), + ui.safe('agency-coins', function () { + return api.wallet.balances('COIN'); + }), + ui.safe('agency-stats', function () { + return api.agencyCenter.stats({ + start_date: state.activeDate, + end_date: state.activeDate, + }); + }), + ]).then(function (results) { + applyPayloads(results[0], results[1], results[2], results[3]); + if (!results.some(Boolean)) { + ui.toast( + ui.t( + 'agency_center.load_failed', + 'Load failed. Try again later.' ) ); + } + }); + } + + function openWithdraw() { + // 资金页必须由产品上下文构造 URL,保证无 app_code 参数的旧客户端仍使用 Fami 租户。 + window.location.href = window.HyGuild.product.buildURL('../wallet/', { + identity: 'AGENCY', + }); + } + + function setInviteStatus(message, success) { + var node = ui.$('inviteStatus'); + node.textContent = message || ''; + node.hidden = !message; + node.classList.toggle('is-success', !!success); + } + + function normalizeInviteProfile(payload, fallbackId) { + var profile = ui.profileFrom(payload, 'Host'); + if (!profile.id) return null; + if (!profile.displayId) profile.displayId = fallbackId; + return profile; + } + + function renderInviteResult() { + var root = ui.$('inviteResult'); + root.replaceChildren(); + if (!state.inviteProfile) return; + var row = document.createElement('div'); + row.className = 'fami-search-result'; + var avatar = document.createElement('img'); + avatar.alt = ''; + ui.setAvatar(avatar, state.inviteProfile.avatar, fallbackAvatar); + var copy = document.createElement('div'); + copy.className = 'fami-search-result__copy'; + var name = document.createElement('strong'); + name.textContent = state.inviteProfile.name || 'Host'; + var id = document.createElement('span'); + id.textContent = 'ID: ' + state.inviteProfile.displayId; + var action = document.createElement('button'); + action.className = 'fami-search-result__action'; + action.type = 'button'; + action.disabled = state.inviting; + action.textContent = state.inviting + ? ui.t('agency_center.pending', 'Pending') + : ui.t('agency_center.invite', 'Invite'); + action.addEventListener('click', inviteHost); + copy.appendChild(name); + copy.appendChild(id); + row.appendChild(avatar); + row.appendChild(copy); + row.appendChild(action); + root.appendChild(row); + } + + function searchInvite(event) { + event.preventDefault(); + var value = String(ui.$('inviteSearchInput').value || '').trim(); + state.inviteProfile = null; + renderInviteResult(); + if (!value) return; + if (ui.isMock()) { + state.inviteProfile = ui.profileFrom(mock.user, 'Host'); + state.inviteProfile.id = 'mock-host-' + value; + state.inviteProfile.displayId = value; + renderInviteResult(); + return; + } + var api = window.HyAppAPI && window.HyAppAPI.user; + if (!api || !api.resolveDisplayUserID) { + setInviteStatus( + ui.t('agency_center.api_not_ready', 'API module is not ready.') + ); + return; + } + ui.$('inviteSearchButton').disabled = true; + setInviteStatus(ui.t('agency_center.searching', 'Searching...')); + api.resolveDisplayUserID(value) + .then(function (payload) { + state.inviteProfile = normalizeInviteProfile(payload, value); + setInviteStatus( + state.inviteProfile + ? '' + : ui.t('agency_center.no_users_found', 'No users found') + ); + renderInviteResult(); + }) + .catch(function (error) { + setInviteStatus( + (error && error.message) || + ui.t( + 'agency_center.search_failed', + 'Search failed. Try again later.' + ) + ); }) .finally(function () { - setMutating(false); + ui.$('inviteSearchButton').disabled = false; }); } - function inviteHost(event) { - event.preventDefault(); - if (state.mutating) return; - var displayID = String($('inviteHostID').value || '').trim(); - if (!displayID) return; - setMutating(true); - window.HyGuild.agencyCenter - .inviteHost(displayID) + function inviteHost() { + if (!state.inviteProfile || state.inviting) return; + if (ui.isMock()) { + setInviteStatus( + ui.t( + 'agency_center.invite_sent', + 'Invite sent. Waiting for confirmation.' + ), + true + ); + return; + } + var api = window.HyAppAPI && window.HyAppAPI.agencyCenter; + if (!api || !api.inviteHost) return; + state.inviting = true; + renderInviteResult(); + // 邀请使用唯一 command_id 交给服务端幂等;结果未确认前禁用按钮,避免 WebView 连点产生多份待确认邀请。 + api.inviteHost({ + command_id: ui.commandId('agency-invite-host'), + target_user_id: state.inviteProfile.id, + }) .then(function () { - $('inviteDialog').close(); - toast( - t( - 'fami_agency.invite_sent', - 'Invitation sent. The host must accept it.' - ) + setInviteStatus( + ui.t( + 'agency_center.invite_sent', + 'Invite sent. Waiting for confirmation.' + ), + true + ); + state.inviteProfile = null; + renderInviteResult(); + }) + .catch(function (error) { + setInviteStatus( + (error && error.message) || + ui.t( + 'agency_center.invite_failed', + 'Invite failed. Try again later.' + ) ); }) - .catch(function () { - toast(t('fami_agency.invite_failed', 'Unable to invite host.')); - }) .finally(function () { - setMutating(false); + state.inviting = false; + renderInviteResult(); }); } - function openRemove(hostUserID) { - var hosts = state.data.stats.hosts || []; - state.removingHost = hosts.find(function (host) { - return host.userId === hostUserID; - }); - if (!state.removingHost) return; - $('removeMessage').textContent = t( - 'fami_agency.remove_confirm', - 'Remove {name} from this agency? Historical earnings will remain unchanged.' - ).replace('{name}', state.removingHost.name || hostUserID); - $('removeDialog').showModal(); - } - - function removeHost(event) { - event.preventDefault(); - if (state.mutating || !state.removingHost) return; - var target = state.removingHost; - setMutating(true); - window.HyGuild.agencyCenter - .removeHost(target.userId) - .then(function () { - // 成员关系由后端原子结束;成功后重拉统计,不能只在本地删行伪造 total_hosts。 - $('removeDialog').close(); - state.removingHost = null; - toast(t('fami_agency.host_removed', 'Host removed.')); - return load(); - }) - .catch(function () { - toast(t('fami_agency.remove_failed', 'Unable to remove host.')); - }) - .finally(function () { - setMutating(false); - }); - } - - function bind() { - $('backButton').addEventListener('click', function () { - if (window.history.length > 1) window.history.back(); - else if (window.HyAppBridge) window.HyAppBridge.back(); - }); - $('withdrawButton').addEventListener('click', function () { - window.location.href = - window.HyGuild.product.buildURL('../wallet/'); - }); - $('editContactButton').addEventListener('click', function () { - $('contactInput').value = (state.data && state.data.contact) || ''; - $('contactDialog').showModal(); - }); - $('cancelContactButton').addEventListener('click', function () { - $('contactDialog').close(); - }); - $('contactForm').addEventListener('submit', saveContact); - document - .querySelector('.data-tabs') - .addEventListener('click', function (event) { - var button = event.target.closest('[data-tab]'); - if (!button) return; - state.tab = button.getAttribute('data-tab'); - renderTabs(); - }); - $('rangeFilters').addEventListener('click', function (event) { - var button = event.target.closest('[data-range]'); - if (button && !state.loading) - selectRange(button.getAttribute('data-range')); - }); - $('cancelRangeButton').addEventListener('click', function () { - $('rangeDialog').close(); - }); - $('rangeForm').addEventListener('submit', applyCustomRange); - $('addHostButton').addEventListener('click', function () { - $('inviteHostID').value = ''; - $('inviteDialog').showModal(); - }); - $('cancelInviteButton').addEventListener('click', function () { - $('inviteDialog').close(); - }); - $('inviteForm').addEventListener('submit', inviteHost); - $('hostTableBody').addEventListener('click', function (event) { - var button = event.target.closest('[data-remove-host]'); - if (button) openRemove(button.getAttribute('data-remove-host')); - }); - $('cancelRemoveButton').addEventListener('click', function () { - $('removeDialog').close(); - }); - $('removeForm').addEventListener('submit', removeHost); - window.addEventListener('hyapp:i18n-ready', renderAll); + function openInvite() { + ui.$('hostSearchInput').value = ui.isMock() ? '1235456' : ''; + state.view = 'hosts'; + renderView(); + loadDailyMatrix(); + ui.openModal('inviteModal'); + ui.$('inviteSearchInput').value = ''; + state.inviteProfile = null; + setInviteStatus(''); + renderInviteResult(); } function init() { - state.range = window.HyGuild.dateRange.create('today'); - bind(); - renderRange(); + contactController = ui.bindContact(); + ui.$('backButton').addEventListener('click', function () { + if (state.view === 'hosts') { + state.view = 'summary'; + renderView(); + return; + } + ui.goBack(); + }); + ui.$('withdrawButton').addEventListener('click', openWithdraw); + ui.$('addHostButton').addEventListener('click', openInvite); + ui.$('inviteSearchForm').addEventListener('submit', searchInvite); + ui.$('hostSearchForm').addEventListener('submit', function (event) { + event.preventDefault(); + renderHostTable(); + }); + document + .querySelectorAll('[data-invite-close]') + .forEach(function (node) { + node.addEventListener('click', function () { + ui.closeModal('inviteModal'); + }); + }); + document + .querySelectorAll('.agency-tab:not([disabled])') + .forEach(function (tab) { + tab.addEventListener('click', function () { + state.activeAsset = tab.getAttribute('data-type'); + renderTabs(); + }); + }); + window.addEventListener('hyapp:i18n-ready', render); + render(); load(); + if (window.HyAppBridge) { + window.HyAppBridge.ready({ + page: 'agency-center', + app_code: 'fami', + mock: ui.isMock(), + }); + } } - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); - } else { - init(); - } + document.addEventListener('DOMContentLoaded', init); })(); diff --git a/guild/fami/agency-center/style.css b/guild/fami/agency-center/style.css index 28152ba..71bef9d 100644 --- a/guild/fami/agency-center/style.css +++ b/guild/fami/agency-center/style.css @@ -1,566 +1,246 @@ -* { - box-sizing: border-box; +.agency-profile .fami-profile__copy { + width: min(100%, 210px); } -html, -body { - min-height: 100%; - margin: 0; - background: var(--hy-theme-bg); - color: var(--hy-theme-text); - font-family: - Inter, - -apple-system, - BlinkMacSystemFont, - 'Segoe UI', - sans-serif; +.agency-profile .fami-profile__note { + color: var(--fami-text); } -button, -input { - font: inherit; +.agency-contact .fami-contact__copy { + max-width: 240px; } -button { - cursor: pointer; -} - -.center-header { - position: sticky; - z-index: 20; - top: 0; - display: grid; - grid-template-columns: 48px 1fr 48px; - align-items: center; - min-height: calc(64px + env(safe-area-inset-top)); - padding: env(safe-area-inset-top) 12px 0; - border-bottom: 1px solid var(--hy-theme-line); - background: color-mix(in srgb, var(--hy-theme-bg) 94%, transparent); - backdrop-filter: blur(16px); -} - -.center-header h1 { - margin: 0; - font-size: 22px; - text-align: center; -} - -.icon-button, -.language-button { - display: grid; - width: 44px; - height: 40px; - place-items: center; - border: 0; - border-radius: var(--hy-theme-radius-language); - background: var(--hy-theme-surface); - color: var(--hy-theme-text); - box-shadow: var(--hy-theme-shadow); -} - -.icon-button span { - font-size: 38px; - font-weight: 300; - line-height: 30px; -} - -.language-switcher { - position: relative; -} - -.language-button { - font-size: 13px; - font-weight: 800; -} - -.language-menu { +.agency-tabs { position: absolute; - top: 46px; - right: 0; - overflow: hidden; - width: 64px; - border: 1px solid var(--hy-theme-line); - border-radius: var(--hy-theme-radius-small); - background: var(--hy-theme-surface); - box-shadow: var(--hy-theme-modal-shadow); -} - -[dir='rtl'] .language-menu { - right: auto; + top: 384px; left: 0; -} - -.language-menu button { - width: 100%; - padding: 10px; - border: 0; - background: transparent; - color: var(--hy-theme-text); -} - -.language-menu button.is-active { - background: var(--hy-theme-primary-soft); - color: var(--hy-theme-primary-deep); -} - -.agency-profile-card, -.contact-card, -.balance-card { display: flex; align-items: center; - padding: 18px; + gap: 43px; + width: 172px; + height: 44px; + padding: 0 12px 0 12px; } -.agency-avatar { - display: grid; - flex: 0 0 76px; - overflow: hidden; - width: 76px; - height: 76px; - place-items: center; - border: 3px solid var(--hy-theme-primary); - border-radius: 50%; - background: var(--hy-theme-primary-soft); - color: var(--hy-theme-primary-deep); - font-size: 30px; - font-weight: 800; -} - -.agency-avatar img { - width: 100%; - height: 100%; - object-fit: cover; -} - -.agency-profile-copy { - display: flex; - min-width: 0; - flex-direction: column; - gap: 5px; - margin-inline-start: 16px; -} - -.agency-profile-copy strong { - overflow: hidden; - font-size: 21px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.agency-profile-copy span { - color: var(--hy-theme-muted); - font-size: 13px; -} - -.contact-card { - gap: 14px; -} - -.contact-icon { - display: grid; - width: 48px; - height: 48px; - place-items: center; - border-radius: 50%; - background: color-mix( - in srgb, - var(--hy-theme-success) 13%, - var(--hy-theme-surface) - ); - color: var(--hy-theme-success); -} - -.contact-card > div { - display: flex; - min-width: 0; - flex: 1; - flex-direction: column; - gap: 3px; -} - -.contact-card > div span { - overflow: hidden; - color: var(--hy-theme-muted); - text-overflow: ellipsis; - white-space: nowrap; -} - -.contact-card > button { - border: 0; - background: transparent; - color: var(--hy-theme-primary-deep); - font-weight: 750; -} - -.balance-card { - justify-content: space-between; - gap: 18px; -} - -.balance-card > div { - display: flex; - min-width: 0; - flex-direction: column; - gap: 8px; -} - -.card-label, -.section-title-row span { - color: var(--hy-theme-muted); - font-size: 13px; -} - -.balance-value { - display: flex; - align-items: center; - gap: 8px; - overflow-wrap: anywhere; - font-size: clamp(20px, 6vw, 30px); -} - -.primary-action, -.danger-action { - min-width: 94px; - min-height: 42px; - padding: 0 18px; - border: 0; - border-radius: var(--hy-theme-radius-pill); - color: #fff; - font-weight: 750; -} - -.primary-action { - background: var(--hy-theme-button); - box-shadow: var(--hy-theme-button-shadow); -} - -.danger-action { - background: var(--hy-theme-danger); -} - -.primary-action.compact { - display: inline-flex; - min-width: 0; - min-height: 38px; - align-items: center; - gap: 5px; - padding: 0 14px; - font-size: 13px; -} - -.section-title-row, -.host-panel-toolbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} - -.section-title-row { - margin-bottom: 12px; -} - -.section-title-row h2 { - margin: 0; - font-size: 19px; -} - -.data-tabs { - display: grid; - grid-template-columns: 1fr 1fr; - margin-bottom: 12px; - border-bottom: 1px solid var(--hy-theme-line); -} - -.data-tabs button { +.agency-tab { position: relative; - min-height: 44px; - border: 0; - background: transparent; - color: var(--hy-theme-muted); - font-weight: 750; + height: 44px; + color: rgba(51, 51, 51, 0.4); + font-size: 16px; + font-weight: 400; + line-height: 16px; + white-space: nowrap; } -.data-tabs button[aria-selected='true'] { - color: var(--hy-theme-primary-deep); +.agency-tab.is-active { + color: var(--fami-text); + font-weight: 500; } -.data-tabs button[aria-selected='true']::after { +.agency-tab.is-active::after { position: absolute; - right: 18%; - bottom: -1px; - left: 18%; - height: 3px; - border-radius: var(--hy-theme-radius-pill); - background: var(--hy-theme-primary-strong); + bottom: 6px; + left: 50%; + width: 21px; + height: 4px; + background: url('../../products/fami/centers/assets/fami/underline.svg') + center / 21px 4px no-repeat; content: ''; + transform: translateX(-50%); } -.guild-filter-bar { - margin-bottom: 12px; +.agency-date-strip { + top: 438px; } -.guild-filter-bar button { - padding: 5px 4px; - font-size: 11px; -} - -.summary-grid { +.agency-metrics { + position: absolute; + top: 474px; + right: 12px; + left: 12px; display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - overflow: hidden; + grid-template-columns: repeat(3, 101px); + grid-template-rows: 78px 70px; + gap: 12px; + height: 184px; + padding: 12px; } -.summary-grid article { +.agency-metric { display: flex; - min-height: 108px; + flex-direction: column; align-items: center; justify-content: center; - padding: 14px 8px; - border-right: 1px solid var(--hy-theme-line); - border-bottom: 1px solid var(--hy-theme-line); - flex-direction: column; - gap: 8px; + gap: 6px; + width: 101px; + border-radius: 8px; + background: #f5f5f5; text-align: center; } -.summary-grid article:nth-child(3n) { - border-inline-end: 0; +.agency-metric:nth-child(4), +.agency-metric:nth-child(5) { + transform: translateX(56.5px); } -.summary-grid article:nth-last-child(-n + 2) { - border-bottom: 0; +.agency-metric__value { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + min-height: 20px; + color: var(--fami-primary); + font-size: 14px; + font-weight: 600; + line-height: 20px; } -.summary-grid article strong { - color: var(--hy-theme-primary-deep); - font-size: clamp(18px, 5vw, 25px); +.agency-metric__value img { + width: 28px; + height: 28px; + object-fit: contain; } -.summary-grid article span { - color: var(--hy-theme-muted); - font-size: 12px; +.agency-metric__value strong { + overflow: hidden; + max-width: 68px; + background: linear-gradient(160.758deg, #21ecd8 1.97%, #15b2ab 100%); + color: transparent; + text-overflow: ellipsis; + white-space: nowrap; + -webkit-background-clip: text; + background-clip: text; } -.host-panel-toolbar { - margin-bottom: 10px; - color: var(--hy-theme-muted); - font-size: 13px; -} - -.host-table-shell { - min-width: 0; - max-width: 100%; - overflow-x: auto; -} - -.data-section, -#hostsPanel { - min-width: 0; - max-width: 100%; -} - -.host-table { - width: 100%; - min-width: 1060px; - border-collapse: collapse; - font-size: 12px; -} - -.host-table th, -.host-table td { - padding: 13px 12px; - border-bottom: 1px solid var(--hy-theme-line); - text-align: start; +.agency-metric > span { + color: #858585; + font-size: 14px; + font-weight: 500; + line-height: 20px; white-space: nowrap; } -.host-table th { - color: var(--hy-theme-muted); - font-size: 11px; - font-weight: 750; +.agency-add-host { + position: absolute; + top: 695px; + left: 40px; } -.host-table tr:last-child td { - border-bottom: 0; +.agency-host-search { + position: absolute; + top: 440px; + right: 12px; + left: 12px; + display: grid; + grid-template-columns: 280px 1fr; + gap: 6px; + align-items: center; + height: 36px; } -.host-identity { +.agency-host-search__field { display: flex; align-items: center; - gap: 9px; + gap: 4px; + height: 36px; + border-radius: 53px; + padding: 6px 12px; + background: #e8e8e8; } -.host-avatar { +.agency-host-search__field img { + width: 24px; + height: 24px; +} + +.agency-host-search__field input { + min-width: 0; + flex: 1; + border: 0; + padding: 0; + background: transparent; + color: rgba(0, 0, 0, 0.8); + font-size: 12px; + font-weight: 600; + line-height: 16px; +} + +.agency-host-search > button { + width: 44px; + justify-self: start; + margin-left: 1px; + color: var(--fami-text); + font-size: 14px; + font-weight: 700; + line-height: 18px; + letter-spacing: -0.42px; +} + +.agency-host-table { + position: absolute; + top: 488px; + right: 12px; + left: 12px; + height: 250px; + overflow: auto hidden; + scrollbar-width: none; +} + +.agency-host-table::-webkit-scrollbar { + display: none; +} + +.agency-host-table__grid { + position: relative; + width: 421px; +} + +.agency-host-table__header { display: grid; - overflow: hidden; - width: 38px; - height: 38px; - place-items: center; - border-radius: 50%; - background: var(--hy-theme-primary-soft); - color: var(--hy-theme-primary-deep); - font-weight: 800; + grid-template-columns: repeat(9, 48px); + align-items: start; + width: 432px; + height: 25px; + transform: translateX(-3px); } -.host-avatar img { - width: 100%; - height: 100%; +.agency-host-table__rows { + display: grid; + grid-template-columns: 45px repeat(8, 48px); + grid-auto-rows: 37px; + align-items: center; + width: 429px; + transform: translateY(-2px); +} + +.agency-host-table__head { + color: var(--fami-text); + font-size: 14px; + font-weight: 600; + line-height: 20px; + text-align: center; + white-space: nowrap; +} + +.agency-host-table__avatar { + width: 32px; + height: 32px; + margin-left: 5px; + border-radius: 50%; object-fit: cover; } -.host-copy { - display: flex; - max-width: 150px; - flex-direction: column; - gap: 2px; -} - -.host-copy strong { - overflow: hidden; - text-overflow: ellipsis; -} - -.host-copy span { - color: var(--hy-theme-muted); +.agency-host-table__value { + color: var(--fami-text); font-size: 11px; -} - -.remove-button { - border: 0; - background: transparent; - color: var(--hy-theme-danger); - font-weight: 750; -} - -.empty-state, -.stats-status { - padding: 24px; - color: var(--hy-theme-muted); + font-weight: 600; + line-height: 15px; text-align: center; + transform: translateY(3px); + white-space: nowrap; } -.stats-status { - margin-bottom: 12px; - border: 1px solid var(--hy-theme-line); - border-radius: var(--hy-theme-radius-control); - background: var(--hy-theme-primary-soft); -} - -.edit-dialog { - width: min(calc(100% - 32px), 420px); - padding: 0; - border: 0; - border-radius: var(--hy-theme-radius-modal); - background: var(--hy-theme-surface); - color: var(--hy-theme-text); - box-shadow: var(--hy-theme-modal-shadow); -} - -.edit-dialog::backdrop { - background: rgba(31, 23, 45, 0.42); -} - -.edit-dialog form { - display: grid; - gap: 16px; - padding: 24px; -} - -.edit-dialog h2, -.edit-dialog p { - margin: 0; -} - -.edit-dialog p { - color: var(--hy-theme-muted); - font-size: 13px; - line-height: 1.55; -} - -.edit-dialog label { - display: grid; - gap: 8px; - color: var(--hy-theme-muted); - font-size: 13px; -} - -.edit-dialog input { - width: 100%; - min-height: 46px; - padding: 0 14px; - border: 1px solid var(--hy-theme-line); - border-radius: var(--hy-theme-radius-control); - outline: none; - background: var(--hy-theme-surface-soft); - color: var(--hy-theme-text); -} - -.edit-dialog input:focus { - border-color: var(--hy-theme-primary-strong); - box-shadow: 0 0 0 4px var(--hy-theme-focus-ring); -} - -.dialog-actions { - display: flex; - justify-content: flex-end; - gap: 10px; -} - -.dialog-actions > button:not(.primary-action, .danger-action) { - min-height: 42px; - padding: 0 16px; - border: 1px solid var(--hy-theme-line); - border-radius: var(--hy-theme-radius-pill); - background: var(--hy-theme-surface); - color: var(--hy-theme-text); -} - -.is-loading .data-section, -.is-mutating button { - pointer-events: none; - opacity: 0.65; -} - -@media (max-width: 520px) { - .guild-center__content { - padding-inline: 12px; - } - - .summary-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .summary-grid article:nth-child(3n) { - border-inline-end: 1px solid var(--hy-theme-line); - } - - .summary-grid article:nth-child(2n) { - border-inline-end: 0; - } - - .summary-grid article:nth-last-child(-n + 2) { - border-bottom: 1px solid var(--hy-theme-line); - } - - .summary-grid article:last-child { - border-bottom: 0; - } -} - -@media (max-width: 380px) { - .agency-avatar { - flex-basis: 64px; - width: 64px; - height: 64px; - } - - .balance-card { - align-items: flex-start; - flex-direction: column; - } - - .balance-card .primary-action { - width: 100%; - } +[dir='rtl'] .agency-metric:nth-child(4), +[dir='rtl'] .agency-metric:nth-child(5) { + transform: translateX(-56.5px); } diff --git a/guild/fami/bd-center/index.html b/guild/fami/bd-center/index.html index 85cd449..c93816d 100644 --- a/guild/fami/bd-center/index.html +++ b/guild/fami/bd-center/index.html @@ -1,291 +1,268 @@ - + BD Center - + - - -
-
+
+ + +
+ -
-
-
- +
+ +
+ - + User ID: -
-
- BDUID: — -
-
- -
- -
- WhatsApp -
-
-
-
-

- Agency data -

- -
-
- -
- -
- - - - - - - - - - - - - -
Agency - Owner ID - Country - Total hosts - - Gifted hosts - - Agency diamond earnings - - Joined -
- -
-
+ + +

+ Diamond +

+
- -
-

Edit WhatsApp

- -
+ + + + + - -
- -
-

- Custom date range -

- -
- -
-
-
- -
-

Add agency

-

- Enter the future agency owner's display ID. The agency - is created after the invitation is accepted. -

- -
- -
-
-
+ + + +
- + + + - - + diff --git a/guild/fami/bd-center/script.js b/guild/fami/bd-center/script.js index e5083ed..dbe190f 100644 --- a/guild/fami/bd-center/script.js +++ b/guild/fami/bd-center/script.js @@ -1,292 +1,397 @@ (function () { - 'use strict'; + var ui = window.FamiCenterUI; + var fallbackAvatar = + '../../products/fami/centers/assets/fami/bd-avatar.png'; + var dates = []; + var contactController = null; + var state = { + profile: null, + owner: null, + agencies: [], + dailyAgencies: {}, + dailyRequestSequence: 0, + inviteProfile: null, + inviting: false, + }; - var state = { range: null, data: null, loading: false, mutating: false }; + var mockItems = Array.from({ length: 5 }, function (_, index) { + return { + agency: { + agency_id: 'agency-' + index, + name: 'agency ' + (index + 1), + avatar: fallbackAvatar, + }, + owner: { + user_id: 'owner-' + index, + display_user_id: '12345' + (index + 10), + username: 'agency ' + (index + 1), + avatar: fallbackAvatar, + }, + diamond_earnings: 123214, + }; + }); - function $(id) { - return document.getElementById(id); + var mock = { + user: { + user_id: 'bd-123456789', + display_user_id: '123456789', + username: 'konghuimingc', + avatar: fallbackAvatar, + contact_info: '+65 1234 5678', + }, + overview: { + profile: { + user_id: 'bd-123456789', + display_user_id: '123456789', + username: 'konghuimingc', + avatar: fallbackAvatar, + }, + }, + stats: { items: mockItems }, + }; + + function normalizeAgency(item) { + item = item || {}; + var agency = item.agency || {}; + var owner = item.owner || {}; + return { + id: String(agency.agency_id || ''), + displayId: String(owner.display_user_id || ''), + name: String(owner.username || agency.name || 'Agency'), + avatar: String(owner.avatar || agency.avatar || ''), + diamondEarnings: ui.number(item.diamond_earnings), + }; } - function t(key, fallback) { - return window.HyAppI18n && window.HyAppI18n.t - ? window.HyAppI18n.t(key, fallback) - : fallback || key; + + function applyStats(payload, date) { + var data = ui.unwrap(payload) || {}; + var items = Array.isArray(data.items) + ? data.items.map(normalizeAgency) + : []; + state.agencies = items; + if (date) state.dailyAgencies[date] = items; } - function toast(message) { - if (window.HyAppToast) window.HyAppToast.show(message); - } - function count(value) { - return Number.isFinite(value) - ? Number(value).toLocaleString(undefined, { - maximumFractionDigits: 0, - }) - : '—'; - } - function dateTime(value) { - return Number(value) - ? new Date(Number(value)).toLocaleString(undefined, { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - }) - : '—'; + + function agencyValue(agency, index) { + var date = dates[index] && dates[index].value; + var daily = state.dailyAgencies[date] || []; + var item = daily.find(function (candidate) { + return candidate.id === agency.id; + }); + return item ? item.diamondEarnings : 0; } function renderProfile() { - if (!state.data) return; - var profile = state.data.profile || {}; - $('profileName').textContent = profile.name || '—'; - $('profileUID').textContent = - t('fami_common.uid_prefix', 'UID:') + - ' ' + - (profile.displayUserId || profile.userId || '—'); - $('profileInitial').textContent = String(profile.name || 'B') - .trim() - .charAt(0) - .toUpperCase(); - $('profileAvatar').hidden = !profile.avatar; - $('profileInitial').hidden = !!profile.avatar; - if (profile.avatar) $('profileAvatar').src = profile.avatar; - $('contactValue').textContent = profile.contact || '—'; + var profile = state.profile || {}; + var owner = state.owner || profile; + ui.setAvatar(ui.$('profileAvatar'), profile.avatar, fallbackAvatar); + ui.$('profileName').textContent = profile.name || '-'; + ui.$('profileUserId').textContent = ui + .t('fami.user_id', 'User ID:{id}') + .replace('{id}', profile.displayId || '-'); + ui.$('contactName').textContent = owner.name || profile.name || '-'; + ui.$('contactUserId').textContent = ui + .t('fami.user_id', 'User ID:{id}') + .replace('{id}', owner.displayId || profile.displayId || '-'); + if (contactController) + contactController.setContact(owner.contact || ''); } - function agencyAvatar(item) { - var shell = document.createElement('span'); - shell.className = 'agency-avatar'; - if (item.avatar) { - var image = document.createElement('img'); - image.src = item.avatar; - image.alt = ''; - shell.appendChild(image); - } else { - shell.textContent = String(item.name || 'A') - .charAt(0) - .toUpperCase(); - } - return shell; - } - - function textCell(row, value) { - var cell = document.createElement('td'); - cell.textContent = value; - row.appendChild(cell); - } - - function renderAgencies() { - var agencies = - state.data && state.data.stats - ? state.data.stats.agencies || [] - : []; - var body = $('agencyTableBody'); - body.textContent = ''; - agencies.forEach(function (item) { - var row = document.createElement('tr'); - var identityCell = document.createElement('td'); - var identity = document.createElement('div'); - identity.className = 'agency-identity'; - identity.appendChild(agencyAvatar(item)); - var copy = document.createElement('span'); - copy.className = 'agency-copy'; - var name = document.createElement('strong'); - name.textContent = item.name || '—'; - var id = document.createElement('span'); - id.textContent = - t('fami_agency.id_prefix', 'Agency ID:') + - ' ' + - (item.shortId || item.id || '—'); - copy.appendChild(name); - copy.appendChild(id); - identity.appendChild(copy); - identityCell.appendChild(identity); - row.appendChild(identityCell); - textCell(row, item.ownerId || '—'); - textCell(row, item.country || '—'); - textCell(row, count(item.totalHosts)); - textCell(row, count(item.giftedHostCount)); - textCell(row, count(item.diamondEarnings)); - textCell(row, dateTime(item.joinedAtMS)); - body.appendChild(row); + function renderTable() { + dates = ui.dateWindow(9); + var grid = document.createElement('div'); + grid.className = 'bd-agency-table__grid'; + var header = document.createElement('div'); + header.className = 'bd-agency-table__header'; + dates.forEach(function (date) { + var head = document.createElement('span'); + head.className = 'bd-agency-table__head'; + head.textContent = ui.isMock() + ? ui.t('fami.today', 'Today') + : date.label; + head.setAttribute('aria-label', date.label + ' ' + date.value); + header.appendChild(head); }); - $('agencyEmpty').hidden = agencies.length > 0; - } - - function renderRange() { - document.querySelectorAll('[data-range]').forEach(function (button) { - button.setAttribute( - 'aria-pressed', - button.getAttribute('data-range') === state.range.preset - ? 'true' - : 'false' - ); - button.disabled = state.loading; + var rows = document.createElement('div'); + rows.className = 'bd-agency-table__rows'; + state.agencies.forEach(function (agency) { + var avatar = document.createElement('img'); + avatar.className = 'bd-agency-table__avatar'; + avatar.alt = ''; + ui.setAvatar(avatar, agency.avatar, fallbackAvatar); + rows.appendChild(avatar); + dates.slice(0, 8).forEach(function (_, index) { + var value = document.createElement('span'); + value.className = 'bd-agency-table__value'; + value.textContent = ui + .formatNumber(agencyValue(agency, index)) + .replace(/,/g, ''); + rows.appendChild(value); + }); }); - $('rangeCaption').textContent = - state.range.startDate === state.range.endDate - ? state.range.startDate - : state.range.startDate + ' – ' + state.range.endDate; + grid.appendChild(header); + grid.appendChild(rows); + ui.$('agencyTable').replaceChildren(grid); } - function renderAll() { + function render() { renderProfile(); - renderAgencies(); - renderRange(); + renderTable(); } - function setLoading(value) { - state.loading = value; - document.body.classList.toggle('is-loading', value); - renderRange(); + + function applyPayloads(userPayload, overviewPayload, statsPayload) { + var overview = ui.unwrap(overviewPayload) || {}; + state.profile = ui.profileFrom(overview.profile || overview, 'BD'); + state.owner = ui.profileFrom(userPayload, state.profile.name); + applyStats(statsPayload, dates[0] && dates[0].value); + render(); } - function setMutating(value) { - state.mutating = value; - document.body.classList.toggle('is-mutating', value); + + function loadDailyMatrix() { + if (ui.isMock()) { + dates.slice(0, 8).forEach(function (date) { + state.dailyAgencies[date.value] = + mockItems.map(normalizeAgency); + }); + renderTable(); + return; + } + var api = window.HyAppAPI && window.HyAppAPI.bdCenter; + if (!api || !api.stats) return; + var sequence = ++state.dailyRequestSequence; + // 每列独立读取对应 UTC 日,单日失败只留空该列;复制聚合值会把真实团队收益错误归到其他日期。 + Promise.all( + dates.slice(0, 8).map(function (date) { + return ui.safe('bd-stats-' + date.value, function () { + return api.stats({ + start_date: date.value, + end_date: date.value, + }); + }); + }) + ).then(function (payloads) { + if (sequence !== state.dailyRequestSequence) return; + payloads.forEach(function (payload, index) { + var day = dates[index] && dates[index].value; + var data = ui.unwrap(payload) || {}; + state.dailyAgencies[day] = Array.isArray(data.items) + ? data.items.map(normalizeAgency) + : []; + }); + renderTable(); + }); } function load() { - setLoading(true); - return window.HyGuild.bdCenter - .load(state.range) - .then(function (data) { - state.data = data; - $('statsStatus').hidden = true; - renderAll(); - }) - .catch(function () { - $('statsStatus').hidden = false; - $('statsStatus').textContent = t( - 'fami_bd.load_failed', - 'Unable to load BD Center.' - ); - toast($('statsStatus').textContent); - }) - .finally(function () { - setLoading(false); - }); - } - - function selectRange(preset) { - if (preset === 'custom') { - $('rangeStart').value = state.range.startDate; - $('rangeEnd').value = state.range.endDate; - $('rangeDialog').showModal(); + dates = ui.dateWindow(9); + if (ui.isMock()) { + applyPayloads(mock.user, mock.overview, mock.stats); + loadDailyMatrix(); return; } - state.range = window.HyGuild.dateRange.create(preset); - load(); - } - - function applyCustomRange(event) { - event.preventDefault(); - try { - state.range = window.HyGuild.dateRange.create('custom', { - start: $('rangeStart').value, - end: $('rangeEnd').value, - }); - } catch (_) { - toast(t('fami_range.invalid', 'Choose a valid date range.')); + var api = window.HyAppAPI; + if (!api || !api.bdCenter || !api.bdCenter.stats) { + render(); + ui.toast( + ui.t('bd_center.api_not_ready', 'API module is not ready.') + ); return; } - $('rangeDialog').close(); - load(); - } - function saveContact(event) { - event.preventDefault(); - if (state.mutating) return; - var value = String($('contactInput').value || '').trim(); - if (!value) { - toast(t('fami_agency.contact_required', 'Enter a contact number.')); - return; - } - setMutating(true); - window.HyGuild.bdCenter - .saveContact(value) - .then(function () { - state.data.profile.contact = value; - $('contactDialog').close(); - renderProfile(); - toast(t('fami_agency.contact_saved', 'Contact saved.')); - }) - .catch(function () { - toast( - t( - 'fami_agency.contact_save_failed', - 'Unable to save contact.' + // BD 资料和团队收益属于不同 owner;初始只取今天建立行集合,随后并行补齐八日矩阵,避免用 agency 列表里的 host_count/工资字段冒充收益。 + Promise.all([ + ui.safe('bd-owner', function () { + return api.user.me(); + }), + ui.safe('bd-overview', function () { + return api.bdCenter.overview(); + }), + ui.safe('bd-stats', function () { + return api.bdCenter.stats({ + start_date: dates[0].value, + end_date: dates[0].value, + }); + }), + ]).then(function (results) { + applyPayloads(results[0], results[1], results[2]); + loadDailyMatrix(); + if (!results.some(Boolean)) { + ui.toast( + ui.t( + 'bd_center.load_failed', + 'Load failed. Try again later.' ) ); - }) - .finally(function () { - setMutating(false); - }); + } + }); } - function inviteAgency(event) { + function setInviteStatus(message, success) { + var node = ui.$('inviteStatus'); + node.textContent = message || ''; + node.hidden = !message; + node.classList.toggle('is-success', !!success); + } + + function renderInviteResult() { + var root = ui.$('inviteResult'); + root.replaceChildren(); + if (!state.inviteProfile) return; + var row = document.createElement('div'); + row.className = 'fami-search-result'; + var avatar = document.createElement('img'); + avatar.alt = ''; + ui.setAvatar(avatar, state.inviteProfile.avatar, fallbackAvatar); + var copy = document.createElement('div'); + copy.className = 'fami-search-result__copy'; + var name = document.createElement('strong'); + name.textContent = state.inviteProfile.name || 'Agency'; + var id = document.createElement('span'); + id.textContent = 'ID: ' + state.inviteProfile.displayId; + var action = document.createElement('button'); + action.className = 'fami-search-result__action'; + action.type = 'button'; + action.disabled = state.inviting; + action.textContent = state.inviting + ? ui.t('bd_center.pending', 'Pending') + : ui.t('bd_center.invite', 'Invite'); + action.addEventListener('click', inviteAgency); + copy.appendChild(name); + copy.appendChild(id); + row.appendChild(avatar); + row.appendChild(copy); + row.appendChild(action); + root.appendChild(row); + } + + function searchInvite(event) { event.preventDefault(); - if (state.mutating) return; - var ownerID = String($('ownerDisplayID').value || '').trim(); - if (!ownerID) return; - setMutating(true); - window.HyGuild.bdCenter - .inviteAgency(ownerID, $('agencyNameInput').value) - .then(function () { - $('inviteDialog').close(); - toast( - t( - 'fami_bd.invite_sent', - 'Invitation sent. The agency is created after acceptance.' - ) + var value = String(ui.$('inviteSearchInput').value || '').trim(); + state.inviteProfile = null; + renderInviteResult(); + if (!value) return; + if (ui.isMock()) { + state.inviteProfile = ui.profileFrom(mock.user, 'Agency'); + state.inviteProfile.id = 'mock-agency-' + value; + state.inviteProfile.displayId = value; + renderInviteResult(); + return; + } + var api = window.HyAppAPI && window.HyAppAPI.user; + if (!api || !api.resolveDisplayUserID) { + setInviteStatus( + ui.t('bd_center.api_not_ready', 'API module is not ready.') + ); + return; + } + ui.$('inviteSearchButton').disabled = true; + setInviteStatus(ui.t('bd_center.searching', 'Searching...')); + api.resolveDisplayUserID(value) + .then(function (payload) { + state.inviteProfile = ui.profileFrom(payload, 'Agency'); + if (!state.inviteProfile.id) state.inviteProfile = null; + if (state.inviteProfile && !state.inviteProfile.displayId) { + state.inviteProfile.displayId = value; + } + setInviteStatus( + state.inviteProfile + ? '' + : ui.t('bd_center.no_users_found', 'No users found') + ); + renderInviteResult(); + }) + .catch(function (error) { + setInviteStatus( + (error && error.message) || + ui.t( + 'bd_center.search_failed', + 'Search failed. Try again later.' + ) ); }) - .catch(function () { - toast(t('fami_bd.invite_failed', 'Unable to invite agency.')); - }) .finally(function () { - setMutating(false); + ui.$('inviteSearchButton').disabled = false; }); } - function bind() { - $('backButton').addEventListener('click', function () { - if (window.history.length > 1) window.history.back(); - else if (window.HyAppBridge) window.HyAppBridge.back(); - }); - $('editContactButton').addEventListener('click', function () { - $('contactInput').value = - (state.data && state.data.profile.contact) || ''; - $('contactDialog').showModal(); - }); - $('cancelContactButton').addEventListener('click', function () { - $('contactDialog').close(); - }); - $('contactForm').addEventListener('submit', saveContact); - $('addAgencyButton').addEventListener('click', function () { - $('ownerDisplayID').value = ''; - $('agencyNameInput').value = ''; - $('inviteDialog').showModal(); - }); - $('cancelInviteButton').addEventListener('click', function () { - $('inviteDialog').close(); - }); - $('inviteForm').addEventListener('submit', inviteAgency); - $('rangeFilters').addEventListener('click', function (event) { - var button = event.target.closest('[data-range]'); - if (button && !state.loading) - selectRange(button.getAttribute('data-range')); - }); - $('cancelRangeButton').addEventListener('click', function () { - $('rangeDialog').close(); - }); - $('rangeForm').addEventListener('submit', applyCustomRange); - window.addEventListener('hyapp:i18n-ready', renderAll); + function inviteAgency() { + if (!state.inviteProfile || state.inviting) return; + if (ui.isMock()) { + setInviteStatus( + ui.t('bd_center.invitation_submitted', 'Invitation submitted'), + true + ); + return; + } + var api = window.HyAppAPI && window.HyAppAPI.bdCenter; + if (!api || !api.inviteAgency) return; + state.inviting = true; + renderInviteResult(); + // 创建公会邀请会触发角色与归属流转;command_id 保证重试幂等,后端确认前不本地伪造公会记录。 + api.inviteAgency({ + command_id: ui.commandId('bd-agency'), + target_user_id: state.inviteProfile.id, + agency_name: state.inviteProfile.name, + }) + .then(function () { + setInviteStatus( + ui.t( + 'bd_center.invitation_submitted', + 'Invitation submitted' + ), + true + ); + state.inviteProfile = null; + renderInviteResult(); + }) + .catch(function (error) { + setInviteStatus( + (error && error.message) || + ui.t( + 'bd_center.invite_failed', + 'Invite failed. Try again later.' + ) + ); + }) + .finally(function () { + state.inviting = false; + renderInviteResult(); + }); + } + + function openInvite() { + state.inviteProfile = null; + ui.$('inviteSearchInput').value = ''; + setInviteStatus(''); + renderInviteResult(); + ui.openModal('inviteModal'); } function init() { - state.range = window.HyGuild.dateRange.create('today'); - bind(); - renderRange(); + contactController = ui.bindContact(); + ui.$('backButton').addEventListener('click', function () { + ui.goBack(); + }); + ui.$('addGuildButton').addEventListener('click', openInvite); + ui.$('inviteSearchForm').addEventListener('submit', searchInvite); + document + .querySelectorAll('[data-invite-close]') + .forEach(function (node) { + node.addEventListener('click', function () { + ui.closeModal('inviteModal'); + }); + }); + window.addEventListener('hyapp:i18n-ready', render); + render(); load(); + if (window.HyAppBridge) { + window.HyAppBridge.ready({ + page: 'bd-center', + app_code: 'fami', + mock: ui.isMock(), + }); + } } - if (document.readyState === 'loading') - document.addEventListener('DOMContentLoaded', init); - else init(); + + document.addEventListener('DOMContentLoaded', init); })(); diff --git a/guild/fami/bd-center/style.css b/guild/fami/bd-center/style.css index 8082216..1e27450 100644 --- a/guild/fami/bd-center/style.css +++ b/guild/fami/bd-center/style.css @@ -1,376 +1,99 @@ -* { - box-sizing: border-box; -} -html, -body { - min-height: 100%; - margin: 0; - background: var(--hy-theme-bg); - color: var(--hy-theme-text); - font-family: - Inter, - -apple-system, - BlinkMacSystemFont, - 'Segoe UI', - sans-serif; -} -button, -input { - font: inherit; -} -button { - cursor: pointer; -} -.center-header { - position: sticky; - z-index: 20; - top: 0; - display: grid; - grid-template-columns: 48px 1fr 48px; - align-items: center; - min-height: calc(64px + env(safe-area-inset-top)); - padding: env(safe-area-inset-top) 12px 0; - border-bottom: 1px solid var(--hy-theme-line); - background: color-mix(in srgb, var(--hy-theme-bg) 94%, transparent); - backdrop-filter: blur(16px); -} -.center-header h1 { - margin: 0; - font-size: 22px; - text-align: center; -} -.icon-button, -.language-button { - display: grid; - width: 44px; - height: 40px; - place-items: center; - border: 0; - border-radius: var(--hy-theme-radius-language); - background: var(--hy-theme-surface); - color: var(--hy-theme-text); - box-shadow: var(--hy-theme-shadow); -} -.icon-button span { - font-size: 38px; - font-weight: 300; - line-height: 30px; -} -.language-switcher { - position: relative; -} -.language-button { - font-size: 13px; - font-weight: 800; -} -.language-menu { - position: absolute; - top: 46px; - right: 0; - overflow: hidden; - width: 64px; - border: 1px solid var(--hy-theme-line); - border-radius: var(--hy-theme-radius-small); - background: var(--hy-theme-surface); - box-shadow: var(--hy-theme-modal-shadow); -} -[dir='rtl'] .language-menu { - right: auto; - left: 0; -} -.language-menu button { - width: 100%; - padding: 10px; - border: 0; - background: transparent; - color: var(--hy-theme-text); -} -.language-menu button.is-active { - background: var(--hy-theme-primary-soft); - color: var(--hy-theme-primary-deep); -} -.profile-card, -.contact-card { - display: flex; - align-items: center; - padding: 18px; -} -.profile-avatar { - display: grid; - flex: 0 0 76px; - overflow: hidden; - width: 76px; +.bd-profile { height: 76px; - place-items: center; - border: 3px solid var(--hy-theme-primary); - border-radius: 50%; - background: var(--hy-theme-primary-soft); - color: var(--hy-theme-primary-deep); - font-size: 30px; - font-weight: 800; } -.profile-avatar img { - width: 100%; - height: 100%; - object-fit: cover; + +.bd-profile .fami-profile__avatar { + width: 52px; + height: 52px; } -.profile-copy { - display: flex; - min-width: 0; - flex-direction: column; + +.bd-profile .fami-profile__copy { gap: 4px; - margin-inline-start: 16px; + width: min(100%, 220px); } -.profile-copy strong { - overflow: hidden; - font-size: 21px; - text-overflow: ellipsis; - white-space: nowrap; + +.bd-contact { + top: 188px; } -.profile-copy > span:not(.role-pill) { - color: var(--hy-theme-muted); - font-size: 13px; + +.bd-add-guild { + position: absolute; + top: 265px; + left: 40px; } -.role-pill { - align-self: flex-start; - padding: 4px 11px; - border-radius: var(--hy-theme-radius-pill); - background: var(--hy-theme-primary-soft); - color: var(--hy-theme-primary-deep); - font-size: 12px; - font-weight: 800; -} -.contact-card { - gap: 14px; -} -.contact-icon { - display: grid; - width: 48px; - height: 48px; - place-items: center; - border-radius: 50%; - background: color-mix( - in srgb, - var(--hy-theme-success) 13%, - var(--hy-theme-surface) - ); - color: var(--hy-theme-success); -} -.contact-card > div { - display: flex; - min-width: 0; - flex: 1; - flex-direction: column; - gap: 3px; -} -.contact-card > div span { - overflow: hidden; - color: var(--hy-theme-muted); - text-overflow: ellipsis; - white-space: nowrap; -} -.contact-card > button { - border: 0; - background: transparent; - color: var(--hy-theme-primary-deep); - font-weight: 750; -} -.add-agency-card { - display: flex; - min-height: 82px; - align-items: center; - justify-content: center; - gap: 9px; - border: 1px solid var(--hy-theme-line); - border-radius: var(--hy-theme-radius-card); - background: var(--hy-theme-primary-soft); - color: var(--hy-theme-primary-deep); - box-shadow: var(--hy-theme-shadow); - font-weight: 800; -} -.data-section, -.agency-table-shell { - min-width: 0; - max-width: 100%; -} -.add-agency-card span:first-child { - display: grid; - width: 30px; - height: 30px; - place-items: center; - border-radius: 50%; - background: var(--hy-theme-surface); - font-size: 20px; -} -.section-title-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 12px; -} -.section-title-row h2 { + +.bd-diamond-title { + position: absolute; + top: 331px; + left: 12px; + height: 16px; margin: 0; - font-size: 19px; + color: var(--fami-text); + font-size: 16px; + font-weight: 500; + line-height: 16px; } -.section-title-row span { - color: var(--hy-theme-muted); - font-size: 13px; + +.bd-agency-table { + position: absolute; + top: 359px; + right: 12px; + left: 12px; + height: 380px; + overflow: auto hidden; + scrollbar-width: none; } -.guild-filter-bar { - margin-bottom: 12px; + +.bd-agency-table::-webkit-scrollbar { + display: none; } -.guild-filter-bar button { - padding: 5px 4px; - font-size: 11px; + +.bd-agency-table__grid { + position: relative; + width: 421px; } -.agency-table-shell { - overflow-x: auto; + +.bd-agency-table__header { + display: grid; + grid-template-columns: repeat(9, 48px); + align-items: start; + width: 432px; + height: 25px; + transform: translateX(-3px); } -.agency-table { - width: 100%; - min-width: 900px; - border-collapse: collapse; - font-size: 12px; + +.bd-agency-table__rows { + display: grid; + grid-template-columns: 45px repeat(8, 48px); + grid-auto-rows: 37px; + align-items: center; + width: 429px; + transform: translateY(-3px); } -.agency-table th, -.agency-table td { - padding: 13px 12px; - border-bottom: 1px solid var(--hy-theme-line); - text-align: start; + +.bd-agency-table__head { + color: var(--fami-text); + font-size: 14px; + font-weight: 600; + line-height: 20px; + text-align: center; white-space: nowrap; } -.agency-table th { - color: var(--hy-theme-muted); - font-size: 11px; -} -.agency-table tr:last-child td { - border-bottom: 0; -} -.agency-identity { - display: flex; - align-items: center; - gap: 9px; -} -.agency-avatar { - display: grid; - overflow: hidden; - width: 40px; - height: 40px; - place-items: center; + +.bd-agency-table__avatar { + width: 32px; + height: 32px; + margin-left: 5px; border-radius: 50%; - background: var(--hy-theme-primary-soft); - color: var(--hy-theme-primary-deep); - font-weight: 800; -} -.agency-avatar img { - width: 100%; - height: 100%; object-fit: cover; } -.agency-copy { - display: flex; - max-width: 170px; - flex-direction: column; - gap: 2px; -} -.agency-copy strong { - overflow: hidden; - text-overflow: ellipsis; -} -.agency-copy span { - color: var(--hy-theme-muted); + +.bd-agency-table__value { + color: var(--fami-text); font-size: 11px; -} -.empty-state, -.stats-status { - padding: 24px; - color: var(--hy-theme-muted); + font-weight: 600; + line-height: 15px; text-align: center; -} -.stats-status { - margin-bottom: 12px; - border: 1px solid var(--hy-theme-line); - border-radius: var(--hy-theme-radius-control); - background: var(--hy-theme-primary-soft); -} -.primary-action { - min-width: 94px; - min-height: 42px; - padding: 0 18px; - border: 0; - border-radius: var(--hy-theme-radius-pill); - background: var(--hy-theme-button); - color: #fff; - box-shadow: var(--hy-theme-button-shadow); - font-weight: 750; -} -.edit-dialog { - width: min(calc(100% - 32px), 420px); - padding: 0; - border: 0; - border-radius: var(--hy-theme-radius-modal); - background: var(--hy-theme-surface); - color: var(--hy-theme-text); - box-shadow: var(--hy-theme-modal-shadow); -} -.edit-dialog::backdrop { - background: rgba(31, 23, 45, 0.42); -} -.edit-dialog form { - display: grid; - gap: 16px; - padding: 24px; -} -.edit-dialog h2, -.edit-dialog p { - margin: 0; -} -.edit-dialog p { - color: var(--hy-theme-muted); - font-size: 13px; - line-height: 1.55; -} -.edit-dialog label { - display: grid; - gap: 8px; - color: var(--hy-theme-muted); - font-size: 13px; -} -.edit-dialog input { - width: 100%; - min-height: 46px; - padding: 0 14px; - border: 1px solid var(--hy-theme-line); - border-radius: var(--hy-theme-radius-control); - outline: none; - background: var(--hy-theme-surface-soft); - color: var(--hy-theme-text); -} -.edit-dialog input:focus { - border-color: var(--hy-theme-primary-strong); - box-shadow: 0 0 0 4px var(--hy-theme-focus-ring); -} -.dialog-actions { - display: flex; - justify-content: flex-end; - gap: 10px; -} -.dialog-actions > button:not(.primary-action) { - min-height: 42px; - padding: 0 16px; - border: 1px solid var(--hy-theme-line); - border-radius: var(--hy-theme-radius-pill); - background: var(--hy-theme-surface); - color: var(--hy-theme-text); -} -.is-loading .data-section, -.is-mutating button { - pointer-events: none; - opacity: 0.65; -} -@media (max-width: 520px) { - .guild-center__content { - padding-inline: 12px; - } + transform: translateY(4px); + white-space: nowrap; } diff --git a/guild/fami/host-center/index.html b/guild/fami/host-center/index.html index 0a219f8..65b117d 100644 --- a/guild/fami/host-center/index.html +++ b/guild/fami/host-center/index.html @@ -1,42 +1,66 @@ - + Host Center - + - - -
-
+
+ + +
+ -
-
-
- - -
-
- - UID: — - - +
+
+ +
+ - + User ID: - + Agency ID: - + Join time: -
-
- -
- WhatsApp - -
- + +
+ Gold Coins : - Edit - -
- -
-
- Diamond balance - +
+ + 0
-
-
-

- Data details -

- -
-
- - - - - - - -
+
- -
-
- Diamond earnings -
-
- Diamonds exchanged -
-
- Gift senders -
-
- Online duration -
-
- Valid mic duration -
-
- Valid mic days -
-
- Private message users -
-
- New followers -
-
+
+

Data Details

+
- -
-

Edit WhatsApp

- -
-
+ + -
-
- - -
-

- Custom date range -

- - -
- - -
-
-
+ + +
+
- + + + - - + diff --git a/guild/fami/host-center/script.js b/guild/fami/host-center/script.js index 1c512c4..f64140a 100644 --- a/guild/fami/host-center/script.js +++ b/guild/fami/host-center/script.js @@ -1,279 +1,302 @@ (function () { - 'use strict'; + var ui = window.FamiCenterUI; + var fallbackAvatar = + '../../products/fami/centers/assets/fami/host-avatar.png'; + var dates = []; + var activeDate = ''; + var contactController = null; var state = { - range: null, - data: null, - loading: false, - savingContact: false, + loading: true, + profile: null, + agency: null, + balance: 0, + stats: null, + statsRequestSequence: 0, }; - function $(id) { - return document.getElementById(id); + var metricDefinitions = [ + { + key: 'diamond_earnings', + label: 'fami.diamond_earnings', + fallback: 'Diamond Earnings', + }, + { + key: 'diamond_exchanged', + label: 'fami.diamond_exchanged', + fallback: 'Diamond Exchanged', + }, + { + key: 'gift_senders', + label: 'fami.gift_senders', + fallback: 'Gift Senders', + }, + { + key: 'online_duration_ms', + label: 'fami.online_minutes', + fallback: 'Online Minutes', + minutes: true, + }, + { + key: 'valid_mic_duration_ms', + label: 'fami.valid_mic_minutes', + fallback: 'Valid Mic Minutes', + minutes: true, + }, + { + key: 'valid_mic_days', + label: 'fami.valid_mic_days', + fallback: 'Valid Mic Days', + }, + { + key: 'private_message_senders', + label: 'fami.private_chats', + fallback: 'Private Chats', + }, + { + key: 'new_followers', + label: 'fami.new_followers', + fallback: 'New Followers', + }, + ]; + + var mock = { + profile: { + user_id: 'host-123456789', + display_user_id: '123456789', + username: 'konghuimingc', + avatar: fallbackAvatar, + contact_info: '+65 1234 5678', + created_at_ms: Date.UTC(2025, 5, 12, 8, 0, 0), + }, + agency: { + agency: { + agency_id: '123456789', + short_id: '123456789', + name: 'konghuimingc', + joined_at_ms: Date.UTC(2025, 5, 12, 8, 0, 0), + }, + }, + coins: { + balances: [{ asset_type: 'COIN', available_amount: 1000000000 }], + }, + stats: { + metrics: { + diamond_earnings: 999999, + diamond_exchanged: 999999, + gift_senders: 999999, + online_duration_ms: 999999 * 60000, + valid_mic_duration_ms: 999999 * 60000, + valid_mic_days: 999999, + private_message_senders: 999999, + new_followers: 999999, + }, + }, + }; + + function agencyFrom(payload) { + var data = ui.unwrap(payload) || {}; + return data.agency || {}; } - function t(key, fallback) { - return window.HyAppI18n && window.HyAppI18n.t - ? window.HyAppI18n.t(key, fallback) - : fallback || key; - } - - function toast(message) { - if (window.HyAppToast) window.HyAppToast.show(message); - } - - function count(value) { - if (value === null || value === undefined || !Number.isFinite(value)) { - return '—'; - } - return Number(value).toLocaleString(undefined, { - maximumFractionDigits: 0, + function metricValues() { + var metrics = (state.stats && state.stats.metrics) || {}; + return metricDefinitions.map(function (definition) { + var value = ui.number(metrics[definition.key]); + return definition.minutes ? Math.floor(value / 60000) : value; }); } - function duration(value) { - if (value === null || value === undefined || !Number.isFinite(value)) { - return '—'; - } - var minutes = Math.max(0, Math.floor(Number(value) / 60000)); - var days = Math.floor(minutes / 1440); - var hours = Math.floor((minutes % 1440) / 60); - var rest = minutes % 60; - var parts = []; - if (days) parts.push(days + t('fami_unit.day_short', 'd')); - if (hours) parts.push(hours + t('fami_unit.hour_short', 'h')); - if (!days && rest) parts.push(rest + t('fami_unit.minute_short', 'm')); - return parts.length - ? parts.join(' ') - : '0' + t('fami_unit.minute_short', 'm'); - } - - function dateTime(value) { - if (!Number(value)) return ''; - return new Date(Number(value)).toLocaleString(undefined, { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - }); + function renderDates() { + dates = ui.dateWindow(7); + if (!activeDate && dates.length) activeDate = dates[0].value; + ui.renderDateStrip(ui.$('dateStrip'), dates, activeDate, selectDate); } function renderProfile() { - if (!state.data) return; - var profile = state.data.profile || {}; - var agency = state.data.agency; - $('profileName').textContent = profile.name || '—'; - $('profileUID').textContent = - t('fami_host.uid_prefix', 'UID:') + - ' ' + - (profile.displayUserId || profile.userId || '—'); - $('profileInitial').textContent = String(profile.name || 'H') - .trim() - .charAt(0) - .toUpperCase(); - $('profileAvatar').hidden = !profile.avatar; - $('profileInitial').hidden = !!profile.avatar; - if (profile.avatar) $('profileAvatar').src = profile.avatar; + var profile = state.profile || {}; + var agency = state.agency || {}; + ui.setAvatar(ui.$('profileAvatar'), profile.avatar, fallbackAvatar); + ui.$('profileName').textContent = profile.name || '-'; + ui.$('profileUserId').textContent = ui + .t('fami.user_id', 'User ID:{id}') + .replace('{id}', profile.displayId || '-'); + ui.$('profileAgencyId').textContent = ui + .t('fami.agency_id', 'Agency ID:{id}') + .replace( + '{id}', + String(agency.short_id || agency.agency_id || '-') + ); - $('agencyPill').hidden = !agency; - $('joinTime').hidden = !agency || !agency.joinedAtMS; - if (agency) { - $('agencyPill').textContent = - t('fami_host.agency_id_prefix', 'Agency ID:') + + var joinedAt = agency.joined_at_ms || profile.createdAt; + var joinedDate = joinedAt ? new Date(joinedAt) : null; + var formatted = ''; + if (joinedDate && !Number.isNaN(joinedDate.getTime())) { + // 设计稿要求月名与 24 小时时间之间使用固定英文标点;手工拼接可避免浏览器插入 at 或本地化逗号。 + var month = ui.formatDate(joinedDate, { month: 'long' }); + formatted = + month + ' ' + - (agency.shortId || agency.id || '—'); - if (agency.joinedAtMS) { - $('joinTime').textContent = - t('fami_host.joined_at_prefix', 'Joined:') + - ' ' + - dateTime(agency.joinedAtMS); - } + joinedDate.getDate() + + ', ' + + joinedDate.getFullYear() + + ', ' + + String(joinedDate.getHours()).padStart(2, '0') + + ':' + + String(joinedDate.getMinutes()).padStart(2, '0'); } - $('contactValue').textContent = profile.contact || '—'; - $('diamondBalance').textContent = count(state.data.pointBalance); + ui.$('profileJoinTime').textContent = ui + .t('fami.join_time', 'Join time: {time}') + .replace('{time}', formatted || '-'); + ui.$('contactName').textContent = profile.name || '-'; + ui.$('contactUserId').textContent = ui + .t('fami.user_id', 'User ID:{id}') + .replace('{id}', profile.displayId || '-'); + if (contactController) + contactController.setContact(profile.contact || ''); } function renderMetrics() { - var metrics = - state.data && state.data.stats ? state.data.stats.metrics : {}; - window.HyGuild.hostCenter.metrics.forEach(function (definition) { - var node = document.querySelector( - '[data-metric="' + definition.key + '"] strong' - ); - if (!node) return; - node.textContent = - definition.format === 'duration' - ? duration(metrics[definition.key]) - : count(metrics[definition.key]); + var grid = ui.$('metricGrid'); + var values = metricValues(); + grid.replaceChildren(); + values.forEach(function (value, index) { + var card = document.createElement('div'); + card.className = 'host-metric'; + var number = document.createElement('strong'); + number.textContent = ui.formatNumber(value).replace(/,/g, ''); + var label = document.createElement('span'); + var definition = metricDefinitions[index]; + label.textContent = ui.isMock() + ? ui.t('fami.diamond_earnings', 'Diamond Earnings') + : ui.t(definition.label, definition.fallback); + card.appendChild(number); + card.appendChild(label); + grid.appendChild(card); }); - var statsError = - state.data && state.data.errors ? state.data.errors.stats : null; - $('statsStatus').hidden = !statsError; - if (statsError) { - $('statsStatus').textContent = t( - 'fami_host.stats_unavailable', - 'Statistics are temporarily unavailable.' - ); + } + + function render() { + renderProfile(); + ui.$('goldBalance').textContent = ui.formatNumber(state.balance); + renderDates(); + renderMetrics(); + } + + function applyPayloads( + profilePayload, + agencyPayload, + coinPayload, + statsPayload + ) { + state.profile = ui.profileFrom(profilePayload, 'Host'); + state.agency = agencyFrom(agencyPayload); + state.balance = ui.walletAvailable(coinPayload, 'COIN'); + state.stats = ui.unwrap(statsPayload) || { metrics: {} }; + state.loading = false; + render(); + } + + function selectDate(value) { + activeDate = value; + renderDates(); + if (ui.isMock()) { + renderMetrics(); + return; } - } - - function renderRange() { - document.querySelectorAll('[data-range]').forEach(function (button) { - button.setAttribute( - 'aria-pressed', - button.getAttribute('data-range') === state.range.preset - ? 'true' - : 'false' - ); - button.disabled = state.loading; - }); - $('rangeCaption').textContent = - state.range.startDate === state.range.endDate - ? state.range.startDate - : state.range.startDate + ' – ' + state.range.endDate; - } - - function setLoading(loading) { - state.loading = loading; - document.body.classList.toggle('is-loading', loading); - renderRange(); + var api = window.HyAppAPI && window.HyAppAPI.hostCenter; + if (!api || !api.stats) return; + var sequence = ++state.statsRequestSequence; + // 日期快速切换时旧响应可能晚到;仅最后一次请求可以覆盖卡片,避免用户选中 A 日却看到 B 日数据。 + api.stats({ start_date: value, end_date: value }) + .then(function (payload) { + if (sequence !== state.statsRequestSequence) return; + state.stats = ui.unwrap(payload) || { metrics: {} }; + renderMetrics(); + }) + .catch(function (error) { + if (sequence !== state.statsRequestSequence) return; + ui.toast( + (error && error.message) || + ui.t( + 'host_center.load_failed', + 'Load failed. Try again later.' + ) + ); + }); } function load() { - setLoading(true); - return window.HyGuild.hostCenter - .load(state.range) - .then(function (data) { - state.data = data; - renderProfile(); - renderMetrics(); - }) - .catch(function () { - toast( - t('fami_host.load_failed', 'Unable to load Host Center.') - ); - $('statsStatus').hidden = false; - $('statsStatus').textContent = t( - 'fami_host.load_failed', - 'Unable to load Host Center.' - ); - }) - .finally(function () { - setLoading(false); - }); - } - - function selectRange(preset) { - if (preset === 'custom') { - $('rangeStart').value = state.range.startDate; - $('rangeEnd').value = state.range.endDate; - $('rangeDialog').showModal(); + dates = ui.dateWindow(7); + if (!activeDate && dates.length) activeDate = dates[0].value; + if (ui.isMock()) { + applyPayloads(mock.profile, mock.agency, mock.coins, mock.stats); return; } - state.range = window.HyGuild.dateRange.create(preset); - renderRange(); - load(); - } - - function saveContact(event) { - event.preventDefault(); - if (state.savingContact) return; - var value = String($('contactInput').value || '').trim(); - if (!value) { - toast(t('fami_host.contact_required', 'Enter a contact number.')); + var api = window.HyAppAPI; + if (!api || !api.hostCenter || !api.hostCenter.stats) { + state.loading = false; + render(); + ui.toast( + ui.t('host_center.api_not_ready', 'API module is not ready.') + ); return; } - state.savingContact = true; - window.HyGuild.hostCenter - .saveContact(value) - .then(function () { - state.data.profile.contact = value; - $('contactValue').textContent = value; - $('contactDialog').close(); - toast(t('fami_host.contact_saved', 'Contact saved.')); - }) - .catch(function () { - toast( - t( - 'fami_host.contact_save_failed', - 'Unable to save contact.' + + // 资料、归属、COIN 余额和选中自然日统计来自四个独立 owner;部分失败仍保留其他已确认数据,不能用工资余额或政策字段伪造金币/收益。 + Promise.all([ + ui.safe('host-profile', function () { + return api.user.me(); + }), + ui.safe('host-agency', function () { + return api.hostCenter.agency(); + }), + ui.safe('host-coins', function () { + return api.wallet.balances('COIN'); + }), + ui.safe('host-stats', function () { + return api.hostCenter.stats({ + start_date: activeDate, + end_date: activeDate, + }); + }), + ]).then(function (results) { + applyPayloads(results[0], results[1], results[2], results[3]); + if (!results.some(Boolean)) { + ui.toast( + ui.t( + 'host_center.load_failed', + 'Load failed. Try again later.' ) ); - }) - .finally(function () { - state.savingContact = false; - }); - } - - function applyCustomRange(event) { - event.preventDefault(); - try { - state.range = window.HyGuild.dateRange.create('custom', { - start: $('rangeStart').value, - end: $('rangeEnd').value, - }); - } catch (_) { - toast(t('fami_range.invalid', 'Choose a valid date range.')); - return; - } - $('rangeDialog').close(); - renderRange(); - load(); - } - - function bind() { - $('backButton').addEventListener('click', function () { - if (window.history.length > 1) window.history.back(); - else if (window.HyAppBridge) window.HyAppBridge.back(); - }); - $('withdrawButton').addEventListener('click', function () { - // 钱包是独立页面包;统一 URL builder 继承 token/env/lang 并强制 app_code=fami。 - window.location.href = - window.HyGuild.product.buildURL('../wallet/'); - }); - $('editContactButton').addEventListener('click', function () { - $('contactInput').value = - (state.data && state.data.profile.contact) || ''; - $('contactDialog').showModal(); - }); - $('cancelContactButton').addEventListener('click', function () { - $('contactDialog').close(); - }); - $('contactForm').addEventListener('submit', saveContact); - $('rangeFilters').addEventListener('click', function (event) { - var button = event.target.closest('[data-range]'); - if (button && !state.loading) { - selectRange(button.getAttribute('data-range')); } }); - $('cancelRangeButton').addEventListener('click', function () { - $('rangeDialog').close(); - }); - $('rangeForm').addEventListener('submit', applyCustomRange); - window.addEventListener('hyapp:i18n-ready', function () { - renderProfile(); - renderMetrics(); + } + + function openWithdraw() { + // Center 与 Wallet 只继承认证、环境、语言和 mock 上下文;页面筛选参数不能泄漏到资金页。 + window.location.href = window.HyGuild.product.buildURL('../wallet/', { + identity: 'HOST', }); } function init() { - state.range = window.HyGuild.dateRange.create('today'); - bind(); - renderRange(); - load().finally(function () { - if (window.HyAppBridge) { - window.HyAppBridge.ready({ - page: 'fami-host-center', - app_code: 'fami', - }); - } + contactController = ui.bindContact(); + ui.$('backButton').addEventListener('click', function () { + ui.goBack(); }); + ui.$('withdrawButton').addEventListener('click', openWithdraw); + window.addEventListener('hyapp:i18n-ready', render); + render(); + load(); + if (window.HyAppBridge) { + window.HyAppBridge.ready({ + page: 'fami-host-center', + app_code: 'fami', + mock: ui.isMock(), + }); + } } - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); - } else { - init(); - } + document.addEventListener('DOMContentLoaded', init); })(); diff --git a/guild/fami/host-center/style.css b/guild/fami/host-center/style.css index b0add14..0260362 100644 --- a/guild/fami/host-center/style.css +++ b/guild/fami/host-center/style.css @@ -1,409 +1,88 @@ -* { - box-sizing: border-box; +.host-profile .fami-profile__copy { + gap: 4px; + width: min(100%, 210px); } -html, -body { - min-height: 100%; +.host-balance-card { + top: 285px; +} + +.host-date-strip { + top: 397px; +} + +.host-details { + position: absolute; + top: 433px; + right: 12px; + left: 12px; + height: 272px; + padding: 12px; +} + +.host-details h2 { + height: 20px; margin: 0; - background: var(--hy-theme-bg); - color: var(--hy-theme-text); - font-family: - Inter, - -apple-system, - BlinkMacSystemFont, - 'Segoe UI', - sans-serif; -} - -button, -input { - font: inherit; -} - -button { - cursor: pointer; -} - -.fami-host-center { - background: - radial-gradient( - circle at 16% 5%, - var(--hy-theme-primary-soft), - transparent 32% - ), - var(--hy-theme-bg); -} - -.center-header { - position: sticky; - z-index: 20; - top: 0; - display: grid; - grid-template-columns: 48px 1fr 48px; - align-items: center; - min-height: calc(64px + env(safe-area-inset-top)); - padding: env(safe-area-inset-top) 12px 0; - border-bottom: 1px solid var(--hy-theme-line); - background: color-mix(in srgb, var(--hy-theme-bg) 88%, transparent); - backdrop-filter: blur(18px); -} - -.center-header h1 { - margin: 0; - font-size: 22px; - line-height: 1.2; + color: var(--fami-text); + font-size: 14px; + font-weight: 600; + line-height: 20px; text-align: center; } -.icon-button, -.language-button { - display: grid; - place-items: center; - width: 44px; - height: 40px; - border: 0; - border-radius: var(--hy-theme-radius-language); - background: var(--hy-theme-surface); - color: var(--hy-theme-text); - box-shadow: var(--hy-theme-shadow); -} - -.icon-button span { - font-size: 38px; - font-weight: 300; - line-height: 30px; -} - -.language-switcher { - position: relative; -} - -.language-button { - font-size: 13px; - font-weight: 800; -} - -.language-menu { +.host-metric-grid { position: absolute; - top: 46px; - right: 0; - overflow: hidden; - width: 64px; - border: 1px solid var(--hy-theme-line); - border-radius: var(--hy-theme-radius-small); - background: var(--hy-theme-surface); - box-shadow: var(--hy-theme-modal-shadow); -} - -.language-menu button { - width: 100%; - padding: 10px; - border: 0; - background: transparent; - color: var(--hy-theme-text); -} - -.language-menu button.is-active { - background: var(--hy-theme-primary-soft); - color: var(--hy-theme-primary-deep); -} - -.profile-card, -.contact-card, -.balance-card { - display: flex; - align-items: center; - padding: 18px; -} - -.avatar-shell { + top: 44px; + left: 12px; display: grid; - flex: 0 0 76px; - place-items: center; - overflow: hidden; - width: 76px; - height: 76px; - border: 3px solid var(--hy-theme-primary); - border-radius: 50%; - background: var(--hy-theme-primary-soft); - color: var(--hy-theme-primary-deep); - font-size: 30px; - font-weight: 800; -} - -.avatar-shell img { - width: 100%; - height: 100%; - object-fit: cover; -} - -.profile-copy { - display: flex; - min-width: 0; - flex-direction: column; - gap: 5px; - margin-inline-start: 16px; -} - -.profile-copy strong { - overflow: hidden; - font-size: 21px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.profile-copy > span:not(.agency-pill) { - color: var(--hy-theme-muted); - font-size: 13px; -} - -.agency-pill { - align-self: flex-start; - padding: 5px 10px; - border-radius: var(--hy-theme-radius-pill); - background: var(--hy-theme-primary-soft); - color: var(--hy-theme-primary-deep); - font-size: 12px; - font-weight: 750; -} - -.contact-card { - gap: 14px; -} - -.contact-icon { - display: grid; - width: 48px; - height: 48px; - place-items: center; - border-radius: 50%; - background: color-mix( - in srgb, - var(--hy-theme-success) 14%, - var(--hy-theme-surface) - ); - color: var(--hy-theme-success); - font-size: 20px; -} - -.contact-card > div { - display: flex; - min-width: 0; - flex: 1; - flex-direction: column; - gap: 3px; -} - -.contact-card > div span { - overflow: hidden; - color: var(--hy-theme-muted); - text-overflow: ellipsis; - white-space: nowrap; -} - -.contact-card > button { - border: 0; - background: transparent; - color: var(--hy-theme-primary-deep); - font-weight: 750; -} - -.balance-card { - justify-content: space-between; - gap: 18px; -} - -.balance-card > div { - display: flex; - min-width: 0; - flex-direction: column; - gap: 8px; -} - -.card-label, -.section-title-row span { - color: var(--hy-theme-muted); - font-size: 13px; -} - -.balance-value { - display: flex; - align-items: center; - gap: 8px; - overflow-wrap: anywhere; - font-size: clamp(20px, 6vw, 30px); -} - -.primary-action { - min-width: 94px; - min-height: 42px; - padding: 0 18px; - border: 0; - border-radius: var(--hy-theme-radius-pill); - background: var(--hy-theme-button); - color: white; - box-shadow: var(--hy-theme-button-shadow); - font-weight: 750; -} - -.section-title-row { - display: flex; - align-items: end; - justify-content: space-between; + grid-template-columns: repeat(3, 101px); + grid-auto-rows: 64px; gap: 12px; - margin-bottom: 12px; } -.section-title-row h2 { - margin: 0; - font-size: 19px; -} - -.guild-filter-bar { - grid-template-columns: repeat(4, minmax(0, 1fr)); - margin-bottom: 12px; -} - -.guild-filter-bar button { - padding: 5px 4px; - font-size: 11px; -} - -.metrics-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - overflow: hidden; -} - -.metrics-grid article { +.host-metric { display: flex; - min-height: 104px; + flex-direction: column; align-items: center; justify-content: center; - padding: 14px 8px; - border-right: 1px solid var(--hy-theme-line); - border-bottom: 1px solid var(--hy-theme-line); - flex-direction: column; - gap: 8px; + gap: 6px; + width: 101px; + height: 64px; + border-radius: 8px; + background: #f5f5f5; text-align: center; } -.metrics-grid article:nth-child(3n) { - border-right: 0; +.host-metric:nth-child(7), +.host-metric:nth-child(8) { + transform: translateX(56.5px); } -.metrics-grid article:nth-last-child(-n + 2) { - border-bottom: 0; +.host-metric strong { + overflow: hidden; + max-width: 90px; + background: linear-gradient(160.758deg, #21ecd8 1.97%, #15b2ab 100%); + color: transparent; + font-size: 14px; + font-weight: 600; + line-height: 20px; + text-overflow: ellipsis; + white-space: nowrap; + -webkit-background-clip: text; + background-clip: text; } -.metrics-grid article strong { - color: var(--hy-theme-text); - font-size: 19px; +.host-metric span { + max-width: 98px; + color: #7e7e7e; + font-size: 10px; + font-weight: 500; + line-height: 14px; + letter-spacing: -0.35px; + white-space: nowrap; } -.metrics-grid article span { - color: var(--hy-theme-muted); - font-size: 12px; - line-height: 1.35; -} - -.stats-status { - margin-bottom: 10px; - padding: 11px 14px; - border: 1px solid var(--hy-theme-line); - border-radius: var(--hy-theme-radius-small); - background: var(--hy-theme-primary-soft); - color: var(--hy-theme-primary-deep); - font-size: 13px; -} - -.edit-dialog { - width: min(420px, calc(100vw - 32px)); - padding: 0; - border: 0; - border-radius: var(--hy-theme-radius-modal); - background: var(--hy-theme-surface); - color: var(--hy-theme-text); - box-shadow: var(--hy-theme-modal-shadow); -} - -.edit-dialog::backdrop { - background: rgb(25 18 38 / 45%); -} - -.edit-dialog form { - display: grid; - gap: 18px; - padding: 22px; -} - -.edit-dialog h2 { - margin: 0; - font-size: 20px; -} - -.edit-dialog label { - display: grid; - gap: 8px; - color: var(--hy-theme-muted); - font-size: 13px; -} - -.edit-dialog input { - width: 100%; - min-height: 46px; - padding: 0 13px; - border: 1px solid var(--hy-theme-line); - border-radius: var(--hy-theme-radius-control); - outline: none; - background: var(--hy-theme-surface-soft); - color: var(--hy-theme-text); -} - -.edit-dialog input:focus { - border-color: var(--hy-theme-primary-strong); - box-shadow: 0 0 0 4px var(--hy-theme-focus-ring); -} - -.dialog-actions { - display: flex; - justify-content: flex-end; - gap: 10px; -} - -.dialog-actions button:not(.primary-action) { - min-height: 42px; - padding: 0 18px; - border: 1px solid var(--hy-theme-line); - border-radius: var(--hy-theme-radius-pill); - background: var(--hy-theme-surface); - color: var(--hy-theme-text); -} - -[dir='rtl'] .language-menu { - right: auto; - left: 0; -} - -@media (max-width: 420px) { - .guild-filter-bar { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - - .metrics-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .metrics-grid article:nth-child(3n) { - border-right: 1px solid var(--hy-theme-line); - } - - .metrics-grid article:nth-child(2n) { - border-right: 0; - } - - .metrics-grid article:nth-last-child(-n + 2) { - border-bottom: 0; - } +[dir='rtl'] .host-metric:nth-child(7), +[dir='rtl'] .host-metric:nth-child(8) { + transform: translateX(-56.5px); } diff --git a/guild/fami/wallet/index.html b/guild/fami/wallet/index.html index 31d3ea7..9bd7c21 100644 --- a/guild/fami/wallet/index.html +++ b/guild/fami/wallet/index.html @@ -1,207 +1,345 @@ - + - Wallet + Withdrawal - + - - -
-
+
+ + +
+

+ Withdrawal +

+ -
-
+
+
- +
-
- POINT balance - - -
- -
- -
- - +
+ Diamond Balance: + + + 0 + +
+
-
- Estimated receive - -
- -
- -
-
-

- Choose coin seller -

-
-
- - -
- -
+ + +
+

- Save address - + Select a Currency Dealer +

+
-
-
-

- Withdrawal history -

-
-
- + + +
+ + + + + + +
- + + + - + diff --git a/guild/fami/wallet/script.js b/guild/fami/wallet/script.js index 379894f..8b22662 100644 --- a/guild/fami/wallet/script.js +++ b/guild/fami/wallet/script.js @@ -1,417 +1,544 @@ (function () { - 'use strict'; - + var ui = window.FamiCenterUI; + var identity = String(ui.params.get('identity') || 'HOST').toUpperCase(); + var explicitChannel = ui.params.get('channel'); var state = { - data: null, - mode: 'seller', - selectedSellerID: '', - busy: false, + channel: explicitChannel === 'address' ? 'address' : 'dealer', + diamondBalance: 0, + ratio: 1000000, + minimumPoints: 0, + feeBPS: 0, + dealers: [], + selectedDealerId: '', + address: '', + profileAvatar: '', + contact: '', + submitting: false, + savingAddress: false, + }; + var contactController = null; + + var mock = { + overview: { + asset_type: 'POINT', + balance: { available_amount: 1000000000 }, + withdraw_address: { withdraw_usdt_trc20_address: '' }, + points_per_usd: 1000000, + fee_bps: 0, + minimum_points: 1000000, + coin_sellers: Array.from({ length: 3 }, function (_, index) { + return { + user_id: 'seller-' + (index + 1), + display_user_id: '123456789', + nickname: 'konghuimingc', + avatar: '../../products/fami/centers/assets/fami/agency-avatar.png', + }; + }), + }, + user: { + avatar: '../../products/fami/centers/assets/fami/agency-avatar.png', + contact_info: '+65 1234 5678', + }, }; - function $(id) { - return document.getElementById(id); - } - - function t(key, fallback) { - return window.HyAppI18n && window.HyAppI18n.t - ? window.HyAppI18n.t(key, fallback) - : fallback || key; - } - - function toast(message) { - if (window.HyAppToast) window.HyAppToast.show(message); - } - - function number(value) { - var normalized = value || 0; - if ( - typeof BigInt === 'function' && - typeof normalized === 'string' && - /^-?\d+$/.test(normalized) - ) { - return new Intl.NumberFormat().format(BigInt(normalized)); + function normalizeIdentity(value) { + var normalized = String(value || '').toUpperCase(); + if (normalized === 'AGENCY' || normalized === 'BD') { + return normalized; } - return Number(normalized).toLocaleString(undefined, { - maximumFractionDigits: 0, - }); + return 'HOST'; } - function amount() { - var value = Number($('pointAmount').value || 0); - return Number.isSafeInteger(value) && value > 0 ? value : 0; - } - - function selectedSeller() { - if (!state.data) return null; - return state.data.sellers.find(function (seller) { - return seller.userId === state.selectedSellerID; - }); - } - - function setBusy(value) { - state.busy = value; - document.body.classList.toggle('is-busy', value); - $('sellerSubmit').disabled = value || !selectedSeller(); - $('usdtSubmit').disabled = value; - $('saveAddressButton').disabled = value; - } - - function renderOverview() { - if (!state.data) return; - $('pointBalance').textContent = number(state.data.balance.available); - $('balanceUSD').textContent = - '≈ ' + state.data.balance.displayUSD + ' USDT'; - $('addressInput').value = state.data.address || ''; - $('addressStatus').classList.toggle( - 'has-address', - Boolean(state.data.address) - ); - $('addressStatus').textContent = state.data.address - ? t('fami_wallet.saved_address', 'Saved address:') + - ' ' + - state.data.address - : t('fami_wallet.no_address', 'No USDT address saved.'); - $('policyCaption').textContent = - t('fami_wallet.policy_caption', 'Rate and fee are set by Admin.') + - ' · 1 USDT = ' + - number(state.data.pointsPerUSD) + - ' POINT · ' + - (state.data.feeBPS / 100).toFixed(2) + - '%'; - if (!$('pointAmount').value && state.data.minimumPoints > 0) { - $('pointAmount').value = String(state.data.minimumPoints); - } - renderConversion(); - } - - function renderSellers() { - var sellers = state.data ? state.data.sellers : []; - if ( - sellers.length && - !sellers.some(function (seller) { - return seller.userId === state.selectedSellerID; - }) - ) { - state.selectedSellerID = sellers[0].userId; - } - $('sellerList').innerHTML = sellers - .map(function (seller) { - var selected = seller.userId === state.selectedSellerID; - var avatar = seller.avatar - ? '' - : escapeHTML( - (seller.nickname || 'C').charAt(0).toUpperCase() - ); - return ( - '' - ); - }) - .join(''); - $('sellerEmpty').hidden = sellers.length > 0; - setBusy(state.busy); - } - - function renderHistory() { - var items = state.data ? state.data.history || [] : []; - $('historyList').innerHTML = items - .map(function (item) { - var seller = item.counterpartyUserId - ? ' · UID ' + escapeHTML(item.counterpartyUserId) - : ''; - return ( - '
' + - escapeHTML(historyLabel(item.bizType)) + - seller + - '' + - (item.availableDelta > 0 ? '+' : '') + - number(item.availableDelta) + - '' + - dateTime(item.createdAtMS) + - '
' - ); - }) - .join(''); - $('historyEmpty').textContent = t( - 'fami_wallet.no_history', - 'No withdrawal records' - ); - $('historyEmpty').hidden = items.length > 0; - if (state.data && state.data.historyUnavailable) { - $('historyEmpty').hidden = false; - $('historyEmpty').textContent = t( - 'fami_wallet.history_unavailable', - 'History is temporarily unavailable.' - ); - } - } - - function historyLabel(bizType) { - if (bizType === 'point_transfer_to_coin_seller') { - return t('fami_wallet.seller_withdrawal', 'Coin seller withdrawal'); - } - if (String(bizType).indexOf('withdrawal') >= 0) { - return t('fami_wallet.usdt_withdrawal', 'USDT withdrawal'); - } - return t('fami_wallet.point_transaction', 'POINT transaction'); - } - - function renderConversion() { - var points = amount(); - if (!state.data || !points) { - $('estimatedReceive').textContent = '—'; - return; - } - if (state.mode === 'seller') { - var seller = selectedSeller(); - var receive = seller ? ratioAmount(points, seller) : '0'; - $('estimatedReceive').textContent = - number(receive) + - ' ' + - t('fami_wallet.seller_coin', 'seller coins'); - return; - } - var fee = Math.floor((points * state.data.feeBPS) / 10000); - var net = points - fee; - $('estimatedReceive').textContent = - (net / state.data.pointsPerUSD).toFixed(2) + ' USDT'; - } - - function ratioAmount(points, seller) { - // 余额可达到万亿级,Number 中间乘法会超过安全整数;BigInt 只用于前端预计值,最终到账仍由 wallet-service 计算。 - if (typeof BigInt === 'function') { - return ( - (BigInt(points) * BigInt(seller.sellerCoinAmount)) / - BigInt(seller.pointAmount) - ).toString(); - } + function paymentAddress(payload) { + var data = ui.unwrap(payload) || {}; + var source = data.withdraw_address || data; return String( - Math.floor((points * seller.sellerCoinAmount) / seller.pointAmount) + source.withdraw_usdt_trc20_address || + source.usdt_trc20_address || + '' + ).trim(); + } + + function normalizeDealer(item) { + item = item || {}; + return { + id: String(item.user_id || ''), + displayId: String(item.display_user_id || ''), + name: String(item.nickname || 'Dealer'), + avatar: String(item.avatar || ''), + }; + } + + function amountUSD() { + return ui.number(String(ui.$('withdrawAmount').value || '').trim()); + } + + function amountPoints() { + var text = String(ui.$('withdrawAmount').value || '').trim(); + if (!/^\d+(?:\.\d{0,2})?$/.test(text)) return 0; + var parts = text.split('.'); + var usdMinor = + ui.number(parts[0]) * 100 + + ui.number((parts[1] || '').padEnd(2, '0')); + // 输入框按 Figma 使用美元符号,账本却只接收整数 POINT;用分为单位换算可避免浮点金额跨端不一致。 + return Math.round((usdMinor * state.ratio) / 100); + } + + function validTrc20(value) { + return /^T[1-9A-HJ-NP-Za-km-z]{33}$/.test(String(value || '').trim()); + } + + function applyOverview(payload) { + var data = ui.unwrap(payload) || {}; + var balance = data.balance || {}; + state.diamondBalance = ui.number(balance.available_amount); + state.ratio = ui.number(data.points_per_usd) || 1000000; + state.minimumPoints = ui.number(data.minimum_points); + state.feeBPS = ui.number(data.fee_bps); + state.dealers = Array.isArray(data.coin_sellers) + ? data.coin_sellers.map(normalizeDealer).filter(function (item) { + return item.id && item.displayId; + }) + : []; + if ( + !state.dealers.some(function (dealer) { + return dealer.id === state.selectedDealerId; + }) + ) { + state.selectedDealerId = state.dealers.length + ? state.dealers[0].id + : ''; + } + state.address = paymentAddress(data); + if (!explicitChannel) { + state.channel = state.dealers.length ? 'dealer' : 'address'; + } + } + + function renderTabs() { + document.querySelectorAll('.withdraw-tab').forEach(function (tab) { + var active = tab.getAttribute('data-mode') === 'usdt'; + tab.classList.toggle('is-active', active); + tab.setAttribute('aria-selected', String(active)); + }); + } + + function renderDealerList() { + var list = ui.$('dealerList'); + list.replaceChildren(); + state.dealers.slice(0, 3).forEach(function (dealer) { + var card = document.createElement('button'); + card.className = 'dealer-card'; + card.type = 'button'; + card.setAttribute( + 'aria-pressed', + String(dealer.id === state.selectedDealerId) + ); + var avatar = document.createElement('img'); + avatar.className = 'dealer-card__avatar'; + avatar.alt = ''; + ui.setAvatar( + avatar, + dealer.avatar, + '../../products/fami/centers/assets/fami/agency-avatar.png' + ); + var copy = document.createElement('span'); + copy.className = 'dealer-card__copy'; + var name = document.createElement('strong'); + name.textContent = dealer.name; + var id = document.createElement('span'); + id.textContent = 'ID:' + dealer.displayId; + var radio = document.createElement('img'); + radio.className = 'dealer-card__radio'; + radio.alt = ''; + radio.src = + dealer.id === state.selectedDealerId + ? '../../products/fami/centers/assets/fami/radio-selected.svg' + : '../../products/fami/centers/assets/fami/radio-unselected.svg'; + copy.appendChild(name); + copy.appendChild(id); + card.appendChild(avatar); + card.appendChild(copy); + card.appendChild(radio); + card.addEventListener('click', function () { + state.selectedDealerId = dealer.id; + renderDealerList(); + updateButtons(); + }); + list.appendChild(card); + }); + if (!state.dealers.length) { + var empty = document.createElement('p'); + empty.className = 'fami-empty'; + empty.textContent = ui.t( + 'fami.no_currency_dealer', + 'No currency dealer' + ); + list.appendChild(empty); + } + } + + function renderAddress() { + ui.setAvatar( + ui.$('addressAvatar'), + state.profileAvatar, + '../../products/fami/centers/assets/fami/agency-avatar.png' + ); + ui.$('addressTitle').textContent = state.address + ? 'USDT TRC20' + : ui.t('withdraw_exchange.no_payment_info', 'No USDT address'); + ui.$('addressText').textContent = state.address + ? state.address + : ui.t( + 'fami.no_address_copy', + 'Fill in a TRC20 USDT address before withdrawing.' + ); + ui.$('addressAddButton').hidden = !!state.address; + ui.$('addressAddButton').setAttribute( + 'aria-label', + ui.t('withdraw_exchange.save_address', 'Save address') ); } - function selectMode(mode) { - state.mode = mode === 'usdt' ? 'usdt' : 'seller'; - $('sellerPanel').hidden = state.mode !== 'seller'; - $('usdtPanel').hidden = state.mode !== 'usdt'; - document.querySelectorAll('[data-mode]').forEach(function (button) { - button.setAttribute( - 'aria-selected', - button.getAttribute('data-mode') === state.mode - ? 'true' - : 'false' - ); - }); - renderConversion(); + function updateButtons() { + var points = amountPoints(); + var commonDisabled = + state.submitting || + points <= 0 || + points > state.diamondBalance || + (state.minimumPoints > 0 && points < state.minimumPoints); + // Dealer 参考稿在空金额时仍为主色;点击后由 validateSubmit 解释原因,地址真实提交则保持不可用直到金额和地址都有效。 + ui.$('dealerSubmitButton').disabled = + state.submitting || !state.selectedDealerId; + ui.$('addressSubmitButton').disabled = commonDisabled || !state.address; + var text = state.submitting + ? ui.t('withdraw_exchange.submitting', 'Submitting...') + : ui.t('fami.withdrawal', 'Withdrawal'); + ui.$('dealerSubmitButton').textContent = text; + ui.$('addressSubmitButton').textContent = text; + } + + function renderViews() { + ui.$('dealerView').hidden = state.channel !== 'dealer'; + ui.$('addressView').hidden = state.channel !== 'address'; + ui.$('goldView').hidden = true; + } + + function render() { + renderTabs(); + ui.$('diamondBalance').textContent = ui.formatNumber( + state.diamondBalance + ); + ui.$('withdrawRatio').textContent = Math.round(state.ratio) + '=$1'; + renderViews(); + renderDealerList(); + renderAddress(); + updateButtons(); + if (contactController) contactController.setContact(state.contact); } function load() { - setBusy(true); - return window.HyGuild.wallet - .load() - .then(function (data) { - state.data = data; - renderOverview(); - renderSellers(); - renderHistory(); - }) - .catch(function () { - toast(t('fami_wallet.load_failed', 'Unable to load wallet.')); - }) - .finally(function () { - setBusy(false); - }); - } - - function submitSeller() { - var seller = selectedSeller(); - var points = amount(); - if (!seller || !validateAmount(points, state.data.minimumPoints)) - return; - setBusy(true); - window.HyGuild.wallet - .transferToCoinSeller(seller.userId, points) - .then(function () { - toast(t('fami_wallet.submitted', 'Withdrawal submitted.')); - return load(); - }) - .catch(function () { - toast( - t( - 'fami_wallet.submit_failed', - 'Unable to submit withdrawal.' - ) - ); - }) - .finally(function () { - setBusy(false); - }); - } - - function submitUSDT() { - var points = amount(); - if (!validateAmount(points, state.data.minimumPoints)) return; - var address = String($('addressInput').value || '').trim(); - if (!validTRC20(address)) { - toast( - t('fami_wallet.invalid_address', 'Enter a valid TRC20 address.') - ); + if (ui.isMock()) { + applyOverview(mock.overview); + state.profileAvatar = ui.profileFrom(mock.user, 'User').avatar; + state.contact = ui.contactFrom(mock.user); + if (explicitChannel === 'address') state.channel = 'address'; + render(); return; } - setBusy(true); - window.HyGuild.wallet - .withdrawUSDT(points, address) - .then(function () { - toast(t('fami_wallet.submitted', 'Withdrawal submitted.')); - return load(); - }) - .catch(function () { - toast( - t( - 'fami_wallet.submit_failed', - 'Unable to submit withdrawal.' - ) - ); - }) - .finally(function () { - setBusy(false); - }); - } - - function saveAddress() { - var address = String($('addressInput').value || '').trim(); - if (!validTRC20(address)) { - toast( - t('fami_wallet.invalid_address', 'Enter a valid TRC20 address.') - ); - return; - } - setBusy(true); - window.HyGuild.wallet - .saveAddress(address) - .then(function () { - state.data.address = address; - renderOverview(); - toast(t('fami_wallet.address_saved', 'Address saved.')); - }) - .catch(function () { - toast( - t( - 'fami_wallet.address_save_failed', - 'Unable to save address.' - ) - ); - }) - .finally(function () { - setBusy(false); - }); - } - - function validateAmount(points, minimum) { - if (!points || !state.data || points > state.data.balance.available) { - toast( - t( - 'fami_wallet.invalid_amount', - 'Enter an available POINT amount.' + var api = window.HyAppAPI; + if (!api || !api.pointWallet || !api.salaryWallet) { + render(); + ui.toast( + ui.t( + 'withdraw_exchange.load_failed', + 'Load failed. Try again later.' ) ); - return false; + return; } - if (points < minimum) { - toast( - t('fami_wallet.minimum_amount', 'Minimum POINT amount:') + - ' ' + - number(minimum) - ); - return false; - } - return true; - } - function validTRC20(value) { - return /^T[1-9A-HJ-NP-Za-km-z]{33}$/.test(value); - } - - function escapeHTML(value) { - return String(value || '') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - } - - function dateTime(value) { - if (!Number(value)) return '—'; - return new Date(Number(value)).toLocaleString(); - } - - function bind() { - $('backButton').addEventListener('click', function () { - if (window.history.length > 1) window.history.back(); - else if (window.HyAppBridge) window.HyAppBridge.back(); - }); - document - .querySelector('.wallet-tabs') - .addEventListener('click', function (event) { - var button = event.target.closest('[data-mode]'); - if (button) selectMode(button.getAttribute('data-mode')); - }); - $('sellerList').addEventListener('click', function (event) { - var button = event.target.closest('[data-seller-id]'); - if (!button || state.busy) return; - state.selectedSellerID = button.getAttribute('data-seller-id'); - renderSellers(); - renderConversion(); - }); - $('pointAmount').addEventListener('input', renderConversion); - $('allButton').addEventListener('click', function () { - if (!state.data) return; - $('pointAmount').value = String(state.data.balance.available); - renderConversion(); - }); - $('sellerSubmit').addEventListener('click', submitSeller); - $('usdtSubmit').addEventListener('click', submitUSDT); - $('saveAddressButton').addEventListener('click', saveAddress); - window.addEventListener('hyapp:i18n-ready', function () { - renderOverview(); - renderHistory(); - }); - } - - function init() { - bind(); - selectMode('seller'); - load().finally(function () { - if (window.HyAppBridge) { - window.HyAppBridge.ready({ - page: 'fami-wallet', - app_code: 'fami', - }); + // POINT 总览一次返回真实余额、政策比例、最低门槛、地址和白名单币商;联系方式单独读取用户资料,不能再用工资 USD 推导钻石。 + Promise.all([ + ui.safe('point-wallet-overview', function () { + return api.pointWallet.overview(); + }), + ui.safe('withdraw-contact', function () { + return api.user.me(); + }), + ]).then(function (results) { + if (results[0]) applyOverview(results[0]); + var profile = results[1] + ? ui.profileFrom(results[1], 'User') + : null; + state.profileAvatar = profile ? profile.avatar : ''; + state.contact = profile ? profile.contact : ''; + render(); + if (!results.some(Boolean)) { + ui.toast( + ui.t( + 'withdraw_exchange.load_failed', + 'Load failed. Try again later.' + ) + ); } }); } - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); - } else { - init(); + function normalizeAmount() { + var input = ui.$('withdrawAmount'); + var value = String(input.value || '') + .replace(/[^0-9.]/g, '') + .replace(/(\..*)\./g, '$1'); + var parts = value.split('.'); + if (parts.length > 1) value = parts[0] + '.' + parts[1].slice(0, 2); + input.value = value; + updateButtons(); } + + function setAddressStatus(message, success) { + var node = ui.$('addressStatus'); + node.textContent = message || ''; + node.hidden = !message; + node.classList.toggle('is-success', !!success); + } + + function openAddressEditor() { + ui.$('addressInput').value = state.address; + setAddressStatus(''); + ui.openModal('addressModal'); + window.setTimeout(function () { + ui.$('addressInput').focus(); + }, 0); + } + + function saveAddress(event) { + event.preventDefault(); + var value = String(ui.$('addressInput').value || '').trim(); + if (!validTrc20(value)) { + setAddressStatus( + ui.t( + 'withdraw_exchange.invalid_address', + 'Please enter a valid TRC20 USDT address' + ) + ); + return; + } + if (ui.isMock()) { + state.address = value; + ui.closeModal('addressModal'); + render(); + ui.toast(ui.t('withdraw_exchange.address_saved', 'Address saved')); + return; + } + var api = window.HyAppAPI && window.HyAppAPI.salaryWallet; + if (!api || !api.saveWithdrawAddress || state.savingAddress) return; + state.savingAddress = true; + ui.$('addressSaveButton').disabled = true; + ui.$('addressSaveButton').textContent = ui.t( + 'withdraw_exchange.saving', + 'Saving...' + ); + + // 提现地址由用户资料服务持久化;只在服务端确认后更新页面,失败时保留用户输入供修正。 + api.saveWithdrawAddress({ usdt_trc20_address: value }) + .then(function (response) { + state.address = paymentAddress(response) || value; + ui.closeModal('addressModal'); + render(); + ui.toast( + ui.t('withdraw_exchange.address_saved', 'Address saved') + ); + }) + .catch(function (error) { + setAddressStatus( + (error && error.message) || + ui.t( + 'withdraw_exchange.submit_failed', + 'Submit failed.' + ) + ); + }) + .finally(function () { + state.savingAddress = false; + ui.$('addressSaveButton').disabled = false; + ui.$('addressSaveButton').textContent = ui.t( + 'withdraw_exchange.save_address', + 'Save address' + ); + }); + } + + function selectedDealer() { + return state.dealers.find(function (dealer) { + return dealer.id === state.selectedDealerId; + }); + } + + function validateSubmit() { + var usd = amountUSD(); + var points = amountPoints(); + if (usd <= 0 || points <= 0) { + return ui.t( + 'withdraw_exchange.invalid_amount', + 'Please enter a valid amount' + ); + } + if (points > state.diamondBalance) { + return ui.t( + 'withdraw_exchange.amount_exceeds_balance', + 'Amount exceeds balance' + ); + } + if (state.minimumPoints > 0 && points < state.minimumPoints) { + return ui + .t( + 'withdraw_exchange.minimum_points', + 'Minimum withdrawal is {amount} diamonds' + ) + .replace('{amount}', ui.formatNumber(state.minimumPoints)); + } + if (state.channel === 'dealer' && !selectedDealer()) { + return ui.t( + 'fami.select_currency_dealer', + 'Select a Currency Dealer' + ); + } + if (state.channel === 'address' && !state.address) { + return ui.t( + 'withdraw_exchange.missing_payment_info', + 'Please add a TRC20 USDT address' + ); + } + return ''; + } + + function requestSubmit(points) { + var api = window.HyAppAPI.pointWallet; + if (state.channel === 'dealer') { + return api.transferToCoinSeller({ + command_id: ui.commandId('point-transfer'), + seller_user_id: selectedDealer().id, + point_amount: points, + }); + } + return api.withdrawUSDT({ + command_id: ui.commandId('point-withdraw'), + gross_point_amount: points, + usdt_trc20_address: state.address, + }); + } + + function submit() { + var error = validateSubmit(); + if (error) { + ui.toast(error); + return; + } + if (!state.contact) { + ui.$('contactCard').click(); + return; + } + var points = amountPoints(); + if (ui.isMock()) { + state.diamondBalance = Math.max(0, state.diamondBalance - points); + ui.$('withdrawAmount').value = ''; + render(); + ui.toast( + ui.t( + 'withdraw_exchange.withdraw_success', + 'Withdrawal submitted' + ) + ); + return; + } + if ( + !window.HyAppAPI || + !window.HyAppAPI.pointWallet || + state.submitting + ) + return; + state.submitting = true; + updateButtons(); + + // 两条资金链返回余额字段不同:币商转账返回 source_point_balance_after,链上提现返回 balance_after 快照;缺失时才本地扣减展示。 + requestSubmit(points) + .then(function (result) { + var after = + state.channel === 'dealer' + ? result && result.source_point_balance_after + : result && + result.balance_after && + result.balance_after.available_amount; + state.diamondBalance = + after === undefined || after === null + ? Math.max(0, state.diamondBalance - points) + : ui.number(after); + ui.$('withdrawAmount').value = ''; + ui.toast( + ui.t( + 'withdraw_exchange.withdraw_success', + 'Withdrawal submitted' + ) + ); + }) + .catch(function (submitError) { + ui.toast( + (submitError && submitError.message) || + ui.t( + 'withdraw_exchange.submit_failed', + 'Submit failed.' + ) + ); + }) + .finally(function () { + state.submitting = false; + render(); + }); + } + + function fallbackURL() { + var fallback = '../host-center/'; + if (identity === 'AGENCY') fallback = '../agency-center/'; + if (identity === 'BD') fallback = '../bd-center/'; + // Wallet 回跳只继承产品上下文,不回传金额、渠道等资金页状态。 + return window.HyGuild.product.buildURL(fallback); + } + + function init() { + identity = normalizeIdentity(identity); + contactController = ui.bindContact({ + onSaved: function (value) { + state.contact = value; + }, + }); + ui.$('backButton').addEventListener('click', function () { + ui.goBack(fallbackURL()); + }); + ui.$('withdrawAmount').addEventListener('input', normalizeAmount); + ui.$('dealerSubmitButton').addEventListener('click', submit); + ui.$('addressSubmitButton').addEventListener('click', submit); + ui.$('addressAddButton').addEventListener('click', openAddressEditor); + ui.$('addressCard').addEventListener('click', openAddressEditor); + ui.$('addressForm').addEventListener('submit', saveAddress); + document + .querySelectorAll('[data-address-close]') + .forEach(function (node) { + node.addEventListener('click', function () { + ui.closeModal('addressModal'); + }); + }); + window.addEventListener('hyapp:i18n-ready', render); + render(); + load(); + if (window.HyAppBridge) { + window.HyAppBridge.ready({ + page: 'fami-wallet', + app_code: 'fami', + mock: ui.isMock(), + }); + } + } + + document.addEventListener('DOMContentLoaded', init); })(); diff --git a/guild/fami/wallet/style.css b/guild/fami/wallet/style.css index 37cf370..8962fa7 100644 --- a/guild/fami/wallet/style.css +++ b/guild/fami/wallet/style.css @@ -1,396 +1,313 @@ -* { - box-sizing: border-box; -} - -html, -body { - min-height: 100%; - margin: 0; - background: var(--hy-theme-bg); - color: var(--hy-theme-text); - font-family: - Inter, - -apple-system, - BlinkMacSystemFont, - 'Segoe UI', - sans-serif; -} - -button, -input { - font: inherit; -} - -button { - cursor: pointer; -} - -.center-header { - position: sticky; - z-index: 20; - top: 0; - display: grid; - grid-template-columns: 48px 1fr 48px; - align-items: center; - min-height: calc(64px + env(safe-area-inset-top)); - padding: env(safe-area-inset-top) 12px 0; - border-bottom: 1px solid var(--hy-theme-line); - background: var(--hy-theme-bg); -} - -.center-header h1 { - margin: 0; - font-size: 22px; - text-align: center; -} - -.icon-button, -.language-button { - display: grid; - width: 44px; - height: 40px; - place-items: center; - border: 0; - border-radius: var(--hy-theme-radius-language); - background: var(--hy-theme-surface); - color: var(--hy-theme-text); - box-shadow: var(--hy-theme-shadow); -} - -.icon-button span { - font-size: 38px; - font-weight: 300; - line-height: 30px; -} - -.language-switcher { - position: relative; -} - -.language-button { - font-size: 13px; - font-weight: 800; -} - -.language-menu { +.withdraw-tabs { position: absolute; - top: 46px; - right: 0; - overflow: hidden; - width: 64px; - border: 1px solid var(--hy-theme-line); - border-radius: var(--hy-theme-radius-small); - background: var(--hy-theme-surface); - box-shadow: var(--hy-theme-modal-shadow); -} - -[dir='rtl'] .language-menu { - right: auto; + top: 88px; left: 0; -} - -.language-menu button { - width: 100%; - padding: 10px; - border: 0; - background: transparent; - color: var(--hy-theme-text); -} - -.wallet-content { - gap: 12px; -} - -.wallet-tabs { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - padding: 4px; - border: 1px solid var(--hy-theme-line); - border-radius: var(--hy-theme-radius-control); - background: var(--hy-theme-surface); -} - -.wallet-tabs button { - min-height: 40px; - border: 0; - border-radius: calc(var(--hy-theme-radius-control) - 4px); - background: transparent; - color: var(--hy-theme-muted); - font-weight: 700; -} - -.wallet-tabs button[aria-selected='true'] { - background: var(--hy-theme-primary-soft); - color: var(--hy-theme-primary-deep); -} - -.wallet-balance-card, -.amount-card { - display: grid; - gap: 8px; - padding: 18px; -} - -.wallet-balance-card > span, -.amount-card > label, -.amount-card > small { - color: var(--hy-theme-muted); - font-size: 13px; -} - -.wallet-balance-card strong { - overflow-wrap: anywhere; - color: var(--hy-theme-text); - font-size: clamp(27px, 8vw, 38px); - line-height: 1.1; -} - -.wallet-balance-card small { - color: var(--hy-theme-primary-deep); -} - -.amount-input-row { - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - align-items: center; - gap: 8px; - min-height: 48px; - padding: 0 12px; - border: 1px solid var(--hy-theme-line); - border-radius: var(--hy-theme-radius-control); - background: var(--hy-theme-surface-soft); -} - -.amount-input-row > span { - color: var(--hy-theme-primary-deep); -} - -.amount-input-row input, -.address-label input { - min-width: 0; - border: 0; - outline: 0; - background: transparent; - color: var(--hy-theme-text); - font: inherit; -} - -.amount-input-row input { - width: 100%; - font-size: 18px; - font-weight: 750; -} - -.amount-input-row button { - border: 0; - background: transparent; - color: var(--hy-theme-primary-deep); - font-weight: 750; -} - -.conversion-row { display: flex; align-items: center; - justify-content: space-between; - gap: 12px; - color: var(--hy-theme-muted); - font-size: 13px; + gap: 43px; + width: 230px; + height: 44px; + padding: 0 24px; } -.conversion-row strong { - color: var(--hy-theme-text); - font-size: 15px; -} - -#sellerPanel, -#usdtPanel, -.history-section { - display: grid; - gap: 12px; -} - -#sellerPanel[hidden], -#usdtPanel[hidden] { - display: none; -} - -.section-heading h2 { - margin: 2px 0; - color: var(--hy-theme-text); +.withdraw-tab { + position: relative; + height: 44px; + color: rgba(51, 51, 51, 0.4); font-size: 16px; + font-weight: 400; + line-height: 16px; + white-space: nowrap; } -.section-heading p { - margin: 4px 0 0; - color: var(--hy-theme-muted); +.withdraw-tab.is-active { + color: var(--fami-text); + font-weight: 500; +} + +.withdraw-tab.is-active::after { + position: absolute; + bottom: 6px; + left: 50%; + width: 21px; + height: 4px; + background: url('../../products/fami/centers/assets/fami/underline.svg') + center / 21px 4px no-repeat; + content: ''; + transform: translateX(-50%); +} + +.withdraw-amount-panel { + position: absolute; + top: 144px; + right: 12px; + left: 12px; + height: 104px; + border-radius: 12px; + padding: 10px; + background: url('../../products/fami/centers/assets/fami/withdraw-panel.svg') + center / 100% 100% no-repeat; +} + +.withdraw-balance-line { + display: flex; + align-items: center; + height: 20px; + color: var(--fami-copy); font-size: 12px; - line-height: 1.5; + font-weight: 400; + line-height: 14px; + letter-spacing: -0.48px; + text-transform: capitalize; } -.seller-list, -.history-list { - display: grid; - gap: 10px; +.withdraw-balance-line, +.withdraw-amount-input { + margin-bottom: 6px; } -.seller-card { +.withdraw-balance-value { + display: flex; + align-items: center; + gap: 7px; + margin-left: 7px; + color: var(--fami-text); + font-size: 14px; + font-weight: 500; + line-height: 18px; + letter-spacing: 0; +} + +.withdraw-balance-value img { + width: 20px; + height: 20px; + object-fit: contain; +} + +.withdraw-amount-input { + display: flex; + align-items: center; + width: 285px; + height: 31px; +} + +.withdraw-amount-input img { + width: 21px; + height: 31px; +} + +.withdraw-amount-input input { + width: 240px; + height: 31px; + border: 0; + padding: 0; + background: transparent; + color: var(--fami-text); + font-size: 19.286px; + font-weight: 400; + line-height: 27px; + letter-spacing: -0.771px; +} + +.withdraw-amount-input input::placeholder { + color: var(--fami-muted); + opacity: 1; + text-transform: capitalize; +} + +.withdraw-ratio-line { + display: flex; + align-items: center; + height: 16px; + color: var(--fami-copy); + font-size: 10px; + font-weight: 400; + line-height: 14px; + letter-spacing: -0.4px; + text-transform: capitalize; +} + +.withdraw-ratio-value { + display: flex; + align-items: center; + gap: 3px; + margin-left: 5px; + color: var(--fami-text); + font-size: 11.2px; + font-weight: 500; + line-height: 15px; + letter-spacing: 0; +} + +.withdraw-ratio-value img { + width: 16px; + height: 16px; + object-fit: contain; +} + +.withdraw-section-title { + position: absolute; + top: 256px; + left: 12px; + height: 16px; + margin: 0; + color: var(--fami-text); + font-size: 16px; + font-weight: 500; + line-height: 16px; + white-space: nowrap; +} + +.withdraw-section-copy { + position: absolute; + top: 284px; + left: 12px; + margin: 0; + color: var(--fami-copy); + font-size: 12px; + font-weight: 400; + line-height: 17px; + letter-spacing: -0.48px; + text-transform: capitalize; + white-space: nowrap; +} + +.dealer-list { + position: absolute; + top: 284px; + right: 12px; + left: 12px; display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; + gap: 12px; +} + +.dealer-card { + position: relative; + display: flex; align-items: center; gap: 12px; width: 100%; - padding: 14px; - border: 1px solid var(--hy-theme-line); - border-radius: var(--hy-theme-radius-card); - background: var(--hy-theme-surface); - color: var(--hy-theme-text); - text-align: start; - box-shadow: var(--hy-theme-shadow); + height: 76px; + border-radius: 8px; + padding: 12px; + background: #ffffff; + text-align: left; } -.seller-card[aria-pressed='true'] { - border-color: var(--hy-theme-primary-strong); - box-shadow: 0 0 0 2px var(--hy-theme-focus-ring); -} - -.seller-avatar { - display: grid; - width: 44px; - height: 44px; - place-items: center; - overflow: hidden; - border-radius: var(--hy-theme-radius-circle); - background: var(--hy-theme-primary-soft); - color: var(--hy-theme-primary-deep); - font-weight: 800; -} - -.seller-avatar img { - width: 100%; - height: 100%; +.dealer-card__avatar { + width: 52px; + height: 52px; + border-radius: 50%; object-fit: cover; } -.seller-copy { - display: grid; +.dealer-card__copy { + display: flex; min-width: 0; - gap: 3px; + flex: 1; + flex-direction: column; + gap: 8px; + color: var(--fami-text); + text-transform: capitalize; } -.seller-copy strong, -.seller-copy span { +.dealer-card__copy strong { overflow: hidden; + font-size: 16px; + font-weight: 600; + line-height: 19px; text-overflow: ellipsis; white-space: nowrap; } -.seller-copy span, -.seller-ratio { - color: var(--hy-theme-muted); +.dealer-card__copy span { font-size: 12px; + font-weight: 500; + line-height: 14px; } -.seller-check { - color: var(--hy-theme-primary-deep); - font-size: 20px; +.dealer-card__radio { + width: 20px; + height: 20px; } -.wallet-submit, -.secondary-submit { - min-height: 48px; - border-radius: var(--hy-theme-radius-control); - font-weight: 800; +.withdraw-submit, +.address-submit-button, +.gold-submit-button { + position: absolute; + top: 599px; + left: 40px; } -.wallet-submit { - border: 0; - background: var(--hy-theme-button); - color: white; - box-shadow: var(--hy-theme-button-shadow); +.withdraw-address-card { + position: absolute; + top: 313px; + right: 12px; + left: 12px; + display: flex; + align-items: center; + gap: 12px; + min-height: 96px; + border-radius: 8px; + padding: 12px; + background: #ffffff; + text-align: left; } -.secondary-submit { - border: 1px solid var(--hy-theme-primary-strong); - background: var(--hy-theme-primary-soft); - color: var(--hy-theme-primary-deep); +.withdraw-address-card__avatar { + width: 72px; + height: 72px; + border-radius: 50%; + object-fit: cover; + object-position: center 26%; } -.wallet-submit:disabled, -.secondary-submit:disabled { - background: var(--hy-theme-button-disabled); - color: var(--hy-theme-button-disabled-text); - box-shadow: none; -} - -.address-status, -.address-label { - padding: 14px; - border: 1px solid var(--hy-theme-line); - border-radius: var(--hy-theme-radius-control); - background: var(--hy-theme-surface); -} - -.address-status { - color: var(--hy-theme-muted); - font-size: 13px; - overflow-wrap: anywhere; -} - -.address-status.has-address { - color: var(--hy-theme-primary-deep); -} - -.address-label { - display: grid; +.withdraw-address-card__copy { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; gap: 8px; - color: var(--hy-theme-muted); + color: var(--fami-text); + text-transform: capitalize; +} + +.withdraw-address-card__copy strong { + font-size: 16px; + font-weight: 600; + line-height: 19px; +} + +.withdraw-address-card__copy span { + max-width: 166px; + overflow-wrap: anywhere; font-size: 12px; + font-weight: 500; + line-height: 17px; } -.address-label input { - padding: 6px 0; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +.address-add-button { + position: absolute; + top: 526px; + left: 48px; } -.history-item { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 6px 12px; - padding: 14px; - border: 1px solid var(--hy-theme-line); - border-radius: var(--hy-theme-radius-control); - background: var(--hy-theme-surface); +.address-submit-button { + top: 598px; + left: 48px; } -.history-item strong { - color: var(--hy-theme-text); +.gold-submit-button { + top: 526px; } -.history-item .history-amount { - color: var(--hy-theme-danger); - font-weight: 800; +[dir='rtl'] .withdraw-balance-value, +[dir='rtl'] .withdraw-ratio-value { + margin-right: 7px; + margin-left: 0; } -.history-item small { - grid-column: 1 / -1; - color: var(--hy-theme-muted); -} - -.empty-state { - display: grid; - gap: 5px; - padding: 20px; - border: 1px dashed var(--hy-theme-line); - border-radius: var(--hy-theme-radius-card); - color: var(--hy-theme-muted); - text-align: center; -} - -.empty-state[hidden] { - display: none; -} - -body.is-busy button, -body.is-busy input { - pointer-events: none; +[dir='rtl'] .dealer-card, +[dir='rtl'] .withdraw-address-card { + text-align: right; } diff --git a/guild/products/fami/centers/assets/fami/add-circle.svg b/guild/products/fami/centers/assets/fami/add-circle.svg new file mode 100644 index 0000000..046691d --- /dev/null +++ b/guild/products/fami/centers/assets/fami/add-circle.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/guild/products/fami/centers/assets/fami/agency-avatar.png b/guild/products/fami/centers/assets/fami/agency-avatar.png new file mode 100644 index 0000000..de5e12b Binary files /dev/null and b/guild/products/fami/centers/assets/fami/agency-avatar.png differ diff --git a/guild/products/fami/centers/assets/fami/back.svg b/guild/products/fami/centers/assets/fami/back.svg new file mode 100644 index 0000000..68ef6f3 --- /dev/null +++ b/guild/products/fami/centers/assets/fami/back.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/guild/products/fami/centers/assets/fami/balance-bg.svg b/guild/products/fami/centers/assets/fami/balance-bg.svg new file mode 100644 index 0000000..adb9ab8 --- /dev/null +++ b/guild/products/fami/centers/assets/fami/balance-bg.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/guild/products/fami/centers/assets/fami/balance-decor.svg b/guild/products/fami/centers/assets/fami/balance-decor.svg new file mode 100644 index 0000000..d3e24af --- /dev/null +++ b/guild/products/fami/centers/assets/fami/balance-decor.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/guild/products/fami/centers/assets/fami/battery.svg b/guild/products/fami/centers/assets/fami/battery.svg new file mode 100644 index 0000000..357aee6 --- /dev/null +++ b/guild/products/fami/centers/assets/fami/battery.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/guild/products/fami/centers/assets/fami/bd-avatar.png b/guild/products/fami/centers/assets/fami/bd-avatar.png new file mode 100644 index 0000000..de5e12b Binary files /dev/null and b/guild/products/fami/centers/assets/fami/bd-avatar.png differ diff --git a/guild/products/fami/centers/assets/fami/cellular.svg b/guild/products/fami/centers/assets/fami/cellular.svg new file mode 100644 index 0000000..8f1d2b9 --- /dev/null +++ b/guild/products/fami/centers/assets/fami/cellular.svg @@ -0,0 +1,3 @@ + + + diff --git a/guild/products/fami/centers/assets/fami/diamond.png b/guild/products/fami/centers/assets/fami/diamond.png new file mode 100644 index 0000000..3393b20 Binary files /dev/null and b/guild/products/fami/centers/assets/fami/diamond.png differ diff --git a/guild/products/fami/centers/assets/fami/dollar.svg b/guild/products/fami/centers/assets/fami/dollar.svg new file mode 100644 index 0000000..e0b8c98 --- /dev/null +++ b/guild/products/fami/centers/assets/fami/dollar.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/guild/products/fami/centers/assets/fami/edit.svg b/guild/products/fami/centers/assets/fami/edit.svg new file mode 100644 index 0000000..b83714a --- /dev/null +++ b/guild/products/fami/centers/assets/fami/edit.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/guild/products/fami/centers/assets/fami/home-indicator.svg b/guild/products/fami/centers/assets/fami/home-indicator.svg new file mode 100644 index 0000000..ee22b37 --- /dev/null +++ b/guild/products/fami/centers/assets/fami/home-indicator.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/guild/products/fami/centers/assets/fami/host-avatar.png b/guild/products/fami/centers/assets/fami/host-avatar.png new file mode 100644 index 0000000..de5e12b Binary files /dev/null and b/guild/products/fami/centers/assets/fami/host-avatar.png differ diff --git a/guild/products/fami/centers/assets/fami/language.svg b/guild/products/fami/centers/assets/fami/language.svg new file mode 100644 index 0000000..0e4275f --- /dev/null +++ b/guild/products/fami/centers/assets/fami/language.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/guild/products/fami/centers/assets/fami/manifest.json b/guild/products/fami/centers/assets/fami/manifest.json new file mode 100644 index 0000000..f769879 --- /dev/null +++ b/guild/products/fami/centers/assets/fami/manifest.json @@ -0,0 +1,434 @@ +{ + "schemaVersion": 1, + "rule": "Layered restoration only. Root frames, full-page screenshots, dynamic text, controls, and production avatars are not implementation assets.", + "figma": { + "fileKey": "vaDp5wwn6AOQVlt9zk0f9r", + "page": { + "id": "1183:578" + }, + "entryFrames": { + "agencySummary": "3843:5032", + "agencyList": "3843:5157", + "withdrawalDealer": "3843:5291", + "withdrawalAddress": "3843:5388", + "host": "3843:5462", + "bd": "3843:5586" + } + }, + "baseFrame": { + "width": 375, + "height": 812 + }, + "assets": [ + { + "file": "add-circle.svg", + "sourceNode": "3855:6214", + "equivalentNodes": [], + "sourceName": "add-circle", + "sourceType": "frame", + "purpose": "Add action ring icon", + "bounds": { + "width": 32, + "height": 32 + }, + "exportFormat": "svg", + "bytes": 733, + "sha256": "04a749ba0bb515d7171d8ab9698444864c55e685a93cded7f844db9a7b6f54e0", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "agency-avatar.png", + "sourceNode": "3843:5191", + "equivalentNodes": ["3843:5454"], + "sourceName": "Rectangle 346254347", + "sourceType": "rounded-rectangle", + "purpose": "Figma sample agency avatar for explicit mock mode only", + "bounds": { + "width": 182, + "height": 256 + }, + "exportFormat": "png", + "bytes": 66292, + "sha256": "8e0964e9d80a1be5e1e0cde679e0f9f65e27feb32a6aa1378caac943ecc801d0", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": true, + "testOnly": true + }, + { + "file": "back.svg", + "sourceNode": "3843:5483", + "equivalentNodes": [ + "3843:5178", + "3843:5312", + "3843:5409", + "3843:5607" + ], + "sourceName": "icon_20_返回", + "sourceType": "frame", + "purpose": "Navigation back icon", + "bounds": { + "width": 24, + "height": 24 + }, + "exportFormat": "svg", + "bytes": 421, + "sha256": "00bd083f754215810e7a4b5096f6d6c609568fc074a39efafb3ffb72b6a05102", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "balance-bg.svg", + "sourceNode": "3855:6195", + "equivalentNodes": [], + "sourceName": "Rectangle 346254298", + "sourceType": "rounded-rectangle", + "purpose": "Balance card background layer", + "bounds": { + "width": 347, + "height": 99 + }, + "exportFormat": "svg", + "bytes": 594, + "sha256": "6d81c61afc8925007f56b3003a99bbaa9598a786d8df6842e0966ada71af495b", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "balance-decor.svg", + "sourceNode": "3855:6198", + "equivalentNodes": [], + "sourceName": "Mask group", + "sourceType": "frame", + "purpose": "Balance card decorative overlay", + "bounds": { + "width": 347, + "height": 99 + }, + "exportFormat": "svg", + "bytes": 770, + "sha256": "01fa435d8dc5b71ef0e69aa279ba7a8c52512490e25a28d9944222d51ac11127", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "battery.svg", + "sourceNode": "3843:5466", + "equivalentNodes": [], + "sourceName": "Battery", + "sourceType": "frame", + "purpose": "Status bar battery icon", + "bounds": { + "width": 24.3307, + "height": 11.3333 + }, + "exportFormat": "svg", + "bytes": 1055, + "sha256": "7155474b16d39cd4882a002e4567aede4f1fd82859c2318b9b52fe397bb7cbfe", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "bd-avatar.png", + "sourceNode": "3843:5620", + "equivalentNodes": [], + "sourceName": "Rectangle 346254347", + "sourceType": "rounded-rectangle", + "purpose": "Figma sample BD avatar for explicit mock mode only", + "bounds": { + "width": 182, + "height": 256 + }, + "exportFormat": "png", + "bytes": 66292, + "sha256": "8e0964e9d80a1be5e1e0cde679e0f9f65e27feb32a6aa1378caac943ecc801d0", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": true, + "testOnly": true + }, + { + "file": "cellular.svg", + "sourceNode": "3843:5474", + "equivalentNodes": [], + "sourceName": "Cellular Connection", + "sourceType": "boolean-operation", + "purpose": "Status bar cellular icon", + "bounds": { + "width": 17.0001, + "height": 11.3317 + }, + "exportFormat": "svg", + "bytes": 1183, + "sha256": "5d9a790724585de1a0c1d9b138de2bb05572ea9017d5469936877c680efd78fa", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "diamond.png", + "sourceNode": "3855:6204", + "equivalentNodes": [], + "sourceName": "rdfgfdse。 2", + "sourceType": "rounded-rectangle", + "purpose": "Diamond currency ornament", + "bounds": { + "width": 256, + "height": 217 + }, + "exportFormat": "png", + "bytes": 74620, + "sha256": "78d9c4563e30318b7ae8e0cadcd4939033b06c7edbfcc266e9e99b56d239a5c8", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "dollar.svg", + "sourceNode": "3846:5959", + "equivalentNodes": ["3852:5978"], + "sourceName": "美元 1", + "sourceType": "frame", + "purpose": "USDT withdrawal currency icon", + "bounds": { + "width": 21, + "height": 31 + }, + "exportFormat": "svg", + "bytes": 995, + "sha256": "d0c1c3375e83e6f28b923afd67662cdd07d50ccfadf8176f57edf211992859a4", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "edit.svg", + "sourceNode": "3843:5510", + "equivalentNodes": [], + "sourceName": "card-edit", + "sourceType": "frame", + "purpose": "Contact edit icon", + "bounds": { + "width": 20, + "height": 20 + }, + "exportFormat": "svg", + "bytes": 1762, + "sha256": "0e37f885855e6d063ea974ce975f96c622535881cf686ef0ee3b9a8d5684b40e", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "home-indicator.svg", + "sourceNode": "3843:5490", + "equivalentNodes": [], + "sourceName": "Group 594", + "sourceType": "frame", + "purpose": "iOS home indicator decoration", + "bounds": { + "width": 375, + "height": 24 + }, + "exportFormat": "svg", + "bytes": 430, + "sha256": "3d8ee172d4a3951e3039d95deb08a75351ea03d3dea344c88a2e66c3448157b1", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "host-avatar.png", + "sourceNode": "3843:5495", + "equivalentNodes": [], + "sourceName": "Rectangle 346254347", + "sourceType": "rounded-rectangle", + "purpose": "Figma sample host avatar for explicit mock mode only", + "bounds": { + "width": 182, + "height": 256 + }, + "exportFormat": "png", + "bytes": 66292, + "sha256": "8e0964e9d80a1be5e1e0cde679e0f9f65e27feb32a6aa1378caac943ecc801d0", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": true, + "testOnly": true + }, + { + "file": "language.svg", + "sourceNode": "3843:5487", + "equivalentNodes": [], + "sourceName": "language", + "sourceType": "frame", + "purpose": "Language selector icon", + "bounds": { + "width": 24, + "height": 24 + }, + "exportFormat": "svg", + "bytes": 2539, + "sha256": "beb43fafc2f6cd366a30a060b2c7cd94573997ad6c372b2a20cf91e51c3b9fbb", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "radio-selected.svg", + "sourceNode": "3843:5359", + "equivalentNodes": [], + "sourceName": "selected", + "sourceType": "frame", + "purpose": "Selected withdrawal method indicator", + "bounds": { + "width": 20, + "height": 20 + }, + "exportFormat": "svg", + "bytes": 1063, + "sha256": "86056100a8d2f56c2193ad44c40918e8c1d999eb75a83592ac28a392c274e30e", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "radio-unselected.svg", + "sourceNode": "3843:5734", + "equivalentNodes": ["3843:5382"], + "sourceName": "unselected", + "sourceType": "frame", + "purpose": "Unselected withdrawal method indicator", + "bounds": { + "width": 20, + "height": 20 + }, + "exportFormat": "svg", + "bytes": 641, + "sha256": "7bb1f3a3525b836953adc0ab8e39573b87625b7dbf80785236aafe95527e8328", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "search.svg", + "sourceNode": "3846:5899", + "equivalentNodes": [], + "sourceName": "search", + "sourceType": "frame", + "purpose": "User search icon", + "bounds": { + "width": 22.5, + "height": 22.5 + }, + "exportFormat": "svg", + "bytes": 1487, + "sha256": "4f707f812acb120e7bc8a4da6148caee54173a9a642caaf7db0ca049fc388a3d", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "status-frame.svg", + "sourceNode": "3843:5464", + "equivalentNodes": [ + "3843:5034", + "3843:5159", + "3843:5293", + "3843:5390", + "3843:5588" + ], + "sourceName": "Status frame", + "sourceType": "vector", + "purpose": "Status bar time capsule outline", + "bounds": { + "width": 375, + "height": 44 + }, + "exportFormat": "svg", + "bytes": 290, + "sha256": "5d348be81d6922e9b852cc83d306a127f5edad65944fece04c8fe722e734fa98", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "underline.svg", + "sourceNode": "3843:5226", + "equivalentNodes": ["3843:5324", "3843:5421"], + "sourceName": "Vector 1367", + "sourceType": "vector", + "purpose": "Active tab underline ornament", + "bounds": { + "width": 21.0008, + "height": 6.00032 + }, + "exportFormat": "svg", + "bytes": 355, + "sha256": "467a896f256be9bb8875704523f320ea2daea924234761a5f3790260c93f4545", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "whatsapp.svg", + "sourceNode": "3843:5502", + "equivalentNodes": [], + "sourceName": "Group", + "sourceType": "frame", + "purpose": "WhatsApp contact icon", + "bounds": { + "width": 41, + "height": 41 + }, + "exportFormat": "svg", + "bytes": 2475, + "sha256": "4f8db36944e68870672c0bec7dce4c5b29459b8f510436608173db593ab7fe0c", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "wifi.svg", + "sourceNode": "3843:5470", + "equivalentNodes": [], + "sourceName": "Wifi", + "sourceType": "boolean-operation", + "purpose": "Status bar Wi-Fi icon", + "bounds": { + "width": 15.3333, + "height": 11.0067 + }, + "exportFormat": "svg", + "bytes": 1655, + "sha256": "6a8f4ca420ed035e828c0435ee5e8d30412f1b8fb0ea2845fea1e5e66e60b896", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + }, + { + "file": "withdraw-panel.svg", + "sourceNode": "3843:5328", + "equivalentNodes": ["3852:5969"], + "sourceName": "Group 2090056481", + "sourceType": "frame", + "purpose": "Withdrawal method panel background", + "bounds": { + "width": 351, + "height": 104 + }, + "exportFormat": "svg", + "bytes": 1043, + "sha256": "fc314cfeeb6e5793c2f0f1de709aad42f584a71b9982992ab1628b6628b6082f", + "containsText": false, + "containsControls": false, + "containsDynamicMedia": false + } + ] +} diff --git a/guild/products/fami/centers/assets/fami/radio-selected.svg b/guild/products/fami/centers/assets/fami/radio-selected.svg new file mode 100644 index 0000000..0dd4682 --- /dev/null +++ b/guild/products/fami/centers/assets/fami/radio-selected.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/guild/products/fami/centers/assets/fami/radio-unselected.svg b/guild/products/fami/centers/assets/fami/radio-unselected.svg new file mode 100644 index 0000000..6b6e05c --- /dev/null +++ b/guild/products/fami/centers/assets/fami/radio-unselected.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/guild/products/fami/centers/assets/fami/search.svg b/guild/products/fami/centers/assets/fami/search.svg new file mode 100644 index 0000000..b0d8077 --- /dev/null +++ b/guild/products/fami/centers/assets/fami/search.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/guild/products/fami/centers/assets/fami/status-frame.svg b/guild/products/fami/centers/assets/fami/status-frame.svg new file mode 100644 index 0000000..0bbabb7 --- /dev/null +++ b/guild/products/fami/centers/assets/fami/status-frame.svg @@ -0,0 +1,3 @@ + + + diff --git a/guild/products/fami/centers/assets/fami/underline.svg b/guild/products/fami/centers/assets/fami/underline.svg new file mode 100644 index 0000000..0ce264d --- /dev/null +++ b/guild/products/fami/centers/assets/fami/underline.svg @@ -0,0 +1,3 @@ + + + diff --git a/guild/products/fami/centers/assets/fami/whatsapp.svg b/guild/products/fami/centers/assets/fami/whatsapp.svg new file mode 100644 index 0000000..497da59 --- /dev/null +++ b/guild/products/fami/centers/assets/fami/whatsapp.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/guild/products/fami/centers/assets/fami/wifi.svg b/guild/products/fami/centers/assets/fami/wifi.svg new file mode 100644 index 0000000..ccf59fc --- /dev/null +++ b/guild/products/fami/centers/assets/fami/wifi.svg @@ -0,0 +1,3 @@ + + + diff --git a/guild/products/fami/centers/assets/fami/withdraw-panel.svg b/guild/products/fami/centers/assets/fami/withdraw-panel.svg new file mode 100644 index 0000000..4e1a183 --- /dev/null +++ b/guild/products/fami/centers/assets/fami/withdraw-panel.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/guild/products/fami/centers/fami.css b/guild/products/fami/centers/fami.css new file mode 100644 index 0000000..7d5cf4a --- /dev/null +++ b/guild/products/fami/centers/fami.css @@ -0,0 +1,735 @@ +:root { + --fami-primary: #0fe3d4; + --fami-primary-strong: #0ccfc2; + --fami-bg: #f5f5f5; + --fami-surface: #ffffff; + --fami-text: #333333; + --fami-copy: #5c5a4b; + --fami-muted: #878787; + --fami-disabled: #e4e4e4; + --fami-disabled-text: #b1b1b1; +} + +* { + box-sizing: border-box; +} + +html, +body { + width: 100%; + min-height: 100%; + margin: 0; + -webkit-font-smoothing: antialiased; + -webkit-tap-highlight-color: transparent; + -webkit-text-size-adjust: 100%; +} + +body { + display: flex; + justify-content: center; + overflow-x: hidden; + background: #111111; + color: var(--fami-text); + font-family: + -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF Pro Display', + 'Segoe UI', Helvetica, Arial, sans-serif; +} + +button, +input { + font: inherit; +} + +button { + margin: 0; + border: 0; + padding: 0; + background: transparent; + color: inherit; +} + +button, +[role='button'] { + cursor: pointer; +} + +button:focus, +input:focus { + outline: none; +} + +button:focus-visible, +input:focus-visible { + outline: 2px solid rgba(15, 227, 212, 0.55); + outline-offset: 2px; +} + +[hidden] { + display: none !important; +} + +img.is-empty { + background: var(--fami-disabled); +} + +body.modal-open { + overflow: hidden; +} + +.fami-page { + position: relative; + width: 100%; + max-width: 375px; + min-height: max(812px, 100dvh); + overflow-x: hidden; + background: var(--fami-bg); +} + +.fami-status-bar { + position: absolute; + z-index: 10; + top: 0; + left: 0; + width: 100%; + height: 44px; + background: #ffffff; + direction: ltr; +} + +.fami-status-time { + position: absolute; + top: 13px; + left: 33.867px; + width: 54px; + height: 15px; + color: #000000; + font-family: Helvetica, Arial, sans-serif; + font-size: 15px; + font-weight: 400; + line-height: 15px; + text-align: center; +} + +.fami-status-icon { + position: absolute; + display: block; +} + +.fami-status-icon--cellular { + top: 15px; + right: 63.66px; + width: 17px; + height: 11.332px; +} + +.fami-status-icon--wifi { + top: 16.327px; + right: 43.33px; + width: 15.333px; + height: 11.007px; +} + +.fami-status-icon--battery { + top: 16.335px; + right: 14px; + width: 24.331px; + height: 11.333px; +} + +.fami-nav { + position: absolute; + z-index: 9; + top: 44px; + left: 0; + width: 100%; + height: 44px; + background: #ffffff; + direction: ltr; +} + +.fami-nav__back, +.fami-nav__language { + position: absolute; + top: 10px; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; +} + +.fami-nav__back { + left: 12px; +} + +.fami-nav__language { + right: 12px; +} + +.fami-nav__back img, +.fami-nav__language img { + display: block; + width: 24px; + height: 24px; +} + +.fami-nav__title { + position: absolute; + top: 11px; + left: 50%; + margin: 0; + color: var(--fami-text); + font-size: 18px; + font-weight: 600; + line-height: 18px; + text-align: center; + text-transform: capitalize; + white-space: nowrap; + transform: translateX(-50%); +} + +.language-switcher { + position: absolute; + top: 0; + right: 0; + width: 48px; + height: 44px; +} + +.language-menu { + position: absolute; + z-index: 40; + top: 42px; + right: 8px; + display: grid; + gap: 2px; + width: 70px; + padding: 5px; + border: 1px solid rgba(51, 51, 51, 0.08); + border-radius: 8px; + background: #ffffff; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.14); +} + +.language-menu button { + min-height: 32px; + border-radius: 6px; + font-size: 12px; + font-weight: 600; +} + +.language-menu button.is-active { + background: rgba(15, 227, 212, 0.14); + color: #087d75; +} + +.fami-card { + border-radius: 8px; + background: var(--fami-surface); +} + +.fami-profile { + position: absolute; + top: 100px; + right: 12px; + left: 12px; + display: flex; + align-items: center; + gap: 12px; + height: 96px; + padding: 12px; +} + +.fami-profile__avatar { + display: block; + flex: 0 0 auto; + width: 72px; + height: 72px; + border-radius: 8px; + object-fit: cover; + object-position: center 26%; +} + +.fami-profile__avatar.is-round { + border-radius: 999px; +} + +.fami-profile__copy, +.fami-contact__copy { + display: flex; + min-width: 0; + flex-direction: column; + align-items: flex-start; + color: var(--fami-text); + text-transform: capitalize; +} + +.fami-profile__copy { + gap: 8px; +} + +.fami-profile__name, +.fami-contact__name { + overflow: hidden; + width: 100%; + font-size: 16px; + font-weight: 600; + line-height: 19px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fami-profile__meta, +.fami-contact__meta { + overflow: hidden; + width: 100%; + font-size: 12px; + font-weight: 500; + line-height: 14px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fami-profile__note { + overflow: hidden; + width: 100%; + color: #b1b1b1; + font-size: 10px; + font-weight: 600; + line-height: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fami-contact { + position: absolute; + top: 208px; + right: 12px; + left: 12px; + display: flex; + align-items: center; + gap: 12px; + height: 65px; + padding: 12px; + text-align: left; +} + +.fami-contact__icon { + display: block; + flex: 0 0 auto; + width: 41px; + height: 41px; +} + +.fami-contact__copy { + gap: 8px; + flex: 1 1 auto; +} + +.fami-contact__edit { + display: block; + flex: 0 0 auto; + width: 20px; + height: 20px; +} + +.fami-balance-card { + position: absolute; + top: 284px; + right: 16px; + left: 12px; + height: 99px; + overflow: hidden; + border-radius: 12px; + background: url('./assets/fami/balance-bg.svg') center / 100% 100% no-repeat; +} + +.fami-balance-card__label { + position: absolute; + top: 12px; + left: 12px; + color: var(--fami-copy); + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: -0.56px; + text-transform: capitalize; +} + +.fami-balance-card__value { + position: absolute; + top: 45px; + left: 12px; + display: flex; + align-items: center; + gap: 9px; + min-width: 180px; + height: 41px; +} + +.fami-balance-card__value img { + display: block; + width: 41px; + height: 41px; + object-fit: contain; +} + +.fami-balance-card__value strong { + color: var(--fami-text); + font-size: 18px; + font-weight: 500; + line-height: 18px; + white-space: nowrap; +} + +.fami-balance-card__withdraw { + position: absolute; + top: 62px; + right: 12px; + display: flex; + align-items: center; + justify-content: center; + min-width: 89px; + height: 25px; + border-radius: 25px; + padding: 4px 12px; + background: var(--fami-primary); + color: #ffffff; + font-size: 12px; + font-weight: 700; + line-height: 17px; + letter-spacing: -0.5px; + text-align: center; + white-space: nowrap; +} + +.fami-date-strip { + position: absolute; + right: 12px; + left: 12px; + display: flex; + align-items: center; + height: 24px; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + scroll-snap-type: x proximity; +} + +.fami-date-strip::-webkit-scrollbar { + display: none; +} + +.fami-date-strip__item { + position: relative; + flex: 0 0 49px; + height: 24px; + color: rgba(51, 51, 51, 0.4); + font-size: 14px; + font-weight: 600; + line-height: 20px; + text-align: center; + white-space: nowrap; + scroll-snap-align: center; +} + +.fami-date-strip__item.is-active { + color: var(--fami-text); +} + +.fami-date-strip__item:first-child { + flex-basis: 42px; + text-align: left; +} + +.fami-date-strip__item + .fami-date-strip__item { + margin-left: 6px; +} + +.fami-date-strip__item.is-active::after { + position: absolute; + right: 50%; + bottom: 0; + width: 14px; + height: 4px; + background: url('./assets/fami/underline.svg') center / 14px 4px no-repeat; + content: ''; + transform: translateX(50%); +} + +.fami-primary-button { + display: flex; + align-items: center; + justify-content: center; + width: 295px; + height: 56px; + border-radius: 37px; + background: var(--fami-primary); + color: #ffffff; + font-size: 18px; + font-weight: 700; + line-height: 22px; + text-align: center; +} + +.fami-primary-button:active { + background: var(--fami-primary-strong); +} + +.fami-primary-button:disabled, +.fami-primary-button.is-disabled { + background: var(--fami-disabled); + color: var(--fami-disabled-text); +} + +.fami-primary-button__icon { + width: 32px; + height: 32px; + margin-right: 11px; +} + +.fami-home-indicator { + position: fixed; + z-index: 8; + bottom: 0; + left: 50%; + width: min(100%, 375px); + height: 24px; + background: var(--fami-bg) url('./assets/fami/home-indicator.svg') center / + 375px 24px no-repeat; + pointer-events: none; + transform: translateX(-50%); +} + +.fami-empty { + color: var(--fami-muted); + font-size: 12px; + line-height: 18px; + text-align: center; +} + +.fami-skeleton { + position: relative; + overflow: hidden; + color: transparent !important; + border-radius: 4px; + background: #ececec; +} + +.fami-skeleton::after { + position: absolute; + inset: 0; + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.76), + transparent + ); + content: ''; + transform: translateX(-100%); + animation: fami-skeleton 1.2s infinite; +} + +@keyframes fami-skeleton { + to { + transform: translateX(100%); + } +} + +.fami-modal { + position: fixed; + z-index: 60; + inset: 0; + display: flex; + align-items: flex-end; + justify-content: center; +} + +.fami-modal__backdrop { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.42); +} + +.fami-sheet { + position: relative; + z-index: 1; + width: min(100%, 375px); + max-height: 78dvh; + overflow-y: auto; + border-radius: 20px 20px 0 0; + padding: 20px 20px calc(24px + env(safe-area-inset-bottom)); + background: #ffffff; +} + +.fami-sheet__head { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 32px; + margin-bottom: 18px; +} + +.fami-sheet__head h2 { + margin: 0; + color: var(--fami-text); + font-size: 18px; + font-weight: 700; + line-height: 24px; +} + +.fami-sheet__close { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + background: #f1f1f1; + color: var(--fami-text); + font-size: 22px; + line-height: 1; +} + +.fami-field { + display: grid; + gap: 8px; +} + +.fami-field > span { + color: var(--fami-copy); + font-size: 13px; + font-weight: 600; +} + +.fami-field input, +.fami-search-control input { + width: 100%; + height: 48px; + border: 1px solid #e8e8e8; + border-radius: 12px; + padding: 0 14px; + background: #f8f8f8; + color: var(--fami-text); + font-size: 15px; +} + +.fami-search-control { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; +} + +.fami-search-control button { + min-width: 78px; + height: 48px; + border-radius: 12px; + padding: 0 14px; + background: var(--fami-primary); + color: #ffffff; + font-size: 14px; + font-weight: 700; +} + +.fami-sheet__action { + width: 100%; + height: 50px; + margin-top: 16px; + border-radius: 25px; + background: var(--fami-primary); + color: #ffffff; + font-size: 16px; + font-weight: 700; +} + +.fami-sheet__action:disabled { + background: var(--fami-disabled); + color: var(--fami-disabled-text); +} + +.fami-sheet__status { + margin-top: 10px; + color: #c73a4c; + font-size: 12px; + line-height: 18px; +} + +.fami-sheet__status.is-success { + color: #09958b; +} + +.fami-search-result { + display: flex; + align-items: center; + gap: 12px; + min-height: 76px; + margin-top: 14px; + border-radius: 8px; + padding: 12px; + background: var(--fami-bg); +} + +.fami-search-result img { + width: 52px; + height: 52px; + border-radius: 50%; + object-fit: cover; +} + +.fami-search-result__copy { + display: grid; + min-width: 0; + flex: 1; + gap: 6px; +} + +.fami-search-result__copy strong { + overflow: hidden; + font-size: 15px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fami-search-result__copy span { + color: var(--fami-copy); + font-size: 12px; +} + +.fami-search-result__action { + min-width: 66px; + height: 34px; + border-radius: 18px; + padding: 0 12px; + background: var(--fami-primary); + color: #ffffff; + font-size: 12px; + font-weight: 700; +} + +[dir='rtl'] .fami-contact { + text-align: right; +} + +[dir='rtl'] .fami-profile__copy, +[dir='rtl'] .fami-contact__copy { + align-items: flex-end; +} + +[dir='rtl'] .fami-primary-button__icon { + margin-right: 0; + margin-left: 11px; +} + +@media (max-width: 374px) { + .fami-page { + min-height: 100dvh; + } +} + +@media (prefers-reduced-motion: reduce) { + .fami-skeleton::after { + animation: none; + } +} diff --git a/guild/products/fami/centers/fami.js b/guild/products/fami/centers/fami.js new file mode 100644 index 0000000..f4c6bb1 --- /dev/null +++ b/guild/products/fami/centers/fami.js @@ -0,0 +1,402 @@ +(function () { + var params = new URLSearchParams(window.location.search); + + function $(id) { + return document.getElementById(id); + } + + function t(key, fallback) { + return window.HyAppI18n && window.HyAppI18n.t + ? window.HyAppI18n.t(key, fallback) + : fallback || key; + } + + function toast(message) { + if (message && window.HyAppToast && window.HyAppToast.show) { + window.HyAppToast.show(message); + } + } + + function unwrap(payload) { + if (!payload || typeof payload !== 'object') return payload; + return Object.prototype.hasOwnProperty.call(payload, 'data') + ? payload.data + : payload; + } + + function first(source, keys, fallback) { + var target = source || {}; + for (var index = 0; index < keys.length; index += 1) { + var value = target[keys[index]]; + if (value !== undefined && value !== null && value !== '') { + return value; + } + } + return fallback === undefined ? '' : fallback; + } + + function number(value) { + var result = Number(value); + return Number.isFinite(result) ? result : 0; + } + + function formatNumber(value, maximumFractionDigits) { + return number(value).toLocaleString('en-US', { + maximumFractionDigits: + maximumFractionDigits === undefined ? 0 : maximumFractionDigits, + }); + } + + function walletAvailable(payload, assetType) { + var data = unwrap(payload) || {}; + var balances = Array.isArray(data) ? data : data.balances || []; + var expected = String(assetType || '').toUpperCase(); + var balance = balances.find(function (item) { + return ( + String((item && item.asset_type) || '').toUpperCase() === + expected + ); + }); + return balance ? number(balance.available_amount) : 0; + } + + function formatDate(value, options) { + if (!value) return ''; + var date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return ''; + var language = + window.HyAppI18n && window.HyAppI18n.lang + ? window.HyAppI18n.lang() + : 'en'; + var locale = { + en: 'en-US', + ar: 'ar', + tr: 'tr-TR', + es: 'es-ES', + zh: 'zh-CN', + id: 'id-ID', + }[language]; + try { + return date.toLocaleString( + locale || 'en-US', + options || { + month: 'short', + day: 'numeric', + } + ); + } catch (_) { + return date.toLocaleString('en-US', options || {}); + } + } + + function dateKey(value) { + var date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return ''; + return [ + date.getFullYear(), + String(date.getMonth() + 1).padStart(2, '0'), + String(date.getDate()).padStart(2, '0'), + ].join('-'); + } + + function dateWindow(count) { + var result = []; + var total = Math.max(1, Number(count || 7)); + var now = new Date(); + // stats 服务按 UTC 自然日聚合;日期窗也必须从 UTC 零点递减,否则 UTC+ 时区会在本地午夜后提前请求“明天”的收益。 + var today = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) + ); + for (var index = 0; index < total; index += 1) { + var date = new Date(today.getTime() - index * 86400000); + var value = [ + date.getUTCFullYear(), + String(date.getUTCMonth() + 1).padStart(2, '0'), + String(date.getUTCDate()).padStart(2, '0'), + ].join('-'); + result.push({ + value: value, + date: date, + label: + index === 0 + ? t('fami.today', 'Today') + : formatDate(date, { + month: 'numeric', + day: 'numeric', + timeZone: 'UTC', + }), + }); + } + return result; + } + + function renderDateStrip(node, dates, activeValue, onSelect) { + if (!node) return; + node.replaceChildren(); + dates.forEach(function (item) { + var button = document.createElement('button'); + button.type = 'button'; + button.className = 'fami-date-strip__item'; + button.classList.toggle('is-active', item.value === activeValue); + // 视觉回归 mock 复现 Figma 的 Today 占位;真实入口展示实际日期,避免多个同名按钮让用户无法判断筛选日。 + button.textContent = isMock() + ? t('fami.today', 'Today') + : item.label; + button.setAttribute('data-date', item.value); + button.setAttribute('aria-label', item.label + ' ' + item.value); + button.setAttribute( + 'aria-pressed', + String(item.value === activeValue) + ); + button.addEventListener('click', function () { + onSelect(item.value); + }); + node.appendChild(button); + }); + } + + function setAvatar(image, source, fallback) { + if (!image) return; + var explicitSource = String(source || '').trim(); + // Figma 人像只属于显式 mock 验收数据。线上资料没有头像时保留真实空态, + // 不能让设计稿示例人物被误认为当前用户或币商。 + var resolved = + explicitSource || (isMock() ? String(fallback || '').trim() : ''); + image.onerror = function () { + image.onerror = null; + var mockFallback = isMock() ? String(fallback || '').trim() : ''; + if (mockFallback && image.src !== mockFallback) { + image.src = mockFallback; + return; + } + image.removeAttribute('src'); + image.classList.add('is-empty'); + }; + image.classList.toggle('is-empty', !resolved); + if (resolved) { + image.src = resolved; + } else { + image.removeAttribute('src'); + } + } + + function contactFrom(payload) { + var data = unwrap(payload) || {}; + var source = data.profile || data.user || data; + return String( + first(source, [ + 'contact_info', + 'contactInfo', + 'whatsapp_phone', + 'whatsappPhone', + 'contact', + ]) || '' + ).trim(); + } + + function profileFrom(payload, fallbackName) { + var data = unwrap(payload) || {}; + var source = data.profile || data.user || data; + return { + id: String(first(source, ['user_id', 'userId', 'id']) || ''), + displayId: String( + first(source, [ + 'display_user_id', + 'displayUserId', + 'default_display_user_id', + 'uid', + 'user_id', + 'id', + ]) || '' + ), + name: String( + first(source, [ + 'username', + 'nickname', + 'display_name', + 'name', + ]) || + fallbackName || + '' + ), + avatar: String( + first(source, ['avatar', 'avatar_url', 'avatarUrl']) || '' + ), + contact: contactFrom(source), + createdAt: first(source, [ + 'created_at_ms', + 'created_at', + 'createdAt', + 'joined_at_ms', + 'joined_at', + 'joinedAt', + ]), + }; + } + + function commandId(prefix) { + var random = Math.random().toString(36).slice(2, 10); + return [prefix || 'fami', Date.now().toString(36), random].join('-'); + } + + function isMock() { + return params.get('mock') === '1'; + } + + function safe(label, factory) { + return Promise.resolve() + .then(factory) + .catch(function (error) { + if (window.console && window.console.warn) { + window.console.warn('[fami:' + label + ']', error); + } + return null; + }); + } + + function goBack(fallbackURL) { + if (window.HyAppBridge && window.HyAppBridge.back) { + window.HyAppBridge.back(); + return; + } + if (window.history.length > 1) { + window.history.back(); + return; + } + if (fallbackURL) window.location.href = fallbackURL; + } + + function openModal(modal) { + var node = typeof modal === 'string' ? $(modal) : modal; + if (!node) return; + node.hidden = false; + node.setAttribute('aria-hidden', 'false'); + document.body.classList.add('modal-open'); + } + + function closeModal(modal) { + var node = typeof modal === 'string' ? $(modal) : modal; + if (!node) return; + node.hidden = true; + node.setAttribute('aria-hidden', 'true'); + document.body.classList.remove('modal-open'); + } + + function bindContact(options) { + var openButton = $('contactCard'); + var modal = $('contactModal'); + var form = $('contactForm'); + var input = $('contactInput'); + var save = $('contactSaveButton'); + var status = $('contactStatus'); + if (!openButton || !modal || !form || !input || !save || !status) { + return { setContact: function () {} }; + } + + var current = ''; + + function setStatus(message, success) { + status.textContent = message || ''; + status.hidden = !message; + status.classList.toggle('is-success', !!success); + } + + function setContact(value) { + current = String(value || '').trim(); + } + + function close() { + closeModal(modal); + setStatus(''); + } + + openButton.addEventListener('click', function () { + input.value = current; + setStatus(''); + openModal(modal); + window.setTimeout(function () { + input.focus(); + }, 0); + }); + modal.querySelectorAll('[data-contact-close]').forEach(function (node) { + node.addEventListener('click', close); + }); + form.addEventListener('submit', function (event) { + event.preventDefault(); + var value = String(input.value || '').trim(); + if (!value) { + setStatus( + t('contact_card.required', 'Enter WhatsApp contact.') + ); + return; + } + var api = window.HyAppAPI && window.HyAppAPI.user; + if (isMock()) { + setContact(value); + close(); + toast(t('contact_card.saved', 'Saved.')); + if (options && options.onSaved) options.onSaved(current); + return; + } + if (!api || !api.updateContact) { + setStatus( + t( + 'contact_card.api_unavailable', + 'Contact API unavailable.' + ) + ); + return; + } + + // 联系方式属于用户主资料而不是中心页本地状态;只有服务端确认写入后才关闭弹层,防止 UI 显示成功但下一页仍读到旧联系方式。 + save.disabled = true; + save.textContent = t('contact_card.saving', 'Saving...'); + setStatus(''); + api.updateContact({ contact_info: value }) + .then(function (response) { + setContact(contactFrom(response) || value); + close(); + toast(t('contact_card.saved', 'Saved.')); + if (options && options.onSaved) options.onSaved(current); + }) + .catch(function (error) { + setStatus( + (error && error.message) || + t('contact_card.save_failed', 'Save failed.') + ); + }) + .finally(function () { + save.disabled = false; + save.textContent = t('contact_card.save', 'Save'); + }); + }); + + return { setContact: setContact }; + } + + window.FamiCenterUI = { + $: $, + t: t, + toast: toast, + unwrap: unwrap, + first: first, + number: number, + formatNumber: formatNumber, + walletAvailable: walletAvailable, + formatDate: formatDate, + dateKey: dateKey, + dateWindow: dateWindow, + renderDateStrip: renderDateStrip, + setAvatar: setAvatar, + contactFrom: contactFrom, + profileFrom: profileFrom, + commandId: commandId, + isMock: isMock, + params: params, + safe: safe, + goBack: goBack, + openModal: openModal, + closeModal: closeModal, + bindContact: bindContact, + }; +})(); diff --git a/guild/products/fami/locales/ar.json b/guild/products/fami/locales/ar.json index 89df9e0..a189479 100644 --- a/guild/products/fami/locales/ar.json +++ b/guild/products/fami/locales/ar.json @@ -135,5 +135,105 @@ "fami_wallet.usdt_tab": "USDT", "fami_wallet.usdt_warning": "يتم إرسال السحب فقط إلى عنوان TRC20 المحفوظ.", "fami_wallet.usdt_withdrawal": "سحب USDT", - "fami_wallet.withdraw_now": "اسحب الآن" + "fami_wallet.withdraw_now": "اسحب الآن", + "agency_center.api_not_ready": "وحدة API غير جاهزة.", + "agency_center.back": "رجوع", + "agency_center.close": "إغلاق", + "agency_center.enter_host_id": "أدخل معرف المضيف", + "agency_center.invite": "دعوة", + "agency_center.invite_failed": "فشلت الدعوة. حاول لاحقًا.", + "agency_center.invite_sent": "تم إرسال الدعوة وبانتظار التأكيد.", + "agency_center.load_failed": "فشل التحميل. حاول لاحقًا.", + "agency_center.no_users_found": "لم يتم العثور على مستخدمين", + "agency_center.pending": "قيد الانتظار", + "agency_center.search": "بحث", + "agency_center.search_failed": "فشل البحث. حاول لاحقًا.", + "agency_center.searching": "جارٍ البحث...", + "bd_center.api_not_ready": "وحدة API غير جاهزة.", + "bd_center.back": "رجوع", + "bd_center.change_language": "تغيير اللغة", + "bd_center.close": "إغلاق", + "bd_center.enter_user_id": "أدخل معرف المستخدم", + "bd_center.invitation_submitted": "تم إرسال الدعوة", + "bd_center.invite": "دعوة", + "bd_center.invite_failed": "فشلت الدعوة. حاول مرة أخرى لاحقًا.", + "bd_center.load_failed": "فشل التحميل. حاول مرة أخرى لاحقًا.", + "bd_center.no_users_found": "لم يتم العثور على مستخدمين", + "bd_center.pending": "قيد الانتظار", + "bd_center.search": "بحث", + "bd_center.search_failed": "فشل البحث. حاول مرة أخرى لاحقًا.", + "bd_center.searching": "جارٍ البحث...", + "bd_center.title": "مركز BD", + "contact_card.api_unavailable": "واجهة جهة الاتصال غير متاحة.", + "contact_card.close": "إغلاق", + "contact_card.field_label": "رقم أو رابط WhatsApp", + "contact_card.open": "تعديل جهة اتصال WhatsApp", + "contact_card.placeholder": "+6512345678", + "contact_card.required": "أدخل جهة اتصال WhatsApp.", + "contact_card.save": "حفظ", + "contact_card.save_failed": "فشل الحفظ.", + "contact_card.saved": "تم الحفظ.", + "contact_card.saving": "جار الحفظ...", + "contact_card.sheet_title": "جهة اتصال WhatsApp", + "fami.add_guild": "إضافة وكالة", + "fami.add_host": "إضافة مضيف", + "fami.agency_id": "معرّف الوكالة:{id}", + "fami.agency_title": "الوكالة", + "fami.data_details": "تفاصيل البيانات", + "fami.date": "التاريخ", + "fami.diamond": "الماس", + "fami.diamond_balance": "رصيد الماس:", + "fami.diamond_earnings": "أرباح الماس", + "fami.diamond_exchanged": "الماس المستبدل", + "fami.exchange_for_gold": "استبدال بعملات ذهبية", + "fami.gift_income": "دخل الهدايا", + "fami.gift_senders": "مرسلو الهدايا", + "fami.gifted_hosts": "المضيفون المستلمون", + "fami.gold": "الذهب", + "fami.gold_coins": "العملات الذهبية:", + "fami.join_time": "وقت الانضمام: {time}", + "fami.new_followers": "متابعون جدد", + "fami.no_address_copy": "أضف عنوان USDT على شبكة TRC20 قبل السحب.", + "fami.no_currency_dealer": "لا يوجد تاجر عملات", + "fami.online_minutes": "دقائق الاتصال", + "fami.please_enter": "يرجى الإدخال", + "fami.private_chats": "المحادثات الخاصة", + "fami.select_currency_dealer": "اختر تاجر عملات", + "fami.share_income": "دخل الحصة", + "fami.today": "اليوم", + "fami.total_earnings": "إجمالي الأرباح", + "fami.total_hosts": "إجمالي المضيفين", + "fami.total_return": "إجمالي العائد", + "fami.usdt_withdrawal": "سحب USDT", + "fami.user_id": "معرّف المستخدم:{id}", + "fami.valid_mic_days": "أيام الميكروفون", + "fami.valid_mic_minutes": "دقائق الميكروفون", + "fami.welcome_date": "مرحبًا {date}", + "fami.withdrawal": "سحب", + "fami.withdrawal_ratio": "نسبة السحب:", + "host_center.api_not_ready": "وحدة API غير جاهزة.", + "host_center.back": "رجوع", + "host_center.change_language": "تغيير اللغة", + "host_center.load_failed": "فشل التحميل. حاول لاحقا.", + "host_center.title": "مركز المضيف", + "withdraw_exchange.address_saved": "تم حفظ العنوان", + "withdraw_exchange.amount_exceeds_balance": "المبلغ يتجاوز الرصيد", + "withdraw_exchange.back": "رجوع", + "withdraw_exchange.close": "إغلاق", + "withdraw_exchange.exchange_now": "بدّل الآن", + "withdraw_exchange.invalid_address": "يرجى إدخال عنوان USDT TRC20 صالح", + "withdraw_exchange.invalid_amount": "يرجى إدخال مبلغ صالح", + "withdraw_exchange.load_failed": "فشل التحميل. حاول لاحقًا.", + "withdraw_exchange.minimum_points": "الحد الأدنى للسحب هو {amount} ألماسة", + "withdraw_exchange.missing_payment_info": "يرجى إضافة عنوان USDT TRC20", + "withdraw_exchange.no_payment_info": "لا يوجد عنوان USDT", + "withdraw_exchange.payment_method": "عنوان USDT TRC20", + "withdraw_exchange.payment_method_subtitle": "يتم إرسال السحوبات إلى عنوان USDT TRC20 الخاص بك.", + "withdraw_exchange.save_address": "حفظ العنوان", + "withdraw_exchange.saving": "جار الحفظ...", + "withdraw_exchange.submit_failed": "فشل الإرسال.", + "withdraw_exchange.submitting": "جار الإرسال...", + "withdraw_exchange.trc20_address": "عنوان TRC20", + "withdraw_exchange.withdraw_amount": "مبلغ السحب", + "withdraw_exchange.withdraw_success": "تم إرسال طلب السحب" } diff --git a/guild/products/fami/locales/en.json b/guild/products/fami/locales/en.json index f52bf74..c826e44 100644 --- a/guild/products/fami/locales/en.json +++ b/guild/products/fami/locales/en.json @@ -135,5 +135,105 @@ "fami_wallet.usdt_tab": "USDT", "fami_wallet.usdt_warning": "Withdrawals are sent only to your saved TRC20 address.", "fami_wallet.usdt_withdrawal": "USDT withdrawal", - "fami_wallet.withdraw_now": "Withdraw now" + "fami_wallet.withdraw_now": "Withdraw now", + "agency_center.api_not_ready": "API module is not ready.", + "agency_center.back": "Back", + "agency_center.close": "Close", + "agency_center.enter_host_id": "Enter host ID", + "agency_center.invite": "Invite", + "agency_center.invite_failed": "Invite failed. Try again later.", + "agency_center.invite_sent": "Invite sent. Waiting for confirmation.", + "agency_center.load_failed": "Load failed. Try again later.", + "agency_center.no_users_found": "No users found", + "agency_center.pending": "Pending", + "agency_center.search": "Search", + "agency_center.search_failed": "Search failed. Try again later.", + "agency_center.searching": "Searching...", + "bd_center.api_not_ready": "API module is not ready.", + "bd_center.back": "Back", + "bd_center.change_language": "Change language", + "bd_center.close": "Close", + "bd_center.enter_user_id": "Enter User ID", + "bd_center.invitation_submitted": "Invitation submitted", + "bd_center.invite": "Invite", + "bd_center.invite_failed": "Invite failed. Try again later.", + "bd_center.load_failed": "Load failed. Try again later.", + "bd_center.no_users_found": "No users found", + "bd_center.pending": "Pending", + "bd_center.search": "Search", + "bd_center.search_failed": "Search failed. Try again later.", + "bd_center.searching": "Searching...", + "bd_center.title": "BD Center", + "contact_card.api_unavailable": "Contact API unavailable.", + "contact_card.close": "Close", + "contact_card.field_label": "WhatsApp phone or link", + "contact_card.open": "Edit WhatsApp contact", + "contact_card.placeholder": "+6512345678", + "contact_card.required": "Enter WhatsApp contact.", + "contact_card.save": "Save", + "contact_card.save_failed": "Save failed.", + "contact_card.saved": "Saved.", + "contact_card.saving": "Saving...", + "contact_card.sheet_title": "WhatsApp Contact", + "fami.add_guild": "Add Guild", + "fami.add_host": "Add Host", + "fami.agency_id": "Agency ID:{id}", + "fami.agency_title": "Agency", + "fami.data_details": "Data Details", + "fami.date": "Date", + "fami.diamond": "Diamond", + "fami.diamond_balance": "Diamond Balance:", + "fami.diamond_earnings": "Diamond Earnings", + "fami.diamond_exchanged": "Diamond Exchanged", + "fami.exchange_for_gold": "Exchange for Gold Coins", + "fami.gift_income": "Gift Income", + "fami.gift_senders": "Gift Senders", + "fami.gifted_hosts": "Gifted Hosts", + "fami.gold": "Gold", + "fami.gold_coins": "Gold Coins :", + "fami.join_time": "Join time: {time}", + "fami.new_followers": "New Followers", + "fami.no_address_copy": "Fill in a TRC20 USDT address before withdrawing.", + "fami.no_currency_dealer": "No currency dealer", + "fami.online_minutes": "Online Minutes", + "fami.please_enter": "Please Enter", + "fami.private_chats": "Private Chats", + "fami.select_currency_dealer": "Select a Currency Dealer", + "fami.share_income": "Share Income", + "fami.today": "Today", + "fami.total_earnings": "Total Earnings", + "fami.total_hosts": "Total Hosts", + "fami.total_return": "Total Return", + "fami.usdt_withdrawal": "USDT Withdrawal", + "fami.user_id": "User ID:{id}", + "fami.valid_mic_days": "Valid Mic Days", + "fami.valid_mic_minutes": "Valid Mic Minutes", + "fami.welcome_date": "Welcome {date}", + "fami.withdrawal": "Withdrawal", + "fami.withdrawal_ratio": "Withdrawal ratio:", + "host_center.api_not_ready": "API module is not ready.", + "host_center.back": "Back", + "host_center.change_language": "Change language", + "host_center.load_failed": "Load failed. Try again later.", + "host_center.title": "Host Center", + "withdraw_exchange.address_saved": "Address saved", + "withdraw_exchange.amount_exceeds_balance": "Amount exceeds balance", + "withdraw_exchange.back": "Back", + "withdraw_exchange.close": "Close", + "withdraw_exchange.exchange_now": "Exchange now", + "withdraw_exchange.invalid_address": "Please enter a valid TRC20 USDT address", + "withdraw_exchange.invalid_amount": "Please enter a valid amount", + "withdraw_exchange.load_failed": "Load failed. Try again later.", + "withdraw_exchange.minimum_points": "Minimum withdrawal is {amount} diamonds", + "withdraw_exchange.missing_payment_info": "Please add a TRC20 USDT address", + "withdraw_exchange.no_payment_info": "No USDT address", + "withdraw_exchange.payment_method": "USDT TRC20 address", + "withdraw_exchange.payment_method_subtitle": "Withdrawals are sent to your TRC20 USDT address.", + "withdraw_exchange.save_address": "Save address", + "withdraw_exchange.saving": "Saving...", + "withdraw_exchange.submit_failed": "Submit failed.", + "withdraw_exchange.submitting": "Submitting...", + "withdraw_exchange.trc20_address": "TRC20 address", + "withdraw_exchange.withdraw_amount": "Withdraw amount", + "withdraw_exchange.withdraw_success": "Withdrawal submitted" } diff --git a/guild/products/fami/locales/es.json b/guild/products/fami/locales/es.json index 9768738..02f40d1 100644 --- a/guild/products/fami/locales/es.json +++ b/guild/products/fami/locales/es.json @@ -135,5 +135,105 @@ "fami_wallet.usdt_tab": "USDT", "fami_wallet.usdt_warning": "Los retiros se envían solo a tu dirección TRC20 guardada.", "fami_wallet.usdt_withdrawal": "Retiro USDT", - "fami_wallet.withdraw_now": "Retirar ahora" + "fami_wallet.withdraw_now": "Retirar ahora", + "agency_center.api_not_ready": "El módulo API no está listo.", + "agency_center.back": "Volver", + "agency_center.close": "Cerrar", + "agency_center.enter_host_id": "Ingresa ID de host", + "agency_center.invite": "Invitar", + "agency_center.invite_failed": "Error al invitar. Inténtalo más tarde.", + "agency_center.invite_sent": "Invitación enviada. Esperando confirmación.", + "agency_center.load_failed": "Error al cargar. Inténtalo más tarde.", + "agency_center.no_users_found": "No se encontraron usuarios", + "agency_center.pending": "Pendiente", + "agency_center.search": "Buscar", + "agency_center.search_failed": "Error de búsqueda. Inténtalo más tarde.", + "agency_center.searching": "Buscando...", + "bd_center.api_not_ready": "El módulo API no está listo.", + "bd_center.back": "Volver", + "bd_center.change_language": "Cambiar idioma", + "bd_center.close": "Cerrar", + "bd_center.enter_user_id": "Ingresa el ID de usuario", + "bd_center.invitation_submitted": "Invitación enviada", + "bd_center.invite": "Invitar", + "bd_center.invite_failed": "Error al invitar. Inténtalo de nuevo más tarde.", + "bd_center.load_failed": "Error al cargar. Inténtalo de nuevo más tarde.", + "bd_center.no_users_found": "No se encontraron usuarios", + "bd_center.pending": "Pendiente", + "bd_center.search": "Buscar", + "bd_center.search_failed": "Error de búsqueda. Inténtalo de nuevo más tarde.", + "bd_center.searching": "Buscando...", + "bd_center.title": "Centro BD", + "contact_card.api_unavailable": "API de contacto no disponible.", + "contact_card.close": "Cerrar", + "contact_card.field_label": "Teléfono o enlace de WhatsApp", + "contact_card.open": "Editar contacto de WhatsApp", + "contact_card.placeholder": "+6512345678", + "contact_card.required": "Ingresa el contacto de WhatsApp.", + "contact_card.save": "Guardar", + "contact_card.save_failed": "No se pudo guardar.", + "contact_card.saved": "Guardado.", + "contact_card.saving": "Guardando...", + "contact_card.sheet_title": "Contacto de WhatsApp", + "fami.add_guild": "Añadir agencia", + "fami.add_host": "Añadir host", + "fami.agency_id": "ID de agencia:{id}", + "fami.agency_title": "agencia", + "fami.data_details": "Detalles de datos", + "fami.date": "Fecha", + "fami.diamond": "Diamante", + "fami.diamond_balance": "Saldo de diamantes:", + "fami.diamond_earnings": "Ganancias de diamantes", + "fami.diamond_exchanged": "Diamantes convertidos", + "fami.exchange_for_gold": "Cambiar por monedas de oro", + "fami.gift_income": "Ingresos por regalos", + "fami.gift_senders": "Remitentes de regalos", + "fami.gifted_hosts": "Hosts con regalos", + "fami.gold": "Oro", + "fami.gold_coins": "Monedas de oro:", + "fami.join_time": "Hora de ingreso: {time}", + "fami.new_followers": "Nuevos seguidores", + "fami.no_address_copy": "Añade una dirección USDT TRC20 antes de retirar.", + "fami.no_currency_dealer": "No hay vendedor de monedas", + "fami.online_minutes": "Minutos en línea", + "fami.please_enter": "Ingresa un valor", + "fami.private_chats": "Chats privados", + "fami.select_currency_dealer": "Selecciona un vendedor de monedas", + "fami.share_income": "Ingresos por reparto", + "fami.today": "Hoy", + "fami.total_earnings": "Ganancias totales", + "fami.total_hosts": "Total de hosts", + "fami.total_return": "Retorno total", + "fami.usdt_withdrawal": "Retiro USDT", + "fami.user_id": "ID de usuario:{id}", + "fami.valid_mic_days": "Días de micrófono", + "fami.valid_mic_minutes": "Minutos de micrófono", + "fami.welcome_date": "Bienvenido {date}", + "fami.withdrawal": "Retiro", + "fami.withdrawal_ratio": "Tasa de retiro:", + "host_center.api_not_ready": "El módulo API no está listo.", + "host_center.back": "Volver", + "host_center.change_language": "Cambiar idioma", + "host_center.load_failed": "La carga falló. Inténtalo más tarde.", + "host_center.title": "Centro de anfitrión", + "withdraw_exchange.address_saved": "Dirección guardada", + "withdraw_exchange.amount_exceeds_balance": "El monto supera el saldo", + "withdraw_exchange.back": "Volver", + "withdraw_exchange.close": "Cerrar", + "withdraw_exchange.exchange_now": "Intercambiar ahora", + "withdraw_exchange.invalid_address": "Ingresa una dirección USDT TRC20 válida", + "withdraw_exchange.invalid_amount": "Ingresa un monto válido", + "withdraw_exchange.load_failed": "Error al cargar. Inténtalo más tarde.", + "withdraw_exchange.minimum_points": "El retiro mínimo es de {amount} diamantes", + "withdraw_exchange.missing_payment_info": "Agrega una dirección USDT TRC20", + "withdraw_exchange.no_payment_info": "Sin dirección USDT", + "withdraw_exchange.payment_method": "Dirección USDT TRC20", + "withdraw_exchange.payment_method_subtitle": "Los retiros se envían a tu dirección USDT TRC20.", + "withdraw_exchange.save_address": "Guardar dirección", + "withdraw_exchange.saving": "Guardando...", + "withdraw_exchange.submit_failed": "Error al enviar.", + "withdraw_exchange.submitting": "Enviando...", + "withdraw_exchange.trc20_address": "Dirección TRC20", + "withdraw_exchange.withdraw_amount": "Monto a retirar", + "withdraw_exchange.withdraw_success": "Retiro enviado" } diff --git a/guild/products/fami/locales/tr.json b/guild/products/fami/locales/tr.json index dfd9031..46feaca 100644 --- a/guild/products/fami/locales/tr.json +++ b/guild/products/fami/locales/tr.json @@ -135,5 +135,105 @@ "fami_wallet.usdt_tab": "USDT", "fami_wallet.usdt_warning": "Çekimler yalnızca kayıtlı TRC20 adresinize gönderilir.", "fami_wallet.usdt_withdrawal": "USDT çekimi", - "fami_wallet.withdraw_now": "Şimdi çek" + "fami_wallet.withdraw_now": "Şimdi çek", + "agency_center.api_not_ready": "API modülü hazır değil.", + "agency_center.back": "Geri", + "agency_center.close": "Kapat", + "agency_center.enter_host_id": "Host ID girin", + "agency_center.invite": "Davet et", + "agency_center.invite_failed": "Davet başarısız. Daha sonra tekrar deneyin.", + "agency_center.invite_sent": "Davet gönderildi. Onay bekleniyor.", + "agency_center.load_failed": "Yüklenemedi. Daha sonra tekrar deneyin.", + "agency_center.no_users_found": "Kullanıcı bulunamadı", + "agency_center.pending": "Beklemede", + "agency_center.search": "Ara", + "agency_center.search_failed": "Arama başarısız. Daha sonra tekrar deneyin.", + "agency_center.searching": "Aranıyor...", + "bd_center.api_not_ready": "API modülü hazır değil.", + "bd_center.back": "Geri", + "bd_center.change_language": "Dili değiştir", + "bd_center.close": "Kapat", + "bd_center.enter_user_id": "Kullanıcı ID gir", + "bd_center.invitation_submitted": "Davet gönderildi", + "bd_center.invite": "Davet et", + "bd_center.invite_failed": "Davet başarısız. Daha sonra tekrar deneyin.", + "bd_center.load_failed": "Yüklenemedi. Daha sonra tekrar deneyin.", + "bd_center.no_users_found": "Kullanıcı bulunamadı", + "bd_center.pending": "Beklemede", + "bd_center.search": "Ara", + "bd_center.search_failed": "Arama başarısız. Daha sonra tekrar deneyin.", + "bd_center.searching": "Aranıyor...", + "bd_center.title": "BD Merkezi", + "contact_card.api_unavailable": "İletişim API kullanılamıyor.", + "contact_card.close": "Kapat", + "contact_card.field_label": "WhatsApp telefonu veya bağlantısı", + "contact_card.open": "WhatsApp iletişimini düzenle", + "contact_card.placeholder": "+6512345678", + "contact_card.required": "WhatsApp iletişimini gir.", + "contact_card.save": "Kaydet", + "contact_card.save_failed": "Kaydetme başarısız.", + "contact_card.saved": "Kaydedildi.", + "contact_card.saving": "Kaydediliyor...", + "contact_card.sheet_title": "WhatsApp İletişimi", + "fami.add_guild": "Ajans Ekle", + "fami.add_host": "Host Ekle", + "fami.agency_id": "Ajans ID:{id}", + "fami.agency_title": "ajans", + "fami.data_details": "Veri Ayrıntıları", + "fami.date": "Tarih", + "fami.diamond": "Elmas", + "fami.diamond_balance": "Elmas Bakiyesi:", + "fami.diamond_earnings": "Elmas Kazancı", + "fami.diamond_exchanged": "Dönüştürülen Elmas", + "fami.exchange_for_gold": "Altın Coin ile Değiştir", + "fami.gift_income": "Hediye Geliri", + "fami.gift_senders": "Hediye Gönderenler", + "fami.gifted_hosts": "Hediye Alan Host", + "fami.gold": "Altın", + "fami.gold_coins": "Altın Coin:", + "fami.join_time": "Katılma zamanı: {time}", + "fami.new_followers": "Yeni Takipçiler", + "fami.no_address_copy": "Çekimden önce bir TRC20 USDT adresi ekleyin.", + "fami.no_currency_dealer": "Coin satıcısı yok", + "fami.online_minutes": "Çevrimiçi Dakika", + "fami.please_enter": "Lütfen girin", + "fami.private_chats": "Özel Sohbetler", + "fami.select_currency_dealer": "Bir Coin Satıcısı Seçin", + "fami.share_income": "Pay Geliri", + "fami.today": "Bugün", + "fami.total_earnings": "Toplam Kazanç", + "fami.total_hosts": "Toplam Host", + "fami.total_return": "Toplam Getiri", + "fami.usdt_withdrawal": "USDT Çekimi", + "fami.user_id": "Kullanıcı ID:{id}", + "fami.valid_mic_days": "Geçerli Mikrofon Günü", + "fami.valid_mic_minutes": "Geçerli Mikrofon Dakikası", + "fami.welcome_date": "Hoş geldin {date}", + "fami.withdrawal": "Çekim", + "fami.withdrawal_ratio": "Çekim oranı:", + "host_center.api_not_ready": "API modülü hazır değil.", + "host_center.back": "Geri", + "host_center.change_language": "Dili değiştir", + "host_center.load_failed": "Yükleme başarısız. Daha sonra deneyin.", + "host_center.title": "Host Merkezi", + "withdraw_exchange.address_saved": "Adres kaydedildi", + "withdraw_exchange.amount_exceeds_balance": "Tutar bakiyeyi aşıyor", + "withdraw_exchange.back": "Geri", + "withdraw_exchange.close": "Kapat", + "withdraw_exchange.exchange_now": "Şimdi değiştir", + "withdraw_exchange.invalid_address": "Lütfen geçerli bir TRC20 USDT adresi girin", + "withdraw_exchange.invalid_amount": "Lütfen geçerli bir tutar girin", + "withdraw_exchange.load_failed": "Yüklenemedi. Daha sonra tekrar deneyin.", + "withdraw_exchange.minimum_points": "Minimum çekim {amount} elmastır", + "withdraw_exchange.missing_payment_info": "Lütfen bir TRC20 USDT adresi ekleyin", + "withdraw_exchange.no_payment_info": "USDT adresi yok", + "withdraw_exchange.payment_method": "USDT TRC20 adresi", + "withdraw_exchange.payment_method_subtitle": "Çekimler TRC20 USDT adresinize gönderilir.", + "withdraw_exchange.save_address": "Adresi kaydet", + "withdraw_exchange.saving": "Kaydediliyor...", + "withdraw_exchange.submit_failed": "Gönderim başarısız.", + "withdraw_exchange.submitting": "Gönderiliyor...", + "withdraw_exchange.trc20_address": "TRC20 adresi", + "withdraw_exchange.withdraw_amount": "Çekim tutarı", + "withdraw_exchange.withdraw_success": "Çekim talebi gönderildi" } diff --git a/package.json b/package.json index 8204768..3acc96d 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,13 @@ "test:fami-bd-center": "node scripts/test_fami_bd_center.js", "test:fami-wallet": "node scripts/test_fami_wallet.js", "format": "prettier --write \"**/*.{js,html,css,json,md}\"", - "format:check": "prettier --check \"**/*.{js,html,css,json,md}\"" + "format:check": "prettier --check \"**/*.{js,html,css,json,md}\"", + "audit:fami-layers": "node scripts/check-fami-layer-assets.mjs", + "audit:huwaa-invite-layers": "node scripts/check-activity-invite-huwaa-layer-assets.mjs", + "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" }, "devDependencies": { "playwright-core": "^1.61.1", diff --git a/scripts/check-activity-cp-fami-layer-assets.mjs b/scripts/check-activity-cp-fami-layer-assets.mjs new file mode 100644 index 0000000..ff3b1de --- /dev/null +++ b/scripts/check-activity-cp-fami-layer-assets.mjs @@ -0,0 +1,211 @@ +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 root = path.resolve(scriptDirectory, '..'); +const entryDirectory = path.join(root, 'activity', 'cp-space'); +const pageDirectory = path.join(entryDirectory, 'fami'); +const assetDirectory = path.join(pageDirectory, 'assets', 'layers'); +const manifestPath = path.join(assetDirectory, 'manifest.json'); +const forbiddenRootFrames = new Set(['859:2027', '859:2247', '859:2454']); + +function invariant(condition, message) { + if (!condition) throw new Error(message); +} + +async function pageText(file) { + return readFile(path.join(pageDirectory, file), 'utf8'); +} + +async function entryText(file) { + return readFile(path.join(entryDirectory, file), 'utf8'); +} + +const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); +invariant(manifest.schemaVersion === 1, 'Unsupported manifest schema'); +invariant( + manifest.figma?.fileKey === 'vaDp5wwn6AOQVlt9zk0f9r', + 'Unexpected Figma file key' +); +invariant(manifest.figma?.page?.id === '1:27538', 'Unexpected Figma page'); +invariant( + JSON.stringify(manifest.figma?.entryFrames) === + JSON.stringify({ + cp: '859:2027', + brother: '859:2247', + sister: '859:2454', + }), + 'Entry-frame mapping changed' +); +invariant(Array.isArray(manifest.assets), 'Manifest assets must be an array'); +invariant( + manifest.assets.length === 27, + 'Manifest must describe all 27 assets' +); + +const listedFiles = new Set(); +for (const asset of manifest.assets) { + invariant( + asset.file && !listedFiles.has(asset.file), + `Duplicate asset ${asset.file}` + ); + listedFiles.add(asset.file); + invariant(asset.sourceNode, `${asset.file}: sourceNode is required`); + invariant( + !forbiddenRootFrames.has(asset.sourceNode), + `${asset.file}: root-frame export is forbidden` + ); + invariant( + asset.sourceName && asset.sourceType, + `${asset.file}: source name/type is required` + ); + invariant(asset.purpose, `${asset.file}: purpose is required`); + invariant( + asset.bounds?.width > 0 && asset.bounds?.height > 0, + `${asset.file}: dimensions are required` + ); + invariant(asset.exportFormat, `${asset.file}: export format is required`); + invariant( + Number.isInteger(asset.bytes) && asset.bytes > 0, + `${asset.file}: byte size is required` + ); + invariant( + /^[a-f0-9]{64}$/.test(asset.sha256 || ''), + `${asset.file}: SHA-256 is required` + ); + for (const flag of [ + 'containsText', + 'containsControls', + 'containsDynamicMedia', + ]) { + invariant( + typeof asset[flag] === 'boolean', + `${asset.file}: ${flag} must be boolean` + ); + } + invariant(!asset.containsText, `${asset.file}: rendered text is forbidden`); + invariant( + !asset.containsControls, + `${asset.file}: rendered controls are forbidden` + ); + invariant( + !asset.containsDynamicMedia || asset.testOnly === true, + `${asset.file}: dynamic media is allowed only in test-only fixtures` + ); + + const assetPath = path.join(assetDirectory, asset.file); + const [bytes, info] = await Promise.all([ + readFile(assetPath), + stat(assetPath), + ]); + const digest = createHash('sha256').update(bytes).digest('hex'); + invariant(info.size === asset.bytes, `${asset.file}: byte size mismatch`); + invariant(digest === asset.sha256, `${asset.file}: SHA-256 mismatch`); +} + +const diskAssets = (await readdir(assetDirectory)) + .filter((file) => file !== 'manifest.json') + .sort(); +invariant( + JSON.stringify(diskAssets) === JSON.stringify([...listedFiles].sort()), + 'Every visual asset must have exactly one manifest entry' +); + +const fontPath = path.join( + pageDirectory, + 'assets', + 'fonts', + 'source-han-sans-sc-medium-latin.woff2' +); +const [fontBytes, fontLicense] = await Promise.all([ + readFile(fontPath), + readFile(path.join(pageDirectory, 'assets', 'fonts', 'OFL.txt'), 'utf8'), +]); +invariant(fontBytes.length === 36604, 'Source Han font subset size mismatch'); +invariant( + createHash('sha256').update(fontBytes).digest('hex') === + '0c5b80890935bdf1ab542338c7f5ebe5c4fc678c76873c843a9c3be1c6b23e57', + 'Source Han font subset hash mismatch' +); +invariant( + fontLicense.includes('SIL OPEN FONT LICENSE'), + 'Source Han font license is missing' +); + +const [html, js, product, css] = await Promise.all([ + entryText('fami.html'), + pageText('fami.js'), + pageText('product.js'), + pageText('fami.css'), +]); +const runtimeSource = [html, js, product, css].join('\n'); + +for (const forbidden of [ + 'YumiCommon', + 'data-hy-app-code="yumi"', + 'origin=YUMI', + '/gonghui/', + '/guild/fami/', + '/guide/fami/', + '859:2027', + '859:2247', + '859:2454', +]) { + invariant( + !runtimeSource.includes(forbidden), + `Forbidden runtime identity/reference: ${forbidden}` + ); +} + +invariant( + product.includes("window.HyAppDefaultAppCode = 'fami'"), + 'Fami default app code is missing' +); +invariant( + html.includes('data-hy-app-code="fami"'), + 'Fami root app identity is missing' +); +invariant( + html.indexOf('./fami/product.js') < html.indexOf('../../common/api.js'), + 'product.js must run before common/api.js' +); +invariant(html.includes('../../common/theme.css'), 'Common theme is required'); +invariant( + html.includes('../../common/i18n.js'), + 'Common i18n runtime is required' +); +invariant( + html.includes('source-han-sans-sc-medium-latin.woff2'), + 'Figma title font preload is required' +); +invariant( + js.includes('HyAppAPI.cpSpace'), + 'CP Space API namespace is required' +); +invariant(js.includes('.relationships({'), 'Relationship API call is required'); +invariant( + js.includes('relation_type: target'), + 'Active tab must be sent as relation_type' +); +invariant( + js.includes("params.get('mock') === '1'"), + 'Mock data must require an explicit query flag' +); +invariant( + js.includes("layer('mock-avatar.png')"), + 'Visual-QA fixture must use the traced avatar' +); +invariant( + !html.includes('mock-avatar.png'), + 'Test avatar must not be a production DOM fallback' +); +invariant( + !/cancel/i.test(css), + 'The three source frames do not contain a cancel control' +); + +console.log( + `activity/cp-space/fami.html asset audit passed (${manifest.assets.length} traced layers)` +); diff --git a/scripts/check-activity-cp-huwaa-layer-assets.mjs b/scripts/check-activity-cp-huwaa-layer-assets.mjs new file mode 100644 index 0000000..dd85c54 --- /dev/null +++ b/scripts/check-activity-cp-huwaa-layer-assets.mjs @@ -0,0 +1,360 @@ +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 root = path.resolve(scriptDirectory, '..'); +const cpDirectory = path.join(root, 'activity', 'cp'); +const pageDirectory = path.join(cpDirectory, 'huwaa'); +const assetDirectory = path.join(pageDirectory, 'assets', 'layers'); +const manifestPath = path.join(assetDirectory, 'manifest.json'); + +const expectedEntryFrames = { + plaza: '831:3320', + rank: '831:4048', + rewards: '831:3913', +}; +const expectedDesignSizes = { + plaza: { width: 1080, height: 4283 }, + rank: { width: 1080, height: 3733 }, + rewards: { width: 1080, height: 4782 }, +}; +const forbiddenAggregateNodes = new Set([ + '543:1998', + '823:3005', + '831:3320', + '831:4048', + '831:3913', + '831:3323', + '831:3326', + '831:3339', + '831:3349', + '831:3642', + '831:4381', + '831:3942', +]); + +function invariant(condition, message) { + if (!condition) throw new Error(message); +} + +const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); +invariant(manifest.schemaVersion === 1, 'Unsupported manifest schema'); +invariant( + manifest.figma?.fileKey === 'OTOg5cwbhVNWxkImGmzTJl', + 'Unexpected Figma file key' +); +invariant(manifest.figma?.canvas?.id === '543:1998', 'Unexpected Figma canvas'); +invariant( + JSON.stringify(manifest.figma?.entryFrames) === + JSON.stringify(expectedEntryFrames), + 'Huwaa CP entry-frame mapping changed' +); +invariant( + JSON.stringify(manifest.figma?.designSizes) === + JSON.stringify(expectedDesignSizes), + 'Huwaa CP design sizes changed' +); +invariant(Array.isArray(manifest.assets), 'Manifest assets must be an array'); +invariant(manifest.assets.length === 17, 'Manifest must describe 17 layers'); + +function webPDimensions(bytes) { + invariant( + bytes.subarray(0, 4).toString('ascii') === 'RIFF' && + bytes.subarray(8, 12).toString('ascii') === 'WEBP', + 'Asset is not a WebP file' + ); + const chunk = bytes.subarray(12, 16).toString('ascii'); + if (chunk === 'VP8X') { + return { + width: bytes.readUIntLE(24, 3) + 1, + height: bytes.readUIntLE(27, 3) + 1, + }; + } + if (chunk === 'VP8 ') { + return { + width: bytes.readUInt16LE(26) & 0x3fff, + height: bytes.readUInt16LE(28) & 0x3fff, + }; + } + if (chunk === 'VP8L') { + invariant(bytes[20] === 0x2f, 'Invalid lossless WebP signature'); + const packed = bytes.readUInt32LE(21); + return { + width: (packed & 0x3fff) + 1, + height: ((packed >>> 14) & 0x3fff) + 1, + }; + } + throw new Error(`Unsupported WebP chunk ${chunk}`); +} + +const listedFiles = new Set(); +for (const asset of manifest.assets) { + invariant( + asset.file && !listedFiles.has(asset.file), + `Duplicate asset ${asset.file}` + ); + listedFiles.add(asset.file); + invariant(asset.sourceNode, `${asset.file}: sourceNode is required`); + invariant( + !forbiddenAggregateNodes.has(asset.sourceNode), + `${asset.file}: aggregate Figma export is forbidden` + ); + invariant( + asset.sourceName && asset.sourceType, + `${asset.file}: source name/type is required` + ); + invariant(asset.purpose, `${asset.file}: purpose is required`); + invariant( + asset.bounds?.width > 0 && asset.bounds?.height > 0, + `${asset.file}: design bounds are required` + ); + invariant( + asset.original?.width > 0 && asset.original?.height > 0, + `${asset.file}: export dimensions are required` + ); + invariant(asset.exportFormat, `${asset.file}: export format is required`); + invariant( + Number.isInteger(asset.bytes) && asset.bytes > 0, + `${asset.file}: byte size is required` + ); + invariant( + /^[a-f0-9]{64}$/.test(asset.sha256 || ''), + `${asset.file}: SHA-256 is required` + ); + for (const flag of [ + 'containsText', + 'containsDynamicText', + 'containsControls', + 'containsDynamicMedia', + ]) { + invariant( + typeof asset[flag] === 'boolean', + `${asset.file}: ${flag} must be boolean` + ); + } + invariant( + !asset.containsDynamicText, + `${asset.file}: rendered dynamic text is forbidden` + ); + invariant( + !asset.containsControls, + `${asset.file}: rendered controls are forbidden` + ); + invariant( + !asset.containsDynamicMedia, + `${asset.file}: dynamic business media is forbidden` + ); + invariant(!asset.containsText, `${asset.file}: embedded text is forbidden`); + + const assetPath = path.join(assetDirectory, asset.file); + const [bytes, info] = await Promise.all([ + readFile(assetPath), + stat(assetPath), + ]); + const digest = createHash('sha256').update(bytes).digest('hex'); + const dimensions = webPDimensions(bytes); + invariant(info.size === asset.bytes, `${asset.file}: byte size mismatch`); + invariant(digest === asset.sha256, `${asset.file}: SHA-256 mismatch`); + invariant( + dimensions.width === asset.original.width && + dimensions.height === asset.original.height, + `${asset.file}: image dimensions do not match the manifest` + ); +} + +const diskAssets = (await readdir(assetDirectory)) + .filter((file) => file !== 'manifest.json') + .sort(); +invariant( + JSON.stringify(diskAssets) === JSON.stringify([...listedFiles].sort()), + 'Every Huwaa CP visual asset must have exactly one manifest entry' +); + +const [html, css, js, theme, api, locales] = await Promise.all([ + readFile(path.join(cpDirectory, 'huwaa.html'), 'utf8'), + readFile(path.join(pageDirectory, 'huwaa.css'), 'utf8'), + readFile(path.join(pageDirectory, 'huwaa.js'), 'utf8'), + readFile(path.join(root, 'common', 'theme.css'), 'utf8'), + readFile(path.join(root, 'common', 'api.js'), 'utf8'), + Promise.all( + ['en', 'ar', 'tr', 'es'].map((locale) => + readFile( + path.join(root, 'common', 'locales', `${locale}.json`), + 'utf8' + ) + ) + ), +]); +const runtimeSource = [html, css, js].join('\n'); + +for (const nodeID of forbiddenAggregateNodes) { + invariant( + !runtimeSource.includes(nodeID), + `Runtime must not reference aggregate Figma node ${nodeID}` + ); +} +for (const forbidden of [ + 'whole-page', + 'page-screenshot', + 'figma-frame.png', + 'cp-plaza.png', + 'cp-week.png', + 'cp-reward.png', +]) { + invariant( + !runtimeSource.toLowerCase().includes(forbidden), + `Forbidden aggregate screenshot reference: ${forbidden}` + ); +} + +for (const asset of manifest.assets) { + invariant( + runtimeSource.includes(asset.file), + `${asset.file}: traced asset is not consumed by the Huwaa CP page` + ); +} + +invariant(theme.includes('--hy-theme-primary'), 'Common theme is invalid'); +invariant(html.includes('../../common/theme.css'), 'Common theme is required'); +invariant(html.includes('../../common/i18n.js'), 'Common i18n is required'); +invariant(html.includes('../../common/api.js'), 'Common API is required'); +invariant( + html.includes('../../common/params.js'), + 'Common params are required' +); +invariant( + html.includes('data-hy-app-code="huwaa"'), + 'Huwaa app identity is required' +); +invariant( + html.indexOf("window.HyAppDefaultAppCode = 'huwaa'") < + html.indexOf('../../common/api.js'), + 'Huwaa app code must be configured before common API initialization' +); +invariant( + html.includes("window.HyAppI18nSupported = ['en', 'ar', 'tr', 'es']"), + 'Huwaa CP must restrict its language surface to en/ar/tr/es' +); +invariant(api.includes('cpWeeklyRankAPI'), 'CP weekly-rank API is required'); +invariant( + js.includes('HyAppAPI.cpWeeklyRank.status'), + 'Huwaa CP must consume the weekly-rank API' +); +invariant( + !js.includes('HyAppAPI.cpSpace.intimacyLeaderboard') && + js.includes("activityData.plazaState = 'unavailable'"), + 'The cumulative intimacy leaderboard must not be presented as the Plaza gift-event feed' +); +invariant( + js.includes("get('mock') === '1'") && js.includes('isLocalRuntime()'), + 'Visual fixtures must require explicit local mock mode' +); +invariant( + js.includes('Promise.allSettled'), + 'Independent CP API failures must be isolated' +); +invariant( + js.includes('requestGeneration'), + 'Stale asynchronous CP responses must be rejected' +); +invariant( + js.includes('resolveExplicitEndAt') && !js.includes('localStorage'), + 'Countdown must not fabricate and persist a production activity period' +); +invariant( + js.includes('serverTimeOffsetMS') && js.includes('server_time_ms'), + 'Countdown must use the backend server clock' +); +invariant( + js.includes('source.enabled !== false'), + 'The weekly-rank enabled switch must control the page state' +); +invariant( + js.includes('source.rank_no') && js.includes('section.rankNo === rankNo'), + 'Reward tiers must stay aligned to backend rank_no' +); +invariant( + html.includes('hero-art-text-free.webp') && + html.includes('class="huwaa-cp-wordmark"') && + html.includes('data-i18n="cp.pageTitle"'), + 'The visible event title must be localized DOM text over text-free art' +); +invariant( + html.includes("var aliases = ['app', 'app_code', 'appCode']"), + 'All app-code aliases must resolve to Huwaa before common scripts load' +); +invariant( + css.includes('background: #e98c9b') && + css.includes('left: -487px') && + css.includes('mix-blend-mode: screen') && + css.includes('opacity: 0.5'), + 'The Figma frame fill and petal texture blend must be reconstructed exactly' +); +invariant( + css.includes('#000 88.93%') && css.includes('transparent 100%'), + 'The hero must preserve the Figma bottom fade mask' +); +invariant( + js.includes('stageWrap.style.minHeight = stageWrap.style.height'), + 'Responsive stage height must override the initial minimum height' +); +invariant( + js.includes("activeTab === 'rewards' && !activityData.rewards.length"), + 'Empty and failed Rewards states must not retain the full three-panel stage height' +); +invariant( + !runtimeSource.includes('lalu-coin'), + 'Huwaa CP must not display a Lalu coin asset' +); +invariant( + !runtimeSource.includes('Namename') && + !runtimeSource.includes('1234567') && + !runtimeSource.includes('DDDDD'), + 'Figma placeholder business data must not be baked into runtime code' +); +invariant( + html.includes('role="tablist"') && html.includes('role="tabpanel"'), + 'CP tabs must expose accessible tab semantics' +); +invariant( + html.includes('aria-live="polite"'), + 'Dynamic CP state must be announced' +); +invariant( + css.includes('prefers-reduced-motion'), + 'Loading motion must respect reduced motion' +); + +for (const locale of locales) { + for (const key of [ + '"cp.countdownLabel"', + '"cp.huwaaPlazaGiftTitle"', + '"cp.previousWeekTopTitle"', + '"cp.previousWeekTopSubtitle"', + '"cp.rankPodiumLabel"', + '"cp.rankPlace"', + '"cp.loading"', + '"cp.empty"', + '"cp.error"', + '"cp.disabled"', + '"cp.unavailable"', + '"cp.retry"', + '"cp.reward"', + '"cp.rewardCoins"', + '"cp.rewardTierGold"', + '"cp.rewardTierSilver"', + '"cp.rewardTierBronze"', + '"cp.daysLong"', + ]) { + invariant( + locale.includes(key), + `Required Huwaa CP translation is missing: ${key}` + ); + } +} + +console.log( + `activity/cp/huwaa.html asset audit passed (${manifest.assets.length} traced layers)` +); diff --git a/scripts/check-activity-invite-huwaa-layer-assets.mjs b/scripts/check-activity-invite-huwaa-layer-assets.mjs new file mode 100644 index 0000000..6c74423 --- /dev/null +++ b/scripts/check-activity-invite-huwaa-layer-assets.mjs @@ -0,0 +1,224 @@ +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 root = path.resolve(scriptDirectory, '..'); +const inviteDirectory = path.join(root, 'activity', 'invite'); +const pageDirectory = path.join(inviteDirectory, 'huwaa'); +const assetDirectory = path.join(pageDirectory, 'assets', 'layers'); +const manifestPath = path.join(assetDirectory, 'manifest.json'); +const forbiddenAggregateNodes = new Set([ + '543:1998', + '850:232', + '850:431', + '850:638', + '850:237', + '850:255', + '850:262', + '850:291', + '850:314', + '850:425', + '850:628', + '850:835', +]); + +function invariant(condition, message) { + if (!condition) throw new Error(message); +} + +const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); +invariant(manifest.schemaVersion === 1, 'Unsupported manifest schema'); +invariant( + manifest.figma?.fileKey === 'OTOg5cwbhVNWxkImGmzTJl', + 'Unexpected Figma file key' +); +invariant(manifest.figma?.canvas?.id === '543:1998', 'Unexpected Figma canvas'); +invariant( + JSON.stringify(manifest.figma?.entryFrames) === + JSON.stringify({ + default: '850:232', + win: '850:431', + estimatedRewards: '850:638', + }), + 'Entry-frame mapping changed' +); +invariant( + manifest.figma?.designSize?.width === 375 && + manifest.figma?.designSize?.height === 1114, + 'Unexpected design size' +); +invariant(Array.isArray(manifest.assets), 'Manifest assets must be an array'); +invariant(manifest.assets.length === 26, 'Manifest must describe 26 layers'); + +const listedFiles = new Set(); +for (const asset of manifest.assets) { + invariant( + asset.file && !listedFiles.has(asset.file), + `Duplicate asset ${asset.file}` + ); + listedFiles.add(asset.file); + invariant(asset.sourceNode, `${asset.file}: sourceNode is required`); + invariant( + !forbiddenAggregateNodes.has(asset.sourceNode), + `${asset.file}: aggregate Figma export is forbidden` + ); + invariant( + asset.sourceName && asset.sourceType, + `${asset.file}: source name/type is required` + ); + invariant(asset.purpose, `${asset.file}: purpose is required`); + invariant( + asset.bounds?.width > 0 && asset.bounds?.height > 0, + `${asset.file}: design bounds are required` + ); + invariant( + asset.original?.width > 0 && asset.original?.height > 0, + `${asset.file}: original dimensions are required` + ); + invariant(asset.exportFormat, `${asset.file}: export format is required`); + invariant( + Number.isInteger(asset.bytes) && asset.bytes > 0, + `${asset.file}: byte size is required` + ); + invariant( + /^[a-f0-9]{64}$/.test(asset.sha256 || ''), + `${asset.file}: SHA-256 is required` + ); + for (const flag of [ + 'containsText', + 'containsControls', + 'containsDynamicMedia', + ]) { + invariant( + typeof asset[flag] === 'boolean', + `${asset.file}: ${flag} must be boolean` + ); + } + invariant(!asset.containsText, `${asset.file}: rendered text is forbidden`); + invariant( + !asset.containsControls, + `${asset.file}: rendered controls are forbidden` + ); + invariant( + !asset.containsDynamicMedia, + `${asset.file}: dynamic business media is forbidden` + ); + + const assetPath = path.join(assetDirectory, asset.file); + const [bytes, info] = await Promise.all([ + readFile(assetPath), + stat(assetPath), + ]); + const digest = createHash('sha256').update(bytes).digest('hex'); + invariant(info.size === asset.bytes, `${asset.file}: byte size mismatch`); + invariant(digest === asset.sha256, `${asset.file}: SHA-256 mismatch`); +} + +const diskAssets = (await readdir(assetDirectory)) + .filter((file) => file !== 'manifest.json') + .sort(); +invariant( + JSON.stringify(diskAssets) === JSON.stringify([...listedFiles].sort()), + 'Every visual asset must have exactly one manifest entry' +); + +const [html, css, js, theme, api, locales] = await Promise.all([ + readFile(path.join(inviteDirectory, 'huwaa.html'), 'utf8'), + readFile(path.join(pageDirectory, 'huwaa.css'), 'utf8'), + readFile(path.join(pageDirectory, 'huwaa.js'), 'utf8'), + readFile(path.join(root, 'common', 'theme.css'), 'utf8'), + readFile(path.join(root, 'common', 'api.js'), 'utf8'), + Promise.all( + ['en', 'ar', 'tr', 'es'].map((locale) => + readFile( + path.join(root, 'common', 'locales', `${locale}.json`), + 'utf8' + ) + ) + ), +]); +const runtimeSource = [html, css, js].join('\n'); + +for (const nodeID of forbiddenAggregateNodes) { + invariant( + !runtimeSource.includes(nodeID), + `Runtime must not reference aggregate Figma node ${nodeID}` + ); +} +for (const forbidden of [ + 'whole-page', + 'page-screenshot', + 'figma-frame.png', + '850-232.png', + '850-431.png', + '850-638.png', +]) { + invariant( + !runtimeSource.toLowerCase().includes(forbidden), + `Forbidden aggregate screenshot reference: ${forbidden}` + ); +} + +for (const asset of manifest.assets) { + invariant( + runtimeSource.includes(asset.file), + `${asset.file}: traced asset is not consumed by the page` + ); +} + +invariant(theme.includes('--hy-theme-primary'), 'Common theme is invalid'); +invariant(html.includes('../../common/theme.css'), 'Common theme is required'); +invariant(html.includes('../../common/i18n.js'), 'Common i18n is required'); +invariant(html.includes('../../common/api.js'), 'Common API is required'); +invariant( + html.includes('data-hy-app-code="huwaa"'), + 'Huwaa app identity is required' +); +invariant( + html.indexOf("window.HyAppDefaultAppCode = 'huwaa'") < + html.indexOf('../../common/api.js'), + 'Huwaa app code must be configured before common API initialization' +); +invariant(api.includes('activityWheelAPI'), 'Wheel API is required'); +invariant(js.includes('HyAppAPI.activityWheel'), 'Wheel API must be consumed'); +invariant( + /\.balances\(\s*['"]COIN['"]\s*\)/.test(js), + 'Wallet balance API must be consumed' +); +invariant( + js.includes('/api/v1/users/me/invite-overview'), + 'Invite overview API must be consumed' +); +invariant( + js.includes("params.get('mock') === '1'"), + 'Visual fixtures must require an explicit mock query' +); +invariant( + !html.includes('DDDDD'), + 'Figma placeholder reward names must not be baked into production HTML' +); +invariant( + html.includes(']*\ssrc=/, + 'production avatar must be populated from API data' +); +assert.match(js, /value === '1' \|\| value === 'true'/); +assert.match(js, /preview_url/); +assert.match(js, /\/api\/v1\/activities\/cumulative-recharge-reward/); +assert.doesNotMatch( + js, + /HyAppAPI\.post|\/claim['"`]/, + 'cumulative recharge has no claim RPC' +); +assert.doesNotMatch( + js, + /catch\([^)]*\)[\s\S]{0,160}mockData/, + 'production failures must not become mock success' +); + +const mockAssets = manifest.assets + .filter((asset) => asset.mockOnly) + .map((asset) => asset.path); +for (const mockAsset of mockAssets) { + const basename = mockAsset.split('/').pop().replaceAll('.', '\\.'); + assert.match(js, new RegExp(basename)); + assert.doesNotMatch(html, new RegExp(basename)); +} + +console.log( + `recharge reward Fami layer audit passed: ${manifest.assets.length} traced assets, root frame excluded` +); diff --git a/scripts/check-activity-room-reward-fami-layer-assets.mjs b/scripts/check-activity-room-reward-fami-layer-assets.mjs new file mode 100644 index 0000000..e1a6bc1 --- /dev/null +++ b/scripts/check-activity-room-reward-fami-layer-assets.mjs @@ -0,0 +1,129 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..' +); +const pageFile = path.join(repoRoot, 'activity/room-reward/fami.html'); +const cssFile = path.join(repoRoot, 'activity/room-reward/fami/fami.css'); +const scriptFile = path.join(repoRoot, 'activity/room-reward/fami/fami.js'); +const assetRoot = path.join( + repoRoot, + 'activity/room-reward/fami/assets/layers' +); +const manifestFile = path.join( + repoRoot, + 'activity/room-reward/fami/assets/manifest.json' +); + +function fail(message) { + throw new Error(`[fami-room-reward-layer-audit] ${message}`); +} + +function sha256(file) { + return crypto + .createHash('sha256') + .update(fs.readFileSync(file)) + .digest('hex'); +} + +for (const file of [pageFile, cssFile, scriptFile, manifestFile]) { + if (!fs.existsSync(file)) + fail(`missing required file: ${path.relative(repoRoot, file)}`); +} + +const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8')); +const assets = Array.isArray(manifest.assets) ? manifest.assets : []; +if (!assets.length) fail('manifest has no independently traced assets'); + +const forbiddenNodes = new Set(manifest.policy?.forbiddenNodes || []); +const manifestFiles = new Set(); +for (const asset of assets) { + if ( + !asset.file || + !asset.sha256 || + !Array.isArray(asset.nodes) || + !asset.nodes.length + ) { + fail(`incomplete manifest entry: ${JSON.stringify(asset)}`); + } + if (asset.nodes.some((node) => forbiddenNodes.has(node))) { + fail( + `aggregate/root Figma node exported as runtime asset: ${asset.file}` + ); + } + if (!String(asset.kind || '').startsWith('single-')) { + fail(`asset is not classified as a single visual layer: ${asset.file}`); + } + const file = path.join(assetRoot, asset.file); + if (!fs.existsSync(file)) fail(`manifest asset missing: ${asset.file}`); + if (sha256(file) !== asset.sha256) fail(`hash mismatch: ${asset.file}`); + if (asset.pixelSize?.[0] === 1080 && asset.pixelSize?.[1] === 1985) { + fail(`whole-page sized asset is forbidden: ${asset.file}`); + } + manifestFiles.add(asset.file); +} + +for (const file of fs.readdirSync(assetRoot)) { + if (!manifestFiles.has(file)) + fail(`untracked runtime layer asset: ${file}`); +} + +const html = fs.readFileSync(pageFile, 'utf8'); +const css = fs.readFileSync(cssFile, 'utf8'); +const js = fs.readFileSync(scriptFile, 'utf8'); +const runtimeSource = `${html}\n${css}\n${js}`; + +for (const forbidden of [ + 'figma-default.png', + '/.audit/', + '822:2775', + '822:2783', +]) { + if (runtimeSource.includes(forbidden)) + fail(`forbidden aggregate reference: ${forbidden}`); +} + +const referencedAssets = new Set(); +for (const match of runtimeSource.matchAll( + /(?:fami\/)?assets\/layers\/([\w.-]+)/g +)) { + referencedAssets.add(match[1]); +} +for (const file of referencedAssets) { + if (!manifestFiles.has(file)) + fail(`runtime asset is not in manifest: ${file}`); +} + +for (const requiredDOM of [ + 'id="rrTargetValue"', + 'id="rrTierTable"', + 'id="rrUserName"', + 'id="rrContributionValue"', + 'id="rrRewardValue"', +]) { + if (!html.includes(requiredDOM)) + fail(`dynamic DOM layer missing: ${requiredDOM}`); +} + +if (html.includes(' expected.has(item)) + ); +} + +function webpMetadata(bytes, file) { + invariant( + bytes.length >= 20 && + bytes.toString('ascii', 0, 4) === 'RIFF' && + bytes.toString('ascii', 8, 12) === 'WEBP', + `${file}: file is not WebP` + ); + invariant( + bytes.readUInt32LE(4) + 8 === bytes.length, + `${file}: RIFF length mismatch` + ); + + let width; + let height; + let encoding; + let hasAlpha = false; + const setDimensions = (nextWidth, nextHeight, chunk) => { + invariant( + nextWidth > 0 && nextHeight > 0, + `${file}: invalid ${chunk} dimensions` + ); + if (width === undefined) { + width = nextWidth; + height = nextHeight; + return; + } + invariant( + width === nextWidth && height === nextHeight, + `${file}: container and ${chunk} dimensions disagree` + ); + }; + + let offset = 12; + while (offset + 8 <= bytes.length) { + const chunk = bytes.toString('ascii', offset, offset + 4); + const chunkSize = bytes.readUInt32LE(offset + 4); + const payloadStart = offset + 8; + const payloadEnd = payloadStart + chunkSize; + invariant( + payloadEnd <= bytes.length, + `${file}: truncated ${chunk} chunk` + ); + + if (chunk === 'VP8X') { + invariant(chunkSize >= 10, `${file}: invalid VP8X chunk`); + hasAlpha ||= Boolean(bytes[payloadStart] & 0x10); + setDimensions( + 1 + bytes.readUIntLE(payloadStart + 4, 3), + 1 + bytes.readUIntLE(payloadStart + 7, 3), + chunk + ); + } else if (chunk === 'ALPH') { + hasAlpha = true; + } else if (chunk === 'VP8L') { + invariant(chunkSize >= 5, `${file}: invalid VP8L chunk`); + invariant( + bytes[payloadStart] === 0x2f, + `${file}: invalid VP8L signature` + ); + const packed = bytes.readUInt32LE(payloadStart + 1); + invariant(packed >>> 29 === 0, `${file}: unsupported VP8L version`); + setDimensions( + 1 + (packed & 0x3fff), + 1 + ((packed >>> 14) & 0x3fff), + chunk + ); + hasAlpha ||= Boolean((packed >>> 28) & 1); + invariant( + !encoding || encoding === 'lossless', + `${file}: conflicting WebP codecs` + ); + encoding = 'lossless'; + } else if (chunk === 'VP8 ') { + invariant(chunkSize >= 10, `${file}: invalid VP8 chunk`); + invariant( + bytes[payloadStart + 3] === 0x9d && + bytes[payloadStart + 4] === 0x01 && + bytes[payloadStart + 5] === 0x2a, + `${file}: invalid VP8 frame header` + ); + setDimensions( + bytes.readUInt16LE(payloadStart + 6) & 0x3fff, + bytes.readUInt16LE(payloadStart + 8) & 0x3fff, + chunk + ); + invariant( + !encoding || encoding === 'lossy', + `${file}: conflicting WebP codecs` + ); + encoding = 'lossy'; + } + + offset = payloadEnd + (chunkSize % 2); + } + + invariant(offset === bytes.length, `${file}: invalid RIFF chunk padding`); + invariant(width && height && encoding, `${file}: image payload is missing`); + return { width, height, encoding, hasAlpha }; +} + +const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); +invariant(manifest.schemaVersion === 2, 'unsupported manifest schema'); +invariant( + manifest.figma?.fileKey === 'OTOg5cwbhVNWxkImGmzTJl', + 'unexpected Figma file key' +); +invariant(manifest.figma?.canvas?.id === '721:2016', 'unexpected Figma canvas'); +invariant( + JSON.stringify(manifest.figma?.entryFrames) === + JSON.stringify({ + history: { + id: '837:4472', + name: '历史前三', + width: 1080, + height: 2651, + }, + current: { + id: '837:4545', + name: '历史前三', + width: 1080, + height: 2460, + }, + rewards: { + id: '837:4615', + name: '历史前三', + width: 1080, + height: 3532, + }, + }), + 'entry-frame mapping changed' +); + +const heroReference = manifest.figma?.references?.hero; +invariant(heroReference?.groupNode === '837:4721', 'hero group trace missing'); +invariant(heroReference?.maskNode === '837:4722', 'hero mask trace missing'); +invariant(heroReference?.imageNode === '837:4723', 'hero image trace missing'); +invariant( + heroReference?.runtimeExported === false, + 'hero must remain a non-runtime reference' +); + +const forbiddenAggregateNodes = new Set( + manifest.policy?.forbiddenAggregateNodes || [] +); +for (const node of requiredAggregateNodes) { + invariant( + forbiddenAggregateNodes.has(node), + `required aggregate-node ban is missing: ${node}` + ); +} +const allowedSourceTypes = new Set(manifest.policy?.allowedSourceTypes || []); +invariant( + allowedSourceTypes.size === 1 && allowedSourceTypes.has('RECTANGLE'), + 'only isolated rectangle image-fill nodes may be exported' +); + +const assets = manifest.assets; +invariant(Array.isArray(assets), 'manifest assets must be an array'); +invariant( + assets.length === 13, + 'manifest must trace 10 runtime layers and 3 fixtures' +); +invariant( + assets.length === requiredAssetMetadata.size, + 'asset metadata recipe and manifest inventory disagree' +); + +const assetsByFile = new Map(); +const sourceOwners = new Map(); +for (const asset of assets) { + invariant( + typeof asset.file === 'string' && !assetsByFile.has(asset.file), + `duplicate or missing asset file: ${asset.file}` + ); + assetsByFile.set(asset.file, asset); + + const expectedMetadata = requiredAssetMetadata.get(asset.file); + invariant(expectedMetadata, `${asset.file}: export recipe is missing`); + + invariant( + /^\d+:\d+$/.test(asset.sourceNode || ''), + `${asset.file}: source node is required` + ); + invariant( + !forbiddenAggregateNodes.has(asset.sourceNode), + `${asset.file}: aggregate Figma node export is forbidden` + ); + invariant( + allowedSourceTypes.has(asset.sourceType), + `${asset.file}: source type must be an isolated visual leaf` + ); + invariant( + asset.sourceName && asset.purpose, + `${asset.file}: source name and purpose are required` + ); + invariant( + !sourceOwners.has(asset.sourceNode), + `${asset.file}: source node is already owned by ${sourceOwners.get(asset.sourceNode)}` + ); + sourceOwners.set(asset.sourceNode, asset.file); + + invariant( + Array.isArray(asset.equivalentNodes), + `${asset.file}: equivalentNodes must be an array` + ); + for (const node of asset.equivalentNodes) { + invariant( + /^\d+:\d+$/.test(node), + `${asset.file}: invalid equivalent node ${node}` + ); + invariant( + !forbiddenAggregateNodes.has(node), + `${asset.file}: aggregate equivalent node is forbidden: ${node}` + ); + } + + invariant( + String(asset.kind || '').startsWith('single-'), + `${asset.file}: asset is not classified as one visual layer` + ); + invariant( + asset.bounds?.width > 0 && asset.bounds?.height > 0, + `${asset.file}: Figma bounds are required` + ); + invariant( + asset.original?.width > 0 && asset.original?.height > 0, + `${asset.file}: original pixel dimensions are required` + ); + invariant( + asset.original.width === expectedMetadata.original[0] && + asset.original.height === expectedMetadata.original[1], + `${asset.file}: Figma source pixel dimensions changed` + ); + invariant( + asset.exported?.width === expectedMetadata.exported[0] && + asset.exported?.height === expectedMetadata.exported[1], + `${asset.file}: exported pixel dimensions changed` + ); + invariant( + asset.exportFormat === expectedMetadata.exportFormat, + `${asset.file}: export format changed` + ); + invariant( + asset.quality === expectedMetadata.quality, + `${asset.file}: lossy quality changed` + ); + invariant( + asset.alphaQuality === expectedMetadata.alphaQuality, + `${asset.file}: alpha quality changed` + ); + invariant( + asset.hasAlpha === expectedMetadata.hasAlpha, + `${asset.file}: declared alpha presence changed` + ); + if (asset.exportFormat === 'webp-lossy') { + invariant( + Number.isInteger(asset.quality) && + asset.quality >= 1 && + asset.quality <= 100, + `${asset.file}: lossy quality must be between 1 and 100` + ); + invariant( + asset.hasAlpha + ? Number.isInteger(asset.alphaQuality) && + asset.alphaQuality >= 1 && + asset.alphaQuality <= 100 + : asset.alphaQuality === null, + `${asset.file}: alpha quality does not match alpha usage` + ); + } else { + invariant( + asset.exportFormat === 'webp-lossless' && + asset.quality === null && + asset.alphaQuality === null, + `${asset.file}: lossless exports must not declare lossy quality` + ); + } + invariant( + Number.isInteger(asset.bytes) && asset.bytes > 0, + `${asset.file}: byte size is required` + ); + invariant( + /^[a-f0-9]{64}$/.test(asset.sha256 || ''), + `${asset.file}: SHA-256 is required` + ); + invariant( + Array.isArray(asset.dependencies), + `${asset.file}: dependencies must be an array` + ); + for (const flag of [ + 'containsText', + 'containsControls', + 'containsDynamicMedia', + 'mockOnly', + 'dynamic', + ]) { + invariant( + typeof asset[flag] === 'boolean', + `${asset.file}: ${flag} must be boolean` + ); + } + invariant(!asset.containsText, `${asset.file}: rendered text is forbidden`); + invariant( + !asset.containsControls, + `${asset.file}: rendered controls are forbidden` + ); + + const resolvedPath = path.resolve(layerDirectory, asset.file); + invariant( + resolvedPath.startsWith(`${assetRoot}${path.sep}`), + `${asset.file}: asset escapes the week-star asset root` + ); + if (asset.scope === 'runtime') { + invariant( + path.dirname(resolvedPath) === layerDirectory, + `${asset.file}: runtime assets must stay in assets/layers` + ); + invariant( + path.posix.basename(asset.file) === asset.file, + `${asset.file}: runtime file must not use a nested path` + ); + invariant( + !asset.containsDynamicMedia && !asset.mockOnly && !asset.dynamic, + `${asset.file}: runtime layers cannot contain mock or dynamic media` + ); + for (const pattern of manifest.policy.forbiddenRuntimeNamePatterns || + []) { + invariant( + !asset.file + .toLowerCase() + .includes(String(pattern).toLowerCase()), + `${asset.file}: forbidden aggregate-style runtime name ${pattern}` + ); + } + } else if (asset.scope === 'fixture') { + invariant( + path.dirname(resolvedPath) === fixtureDirectory, + `${asset.file}: fixtures must stay in assets/fixtures` + ); + invariant( + asset.file.startsWith('../fixtures/'), + `${asset.file}: fixture path must be explicit` + ); + invariant( + asset.containsDynamicMedia && asset.mockOnly && asset.dynamic, + `${asset.file}: sample media must be marked mockOnly and dynamic` + ); + } else { + invariant(false, `${asset.file}: unsupported scope ${asset.scope}`); + } + + const [bytes, fileInfo] = await Promise.all([ + readFile(resolvedPath), + stat(resolvedPath), + ]); + const digest = createHash('sha256').update(bytes).digest('hex'); + const metadata = webpMetadata(bytes, asset.file); + invariant( + fileInfo.size === asset.bytes, + `${asset.file}: byte size mismatch` + ); + invariant(digest === asset.sha256, `${asset.file}: SHA-256 mismatch`); + invariant( + metadata.width === asset.exported.width && + metadata.height === asset.exported.height, + `${asset.file}: decoded pixels do not match exported dimensions` + ); + invariant( + asset.exportFormat === `webp-${metadata.encoding}`, + `${asset.file}: decoded WebP codec does not match export format` + ); + invariant( + metadata.hasAlpha === asset.hasAlpha, + `${asset.file}: decoded alpha presence does not match manifest` + ); +} + +const actualRuntimeFiles = new Set( + assets + .filter((asset) => asset.scope === 'runtime') + .map((asset) => asset.file) +); +const actualFixtureFiles = new Set( + assets + .filter((asset) => asset.scope === 'fixture') + .map((asset) => asset.file) +); +invariant( + sameSet(actualRuntimeFiles, requiredRuntimeFiles), + 'runtime layer inventory changed' +); +invariant( + sameSet(actualFixtureFiles, requiredFixtureFiles), + 'fixture inventory changed' +); + +const diskRuntimeFiles = new Set( + (await readdir(layerDirectory)).filter((file) => file.endsWith('.webp')) +); +const diskFixtureFiles = new Set( + (await readdir(fixtureDirectory)) + .filter((file) => file.endsWith('.webp')) + .map((file) => `../fixtures/${file}`) +); +invariant( + sameSet(diskRuntimeFiles, requiredRuntimeFiles), + 'every runtime layer must have exactly one manifest entry' +); +invariant( + sameSet(diskFixtureFiles, requiredFixtureFiles), + 'every visual fixture must have exactly one manifest entry' +); + +for (const asset of assets) { + for (const dependency of asset.dependencies) { + const target = assetsByFile.get(dependency); + invariant(target, `${asset.file}: unknown dependency ${dependency}`); + invariant( + dependency !== asset.file, + `${asset.file}: self dependency is forbidden` + ); + if (asset.scope === 'runtime') { + invariant( + target.scope === 'runtime', + `${asset.file}: runtime assets cannot depend on fixtures` + ); + } + } +} + +const visiting = new Set(); +const visited = new Set(); +function visit(file) { + if (visited.has(file)) return; + invariant(!visiting.has(file), `asset dependency cycle includes ${file}`); + visiting.add(file); + for (const dependency of assetsByFile.get(file).dependencies) + visit(dependency); + visiting.delete(file); + visited.add(file); +} +for (const file of assetsByFile.keys()) visit(file); + +const compositions = manifest.compositions; +invariant( + compositions && typeof compositions === 'object', + 'layer compositions are required' +); +const compositionSlots = new Set(); +const composedRuntimeFiles = new Set(); +for (const [name, composition] of Object.entries(compositions)) { + invariant( + Array.isArray(composition.layers) && composition.layers.length > 0, + `${name}: composition layers are required` + ); + invariant( + Array.isArray(composition.dynamicSlots), + `${name}: dynamicSlots must be an array` + ); + for (const file of composition.layers) { + const asset = assetsByFile.get(file); + invariant(asset, `${name}: unknown composition layer ${file}`); + invariant( + asset.scope === 'runtime', + `${name}: production composition cannot include fixture ${file}` + ); + composedRuntimeFiles.add(file); + } + for (const slot of composition.dynamicSlots) { + invariant( + typeof slot === 'string' && slot.length > 0, + `${name}: invalid dynamic slot` + ); + compositionSlots.add(slot); + } +} +invariant( + sameSet(composedRuntimeFiles, requiredRuntimeFiles), + 'every runtime layer must belong to a declared composition' +); + +const fixtureBindings = manifest.fixtureBindings; +invariant( + Array.isArray(fixtureBindings) && fixtureBindings.length === 3, + 'three explicit fixture bindings are required' +); +const boundFixtures = new Set(); +for (const binding of fixtureBindings) { + const asset = assetsByFile.get(binding.file); + invariant( + asset?.scope === 'fixture', + `invalid fixture binding ${binding.file}` + ); + invariant( + binding.mockOnly === true, + `${binding.file}: binding must be mockOnly` + ); + invariant( + compositionSlots.has(binding.slot), + `${binding.file}: fixture slot is not declared by a composition` + ); + invariant( + !boundFixtures.has(binding.file), + `${binding.file}: duplicate fixture binding` + ); + boundFixtures.add(binding.file); +} +invariant( + sameSet(boundFixtures, requiredFixtureFiles), + 'every fixture must bind exactly one dynamic slot' +); + +const shippedSourceNodes = new Set(assets.map((asset) => asset.sourceNode)); +for (const node of [ + heroReference.groupNode, + heroReference.maskNode, + heroReference.imageNode, +]) { + invariant( + !shippedSourceNodes.has(node), + `hero reference node was shipped: ${node}` + ); +} + +console.log( + `[week-star-fami-layer-audit] passed: ${requiredRuntimeFiles.size} isolated runtime layers, ${requiredFixtureFiles.size} mock-only dynamic fixtures` +); diff --git a/scripts/check-activity-week-star-huwaa-assets.mjs b/scripts/check-activity-week-star-huwaa-assets.mjs new file mode 100644 index 0000000..2027c6b --- /dev/null +++ b/scripts/check-activity-week-star-huwaa-assets.mjs @@ -0,0 +1,518 @@ +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 entryDirectory = path.join(repoRoot, 'activity', 'week-star'); +const pageDirectory = path.join(entryDirectory, 'huwaa'); +const layerDirectory = path.join(pageDirectory, 'assets', 'layers'); +const manifestPath = path.join(layerDirectory, 'manifest.json'); + +const expectedFrames = { + history: { + id: '818:1999', + name: '历史前三', + width: 1080, + height: 2309, + }, + weekly: { + id: '818:2066', + name: '本周榜单', + width: 1080, + height: 2309, + }, + rewards: { + id: '818:2132', + name: '奖励说明', + width: 1080, + height: 3138, + }, +}; +const forbiddenRootNodes = new Set([ + '543:1998', + '818:1999', + '818:2066', + '818:2132', +]); +const expectedFiles = new Set([ + 'background-stars.webp', + 'date-ribbon.webp', + 'gift-frame.webp', + 'hero-background.webp', + 'rank-1.webp', + 'rank-2.webp', + 'rank-3.webp', + 'reward-heading.webp', + 'reward-item-frame.webp', + 'reward-item-glow.webp', + 'reward-panel.webp', + 'sample-gift.webp', + 'tab-active.webp', + 'tab-inactive.webp', +]); +const aggregateNamePattern = + /(?:whole[-_ ]?page|full[-_ ]?page|page[-_ ]?screenshot|screen[-_ ]?shot|root[-_ ]?frame|entry[-_ ]?frame|figma[-_ ]?frame|aggregate)/i; + +function invariant(condition, message) { + if (!condition) { + throw new Error(`[week-star-huwaa-asset-audit] ${message}`); + } +} + +function sameSet(actual, expected) { + return ( + actual.size === expected.size && + [...actual].every((value) => expected.has(value)) + ); +} + +function readUInt24LE(bytes, offset) { + return bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16); +} + +// WebP can store dimensions in VP8X, VP8L, or VP8. Walk RIFF chunks instead of +// assuming the image payload is the first chunk, because alpha/metadata chunks +// may precede it in otherwise valid exports. +function webpDimensions(bytes, filename) { + invariant( + bytes.length >= 20 && + bytes.toString('ascii', 0, 4) === 'RIFF' && + bytes.toString('ascii', 8, 12) === 'WEBP', + `${filename}: file is not a RIFF WebP` + ); + + let offset = 12; + while (offset + 8 <= bytes.length) { + const type = bytes.toString('ascii', offset, offset + 4); + const size = bytes.readUInt32LE(offset + 4); + const payload = offset + 8; + invariant( + payload + size <= bytes.length, + `${filename}: truncated ${type} WebP chunk` + ); + + if (type === 'VP8X') { + invariant(size >= 10, `${filename}: invalid VP8X chunk`); + return { + width: 1 + readUInt24LE(bytes, payload + 4), + height: 1 + readUInt24LE(bytes, payload + 7), + }; + } + if (type === 'VP8L') { + invariant( + size >= 5 && bytes[payload] === 0x2f, + `${filename}: invalid VP8L signature` + ); + const packed = bytes.readUInt32LE(payload + 1); + return { + width: 1 + (packed & 0x3fff), + height: 1 + ((packed >>> 14) & 0x3fff), + }; + } + if (type === 'VP8 ') { + invariant( + size >= 10 && + bytes[payload + 3] === 0x9d && + bytes[payload + 4] === 0x01 && + bytes[payload + 5] === 0x2a, + `${filename}: invalid VP8 frame header` + ); + return { + width: bytes.readUInt16LE(payload + 6) & 0x3fff, + height: bytes.readUInt16LE(payload + 8) & 0x3fff, + }; + } + + offset = payload + size + (size & 1); + } + throw new Error( + `[week-star-huwaa-asset-audit] ${filename}: no WebP image chunk found` + ); +} + +const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); +invariant(manifest.schemaVersion === 1, 'unsupported manifest schema'); +invariant( + manifest.figma?.fileKey === 'OTOg5cwbhVNWxkImGmzTJl', + 'unexpected Figma file key' +); +invariant(manifest.figma?.canvas?.id === '543:1998', 'unexpected Figma canvas'); +invariant( + JSON.stringify(manifest.figma?.entryFrames) === + JSON.stringify(expectedFrames), + 'entry-frame mapping changed' +); + +const manifestForbiddenNodes = new Set( + manifest.policy?.forbiddenSourceNodes || [] +); +for (const node of forbiddenRootNodes) { + invariant( + manifestForbiddenNodes.has(node), + `required root/canvas-node ban is missing: ${node}` + ); +} + +const maxLayer = manifest.policy?.maxReasonableFileLayer; +invariant( + maxLayer?.width > 0 && maxLayer?.height > 0 && maxLayer?.pixels > 0, + 'reasonable single-layer size policy is missing' +); + +const assets = manifest.assets; +invariant(Array.isArray(assets), 'manifest assets must be an array'); +invariant(assets.length === expectedFiles.size, 'manifest asset count changed'); + +const assetsByFile = new Map(); +for (const asset of assets) { + invariant( + typeof asset.filename === 'string' && + path.basename(asset.filename) === asset.filename, + 'asset filename must be a basename' + ); + invariant( + !assetsByFile.has(asset.filename), + `duplicate manifest asset: ${asset.filename}` + ); + assetsByFile.set(asset.filename, asset); + invariant( + !aggregateNamePattern.test(asset.filename), + `${asset.filename}: aggregate/root/screenshot-style filename is forbidden` + ); + for (const pattern of manifest.policy?.forbiddenFilenamePatterns || []) { + invariant( + !asset.filename + .toLowerCase() + .includes(String(pattern).toLowerCase()), + `${asset.filename}: forbidden filename pattern ${pattern}` + ); + } + + invariant( + /^\d+:\d+$/.test(asset.sourceNode || ''), + `${asset.filename}: sourceNode is required` + ); + invariant( + !forbiddenRootNodes.has(asset.sourceNode), + `${asset.filename}: canvas/root frame cannot be an asset source` + ); + invariant( + Array.isArray(asset.sourceNodes) && + asset.sourceNodes.length > 0 && + asset.sourceNodes.includes(asset.sourceNode), + `${asset.filename}: complete sourceNodes trace is required` + ); + for (const node of asset.sourceNodes) { + invariant( + /^\d+:\d+$/.test(node) && !forbiddenRootNodes.has(node), + `${asset.filename}: invalid or aggregate source node ${node}` + ); + } + invariant( + asset.sourceType === 'RECTANGLE', + `${asset.filename}: source must be an isolated image-fill rectangle` + ); + invariant( + typeof asset.sourceName === 'string' && asset.sourceName.length > 0, + `${asset.filename}: sourceName is required` + ); + invariant( + /^[a-f0-9]{40}$/.test(asset.sourceImageHash || ''), + `${asset.filename}: exact Figma sourceImageHash is required` + ); + invariant( + typeof asset.use === 'string' && asset.use.length > 0, + `${asset.filename}: use is required` + ); + invariant( + String(asset.kind || '').startsWith('single-'), + `${asset.filename}: asset is not classified as one visual layer` + ); + invariant( + asset.renderedDesignSize?.width > 0 && + asset.renderedDesignSize?.height > 0, + `${asset.filename}: rendered design dimensions are required` + ); + invariant( + asset.file?.format === 'webp' && + Number.isInteger(asset.file?.pixelWidth) && + Number.isInteger(asset.file?.pixelHeight) && + Number.isInteger(asset.file?.bytes) && + asset.file.pixelWidth > 0 && + asset.file.pixelHeight > 0 && + asset.file.bytes > 0, + `${asset.filename}: actual file dimensions/bytes are required` + ); + invariant( + /^[a-f0-9]{64}$/.test(asset.sha256 || ''), + `${asset.filename}: SHA-256 is required` + ); + for (const flag of [ + 'containsText', + 'containsControls', + 'containsDynamicBusinessMedia', + 'mockOnly', + ]) { + invariant( + typeof asset[flag] === 'boolean', + `${asset.filename}: ${flag} must be boolean` + ); + } + invariant( + !asset.containsText, + `${asset.filename}: localized/readable copy must not be baked into assets` + ); + invariant( + !asset.containsControls, + `${asset.filename}: controls must remain DOM elements` + ); + + if (asset.scope === 'runtime') { + invariant( + !asset.containsDynamicBusinessMedia && !asset.mockOnly, + `${asset.filename}: production layers cannot contain mock/dynamic business media` + ); + } else if (asset.scope === 'fixture') { + invariant( + asset.filename === 'sample-gift.webp' && + asset.containsDynamicBusinessMedia && + asset.mockOnly, + `${asset.filename}: only the explicit mock gift may be a fixture` + ); + } else { + invariant(false, `${asset.filename}: unsupported scope ${asset.scope}`); + } + + invariant( + asset.file.pixelWidth <= maxLayer.width && + asset.file.pixelHeight <= maxLayer.height && + asset.file.pixelWidth * asset.file.pixelHeight <= maxLayer.pixels, + `${asset.filename}: file exceeds a reasonable single-layer size` + ); + invariant( + !(asset.file.pixelWidth >= 1000 && asset.file.pixelHeight >= 2200), + `${asset.filename}: whole-screen-sized image is forbidden` + ); + + const assetPath = path.join(layerDirectory, asset.filename); + const [bytes, fileInfo] = await Promise.all([ + readFile(assetPath), + stat(assetPath), + ]); + const digest = createHash('sha256').update(bytes).digest('hex'); + const dimensions = webpDimensions(bytes, asset.filename); + invariant( + fileInfo.size === asset.file.bytes, + `${asset.filename}: byte size mismatch` + ); + invariant(digest === asset.sha256, `${asset.filename}: SHA-256 mismatch`); + invariant( + dimensions.width === asset.file.pixelWidth && + dimensions.height === asset.file.pixelHeight, + `${asset.filename}: WebP dimensions mismatch` + ); +} + +invariant( + sameSet(new Set(assetsByFile.keys()), expectedFiles), + 'manifest must describe exactly the expected 14 traced files' +); +const diskFiles = new Set( + (await readdir(layerDirectory)).filter((file) => file !== 'manifest.json') +); +invariant( + sameSet(diskFiles, expectedFiles), + 'every layer file must have exactly one manifest entry; no source/root screenshots may be stored here' +); + +const hero = assetsByFile.get('hero-background.webp'); +invariant( + hero?.kind === 'single-derived-background', + 'derived hero kind changed' +); +invariant( + hero?.sourceImageHash === 'cbc4013cd84d895e3a3ebf260e42b1013066c6ae', + 'hero exact-fill source hash changed' +); +invariant( + hero?.derivedFrom?.kind === 'exact-figma-image-fill' && + hero.derivedFrom.sourceImageHash === hero.sourceImageHash && + hero.derivedFrom.tool === 'built-in image_gen' && + hero.derivedFrom.sourceContainedReadableText === true && + /removed only the embedded readable title\/date text/i.test( + hero.derivedFrom.operation || '' + ), + 'hero must be documented as an exact-fill derivative with only embedded title/date text removed' +); +invariant( + !hero.containsText && + !hero.containsControls && + !hero.containsDynamicBusinessMedia && + !hero.mockOnly, + 'derived hero output must be a text-free, control-free, non-business-media background' +); +invariant( + /decorative hero background only/i.test(hero.use) && + /DOM\/i18n\/API/i.test(hero.use), + 'hero purpose must keep title/date in live DOM/i18n/API content' +); +for (const asset of assets) { + if (asset.filename !== hero.filename) { + invariant( + asset.derivedFrom === undefined, + `${asset.filename}: unexpected derived-image declaration` + ); + } +} + +const [html, css, js, commonTheme, commonAPI, localeEntries] = + await Promise.all([ + readFile(path.join(entryDirectory, 'huwaa.html'), 'utf8'), + readFile(path.join(pageDirectory, 'huwaa.css'), 'utf8'), + readFile(path.join(pageDirectory, 'huwaa.js'), 'utf8'), + readFile(path.join(repoRoot, 'common', 'theme.css'), 'utf8'), + readFile(path.join(repoRoot, 'common', 'api.js'), 'utf8'), + Promise.all( + ['en', 'ar', 'tr', 'es'].map(async (language) => ({ + language, + messages: JSON.parse( + await 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}: traced asset is not referenced by the page` + ); +} +for (const reference of manifest.policy?.forbiddenRuntimeReferences || []) { + invariant( + !runtimeSource.toLowerCase().includes(String(reference).toLowerCase()), + `forbidden root/source/screenshot runtime reference: ${reference}` + ); +} +invariant( + !aggregateNamePattern.test(runtimeSource), + 'runtime source contains an aggregate/root/page-screenshot reference' +); + +// Any literal local bitmap referenced by HTML/CSS/JS must resolve to the traced +// layer inventory. Remote API media is dynamic and therefore is not a literal. +for (const match of runtimeSource.matchAll( + /["'(]\s*([^"')\s]+\.(?:webp|png|jpe?g|avif))(?:\?[^"')\s]*)?/gi +)) { + const literal = match[1]; + if (/^(?:https?:|data:)/i.test(literal)) continue; + invariant( + expectedFiles.has(path.basename(literal)), + `untraced local bitmap reference: ${literal}` + ); +} + +invariant( + html.includes('../../common/theme.css'), + 'common/theme.css must be loaded' +); +invariant( + commonTheme.includes('--hy-theme-primary') && + commonTheme.includes('--hy-theme-bg') && + commonTheme.includes('--hy-theme-surface'), + 'common theme contract is missing required variables' +); +invariant( + html.includes('../../common/i18n.js') && html.includes('data-i18n'), + 'common i18n runtime and bindings are required' +); +invariant( + html.includes('../../common/api.js'), + 'common API runtime must be loaded' +); +invariant( + html.includes('data-hy-app-code="huwaa"'), + 'Huwaa app identity is required' +); +invariant( + html.indexOf("window.HyAppDefaultAppCode = 'huwaa'") >= 0 && + html.indexOf("window.HyAppDefaultAppCode = 'huwaa'") < + html.indexOf('../../common/api.js'), + 'Huwaa app_code must be set before common API initialization' +); + +invariant( + commonAPI.includes('var weeklyStarAPI') && + commonAPI.includes('weeklyStar: weeklyStarAPI'), + 'common weekly-star API namespace is missing' +); +for (const call of ['.current()', '.history(1)']) { + invariant( + js.includes(call), + `weekly-star page is missing API call ${call}` + ); +} +invariant( + js.includes('HyAppAPI.weeklyStar'), + 'page must consume HyAppAPI.weeklyStar' +); +invariant( + js.includes('data.top_entries') && !js.includes('.leaderboard('), + 'page must consume current.top_entries without a duplicate leaderboard request' +); +invariant( + js.includes("'duration_ms'") && js.includes('/ DAY_MS'), + 'reward validity must consume the gateway duration_ms contract' +); + +invariant( + /\.get\(\s*['"]mock['"]\s*\)\s*===\s*['"]1['"]/.test(js), + 'visual fixture must require explicit ?mock=1' +); +invariant( + /if\s*\(\s*useMock\s*\)\s*loadMock\(\)\s*;?\s*else\s*loadReal\(\)/.test(js), + 'mock and production loading paths must be explicitly separated' +); +invariant( + js.includes('sample-gift.webp') && + !html.includes('sample-gift.webp') && + !css.includes('sample-gift.webp'), + 'sample gift must remain a JS-gated mock fixture, not production markup/style' +); +invariant( + !/catch\s*\([^)]*\)\s*\{[\s\S]{0,1200}(?:loadMock|mockState)\s*\(/.test(js), + 'API failures must not silently fall back to mock business data' +); + +const translationKeys = new Set(); +for (const source of [html, js]) { + for (const match of source.matchAll(/huwaa_weekly_star\.[a-z0-9_.-]+/gi)) { + translationKeys.add(match[0]); + } +} +invariant(translationKeys.size > 0, 'no Huwaa Weekly Star i18n keys found'); +for (const { language, messages } of localeEntries) { + for (const key of translationKeys) { + invariant( + Object.prototype.hasOwnProperty.call(messages, key) && + typeof messages[key] === 'string' && + messages[key].trim().length > 0, + `${language}: missing or empty translation ${key}` + ); + } +} + +console.log( + `[week-star-huwaa-asset-audit] passed: ${assets.length} traced layers, hashes/dimensions/runtime references/theme/i18n/API/mock gating verified` +); diff --git a/scripts/check-coin-seller-fami-layer-assets.mjs b/scripts/check-coin-seller-fami-layer-assets.mjs new file mode 100644 index 0000000..077dd0f --- /dev/null +++ b/scripts/check-coin-seller-fami-layer-assets.mjs @@ -0,0 +1,263 @@ +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 root = path.resolve(scriptDirectory, '..'); +const pageDirectory = path.join(root, 'coin-seller'); +const assetDirectory = path.join(pageDirectory, 'assets', 'fami', 'layers'); +const manifestPath = path.join(assetDirectory, 'manifest.json'); +const forbiddenAggregateNodes = new Set([ + '827:3011', + '827:3012', + '827:3027', + '827:3042', + '827:3057', + '827:3072', + '827:3087', + '827:3014', + '827:3022', +]); + +function invariant(condition, message) { + if (!condition) throw new Error(message); +} + +const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); +invariant(manifest.schemaVersion === 1, 'Unsupported manifest schema'); +invariant( + manifest.figma?.fileKey === 'OTOg5cwbhVNWxkImGmzTJl', + 'Unexpected Figma file key' +); +invariant( + manifest.figma?.rootFrame?.id === '827:3011', + 'Unexpected Figma root frame' +); +invariant( + manifest.figma?.designSize?.width === 1080 && + manifest.figma?.designSize?.height === 2626 && + manifest.figma?.designSize?.cssWidth === 360, + 'Unexpected design dimensions' +); +invariant( + manifest.runtimeRules?.referenceScreenshotRuntimeUse === false, + 'The whole-page reference screenshot must remain QA-only' +); +invariant( + manifest.runtimeRules?.referenceScreenshot === + 'coin-seller/.audit/figma-827-3011.png', + 'The QA screenshot path must remain repo-relative and outside runtime assets' +); +invariant( + manifest.runtimeRules?.heroMask?.sourceNode === '827:3089' && + JSON.stringify(manifest.runtimeRules.heroMask.sourceStops) === + JSON.stringify([65, 100]) && + JSON.stringify(manifest.runtimeRules.heroMask.renderedStops) === + JSON.stringify([83.8, 94.7]), + 'The transformed Figma hero mask trace is required' +); +invariant(Array.isArray(manifest.assets), 'Manifest assets must be an array'); +invariant( + manifest.assets.length === 4, + 'Exactly four traced layers are allowed' +); + +const listedFiles = new Set(); +for (const asset of manifest.assets) { + invariant( + asset.file && !listedFiles.has(asset.file), + `Duplicate asset ${asset.file}` + ); + listedFiles.add(asset.file); + invariant(asset.sourceNode, `${asset.file}: sourceNode is required`); + invariant( + !forbiddenAggregateNodes.has(asset.sourceNode), + `${asset.file}: aggregate Figma export is forbidden` + ); + invariant( + asset.sourceName && asset.sourceType && asset.purpose, + `${asset.file}: source trace and purpose are required` + ); + invariant( + asset.bounds?.width > 0 && asset.bounds?.height > 0, + `${asset.file}: design bounds are required` + ); + invariant( + asset.original?.width > 0 && asset.original?.height > 0, + `${asset.file}: original dimensions are required` + ); + invariant(asset.exportFormat, `${asset.file}: export format is required`); + invariant( + Number.isInteger(asset.bytes) && asset.bytes > 0, + `${asset.file}: byte size is required` + ); + invariant( + /^[a-f0-9]{64}$/.test(asset.sha256 || ''), + `${asset.file}: SHA-256 is required` + ); + invariant( + asset.containsDynamicText === false, + `${asset.file}: dynamic text is forbidden in assets` + ); + invariant( + asset.containsControls === false, + `${asset.file}: rendered controls are forbidden in assets` + ); + invariant( + asset.containsBusinessMedia === false, + `${asset.file}: business media is forbidden in assets` + ); + invariant( + asset.containsDecorativeWordmark === false || + asset.sourceNode === '827:3090', + `${asset.file}: only the single Figma hero image may contain its decorative wordmark` + ); + + const assetPath = path.join(assetDirectory, asset.file); + const [bytes, info] = await Promise.all([ + readFile(assetPath), + stat(assetPath), + ]); + const digest = createHash('sha256').update(bytes).digest('hex'); + invariant(info.size === asset.bytes, `${asset.file}: byte size mismatch`); + invariant(digest === asset.sha256, `${asset.file}: SHA-256 mismatch`); +} + +const diskAssets = (await readdir(assetDirectory)) + .filter((file) => file !== 'manifest.json') + .sort(); +invariant( + JSON.stringify(diskAssets) === JSON.stringify([...listedFiles].sort()), + 'Every runtime visual asset must have exactly one manifest entry' +); + +const [html, css, js, api, theme, locales] = await Promise.all([ + readFile(path.join(pageDirectory, 'fami.html'), 'utf8'), + readFile(path.join(pageDirectory, 'fami.css'), 'utf8'), + readFile(path.join(pageDirectory, 'fami.js'), 'utf8'), + readFile(path.join(root, 'common', 'api.js'), 'utf8'), + readFile(path.join(root, 'common', 'theme.css'), 'utf8'), + Promise.all( + ['en', 'ar', 'tr', 'es', 'zh', 'id'].map((locale) => + readFile( + path.join(root, 'common', 'locales', `${locale}.json`), + 'utf8' + ) + ) + ), +]); +const runtimeSource = [html, css, js].join('\n'); +const runtimeLower = runtimeSource.toLowerCase(); + +for (const nodeID of forbiddenAggregateNodes) { + invariant( + !runtimeSource.includes(nodeID), + `Runtime must not reference aggregate Figma node ${nodeID}` + ); +} +for (const forbidden of [ + 'figma-827-3011.png', + '.audit/figma', + 'whole-page', + 'page-screenshot', + 'root-frame.png', +]) { + invariant( + !runtimeLower.includes(forbidden), + `Forbidden whole-page reference: ${forbidden}` + ); +} + +for (const asset of manifest.assets) { + invariant( + runtimeSource.includes(asset.file), + `${asset.file}: traced asset is not consumed by the page` + ); +} + +invariant(theme.includes('--hy-theme-primary'), 'Common theme is invalid'); +invariant(html.includes('../common/theme.css'), 'Common theme is required'); +invariant(html.includes('../common/i18n.js'), 'Common i18n is required'); +invariant(html.includes('../common/api.js'), 'Common API is required'); +invariant( + html.includes('data-hy-app-code="fami"'), + 'Fami app identity is required' +); +invariant( + html.indexOf("window.HyAppDefaultAppCode = 'fami'") < + html.indexOf('../common/api.js'), + 'Fami app code must be configured before common API initialization' +); +invariant( + html.includes('