Merge pull request #122 from PretendoNetwork/feature-stripe-tiers

Feature stripe tiers
This commit is contained in:
Jonathan Barrow 2022-07-10 08:54:32 -04:00 committed by GitHub
commit accd22112a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 2718 additions and 485 deletions

View File

@ -17,5 +17,23 @@
"role id"
]
},
"stripe": {
"secret_key": "sk_secret",
"webhook_secret": "whsec_secret"
},
"database": {
"account": {
"uri": "mongodb://127.0.0.1:27017",
"database": "pretendo",
"options": {
"useNewUrlParser": true,
"useUnifiedTopology": true
}
}
},
"gmail": {
"user": "email@gmail.com",
"pass": "app-password"
},
"aes_key": "hex key here"
}

244
locales/CN_zh.json Normal file
View File

@ -0,0 +1,244 @@
{
"nav": {
"about": "关于",
"faq": "FAQ",
"docs": "文件",
"credits": "贡献者",
"progress": "进度",
"blog": "博客",
"account": "用户"
},
"hero": {
"subtitle": "游戏服务器",
"title": "重新创建",
"text": "Pretendo 是任天堂 3DS 和 Wii U 服务器的免费和开源替代品,允许所有人在线连接,即使在原始服务器已经关闭",
"buttons": {
"readMore": "更多"
}
},
"aboutUs": {
"title": "关于我们",
"paragraphs": [
"Pretendo 是一个开源项目,旨在使用逆向工程为 3DS 和 Wii U 重新创建 Nintendo Network。",
"我们的服务将是免费和开源的,它们可以在 Nintendo Network 不可避免的关闭之后还存在。"
]
},
"progress": {
"title": "进度",
"paragraphs": [
"目前,我们正在重新创建 Miiverse以及我们的用户服务器及相关的服务。",
"对于 3DS我们也在创建《马里奥赛车 7》的服务器并希望在可能的情况下继续为他游戏做服务器。"
]
},
"faq": {
"title": "常见问题",
"text": "以下是我们被问到的一些常见问题,以获得简单的信息。",
"QAs": [
{
"question": "什么是 Pretendo?",
"answer": "Pretendo 是一个开源的 Nintendo Network 替代品,旨在为 Wii U 和 3DS 系列游戏机构建自定义服务器。我们的目标是保留这些游戏机的在线功能,让玩家可以继续尽情畅玩他们最喜欢的 Wii U 和 3DS 游戏。"
},
{
"question": "我现有的 NNID 可以在 Pretendo 上工作吗?",
"answer": "遗憾的是,不行。现有的 NNID 将无法在 Pretendo 上运行,因为只有任天堂拥有您的用户数据;虽然 NNID 到 PNID 的迁移在理论上是可能的,但它存在风险并且需要我们不希望持有的敏感用户数据。"
},
{
"question": "如何使用 Pretendo",
"answer": "Pretendo 目前尚未处于可供公众使用的状态。但是,一旦完成,您只需在系统上运行我们的自制补丁程序即可使用 Pretendo。"
},
{
"question": "你知道 <功能/服务> 什么时候准备好吗?",
"answer": "不可以。许多 Pretendo 的功能/服务是独立开发的例如Miiverse 可能由一位开发人员构建,而 Accounts 和 Friends 正在由另一位开发人员构建),因此我们无法给出这需要多长时间的总体预计到达时间。"
},
{
"question": "Pretendo 是否适用于 Cemu/模拟器?",
"answer": "Pretendo 是专为 Wii U 和 3DS 设计的;目前,这些支持 NN 的硬件的唯一模拟器是 Cemu。 Cemu 不正式支持自定义服务器,但仍然可以将 Pretendo 与 Cemu 一起使用。<br>Pretendo 目前不支持 Cemu。"
},
{
"question": "如果我在 Nintendo Network 上被封账号,我在使用 Pretendo 时还会被禁吗?",
"answer": "我们将无法访问 Nintendo Network 的禁令,并且不会禁止所有用户使用我们的服务。但是,我们在使用服务时将遵守规则,不遵守这些规则可能会导致被禁止。"
},
{
"question": "Pretendo 会支持 Wii/Switch 吗?",
"answer": "Wii 已经有 <a href=\"https://wiimmfi.de/\" target=\"_blank\">Wiimmfi</a> 提供的自定义服务器。我们目前不希望以 Switch 为目标,因为它既是付费的,又与 Nintendo Network 完全不同。"
},
{
"question": "我需要黑客来连接吗?",
"answer": "是的,您需要破解您的设备才能连接;但是,在 Wii U 上,您只需要访问 Homebrew Launcher即 Haxchi、Coldboot Haxchi 甚至网络浏览器漏洞利用程序),稍后会提供有关 3DS 将如何连接的信息。"
}
]
},
"showcase": {
"title": "我们做什么",
"text": "我们的项目有很多组件。这里是其中的一些。",
"cards": [
{
"title": "游戏服务器",
"caption": "使用自定义服务器带回您最喜爱的游戏和内容。"
},
{
"title": "Juxtaposition",
"caption": "对 Miiverse 的重新想象,仿佛它是在现代时代制造的。"
},
{
"title": "支持 Cemu",
"caption": "即使没有硬件也能玩你最喜欢的 Wii U 游戏!"
}
]
},
"credits": {
"title": "团队",
"text": "认识项目背后的团队",
"people": [
{
"name": "Jonathan Barrow (jonbarrow)",
"caption": "项目所有者和首席开发人员",
"picture": "https://github.com/jonbarrow.png",
"github": "https://github.com/jonbarrow"
},
{
"name": "Jemma (CaramelKat)",
"caption": "Miiverse 研发",
"picture": "https://github.com/caramelkat.png",
"github": "https://github.com/CaramelKat"
},
{
"name": "Rambo6Glaz",
"caption": "网络安装程序和系统研究",
"picture": "https://github.com/Rambo6Glaz.png",
"github": "https://github.com/Rambo6Glaz"
},
{
"name": "quarky",
"caption": "BOSS研究和补丁开发",
"picture": "https://github.com/QuarkTheAwesome.png",
"github": "https://github.com/QuarkTheAwesome"
},
{
"name": "SuperMarioDaBom",
"caption": "控制台等系统研究",
"picture": "https://github.com/supermariodabom.png",
"github": "https://github.com/SuperMarioDaBom"
},
{
"name": "Jip Fr",
"caption": "网络开发主管",
"picture": "https://github.com/jipfr.png",
"github": "https://github.com/jipfr"
},
{
"name": "monty",
"caption": "网络开发",
"picture": "https://github.com/ashmonty.png",
"github": "https://github.com/ashmonty"
},
{
"name": "mrjvs",
"caption": "设计师",
"picture": "https://github.com/mrjvs.png",
"github": "https://github.com/mrjvs"
}
]
},
"specialThanks": {
"title": "特别感谢",
"text": "没有他们Pretendo 就不会是今天的样子。",
"people": [
{
"name": "superwhiskers",
"caption": "Crunch 开发",
"picture": "https://github.com/superwhiskers.png",
"github": "https://github.com/superwhiskers"
},
{
"name": "Stary",
"caption": "3DS 开发和 NEX 解剖器",
"picture": "https://github.com/Stary2001.png",
"github": "https://github.com/Stary2001"
},
{
"name": "Billy",
"caption": "保护主义者",
"picture": "https://github.com/InternalLoss.png",
"github": "https://github.com/InternalLoss"
},
{
"name": "Shutterbug2000",
"caption": "马里奥赛车 7 和 3DS 研究",
"picture": "https://cdn.discordapp.com/avatars/191370953807233024/0311b61e2009c1576828dd2e9a59d72e.png?size=128",
"github": "https://github.com/shutterbug2000"
},
{
"name": "Kinnay",
"special": "特别感谢",
"caption": "任天堂数据结构研究",
"picture": "https://cdn.discordapp.com/avatars/186572995848830987/b55c0d4e7bfd792edf0689f83a25d8ea.png?size=128",
"github": "https://github.com/Kinnay"
},
{
"name": "NinStar",
"caption": "Mii 编辑器和 Juxt 反应的图标",
"picture": "https://github.com/ninstar.png",
"github": "https://github.com/ninstar"
},
{
"name": "GitHub contributors",
"caption": "本地化和其他贡献",
"picture": "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png",
"github": "https://github.com/PretendoNetwork"
}
]
},
"discordJoin": {
"title": "得到我们的动态与更新",
"text": "加入我们的 Discord 服务器以获取该项目的最新更新。",
"widget": {
"text": "获取我们进度的实时更新",
"button": "加入服务器"
}
},
"footer": {
"socials": "社交",
"usefulLinks": "相关链接",
"widget": {
"captions": [
"想要知道动态?",
"加入我们的 Discord!"
],
"button": "现在加入!"
}
},
"progressPage": {
"title": "我们的进度",
"description": "检查项目进度和目标! (大约每小时更新一次,不反映所有项目目标或进度)"
},
"blogPage": {
"title": "博客",
"description": "压缩块的最新更新。如果您想看到更频繁的更新,请考虑在 <a href=\"https://www.patreon.com/PretendoNetwork\" target=\"_blank\">Patreon</a> 上支持我们。"
},
"localizationPage": {
"title": "让我们本地化",
"description": "粘贴指向可公开访问的 JSON 语言环境的链接以在网站上对其进行测试",
"instructions": "查看本地化说明",
"fileInput": "要测试的文件",
"filePlaceholder": "https://a.link.to/the_file.json",
"button": "测试文件"
},
"docs": {
"missingInLocale": "此页面在您的语言环境中不可用。请看下面的英文版本。",
"quickLinks": {
"header": "快速链接",
"links": [
{
"header": "安装 Pretendo",
"caption": "查看设置说明"
},
{
"header": "有错误吗?",
"caption": "在这里搜索"
}
]
}
}
}

491
package-lock.json generated
View File

@ -22,7 +22,10 @@
"gray-matter": "^4.0.3",
"kaitai-struct": "^0.9.0",
"marked": "^4.0.10",
"mongoose": "^6.4.0",
"morgan": "^1.10.0",
"nodemailer": "^6.7.5",
"stripe": "^9.9.0",
"trello": "^0.11.0",
"uuid": "^8.3.2"
},
@ -278,6 +281,20 @@
"@types/node": "*"
}
},
"node_modules/@types/webidl-conversions": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-6.1.1.tgz",
"integrity": "sha512-XAahCdThVuCFDQLT7R7Pk/vqeObFNL3YqRyFZg+AqAP/W1/w3xHaIxuW7WszQqTbIBOPRcItYJIou3i/mppu3Q=="
},
"node_modules/@types/whatwg-url": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.1.tgz",
"integrity": "sha512-2YubE1sjj5ifxievI5Ge1sckb9k/Er66HyR2c+3+I6VDUUg1TLPdYYTEbQ+DjRkS4nTxMJhgWfSfMRD2sl2EYQ==",
"dependencies": {
"@types/node": "*",
"@types/webidl-conversions": "*"
}
},
"node_modules/accepts": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz",
@ -737,6 +754,40 @@
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
"integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4="
},
"node_modules/bson": {
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/bson/-/bson-4.6.4.tgz",
"integrity": "sha512-TdQ3FzguAu5HKPPlr0kYQCyrYUYh8tFM+CMTpxjNzVzxeiJY00Rtuj3LXLHSgiGvmaWlZ8PE+4KyM2thqE38pQ==",
"dependencies": {
"buffer": "^5.6.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/bson/node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.2.1.tgz",
@ -1129,6 +1180,14 @@
"resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz",
"integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM="
},
"node_modules/denque": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.0.1.tgz",
"integrity": "sha512-tfiWc6BQLXNLpNiR5iGd0Ocu3P3VpxfzFiqubLgMfhfOw9WyvgJBd46CClNn9k3qfbjvT//0cf7AlYRX/OslMQ==",
"engines": {
"node": ">=0.10"
}
},
"node_modules/depd": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz",
@ -2252,6 +2311,11 @@
"node": ">= 0.4"
}
},
"node_modules/ip": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/ip/-/ip-1.1.8.tgz",
"integrity": "sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg=="
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@ -2584,6 +2648,11 @@
"resolved": "https://registry.npmjs.org/kaitai-struct/-/kaitai-struct-0.9.0.tgz",
"integrity": "sha512-mfoBu9+IGqaY3ykG1TyAy9omOAZWtheqESQOvo/HKIQVTz+gRPVCNBnhjbO+8wAQ77RD33wYvLBWmITuXIviQg=="
},
"node_modules/kareem": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/kareem/-/kareem-2.4.1.tgz",
"integrity": "sha512-aJ9opVoXroQUPfovYP5kaj2lM7Jn02Gw13bL0lg9v0V7SaUc0qavPs0Eue7d2DcC3NjqI6QAUElXNsuZSeM+EA=="
},
"node_modules/keyv": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.0.4.tgz",
@ -2694,6 +2763,12 @@
"node": ">= 0.6"
}
},
"node_modules/memory-pager": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
"integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
"optional": true
},
"node_modules/merge-descriptors": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
@ -2821,6 +2896,58 @@
"node": ">= 0.8.0"
}
},
"node_modules/mongodb": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-4.7.0.tgz",
"integrity": "sha512-HhVar6hsUeMAVlIbwQwWtV36iyjKd9qdhY+s4wcU8K6TOj4Q331iiMy+FoPuxEntDIijTYWivwFJkLv8q/ZgvA==",
"dependencies": {
"bson": "^4.6.3",
"denque": "^2.0.1",
"mongodb-connection-string-url": "^2.5.2",
"socks": "^2.6.2"
},
"engines": {
"node": ">=12.9.0"
},
"optionalDependencies": {
"saslprep": "^1.0.3"
}
},
"node_modules/mongodb-connection-string-url": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.5.2.tgz",
"integrity": "sha512-tWDyIG8cQlI5k3skB6ywaEA5F9f5OntrKKsT/Lteub2zgwSUlhqEN2inGgBTm8bpYJf8QYBdA/5naz65XDpczA==",
"dependencies": {
"@types/whatwg-url": "^8.2.1",
"whatwg-url": "^11.0.0"
}
},
"node_modules/mongoose": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/mongoose/-/mongoose-6.4.0.tgz",
"integrity": "sha512-eBDrueap1Zx3qFrcYylTiqTFlL5iTEaYAxoDF1MSRdipwAzChQRMJve+vxHtxPhI2q5tmf9RYHfZwXfTUHPd3g==",
"dependencies": {
"bson": "^4.6.2",
"kareem": "2.4.1",
"mongodb": "4.7.0",
"mpath": "0.9.0",
"mquery": "4.0.3",
"ms": "2.1.3",
"sift": "16.0.0"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mongoose"
}
},
"node_modules/mongoose/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/morgan": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz",
@ -2844,6 +2971,46 @@
"node": ">= 0.8"
}
},
"node_modules/mpath": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz",
"integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/mquery": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/mquery/-/mquery-4.0.3.tgz",
"integrity": "sha512-J5heI+P08I6VJ2Ky3+33IpCdAvlYGTSUjwTPxkAr8i8EoduPMBX2OY/wa3IKZIQl7MU4SbFk8ndgSKyB/cl1zA==",
"dependencies": {
"debug": "4.x"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/mquery/node_modules/debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
"dependencies": {
"ms": "2.1.2"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/mquery/node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@ -2868,6 +3035,14 @@
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.1.tgz",
"integrity": "sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw=="
},
"node_modules/nodemailer": {
"version": "6.7.5",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.7.5.tgz",
"integrity": "sha512-6VtMpwhsrixq1HDYSBBHvW0GwiWawE75dS3oal48VqRhUvKJNnKnJo2RI/bCVQubj1vgrgscMNW4DHaD6xtMCg==",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/normalize-url": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
@ -3151,7 +3326,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
"dev": true,
"engines": {
"node": ">=6"
}
@ -3377,6 +3551,18 @@
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"node_modules/saslprep": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/saslprep/-/saslprep-1.0.3.tgz",
"integrity": "sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==",
"optional": true,
"dependencies": {
"sparse-bitfield": "^3.0.3"
},
"engines": {
"node": ">=6"
}
},
"node_modules/sax": {
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/sax/-/sax-0.5.8.tgz",
@ -3515,6 +3701,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sift": {
"version": "16.0.0",
"resolved": "https://registry.npmjs.org/sift/-/sift-16.0.0.tgz",
"integrity": "sha512-ILTjdP2Mv9V1kIxWMXeMTIRbOBrqKc4JAXmFMnFq3fKeyQ2Qwa3Dw1ubcye3vR+Y6ofA0b9gNDr/y2t6eUeIzQ=="
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
@ -3551,6 +3742,28 @@
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
}
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.6.2.tgz",
"integrity": "sha512-zDZhHhZRY9PxRruRMR7kMhnf3I8hDs4S3f9RecfnGxvcBHQcKcIH/oUcEWffsfl1XxdYlA7nnlGbbTvPz9D8gA==",
"dependencies": {
"ip": "^1.1.5",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.13.0",
"npm": ">= 3.0.0"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@ -3559,6 +3772,15 @@
"node": ">=0.10.0"
}
},
"node_modules/sparse-bitfield": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
"optional": true,
"dependencies": {
"memory-pager": "^1.0.2"
}
},
"node_modules/statuses": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
@ -3738,6 +3960,32 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/stripe": {
"version": "9.9.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-9.9.0.tgz",
"integrity": "sha512-UBuHzKoEaHnTv2h65cIcYE0vse7at8CFlwjl/KS8I7piekMKa1lRTA5R2O4eXMp5wllWQbPF/UoLzTfjjcdBqA==",
"dependencies": {
"@types/node": ">=8.1.0",
"qs": "^6.10.3"
},
"engines": {
"node": "^8.1 || >=10.*"
}
},
"node_modules/stripe/node_modules/qs": {
"version": "6.10.5",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.10.5.tgz",
"integrity": "sha512-O5RlPh0VFtR78y79rgcgKK4wbAI0C5zGVLztOIdpWX6ep368q5Hv6XRxDvXuZ9q3C6v+e3n8UfZZJw7IIG27eQ==",
"dependencies": {
"side-channel": "^1.0.4"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/subarg": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/subarg/-/subarg-1.0.0.tgz",
@ -3844,6 +4092,17 @@
"node": ">=0.6"
}
},
"node_modules/tr46": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz",
"integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==",
"dependencies": {
"punycode": "^2.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/trello": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/trello/-/trello-0.11.0.tgz",
@ -4047,6 +4306,26 @@
"resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz",
"integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ=="
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"engines": {
"node": ">=12"
}
},
"node_modules/whatwg-url": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz",
"integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==",
"dependencies": {
"tr46": "^3.0.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -4358,6 +4637,20 @@
"@types/node": "*"
}
},
"@types/webidl-conversions": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-6.1.1.tgz",
"integrity": "sha512-XAahCdThVuCFDQLT7R7Pk/vqeObFNL3YqRyFZg+AqAP/W1/w3xHaIxuW7WszQqTbIBOPRcItYJIou3i/mppu3Q=="
},
"@types/whatwg-url": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.1.tgz",
"integrity": "sha512-2YubE1sjj5ifxievI5Ge1sckb9k/Er66HyR2c+3+I6VDUUg1TLPdYYTEbQ+DjRkS4nTxMJhgWfSfMRD2sl2EYQ==",
"requires": {
"@types/node": "*",
"@types/webidl-conversions": "*"
}
},
"accepts": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz",
@ -4736,6 +5029,25 @@
"pako": "~1.0.5"
}
},
"bson": {
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/bson/-/bson-4.6.4.tgz",
"integrity": "sha512-TdQ3FzguAu5HKPPlr0kYQCyrYUYh8tFM+CMTpxjNzVzxeiJY00Rtuj3LXLHSgiGvmaWlZ8PE+4KyM2thqE38pQ==",
"requires": {
"buffer": "^5.6.0"
},
"dependencies": {
"buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"requires": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
}
}
},
"buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.2.1.tgz",
@ -5065,6 +5377,11 @@
"resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz",
"integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM="
},
"denque": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.0.1.tgz",
"integrity": "sha512-tfiWc6BQLXNLpNiR5iGd0Ocu3P3VpxfzFiqubLgMfhfOw9WyvgJBd46CClNn9k3qfbjvT//0cf7AlYRX/OslMQ=="
},
"depd": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz",
@ -5931,6 +6248,11 @@
"side-channel": "^1.0.4"
}
},
"ip": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/ip/-/ip-1.1.8.tgz",
"integrity": "sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg=="
},
"ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@ -6156,6 +6478,11 @@
"resolved": "https://registry.npmjs.org/kaitai-struct/-/kaitai-struct-0.9.0.tgz",
"integrity": "sha512-mfoBu9+IGqaY3ykG1TyAy9omOAZWtheqESQOvo/HKIQVTz+gRPVCNBnhjbO+8wAQ77RD33wYvLBWmITuXIviQg=="
},
"kareem": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/kareem/-/kareem-2.4.1.tgz",
"integrity": "sha512-aJ9opVoXroQUPfovYP5kaj2lM7Jn02Gw13bL0lg9v0V7SaUc0qavPs0Eue7d2DcC3NjqI6QAUElXNsuZSeM+EA=="
},
"keyv": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.0.4.tgz",
@ -6245,6 +6572,12 @@
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g="
},
"memory-pager": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
"integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
"optional": true
},
"merge-descriptors": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
@ -6344,6 +6677,48 @@
"xtend": "^4.0.0"
}
},
"mongodb": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-4.7.0.tgz",
"integrity": "sha512-HhVar6hsUeMAVlIbwQwWtV36iyjKd9qdhY+s4wcU8K6TOj4Q331iiMy+FoPuxEntDIijTYWivwFJkLv8q/ZgvA==",
"requires": {
"bson": "^4.6.3",
"denque": "^2.0.1",
"mongodb-connection-string-url": "^2.5.2",
"saslprep": "^1.0.3",
"socks": "^2.6.2"
}
},
"mongodb-connection-string-url": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.5.2.tgz",
"integrity": "sha512-tWDyIG8cQlI5k3skB6ywaEA5F9f5OntrKKsT/Lteub2zgwSUlhqEN2inGgBTm8bpYJf8QYBdA/5naz65XDpczA==",
"requires": {
"@types/whatwg-url": "^8.2.1",
"whatwg-url": "^11.0.0"
}
},
"mongoose": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/mongoose/-/mongoose-6.4.0.tgz",
"integrity": "sha512-eBDrueap1Zx3qFrcYylTiqTFlL5iTEaYAxoDF1MSRdipwAzChQRMJve+vxHtxPhI2q5tmf9RYHfZwXfTUHPd3g==",
"requires": {
"bson": "^4.6.2",
"kareem": "2.4.1",
"mongodb": "4.7.0",
"mpath": "0.9.0",
"mquery": "4.0.3",
"ms": "2.1.3",
"sift": "16.0.0"
},
"dependencies": {
"ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
}
}
},
"morgan": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz",
@ -6363,6 +6738,34 @@
}
}
},
"mpath": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz",
"integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew=="
},
"mquery": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/mquery/-/mquery-4.0.3.tgz",
"integrity": "sha512-J5heI+P08I6VJ2Ky3+33IpCdAvlYGTSUjwTPxkAr8i8EoduPMBX2OY/wa3IKZIQl7MU4SbFk8ndgSKyB/cl1zA==",
"requires": {
"debug": "4.x"
},
"dependencies": {
"debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
"requires": {
"ms": "2.1.2"
}
},
"ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
}
}
},
"ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@ -6384,6 +6787,11 @@
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.1.tgz",
"integrity": "sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw=="
},
"nodemailer": {
"version": "6.7.5",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.7.5.tgz",
"integrity": "sha512-6VtMpwhsrixq1HDYSBBHvW0GwiWawE75dS3oal48VqRhUvKJNnKnJo2RI/bCVQubj1vgrgscMNW4DHaD6xtMCg=="
},
"normalize-url": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
@ -6605,8 +7013,7 @@
"punycode": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
"dev": true
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A=="
},
"qs": {
"version": "6.7.0",
@ -6784,6 +7191,15 @@
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"saslprep": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/saslprep/-/saslprep-1.0.3.tgz",
"integrity": "sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==",
"optional": true,
"requires": {
"sparse-bitfield": "^3.0.3"
}
},
"sax": {
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/sax/-/sax-0.5.8.tgz",
@ -6897,6 +7313,11 @@
"object-inspect": "^1.9.0"
}
},
"sift": {
"version": "16.0.0",
"resolved": "https://registry.npmjs.org/sift/-/sift-16.0.0.tgz",
"integrity": "sha512-ILTjdP2Mv9V1kIxWMXeMTIRbOBrqKc4JAXmFMnFq3fKeyQ2Qwa3Dw1ubcye3vR+Y6ofA0b9gNDr/y2t6eUeIzQ=="
},
"simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
@ -6913,11 +7334,34 @@
"is-fullwidth-code-point": "^3.0.0"
}
},
"smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="
},
"socks": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.6.2.tgz",
"integrity": "sha512-zDZhHhZRY9PxRruRMR7kMhnf3I8hDs4S3f9RecfnGxvcBHQcKcIH/oUcEWffsfl1XxdYlA7nnlGbbTvPz9D8gA==",
"requires": {
"ip": "^1.1.5",
"smart-buffer": "^4.2.0"
}
},
"source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
},
"sparse-bitfield": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
"optional": true,
"requires": {
"memory-pager": "^1.0.2"
}
},
"statuses": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
@ -7059,6 +7503,25 @@
"integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
"dev": true
},
"stripe": {
"version": "9.9.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-9.9.0.tgz",
"integrity": "sha512-UBuHzKoEaHnTv2h65cIcYE0vse7at8CFlwjl/KS8I7piekMKa1lRTA5R2O4eXMp5wllWQbPF/UoLzTfjjcdBqA==",
"requires": {
"@types/node": ">=8.1.0",
"qs": "^6.10.3"
},
"dependencies": {
"qs": {
"version": "6.10.5",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.10.5.tgz",
"integrity": "sha512-O5RlPh0VFtR78y79rgcgKK4wbAI0C5zGVLztOIdpWX6ep368q5Hv6XRxDvXuZ9q3C6v+e3n8UfZZJw7IIG27eQ==",
"requires": {
"side-channel": "^1.0.4"
}
}
}
},
"subarg": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/subarg/-/subarg-1.0.0.tgz",
@ -7151,6 +7614,14 @@
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz",
"integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw=="
},
"tr46": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz",
"integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==",
"requires": {
"punycode": "^2.1.1"
}
},
"trello": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/trello/-/trello-0.11.0.tgz",
@ -7311,6 +7782,20 @@
"resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz",
"integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ=="
},
"webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="
},
"whatwg-url": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz",
"integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==",
"requires": {
"tr46": "^3.0.0",
"webidl-conversions": "^7.0.0"
}
},
"which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@ -30,7 +30,10 @@
"gray-matter": "^4.0.3",
"kaitai-struct": "^0.9.0",
"marked": "^4.0.10",
"mongoose": "^6.4.0",
"morgan": "^1.10.0",
"nodemailer": "^6.7.5",
"stripe": "^9.9.0",
"trello": "^0.11.0",
"uuid": "^8.3.2"
},

View File

@ -23,27 +23,59 @@
margin: 8px 0 4px;
}
.account-sidebar .user .username {
margin: 0px;
margin: 0;
}
.account-sidebar .user .tier-name {
margin: 12px 0;
line-height: 1.2em;
border-radius: 1.2em;
border-width: 2px;
border-style: solid;
padding: 4px 16px;
}
.account-sidebar .user .tier-level-0 {
background: #2a2f50;
color: var(--text-secondary);
border-color: #383f6b;
}
.account-sidebar .user .tier-level-1 {
background: rgba(255, 132, 132, 0.2);
color: #FF8484;
border-color: rgba(255, 132, 132, 0.8);
}
.account-sidebar .user .tier-level-2 {
background: rgba(89, 201, 165, 0.3);
color:#59c9a5;
border-color: #59c9a5;
}
.account-sidebar .user .tier-level-3 {
background: rgba(202, 177, 251, 0.3);
color:#cab1fb;
border-color: #cab1fb;
}
.account-sidebar .user .mii {
width: 128px;
height: 128px;
border-radius: 100%;
background: var(--btn-secondary);
}
.account-sidebar .user #download-cemu-files {
.account-sidebar .buttons a {
display: flex;
flex-flow: column;
align-items: center;
padding: 24px;
padding: 20px 24px;
background: #383f6b;
margin: 24px 0 0;
margin: 20px 0 0;
text-decoration: none;
}
.account-sidebar .user #download-cemu-files p.download-caption {
margin: 15px 0 0;
.account-sidebar .buttons a svg {
margin-bottom: 16px;
}
.account-sidebar .user p.cemu-warning {
.account-sidebar .buttons a p.caption {
margin: 0;
}
.account-sidebar .buttons p.cemu-warning {
margin: 4px 0 0;
font-size: 0.7rem;
color: var(--text-secondary);
@ -115,13 +147,11 @@
}
fieldset {
position: relative;
height: min-content;
padding: 0;
border: none;
}
fieldset:disabled form label {
cursor: not-allowed;
}
.setting-card .server-selection {
display: flex;
@ -152,16 +182,18 @@ fieldset:disabled form label {
color: var(--text);
}
.setting-card #remove-discord-connection {
.setting-card #link-discord-account {
width: 100%;
padding: 12px 48px;
cursor: pointer;
background: #383f6b;
}
.setting-card.sign-in-history a button {
.setting-card button {
width: 100%;
height: fit-content;
padding: 12px 48px;
align-self: flex-end;
cursor: pointer;
background: #383f6b;
}

View File

@ -79,27 +79,6 @@
color: var(--text-secondary);
}
@media screen and (max-width: 1064px) {
.selected-locale .locale-names {
display: none;
}
.selected-locale {
width: 80px;
margin-left: auto;
margin-right: 12px;
}
.locale-dropdown {
width: fit-content;
}
.select-box .options-container {
width: 150px;
right: 12px;
}
}
@media screen and (max-width: 946px) {
header nav a:not(.keep-on-mobile) {
display: none;

View File

@ -1,6 +1,5 @@
.select-box {
display: flex;
width: 188px;
flex-direction: column;
position: relative;
user-select: none;
@ -12,7 +11,7 @@
.select-box .options-container {
max-height: 0;
width: 100%;
width: fit-content;
opacity: 0;
transition: all 0.4s;
overflow: hidden;
@ -20,34 +19,8 @@
background-color: var(--btn-secondary);
order: 1;
position: absolute;
top: 50px;
}
.selected-locale {
margin-bottom: 8px;
position: relative;
width: 188px;
height: 45px;
border-radius: 5px;
display: flex;
align-items: center;
background-color: var(--btn-secondary);
color: white;
order: 0;
}
.selected-locale::after {
content: "";
width: 1.2rem;
height: 1.2rem;
background: url("/assets/images/down-arrow.svg");
position: absolute;
right: 15px;
top: 50%;
transition: transform 150ms;
transform: translateY(-50%);
background-size: contain;
background-position: center;
top: 48px;
right: 0;
}
.select-box .option .item {
@ -67,25 +40,21 @@
overflow-y: auto;
}
.select-box .options-container.active + .selected-locale::after {
.select-box .options-container.active + .locale-dropdown-toggle::after {
transform: translateY(-50%) rotateX(180deg);
}
.select-box .options-container::-webkit-scrollbar {
width: 8px;
background: #0d141f;
background: #81878f;
background: #f1f2f3;
background: var(--btn-secondary);
border-radius: 0 5px 5px 0;
}
.select-box .options-container::-webkit-scrollbar-thumb {
background: #525861;
background: #81878f;
background: var(--text-secondary);
border-radius: 0 5px 5px 0;
}
.select-box .option,
.selected-locale {
.select-box .option {
padding: 12px 15px;
cursor: pointer;
}

View File

@ -1,7 +1,11 @@
.wrapper {
display: flex;
flex-flow: column;
min-height: 100vh;
}
header {
margin: 35px 0;
}
.account-form-wrapper {
margin: auto;
@ -9,34 +13,19 @@
overflow: hidden;
}
.account-form-wrapper .logotype {
display: block;
position: relative;
left: -6px;
margin-bottom: 30px;
}
.account-form-wrapper .logotype svg {
width: 140px;
}
.account-form-wrapper .logotype svg text {
font-size: 20px;
transform: translate(593px, 495px);
}
form.account {
display: block;
padding: 40px 48px;
background-color: #292E53;
background-color: #292e53;
color: var(--text-secondary);
border-radius: 12px;
width: 360px;
max-width: calc(90vw - 96px);
width: min(480px, 90vw);
box-sizing: border-box;
}
form.account h2 {
margin: 0;
color: var(--text)
color: var(--text);
}
form.account p {
@ -53,7 +42,7 @@ form.account input {
display: block;
font-family: Poppins, Arial, Helvetica, sans-serif;
font-size: 1rem;
background-color: #353C6A;
background-color: #353c6a;
border: none;
border-radius: 4px;
padding: 12px;
@ -62,7 +51,7 @@ form.account input {
}
form.account input:focus {
background-color: #4B5595;
background-color: #4b5595;
outline: none;
transition: 150ms;
}
@ -84,7 +73,7 @@ form.account button {
font-size: 1rem;
color: var(--text);
padding: 12px 30px;
background: #353C6A;
background: #353c6a;
margin-top: 18px;
cursor: pointer;
width: 100%;
@ -99,7 +88,7 @@ form.account button[type="submit"] {
form.account a {
text-decoration: none;
display: block;
color: var(--text-secondary);
color: var(--text-secondary);
text-align: right;
margin: 6px 0;
width: fit-content;
@ -114,19 +103,23 @@ form.account a.pwdreset {
}
form.account a.register {
margin:auto;
margin: auto;
margin-top: 18px;
}
form.account.register div:last-child {
margin-top: 42px;
}
@keyframes banner-notice {
0% {top: -150px}
20% {top: 35px}
80% {top: 35px}
100% {top: -150px}
0% {
top: -150px;
}
20% {
top: 35px;
}
80% {
top: 35px;
}
100% {
top: -150px;
}
}
.banner-notice {
display: flex;
@ -142,5 +135,35 @@ form.account.register div:last-child {
z-index: 3;
}
.banner-notice.error div {
background: #A9375B;
}
background: #a9375b;
}
form.account.register {
display: grid;
grid-template-columns: repeat(2, 1fr);
width: min(780px, 90vw);
column-gap: 24px;
margin-bottom: 48px;
}
form.account.register div.h-captcha {
grid-column: 1 / span 2;
display: flex;
justify-content: center;
}
form.account.register p,
form.account.register div.email,
form.account.register div.buttons {
grid-column: 1 / span 2;
}
@media screen and (max-width: 720px) {
form.account.register {
grid-template-columns: 1fr;
}
form.account.register div.h-captcha,
form.account.register p,
form.account.register div.email,
form.account.register div.buttons {
grid-column: unset;
}
}

View File

@ -94,10 +94,108 @@ header nav a:hover {
transition: color 50ms ease-in-out;
}
.locale-dropdown {
header .right-section {
display: grid;
grid-auto-flow: column;
grid-gap: 24px;
margin-left: auto;
z-index: 2;
height: 45px;
color: var(--text-secondary);
}
header .locale-dropdown-toggle {
width: fit-content;
height: 24px;
padding: 0;
margin: auto;
transition: color 150ms;
cursor: pointer;
}
header .locale-dropdown-toggle:hover,
header .locale-dropdown-toggle.active {
color: var(--text);
}
header .user-widget-wrapper {
height: 24px;
}
header .user-widget-wrapper a.login-link {
color: var(--text-secondary);
text-decoration: none;
transition: color 150ms;
}
header .user-widget-wrapper a.login-link:hover {
color: var(--text);
}
header .user-widget-wrapper.logged-in {
position: relative;
width: 32px;
height: 32px;
}
header .user-widget-wrapper.logged-in .user-widget-toggle {
width: 32px;
height: 32px;
background: var(--text-secondary-2);
border-radius: 50%;
overflow: hidden;
cursor: pointer;
}
header .user-widget-wrapper .user-widget-toggle img,
header .user-widget .user-avatar img {
width: 100%;
height: 100%;
}
header .user-widget {
max-height: 0;
overflow: hidden;
box-sizing: border-box;
transition: max-height 300ms, padding 200ms, opacity 150ms;
position: absolute;
right: 0;
top: 48px;
padding: 0;
background: #2a2f50;
border-radius: 8px;
text-align: center;
opacity: 0;
box-shadow: 0 0 10px -2px #111531;
}
header .user-widget.active {
max-height: 100vh;
padding: 36px;
opacity: 1;
}
header .user-widget .user-avatar {
width: 128px;
height: 128px;
margin: auto;
background: var(--text-secondary-2);
border-radius: 50%;
overflow: hidden;
}
header .user-widget .user-info {
margin-top: 12px;
}
header .user-widget .user-info .mii-name {
color: var(--text);
}
header .user-widget .buttons {
margin-top: 12px;
}
header .user-widget .button {
margin-top: 12px;
width: 100%;
padding: 8px 60px;
cursor: pointer;
}
header .user-widget .button.logout {
background: #383f6b;
color: var(--text);
}
/* Misc */
@ -693,6 +791,44 @@ section.update-signup div.circle {
}
/* Progress */
.donation-progress {
padding: 50px 20px;
border-radius: 10px;
background: #151934;
grid-column: span 2;
}
.donation-progress h1 {
display: inline-block;
margin: 0;
}
.donation-progress span {
font-weight: bold;
}
.donation-progress a {
color: #9d6ff3;
text-decoration: none;
font-weight: bold;
}
.donation-progress a:hover {
text-decoration: underline;
}
.progress-bar {
position: relative;
display: block;
width: 100%;
height: 12px;
margin: 1rem 0;
border-radius: 6px;
background: var(--btn-secondary);
overflow: hidden;
}
.progress-bar-inner {
height: 100%;
background-color: var(--theme);
}
.all-progress-lists {
margin-top: 50px;
display: grid;
@ -928,6 +1064,9 @@ footer div.discord-server-card svg {
.feature-list-wrapper {
grid-template-columns: 100%;
}
.donation-progress {
grid-column: span 1;
}
header nav a:not(.keep-on-mobile) {
/* You don't really need it on mobile IMO */
@ -1118,25 +1257,6 @@ footer div.discord-server-card svg {
margin: auto !important;
}
.selected-locale .locale-names {
display: none;
}
.selected-locale {
width: 80px;
margin-left: auto;
margin-right: 12px;
}
.locale-dropdown {
width: fit-content;
}
.select-box .options-container {
width: 150px;
right: 12px;
}
footer {
grid-template-columns: 1fr;
grid-template-rows: repeat(4, fit-content(100%));
@ -1168,13 +1288,3 @@ footer div.discord-server-card svg {
margin: 0 10px;
}
}
@media screen and (max-width: 330px) {
.locale-dropdown .selected-locale {
width: 50px;
}
.locale-dropdown .selected-locale::after {
display: none;
}
}

View File

@ -0,0 +1,373 @@
.wrapper {
display: flex;
justify-content: center;
text-align: center;
min-height: 100vh;
}
.wrapper::before {
position: absolute;
top: -800px;
content: "";
background: #111531;
border-radius: 100%;
width: 1600px;
height: 1400px;
}
.back-arrow {
position: absolute;
display: flex;
justify-content: center;
top: 36px;
left: max(calc((100vw - 1590px) / 2), 2.5vw);
padding: 6px 10px;
background: var(--btn-secondary);
border-radius: 24px;
transition: filter 150ms;
text-decoration: none;
color: var(--text);
}
.back-arrow:hover {
filter: brightness(1.5)
}
.back-arrow svg {
width: 24px;
height: 24px;
}
.back-arrow span {
margin: 0 4px;
}
.account-form-wrapper {
display: flex;
flex-flow: column;
width: min(1200px, 100%);
color: var(--text-secondary);
margin: 0 auto 48px;
z-index: 1;
}
.account-form-wrapper .logotype {
margin: 36px auto 0;
width: fit-content;
}
h1.title {
color: var(--text);
}
p.caption {
width: min(100%, 500px);
margin: 0 auto 36px;
}
.account-form-wrapper .progress-bar-wrapper {
justify-content: center;
width: min(100%, 500px);
margin: 0 auto 72px;
padding: 24px;
border-radius: 6px;
background: #1b1f3b;
box-sizing: border-box;
}
.account-form-wrapper .progress-bar-wrapper p {
text-align: left;
margin-bottom: 0;
}
.account-form-wrapper .progress-bar-wrapper p span {
color: var(--text);
font-weight: 600;
}
.account-form-wrapper .progress-bar {
height: 8px;
border-radius: 4px;
margin-top: 0;
}
form {
box-sizing: border-box;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.3rem;
}
form .tier-radio {
display: none;
}
form .tier-radio:checked + label::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
box-shadow: inset 0 0 0 4px #9d70f1;
border-radius: 10px;
}
form .tier-radio:checked + label::after {
content: url(/assets/images/check.svg);
display: flex;
justify-content: center;
background: #9d70f1;
width: 24px;
height: 24px;
border-radius: 100%;
position: absolute;
top: -16px;
right: -16px;
padding: 6px;
}
label.tier {
display: flex;
flex-flow: column;
position: relative;
border-radius: 10px;
align-items: center;
padding-top: calc(50px + 1rem);
background: #393b5f;
cursor: pointer;
transition: all 150ms;
margin-top: 50px;
text-align: center;
}
label.tier p {
margin: 0;
margin-bottom: 0.5rem;
}
label.tier .tier-thumbnail {
height: 100px;
width: 100px;
display: flex;
align-items: center;
overflow: hidden;
border-radius: 8px;
position: absolute;
top: -50px;
z-index: 2;
background: #47496d;
}
form .tier-radio:checked + label .tier-thumbnail::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
box-shadow: inset 0 0 0 4px #9d70f1;
border-radius: 8px;
}
label.tier .tier-text {
display: flex;
flex-flow: column;
margin-bottom: auto;
}
label.tier .tier-name {
color: var(--text);
font-weight: bold;
font-size: 1.2rem;
}
label.tier .tier-perks {
text-align: left;
width: 70%;
margin: 24px auto 48px;
}
label.tier .tier-perks div {
display: grid;
grid-template-columns: 16px auto;
gap: 8px;
}
label.tier .tier-perks svg {
stroke-width: 5px;
stroke: #59c9a5;
stroke-linecap: square;
width: 16px;
height: 16px;
vertical-align: top;
margin-top: 0.5ex;
}
label.tier p.price {
display: flex;
width: 100%;
justify-content: center;
align-items: center;
background: #47496d;
margin: 0;
padding: 1.5rem 1rem;
box-sizing: border-box;
border-radius: 0 0 10px 10px;
}
label.tier p.price span {
font-size: 2rem;
color: var(--text);
font-weight: bold;
margin-right: 0.5ch;
}
form .button-wrapper {
grid-column: 2 / span 1;
position: relative;
margin-top: 24px;
}
button {
appearance: none;
-webkit-appearance: none;
display: block;
font-family: Poppins, Arial, Helvetica, sans-serif;
font-size: 1rem;
height: fit-content;
background: var(--btn);
border: none;
border-radius: 4px;
padding: 12px;
color: var(--text);
width: 100%;
transition: filter 300ms;
pointer-events: all;
cursor: pointer;
filter: none;
}
form button.disabled {
pointer-events: none;
filter: brightness(0.75) saturate(0.75); /* not using opacity here 'cause in the mobile layout you would see the cards under it */
cursor: default;
}
form button.unsubscribe {
position: relative;
background: none;
color: var(--text-secondary);
margin-top: 12px;
padding: 0;
}
form button.unsubscribe.hidden {
position: absolute;
top: 0;
pointer-events: none;
z-index: -1;
}
form button.unsubscribe:hover {
color: var(--text);
}
div.unsub-modal-wrapper,
div.switch-tier-modal-wrapper {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.6);
z-index: 10;
}
div.unsub-modal-wrapper.hidden,
div.switch-tier-modal-wrapper.hidden {
display: none;
}
div.unsub-modal,
div.switch-tier-modal {
background: #393b5f;
padding: 48px;
border-radius: 8px;
text-align: left;
width: min(660px, 90%);
box-sizing: border-box;
}
div.unsub-modal h1,
div.switch-tier-modal h1 {
margin-top: 0;
}
p.unsub-modal-caption,
p.switch-tier-modal-caption {
color: var(--text-secondary);
}
p.unsub-modal-caption span,
p.switch-tier-modal-caption span {
color: var(--text);
}
.unsub-modal-button-wrapper,
.switch-tier-modal-button-wrapper {
margin-top: 24px;
display: flex;
justify-content: flex-end;
}
.unsub-modal-button-wrapper button,
.switch-tier-modal-button-wrapper button {
margin-left: 12px;
width: fit-content;
}
.unsub-modal-button-wrapper button.cancel,
.switch-tier-modal-button-wrapper button.cancel {
background: none;
}
.unsub-modal-button-wrapper button.confirm,
.switch-tier-modal-button-wrapper button.confirm {
padding: 12px 24px;
}
@media screen and (max-width: 900px) {
.account-form-wrapper {
width: min(500px, 100%);
margin-bottom: 172px;
}
form {
grid-template-columns: 1fr;
gap: 2.4rem;
}
form button {
position: relative;
width: 100%;
}
form .button-wrapper {
grid-column: 1 / span 1;
position: fixed;
bottom: 24px;
width: min(500px, 90%);
z-index: 5;
}
form .button-wrapper::before {
content: "";
position: absolute;
top: -24px;
left: -100vw;
width: 200vw;
height: 300%;
background: #111531;
}
}
@media screen and (max-width: 600px) {
div.unsub-modal {
padding: 24px;
}
}
@media screen and (max-width: 380px) {
label.tier .tier-perks {
width: 80%;
}
.back-arrow {
padding: 6px;
}
.back-arrow span {
display: none;
}
}

View File

@ -0,0 +1,85 @@
/* eslint-disable no-undef, no-unused-vars */
// Account widget handler
const userWidgetToggle = document.querySelector('.user-widget-toggle') ;
const userWidget = document.querySelector('.user-widget');
// Open widget on click, close locale dropdown
userWidgetToggle?.addEventListener('click', () => {
userWidget.classList.toggle('active');
localeOptionsContainer.classList.toggle('active');
localeDropdownToggle.classList.toggle('active');
});
// Locale dropdown handler
function localeDropdownHandler(selectedLocale) {
document.cookie = `preferredLocale=${selectedLocale};max-age=31536000`;
window.location.reload();
}
const localeDropdown = document.querySelector(
'.locale-dropdown[data-dropdown]'
);
const localeDropdownOptions = document.querySelectorAll(
'.locale-dropdown[data-dropdown] .options-container'
);
const localeDropdownToggle = document.querySelector('.locale-dropdown-toggle');
const localeOptionsContainer = localeDropdown.querySelector('.options-container');
const localeOptionsList = localeDropdown.querySelectorAll('.option');
// click dropdown element will open dropdown
localeDropdownToggle.addEventListener('click', () => {
localeOptionsContainer.classList.toggle('active');
localeDropdownToggle.classList.toggle('active');
});
// clicking on any option will close dropdown and change value
localeOptionsList.forEach((option) => {
option.addEventListener('click', () => {
localeDropdownToggle.classList.remove('active');
localeOptionsContainer.classList.remove('active');
const selectedLocale = option.querySelector('label').getAttribute('for');
localeDropdownHandler(selectedLocale);
});
});
// close all dropdowns on scroll
document.addEventListener('scroll', () => {
localeDropdownOptions.forEach((el) => el.classList.remove('active'));
localeDropdownToggle.classList.remove('active');
userWidget.classList.remove('active');
});
// click outside of dropdown will close all dropdowns
document.addEventListener('click', (e) => {
const targetElement = e.target;
let found = false;
if (
localeDropdown == targetElement ||
localeDropdown.contains(targetElement)
) {
found = true;
userWidget.classList.remove('active');
}
if (
userWidget == targetElement ||
userWidget.contains(targetElement) ||
userWidgetToggle == targetElement ||
userWidgetToggle.contains(targetElement)
) {
found = true;
localeDropdownToggle.classList.remove('active');
localeOptionsContainer.classList.remove('active');
}
if (found) return;
// click outside of dropdowns
userWidget.classList.remove('active');
localeDropdownToggle.classList.remove('active');
localeOptionsContainer.classList.remove('active');
});

View File

@ -1,64 +0,0 @@
/* eslint-disable no-undef, no-unused-vars */
function setDefaultDropdownLocale(localeString) {
try {
const selected = document.querySelector('.selected-locale');
let item = document.querySelector(`label[for=${localeString}`);
if (!item) { // if locale can't be found, default to en-US
localeString = 'en-US';
item = document.querySelector(`label[for=${localeString}`);
}
selected.innerHTML = item.innerHTML;
} catch (e) {} // If it errors it's probably because there isn't a navbar in the view
}
function localeDropdownHandler(selectedLocale) {
document.cookie = `preferredLocale=${selectedLocale};max-age=31536000`;
window.location.reload();
}
const dropdowns = document.querySelectorAll('*[data-dropdown]');
const dropdownOptions = document.querySelectorAll(
'*[data-dropdown] .options-container'
);
dropdowns.forEach((el) => {
const selected = el.querySelector('.selected-locale');
const optionsContainer = el.querySelector('.options-container');
const optionsList = el.querySelectorAll('.option');
// click dropdown element will open dropdown
selected.addEventListener('click', () => {
optionsContainer.classList.toggle('active');
});
// clicking on any option will close dropdown and change value
optionsList.forEach((option) => {
option.addEventListener('click', () => {
selected.innerHTML = option.querySelector('label').innerHTML;
optionsContainer.classList.remove('active');
const selectedLocale = option.querySelector('label').getAttribute('for');
localeDropdownHandler(selectedLocale);
});
});
});
// close all dropdowns on scroll
document.addEventListener('scroll', () => {
dropdownOptions.forEach((el) => el.classList.remove('active'));
});
// click outside of dropdown will close all dropdowns
document.addEventListener('click', (e) => {
const targetElement = e.target;
// check if target is from a dropdown
let found = false;
dropdowns.forEach((v) => {
if (v == targetElement || v.contains(targetElement)) found = true;
});
if (found) return;
// click outside of dropdowns
dropdownOptions.forEach((el) => el.classList.remove('active'));
});

110
public/assets/js/upgrade.js Normal file
View File

@ -0,0 +1,110 @@
const buttons = {
submit: document.getElementById('submitButton'),
unsubModal: {
show: document.getElementById('unsubModalShowButton'),
close: document.getElementById('unsubModalCloseButton'),
confirm: document.getElementById('unsubModalConfirmButton'),
},
switchTierModal: {
show: document.getElementById('switchTierShowButton'),
close: document.getElementById('switchTierCloseButton'),
confirm: document.getElementById('switchTierConfirmButton'),
},
};
const currentTierID = document.querySelector('form').dataset.currentTier || undefined;
const currentTierElement = document.querySelector(`#${currentTierID}`) || undefined;
// if the condition is met, we disable the submit button and enable the unsubscribe button
function conditionalSubmitButton(condition, target) {
if (condition) {
buttons.submit.innerText = 'Already subscribed to this tier';
buttons.unsubModal.show.innerText = `Unsubscribe from ${currentTierElement.dataset.tierName}`;
buttons.submit.disabled = true;
buttons.submit.classList.add('disabled');
buttons.unsubModal.show.classList.remove('hidden');
} else {
buttons.submit.classList.remove('disabled');
buttons.unsubModal.show.classList.add('hidden');
buttons.submit.disabled = false;
buttons.submit.innerText = `Subscribe to ${target.dataset.tierName}`;
}
}
function submitForm(cancel) {
const form = document.querySelector('form');
if (cancel) {
form.action = '/account/stripe/unsubscribe';
} else {
const selectedTier = form.querySelector('input[type="radio"]:checked').value;
form.action = `/account/stripe/checkout/${selectedTier}`;
}
form.submit();
}
// If the currect tier exists, select it from the list and disable the submit button.
if (currentTierElement) {
currentTierElement.click();
conditionalSubmitButton(true);
}
// If a tier is selected, conditionally enable the submit button.
document.querySelector('form').addEventListener('change', function(e) {
e.preventDefault();
// If the selected tier is the current tier, set the button to disabled. Else we enable the button
conditionalSubmitButton(e.target.value === currentTierElement?.value, e.target);
});
// handle the submit button
buttons.submit.addEventListener('click', function(e) {
e.preventDefault();
// If the user is already subscribed to another tier, we show the confirm modal, else if this is a new subscription we submit the form.
if (currentTierElement) {
const oldTierNameSpan = document.querySelector('.switch-tier-modal-caption span.oldtier');
const newTierNameSpan = document.querySelector('.switch-tier-modal-caption span.newtier');
oldTierNameSpan.innerText = currentTierElement.dataset.tierName;
newTierNameSpan.innerText = document.querySelector('input[name="tier"]:checked').dataset.tierName;
document.querySelector('.switch-tier-modal-wrapper').classList.remove('hidden');
} else {
submitForm();
}
});
buttons.unsubModal.show.addEventListener('click', function(e) {
e.preventDefault();
const tierNameSpan = document.querySelector('.unsub-modal-caption span');
tierNameSpan.innerText = currentTierElement.dataset.tierName;
// Show the unsubscribe modal
document.querySelector('.unsub-modal-wrapper').classList.remove('hidden');
});
buttons.unsubModal.close.addEventListener('click', function(e) {
e.preventDefault();
// Hide the unsubscribe modal
document.querySelector('.unsub-modal-wrapper').classList.add('hidden');
});
buttons.unsubModal.confirm.addEventListener('click', function(e) {
e.preventDefault();
submitForm(true);
});
buttons.switchTierModal.close.addEventListener('click', function(e) {
e.preventDefault();
// Hide the switch tier modal
document.querySelector('.switch-tier-modal-wrapper').classList.add('hidden');
});
buttons.switchTierModal.confirm.addEventListener('click', function(e) {
e.preventDefault();
submitForm(false);
});

View File

@ -1,15 +1,19 @@
const Trello =require('trello');
const Trello = require('trello');
const Stripe = require('stripe');
const got = require('got');
const config = require('../config.json');
const trello = new Trello(config.trello.api_key, config.trello.api_token);
const stripe = new Stripe(config.stripe.secret_key);
const VALID_LIST_NAMES = ['Not Started', 'Started', 'Completed'];
let cache;
let trelloCache;
let stripeDonationCache;
async function getTrelloCache() {
const available = await trelloAPIAvailable();
if (!available) {
return cache || {
return trelloCache || {
update_time: 0,
sections: [{
title: 'Upstream API error',
@ -24,19 +28,19 @@ async function getTrelloCache() {
};
}
if (!cache) {
cache = await updateTrelloCache();
if (!trelloCache) {
trelloCache = await updateTrelloCache();
}
if (cache.update_time < Date.now() - (1000 * 60 * 60)) {
cache = await updateTrelloCache();
if (trelloCache.update_time < Date.now() - (1000 * 60 * 60)) {
trelloCache = await updateTrelloCache();
}
return cache;
return trelloCache;
}
async function updateTrelloCache() {
const progressData = {
const progressCache = {
update_time: Date.now(),
sections: []
};
@ -79,11 +83,11 @@ async function updateTrelloCache() {
}
if (meta.progress.not_started.length !== 0 || meta.progress.started.length !== 0 || meta.progress.completed.length !== 0) {
progressData.sections.push(meta);
progressCache.sections.push(meta);
}
}
return progressData;
return progressCache;
}
async function trelloAPIAvailable() {
@ -91,7 +95,58 @@ async function trelloAPIAvailable() {
return status.indicator !== 'major' && status.indicator !== 'critical';
}
async function getStripeDonationCache() {
if (!stripeDonationCache) {
stripeDonationCache = await updateStripeDonationCache();
}
if (stripeDonationCache.update_time < Date.now() - (1000 * 60 * 60)) {
stripeDonationCache = await updateStripeDonationCache();
}
return stripeDonationCache;
}
async function updateStripeDonationCache() {
const donationCache = {
update_time: Date.now(),
goal: config.stripe.goal_cents,
total: 0,
donators: 0,
completed_real: 0,
completed_capped: 0
};
let hasMore = true;
let lastId;
while (hasMore) {
const { data: activeSubscriptions, has_more } = await stripe.subscriptions.list({
limit: 100,
status: 'active',
starting_after: lastId
});
donationCache.donators += activeSubscriptions.length;
for (const subscription of activeSubscriptions) {
donationCache.total += subscription.plan.amount;
lastId = subscription.id;
}
hasMore = has_more;
}
donationCache.goal_dollars = donationCache.goal / 100;
donationCache.total_dollars = donationCache.total / 100;
donationCache.completed_real = Math.floor((donationCache.total / donationCache.goal) * 100); // real completion amount
donationCache.completed_capped = Math.max(0, Math.min(donationCache.completed_real, 100)); // capped at 100
return donationCache;
}
module.exports = {
getTrelloCache,
updateTrelloCache
getStripeDonationCache
};

27
src/database.js Normal file
View File

@ -0,0 +1,27 @@
const mongoose = require('mongoose');
const PNIDSchema = require('./schema/pnid');
const config = require('../config.json');
const accountServerConfig = config.database.account;
const { uri, database, options } = accountServerConfig;
let accountServerDBConnection;
let PNID;
async function connect() {
accountServerDBConnection = await mongoose.createConnection(`${uri}/${database}`, options);
accountServerDBConnection.on('error', console.error.bind(console, 'Mongoose connection error:'));
accountServerDBConnection.on('close', () => {
accountServerDBConnection.removeAllListeners();
});
await accountServerDBConnection.asPromise();
PNID = accountServerDBConnection.model('PNID', PNIDSchema);
module.exports.PNID = PNID;
}
module.exports = {
connect,
PNID
};

22
src/mailer.js Normal file
View File

@ -0,0 +1,22 @@
const nodemailer = require('nodemailer');
const config = require('../config.json');
const transporter = nodemailer.createTransport({
host: 'smtp.gmail.com',
port: 587,
secure: false,
auth: {
user: config.gmail.user,
pass: config.gmail.pass
}
});
async function sendMail(options) {
options.from = config.gmail.from;
return await transporter.sendMail(options);
}
module.exports = {
sendMail
};

View File

@ -1,13 +1,21 @@
const { Router } = require('express');
const express = require('express');
const crypto = require('crypto');
const DiscordOauth2 = require('discord-oauth2');
const { v4: uuidv4 } = require('uuid');
const AdmZip = require('adm-zip');
const Stripe = require('stripe');
const database = require('../database');
const cache = require('../cache');
const util = require('../util');
const logger = require('../logger');
const config = require('../../config.json');
const router = new Router();
const { Router } = express;
const aesKey = Buffer.from(config.aes_key, 'hex');
const stripe = new Stripe(config.stripe.secret_key);
const router = new Router();
// Create OAuth client
const discordOAuth = new DiscordOauth2({
clientId: config.discord.client_id,
@ -27,14 +35,23 @@ router.get('/', async (request, response) => {
layout: 'main',
locale: util.getLocale(request.locale.region, request.locale.language),
localeString: request.locale.toString(),
linked: request.cookies.linked,
success: request.cookies.success,
error: request.cookies.error
};
// Reset message cookies
response.clearCookie('linked', { domain: '.pretendo.network' });
response.clearCookie('success', { domain: '.pretendo.network' });
response.clearCookie('error', { domain: '.pretendo.network' });
// Check for Stripe messages
const { upgrade_success } = request.query;
if (upgrade_success === 'true') {
renderData.success = 'Account upgraded successfully';
} else if (upgrade_success === 'false') {
renderData.error = 'Account upgrade failed';
}
// Attempt to get user data
let apiResponse = await util.apiGetRequest('/v1/user', {
'Authorization': `${request.cookies.token_type} ${request.cookies.access_token}`
@ -75,9 +92,15 @@ router.get('/', async (request, response) => {
// Set user account info to render data
const account = apiResponse.body;
const pid = account.pid;
const pnid = await database.PNID.findOne({ pid });
renderData.tierName = pnid.get('connections.stripe.tier_name');
renderData.tierLevel = pnid.get('connections.stripe.tier_level');
renderData.account = account;
renderData.isTester = account.access_level > 0;
renderData.isLoggedIn = request.cookies.access_token && request.cookies.refresh_token && request.cookies.ph;
// Check if a Discord account is linked to the PNID
if (account.connections.discord.id && account.connections.discord.id.trim() !== '') {
@ -95,7 +118,7 @@ router.get('/', async (request, response) => {
});
} catch (error) {
renderData.error = 'Invalid Discord refresh token. Remove account and relink';
response.render('account/account', renderData);
return response.render('account/account', renderData);
}
// TODO: Add a dedicated endpoint for updating connections?
@ -175,6 +198,8 @@ router.get('/', async (request, response) => {
renderData.discordAuthURL = discordAuthURL;
}
renderData.isLoggedIn = request.cookies.access_token && request.cookies.refresh_token && request.cookies.ph;
response.render('account/account', renderData);
});
@ -186,6 +211,8 @@ router.get('/login', async (request, response) => {
error: request.cookies.error
};
renderData.redirect = request.query.redirect;
response.clearCookie('error', { domain: '.pretendo.network' });
response.render('account/login', renderData);
@ -193,6 +220,7 @@ router.get('/login', async (request, response) => {
router.post('/login', async (request, response) => {
const { username, password } = request.body;
let { redirect } = request.body;
let apiResponse = await util.apiPostGetRequest('/v1/login', {}, {
username,
@ -227,7 +255,11 @@ router.post('/login', async (request, response) => {
response.cookie('ph', encryptedBody.toString('hex'), { domain: '.pretendo.network' });
response.redirect('/account');
if (!redirect.startsWith('/')) {
redirect = null;
}
response.redirect(redirect || '/account');
});
router.get('/register', async (request, response) => {
@ -246,11 +278,19 @@ router.get('/register', async (request, response) => {
response.clearCookie('username', { domain: '.pretendo.network' });
response.clearCookie('mii_name', { domain: '.pretendo.network' });
let redirect = request.query.redirect;
if (!redirect.startsWith('/')) {
redirect = null;
}
renderData.redirect = redirect;
response.render('account/register', renderData);
});
router.post('/register', async (request, response) => {
const { email, username, mii_name, password, password_confirm, 'h-captcha-response': hCaptchaResponse } = request.body;
let { redirect } = request.body;
response.cookie('email', email, { domain: '.pretendo.network' });
response.cookie('username', username, { domain: '.pretendo.network' });
@ -276,7 +316,20 @@ router.post('/register', async (request, response) => {
response.clearCookie('username', { domain: '.pretendo.network' });
response.clearCookie('mii_name', { domain: '.pretendo.network' });
response.redirect('/account');
if (!redirect.startsWith('/')) {
redirect = null;
}
response.redirect(redirect || '/account');
});
router.get('/logout', async(_request, response) => {
response.clearCookie('refresh_token', { domain: '.pretendo.network' });
response.clearCookie('access_token', { domain: '.pretendo.network' });
response.clearCookie('token_type', { domain: '.pretendo.network' });
response.clearCookie('ph', { domain: '.pretendo.network' });
response.redirect('/');
});
router.get('/connect/discord', async (request, response) => {
@ -346,14 +399,14 @@ router.get('/connect/discord', async (request, response) => {
}
}
response.cookie('linked', 'Discord', { domain: '.pretendo.network' }).redirect('/account');
response.cookie('success', 'Discord account linked successfully', { domain: '.pretendo.network' }).redirect('/account');
});
router.get('/online-files', async (request, response) => {
// Verify the user is logged in
if (!request.cookies.access_token || !request.cookies.refresh_token|| !request.cookies.ph) {
return response.redirect('/account/login');
return response.redirect('/account/login?redirect=/online-files');
}
// Attempt to get user data
@ -494,4 +547,277 @@ router.get('/miieditor', async (request, response) => {
});
});
router.get('/upgrade', async (request, response) => {
// Verify the user is logged in
if (!request.cookies.access_token || !request.cookies.refresh_token || !request.cookies.ph) {
return response.redirect('/account/login?redirect=/account/upgrade');
}
// Attempt to get user data
let apiResponse = await util.apiGetRequest('/v1/user', {
'Authorization': `${request.cookies.token_type} ${request.cookies.access_token}`
});
if (apiResponse.statusCode !== 200) {
// Assume expired, refresh and retry request
apiResponse = await util.apiPostGetRequest('/v1/login', {}, {
refresh_token: request.cookies.refresh_token,
grant_type: 'refresh_token'
});
if (apiResponse.statusCode !== 200) {
return response.redirect('/account/login');
}
const tokens = apiResponse.body;
response.cookie('refresh_token', tokens.refresh_token, { domain: '.pretendo.network' });
response.cookie('access_token', tokens.access_token, { domain: '.pretendo.network' });
response.cookie('token_type', tokens.token_type, { domain: '.pretendo.network' });
apiResponse = await util.apiGetRequest('/v1/user', {
'Authorization': `${tokens.token_type} ${tokens.access_token}`
});
}
// If still failed, something went horribly wrong
if (apiResponse.statusCode !== 200) {
return response.redirect('/account/login');
}
// Set user account info to render data
const account = apiResponse.body;
const pid = account.pid;
const pnid = await database.PNID.findOne({ pid });
const renderData = {
layout: 'main',
locale: util.getLocale(request.locale.region, request.locale.language),
localeString: request.locale.toString(),
error: request.cookies.error,
currentTier: pnid.get('connections.stripe.price_id'),
donationCache: await cache.getStripeDonationCache()
};
const { data: prices } = await stripe.prices.list();
const { data: products } = await stripe.products.list();
renderData.tiers = products
.filter(product => product.active)
.sort((a, b) => +a.metadata.tier_level - +b.metadata.tier_level)
.map(product => {
const price = prices.find(price => price.product === product.id);
const perks = [];
if (product.metadata.discord_read === 'true') {
perks.push('Read-only access to select dev channels on Discord');
}
if (product.metadata.beta === 'true') {
perks.push('Access the beta servers');
}
return {
price_id: price.id,
thumbnail: product.images[0],
name: product.name,
description: product.description,
perks,
price: (price.unit_amount / 100).toLocaleString('en-US', { style: 'currency', currency: 'USD' }),
};
});
response.render('account/upgrade', renderData);
});
router.post('/stripe/checkout/:priceId', async (request, response) => {
// Verify the user is logged in
if (!request.cookies.access_token || !request.cookies.refresh_token || !request.cookies.ph) {
return response.redirect('/account/login');
}
// Attempt to get user data
let apiResponse = await util.apiGetRequest('/v1/user', {
'Authorization': `${request.cookies.token_type} ${request.cookies.access_token}`
});
if (apiResponse.statusCode !== 200) {
// Assume expired, refresh and retry request
apiResponse = await util.apiPostGetRequest('/v1/login', {}, {
refresh_token: request.cookies.refresh_token,
grant_type: 'refresh_token'
});
if (apiResponse.statusCode !== 200) {
return response.redirect('/account/login');
}
const tokens = apiResponse.body;
response.cookie('refresh_token', tokens.refresh_token, { domain: '.pretendo.network' });
response.cookie('access_token', tokens.access_token, { domain: '.pretendo.network' });
response.cookie('token_type', tokens.token_type, { domain: '.pretendo.network' });
apiResponse = await util.apiGetRequest('/v1/user', {
'Authorization': `${tokens.token_type} ${tokens.access_token}`
});
}
// If still failed, something went horribly wrong
if (apiResponse.statusCode !== 200) {
return response.redirect('/account/login');
}
// Set user account info to render data
const account = apiResponse.body;
const pid = account.pid;
let customer;
const { data: searchResults } = await stripe.customers.search({
query: `metadata['pnid_pid']:'${pid}'`
});
if (searchResults.length !== 0) {
customer = searchResults[0];
} else {
customer = await stripe.customers.create({
email: account.email.address,
metadata: {
pnid_pid: pid
}
});
}
await database.PNID.updateOne({ pid }, {
$set: {
'connections.stripe.customer_id': customer.id // ensure PNID always has latest customer ID
}
}, { upsert: true }).exec();
const priceId = request.params.priceId;
const pnid = await database.PNID.findOne({ pid });
if (pnid.get('access_level') >= 2) {
response.cookie('error', 'Staff members do not need to purchase tiers', { domain: '.pretendo.network' });
return response.redirect('/account');
}
try {
const session = await stripe.checkout.sessions.create({
line_items: [
{
price: priceId,
quantity: 1,
},
],
customer: customer.id,
mode: 'subscription',
success_url: `${config.http.base_url}/account?upgrade_success=true`,
cancel_url: `${config.http.base_url}/account?upgrade_success=false`
});
return response.redirect(303, session.url);
} catch (error) {
// Maybe we need a dedicated error page?
// Or handle this as not cookies?
response.cookie('error', error.message, { domain: '.pretendo.network' });
return response.redirect('/account');
}
});
router.post('/stripe/unsubscribe', async (request, response) => {
// Verify the user is logged in
if (!request.cookies.access_token || !request.cookies.refresh_token || !request.cookies.ph) {
return response.redirect('/account/login');
}
// Attempt to get user data
let apiResponse = await util.apiGetRequest('/v1/user', {
'Authorization': `${request.cookies.token_type} ${request.cookies.access_token}`
});
if (apiResponse.statusCode !== 200) {
// Assume expired, refresh and retry request
apiResponse = await util.apiPostGetRequest('/v1/login', {}, {
refresh_token: request.cookies.refresh_token,
grant_type: 'refresh_token'
});
if (apiResponse.statusCode !== 200) {
return response.redirect('/account/login');
}
const tokens = apiResponse.body;
response.cookie('refresh_token', tokens.refresh_token, { domain: '.pretendo.network' });
response.cookie('access_token', tokens.access_token, { domain: '.pretendo.network' });
response.cookie('token_type', tokens.token_type, { domain: '.pretendo.network' });
apiResponse = await util.apiGetRequest('/v1/user', {
'Authorization': `${tokens.token_type} ${tokens.access_token}`
});
}
// If still failed, something went horribly wrong
if (apiResponse.statusCode !== 200) {
return response.redirect('/account/login');
}
// Set user account info to render data
const account = apiResponse.body;
const pid = account.pid;
const pnid = await database.PNID.findOne({ pid });
const subscriptionId = pnid.get('connections.stripe.subscription_id');
const tierName = pnid.get('connections.stripe.tier_name');
if (subscriptionId) {
try {
await stripe.subscriptions.del(subscriptionId);
const updateData = {
'connections.stripe.subscription_id': null,
'connections.stripe.price_id': null,
'connections.stripe.tier_level': 0,
'connections.stripe.tier_name': null,
};
if (pnid.get('access_level') < 2) {
// Fail-safe for if staff members reach here
// Mostly only useful during testing
updateData.access_level = 0;
}
await database.PNID.updateOne({ pid }, { $set: updateData }).exec();
} catch (error) {
logger.error(`Error canceling old user subscription | ${pnid.get('connections.stripe.customer_id')}, ${pid}, ${subscriptionId} | - ${error.message}`);
response.cookie('error', 'Error canceling subscription! Contact support if issue persists', { domain: '.pretendo.network' });
return response.redirect('/account');
}
}
response.cookie('success', `Unsubscribed from ${tierName}`, { domain: '.pretendo.network' });
return response.redirect('/account');
});
router.post('/stripe/webhook', express.raw({ type: 'application/json' }), async (request, response) => {
const stripeSignature = request.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(request.body, stripeSignature, config.stripe.webhook_secret);
} catch (err) {
return response.status(400).send(`Webhook Error: ${err.message}`);
}
await util.handleStripeEvent(event);
response.json({ received: true });
});
module.exports = router;

View File

@ -4,14 +4,20 @@ const router = new Router();
router.get('/', async (request, response) => {
const reqLocale = request.locale;
const locale = util.getLocale(reqLocale.region, reqLocale.language);
response.render('aprilfools', {
const renderData = {
layout: 'main',
locale,
localeString: reqLocale.toString(),
});
locale: util.getLocale(request.locale.region, request.locale.language),
localeString: request.locale.toString(),
};
renderData.isLoggedIn = request.cookies.access_token && request.cookies.refresh_token && request.cookies.ph;
if (renderData.isLoggedIn) {
const account = await util.getAccount(request, response);
renderData.account = account;
}
response.render('aprilfools', renderData);
});
module.exports = router;

View File

@ -36,18 +36,21 @@ const postList = () => {
};
router.get('/', async (request, response) => {
const reqLocale = request.locale;
const locale = util.getLocale(reqLocale.region, reqLocale.language);
const localeString = reqLocale.toString();
response.render('blog/blog', {
const renderData = {
layout: 'main',
locale,
localeString,
postList
});
locale: util.getLocale(request.locale.region, request.locale.language),
localeString: request.locale.toString(),
postList,
};
renderData.isLoggedIn = request.cookies.access_token && request.cookies.refresh_token && request.cookies.ph;
if (renderData.isLoggedIn) {
const account = await util.getAccount(request, response);
renderData.account = account;
}
response.render('blog/blog', renderData);
});
// RSS feed
@ -69,10 +72,19 @@ router.get('/feed.xml', async (request, response) => {
router.get('/:slug', async (request, response, next) => {
const reqLocale = request.locale;
const locale = util.getLocale(reqLocale.region, reqLocale.language);
const renderData = {
layout: 'blog-opengraph',
locale: util.getLocale(request.locale.region, request.locale.language),
localeString: request.locale.toString(),
postList,
};
const localeString = reqLocale.toString();
renderData.isLoggedIn = request.cookies.access_token && request.cookies.refresh_token && request.cookies.ph;
if (renderData.isLoggedIn) {
const account = await util.getAccount(request, response);
renderData.account = account;
}
// Get the name of the post from the URL
const postName = request.params.slug;
@ -89,6 +101,7 @@ router.get('/:slug', async (request, response, next) => {
// Convert the post info into JSON and separate it and the content
// eslint-disable-next-line prefer-const
let { data: postInfo, content } = matter(rawPost);
renderData.postInfo = postInfo;
// Replace [yt-iframe](videoID) with the full <iframe />
content = content
@ -97,14 +110,9 @@ router.get('/:slug', async (request, response, next) => {
// Convert the content into HTML
const htmlPost = marked.parse(content);
renderData.htmlPost = htmlPost;
response.render('blog/blogpost', {
layout: 'blog-opengraph',
locale,
localeString,
postInfo,
htmlPost,
});
response.render('blog/blogpost', renderData);
});
module.exports = router;

View File

@ -11,32 +11,45 @@ router.get('/', async (request, response) => {
});
router.get('/search', async (request, response) => {
const reqLocale = request.locale;
const locale = util.getLocale(reqLocale.region, reqLocale.language);
const localeString = reqLocale.toString();
response.render('docs/search', {
const renderData = {
layout: 'main',
locale,
localeString,
locale: util.getLocale(request.locale.region, request.locale.language),
localeString: request.locale.toString(),
currentPage: request.params.slug,
});
};
renderData.isLoggedIn = request.cookies.access_token && request.cookies.refresh_token && request.cookies.ph;
if (renderData.isLoggedIn) {
const account = await util.getAccount(request, response);
renderData.account = account;
}
response.render('docs/search', renderData);
});
router.get('/:slug', async (request, response, next) => {
const reqLocale = request.locale;
const locale = util.getLocale(reqLocale.region, reqLocale.language);
const renderData = {
layout: 'main',
locale: util.getLocale(request.locale.region, request.locale.language),
localeString: request.locale.toString(),
currentPage: request.params.slug,
};
const localeString = reqLocale.toString();
renderData.isLoggedIn = request.cookies.access_token && request.cookies.refresh_token && request.cookies.ph;
if (renderData.isLoggedIn) {
const account = await util.getAccount(request, response);
renderData.account = account;
}
// Get the name of the page from the URL
const pageName = request.params.slug;
let markdownLocale = localeString;
let markdownLocale = renderData.localeString;
let missingInLocale = false;
// Check if the MD file exists in the user's locale, if not try en-US and show notice, or finally log error and show 404.
if (fs.existsSync(path.join('docs', localeString, `${pageName}.md`))) {
if (fs.existsSync(path.join('docs', renderData.localeString, `${pageName}.md`))) {
null;
} else if (fs.existsSync(path.join('docs', 'en-US', `${pageName}.md`))) {
markdownLocale = 'en-US';
@ -45,6 +58,7 @@ router.get('/:slug', async (request, response, next) => {
next();
return;
}
renderData.missingInLocale = missingInLocale;
let content;
// Get the markdown file corresponding to the page.
@ -57,22 +71,16 @@ router.get('/:slug', async (request, response, next) => {
// Convert the content into HTML
content = marked.parse(content);
renderData.content = content;
// A boolean to show the quick links grid or not.
let showQuickLinks = false;
if (pageName === 'welcome') {
showQuickLinks = true;
}
renderData.showQuickLinks = showQuickLinks;
response.render('docs/docs', {
layout: 'main',
locale,
localeString,
content,
currentPage: request.params.slug,
missingInLocale,
showQuickLinks
});
response.render('docs/docs', renderData);
});
module.exports = router;

View File

@ -3,19 +3,30 @@ const util = require('../util');
const { boards } = require('../../boards/boards.json');
const router = new Router();
const { getTrelloCache } = require('../trello');
const { getTrelloCache } = require('../cache');
router.get('/', async (request, response) => {
const reqLocale = request.locale;
const locale = util.getLocale(reqLocale.region, reqLocale.language);
const renderData = {
layout: 'main',
boards,
locale: util.getLocale(request.locale.region, request.locale.language),
localeString: request.locale.toString(),
};
renderData.isLoggedIn = request.cookies.access_token && request.cookies.refresh_token && request.cookies.ph;
if (renderData.isLoggedIn) {
const account = await util.getAccount(request, response);
renderData.account = account;
}
const cache = await getTrelloCache();
// Builds the arrays of people for the special thanks section
// Shuffles the special thanks people
let specialThanksPeople = locale.specialThanks.people.slice();
const specialThanksPeople = renderData.locale.specialThanks.people.slice();
function shuffleArray(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
@ -29,7 +40,7 @@ router.get('/', async (request, response) => {
const specialThanksSecondRow = specialThanksPeople.slice(3, 7);
// Builds the final array to be sent to the view, and triples each row.
specialThanksPeople = {
renderData.specialThanksPeople = {
first: specialThanksFirstRow.concat(specialThanksFirstRow).concat(specialThanksFirstRow),
second: specialThanksSecondRow.concat(specialThanksSecondRow).concat(specialThanksSecondRow)
};
@ -74,14 +85,9 @@ router.get('/', async (request, response) => {
// Calculates global completion percentage
totalProgress.percent = Math.round(totalProgress._calc.percentageSum / cache.sections.length * 100);
response.render('home', {
layout: 'main',
featuredFeatureList: totalProgress,
boards,
locale,
localeString: reqLocale.toString(),
specialThanksPeople
});
renderData.featuredFeatureList = totalProgress;
response.render('home', renderData);
});
module.exports = router;

View File

@ -3,15 +3,20 @@ const util = require('../util');
const router = new Router();
router.get('/', async (request, response) => {
const reqLocale = request.locale;
const locale = util.getLocale(reqLocale.region, reqLocale.language);
response.render('localization', {
const renderData = {
layout: 'main',
locale,
localeString: reqLocale.toString(),
});
locale: util.getLocale(request.locale.region, request.locale.language),
localeString: request.locale.toString(),
};
renderData.isLoggedIn = request.cookies.access_token && request.cookies.refresh_token && request.cookies.ph;
if (renderData.isLoggedIn) {
const account = await util.getAccount(request, response);
renderData.account = account;
}
response.render('localization', renderData);
});
module.exports = router;

View File

@ -3,22 +3,29 @@ const util = require('../util');
const { boards } = require('../../boards/boards.json');
const router = new Router();
const { getTrelloCache } = require('../trello');
const { getTrelloCache, getStripeDonationCache } = require('../cache');
router.get('/', async (request, response) => {
const reqLocale = request.locale;
const locale = util.getLocale(reqLocale.region, reqLocale.language);
const cache = await getTrelloCache();
response.render('progress', {
const renderData = {
layout: 'main',
boards,
locale,
localeString: reqLocale.toString(),
progressLists: cache
});
locale: util.getLocale(request.locale.region, request.locale.language),
localeString: request.locale.toString(),
};
renderData.isLoggedIn = request.cookies.access_token && request.cookies.refresh_token && request.cookies.ph;
if (renderData.isLoggedIn) {
const account = await util.getAccount(request, response);
renderData.account = account;
}
const trelloCache = await getTrelloCache();
renderData.progressLists = trelloCache;
const stripeDonationCache = await getStripeDonationCache();
renderData.donationCache = stripeDonationCache;
response.render('progress', renderData);
});
module.exports = router;

23
src/schema/pnid.js Normal file
View File

@ -0,0 +1,23 @@
const { Schema } = require('mongoose');
// Only define what we will be using
const PNIDSchema = new Schema({
pid: {
type: Number,
unique: true
},
server_access_level: String,
access_level: Number,
connections: {
stripe: {
customer_id: String,
subscription_id: String,
price_id: String,
tier_level: Number,
tier_name: String,
latest_webhook_timestamp: Number
}
}
});
module.exports = PNIDSchema;

View File

@ -5,14 +5,16 @@ const handlebars = require('express-handlebars');
const morgan = require('morgan');
const expressLocale = require('express-locale');
const cookieParser = require('cookie-parser');
const Stripe = require('stripe');
const logger = require('./logger');
const database = require('./database');
const util = require('./util');
const config = require('../config.json');
const defaultLocale = require('../locales/US_en.json');
const { http: { port } } = config;
const app = express();
const stripe = new Stripe(config.stripe.secret_key);
logger.info('Setting up Middleware');
app.use(morgan('dev'));
@ -44,6 +46,7 @@ app.use(expressLocale({
/* TODO: map more regions to the available locales */
en: 'en-US', 'en-GB': 'en-US', 'en-AU': 'en-US', 'en-CA': 'en-US',
ar: 'ar-AR',
cn: 'zh-CN',
de: 'de-DE',
nl: 'nl-NL',
es: 'es-ES',
@ -57,11 +60,12 @@ app.use(expressLocale({
pt: 'pt-BR',
ro: 'ro-RO',
ru: 'ru-RU',
tr: 'tr-TR'
tr: 'tr-TR',
},
allowed: [
'en', 'en-US', 'en-GB', 'en-AU', 'en-CA',
'ar', 'ar-AR',
'cn', 'zh-CN', 'zh-HK', 'zh-TW',
'de', 'de-DE',
'nl', 'nl-NL',
'es', 'es-ES',
@ -156,6 +160,16 @@ app.engine('handlebars', handlebars({
app.set('view engine', 'handlebars');
logger.info('Starting server');
app.listen(port, () => {
logger.success(`Server listening on http://localhost:${port}`);
database.connect().then(() => {
app.listen(port, async () => {
const events = await stripe.events.list({
delivery_success: false // failed webhooks
});
for (const event of events.data) {
await util.handleStripeEvent(event);
}
logger.success(`Server listening on http://localhost:${port}`);
});
});

View File

@ -1,7 +1,13 @@
const fs = require('fs-extra');
const got = require('got');
const crypto = require('crypto');
const Stripe = require('stripe');
const mailer = require('./mailer');
const database = require('./database');
const logger = require('./logger');
const config = require('../config.json');
const stripe = new Stripe(config.stripe.secret_key);
function fullUrl(request) {
return `${request.protocol}://${request.hostname}${request.originalUrl}`;
@ -68,11 +74,234 @@ function nintendoPasswordHash(password, pid) {
return hashed;
}
async function handleStripeEvent(event) {
if (event.type === 'customer.subscription.updated' || event.type === 'customer.subscription.deleted') {
const subscription = event.data.object;
const product = await stripe.products.retrieve(subscription.plan.product);
const customer = await stripe.customers.retrieve(subscription.customer);
if (!customer?.metadata?.pnid_pid) {
// No PNID PID linked to customer
if (subscription.status !== 'canceled' && subscription.status !== 'unpaid') {
// Abort and refund!
logger.error(`Stripe user ${customer.id} has no PNID linked! Refunding order`);
await stripe.subscriptions.del(subscription.id);
const invoice = await stripe.invoices.retrieve(subscription.latest_invoice);
await stripe.refunds.create({
payment_intent: invoice.payment_intent
});
try {
await mailer.sendMail({
to: customer.email,
subject: 'Pretendo Network Subscription Failed - No Linked PNID',
text: `Your recent subscription to Pretendo Network has failed.\nThis is due to no PNID PID being linked to the Stripe customer account used. The subscription has been canceled and refunded. Please contact Jon immediately.\nStripe Customer ID: ${customer.id}`
});
} catch (error) {
logger.error(`Error sending email | ${customer.id}, ${customer.email} | - ${error.message}`);
}
} else {
logger.error(`Stripe user ${customer.id} has no PNID linked!`);
}
return;
}
const pid = Number(customer.metadata.pnid_pid);
const pnid = await database.PNID.findOne({ pid });
const latestWebhookTimestamp = pnid.get('connections.stripe.latest_webhook_timestamp');
if (latestWebhookTimestamp && latestWebhookTimestamp > event.created) {
// Do nothing, this webhook is older than the latest seen
return;
}
if (!pnid && subscription.status !== 'canceled' && subscription.status !== 'unpaid') {
// PNID does not exist. Abort and refund!
logger.error(`PNID PID ${pid} does not exist! Found on Stripe user ${customer.id}! Refunding order`);
await stripe.subscriptions.del(subscription.id);
const invoice = await stripe.invoices.retrieve(subscription.latest_invoice);
await stripe.refunds.create({
payment_intent: invoice.payment_intent
});
try {
await mailer.sendMail({
to: customer.email,
subject: 'Pretendo Network Subscription Failed - PNID Not Found',
text: `Your recent subscription to Pretendo Network has failed.\nThis is due to the provided PNID not being found. The subscription has been canceled and refunded. Please contact Jon immediately.\nStripe Customer ID: ${customer.id}\nPNID PID: ${pid}`
});
} catch (error) {
logger.error(`Error sending email | ${customer.id}, ${customer.email} | - ${error.message}`);
}
return;
}
const currentSubscriptionId = pnid.get('connections.stripe.subscription_id');
if (subscription.status === 'canceled' && currentSubscriptionId && subscription.id !== currentSubscriptionId) {
// Canceling old subscription, do nothing but update webhook date
const updateData = {
'connections.stripe.latest_webhook_timestamp': event.created
};
await database.PNID.updateOne({
pid,
'connections.stripe.latest_webhook_timestamp': {
$lte: event.created
}
}, { $set: updateData }).exec();
return;
}
const updateData = {
'connections.stripe.subscription_id': subscription.status === 'active' ? subscription.id : null,
'connections.stripe.price_id': subscription.status === 'active' ? subscription.plan.id : null,
'connections.stripe.tier_level': subscription.status === 'active' ? Number(product.metadata.tier_level || 0) : 0,
'connections.stripe.tier_name': subscription.status === 'active' ? product.name : null,
'connections.stripe.latest_webhook_timestamp': event.created,
};
if (product.metadata.beta === 'true') {
switch (subscription.status) {
case 'active':
if (pnid.access_level < 2) { // only change access level if not staff member
updateData.access_level = 1;
}
break;
case 'canceled': // Subscription was canceled
case 'unpaid': // User missed too many payments
if (pnid.access_level < 2) { // only change access level if not staff member
updateData.access_level = 0;
}
break;
default:
break;
}
}
await database.PNID.updateOne({
pid,
'connections.stripe.latest_webhook_timestamp': {
$lte: event.created
}
}, { $set: updateData }).exec();
if (subscription.status === 'active') {
// Get all the customers active subscriptions
const { data: activeSubscriptions } = await stripe.subscriptions.list({
limit: 100,
status: 'active',
customer: customer.id
});
// Order subscriptions by creation time and remove the latest one
const orderedActiveSubscriptions = activeSubscriptions.sort((a, b) => b.created - a.created);
const pastSubscriptions = orderedActiveSubscriptions.slice(1);
// Remove any old past subscriptions that might still be hanging around
for (const pastSubscription of pastSubscriptions) {
try {
await stripe.subscriptions.del(pastSubscription.id);
} catch (error) {
logger.error(`Error canceling old user subscription | ${customer.id}, ${pid}, ${pastSubscription.id} | - ${error.message}`);
}
}
try {
await mailer.sendMail({
to: customer.email,
subject: 'Pretendo Network Subscription - Active',
text: `Thank you for purchasing the ${product.name} tier! We greatly value your support, thank you for helping keep Pretendo Network alive!\nIt may take a moment for your account dashboard to reflect these changes. Please wait a moment and refresh the dashboard to see them!`
});
} catch (error) {
logger.error(`Error sending email | ${customer.id}, ${customer.email}, ${pid} | - ${error.message}`);
}
}
if (subscription.status === 'canceled') {
try {
await mailer.sendMail({
to: customer.email,
subject: 'Pretendo Network Subscription - Canceled',
text: `Your subscription for the ${product.name} tier has been canceled. We thank for your previous support, and hope you still enjoy the network! `
});
} catch (error) {
logger.error(`Error sending email | ${customer.id}, ${customer.email}, ${pid} | - ${error.message}`);
}
}
if (subscription.status === 'unpaid') {
try {
await mailer.sendMail({
to: customer.email,
subject: 'Pretendo Network Subscription - Unpaid',
text: `Your subscription for the ${product.name} tier has been canceled due to non payment. We thank for your previous support, and hope you still enjoy the network! `
});
} catch (error) {
logger.error(`Error sending email | ${customer.id}, ${customer.email}, ${pid} | - ${error.message}`);
}
}
}
}
async function getAccount(request, response) {
// Attempt to get user data
let apiResponse = await apiGetRequest('/v1/user', {
'Authorization': `${request.cookies.token_type} ${request.cookies.access_token}`
});
if (apiResponse.statusCode !== 200) {
// Assume expired, refresh and retry request
apiResponse = await apiPostGetRequest('/v1/login', {}, {
refresh_token: request.cookies.refresh_token,
grant_type: 'refresh_token'
});
if (apiResponse.statusCode !== 200) {
// TODO: Error message
return response.status(apiResponse.statusCode).json({
error: 'Bad'
});
}
const tokens = apiResponse.body;
apiResponse = await apiGetRequest('/v1/user', {
'Authorization': `${tokens.token_type} ${tokens.access_token}`
});
}
// If still failed, something went horribly wrong
if (apiResponse.statusCode !== 200) {
// TODO: Error message
return response.status(apiResponse.statusCode).json({
error: 'Bad'
});
}
// Return user account info
const account = apiResponse.body;
return account;
}
module.exports = {
fullUrl,
getLocale,
apiGetRequest,
apiPostGetRequest,
apiDeleteGetRequest,
nintendoPasswordHash
nintendoPasswordHash,
handleStripeEvent,
getAccount
};

View File

@ -10,11 +10,22 @@
<img src="{{account.mii.image_url}}" class="mii" />
<p class="miiname">{{account.mii.name}}</p>
<p class="username" value="{{account.username}}">PNID: {{account.username}}</p>
{{#if tierName}}
<p class="tier-name tier-level-{{tierLevel}}" value="{{tierName}}">{{tierName}}</p>
{{else}}
<p class="tier-name tier-level-0" value="Default">Default</p>
{{/if}}
</div>
<div class="buttons">
<a class="button secondary" id="download-cemu-files" href="/account/online-files" download>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-download"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
<p class="download-caption">Download account files</p>
<p class="caption">Download account files</p>
<p class="cemu-warning">(will not work on Nintendo Network)</p>
</a>
<a class="button secondary" id="account-upgrade" href="/account/upgrade">
<p class="caption">Upgrade account</p>
</a>
</div>
</div>
@ -52,8 +63,8 @@
</div>
<div class="setting-card">
<h2 class="header">Servers</h2>
<fieldset {{#if isTester}}{{else}}disabled{{/if}}>
<h2 class="header">Server environment</h2>
<fieldset {{#unless isTester}}disabled{{/unless}}>
<form class="server-selection" id="server">
<input type="radio" id="prod" name="server_selection" value="prod" checked="{{ eq account.server_access_level 'prod'}}">
<label for="prod">
@ -67,6 +78,7 @@
</svg>
<h2>Production</h2>
</label>
{{#if isTester}}
<input type="radio" id="beta" name="server_selection" value="beta" checked="{{ neq account.server_access_level 'prod'}}">
<label for="beta">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@ -74,18 +86,14 @@
</svg>
<h2>Beta</h2>
</label>
{{/if}}
</form>
</fieldset>
{{#if discordUser}}
{{#if isTester }}
<p>Connected as {{ discordUser.username }}#{{ discordUser.discriminator }}</p>
<button class="button secondary" id="remove-discord-connection">Remove Discord account</button>
{{else}}
<p>Beta servers are exclusive to testers. To become a tester, check us out on <a href="https://www.patreon.com/PretendoNetwork" target="_blank">Patreon</a> and link your Discord account to your Patreon and PNID accounts</p>
{{/if}}
{{else}}
<p>Beta servers are exclusive to testers. If you're already a tester, connect your Discord account <a href="{{ discordAuthURL }}">here</a>.</p>
{{/if}}
{{#unless isTester}}
<p>Beta servers are exclusive to beta testers.<br>To become a beta tester, upgrade to a higher account tier.</p>
{{else}}
<p>Your current tier (insert current tier{{currentTier}}) gives you beta server access. Cool!</p>
{{/unless}}
</div>
<h2 class="section-header" id="security">Sign in and security</h2>
@ -125,7 +133,17 @@
</div>
<h2 class="section-header" id="other">Other settings</h2>
<div class="setting-card span-both-columns">
<div class="setting-card">
<h2 class="header">Discord</h2>
{{#if discordUser}}
<p>Connected to Discord as {{ discordUser.username }}#{{ discordUser.discriminator }}</p>
<button class="button secondary" id="remove-discord-connection">Remove Discord account</button>
{{else}}
<p>To link your Discord account, run /whatever in the <a href="https://invite.gg/pretendo" >Discord server</a></p>
{{/if}}
</div>
<div class="setting-card">
<h2 class="header">Newsletter</h2>
<form id="other">
<input type="checkbox" id="marketing" name="marketing" {{#if account.flags.marketing}}checked{{/if}}>
@ -138,10 +156,10 @@
</div>
{{#if linked}}
{{#if success}}
<div class="banner-notice success">
<div>
<p>{{ linked }} account linked successfully</p>
<p>{{success}}</p>
</div>
</div>
{{/if}}
@ -149,7 +167,7 @@
{{#if error}}
<div class="banner-notice error">
<div>
<p>{{ error }}</p>
<p>{{error}}</p>
</div>
</div>
{{/if}}

View File

@ -1,32 +1,10 @@
<link rel="stylesheet" href="/assets/css/login.css" />
<div class="wrapper">
<div class="account-form-wrapper">
<a class="logotype" href="/">
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="39.876">
<g id="logo_type" data-name="logo type" transform="translate(-553 -467)">
<g id="logo" transform="translate(553 467)">
<rect id="XMLID_158_" width="39.876" height="39.876" fill="#9d6ff3" opacity="0" />
<g id="XMLID_6_" transform="translate(8.222 1.418)">
<path id="XMLID_15_"
d="M69.149,28.312c-1.051.553-.129,2.139.922,1.585a12.365,12.365,0,0,1,8.794-.571,10.829,10.829,0,0,1,6.342,4.166c.645,1,2.231.074,1.585-.922C83.308,27.169,74.7,25.436,69.149,28.312Z"
transform="translate(-64.246 -23.389)" fill="#9d6ff3" />
<path id="XMLID_14_"
d="M82.64,14.608A15.565,15.565,0,0,0,73.5,8.45a17.535,17.535,0,0,0-12.647.9c-1.051.553-.129,2.139.922,1.585,3.411-1.788,7.6-1.714,11.209-.719,3.1.848,6.268,2.544,8.038,5.309C81.681,16.543,83.267,15.622,82.64,14.608Z"
transform="translate(-57.476 -7.693)" fill="#9d6ff3" />
<path id="XMLID_9_"
d="M55.68,47.8a10.719,10.719,0,0,0-6.71,2.3H45.983A1.336,1.336,0,0,0,44.6,51.376V75.84a1.431,1.431,0,0,0,1.383,1.383h3.023a1.367,1.367,0,0,0,1.309-1.383V68.392A10.993,10.993,0,1,0,55.68,47.8Zm0,17.182a6.213,6.213,0,1,1,6.213-6.213A6.216,6.216,0,0,1,55.68,64.982Z"
transform="translate(-44.6 -40.406)" fill="#9d6ff3" />
</g>
</g>
<text id="Pretendo" transform="translate(593 492)" fill="#fff" font-size="17"
font-family="Poppins-Bold, Poppins" font-weight="700">
<tspan x="0" y="0">Pretendo</tspan>
</text>
</g>
</svg>
</a>
{{> header}}
<div class="account-form-wrapper">
<form action="/account/login" method="post" class="account">
<h2>Sign in</h2>
<p>Enter your account details below</p>
@ -40,9 +18,10 @@
<a href="/account/passwordreset" class="pwdreset">Forgot your password?</a>
</div>
<input name="grant_type" id="grant_type" type="hidden" value="password">
<input name="redirect" id="redirect" type="hidden" value="{{redirect}}">
<div class="buttons">
<button type="submit">Login</button>
<a href="/account/register" class="register">Don't have an account?</a>
<a href="/account/register{{#if redirect}}?redirect={{redirect}}{{/if}}" class="register">Don't have an account?</a>
</div>
</form>
</div>

View File

@ -2,36 +2,14 @@
<link rel="stylesheet" href="/assets/css/login.css" />
<div class="wrapper">
<div class="account-form-wrapper">
<a class="logotype" href="/">
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="39.876">
<g id="logo_type" data-name="logo type" transform="translate(-553 -467)">
<g id="logo" transform="translate(553 467)">
<rect id="XMLID_158_" width="39.876" height="39.876" fill="#9d6ff3" opacity="0" />
<g id="XMLID_6_" transform="translate(8.222 1.418)">
<path id="XMLID_15_"
d="M69.149,28.312c-1.051.553-.129,2.139.922,1.585a12.365,12.365,0,0,1,8.794-.571,10.829,10.829,0,0,1,6.342,4.166c.645,1,2.231.074,1.585-.922C83.308,27.169,74.7,25.436,69.149,28.312Z"
transform="translate(-64.246 -23.389)" fill="#9d6ff3" />
<path id="XMLID_14_"
d="M82.64,14.608A15.565,15.565,0,0,0,73.5,8.45a17.535,17.535,0,0,0-12.647.9c-1.051.553-.129,2.139.922,1.585,3.411-1.788,7.6-1.714,11.209-.719,3.1.848,6.268,2.544,8.038,5.309C81.681,16.543,83.267,15.622,82.64,14.608Z"
transform="translate(-57.476 -7.693)" fill="#9d6ff3" />
<path id="XMLID_9_"
d="M55.68,47.8a10.719,10.719,0,0,0-6.71,2.3H45.983A1.336,1.336,0,0,0,44.6,51.376V75.84a1.431,1.431,0,0,0,1.383,1.383h3.023a1.367,1.367,0,0,0,1.309-1.383V68.392A10.993,10.993,0,1,0,55.68,47.8Zm0,17.182a6.213,6.213,0,1,1,6.213-6.213A6.216,6.216,0,0,1,55.68,64.982Z"
transform="translate(-44.6 -40.406)" fill="#9d6ff3" />
</g>
</g>
<text id="Pretendo" transform="translate(593 492)" fill="#fff" font-size="17"
font-family="Poppins-Bold, Poppins" font-weight="700">
<tspan x="0" y="0">Pretendo</tspan>
</text>
</g>
</svg>
</a>
{{> header}}
<div class="account-form-wrapper">
<form action="/account/register" method="post" class="account register">
<h2>Register</h2>
<p>Enter your account details below</p>
<div>
<div class="email">
<label for="email">Email</label>
<input name="email" id="email" type="email" value="{{ email }}" required>
</div>
@ -52,9 +30,10 @@
<input name="password_confirm" id="password_confirm" type="password" autocomplete="new-password" required>
</div>
<div class="h-captcha" data-sitekey="cf3fd74e-93ca-47e6-9fa0-5fc439de06d4"></div>
<input name="redirect" id="redirect" type="hidden" value="{{redirect}}">
<div class="buttons">
<button type="submit">Register</button>
<a href="/account/login" class="register">Already have an account?</a>
<a href="/account/login{{#if redirect}}?redirect={{redirect}}{{/if}}" class="register">Already have an account?</a>
</div>
</form>
</div>

View File

@ -0,0 +1,105 @@
<link rel="stylesheet" href="/assets/css/upgrade.css" />
<div class="wrapper">
<a href="/account" class="back-arrow">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-arrow-left"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>
<span>Back</span>
</a>
<div class="account-form-wrapper">
<a class="logotype" href="/">
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="39.876">
<g id="logo_type" data-name="logo type" transform="translate(-553 -467)">
<g id="logo" transform="translate(553 467)">
<rect id="XMLID_158_" width="39.876" height="39.876" fill="#9d6ff3" opacity="0" />
<g id="XMLID_6_" transform="translate(8.222 1.418)">
<path id="XMLID_15_"
d="M69.149,28.312c-1.051.553-.129,2.139.922,1.585a12.365,12.365,0,0,1,8.794-.571,10.829,10.829,0,0,1,6.342,4.166c.645,1,2.231.074,1.585-.922C83.308,27.169,74.7,25.436,69.149,28.312Z"
transform="translate(-64.246 -23.389)" fill="#9d6ff3" />
<path id="XMLID_14_"
d="M82.64,14.608A15.565,15.565,0,0,0,73.5,8.45a17.535,17.535,0,0,0-12.647.9c-1.051.553-.129,2.139.922,1.585,3.411-1.788,7.6-1.714,11.209-.719,3.1.848,6.268,2.544,8.038,5.309C81.681,16.543,83.267,15.622,82.64,14.608Z"
transform="translate(-57.476 -7.693)" fill="#9d6ff3" />
<path id="XMLID_9_"
d="M55.68,47.8a10.719,10.719,0,0,0-6.71,2.3H45.983A1.336,1.336,0,0,0,44.6,51.376V75.84a1.431,1.431,0,0,0,1.383,1.383h3.023a1.367,1.367,0,0,0,1.309-1.383V68.392A10.993,10.993,0,1,0,55.68,47.8Zm0,17.182a6.213,6.213,0,1,1,6.213-6.213A6.216,6.216,0,0,1,55.68,64.982Z"
transform="translate(-44.6 -40.406)" fill="#9d6ff3" />
</g>
</g>
<text id="Pretendo" transform="translate(593 492)" fill="#fff" font-size="17"
font-family="Poppins-Bold, Poppins" font-weight="700">
<tspan x="0" y="0">Pretendo</tspan>
</text>
</g>
</svg>
</a>
<h1 class="title dot">Upgrade</h1>
<p class="caption">
Reaching the monthly goal will make Pretendo a full time job, providing better quality updates at a faster rate.
</p>
<div class="progress-bar-wrapper">
<div class="progress-bar">
<div class="progress-bar-inner" style="width: {{ donationCache.completed_capped }}%;" ></div>
</div>
<p>
<span>${{donationCache.total_dollars}}</span> of <span>${{donationCache.goal_dollars}}</span>/month, <span>${{donationCache.completed_real}}%</span> of the monthly goal.
</p>
</div>
<form method="post" data-current-tier="{{currentTier}}">
{{#each tiers}}
<input type="radio" class="tier-radio" data-tier-name="{{this.name}}" name="tier" value="{{this.price_id}}" id="{{this.price_id}}" />
<label class="tier" for="{{this.price_id}}">
<div class="tier-thumbnail">
<img src="{{this.thumbnail}}" width="100%" height="auto" alt="Tier icon" />
</div>
<div class="tier-text">
<p class="tier-name">{{this.name}}</p>
<div class="tier-perks">
{{#each this.perks}}
<div>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-plus"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
<p>{{this}}</p>
</div>
{{/each}}
</div>
</div>
<p class="price">
<span>{{this.price}}</span> / month
</p>
</label>
{{/each}}
<div class="button-wrapper">
<button class="disabled" type="submit" id="submitButton">Select a tier</button>
<button class="unsubscribe hidden" id="unsubModalShowButton">Unsubscribe</button>
</div>
</form>
</div>
<div class="unsub-modal-wrapper hidden">
<div class="unsub-modal">
<h1 class="title dot">Unsubscribe</h1>
<p class="unsub-modal-caption">Are you sure you want to unsubscribe from <span>tiername</span>?
You will lose access to the perks associated with that tier.</p>
<div class="unsub-modal-button-wrapper">
<button class="cancel" id="unsubModalCloseButton">Cancel</button>
<button class="confirm" id="unsubModalConfirmButton">Unsubscribe</button>
</div>
</div>
</div>
<div class="switch-tier-modal-wrapper hidden">
<div class="switch-tier-modal">
<h1 class="title dot">Change tier</h1>
<p class="switch-tier-modal-caption">Are you sure you want to unsubscribe
from <span class="oldtier">oldtiername</span> and subscribe to <span class="newtier">newtiername</span>?</p>
<div class="switch-tier-modal-button-wrapper">
<button class="cancel" id="switchTierCloseButton">Cancel</button>
<button class="confirm" id="switchTierConfirmButton">Confirm</button>
</div>
</div>
</div>
</div>
<script src="/assets/js/upgrade.js" />

View File

@ -26,9 +26,9 @@
<svg data-prefix="fab" data-icon="github" class="svg-inline--fa fa-github fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path fill="currentColor" d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"></path></svg>
</button>
</a>
<a href="https://patreon.com/PretendoNetwork/" target="_blank">
<a href="/account/upgrade" target="_blank">
<button class="button secondary github icon-btn">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!-- Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) --><path fill="currentColor" d="M512 194.8c0 101.3-82.4 183.8-183.8 183.8-101.7 0-184.4-82.4-184.4-183.8 0-101.6 82.7-184.3 184.4-184.3C429.6 10.5 512 93.2 512 194.8zM0 501.5h90v-491H0v491z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-heart"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path></svg>
</button>
</a>
</div>

View File

@ -70,7 +70,6 @@
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.5.1/dist/chart.min.js"></script>
<script src="/assets/js/progress-charts.js"></script>
<script src="/assets/js/locale-dropdown-handler.js"></script>
<script>setDefaultDropdownLocale("{{localeString}}")</script>
</body>
</html>

View File

@ -67,8 +67,6 @@
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.5.1/dist/chart.min.js"></script>
<script src="/assets/js/progress-charts.js"></script>
<script src="/assets/js/locale-dropdown-handler.js"></script>
<script>setDefaultDropdownLocale("{{localeString}}")</script>
</body>
</html>

View File

@ -28,8 +28,6 @@
<div>
<h1>{{ localeHelper locale "footer" "socials" }}</h1>
<a href="https://twitter.com/PretendoNetwork/" target="_blank">Twitter</a>
<a href="https://patreon.com/PretendoNetwork" target="_blank">Patreon</a>
<a href="https://ko-fi.com/PretendoNetwork" target="_blank">Ko-fi</a>
<a href="https://invite.gg/pretendo" target="_blank">Discord</a>
<a href="https://github.com/PretendoNetwork" target="_blank">GitHub</a>
</div>
@ -39,7 +37,7 @@
<a href="/#faq">{{ localeHelper locale "nav" "faq" }}</a>
<a href="/progress">{{ localeHelper locale "nav" "progress" }}</a>
<a href="/blog">{{ localeHelper locale "nav" "blog" }}</a>
<a href="/account" style="display: none; /* Hiding for now since it's not ready */">{{ localeHelper locale "nav" "account" }}</a>
<a href="/account">{{ localeHelper locale "nav" "account" }}</a>
</div>
<div class="discord-server-card">
<h1>{{ localeHelper locale "footer" "widget" "captions" 0 }}</h1>

View File

@ -33,101 +33,145 @@
<a href="/blog" class="keep-on-mobile">{{ localeHelper locale "nav" "blog" }}</a>
</nav>
<!-- Ordered the locales in the same way Google orders them -->
<div class="select-box locale-dropdown" data-dropdown>
<div class="options-container">
<div class="option">
<input type="radio" class="radio" id="en-US" name="category" />
<label for="en-US">
<div class="item"><span class="lang">🇺🇸</span><span class="locale-names">English</span></div>
</label>
<div class="right-section">
<!-- Ordered the locales in the same way Google orders them -->
<div class="select-box locale-dropdown" data-dropdown>
<div class="options-container">
<div class="option">
<input type="radio" class="radio" id="en-US" name="category" />
<label for="en-US">
<div class="item"><span class="locale-names">English</span></div>
</label>
</div>
<div class="option">
<input type="radio" class="radio" id="de-DE" name="category" />
<label for="de-DE">
<div class="item"><span class="locale-names">Deutsch</span></div>
</label>
</div>
<div class="option">
<input type="radio" class="radio" id="es-ES" name="category" />
<label for="es-ES">
<div class="item"><span class="locale-names">Español</span></div>
</label>
</div>
<div class="option">
<input type="radio" class="radio" id="fr-FR" name="category" />
<label for="fr-FR">
<div class="item"><span class="locale-names">Français</span></div>
</label>
</div>
<div class="option">
<input type="radio" class="radio" id="it-IT" name="category" />
<label for="it-IT">
<div class="item"><span class="locale-names">Italiano</span></div>
</label>
</div>
<div class="option">
<input type="radio" class="radio" id="nl-NL" name="category" />
<label for="nl-NL">
<div class="item"><span class="locale-names">Nederlands</span></div>
</label>
</div>
<div class="option">
<input type="radio" class="radio" id="nb-NO" name="category" />
<label for="nb-NO">
<div class="item"><span class="locale-names">Norsk</span></div>
</label>
</div>
<div class="option">
<input type="radio" class="radio" id="pl-PL" name="category" />
<label for="pl-PL">
<div class="item"><span class="locale-names">Polski</span></div>
</label>
</div>
<div class="option">
<input type="radio" class="radio" id="pt-BR" name="category" />
<label for="pt-BR">
<div class="item"><span class="locale-names">Português</span></div>
</label>
</div>
<div class="option">
<input type="radio" class="radio" id="ro-RO" name="category" />
<label for="ro-RO">
<div class="item"><span class="locale-names">Română</span></div>
</label>
</div>
<div class="option">
<input type="radio" class="radio" id="tr-TR" name="category" />
<label for="tr-TR">
<div class="item"><span class="locale-names">Türkçe</span></div>
</label>
</div>
<div class="option">
<input type="radio" class="radio" id="ru-RU" name="category" />
<label for="ru-RU">
<div class="item"><span class="locale-names">Pусский</span></div>
</label>
</div>
<div class="option">
<input type="radio" class="radio" id="ar-AR" name="category" />
<label for="ar-AR">
<div class="item"><span class="locale-names">اَلْعَرَبِيَّةُ</span></div>
</label>
</div>
<div class="option">
<input type="radio" class="radio" id="zh-CN" name="category" />
<label for="zh-CN">
<div class="item"><span class="locale-names">中文(简体)</span></div>
</div>
<div class="option">
<input type="radio" class="radio" id="ja-JP" name="category" />
<label for="ja-JP">
<div class="item"><span class="locale-names">日本語</span></div>
</div>
<div class="option">
<input type="radio" class="radio" id="ko-KR" name="category" />
<label for="ko-KR">
<div class="item"><span class="locale-names">한국어</span></div>
</label>
</div>
</div>
<div class="option">
<input type="radio" class="radio" id="de-DE" name="category" />
<label for="de-DE">
<div class="item"><span class="lang">🇩🇪</span><span class="locale-names">Deutsch</span></div>
</label>
</div>
<div class="option">
<input type="radio" class="radio" id="es-ES" name="category" />
<label for="es-ES">
<div class="item"><span class="lang">🇪🇸</span><span class="locale-names">Español</span></div>
</label>
</div>
<div class="option">
<input type="radio" class="radio" id="fr-FR" name="category" />
<label for="fr-FR">
<div class="item"><span class="lang">🇫🇷</span><span class="locale-names">Français</span></div>
</label>
</div>
<div class="option">
<input type="radio" class="radio" id="it-IT" name="category" />
<label for="it-IT">
<div class="item"><span class="lang">🇮🇹</span><span class="locale-names">Italiano</span></div>
</label>
</div>
<div class="option">
<input type="radio" class="radio" id="nl-NL" name="category" />
<label for="nl-NL">
<div class="item"><span class="lang">🇳🇱</span><span class="locale-names">Nederlands</span></div>
</label>
</div>
<div class="option">
<input type="radio" class="radio" id="nb-NO" name="category" />
<label for="nb-NO">
<div class="item"><span class="lang">🇳🇴</span><span class="locale-names">Norsk</span></div>
</label>
</div>
<div class="option">
<input type="radio" class="radio" id="pl-PL" name="category" />
<label for="pl-PL">
<div class="item"><span class="lang">🇵🇱</span><span class="locale-names">Polski</span></div>
</label>
</div>
<div class="option">
<input type="radio" class="radio" id="pt-BR" name="category" />
<label for="pt-BR">
<div class="item"><span class="lang">🇧🇷</span><span class="locale-names">Português</span></div>
</label>
</div>
<div class="option">
<input type="radio" class="radio" id="ro-RO" name="category" />
<label for="ro-RO">
<div class="item"><span class="lang">🇷🇴</span><span class="locale-names">Română</span></div>
</label>
</div>
<div class="option">
<input type="radio" class="radio" id="tr-TR" name="category" />
<label for="tr-TR">
<div class="item"><span class="lang">🇹🇷</span><span class="locale-names">Türkçe</span></div>
</label>
</div>
<div class="option">
<input type="radio" class="radio" id="ru-RU" name="category" />
<label for="ru-RU">
<div class="item"><span class="lang">🇷🇺</span><span class="locale-names">Pусский</span></div>
</label>
</div>
<div class="option">
<input type="radio" class="radio" id="ar-AR" name="category" />
<label for="ar-AR">
<div class="item"><span class="lang">🇸🇦</span><span class="locale-names">اَلْعَرَبِيَّةُ</span></div>
</label>
</div>
<div class="option">
<input type="radio" class="radio" id="ja-JP" name="category" />
<label for="ja-JP">
<div class="item"><span class="lang">🇯🇵</span><span class="locale-names">日本語</span></div>
</div>
<div class="option">
<input type="radio" class="radio" id="ko-KR" name="category" />
<label for="ko-KR">
<div class="item"><span class="lang">🇰🇷</span><span class="locale-names">한국어</span></div>
</label>
<div class="locale-dropdown-toggle">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-globe"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>
</div>
</div>
<div class="selected-locale">
<div class="item"><span class="lang"></span></div>
</div>
{{#if isLoggedIn}}
<div class="user-widget-wrapper logged-in">
<div class="user-widget-toggle">
<img src="{{ account.mii.image_url }}" alt="{{ account.mii.name }}" />
</div>
<div class="user-widget">
<div class="user-avatar">
<img src="{{ account.mii.image_url }}" alt="{{ account.mii.name }}" />
</div>
<div class="user-info">
<div class="mii-name">{{ account.mii.name }}</div>
<div class="pnid">{{ account.username }}</div>
</div>
<div class="buttons">
<a href="/account">
<button class="button primary">
Settings
</button>
</a>
<a href="/account/logout">
<button class="button logout">
Logout
</button>
</a>
</div>
</div>
</div>
{{else}}
<div class="user-widget-wrapper">
<a class="login-link" href="/account/login">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-user"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
</a>
</div>
{{/if}}
</div>
</header>
<script src="/assets/js/header-handler.js"></script>

View File

@ -10,7 +10,17 @@
</div>
</div>
<div class="all-progress-lists">
<div class="donation-progress">
<h1 class="title dot">Donation goal</h1>
<div class="progress-bar">
<div class="progress-bar-inner" style="width: {{ donationCache.completed_capped }}%;" ></div>
</div>
<p><span>${{donationCache.total_dollars}}</span> of <span>${{donationCache.goal_dollars}}</span>/month, <span>${{donationCache.completed_real}}%</span> of the monthly goal. To become a subscriber and gain access to cool perks, visit the <a href="/account/upgrade">upgrade page</a>.</p>
</div>
{{#each progressLists.sections}}
<div class="purple-card">
{{> progress-list data=this boards=@root.boards }}