Compare commits

...

25 Commits

Author SHA1 Message Date
Kalista Payne
4ecd13fbdc chore(github): split responsiveness to #15514 2025-09-17 16:25:19 -05:00
Hafiz
8ac414a184 move !error.response to correct level
!error.response before any attempt to access error.response.status
2025-09-15 12:03:22 -05:00
Kalista Payne
c4c0cca369 fix(admin): revert accidental change from rebase 2025-09-12 18:21:52 -05:00
Kalista Payne
326843fdc5 fix(blockers): duplicated code from rebase 2025-09-12 18:05:30 -05:00
Hafiz
0df9ea32fb Revert "Merge branch 'fiz/item-container-scaling' into qa/bat"
This reverts commit 4f28bfaad4, reversing
changes made to 477dd6328a.
2025-09-12 18:00:53 -05:00
Hafiz
bd3625aa4b remove redundant disabled styles in task modals
The .disabled class conflicting with existing disabled state implementations
2025-09-12 18:00:53 -05:00
Hafiz
0654d59752 Responsive Layout for Equipment Containers
- Added responsive CSS for mobile (<768px) and tablet (769px-1024px)
- Implemented flex-wrap layout that automatically stacks items in rows of 4 on smaller
2025-09-12 18:00:49 -05:00
Hafiz
0f901c9007 lint fix 2025-09-12 18:00:05 -05:00
Hafiz
30b6584a47 Update ToS error message
- Updated account suspension message from "This account, User ID..." to "Your account @[username] has been
  blocked..."
- Modified server auth middleware to pass username parameter when throwing account suspended error
-Modified auth utils loginRes function to include username in suspended account error
- Updated client bannedAccountModal component to pass username (empty string if unavailable)
- Updated login test to expect username in account suspended message
2025-09-12 18:00:05 -05:00
Hafiz
846250e4b8 Fix shop tabs overflow off screen at certain zoom levels
Fix quest cards get cut off on small screens
Fix pop-up windows extend past screen edges on mobile
2025-09-12 18:00:05 -05:00
Hafiz
0d1a5b6a7c Await genericPurchase completion before page reload to prevent request cancellation.
Also adds defensive check for undefined error.response in axios interceptor to prevent "t.response undefined" errors.
2025-09-12 18:00:05 -05:00
Phillip Thelen
c97845329a lint fixes 2025-09-12 17:56:34 -05:00
Phillip Thelen
5adbf536f5 add blocker to block emails from registration 2025-09-12 17:51:49 -05:00
Phillip Thelen
d9e76fcb3f restructure admin pages 2025-09-12 17:46:26 -05:00
Phillip Thelen
1efe30b7a7 Add UI for managing blockers 2025-09-12 17:36:29 -05:00
Phillip Thelen
4e2a8eb550 Tweak wording 2025-09-12 17:36:29 -05:00
Phillip Thelen
c3fd1fdd66 correctly reset local data after creating blocker 2025-09-12 17:36:29 -05:00
Phillip Thelen
0ec74582f0 Add UI for managing blockers 2025-09-12 17:36:27 -05:00
Phillip Thelen
69bf75322f add new frontend files 2025-09-12 17:33:03 -05:00
dependabot[bot]
447eb6a0c4 chore(deps): bump brace-expansion from 1.1.11 to 1.1.12 (#15498)
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 1.1.11 to 1.1.12.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/1.1.11...v1.1.12)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-11 14:58:24 -05:00
Kalista Payne
3dec49b72c GPC Message (#15508)
* feat(gpc): warn user about enabling analytics

* fix(gpc): style tweaks

* fix(privacy): local storage doesn't understand Boolean

* fix(gpc): do record if user has opted in

* fix(privacy): don't flip flop if no value changed
2025-09-11 14:58:10 -05:00
dependabot[bot]
472d03f276 chore(deps): bump vite from 6.3.5 to 6.3.6 in /website/client (#15507)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.3.5 to 6.3.6.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.3.6/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.3.6/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.3.6
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-11 14:53:43 -05:00
Kalista Payne
fd9a27c3ab 5.41.0 2025-09-11 14:43:53 -05:00
Weblate
a5c1423837 Translated using Weblate (Japanese)
Currently translated at 92.6% (3187 of 3441 strings)

Translated using Weblate (Japanese)

Currently translated at 99.6% (270 of 271 strings)

Translated using Weblate (Japanese)

Currently translated at 93.4% (229 of 245 strings)

Translated using Weblate (English (United Kingdom))

Currently translated at 98.1% (906 of 923 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 91.7% (3157 of 3441 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 90.5% (3117 of 3441 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 90.5% (3117 of 3441 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (189 of 189 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (245 of 245 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (284 of 284 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (132 of 132 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (245 of 245 strings)

Translated using Weblate (Italian)

Currently translated at 89.6% (243 of 271 strings)

Translated using Weblate (German)

Currently translated at 99.9% (3439 of 3441 strings)

Translated using Weblate (German)

Currently translated at 100.0% (273 of 273 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (923 of 923 strings)

Translated using Weblate (Japanese)

Currently translated at 92.5% (3186 of 3441 strings)

Co-authored-by: Bernardo Oliveira Abrão <bernardooliveiraabrao@gmail.com>
Co-authored-by: Deleted User <noreply+1161@weblate.org>
Co-authored-by: Karictre <karictre.git@gmail.com>
Co-authored-by: Lyam Santos Peres <kaka1213spaenrteoss@gmail.com>
Co-authored-by: Omar Bertolla <scaram@icloud.com>
Co-authored-by: Sven Baumann <svenbaumann1996@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: インコ <ayakabooker@gmail.com>
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/en_GB/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/it/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/front/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/de/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/de/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/it/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/ja/
Translation: Habitica/Backgrounds
Translation: Habitica/Faq
Translation: Habitica/Front
Translation: Habitica/Gear
Translation: Habitica/Limited
Translation: Habitica/Npc
Translation: Habitica/Settings
Translation: Habitica/Subscriber
2025-09-11 21:43:25 +02:00
Phillip Thelen
e9829b8b60 Phillip/admin deleter (#15466)
* refactor sending jobs to worker server

* remove unused imports

* add delete button to adminpanel

* June 2025 content build (#15437)

* chore: June 2025 content build

* chore: typo fixing

* chore: corrections to summer 2025 mage armor, spritesheet

* fix(css): rebuild spritesmith-main

---------

Co-authored-by: Kalista Payne <sabrecat@gmail.com>

* fix(script): don't use extremely costly regex

* fix(logging): don't spam empty error events

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (134 of 134 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (280 of 280 strings)

Translated using Weblate (French)

Currently translated at 100.0% (280 of 280 strings)

Translated using Weblate (Spanish)

Currently translated at 99.6% (279 of 280 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 97.4% (840 of 862 strings)

Translated using Weblate (German)

Currently translated at 99.8% (907 of 908 strings)

Translated using Weblate (Dutch)

Currently translated at 79.3% (219 of 276 strings)

Translated using Weblate (Dutch)

Currently translated at 28.1% (69 of 245 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 97.4% (840 of 862 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 97.5% (402 of 412 strings)

Translated using Weblate (Dutch)

Currently translated at 91.5% (377 of 412 strings)

Translated using Weblate (Dutch)

Currently translated at 85.2% (774 of 908 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (91 of 91 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (908 of 908 strings)

Translated using Weblate (Slovak)

Currently translated at 63.4% (106 of 167 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (908 of 908 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (908 of 908 strings)

Translated using Weblate (Slovak)

Currently translated at 2.0% (5 of 245 strings)

Translated using Weblate (French)

Currently translated at 100.0% (908 of 908 strings)

Translated using Weblate (Russian)

Currently translated at 64.4% (158 of 245 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 97.0% (837 of 862 strings)

Translated using Weblate (German)

Currently translated at 97.9% (844 of 862 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (91 of 91 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 97.3% (401 of 412 strings)

Translated using Weblate (Portuguese)

Currently translated at 95.3% (393 of 412 strings)

Translated using Weblate (Slovak)

Currently translated at 45.6% (413 of 905 strings)

Translated using Weblate (Slovak)

Currently translated at 50.8% (85 of 167 strings)

Translated using Weblate (Russian)

Currently translated at 99.1% (113 of 114 strings)

Translated using Weblate (Russian)

Currently translated at 64.0% (157 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 64.0% (157 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 62.0% (152 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 62.0% (152 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 60.8% (149 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 60.8% (149 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 60.4% (148 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 60.4% (148 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 60.0% (147 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 60.0% (147 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 57.9% (142 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 57.9% (142 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 56.7% (139 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 56.7% (139 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 56.3% (138 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 56.3% (138 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 53.8% (132 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 53.8% (132 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 53.4% (131 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 53.4% (131 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 48.9% (120 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 48.9% (120 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 48.5% (119 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 48.5% (119 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 46.9% (115 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 46.9% (115 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 46.9% (115 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 46.9% (115 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 46.9% (115 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 46.9% (115 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 46.9% (115 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 46.9% (115 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 46.9% (115 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 46.9% (115 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 46.9% (115 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 46.9% (115 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 46.9% (115 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 45.3% (111 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 45.3% (111 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 45.3% (111 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 45.3% (111 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 44.4% (109 of 245 strings)

Translated using Weblate (German)

Currently translated at 99.9% (3324 of 3325 strings)

Translated using Weblate (Russian)

Currently translated at 44.4% (109 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 44.4% (109 of 245 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (91 of 91 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (94 of 94 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 93.8% (107 of 114 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (22 of 22 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.7% (429 of 430 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 95.1% (820 of 862 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.6% (902 of 905 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (167 of 167 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (167 of 167 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 95.1% (820 of 862 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 95.1% (820 of 862 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 95.1% (820 of 862 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 93.8% (107 of 114 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 93.6% (3114 of 3325 strings)

Translated using Weblate (Portuguese)

Currently translated at 53.9% (1793 of 3325 strings)

Translated using Weblate (Dutch)

Currently translated at 78.1% (2600 of 3325 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.5% (242 of 243 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 95.1% (820 of 862 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 96.6% (398 of 412 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.6% (902 of 905 strings)

Translated using Weblate (Italian)

Currently translated at 99.1% (113 of 114 strings)

Translated using Weblate (Italian)

Currently translated at 87.3% (2903 of 3325 strings)

Translated using Weblate (Italian)

Currently translated at 17.1% (42 of 245 strings)

Translated using Weblate (Italian)

Currently translated at 99.0% (408 of 412 strings)

Translated using Weblate (Italian)

Currently translated at 92.7% (102 of 110 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 99.0% (3292 of 3325 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 98.7% (3285 of 3325 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 98.7% (3285 of 3325 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (134 of 134 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (412 of 412 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (91 of 91 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (905 of 905 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 98.1% (3262 of 3325 strings)

Co-authored-by: Andrea <goffopaguro@gmail.com>
Co-authored-by: Artem StolyROV <stolyarov11303@gmail.com>
Co-authored-by: Céu <marcel.ufscar@gmail.com>
Co-authored-by: David Kaya <david@kaya.sk>
Co-authored-by: Filip Betko <filipbetko@gmail.com>
Co-authored-by: FingerTiao <787170918@qq.com>
Co-authored-by: Irina  Shcherbinina <cat3dcat007@gmail.com>
Co-authored-by: Jaime Martí <jaumemarti77@icloud.com>
Co-authored-by: Mencius <beautyalinap@gmail.com>
Co-authored-by: Natalie Luhrs <eilatan@gmail.com>
Co-authored-by: Nikita Maximov <ruvemaximus@gmail.com>
Co-authored-by: Sophie LE MASLE <sophiesuff@gmail.com>
Co-authored-by: Summer_GUI <heyang94@163.com>
Co-authored-by: Tetiana <merekka13@gmail.com>
Co-authored-by: Tom <tompsognathus@gmail.com>
Co-authored-by: Toro Mor <thomas.bizer@gmx.de>
Co-authored-by: V Aar <v.vanderaar@gmail.com>
Co-authored-by: Viktor Révész <rviktor@ivankapal.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: razil <boss.razmarin@gmail.com>
Co-authored-by: Волкозмей <klippiky@gmail.com>
Co-authored-by: Данила Мальцев <maltsev-danila@inbox.ru>
Co-authored-by: Татьяна Куклева <klippiky@gmail.com>
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/sk/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/de/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/es/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/sk/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/it/
Translate-URL: https://translate.habitica.com/projects/habitica/communityguidelines/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/content/it/
Translate-URL: https://translate.habitica.com/projects/habitica/content/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/content/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/content/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/content/sk/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/it/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/ru/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/sk/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/de/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/it/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/es/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/loginincentives/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/sk/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/pets/it/
Translate-URL: https://translate.habitica.com/projects/habitica/pets/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/pets/ru/
Translate-URL: https://translate.habitica.com/projects/habitica/quests/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/de/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/pt_BR/
Translation: Habitica/Achievements
Translation: Habitica/Backgrounds
Translation: Habitica/Challenge
Translation: Habitica/Communityguidelines
Translation: Habitica/Content
Translation: Habitica/Faq
Translation: Habitica/Gear
Translation: Habitica/Generic
Translation: Habitica/Groups
Translation: Habitica/Limited
Translation: Habitica/Loginincentives
Translation: Habitica/Npc
Translation: Habitica/Pets
Translation: Habitica/Quests
Translation: Habitica/Questscontent

* 5.36.4

* chore(deps): bump serialize-javascript in /website/client (#15395)

Bumps [serialize-javascript](https://github.com/yahoo/serialize-javascript) from 6.0.1 to 6.0.2.
- [Release notes](https://github.com/yahoo/serialize-javascript/releases)
- [Commits](https://github.com/yahoo/serialize-javascript/compare/v6.0.1...v6.0.2)

---
updated-dependencies:
- dependency-name: serialize-javascript
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps-dev): bump axios from 1.7.4 to 1.8.2 (#15401)

Bumps [axios](https://github.com/axios/axios) from 1.7.4 to 1.8.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.7.4...v1.8.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump prismjs from 1.29.0 to 1.30.0 (#15403)

Bumps [prismjs](https://github.com/PrismJS/prism) from 1.29.0 to 1.30.0.
- [Release notes](https://github.com/PrismJS/prism/releases)
- [Changelog](https://github.com/PrismJS/prism/blob/master/CHANGELOG.md)
- [Commits](https://github.com/PrismJS/prism/compare/v1.29.0...v1.30.0)

---
updated-dependencies:
- dependency-name: prismjs
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump @babel/runtime-corejs2 in /website/client (#15406)

Bumps [@babel/runtime-corejs2](https://github.com/babel/babel/tree/HEAD/packages/babel-runtime-corejs2) from 7.23.6 to 7.26.10.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.26.10/packages/babel-runtime-corejs2)

---
updated-dependencies:
- dependency-name: "@babel/runtime-corejs2"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump @babel/helpers in /website/client (#15407)

Bumps [@babel/helpers](https://github.com/babel/babel/tree/HEAD/packages/babel-helpers) from 7.23.6 to 7.26.10.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.26.10/packages/babel-helpers)

---
updated-dependencies:
- dependency-name: "@babel/helpers"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump @babel/runtime from 7.23.9 to 7.26.10 (#15410)

Bumps [@babel/runtime](https://github.com/babel/babel/tree/HEAD/packages/babel-runtime) from 7.23.9 to 7.26.10.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.26.10/packages/babel-runtime)

---
updated-dependencies:
- dependency-name: "@babel/runtime"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump http-proxy-middleware in /website/client (#15427)

Bumps [http-proxy-middleware](https://github.com/chimurai/http-proxy-middleware) from 2.0.6 to 2.0.9.
- [Release notes](https://github.com/chimurai/http-proxy-middleware/releases)
- [Changelog](https://github.com/chimurai/http-proxy-middleware/blob/v2.0.9/CHANGELOG.md)
- [Commits](https://github.com/chimurai/http-proxy-middleware/compare/v2.0.6...v2.0.9)

---
updated-dependencies:
- dependency-name: http-proxy-middleware
  dependency-version: 2.0.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Optimize database access for some use cases (#15444)

* optimize query when listing challenge tasks

* Optimize query for checking if user is party leader

* correct worker call

* remove unused priority

* fix tests

* don’t use body with delete

* add detailed information about sub payment for google and apple

* Support paypal details for subscription in admin panel

* stripe payment details

* fix imports

* fix tests

* fix deleting account

* begin building group admin panel

* fix convertig sub to group plan

* improve sub status display

* fix lint

* fix long line

* fix sub state display

* lint fix

* fix

* delete amplitude data by default

* improve searching for email in admin panel

* correctly call method

* move delete button in admin panel

* fix(lint): whitespace

* fix(style): indent

* fix(typo): humand

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Natalie <78037386+CuriousMagpie@users.noreply.github.com>
Co-authored-by: Kalista Payne <sabrecat@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: Andrea <goffopaguro@gmail.com>
Co-authored-by: Artem StolyROV <stolyarov11303@gmail.com>
Co-authored-by: Céu <marcel.ufscar@gmail.com>
Co-authored-by: David Kaya <david@kaya.sk>
Co-authored-by: Filip Betko <filipbetko@gmail.com>
Co-authored-by: FingerTiao <787170918@qq.com>
Co-authored-by: Irina  Shcherbinina <cat3dcat007@gmail.com>
Co-authored-by: Jaime Martí <jaumemarti77@icloud.com>
Co-authored-by: Mencius <beautyalinap@gmail.com>
Co-authored-by: Natalie Luhrs <eilatan@gmail.com>
Co-authored-by: Nikita Maximov <ruvemaximus@gmail.com>
Co-authored-by: Sophie LE MASLE <sophiesuff@gmail.com>
Co-authored-by: Summer_GUI <heyang94@163.com>
Co-authored-by: Tetiana <merekka13@gmail.com>
Co-authored-by: Tom <tompsognathus@gmail.com>
Co-authored-by: Toro Mor <thomas.bizer@gmx.de>
Co-authored-by: V Aar <v.vanderaar@gmail.com>
Co-authored-by: Viktor Révész <rviktor@ivankapal.com>
Co-authored-by: razil <boss.razmarin@gmail.com>
Co-authored-by: Волкозмей <klippiky@gmail.com>
Co-authored-by: Данила Мальцев <maltsev-danila@inbox.ru>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kalista Payne <kalista@habitica.com>
2025-09-09 16:41:03 -05:00
64 changed files with 1536 additions and 322 deletions

18
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "habitica",
"version": "5.40.2",
"version": "5.41.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "habitica",
"version": "5.40.2",
"version": "5.41.0",
"hasInstallScript": true,
"dependencies": {
"@babel/core": "^7.22.10",
@@ -6070,9 +6070,10 @@
}
},
"node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -11431,9 +11432,10 @@
}
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}

View File

@@ -1,7 +1,7 @@
{
"name": "habitica",
"description": "A habit tracker app which treats your goals like a Role Playing Game.",
"version": "5.40.2",
"version": "5.41.0",
"main": "./website/server/index.js",
"dependencies": {
"@babel/core": "^7.22.10",

View File

@@ -150,7 +150,7 @@ describe('emails', () => {
sendTxn(mailingInfo, emailType);
expect(got.post).to.be.called;
expect(got.post).to.be.calledWith('undefined/job', sinon.match({
expect(got.post).to.be.calledWith('http://example.com/job', sinon.match({
json: {
data: {
emailType: sinon.match.same(emailType),
@@ -234,7 +234,7 @@ describe('emails', () => {
sendTxn(mailingInfo, emailType);
expect(got.post).to.be.called;
expect(got.post).to.be.calledWith('undefined/job', sinon.match({
expect(got.post).to.be.calledWith('http://example.com/job', sinon.match({
json: {
data: {
emailType: sinon.match.same(emailType),
@@ -254,7 +254,7 @@ describe('emails', () => {
sendTxn(mailingInfo, emailType, variables);
expect(got.post).to.be.called;
expect(got.post).to.be.calledWith('undefined/job', sinon.match({
expect(got.post).to.be.calledWith('http://example.com/job', sinon.match({
json: {
data: {
variables: sinon.match(value => value[0].name === 'BASE_URL', 'matches variables'),

View File

@@ -12,11 +12,33 @@ const { i18n } = common;
describe('Apple Payments', () => {
const subKey = 'basic_3mo';
let iapSetupStub;
let iapValidateStub;
let iapIsValidatedStub;
let iapIsCanceledStub;
let iapIsExpiredStub;
let paymentBuySkuStub;
let iapGetPurchaseDataStub;
let validateGiftMessageStub;
let paymentsCreateSubscritionStub;
beforeEach(() => {
iapSetupStub = sinon.stub(iap, 'setup').resolves();
iapValidateStub = sinon.stub(iap, 'validate').resolves({});
});
afterEach(() => {
iap.setup.restore();
iap.validate.restore();
iap.isValidated.restore();
iap.isExpired.restore();
iap.isCanceled.restore();
iap.getPurchaseData.restore();
});
describe('verifyPurchase', () => {
let sku; let user; let token; let receipt; let
headers;
let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let paymentBuySkuStub; let
iapGetPurchaseDataStub; let validateGiftMessageStub;
beforeEach(() => {
token = 'testToken';
@@ -25,13 +47,9 @@ describe('Apple Payments', () => {
receipt = `{"token": "${token}", "productId": "${sku}"}`;
headers = {};
iapSetupStub = sinon.stub(iap, 'setup')
.resolves();
iapValidateStub = sinon.stub(iap, 'validate')
.resolves({});
iapIsValidatedStub = sinon.stub(iap, 'isValidated').returns(true);
sinon.stub(iap, 'isExpired').returns(false);
sinon.stub(iap, 'isCanceled').returns(false);
iapIsCanceledStub = sinon.stub(iap, 'isCanceled').returns(false);
iapIsExpiredStub = sinon.stub(iap, 'isExpired').returns(false);
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
.returns([{
productId: 'com.habitrpg.ios.Habitica.21gems',
@@ -42,12 +60,6 @@ describe('Apple Payments', () => {
});
afterEach(() => {
iap.setup.restore();
iap.validate.restore();
iap.isValidated.restore();
iap.isExpired.restore();
iap.isCanceled.restore();
iap.getPurchaseData.restore();
payments.buySkuItem.restore();
gems.validateGiftMessage.restore();
});
@@ -209,9 +221,6 @@ describe('Apple Payments', () => {
describe('subscribe', () => {
let sub; let sku; let user; let token; let receipt; let headers; let
nextPaymentProcessing;
let iapSetupStub; let iapValidateStub; let iapIsValidatedStub;
let paymentsCreateSubscritionStub; let
iapGetPurchaseDataStub;
beforeEach(() => {
sub = common.content.subscriptionBlocks[subKey];
@@ -223,12 +232,10 @@ describe('Apple Payments', () => {
nextPaymentProcessing = moment.utc().add({ days: 2 });
user = new User();
iapSetupStub = sinon.stub(iap, 'setup')
.resolves();
iapValidateStub = sinon.stub(iap, 'validate')
.resolves({});
iapIsValidatedStub = sinon.stub(iap, 'isValidated')
.returns(true);
iapIsCanceledStub = sinon.stub(iap, 'isCanceled').returns(false);
iapIsExpiredStub = sinon.stub(iap, 'isExpired').returns(false);
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
.returns([{
expirationDate: moment.utc().subtract({ day: 1 }).toDate(),
@@ -250,10 +257,6 @@ describe('Apple Payments', () => {
});
afterEach(() => {
iap.setup.restore();
iap.validate.restore();
iap.isValidated.restore();
iap.getPurchaseData.restore();
if (payments.createSubscription.restore) payments.createSubscription.restore();
});
@@ -270,6 +273,29 @@ describe('Apple Payments', () => {
});
});
it('should throw an error if no active subscription is found', async () => {
iap.isCanceled.restore();
iapIsCanceledStub = sinon.stub(iap, 'isCanceled')
.returns(true);
iap.getPurchaseData.restore();
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
.returns([{
expirationDate: moment.utc().add({ day: -2 }).toDate(),
purchaseDate: new Date(),
productId: 'subscription1month',
transactionId: token,
originalTransactionId: token,
}]);
await expect(applePayments.subscribe(user, receipt, headers, nextPaymentProcessing))
.to.eventually.be.rejected.and.to.eql({
httpCode: 401,
name: 'NotAuthorized',
message: applePayments.constants.RESPONSE_NO_ITEM_PURCHASED,
});
});
const subOptions = [
{
sku: 'subscription1month',
@@ -574,8 +600,7 @@ describe('Apple Payments', () => {
describe('cancelSubscribe ', () => {
let user; let token; let receipt; let headers; let customerId; let
expirationDate;
let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let iapGetPurchaseDataStub; let
paymentCancelSubscriptionSpy;
let paymentCancelSubscriptionSpy;
beforeEach(async () => {
token = 'test-token';
@@ -584,8 +609,7 @@ describe('Apple Payments', () => {
customerId = 'test-customerId';
expirationDate = moment.utc();
iapSetupStub = sinon.stub(iap, 'setup')
.resolves();
iapValidateStub.restore();
iapValidateStub = sinon.stub(iap, 'validate')
.resolves({
expirationDate,
@@ -593,8 +617,8 @@ describe('Apple Payments', () => {
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
.returns([{ expirationDate: expirationDate.toDate() }]);
iapIsValidatedStub = sinon.stub(iap, 'isValidated').returns(true);
sinon.stub(iap, 'isCanceled').returns(false);
sinon.stub(iap, 'isExpired').returns(true);
iapIsCanceledStub = sinon.stub(iap, 'isCanceled').returns(false);
iapIsExpiredStub = sinon.stub(iap, 'isExpired').returns(true);
user = new User();
user.profile.name = 'sender';
user.purchased.plan.paymentMethod = applePayments.constants.PAYMENT_METHOD_APPLE;
@@ -606,13 +630,7 @@ describe('Apple Payments', () => {
});
afterEach(() => {
iap.setup.restore();
iap.validate.restore();
iap.isValidated.restore();
iap.isExpired.restore();
iap.isCanceled.restore();
iap.getPurchaseData.restore();
payments.cancelSubscription.restore();
paymentCancelSubscriptionSpy.restore();
});
it('should throw an error if we are missing a subscription', async () => {
@@ -695,6 +713,8 @@ describe('Apple Payments', () => {
expect(iapIsValidatedStub).to.be.calledWith({
expirationDate,
});
expect(iapIsCanceledStub).to.be.calledOnce;
expect(iapIsExpiredStub).to.be.calledOnce;
expect(iapGetPurchaseDataStub).to.be.calledOnce;
expect(paymentCancelSubscriptionSpy).to.be.calledOnce;

View File

@@ -11,12 +11,36 @@ const { i18n } = common;
describe('Google Payments', () => {
const subKey = 'basic_3mo';
let iapSetupStub;
let iapValidateStub;
let iapIsValidatedStub;
let paymentBuySkuStub;
let validateGiftMessageStub;
beforeEach(() => {
iapSetupStub = sinon.stub(iap, 'setup')
.resolves();
iapIsValidatedStub = sinon.stub(iap, 'isValidated')
.returns(true);
sinon.stub(iap, 'isCanceled').returns(false);
sinon.stub(iap, 'isExpired').returns(false);
paymentBuySkuStub = sinon.stub(payments, 'buySkuItem').resolves({});
validateGiftMessageStub = sinon.stub(gems, 'validateGiftMessage');
});
afterEach(() => {
iap.setup.restore();
iap.validate.restore();
iap.isValidated.restore();
iap.isCanceled.restore();
iap.isExpired.restore();
payments.buySkuItem.restore();
gems.validateGiftMessage.restore();
});
describe('verifyPurchase', () => {
let sku; let user; let token; let receipt; let signature; let
headers;
let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let
paymentBuySkuStub; let validateGiftMessageStub;
beforeEach(() => {
sku = 'com.habitrpg.android.habitica.iap.21gems';
@@ -25,21 +49,7 @@ describe('Google Payments', () => {
signature = '';
headers = {};
iapSetupStub = sinon.stub(iap, 'setup')
.resolves();
iapValidateStub = sinon.stub(iap, 'validate').resolves({ productId: sku });
iapIsValidatedStub = sinon.stub(iap, 'isValidated')
.returns(true);
paymentBuySkuStub = sinon.stub(payments, 'buySkuItem').resolves({});
validateGiftMessageStub = sinon.stub(gems, 'validateGiftMessage');
});
afterEach(() => {
iap.setup.restore();
iap.validate.restore();
iap.isValidated.restore();
payments.buySkuItem.restore();
gems.validateGiftMessage.restore();
});
it('should throw an error if receipt is invalid', async () => {
@@ -160,8 +170,7 @@ describe('Google Payments', () => {
describe('subscribe', () => {
let sub; let sku; let user; let token; let receipt; let signature; let headers; let
nextPaymentProcessing;
let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let
paymentsCreateSubscritionStub;
let paymentsCreateSubscritionStub;
beforeEach(() => {
sub = common.content.subscriptionBlocks[subKey];
@@ -173,19 +182,12 @@ describe('Google Payments', () => {
signature = '';
nextPaymentProcessing = moment.utc().add({ days: 2 });
iapSetupStub = sinon.stub(iap, 'setup')
.resolves();
iapValidateStub = sinon.stub(iap, 'validate')
.resolves({});
iapIsValidatedStub = sinon.stub(iap, 'isValidated')
.returns(true);
paymentsCreateSubscritionStub = sinon.stub(payments, 'createSubscription').resolves({});
});
afterEach(() => {
iap.setup.restore();
iap.validate.restore();
iap.isValidated.restore();
payments.createSubscription.restore();
});
@@ -243,7 +245,7 @@ describe('Google Payments', () => {
describe('cancelSubscribe ', () => {
let user; let token; let receipt; let signature; let headers; let customerId; let
expirationDate;
let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let iapGetPurchaseDataStub; let
let iapGetPurchaseDataStub; let
paymentCancelSubscriptionSpy;
beforeEach(async () => {
@@ -253,17 +255,12 @@ describe('Google Payments', () => {
signature = '';
customerId = 'test-customerId';
expirationDate = moment.utc();
iapSetupStub = sinon.stub(iap, 'setup')
.resolves();
iapValidateStub = sinon.stub(iap, 'validate')
.resolves({
expirationDate,
});
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
.returns([{ expirationDate: expirationDate.toDate(), autoRenewing: false }]);
iapIsValidatedStub = sinon.stub(iap, 'isValidated')
.returns(true);
user = new User();
user.profile.name = 'sender';
@@ -276,9 +273,6 @@ describe('Google Payments', () => {
});
afterEach(() => {
iap.setup.restore();
iap.validate.restore();
iap.isValidated.restore();
iap.getPurchaseData.restore();
payments.cancelSubscription.restore();
});
@@ -308,6 +302,8 @@ describe('Google Payments', () => {
});
it('should cancel a user subscription', async () => {
iap.isCanceled.restore();
iap.isCanceled = sinon.stub(iap, 'isCanceled').returns(true);
await googlePayments.cancelSubscribe(user, headers);
expect(iapSetupStub).to.be.calledOnce;
@@ -332,11 +328,20 @@ describe('Google Payments', () => {
});
it('should cancel a user subscription with multiple inactive subscriptions', async () => {
iap.isCanceled.restore();
iap.isCanceled = sinon.stub(iap, 'isCanceled').returns(true);
const laterDate = moment.utc().add(7, 'days');
iap.getPurchaseData.restore();
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
.returns([{ expirationDate, autoRenewing: false },
{ expirationDate: laterDate, autoRenewing: false },
.returns([{
startTimeMillis: expirationDate.valueOf(),
expirationDate,
autoRenewing: false,
}, {
startTimeMillis: laterDate.valueOf(),
expirationDate: laterDate,
autoRenewing: false,
},
]);
await googlePayments.cancelSubscribe(user, headers);
@@ -365,7 +370,12 @@ describe('Google Payments', () => {
iap.getPurchaseData.restore();
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
.returns([{ autoRenewing: true }]);
await googlePayments.cancelSubscribe(user, headers);
await expect(googlePayments.cancelSubscribe(user, headers))
.to.eventually.be.rejected.and.to.eql({
httpCode: 401,
name: 'NotAuthorized',
message: googlePayments.constants.RESPONSE_STILL_VALID,
});
expect(iapSetupStub).to.be.calledOnce;
expect(iapValidateStub).to.be.calledOnce;
@@ -388,8 +398,12 @@ describe('Google Payments', () => {
.returns([{ expirationDate, autoRenewing: false },
{ autoRenewing: true },
{ expirationDate, autoRenewing: false }]);
await googlePayments.cancelSubscribe(user, headers);
await expect(googlePayments.cancelSubscribe(user, headers))
.to.eventually.be.rejected.and.to.eql({
httpCode: 401,
name: 'NotAuthorized',
message: googlePayments.constants.RESPONSE_STILL_VALID,
});
expect(iapSetupStub).to.be.calledOnce;
expect(iapValidateStub).to.be.calledOnce;
expect(iapValidateStub).to.be.calledWith(iap.GOOGLE, {

View File

@@ -44,7 +44,7 @@ describe('POST /user/auth/local/login', () => {
})).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('accountSuspended', { communityManagerEmail: nconf.get('EMAILS_COMMUNITY_MANAGER_EMAIL'), userId: user._id }),
message: t('accountSuspended', { communityManagerEmail: nconf.get('EMAILS_COMMUNITY_MANAGER_EMAIL'), userId: user._id, username: user.auth.local.username }),
});
});

View File

@@ -38,7 +38,7 @@
"timers-browserify": "^2.0.12",
"uuid": "^9.0.1",
"validator": "^13.9.0",
"vite": "^6.0.0",
"vite": "^6.3.6",
"vite-plugin-compression2": "^1.3.3",
"vue": "^2.7.10",
"vue-fragment": "^1.6.0",
@@ -5123,6 +5123,20 @@
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -7126,6 +7140,21 @@
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/popper.js": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",
@@ -8528,9 +8557,10 @@
}
},
"node_modules/vite": {
"version": "6.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
"version": "6.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz",
"integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",

View File

@@ -42,7 +42,7 @@
"timers-browserify": "^2.0.12",
"uuid": "^9.0.1",
"validator": "^13.9.0",
"vite": "^6.0.0",
"vite": "^6.3.6",
"vite-plugin-compression2": "^1.3.3",
"vue": "^2.7.10",
"vue-fragment": "^1.6.0",

View File

@@ -203,6 +203,9 @@ export default {
return response;
}, error => { // Set up Error interceptors
if (!error.response) {
return Promise.reject(error);
}
if (error.response.status >= 400) {
const isBanned = this.checkForBannedUser(error);
if (isBanned === true) return null; // eslint-disable-line consistent-return

View File

@@ -123,6 +123,10 @@ h4 {
background-color: $purple-300 !important;
}
.bg-yellow-50 {
background-color: $yellow-50 !important;
}
.bg-white {
background-color: $white !important;
}

View File

@@ -81,7 +81,7 @@ export default {
watch: {
userIdentifier () {
this.isSearching = true;
this.$store.dispatch('adminPanel:searchUsers', { userIdentifier: this.userIdentifier }).then(users => {
this.$store.dispatch('admin:searchUsers', { userIdentifier: this.userIdentifier }).then(users => {
this.isSearching = false;
if (users.length === 1) {
this.loadUser(users[0]._id);

View File

@@ -5,6 +5,12 @@
class="row"
>
<div class="form col-12">
<button
class="btn btn-danger mt-3 float-right"
@click="confirmDeleteHero"
>
Begin Member deletion
</button>
<basic-details
:user-id="hero._id"
:auth="hero.auth"
@@ -96,6 +102,53 @@
:reset-counter="resetCounter"
@clear-data="clearData"
/>
<b-modal
id="delete-member-modal"
title="Delete Member"
ok-title="Delete"
ok-variant="danger"
cancel-title="Cancel"
@ok="deleteHero"
>
<b-modal-body>
<p>
Are you sure you want to delete this member?
</p>
<p class="errorMessage">
Please note: This action cannot be undone!
</p>
<div class="ml-4">
<div class="form-check">
<input
id="deleteAccountCheck"
v-model="deleteHabiticaAccount"
class="form-check-input"
type="checkbox"
>
<label
class="form-check-label"
for="deleteAccountCheck"
>
Delete Habitica account
</label>
</div>
<div class="form-check">
<input
id="deleteAmplitudeCheck"
v-model="deleteAmplitudeData"
class="form-check-input"
type="checkbox"
>
<label
class="form-check-label"
for="deleteAmplitudeCheck"
>
Delete Amplitude data
</label>
</div>
</div>
</b-modal-body>
</b-modal>
</div>
</div>
</div>
@@ -184,6 +237,8 @@ export default {
hasParty: false,
partyNotExistError: false,
adminHasPrivForParty: true,
deleteHabiticaAccount: true,
deleteAmplitudeData: true,
};
},
watch: {
@@ -249,6 +304,25 @@ export default {
this.resetCounter += 1; // tell child components to reinstantiate from scratch
},
confirmDeleteHero () {
if (this.hero._id === this.user._id) {
window.alert('You cannot delete your own account.');
return;
}
this.$root.$emit('bv::show::modal', 'delete-member-modal');
},
deleteHero () {
this.$store.dispatch('hall:deleteHero', {
uuid: this.hero._id,
deleteHabiticaAccount: this.deleteHabiticaAccount,
deleteAmplitudeData: this.deleteAmplitudeData,
}).then(() => {
this.$root.$emit('bv::hide::modal', 'delete-member-modal');
this.$router.push({ name: 'adminPanel' });
}).catch(err => {
window.alert(err);
});
},
hasUnsavedChanges (...comparisons) {
for (const index in comparisons) {
if (index && comparisons[index]) {

View File

@@ -37,7 +37,11 @@
Party ID
</label>
<strong class="col-sm-9 col-form-label">
{{ groupPartyData._id }}
<router-link
:to="{'name': 'groupAdminGroup', 'params': {'groupId': groupPartyData._id}}"
>
{{ groupPartyData._id }}
</router-link>
</strong>
</div>
<div class="form-group row">

View File

@@ -15,6 +15,25 @@
:class="{ 'open': expand }"
>
Subscription, Monthly Perks
<span
v-if="isSubscribed() && !isCancelled()"
class="text-success float-right ml-3"
>
Active
</span>
<span
v-else-if="isSubscribed() && isCancelled()"
class="text-success float-right ml-3"
>
Active until {{ dateFormat(hero.purchased.plan.dateTerminated) }}
</span>
<span
v-else-if="hero.purchased.plan.customerId && hero.purchased.plan.dateTerminated"
class="text-warning float-right ml-3"
>
Inactive
</span>
<b
v-if="hasUnsavedChanges && !expand"
class="text-warning float-right"
@@ -46,7 +65,7 @@
class="form-control"
type="text"
>
<option value="groupPlan">
<option value="Group Plan">
Group Plan
</option>
<option value="Stripe">
@@ -154,7 +173,11 @@
>
<div class="card-body">
<h6 class="card-title">
{{ group.name }}
<router-link
:to="{ name: 'groupAdminGroup', params: { groupId: group._id } }"
>
{{ group.name }}
</router-link>
<small class="float-right">{{ group._id }}</small>
</h6>
<p class="card-text">
@@ -245,8 +268,7 @@
</div>
</div>
<small
v-if="!hero.purchased.plan.dateTerminated
&& hero.purchased.plan.planId"
v-if="isSubscribed() && !isCancelled()"
class="text-success"
>
The subscription does not have a termination date and is active.
@@ -419,6 +441,79 @@
>
</div>
</div>
<div class="form-group row">
<h2>Payment Details</h2>
</div>
<div class="form-group row">
<div class="offset-sm-3 col-sm-9 mb-3">
<button
type="button"
class="btn btn-secondary btn-sm"
@click="getSubscriptionPaymentDetails"
>
Get Subscription Payment Details
</button>
</div>
</div>
<div
v-if="paymentDetails"
>
<div
v-for="(value, key) in paymentDetails"
:key="key"
class="form-group row"
>
<label class="col-sm-3 col-form-label">
{{ getHumanReadablePaymentDetails(key).label }}:
<span
:id="`${key}_tooltip`"
v-b-tooltip.hover.right="getHumanReadablePaymentDetails(key).help"
class="info-icon"
>?</span>
</label>
<strong class="col-sm-9 col-form-label">
<span v-if="value === true">Yes</span>
<span v-else-if="value === false">No</span>
<span
v-else-if="value instanceof String && isDate(value)"
v-b-tooltip.hover="value"
>
{{ formatDate(value) }}
</span>
<span v-else-if="value === null">---</span>
<span v-else>{{ value }}</span>
</strong>
</div>
<div class="form-group row">
<div class="offset-sm-3 col-sm-9">
<a
v-if="hero.purchased.plan.paymentMethod === 'Google'"
class="btn btn-primary btn-sm"
target="_blank"
:href="playOrdersUrl"
>
Play Console
</a>
<a
v-else-if="hero.purchased.plan.paymentMethod === 'Paypal'"
class="btn btn-primary btn-sm"
target="_blank"
:href="'https://www.paypal.com/billing/subscriptions/' + paymentDetails.customerId"
>
PayPal Dashboard
</a>
<a
v-else-if="hero.purchased.plan.paymentMethod === 'Stripe'"
class="btn btn-primary btn-sm"
target="_blank"
:href="'https://dashboard.stripe.com/customers/' + paymentDetails.customerId"
>
Stripe Dashboard
</a>
</div>
</div>
</div>
</div>
<div
v-if="expand"
@@ -474,17 +569,36 @@
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
.input-group-append {
width: auto;
.input-group-text {
border-bottom-right-radius: 2px;
border-top-right-radius: 2px;
font-weight: 600;
font-size: 0.8rem;
color: $gray-200;
.form-group {
margin-bottom: 0.4rem;
}
.input-group-append {
width: auto;
.input-group-text {
border-bottom-right-radius: 2px;
border-top-right-radius: 2px;
font-weight: 600;
font-size: 0.8rem;
color: $gray-200;
}
}
.info-icon {
font-size: 0.8rem;
color: $purple-400;
cursor: pointer;
margin-left: 0.2rem;
background-color: $gray-500;
padding: 0.1rem 0.3rem;
border-radius: 0.2rem;
}
.info-icon:hover {
background-color: $purple-400;
color: white;
}
}
</style>
<script>
@@ -495,6 +609,55 @@ import subscriptionBlocks from '@/../../common/script/content/subscriptionBlocks
import saveHero from '../mixins/saveHero';
import LoadingSpinner from '@/components/ui/loadingSpinner';
const PLAY_CONSOLE_ORDERS_BASE_URL = import.meta.env.PLAY_CONSOLE_ORDERS_BASE_URL;
const humanReadablePaymentDetails = {
customerId: {
label: 'Customer ID',
help: 'The unique identifier for the customer in the payment system.',
},
purchaseDate: {
label: 'Purchase Date',
help: 'The date when the subscription was purchased or renewed.',
},
originalPurchaseDate: {
label: 'Original Purchase Date',
help: 'The date when the subscription was first purchased.',
},
productId: {
label: 'Product ID',
help: 'The identifier for the product associated with the subscription.',
},
transactionId: {
label: 'Transaction ID',
help: 'The unique identifier for the last transaction in the payment system.',
},
isCanceled: {
label: 'Is Canceled',
help: 'Indicates whether the subscription has been canceled by the user or the system.',
},
isExpired: {
label: 'Is Expired',
help: 'Indicates whether the subscription has expired. A cancelled subscription may still be active until the end of the billing cycle.',
},
expirationDate: {
label: 'Termination Date',
help: 'The date when the subscription will expire or has expired.',
},
nextPaymentDate: {
label: 'Next Payment Date',
help: 'The date when the next payment is due. If the subscription is canceled or expired, this may be null.',
},
lastPaymentDate: {
label: 'Last Payment Date',
help: 'The date when the lastpayment was made for the subscription.',
},
failedPayments: {
label: 'Failed Payments',
help: 'Number of times the payment failed for this subscription.',
},
};
export default {
components: {
LoadingSpinner,
@@ -520,6 +683,7 @@ export default {
isConvertingToGroupPlan: false,
groupPlanID: '',
subscriptionBlocks,
paymentDetails: null,
};
},
computed: {
@@ -553,6 +717,9 @@ export default {
}
return terminationDate;
},
playOrdersUrl () {
return `${PLAY_CONSOLE_ORDERS_BASE_URL}${this.paymentDetails?.transactionId || ''}`;
},
},
methods: {
dateFormat (date) {
@@ -583,6 +750,20 @@ export default {
this.isConvertingToGroupPlan = true;
this.hero.purchased.plan.owner = '';
},
getSubscriptionPaymentDetails () {
this.$store.dispatch('admin:getSubscriptionPaymentDetails', { userIdentifier: this.hero._id })
.then(details => {
if (details) {
this.paymentDetails = details;
} else {
alert('No payment details found.');
}
})
.catch(error => {
console.error('Error fetching subscription payment details:', error);
alert(`Failed to fetch payment details: ${error.message || 'Unknown error'}`);
});
},
saveClicked (e) {
e.preventDefault();
if (this.isConvertingToGroupPlan) {
@@ -601,6 +782,31 @@ export default {
this.$emit('changeUserIdentifier', id);
}
},
getHumanReadablePaymentDetails (key) {
return humanReadablePaymentDetails[key] || { label: key, help: '' };
},
isDate (date) {
return moment(date).isValid();
},
formatDate (date) {
return date ? moment(date).format('MM/DD/YYYY') : '---';
},
isSubscribed () {
console.log(this.hero.purchased.plan.customerId, this.hero.purchased.plan.dateTerminated);
return this.hero.purchased.plan
&& this.hero.purchased.plan.customerId
&& this.hero.purchased.plan.planId
&& this.hero.purchased.plan.paymentMethod
&& (
!this.hero.purchased.plan.dateTerminated
|| moment(this.hero.purchased.plan.dateTerminated).isAfter(moment())
);
},
isCancelled () {
return this.hero.purchased.plan
&& this.hero.purchased.plan.dateTerminated
&& this.hero.purchased.plan.dateTerminated !== '';
},
},
};
</script>

View File

@@ -226,7 +226,7 @@ export default {
}
},
async retrieveUserHistory () {
const history = await this.$store.dispatch('adminPanel:getUserHistory', { userIdentifier: this.hero._id });
const history = await this.$store.dispatch('admin:getUserHistory', { userIdentifier: this.hero._id });
this.armoire = history.armoire;
this.questInviteResponses = history.questInviteResponses;
this.cron = history.cron;

View File

@@ -8,6 +8,13 @@
>
{{ $t('adminPanel') }}
</router-link>
<router-link
v-if="hasPermission(user, 'groupSupport')"
class="nav-link"
:to="{name: 'groupAdmin'}"
>
{{ $t('groupAdmin') }}
</router-link>
<router-link
v-if="hasPermission(user, 'accessControl')"
class="nav-link"

View File

@@ -0,0 +1,47 @@
<template>
<div class="form-group row">
<label class="col-sm-3 col-form-label"><slot name="label">{{ label }}</slot></label>
<div class="col-sm-9">
<slot>
<textarea
v-if="inputType === 'textarea'"
:value="value"
class="form-control"
:rows="rows"
@input="$emit('input', $event.target.value)"
></textarea>
<input
v-else
:value="value"
class="form-control"
:type="inputType"
@input="$emit('input', $event.target.value)"
>
</slot>
</div>
</div>
</template>
<script>
export default {
model: {
prop: 'value',
event: 'input',
},
props: {
label: {
type: String,
},
value: {
type: [String, Boolean],
},
inputType: {
type: String,
default: 'text',
},
rows: {
default: 3,
},
},
};
</script>

View File

@@ -0,0 +1,45 @@
<template>
<form>
<form-row
v-model="group.name"
:label="$t('groupName')"
/>
<form-row
v-model="group.summary"
:label="$t('guildSummary')"
input-type="textarea"
/>
<form-row
v-model="group.description"
:label="$t('groupDescription')"
input-type="textarea"
rows="6"
/>
<form-row
v-model="group.bannedWordsAllowed"
:label="$t('bannedWordsAllowed')"
input-type="checkbox"
/>
<form-row
v-model="group.leaderOnly.challenges"
:label="$t('leaderOnlyChallenges')"
input-type="checkbox"
/>
</form>
</template>
<script>
import formRow from '@/components/admin/formRow.vue';
export default {
components: {
formRow,
},
props: {
group: {
type: Object,
required: true,
},
},
};
</script>

View File

@@ -0,0 +1,69 @@
<template>
<div v-if="hasPermission(user, 'groupSupport')">
<h2>{{ group.name }}</h2>
<supportContainer
:title="$t('groupData')"
>
<groupData
:group="group"
/>
</supportContainer>
<supportContainer
:title="$t('groupPlanSubscription')"
/>
<supportContainer
v-if="group.type === 'party'"
:title="$t('questDetails')"
/>
<supportContainer
:title="$t('members')"
>
<members
:group="group"
/>
</supportContainer>
</div>
</template>
<script>
import { userStateMixin } from '../../../../mixins/userState';
import supportContainer from '../../supportContainer.vue';
import groupData from './groupData.vue';
import members from './members.vue';
export default {
components: {
supportContainer,
groupData,
members,
},
mixins: [userStateMixin],
data () {
return {
groupId: '',
group: {},
};
},
watch: {
groupId () {
this.loadGroup(this.groupId);
},
},
mounted () {
this.groupId = this.$route.params.groupId;
},
methods: {
clearData () {
this.group = {};
},
async loadGroup (groupId) {
this.$emit('changeGroupId', groupId);
this.group = await this.$store.dispatch('admin:getGroup', { groupId });
},
async updateGroup () {
await this.$store.dispatch('admin:updateGroup', { group: this.group });
this.$emit('groupSaved', this.group);
},
},
};
</script>

View File

@@ -0,0 +1,29 @@
<template>
<form-row
:label="$t('groupLeader')"
>
<strong class="col-form-label">
<router-link
:to="{'name': 'adminPanelUser', 'params': {'userIdentifier': group.leader }}"
>
{{ group.leader }}
</router-link>
</strong>
</form-row>
</template>
<script>
import formRow from '@/components/admin/formRow.vue';
export default {
components: {
formRow,
},
props: {
group: {
type: Object,
required: true,
},
},
};
</script>

View File

@@ -0,0 +1,93 @@
<template>
<div class="row standard-page col-12 d-flex justify-content-center">
<div class="group-admin-content">
<h1>{{ $t("groupAdmin") }}</h1>
<form
class="form-inline"
@submit.prevent="loadGroup(groupID)"
>
<div class="input-group col pl-0 pr-0">
<input
v-model="groupID"
class="form-control"
type="text"
placeholder="Group ID"
>
<div class="input-group-append">
<button
class="btn btn-primary"
type="button"
:disabled="!groupID"
@click="loadGroup(groupID)"
>
Load
</button>
</div>
</div>
</form>
<router-view
class="mt-3"
@changeGroupId="changeGroupId"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.uidField {
min-width: 45ch;
}
.input-group-append {
width:auto;
}
.group-admin-content {
flex: 0 0 800px;
max-width: 800px;
}
</style>
<script>
import VueRouter from 'vue-router';
import { mapState } from '@/libs/store';
const { isNavigationFailure, NavigationFailureType } = VueRouter;
export default {
data () {
return {
groupID: '',
};
},
computed: {
...mapState({ user: 'user.data' }),
},
mounted () {
this.$store.dispatch('common:setTitle', {
section: this.$t('groupAdmin'),
});
},
methods: {
changeGroupId (id) {
this.groupID = id;
},
async loadGroup (groupId) {
if (this.$router.currentRoute.name === 'groupAdminGroup') {
await this.$router.push({
name: 'groupAdmin',
});
}
await this.$router.push({
name: 'groupAdminGroup',
params: { groupId },
}).catch(failure => {
if (isNavigationFailure(failure, NavigationFailureType.duplicated)) {
this.$router.go();
}
});
},
},
};
</script>

View File

@@ -0,0 +1,53 @@
<template>
<div class="card mt-2">
<div class="card-header">
<h3
class="mb-0 mt-0"
:class="{'open': expand}"
@click="expand = !expand"
>
<slot name="title">
{{ title }}
</slot>
</h3>
</div>
<div
v-if="expand"
class="card-body"
>
<slot></slot>
</div>
<div
v-if="expand && onSave"
class="card-footer"
>
<button
class="btn btn-primary mt-1"
@click="onSave"
>
{{ $t('save') }}
</button>
</div>
</div>
</template>
<script>
export default {
props: {
title: {
type: String,
required: false,
},
onSave: {
type: Function,
required: false,
},
},
data () {
return {
expand: false,
};
},
};
</script>

View File

@@ -43,9 +43,11 @@ export default {
const AUTH_SETTINGS = localStorage.getItem(LOCALSTORAGE_AUTH_KEY);
const parseSettings = JSON.parse(AUTH_SETTINGS);
const userId = parseSettings ? parseSettings.auth.apiId : '';
const username = this.$store?.state?.user?.data?.auth?.local?.username || '';
return this.$t('accountSuspended', {
userId,
username,
communityManagerEmail: COMMUNITY_MANAGER_EMAIL,
});
},

View File

@@ -851,7 +851,7 @@ export default {
return;
}
if (this.genericPurchase) {
this.makeGenericPurchase(this.item, 'buyModal', this.selectedAmountToBuy);
await this.makeGenericPurchase(this.item, 'buyModal', this.selectedAmountToBuy);
await this.purchased(this.item.text);
}
}

View File

@@ -35,7 +35,7 @@
</button>
<button
class="btn btn-secondary d-flex align-items-center justify-content-center"
:class="{disabled: !canSave}"
:class="{'btn-disabled': !canSave}"
type="button"
@click="submit()"
>
@@ -162,13 +162,13 @@
>
<div
class="habit-option-icon svg-icon no-transition"
:class="task.up ? '' : 'disabled'"
:class="task.up ? '' : 'icon-disabled'"
v-html="icons.positive"
></div>
</div>
<div
class="habit-option-label no-transition"
:class="task.up ? cssClass('icon') : 'disabled'"
:class="task.up ? cssClass('icon') : 'label-disabled'"
>
{{ $t('positive') }}
</div>
@@ -188,13 +188,13 @@
>
<div
class="habit-option-icon no-transition svg-icon negative mx-auto"
:class="task.down ? '' : 'disabled'"
:class="task.down ? '' : 'icon-disabled'"
v-html="icons.negative"
></div>
</div>
<div
class="habit-option-label no-transition"
:class="task.down ? cssClass('icon') : 'disabled'"
:class="task.down ? cssClass('icon') : 'label-disabled'"
>
{{ $t('negative') }}
</div>
@@ -592,7 +592,7 @@
<button
class="btn btn-primary btn-footer
d-flex align-items-center justify-content-center"
:class="{disabled: !canSave}"
:class="{'btn-disabled': !canSave}"
type="button"
@click="submit()"
>
@@ -881,12 +881,14 @@
}
}
.disabled {
.btn-disabled {
background-color: $white;
border: 2px solid transparent;
color: $gray-200;
line-height: 1.714;
box-shadow: 0px 1px 3px 0px rgba(26, 24, 29, 0.12), 0px 1px 2px 0px rgba(26, 24, 29, 0.24);
cursor: not-allowed;
opacity: 0.6;
&:focus {
background-color: $white;
@@ -948,7 +950,7 @@
height: 10px;
color: $white;
&.disabled {
&.icon-disabled {
color: $gray-200;
}
@@ -962,7 +964,7 @@
font-weight: bold;
text-align: center;
&.disabled {
&.label-disabled {
color: $gray-100;
font-weight: normal;
}
@@ -1018,7 +1020,7 @@
border: 0;
}
.disabled .input-group-text {
.input-group-outer.disabled .input-group-text {
color: $gray-200;
}

View File

@@ -116,7 +116,7 @@
.toggle-switch-inner:before {
content: "";
padding-left: 10px;
background-color: $green-10;
background-color: $green-50;
}
.toggle-switch-inner:after {

View File

@@ -19,7 +19,7 @@ let analyticsReady = false;
function _getConsentedUser () {
const store = getStore();
const user = store.state.user.data;
if (!user?.preferences?.analyticsConsent || navigator.globalPrivacyControl) {
if (!user?.preferences?.analyticsConsent) {
return false;
}
return user;

View File

@@ -98,7 +98,7 @@
}
.settings-content {
flex: 0 0 732px;
flex: 0 0 751px;
max-width: unset;
::v-deep {

View File

@@ -33,6 +33,21 @@
v-html="$t('privacySettingsOverview') + ' ' + $t('learnMorePrivacy')"
>
</p>
<div
v-if="gpcEnabled"
class="mx-4 px-3 py-2 mb-4 gpc-alert d-flex align-items-center black bg-yellow-50"
>
<div
class="svg svg-icon mr-2"
v-html="icons.alert"
>
</div>
<div
class="gpc-message"
v-html="gpcInfo"
>
</div>
</div>
<div
class="d-flex justify-content-center"
>
@@ -91,6 +106,29 @@
line-height: 1.33;
}
.gpc-alert {
border-radius: 4px;
line-height: 1.714;
.gpc-message {
opacity: 0.9;
}
::v-deep a {
color: $black;
text-decoration: underline;
}
.svg-icon {
width: 16px;
opacity: 0.75;
::v-deep svg path {
fill: $black;
}
}
}
.mb-28p {
margin-bottom: 28px;
}
@@ -110,6 +148,7 @@ import ToggleSwitch from '@/components/ui/toggleSwitch.vue';
import { GenericUserPreferencesMixin } from '@/pages/settings/components/genericUserPreferencesMixin';
import { InlineSettingMixin } from '../components/inlineSettingMixin';
import { mapState } from '@/libs/store';
import alert from '@/assets/svg/for-css/alert.svg?raw';
export default {
mixins: [
@@ -120,14 +159,32 @@ export default {
SaveCancelButtons,
ToggleSwitch,
},
data () {
return {
icons: Object.freeze({
alert,
}),
};
},
computed: {
...mapState({
user: 'user.data',
}),
gpcEnabled () {
return navigator.globalPrivacyControl;
},
gpcInfo () {
const gpcUrl = 'https://globalprivacycontrol.org/';
if (this.user.preferences.analyticsConsent) {
return this.$t('gpcPlusAnalytics', { url: gpcUrl });
}
return this.$t('gpcWarning', { url: gpcUrl });
},
},
methods: {
finalize () {
this.setUserPreference('analyticsConsent');
localStorage.setItem('analyticsConsent', this.user.preferences.analyticsConsent);
this.mixinData.inlineSettingMixin.sharedState.inlineSettingUnsavedValues = false;
},
prefToggled () {
@@ -135,7 +192,10 @@ export default {
this.mixinData.inlineSettingMixin.sharedState.inlineSettingUnsavedValues = newVal;
},
resetControls () {
this.user.preferences.analyticsConsent = !this.user.preferences.analyticsConsent;
if (this.mixinData.inlineSettingMixin.sharedState.inlineSettingUnsavedValues) {
this.user.preferences.analyticsConsent = !this.user.preferences.analyticsConsent;
this.mixinData.inlineSettingMixin.sharedState.inlineSettingUnsavedValues = false;
}
},
},
};

View File

@@ -263,11 +263,12 @@ export default {
this.$store.dispatch('tasks:fetchUserTasks'),
]).then(() => {
this.$store.state.isUserLoaded = true;
const analyticsConsent = localStorage.getItem('analyticsConsent');
if (analyticsConsent !== null
&& analyticsConsent !== this.user.preferences.analyticsConsent
) {
this.$store.dispatch('user:set', { 'preferences.analyticsConsent': analyticsConsent });
let analyticsConsent = localStorage.getItem('analyticsConsent');
if (analyticsConsent !== null) {
analyticsConsent = analyticsConsent === 'true';
if (analyticsConsent !== this.user.preferences.analyticsConsent) {
this.$store.dispatch('user:set', { 'preferences.analyticsConsent': analyticsConsent });
}
}
if (window && window['habitica-i18n']) {
if (this.user.preferences.language === window['habitica-i18n'].language.code) {

View File

@@ -24,6 +24,8 @@ const AdminContainerPage = () => import(/* webpackChunkName: "admin-panel" */'@/
const AdminPanelPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin/admin-panel');
const AdminPanelUserPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin/admin-panel/user-support');
const AdminPanelSearchPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin/admin-panel/search');
const GroupAdminPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin/groups');
const GroupAdminGroupPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin/groups/group-support');
const BlockerPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin/blocker');
// Tasks
@@ -216,6 +218,28 @@ const router = new VueRouter({
},
],
},
{
name: 'groupAdmin',
path: 'groups',
component: GroupAdminPage,
meta: {
privilegeNeeded: [ // any one of these is enough to give access
'groupSupport',
],
},
children: [
{
name: 'groupAdminGroup',
path: ':groupId',
component: GroupAdminGroupPage,
meta: {
privilegeNeeded: [
'groupsSupport',
],
},
},
],
},
{
name: 'blockers',
path: 'blockers',

View File

@@ -0,0 +1,31 @@
import axios from 'axios';
export async function searchUsers (store, payload) {
const url = `/api/v4/admin/search/${payload.userIdentifier}`;
const response = await axios.get(url);
return response.data.data;
}
export async function getUserHistory (store, payload) {
const url = `/api/v4/admin/user/${payload.userIdentifier}/history`;
const response = await axios.get(url);
return response.data.data;
}
export async function getSubscriptionPaymentDetails (store, payload) {
const url = `/api/v4/admin/user/${payload.userIdentifier}/subscription-payment-details`;
const response = await axios.get(url);
return response.data.data;
}
export async function getGroup (store, payload) {
const url = `/api/v4/admin/groups/${payload.groupId}`;
const response = await axios.get(url);
return response.data.data;
}
export async function updateGroup (store, payload) {
const url = `/api/v4/admin/groups/${payload.groupId || payload.group._id}`;
const response = await axios.put(url, payload.group);
return response.data.data;
}

View File

@@ -1,13 +0,0 @@
import axios from 'axios';
export async function searchUsers (store, payload) {
const url = `/api/v4/admin/search/${payload.userIdentifier}`;
const response = await axios.get(url);
return response.data.data;
}
export async function getUserHistory (store, payload) {
const url = `/api/v4/admin/user/${payload.userIdentifier}/history`;
const response = await axios.get(url);
return response.data.data;
}

View File

@@ -38,3 +38,9 @@ export async function getHeroGroupPlans (store, payload) {
const response = await axios.get(url);
return response.data.data;
}
export async function deleteHero (store, payload) {
const url = `/api/v4/members/${payload.uuid}?deleteAccount=${payload.deleteHabiticaAccount}&deleteAmplitude=${payload.deleteAmplitudeData}`;
const response = await axios.delete(url);
return response.data.data;
}

View File

@@ -1,6 +1,6 @@
import { flattenAndNamespace } from '@/libs/store/helpers/internals';
import * as adminPanel from './adminPanel';
import * as admin from './admin';
import * as common from './common';
import * as user from './user';
import * as tasks from './tasks';
@@ -26,7 +26,7 @@ import * as blockers from './blockers';
// Example: fetch in user.js -> 'user:fetch'
const actions = flattenAndNamespace({
adminPanel,
admin,
common,
user,
tasks,

View File

@@ -36,7 +36,7 @@ const envVars = [
'TIME_TRAVEL_ENABLED',
'DEBUG_ENABLED',
'CONTENT_SWITCHOVER_TIME_OFFSET',
// TODO necessary? if yes how not to mess up with vue cli? 'NODE_ENV'
'PLAY_CONSOLE_ORDERS_BASE_URL',
];
const envObject = {};

View File

@@ -2215,9 +2215,9 @@
"armorSpecialWinter2021RogueText": "Efeu-Grünes Gewand",
"weaponSpecialWinter2021HealerNotes": "Dirigiere deine Kämpfe mit unvorhersehbarem Schwung, wie ein Schneegestöber! Erhöht Intelligenz um <%= int %>. Limitierte Ausgabe 2020-2021 Winterausrüstung.",
"weaponSpecialWinter2021HealerText": "Flocken Flanken Rute",
"weaponSpecialWinter2021MageNotes": "Diese mächtige Waffe ist nicht nur eine Phase! Konzentriere deine Kräfte, fokussiere den Verlauf eines Monates und studiere den Lauf von Zeit und Raum. Erhöht Intelligenz um <%= int %> und Wahrnehmung um <%= per %>. Limitierte Ausgabe 2020-2021 Winterausrüstung.",
"weaponSpecialWinter2021MageNotes": "Diese mächtige Waffe ist definitiv mehr als nur eine Phase! Konzentriere deine Kräfte, fokussiere auf den Verlauf des Monates und studiere den Lauf von Zeit und Raum. Erhöht Intelligenz um <%= int %> und Wahrnehmung um <%= per %>. Limitierte Ausgabe 2020-2021 Winterausrüstung.",
"weaponSpecialWinter2021MageText": "Magischer Mond-Phaser",
"weaponSpecialWinter2021WarriorNotes": "Hiermit kannst Du die größten Frische an Land ziehen! Erhöht Stärke um <%= str %>. Limitierte Ausgabe 2020-2021 Winterausrüstung.",
"weaponSpecialWinter2021WarriorNotes": "Hiermit kannst Du die größten Fische an Land ziehen! Erhöht Stärke um <%= str %>. Limitierte Ausgabe 2020-2021 Winterausrüstung.",
"weaponSpecialWinter2021WarriorText": "Mächtige Angelrute",
"weaponSpecialWinter2021RogueNotes": "Tarnung und Waffe in einem, die giftigen Früchte der Stechpalme helfen dir mit den schwierigsten Aufgaben umzugehen. Erhöht Stärke um <%= str %>. Limitierte Ausgabe 2020-2021 Winterausrüstung.",
"weaponSpecialWinter2021RogueText": "Ilex-Beeren Morgenstern",
@@ -3413,5 +3413,29 @@
"headSpecialFall2025MageNotes": "Ätherisch und glühend - diese Maske bedeckt deinen Kopf, während du alle deine wichtigen Aufgaben abdeckst. Erhöht Wahrnehmung um <%= per %>. Limitierte Herbstausrüstung 2025.",
"armorArmoireRedWaistcoatNotes": "Sieh elegant und umwerfend aus, während du deine Aufgaben bewältigst. In der Westentasche ist etwas geheimes versteckt — was denkst du, könnte es sein? Erhöht Ausdauer und Stärke um jeweils <%= attrs %>. Verzauberter Schrank: Rote Weste Set (Gegenstand 2 von 2)",
"armorArmoireSoftOrangeSuitNotes": "Orange ist eine lebhafte Farbe. Zieh dies an, wenn du zu Bett gehst und in allen Abenteuern, denen du in deinen Träumen begegnest, wirst du sicher Erfolg haben. Erhöht Ausdauer und Stärke um jeweils <%= attrs %> . Verzauberter Schrank: Oranges Loungewear-Set (Gegenstand 2 von 3).",
"headSpecialFall2025HealerNotes": "Markant und gehörnt - diese Maske bedeckt deinen Kopf, während du alle deine wichtigen Aufgaben abdeckst. Erhöht Intelligenz um <%= int %>. Limitierte Herbstausrüstung 2025."
"headSpecialFall2025HealerNotes": "Markant und gehörnt - diese Maske bedeckt deinen Kopf, während du alle deine wichtigen Aufgaben abdeckst. Erhöht Intelligenz um <%= int %>. Limitierte Herbstausrüstung 2025.",
"headArmoireRedNewsieHatText": "Rote Zeitungsjungenmütze",
"headArmoireRedNewsieHatNotes": "Extra! Extra! Lesen Sie alles darüber: Diese Mütze ist bequem, modisch und praktisch. Erhöht die Wahrnehmung und Intelligenz um jeweils <%= attrs %>. Verzauberter Kleiderschrank: Rotes Weste-Set (Item 1 von 2)",
"headArmoireFloppyOrangeHatText": "Orangener Schlapphut",
"headArmoireFloppyOrangeHatNotes": "In diesen simplen Hut wurden zahlreiche Zauber eingearbeitet, die ihm eine auffällige orange Farbe verleihen. Erhöht alle Werte um jeweils <%= attrs %>. Verzauberter Kleiderschrank: Orangenes Loungewear-Set (Item 1 von 3).",
"headArmoireBlackHairbowText": "Schwarze Haarschleife",
"headArmoireBlackHairbowNotes": "Werde stark, klug und herzhaft, während du diese wunderschöne schwarze Haarschleife trägst! Erhöht Stärke, Intelligenz und Konstitution um jeweils <%= attrs %>. Verzauberter Kleiderschrank: Schwarzes Haarschleifen-Set (Item 1 von 2).",
"headArmoireBlacksmithsGogglesText": "Schmiedebrille",
"shieldSpecialFall2025RogueNotes": "Eine mächtige Waffe, mit der Sie Ihre To-Do's um die Hälfte reduzieren können. Erhöht Stärke um <%= str %>. Limitierte Ausgabe Herbst 2025 Ausrüstung.",
"shieldSpecialFall2025HealerText": "Koboldschild",
"shieldMystery202508Text": "Brillante Cyan-Klinge",
"shieldMystery202508Notes": "Wenn Sie schon eine rotierende Klinge cool fanden, probieren Sie doch mal zwei! Bietet keinen Vorteil. August 2025 Abonnentengegenstand.",
"shieldMystery202511Text": "Frostschild",
"shieldMystery202511Notes": "Dieser robuste Schild aus eisigem Gestein schützt dich vor schlechten Gewohnheiten, ohne deine Hände zu vereisen. Verleiht keinen Vorteil. November 2025 Abonnentengegenstand.",
"shieldArmoireSoftOrangePillowText": "Weiches orangenes Kissen",
"backMystery202510Text": "Gleitende Ghulflügel",
"backMystery202510Notes": "Fliege mit diesen riesigen Flügeln lautlos durch den heimgesuchten Himmel. Verleiht keinen Vorteil. Oktober 2025 Abonnentengegenstand.",
"bodyMystery202509Notes": "Dieser Schal schützt dein Gesicht vor Wind und sieht auch noch verdammt cool aus. Bietet keinen Vorteil. September 2025 Abonnentengegenstand.",
"eyewearMystery202510Text": "Gleitende Ghul-Augen",
"eyewearMystery202510Notes": "Diese gruseligen Augen leuchten wie der Erntemond. Verleiht keinen Vorteil. Oktober 2025 Abonnentengegenstand.",
"headArmoireBlacksmithsGogglesNotes": "Bei der Arbeit in einer Schmiede benötigen Sie einen bruchsicheren und hitzebeständigen Augenschutz. Erhöht die Wahrnehmung um <%= per %>. Verzauberter Schrank: Schmiedeset (Item 1 von 3).",
"shieldSpecialFall2025WarriorText": "Sasquatch Schild",
"shieldSpecialFall2025WarriorNotes": "Verschaffe dir etwas mehr Zeit zum Nachdenken und Planen, indem du dich vor deinen nächsten Tagesaufgaben abschirmst. Erhöht die Konstitution um <%= con %>. Limitierte Auflage Herbst 2025 Ausrüstung.",
"shieldSpecialFall2025HealerNotes": "Verschaffe dir etwas mehr Zeit, um Vorräte zu sammeln, indem du dich vor deinen Aufgaben abschirmst. Erhöht die Konstitution um <%= con %>. Limitierte Ausgabe Herbst 2025 Ausrüstung.",
"shieldArmoireSoftOrangePillowNotes": "Der vorbereitete Krieger packt für jede Expedition ein Kissen ein. Mach dich bereit, neue Verpflichtungen zu übernehmen ... sogar während du ein Nickerchen machst. Erhöht Intelligenz und Wahrnehmung um jeweils <%= attrs %>. Verzauberter Kleiderschrank: Orangenes Loungewear-Set (Gegenstand 3 von 3)."
}

View File

@@ -2,7 +2,7 @@
"settings": "Einstellungen",
"language": "Sprache",
"americanEnglishGovern": "Im Fall von Bedeutungsunterschieden gilt die englische Version.",
"helpWithTranslation": "Hast du Interesse, bei der Übersetzung von Habitica helfen? Toll! Dann besuche doch die <a href=\"/groups/guild/7732f64c-33ee-4cce-873c-fc28f147a6f7\">Aspiring Linguists Guild</a>!",
"helpWithTranslation": "Hast du Interesse, bei der Übersetzung von Habitica helfen? Toll! Dann besuche doch die <a href=\"https://translate.habitica.com\"> Habitica's Weblate Seite</a>!",
"stickyHeader": "Kopfzeile anheften",
"newTaskEdit": "Neue Aufgaben im Bearbeiten-Modus öffnen",
"reverseChatOrder": "Zeige die Chat-Nachrichten in umgekehrter Reihenfolge",

View File

@@ -3,5 +3,9 @@
"siteBlockers": "Site Blockers",
"newsroom": "Newsroom",
"adminBlockerTypeDescription": "<b>IP-Address</b> - Block access for a specific IP-Address\n\nClient - Block access for a client based on the \"x-client\" header.\n\nE-Mail - Blocks e-mails from being used for signup.",
"adminBlockerAreaDescription": "A blocker can either apply to the full site, completely blocking any access. Or it can apply to purchases, which still allows the site to be accessed."
"adminBlockerAreaDescription": "A blocker can either apply to the full site, completely blocking any access. Or it can apply to purchases, which still allows the site to be accessed.",
"groupAdmin": "Group Admin",
"groupSupportDescription": "Manage groups and their members. You can search for groups by ID, or load your own group by leaving the field blank.",
"groupData": "Group Data",
"groupPlanSubscription": "Group Plan Subscription"
}

View File

@@ -133,7 +133,7 @@
"passwordReset": "If we have your email or username on file, instructions for setting a new password have been sent to your email.",
"invalidLoginCredentialsLong": "Your email, username, or password are incorrect. Please try again or use \"Forgot Password.\"",
"invalidCredentials": "There is no account that uses those credentials.",
"accountSuspended": "This account, User ID \"<%= userId %>\", has been blocked for breaking the Community Guidelines (https://habitica.com/static/community-guidelines) or Terms of Service (https://habitica.com/static/terms). For details or to ask to be unblocked, please email our Community Manager at <%= communityManagerEmail %> or ask your parent or guardian to email them. Please include your @Username in the email.",
"accountSuspended": "Your account @<%= username %> has been blocked. For additional information, or to request an appeal, email admin@habitica.com with your Habitica username or User ID.",
"accountSuspendedTitle": "Account has been suspended",
"unsupportedNetwork": "This network is not currently supported.",
"cantDetachSocial": "Account lacks another authentication method; can't detach this authentication method.",

View File

@@ -271,5 +271,7 @@
"performanceAnalytics": "Performance and Analytics",
"usedForSupport": "These are used to improve the user experience, performance, and services of our website and apps. This data is used by our support team when handling requests and bug reports.",
"savePreferences": "Save Preferences",
"habiticaPrivacyPolicy": "Habitica's Privacy Policy"
"habiticaPrivacyPolicy": "Habitica's Privacy Policy",
"gpcWarning": "<a href='<%= url %>' target='_blank'>GPC</a> is on. Turning on tracking below will override this and send data to our analytics partners.",
"gpcPlusAnalytics": "<a href='<%= url %>' target='_blank'>GPC</a> is on. You have opted in to tracking and sending data to our analytics partners."
}

View File

@@ -901,13 +901,25 @@
"backgrounds0420205": "SET 131: Released April 2025",
"backgroundGardenWithFlowerBedsText": "Garden with Flower Beds",
"backgroundGardenWithFlowerBedsNotes": "Enjoy the blooms of spring in a Garden with Flower Beds.",
"backgrounds062025": " 133由2025 年 6 月发布",
"backgroundSummerSeashoreText": "夏日海滨",
"backgroundSummerSeashoreNotes": "在夏日海滨乘风破浪.",
"backgrounds052025": " 132于2025 年 5 月发布",
"backgroundTrailThroughAForestText": "穿越森林的小径",
"backgroundTrailThroughAForestNotes": "沿着穿过森林的小径漫步。",
"backgrounds062025": "SET 133: Released June 2025",
"backgroundSummerSeashoreText": "Summer Seashore",
"backgroundSummerSeashoreNotes": "Catch a wave at a Summer Seashore.",
"backgrounds052025": "SET 132: Released May 2025",
"backgroundTrailThroughAForestText": "Trail Through a Forest",
"backgroundTrailThroughAForestNotes": "Wander down a Trail Through a Forest.",
"backgrounds072025": "SET 134: Released July 2025",
"backgroundSirensLairText": "Siren's Lair",
"backgroundSirensLairNotes": "Dare to dive into a Sirens Lair."
"backgroundSirensLairNotes": "Dare to dive into a Sirens Lair.",
"backgrounds082025": "SET 135: Released August 2025",
"backgroundSunnyStreetWithShopsText": "Sunny Street with Shops",
"backgroundSunnyStreetWithShopsNotes": "Enjoy the sights and sounds of a Sunny Street with Shops.",
"backgrounds092025": "SET 136: Released September 2025",
"backgroundAutumnSwampText": "Autumn Swamp",
"backgroundAutumnSwampNotes": "Take in the haunting vibes of an Autumn Swamp.",
"backgrounds102025": "SET 137: Released October 2025",
"backgroundInsideForestWitchsCottageText": "Forest Witch's Cottage",
"backgroundInsideForestWitchsCottageNotes": "Weave spells inside a Forest Witch's Cottage.",
"backgrounds112025": "SET 138: Released November 2025",
"backgroundCastleKeepWithBannersText": "Castle Hall with Banners",
"backgroundCastleKeepWithBannersNotes": "Sing tales of heroic deeds in a Castle Hall with Banners."
}

View File

@@ -906,5 +906,20 @@
"backgroundSummerSeashoreNotes": "Cavalca un'onda su una Spiaggia Estiva.",
"backgrounds052025": "SET 132: Rilasciato a Maggio 2025",
"backgroundTrailThroughAForestText": "Sentiero Attraverso una Foresta",
"backgroundTrailThroughAForestNotes": "Passeggia lungo un Sentiero Attraverso una Foresta."
"backgroundTrailThroughAForestNotes": "Passeggia lungo un Sentiero Attraverso una Foresta.",
"backgrounds072025": "SET 134: Rilasciato Luglio 2025",
"backgroundSirensLairText": "Tana della Sirena",
"backgroundSirensLairNotes": "Abbi il coraggio di entrare nella Tana della Sirena.",
"backgrounds082025": "SET 135: Rilasciato Agosto 2025",
"backgroundSunnyStreetWithShopsText": "Strada Soleggiata con Negozi",
"backgroundSunnyStreetWithShopsNotes": "Lasciati incantare dallatmosfera di una strada soleggiata con negozi.",
"backgroundAutumnSwampText": "Palude Autunnale",
"backgroundAutumnSwampNotes": "Immergiti nelle inquietanti atmosfere di una palude dautunno.",
"backgrounds102025": "SET 137: Rilasciato Ottobre 2025",
"backgroundInsideForestWitchsCottageNotes": "Tessi incantesimi nella casetta della Strega della Foresta.",
"backgrounds112025": "SET 138: Rilasciato Novembre 2025",
"backgroundCastleKeepWithBannersText": "Sala del castello con stendardi",
"backgrounds092025": "SET 136: Rilasciato Settembre 2025",
"backgroundInsideForestWitchsCottageText": "Casetta della Strega della Foresta",
"backgroundCastleKeepWithBannersNotes": "Canta storie di gesta eroiche nella sala del castello con gli stendardi."
}

View File

@@ -21,7 +21,7 @@
"subGemName": "Gemme abbonato",
"maxBuyGems": "Hai comprato tutte le Gemme a disposizione per questo mese. Altre saranno disponibili entro i primi tre giorni del mese prossimo. Grazie per esserti abbonato!",
"timeTravelers": "Viaggiatori del Tempo",
"timeTravelersPopoverNoSubMobile": "Pare che tu abbia bisogno di una Clessidra Mistica per aprire il portale temporale ed evocare i Misteriosi Viaggiatori del Tempo.",
"timeTravelersPopoverNoSubMobile": "Gli abbonati ricevono una rara Clessidra Mistica ogni mese da utilizzare nel negozio dei Viaggiatori del Tempo.",
"timeTravelersPopover": "La tua Clessidra Mistica ha aperto il nostro portale temporale! Scegli cosa vorresti recuperare dal passato o dal futuro.",
"mysterySetNotFound": "Completo Mistery non trovato o già posseduto.",
"mysteryItemIsEmpty": "Gli oggetti Mistery sono finiti",
@@ -120,14 +120,14 @@
"gemBenefit2": "Sfondi per immergere il tuo avatar nel mondi di Habitica!",
"gemBenefit3": "Entusiasmanti Missioni che hanno come ricompensa uova di animali.",
"gemBenefit4": "Resetta le statistiche del tuo avatar e cambia la sua classe.",
"subscriptionBenefit1": "Alexander il Mercante ti venderà ora delle Gemme nel Mercato al prezzo di 20 Oro l'una!",
"subscriptionBenefit3": "Scopri ancora più oggetti in Habitica con un bottino giornaliero raddoppiato.",
"subscriptionBenefit4": "Ogni mese un costume unico e alla moda per il tuo avatar.",
"subscriptionBenefit5": "Riceverai il Lepronte Porpora una volta diventato un nuovo Abbonato.",
"subscriptionBenefit6": "Ottieni Clessidre Mistiche per comprare oggetti nel negozio dei Viaggiatori del Tempo!",
"subscriptionBenefit1": "Ottieni fino a 50 gemme acquistabili con Oro nel Mercato per comprare Missioni, Personalizzazioni, Animali ed altro!",
"subscriptionBenefit3": "Trova il doppio di Uova, Pozioni di schiusa e cibo ogni giorno per accrescere la tua collezioni di Animali!",
"subscriptionBenefit4": "Rimani aggiornato con gli ultimi equipaggiamenti esclusivi. Abbonati adesso per ottenere <%= month %>s <%= currentMysterySetName %>!",
"subscriptionBenefit5": "Ottieni l'esclusivo Lepronte Porpora una volta che ti sei abbonato oggi!",
"subscriptionBenefit6": "Non perderti neanche un oggetto con 1 Clessidra Mistica al mese da utilizzare nel negozio dei Viaggiatori del Tempo!",
"purchaseAll": "Acquista Set",
"gemsRemaining": "Gemme rimanenti",
"notEnoughGemsToBuy": "Non puoi comprare quella quantità di Gemme",
"gemsRemaining": "rimanenti",
"notEnoughGemsToBuy": "Non ci sono più gemme disponibili per l'acquisto questo mese. Altre diventeranno disponibili entro i primi 3 giorni di ogni mese.",
"subWillBecomeInactive": "Diventerà inattivo",
"confirmCancelSub": "Sei sicuro di voler cancellare il tuo abbonamento? Perderai tutti i tuoi benefici.",
"mysticHourglassNeededNoSub": "Questo articolo ha bisogno di una Clessidra Mistica. Ne otterrai sottoscrivendo un abbonamento ad Habitica.",
@@ -226,5 +226,26 @@
"mysterySet202305": "Set Drago del Vespro",
"mysterySet202302": "Set del Prestigiatore Striato",
"mysterySet202304": "Set Teiera Eccellente",
"mysterySet202310": "Set Fantasma della Luce Spettrale"
"mysterySet202310": "Set Fantasma della Luce Spettrale",
"mysterySet202508": "Set della Lama Brillante",
"mysterySet202403": "Set della Leggenda Fortunata",
"mysterySet202409": "Set del Mago dell'Eliotropio",
"mysterySet202407": "Set dell'amabile Axolotl",
"mysterySet202509": "Set del Viandante spazzato dal vento",
"mysterySet202511": "Set del Guerriero del Gelo",
"mysterySet202408": "Set dell'Egida Arcana",
"mysterySet202504": "Set dello Yeti sfuggente",
"mysterySet202402": "Set del Paradiso Rosa",
"mysterySet202412": "Set del Coniglietto del Bastoncino di Zucchero",
"subscribeTo": "Abbonati a",
"mysterySet202503": "Set Furia di Giada",
"mysterySet202404": "Set del Mago dei Funghi",
"mysterySet202405": "Set del Drago Dorato",
"mysterySet202501": "Set del Vincolagelo",
"mysterySet202502": "Set dell'Arlecchino sincero",
"mysterySet202406": "Set del Bucaniere Fantasma",
"mysterySet202411": "Set del Combattente peloso",
"mysterySet202506": "Set della Luce Solare",
"mysterySet202505": "Set della Coda di rondine volante",
"mysterySet202507": "Set dello Skater coraggioso"
}

View File

@@ -39,7 +39,7 @@
"webFaqAnswer37": "「衣装を着る」オプションがオンになっているか確認してください。もしアバターが衣装を着ている場合、その装備セットが武装の代わりに表示されます。\n\nモバイル版で衣装を切り替えるには\n * メニューから「装備」を選択し、「衣装を使用する」トグルを見つけます\n\nウェブ版で衣装を切り替えるには\n * 所持品から「装備」を選択し、装備ドロワーの衣装タブで「衣装を使用する」トグルを見つけます",
"webFaqAnswer38": "新しいHabiticaプレイヤーは基本の戦士クラスの装備しか購入できません。プレイヤーは装備を順番に購入して次のピースをアンロックする必要があります。\n\n多くの装備はクラス固有であり、プレイヤーは現在のクラスに属する装備のみ購入できます。",
"webFaqAnswer39": "もし装備を手に入れたい場合は、Habiticaの有料会員になるか、ラッキー宝箱に挑戦するか、Habiticaの大祭のうちの1つで贅沢をすることができます。\n\nHabiticaの有料会員は毎月特別な独占装備セットと、タイムトラベラーショップで過去の装備セットを購入するための神秘的な砂時計を受け取ります。\n\nごほうびのラッキー宝箱には350以上の装備があります100ゴールドで、特別な装備、ペットを乗騎に育てるための餌、またはレベルアップのための経験値のいずれかを受け取るチャンスがあります\n\n四季ごとの大祭では、新しいクラスの装備がゴールドで購入でき、以前の大祭セットはジェムで購入できます。",
"webFaqAnswer41": "神秘の砂時計はHabiticaの有料会員限定の通貨でタイムトラベラーの店で使用できます。有料会員は神秘の砂時計を登録特典がある月ごとに他の特典とともに受け取れます。\n特別な背景、ペット、クエスト、道具に興味があるならば有料プランのオプションをタイムトラベラーの店でチェックしてみてください",
"webFaqAnswer41": "神秘の砂時計はHabiticaの有料会員限定の通貨でタイムトラベラーの店で使用できます。有料会員は登録している間、神秘の砂時計と、ほかにもいろいろな特典を毎月受け取れます。タイムトラベラーの店で買える特別な背景、ペット、クエストや装備に興味がある方は、ぜひ有料プランのオプションをチェックしてみてください",
"webFaqAnswer42": "自分自身を奮起させ、タスクを達成するために責任感を高める最良の方法の一つは、パーティーに参加することです! 他のHabiticaプレイヤーと一緒にパーティーを組むことは、クエストに挑戦してペットと装備を手に入れ、仲間のスキルからバフを受け、モチベーションを高める素晴らしい方法です。\n\n責任感を高める別の方法は、チャレンジに参加することです。 チャレンジは特定の目標に関連するタスクを自動的にリストに追加します! これにより、ジェムの賞を目指して競争する要素が追加され、モチベーションが向上するかもしれません。 Habiticaチームによって作成された公式のチャレンジだけでなく、他のプレイヤーによって作成されたチャレンジもあります。",
"faqQuestion43": "クエストの進め方は?",
"webFaqAnswer43": "クエストを始めるには、まずパーティーに参加する必要があります。パーティーは、単独でクエストに挑戦する冒険としても、他のHabiticaプレーヤーを招待してクエストに迅速に取り組むこともできます\n\nパーティーから「クエストの開始」ボタンを選択して、インベントリからクエストスクロールを選択します。クエストを進めるために通常どおりタスクを完了してくださいボスクエストに挑戦している場合はモンスターに対してダメージを蓄積し、コレクションクエストに挑戦している場合はアイテムを見つけるチャンスがあります。すべての進捗状況は翌日に適用されます。\n\n十分なダメージを与えたり、すべてのアイテムを集めたりすると、クエストが完了し、報酬がもらえます",
@@ -192,7 +192,7 @@
"subscriptionDetail001": "すべての有料会員は神秘の砂時計を毎月のミステリーアイテムと同じく毎月同じ日にもらえます。",
"subscriptionDetail45": "もし追加で有料プランをプレゼントされた場合に神秘の砂時計を多くもらえたりジェムの最大値が早く増えたりはしますか?",
"subscriptionDetail480": "これらの変更は神秘の砂時計と有料会員のジェムにのみ反映されます。そのほかすべての得点はそのまま残ります。",
"subscriptionPara2": "もし上記の回答にない質問があるならば、いつでも <%= mailto %>.に連絡してください",
"subscriptionPara2": "もし上記の回答にない質問があるならば、いつでも <%= mailto %>に連絡してください",
"subscriptionBenefitsAdjustments": "有料会員の特典調整",
"subscriptionBenefitsFaqTitle": "有料会員の特典調整のよくある質問",
"subscriptionPara0": "私たちはHabiticaの有料プランをより多くの神秘の砂時計とジェムで過去最高のものにしましたこれらの変更は有料プランの特典をもっとわかりやすくするものです。",

View File

@@ -1363,7 +1363,7 @@
"shieldSpecialWinter2017WarriorText": "パックの盾",
"shieldSpecialWinter2017WarriorNotes": "巨大なホッケーパックから作られたこの盾は、かなりの衝撃に耐えられます。体質が<%= con %>上がります。2016-2017年冬の限定装備。",
"shieldSpecialWinter2017HealerText": "シュガープラムの盾",
"shieldSpecialWinter2017HealerNotes": "この噛みごたえのある装備は、涙がでるほど酸っぱいタスクからあなたを守ってくれます! 体質が<%= con %>上がります。2016-2017年冬の限定装備。",
"shieldSpecialWinter2017HealerNotes": "この噛みごたえのある装備は、涙がでるほど酸っぱいタスクからあなたを守ってくれます!体質が<%= con %>上がります。2016-2017年冬の限定装備。",
"shieldSpecialSpring2017WarriorText": "毛糸玉の盾",
"shieldSpecialSpring2017WarriorNotes": "この盾の糸には、1本1本に防御魔法が編みこまれています! 遊んじゃいけませんよ(あんまりたくさんはね)。体質が<%= con %>上がります。2017年春の限定装備。",
"shieldSpecialSpring2017HealerText": "バスケットの盾",
@@ -2821,7 +2821,7 @@
"armorArmoireBasketballUniformText": "バスケットボールのユニフォーム",
"weaponArmoirePaintbrushText": "絵筆",
"armorSpecialSpring2023HealerText": "ユリの葉のガウン",
"armorArmoireBasketballUniformNotes": "このユニフォームの背中に何がプリントされているのか気になりますか?もちろん、それはあなたのラッキーナンバーです!知覚が<%= per %>、力が上がります。ラッキー宝箱:昔懐かしいバスケットボールセット(アイテム1/2)。",
"armorArmoireBasketballUniformNotes": "このユニフォームの背中に何がプリントされているのか気になりますか?もちろん、それはあなたのラッキーナンバーです!知覚が<%= per %>上がります。ラッキー宝箱:昔懐かしいバスケットボールセットアイテム1/2。",
"headMystery202301Text": "勇敢な妖狐の耳",
"backMystery202301Text": "武勇の五尾",
"backMystery202302Notes": "このしっぽを付ければ、素敵な一日になること間違いなしカルーカライ効果なし。2023年2月有料会員アイテム。",
@@ -3182,5 +3182,8 @@
"shieldSpecialFall2024HealerText": "宇宙の盾",
"shieldSpecialFall2024WarriorText": "炎の盾",
"headSpecialFall2025HealerText": "コボルドの仮面",
"shieldSpecialFall2025HealerText": "コボルドの盾"
"shieldSpecialFall2025HealerText": "コボルドの盾",
"shieldArmoireHattersPocketWatchText": "ピカピカなポケットウォッチ",
"shieldSpecialSummer2024HealerText": "海貝の盾",
"shieldSpecialSummer2024HealerNotes": "このぴかぴかする盾は、海貝の杖よりも強いです。体質が<%= con %>上がります。2024年夏の限定装備。"
}

View File

@@ -281,5 +281,6 @@
"summer2025FairyWrasseMageSet": "イトヒキベラの魔導士セット",
"fall2025SasquatchWarriorSet": "サスクワッチの戦士セット",
"fall2025SkeletonRogueSet": "ガイコツの盗賊セット",
"fall2025KoboldHealerSet": "コボルドの治療師セット"
"fall2025KoboldHealerSet": "コボルドの治療師セット",
"fall2025MaskedGhostMageSet": "覆面をした幽霊の魔導士セット"
}

View File

@@ -115,7 +115,7 @@
"paymentAutoRenew": "この有料プランは解約するまで自動更新されます。有料プランを解約する必要がある場合は、設定から解約できます。",
"paymentCanceledDisputes": "キャンセルの承認のメールをあなたに送りました。もしそのメールが見つからない場合は、今後の請求についての論争を防ぐために、私たちにご連絡をお願いいたします。",
"cannotUnpinItem": "このアイテムはピン留めを外せません。",
"paymentSubBillingWithMethod": "あなたの有料プランは、<br><strong><%= months %>ヶ月</strong>ごとに<strong><%= paymentMethod %></strong>によって <strong>$<%= amount %>.00 米ドル</strong> 請求されます。",
"paymentSubBillingWithMethod": "あなたの有料プランは、<br><strong><%= months %>ヶ月</strong>ごとに<strong><%= paymentMethod %></strong>によって<strong>$<%= amount %>.00 米ドル</strong>請求されます。",
"invalidUnlockSet": "このアイテムセットは無効なので、アンロックできません。",
"nGems": "<%= nGems %>ジェム",
"nMonthsSubscriptionGift": "<%= nMonths %>ヶ月の有料プラン(ギフト)",

View File

@@ -124,7 +124,7 @@
"subscriptionBenefit3": "毎日2倍のタマゴ、たまごがえしの薬と餌を見つけてペットコレクションを増やしましょう",
"subscriptionBenefit4": "最新の限定装備で着飾りましょう。有料プランに登録して<%= month %>の <%= currentMysterySetName %>をゲットしよう!",
"subscriptionBenefit5": "さっそく有料プランに登録して、特別なロイヤルパープルのジャッカロープのペットをゲットしよう!",
"subscriptionBenefit6": "タイムトラベラーの店でアイテムを買うために神秘の砂時計を手に入れましょう!",
"subscriptionBenefit6": "タイムトラベラーの店で使える神秘の砂時計を毎月1個ゲットして、アイテムを見逃さないようにしよう!",
"purchaseAll": "セットを購入する",
"gemsRemaining": "残り",
"notEnoughGemsToBuy": "今月はこれ以上購入できるジェムはありません。毎月最初の 3 日以内にさらに多く購入可能になります。",
@@ -263,5 +263,10 @@
"mysterySet202511": "霜の戦士セット",
"recurringNMonthly": "<%= length %>ヶ月ごとに更新されます",
"mysterySet202510": "空中に浮かぶグールセット",
"unlockNGemsGift": "受取人は毎月<strong><%= count %> 個のジェム</strong>をアンロックします"
"unlockNGemsGift": "受取人は毎月<strong><%= count %> 個のジェム</strong>をアンロックします",
"earn2GemsGift": "受取人は有料プランに登録している間、毎月<strong>+2個のジェム</strong>を獲得します",
"earn2Gems": "登録している間、毎月<strong>+2個のジェム</strong>を獲得",
"mysterySet202502": "親切なアルレッキーノセット",
"maxGemCapGift": "受取人は<strong>最大限のジェム</strong>をゲットします",
"subscribeAgainContinueHourglasses": "神秘の砂時計をゲットし続けるには、再び有料プランに登録してください"
}

View File

@@ -7,7 +7,7 @@
"commonQuestions": "Dúvidas Comuns",
"faqQuestion25": "Quais são os diferentes tipos de tarefas?",
"webFaqAnswer25": "O Habitica utiliza três tipos diferentes de tarefas para acomodar as suas necessidades: Hábitos, Diárias e Afazeres.\n\nHábitos podem ser positivos ou negativos e representam algo que você gostaria de acompanhar ao longo de vários momentos do dia ou fora de uma agenda estruturada. Hábitos Positivos garantem recompensas, como Ouro e Experiência (EXP), enquanto Hábitos Negativos causam dano a você, fazendo com que você perca pontos de vida.\n\nDiárias são tarefas que se repetem e que devem ser concluídas em uma agenda bem estruturada, como, por exemplo, uma vez ao dia, três vezes na semana ou quatro vezes em um mês. Não completar as suas Diárias causa dano a você e ao seu Grupo, fazendo com que vocês percam pontos de vida. Por outro lado, quanto maior a dificuldade das suas Diárias, melhores serão as recompensas!\n\nAfazeres são tarefas que ocorrem apenas uma vez e que garantem recompensas imediatamente após a sua conclusão. Afazeres podem ter uma data limite estipulada, mas não irão causar dano a você ou ao seu Grupo caso não sejam concluídos dentro do prazo.\n\nEscolha o tipo de tarefa que melhor se enquadra a cada um dos objetivos que você deseja atingir!",
"faqQuestion26": "Quais seriam algumas tarefas de exemplo?",
"faqQuestion26": "Quais são algumas tarefas de exemplo?",
"webFaqAnswer26": "Hábitos Positivos (Comportamentos que você quer incentivar; apresenta sinal \"de mais/adição\")\n\n * Tomar suas vitaminas\n * Passar fio dental\n * Estudar por uma hora\n\nHábitos Negativos (Comportamentos que você quer evitar parcialmente ou completamente; apresenta botão com sinal \"de menos/subtração\")\n\n * Fumar\n * Perder tempo no feed de redes sociais\n * Roer unhas\n\nHábitos Ambíguos (Hábitos que envolvem a escolha entre ações positivas e negativas; apresenta ambos os botões positivos e negativos)\n\n * Beber água x Beber Refrigerante\n * Estudar x Procrastinar\n\nExemplos de Tarefas Diárias (Tarefas que você deve fazer em determinada frequência)\n * Lavar a louça\n * Regar as plantas\n * 30 minutos de atividade física\n\nExemplos de Afazeres (Tarefas que você vai realizar uma única vez)\n\n * Agendar uma consulta médica\n * Organizar o guarda roupa\n * Terminar o TCC",
"faqQuestion27": "Por que as tarefas mudam de cor?",
"webFaqAnswer27": "A cor de uma tarefa é uma representação visual de seu valor. Todas as tarefas começam como amarelas (neutras), azuis são melhores e vermelhas são piores. Veja como cada tipo de tarefa determina seu valor:\n\nHábitos tornam-se mais azuis ou vermelhos conforme você toca no botão de mais ou menos. Hábitos positivos e negativos degradam para amarelo ao longo do tempo se você não os completar. Hábitos duplos só mudam de cor com base em suas entradas.\n\nDiárias mudam de cor com base na frequência que são concluídos, ficando mais azuis conforme são concluídos ou mais vermelhos se forem perdidos.\n\nAfazeres ficam gradualmente mais vermelhos quanto mais tempo permanecerem incompletos.\n\nQuanto mais vermelha estiver a tarefa, mais Ouro e Experiência você ganhará ao concluí-la, então certifique-se de completar até mesmo suas tarefas mais difíceis!",
@@ -19,15 +19,15 @@
"webFaqAnswer30": "Se seus Pontos de Vida chegarem a zero, você perderá um nível, todo o seu Ouro e uma peça de Equipamento que pode ser recomprada.",
"faqQuestion31": "Por que perdi Pontos de Vida ao interagir com uma tarefa não negativa?",
"webFaqAnswer31": "Se você completar uma tarefa e perder Pontos de Vida quando não deveria, você encontrou um atraso enquanto o servidor está sincronizando as alterações feitas em outras plataformas. Por exemplo, se você usar Ouro, Mana ou perder Pontos de Vida no aplicativo para celular e depois completar uma tarefa no site, o servidor está apenas confirmando que tudo está sincronizado.",
"faqQuestion32": "Como posso escolher uma classe?",
"faqQuestion32": "Como eu posso escolher uma classe?",
"webFaqAnswer32": "Todos os jogadores começam como Guerreiro até atingirem o nível 10. Depois de alcançar o nível 10, você terá a opção de escolher entre selecionar uma nova classe ou continuar como Guerreiro.\n\nCada classe possui Equipamentos e Habilidades diferentes. Se você não quiser escolher uma classe, pode selecionar \"Abster-se\". Se optar por abster-se, você sempre poderá ativar o Sistema de Classes nas Configurações mais tarde.\n\nSe você quiser trocar sua classe após o nível 10, é possível através do uso do Orbe do Renascimento. O Orbe do Renascimento é disponibilizado no Mercado por 6 gemas quando você atinge o nível 50 ou de graça quando você atinge o nível 100.\n\nComo alternativa, você pode trocar de classe a qualquer momento nas Configurações por 3 gemas. Isso não irá reiniciar seus níveis como o Orbe do Renascimento, mas você poderá realocar seus pontos de habilidades acumulados para adaptar-los a sua nova classe.",
"faqQuestion33": "O que é a barra azul que aparece após o nível 10?",
"webFaqAnswer33": "Após desbloquear o Sistema de Classes, você também desbloqueia Habilidades que requerem Mana para serem lançadas. A Mana é determinada pelo seu atributo INT e pode ser ajustada por meio de Habilidades e Equipamentos.",
"faqQuestion34": "Que tipo de Comida meu Mascote gosta?",
"faqQuestion35": "Alimentei meu Mascote e ele desapareceu! O que aconteceu?",
"webFaqAnswer35": "Depois de alimentar o seu Mascote o suficiente para transformá-lo em uma Montaria, será necessário eclodir esse tipo de Mascote novamente para tê-lo em seu Estábulo.\n\nPara visualizar Montarias no aplicativo:\n\n No Menu, selecione \"Mascotes & Montarias\" e mude para a guia de Montarias.\n\nPara visualizar Montarias no site:\n\n * No menu Inventário, selecione \"Estábulo\" e role para baixo até a seção de Montarias",
"webFaqAnswer35": "Depois de alimentar o seu Mascote o suficiente para transformá-lo em uma Montaria, será necessário eclodir esse tipo de Mascote novamente para tê-lo em seu Estábulo.\n\nPara visualizar Montarias no aplicativo:\n\n No Menu, selecione \"Mascotes E Montarias\" e mude para a guia de Montarias.\n\nPara visualizar Montarias no site:\n\n * No menu Inventário, selecione \"Estábulo\" e role para baixo até a seção de Montarias",
"faqQuestion36": "Como eu mudo a aparência do meu Avatar?",
"webFaqAnswer36": "Existem inúmeras maneiras de personalizar a aparência do seu Avatar no Habitica! Você pode alterar a forma do corpo, o estilo e a cor do cabelo, a cor da pele, adicionar óculos ou auxíliadores de mobilidade, selecionando “Personalizar” (ou \"Editar\") Avatar no menu.\n\nPara personalizar o seu Avatar no aplicativo:\n *No menu, selecione “Personalizar Avatar”\n\nPara personalizar o seu Avatar no site:\n *No menu de usuário na barra de navegação, selecione “Editar Avatar”",
"webFaqAnswer36": "Existem inúmeras maneiras de personalizar a aparência do seu Avatar no Habitica! Você pode alterar a forma do corpo, o estilo e a cor do cabelo, a cor da pele ou adicionar óculos, ou auxiliadores de mobilidade, selecionando “Personalizar” (ou \"Editar\") Avatar no menu.\n\nPara personalizar o seu Avatar no aplicativo:\n *No menu, selecione “Personalizar Avatar”\n\nPara personalizar o seu Avatar no site:\n *No menu de usuário na barra de navegação, selecione “Editar Avatar”",
"faqQuestion37": "Por que meu Equipamento não está sendo exibido no meu Avatar?",
"faqQuestion38": "Por que não consigo comprar determinados itens?",
"webFaqAnswer37": "Verifique se a opção de Fantasia está ativada. Se o seu Avatar estiver usando uma Fantasia, esse Conjunto de Equipamentos será exibido em vez do seu Equipamento de Batalha.\n\nPara ativar ou desativar a Fantasia no aplicativo:\n * No menu, selecione “Equipamento” para encontrar a opção de Fantasia\n\nPara ativar ou desativar a Fantasia no site:\n * No seu Inventário, selecione “Equipamento” e localize a opção de Fantasia na guia de Fantasias do menu de Equipamentos",
@@ -54,7 +54,7 @@
"faqQuestion48": "Posso jogar Habitica com outras pessoas?",
"webFaqAnswer48": "Sim, com Grupos! Você pode iniciar seu próprio Grupo ou ingressar em um existente. Fazer parte de um Grupo com outros jogadores do Habitica é uma ótima maneira de embarcar em Missões, receber benefícios das habilidades dos membros do Grupo e aumentar sua motivação com responsabilidade adicional.",
"faqQuestion49": "Como encontro um Grupo quando não estou em nenhum?",
"webFaqAnswer49": "Se você deseja vivenciar o Habitica com outras pessoas, mas não conhece outros jogadores, procurar por um Grupo é a melhor opção! Se você já conhece outros jogadores que têm um Grupo, pode compartilhar seu nome de usuário com eles para ser convidado. Alternativamente, você pode criar um novo Grupo e convidá-los com seu nome de usuário ou endereço de e-mail.\n\nPara criar ou procurar um Grupo, selecione \"Grupo\" no menu de navegação e escolha a opção que melhor funciona para você.",
"webFaqAnswer49": "Se você deseja vivenciar o Habitica com outras pessoas, mas não conhece outros jogadores, procurar por um Grupo é a melhor opção! Se você já conhece outros jogadores que têm um Grupo, pode compartilhar seu nome de usuário com eles para ser convidado. Alternativamente, você pode criar um novo Grupo e convidá-los com seu nome de usuário ou endereço de e-mail.\n\nPara criar ou procurar um Grupo, selecione \"Grupo\" no menu de navegação e escolha a opção que funciona para você.",
"faqQuestion50": "Como funciona a busca por um Grupo?",
"webFaqAnswer50": "Após selecionar “Procurar um Grupo”, você será adicionado a uma lista de jogadores que desejam entrar em um Grupo. Os líderes do Grupo podem visualizar essa lista e enviar convites. Assim que receber um convite, você pode aceitá-lo nas suas notificações para ingressar no Grupo de sua escolha!\n\nVocê pode receber vários convites para diferentes Grupos. No entanto, você só pode ser membro de um Grupo por vez.",
"faqQuestion51": "Por quanto tempo posso procurar por um Grupo após entrar na lista?",
@@ -117,7 +117,7 @@
"faqQuestion65": "Os Planos de Time são suportados nos aplicativos móveis?",
"faqQuestion66": "Qual é a diferença entre as tarefas compartilhadas de um Plano de Time e as tarefas de Desafio?",
"sunsetFaqTitle": "FAQ sobre a Descontinuação do Serviço de Taverna e Guildas do Habitica",
"webFaqAnswer60": "Aqui estão algumas dicas para começar com seu novo Plano de Grupo Habitica:\n\n * Promova um membro a gerente para dar a ele a habilidade de criar e editar tarefas\n * Deixe tarefas não-designadas se qualquer um puder completá-la e ela só precisa ser feita uma vez\n * Designe uma tarefa a uma pessoa para ter certeza que ninguém mais completará a tarefa dela\n * Designe uma tarefa para múltiplas pessoas se todos eles precisarem completá-la *Ative a habilidade que mostra tarefas compartilhas no seu mural pessoal para não esquecer de nada\n * Você é recompensado pelas tarefas que você completa, mesmo as multi-designadas\n * Recompensas por completar tarefas não são divididas entre os membros.\n * Use as cores de tarefas no mural do time para avaliar a frequência média de tarefas concluídas.\n * Regularmente revise as tarefas no mural de tarefas compartilhado para ter certeza que elas continuam relevantes.\n * Deixar de fazer uma Diária não dará dano a você ou ao seu time, mas a cor da missão vai decair",
"webFaqAnswer60": "Aqui estão algumas dicas para começar com seu novo Plano de Grupo Habitica:\n\n * Promova um membro a gerente para dar a ele a habilidade de criar e editar tarefas\n * Deixe tarefas não-designadas se qualquer um puder completá-la e ela só precisa ser feita uma vez\n * Designe uma tarefa a uma pessoa para ter certeza que ninguém mais completará a tarefa dela\n * Designe uma tarefa para múltiplas pessoas se todos eles precisarem completá-la \n*Ative a habilidade que mostra tarefas compartilhas no seu mural pessoal para não esquecer de nada\n * Você é recompensado pelas tarefas que você completa, mesmo as multi-designadas\n * Recompensas por completar tarefas não são divididas entre os membros.\n * Use as cores de tarefas no mural do time para avaliar a frequência média de tarefas concluídas.\n * Regularmente revise as tarefas no mural de tarefas compartilhado para ter certeza que elas continuam relevantes.\n * Deixar de fazer uma Diária não dará dano a você ou ao seu time, mas a cor da missão vai decair",
"webFaqAnswer65": "Enquanto os aplicativos móveis ainda não suportam totalmente a funcionalidade de Plano de Time, até lá você pode completar as tarefas compartilhadas dos aplicativos de iOS e Android!\n\nNo Android, você pode tocar no seu nome mostrado no topo da tela enquanto estiver vendo suas tarefas para mudar para o quadro de tarefas compartilhadas. A partir daí, você pode ver os membros, acessar o bate-papo, e criar, completar, ou atribuir tarefas.\n\nVocê também pode ativar a preferência para copiar tarefas compartilhadas para seu quadro de tarefas pessoal, para que assim você possa completar todas suas tarefas de um só lugar.\n\nPara fazer isso em aplicativos móveis:\n *Abra as Configurações e ative \"Copie as tarefas compartilhadas\"\n\nPara fazer isso no website do Habitica:\n *Navegue até o seu Plano de TIme e ative o botão de \"Copiar tarefas\" no quadro de afazeres compartilhados",
"webFaqAnswer64": "Tarefas compartilhadas irão reiniciar ao mesmo tempo para todos para manter o quadro de tarefas compartilhadas em sincronia. Esse tempo pode ser visto no quadro de tarefas compartilhadas, e é determinado pela hora de início do dia do líder do Plano de Time. Pelo fato de que as tarefas compartilhadas reiniciam automaticamente, você não terá a chance de completar as diárias compartilhadas incompletas do dia anterior quando conferir na manhã seguinte.\n\nDiárias compartilhadas não causarão dano se não forem completadas, no entanto, sua cor degradará para ajudar na visualização do progresso.",
"webFaqAnswer66": "O quadro de afazeres compartilhados do Plano de Time são mais dinâmicos que os Desafios, pois eles podem ser atualizados constantemente, além de serem interativos. Desafios são ótimos se você tem um conjunto de tarefas para ser mandado para várias pessoas.\n\nOs Planos de Time são uma funcionalidade paga, enquanto os Desafios são disponibilizados de graça para todos.\n\nVocê não pode atribuir tarefas específicas nos Desafios, e os Desafios não possuem um dia compartilhado para reinício. No geral, Desafios oferecem menos controle e interação direta.",
@@ -149,7 +149,7 @@
"contentQuestion1": "Por que o Habitica está fazendo essas mudanças?",
"contentAnswer12": "Os jogadores terão mais facilidade para completar suas coleções com os itens sendo lançados em um cronograma mais previsível.",
"contentQuestion2": "De que forma as Festas de Gala estão mudando?",
"contentAnswer03": "Fundos, Cores e Estilos de Cabelo, Peles, Acessórios Animais e Camisas agora poderão ser adquiridos na nova <strong>Loja de Customização!</strong>",
"contentAnswer03": "Planos de fundo, Cores e Estilos de Cabelo, Peles, Acessórios Animais e Camisas agora poderão ser adquiridos na nova <strong>Loja de Customização!</strong>",
"contentAnswer20": "Sempre haverá Festas de Gala ativas quando as mudanças de programação começarem.",
"contentAnswer200": "<strong>Explosão de Verão</strong>: 21 de junho a 20 de setembro",
"contentAnswer201": "<strong>Festival de Outono</strong>: 21 de setembro a 20 de dezembro",
@@ -164,7 +164,7 @@
"contentAnswer22": "As Poções Mágicas de Eclosão não estarão mais associadas às Galas e, em vez disso, seguirão um cronograma de lançamento mensal próprio, com tema nas festividades em andamento.",
"contentAnswer30": "As lojas irão alternar uma seleção de seus itens a cada mês. Isso contribuirá para que a quantidade de conteúdo nas lojas seja mais organizada e simples de explorar. O novo cronograma oferecerá itens inéditos para os jogadores novos, enquanto cria um cronograma previsível para os colecionadores veteranos.",
"subscriptionDetail47": "Eu tenho uma assinatura de Plano de time, como isso me afeta?",
"contentAnswer303": "<strong>No dia 21 de cada mês:</strong> As Poções de Incubação Mágicas disponíveis na Loja são trocadas.",
"contentAnswer303": "<strong>No dia 21 de cada mês:</strong> As Poções de Incubação Mágicas disponíveis na Loja são alteradas.",
"contentQuestion4": "Que conteúdo novo está chegando?",
"contentAnswer400": "Missões de Companheiros",
"contentAnswer403": "Cores de cabelos do verão",
@@ -179,8 +179,8 @@
"contentAnswer62": "As Poções Mágicas de Incubação do Dia dos Namorados agora estão incluídas na programação mensal.",
"contentQuestion7": "E quanto aos outros itens disponíveis na Loja dos Viajantes do Tempo além dos Conjuntos de Assinantes anteriores?",
"contentAnswer71": "Fique ligado para mais atualizações sobre melhorias planejadas para a experiência da Loja dos Viajantes do Tempo.",
"faqQuestion67": "Quais são as classes no Habitica?",
"contentAnswer40": "Para preencher este novo cronograma, temos trabalhado duro criando novos itens em uma variedade de categorias, incluindo:",
"faqQuestion67": "O que são as classes no Habitica?",
"contentAnswer40": "Para preencher essa nova programação, temos trabalhado arduamente criando novos itens em uma variedade de categorias, incluindo:",
"contentAnswer401": "Missões de Poções Mágicas de Incubação",
"contentAnswer402": "Poções Mágicas de Incubação",
"contentAnswer52": "Esperamos que essa mudança ajude os jogadores a classificar as personalizações que possuem ao editar a aparência de seus avatares, ao mesmo tempo em que mantêm a experiência familiar da loja para outros itens compráveis.",
@@ -189,7 +189,7 @@
"contentAnswer70": "Papéis de Fundo, missões, mascotes e montarias disponíveis na Loja dos Viajantes do Tempo permanecerão disponíveis o ano todo.",
"subscriptionBenefitsAdjustments": "Ajustes de Benefícios para Assinantes",
"subscriptionBenefitsFaqTitle": "Perguntas frequentes sobre ajustes de benefícios para assinantes",
"contentAnswer302": "<strong>No dia 14 de cada mês:</strong> Missões de Companheiros, Missões de Poções e Pacotes de Missões disponiveis na Loja de Missões são trocadas.",
"contentAnswer302": "<strong>No dia 14 de cada mês:</strong> Missões de Mascotes, Missões de Poções e Pacotes de Missões disponíveis na Loja de Missões são alterados.",
"contentFaqPara3": "Caso tenha alguma dúvida que não esteja nas respostas acima, entre em contato com nossa equipe pelo e-mail <%= mailto %>! Estamos animados com este novo cronograma de lançamento de conteúdo e ansiosos por mais projetos no futuro para ajudar a tornar o Habitica melhor para todos os jogadores.",
"webFaqAnswer67": "Classes são diferentes papéis que seu personagem pode desempenhar. Cada classe oferece seu próprio conjunto de benefícios e habilidades únicas conforme você sobe de nível. Essas habilidades podem complementar a forma como você interage com suas tarefas ou ajudar a contribuir para completar missões em seu grupo.\n\nSua classe também determina o equipamento que estará disponível para você comprar nas suas recompensas, no mercado e na loja sazonal.\n\nAqui está um resumo de cada classe para ajudar você a escolher qual delas se encaixa melhor no seu estilo de jogo:\n#### **Guerreiro**\n*Guerreiros causam alto dano a chefes e têm uma grande chance de acertos críticos ao completar tarefas, recompensando você com experiência e ouro extra.\n*Força é seu atributo principal, aumentando o dano causado.\n*Constituição é seu atributo secundário, reduzindo o dano recebido.\n*As habilidades dos Guerreiros aumentam a constituição e a força dos colegas de grupo.\n*Considere jogar como Guerreiro se você gosta de enfrentar chefes, mas também quer alguma proteção caso perca tarefas de vez em quando.\n#### **Curandeiro**\nCurandeiros têm alta defesa e podem curar a si mesmos e aos colegas de grupo.\n*Constituição é seu atributo principal, aumentando suas curas e reduzindo o dano recebido.\n*Inteligência é seu atributo secundário, aumentando sua mana e experiência.\n*As habilidades dos Curandeiros tornam suas tarefas menos vermelhas e aumentam a constituição dos colegas de grupo.\n*Considere jogar como Curandeiro se você costuma perder tarefas e precisa da habilidade de se curar ou curar membros do grupo. Curandeiros também sobem de nível rapidamente.\n#### **Mago**\n*Magos sobem de nível rapidamente, ganham muita mana e causam dano a chefes em missões.\n*Inteligência é seu atributo principal, aumentando sua mana e experiência.\n*Percepção é seu atributo secundário, aumentando seu ouro e a chance de encontrar itens.\n*As habilidades dos Magos congelam sequências de tarefas, restauram a mana dos colegas de grupo e aumentam sua Inteligência.\n*Considere jogar como Mago se você se motiva ao progredir rapidamente pelos níveis e contribuir com dano em missões contra chefes.\n#### **Ladino**\n*Ladinos obtêm mais itens e ouro ao completar tarefas, e têm grande chance de acertos críticos, ganhando ainda mais experiência e ouro.\n*Percepção é seu atributo principal, aumentando seu ouro e a chance de encontrar itens.\n*Força é seu atributo secundário, aumentando o dano causado.\n*As habilidades dos Ladinos ajudam a evitar consequências por tarefas diárias não cumpridas, roubam ouro e aumentam a percepção dos colegas de grupo.\n*Considere jogar como Ladino se você se motiva muito por recompensas.",
"subscriptionDetail00": "Todos os assinantes, incluindo aqueles com assinaturas de presente, receberão 1 Ampulheta Mística no início de cada mês em que tiverem os benefícios de assinante.",
@@ -208,5 +208,40 @@
"subscriptionDetail012": "Este bônus não se aplica a assinaturas presenteadas.",
"subscriptionHeading1": "Mudanças nas Gemas dos Assinantes",
"subscriptionDetail102": "As novas assinaturas de 12 meses começarão imediatamente com a quantidade máxima de Gemas por mês, 50 Gemas em vez das 45 anteriores.",
"subscriptionDetail11": "A quantidade de Gemas que você pode comprar mensalmente por Ouro não será mais zerada se sua assinatura acabar."
"subscriptionDetail11": "A quantidade de Gemas que você pode comprar mensalmente por Ouro não será mais zerada se sua assinatura acabar.",
"subscriptionHeading2": "Por que estamos fazendo estas mudanças?",
"subscriptionDetail20": "Com a estrutura atual, pode ser difícil entender quantas Ampulhetas Místicas você receberá e quando.",
"subscriptionDetail22": "Assinaturas presenteadas e recorrentes apresentavam alguns conflitos em relação a benefícios e regras que queríamos simplificar.",
"subscriptionDetail23": "Fornecer uma Ampulheta Mística por mês permite que os assinantes aproveitem os itens rotativos na Loja dos Viajantes do Tempo.",
"subscriptionDetail30": "Jogadores com assinaturas recorrentes de 1 mês ou assinaturas de Plano de Grupo receberão 2 Ampulhetas Místicas e 20 Gemas.",
"subscriptionDetail31": "Jogadores com assinaturas recorrentes de 3 ou 6 meses receberão 4 Ampulhetas Místicas e 20 Gemas.",
"subscriptionDetail45": "Comprar assinaturas extras de presente me dará mais Ampulhetas Místicas ou um limite maior de Gemas mais rápido?",
"subscriptionDetail451": "Cada assinatura presenteada aumentará a quantidade de meses em que o jogador terá benefícios de assinatura, permitindo que ele continue recebendo mais Ampulhetas Místicas e aumentos em seu limite de Gemas a cada mês que passa.",
"subscriptionDetail110": "Se você aumentar a quantidade de Gemas que pode comprar a cada mês e cancelar sua assinatura, poderá recuperá-la na mesma quantidade a qualquer momento no futuro, mesmo se comprar um nível de assinatura menor.",
"subscriptionDetail21": "Os quatro níveis de assinatura eram conhecidos por causar complicações ao fazer upgrade ou downgrade para níveis diferentes.",
"subscriptionDetail33": "Para receber essas recompensas, sua conta deve ter uma assinatura recorrente ativa antes de 19 de novembro.",
"subscriptionDetail4400": "Se você atualmente desbloqueou <%= initialNumber %> Gemas por mês, você será definido como <%= roundedNumber %>.",
"subscriptionDetail450": "Como as Ampulhetas Místicas e o aumento do limite de Gemas agora são benefícios mensais, comprar várias assinaturas de presente não dará mais benefícios de uma só vez.",
"subscriptionDetail24": "Queríamos que os assinantes tivessem mais de quatro chances por ano para coletar itens da Loja dos Viajantes do Tempo.",
"subscriptionDetail25": "Entendemos que as finanças mudam e não queríamos punir os assinantes, retirando benefícios que eles haviam conquistado, por assinaturas canceladas.",
"subscriptionHeading3": "Recompensas do dia de Lançamento",
"subscriptionPara1": "Para facilitar a transição para o novo formato, os assinantes atuais podem esperar alguns brindes extras no dia do lançamento. Gostaríamos de agradecer sinceramente pelo seu apoio contínuo durante essa mudança!",
"subscriptionDetail32": "Jogadores com assinaturas recorrentes de 12 meses receberão o bônus de 12 Ampulhetas Místicas mencionado acima e 20 Gemas.",
"subscriptionDetail40": "Sou assinante. Quando receberei minha primeira Ampulheta Mística e capacidade de Gemas no novo formato?",
"subscriptionDetail400": "Assinantes atuais receberão sua primeira Ampulheta Mística e +2 Gemas adicionadas a capacidade mensal no primeiro login do mês seguinte ao lançamento. Isso significa que, se você já tiver feito login em novembro, seu primeiro aumento regular ocorrerá em dezembro.",
"subscriptionDetail41": "O preço das assinaturas mudará no lançamento?",
"subscriptionDetail410": "Essas mudanças não afetarão o valor das assinaturas.",
"subscriptionDetail42": "Se eu não fizer login por um mês enquanto for assinante, perderei esses benefícios?",
"subscriptionDetail420": "Assim como nos Conjuntos de Equipamentos Misteriosos, você não perderá nenhum aumento de Ampulhetas Místicas ou de limite de Gemas se não fizer login enquanto estiver inscrito. Na próxima vez que fizer login, você receberá todos os benefícios devidos para cada mês em que esteve inscrito.",
"subscriptionDetail43": "Se eu assinar uma assinatura recorrente e depois cancelar, ainda receberei benefícios?",
"subscriptionDetail430": "O cancelamento de uma assinatura recorrente definirá uma data de término para seus benefícios, mas você ainda terá acesso total a todas as vantagens da assinatura antes dessa data. Isso significa que você ainda receberá Ampulhetas Místicas mensais e aumentos no limite de Gemas no início de cada mês em que tiver acesso a esses benefícios.",
"subscriptionDetail44": "Já sou assinante. Quantas Gemas terei disponíveis no Mercado a cada mês após a mudança?",
"subscriptionDetail440": "No dia em que essas mudanças entrarem em vigor, os assinantes atuais com um número ímpar de Gemas por mês verão estes ajustes em seu limite de Gemas:",
"subscriptionDetail46": "Se eu já tive uma assinatura, posso desbloquear meu antigo limite de gemas se eu assinar novamente agora?",
"subscriptionDetail460": "Como costumávamos redefinir a quantidade de Gemas que você podia comprar a cada mês quando seus benefícios acabavam, jogadores com benefícios de assinatura expirados terão que começar do zero com este novo sistema.",
"subscriptionDetail470": "Os benefícios para assinantes do Plano de Grupo serão os mesmos de uma assinatura recorrente de 1 mês. Você receberá uma Ampulheta Mística no início de cada mês e a quantidade de Gemas que você pode comprar mensalmente no Mercado aumentará em 2 até atingir 50.",
"subscriptionDetail48": "Haverá alguma mudança em outros benefícios da assinatura, como os Conjuntos de Equipamento Misteriosos?",
"subscriptionDetail480": "Essas mudanças afetam apenas Ampulhetas Místicas e Gemas de assinantes. Todos os outros benefícios permanecerão os mesmos.",
"subscriptionPara2": "Caso tenha alguma dúvida que não esteja nas respostas acima, você pode entrar em contato com nossa equipe pelo e-mail <%= mailto %>.",
"subscriptionPara3": "Esperamos que esse novo cronograma seja mais previsível, permita mais acesso ao incrível estoque de itens na Loja dos Viajantes do Tempo e dê ainda mais motivação para progredir em suas tarefas a cada mês!"
}

View File

@@ -22,7 +22,7 @@
"guidanceForBlacksmiths": "Diretrizes para Ferreiros",
"history": "História",
"invalidEmail": "Um endereço de e-mail válido é necessário para redefinir sua senha.",
"login": "Login",
"login": "Iniciar sessão",
"logout": "Desconectar",
"marketing1Header": "Melhore seus hábitos um nível por vez!",
"marketing1Lead1Title": "Gamifique sua vida",
@@ -32,24 +32,24 @@
"marketing1Lead3Title": "Seja recompensado pelo seu esforço",
"marketing1Lead3": "Ter algo pelo que esperar pode ser a diferença entre concluir uma tarefa ou tê-la provocando você por semanas. Quando a vida não oferece uma recompensa, o Habitica cuida disso! Você será recompensado por cada tarefa, mas surpresas o aguardam a cada esquina — então continue progredindo! ",
"marketing2Header": "Junte-se aos amigos",
"marketing2Lead1Title": "Produtividade Social",
"marketing2Lead1": "Enquanto você pode jogar Habitica sozinho, as coisas ficam realmente interessantes quando você começa a colaborar, competir e ajudar uns aos outros. A parte mais efetiva de qualquer programa de auto-aperfeiçoamento é a cobrança social, e qual o melhor ambiente para responsabilidade e competição do que um jogo?",
"marketing2Lead2Title": "Lute Contra Monstros",
"marketing2Lead2": "O que é um RPG sem batalhas? Enfrente monstros junto com seu grupo. Chefões são um \"modo de super cobrança\", um dia que você falta à academia é um dia que o chefão machuca *todo mundo!*",
"marketing2Lead1Title": "Produtividade social",
"marketing2Lead1": "Aumente sua motivação colaborando, competindo e interagindo com outras pessoas! O Habitica foi criado para aproveitar a parte mais eficaz de qualquer programa de autoaperfeiçoamento: a responsabilidade social.",
"marketing2Lead2Title": "Derrote monstros nas missões",
"marketing2Lead2": "Participe de uma das nossas centenas de missões com um Grupo de amigos para entrar na briga. Os monstros das missões levam sua responsabilidade ao limite. Esquecer de usar fio dental significa dano a todos!",
"marketing2Lead3Title": "Desafiem-se",
"marketing2Lead3": "Desafios lhe permite competir com amigos e desconhecidos. Quem se sair melhor ao final do desafio ganha prêmios especiais.",
"marketing3Header": "Apps e Extensões",
"marketing3Lead1": "Os aplicativos para **iPhone e Android** permitem que você cuide de suas tarefas em qualquer lugar. Nós sabemos que conectar no website para clicar em botões pode ser chato.",
"marketing3Lead2Title": "Integrações",
"marketing3Lead2": "Outras **Ferramentas de Terceiros** conectam o Habitica a vários outros aspectos da sua vida. Nossa API fornece uma integração fácil a ferramentas como a [Extensão do Chrome](https://chrome.google.com/webstore/detail/habitica/pidkmpibnnnhneohdgjclfdjpijggmjj?hl=en-US), pela qual você perde pontos ao navegar em sites improdutivos e ganha pontos nos sites produtivos. [Veja mais informações aqui](https://habitica.fandom.com/pt-br/wiki/Extens%C3%B5es,_Add-ons_e_Personaliza%C3%A7%C3%B5es).",
"marketing4Header": "Uso Organizacional",
"marketing4Lead1": "Educação é um dos melhores setores para gamificação. Todos nós sabemos o quanto estudantes estão grudados no telefone e em jogos hoje em dia - aproveite esse poder! Coloque seus estudantes uns contra os outros em uma competição amigável. Recompense bons comportamentos com prêmios raros. Veja suas notas e comportamentos melhorarem.",
"marketing4Lead1Title": "Gamificação na Educação",
"marketing4Lead2": "Os custos de assistência médica estão subindo, e alguém tem que ceder. Centenas de programas são feitos para reduzir custos e melhorar o bem-estar. Acreditamos que o Habitica pode construir um caminho muito importante em direção a estilos de vida saudáveis.",
"marketing4Lead2Title": "Gamificação em Saúde e Bem-estar",
"marketing4Lead3-1": "Quer gamificar sua vida?",
"marketing2Lead3": "Participe dos desafios criados pela nossa comunidade para obter listas de tarefas personalizadas que se adaptam aos seus interesses e objetivos. Dê o seu melhor para concorrer ao prêmio de Gemas concedido ao vencedor!",
"marketing3Header": "Outras maneiras de usar o Habitica",
"marketing3Lead1": "Você pode instalar o Habitica no seu dispositivo Android ou iOS para concluir tarefas em qualquer lugar. Confira nossos aplicativos premiados para uma abordagem inovadora para realizar tarefas.",
"marketing3Lead2Title": "Comunidade de Código Aberto",
"marketing3Lead2": "Temos orgulho de ser um projeto de código aberto que aceita contribuições da nossa comunidade dedicada. Adapte o Habitica às suas necessidades ou contribua para melhorar a experiência de todos os jogadores ao redor do mundo. Visite-nos no [GitHub](https://github.com/HabitRPG/habitica/wiki/Contributing-to-Habitica) para saber mais!",
"marketing4Header": "Além das tarefas domésticas",
"marketing4Lead1": "A educação é um dos melhores lugares para um pouco de gamificação! Quebre a monotonia das aulas cotidianas adicionando um pouco de jogos. O Habitica pode ser uma maneira divertida de acompanhar as tarefas de casa, criar desafios em sala de aula e permitir que seus alunos exibam suas conquistas.",
"marketing4Lead1Title": "Gamificação na educação",
"marketing4Lead2": "Construir um estilo de vida mais saudável pode facilmente se tornar uma tarefa árdua. O Habitica ajuda você a monitorar todos os aspectos dos seus objetivos de condicionamento físico com horários flexíveis e intensidade que se adaptam a você onde você estiver. Então, divirta-se enquanto busca uma saúde melhor!",
"marketing4Lead2Title": "Gamificação em saúde e bem-estar",
"marketing4Lead3-1": "Pronto para se divertir realizando tarefas?",
"marketing4Lead3-2": "Interessado em coordenar um grupo em educação, bem-estar e outros?",
"marketing4Lead3Title": "Gamifique Tudo",
"marketing4Lead3Title": "Comece sua jornada!",
"mobileAndroid": "Aplicativo Android",
"mobileIOS": "Aplicativo iOS",
"oldNews": "Notícias",
@@ -86,14 +86,14 @@
"sync": "Sincronizar",
"tasks": "Tarefas",
"teams": "Times",
"terms": "Termos e Condições",
"terms": "Termos de Serviço",
"tumblr": "Tumblr",
"localStorageTryFirst": "Se você estiver tendo problemas com o Habitica, clique no botão abaixo para limpar o armazenamento local e a maioria dos cookies deste website (outros websites não serão afetados). Você precisará entrar na sua conta novamente depois de fazer isso, então fique à vontade para saber dos detalhes de log-in, que podem ser encontrados em Configurações-> <%= linkStart %>Site<%= linkEnd %>.",
"localStorageTryNext": "Se os problemas persistirem, por favor <%= linkStart %>Relate um Bug<%= linkEnd %> se ainda não o fez.",
"localStorageClear": "Limpar Dados",
"localStorageClearExplanation": "Este botão irá limpar o armazenamento local e a maioria dos cookies, e irá desconecta-lo.",
"username": "Nome de Usuário",
"emailOrUsername": "E-mail ou nome de usuário (diferencia minúsculas de maiúsculas)",
"emailOrUsername": "Nome de usuário ou E-mail (diferencia minúsculas de maiúsculas)",
"work": "Trabalho",
"reportAccountProblems": "Reportar Problemas na Conta",
"reportCommunityIssues": "Reportar Problemas com a Comunidade",
@@ -101,7 +101,7 @@
"generalQuestionsSite": "Perguntas Gerais sobre o Site",
"businessInquiries": "Consultas de Negócios e Marketing",
"merchandiseInquiries": "Consultas sobre Mercadorias Físicas (Camisetas, Adesivos)",
"tweet": "Tweet",
"tweet": "Tuíte",
"checkOutMobileApps": "Confira nossos aplicativos móveis!",
"missingAuthHeaders": "Faltando cabeçalhos de autenticação.",
"missingUsernameEmail": "Faltando nome de usuário ou e-mail.",
@@ -122,7 +122,7 @@
"passwordConfirmationMatch": "A confirmação de senha não corresponde à senha.",
"passwordResetPage": "Mudar a Senha",
"passwordReset": "Se nós tivermos seu e-mail ou nome de usuário nos nossos arquivos, as instruções para mudar sua senha já foram mandadas para o seu e-mail.",
"invalidLoginCredentialsLong": "Oh não - seu endereço de e-mail / nome de usuário ou senha está incorreto.\n- Certifique-se de que foram digitados corretamente. Seu nome de usuário e senha diferenciam minusculas de maiúsculas.\n- Você pode ter se cadastrado com o Facebook ou Google, não com o e-mail. Cheque tentando fazer login com estas opções.\n- Se você esqueceu sua senha, clique em \"Esqueci a Senha\".",
"invalidLoginCredentialsLong": "Seu e-mail, nome de usuário ou senha estão incorretos. Tente novamente ou selecione \"Esqueceu sua senha?\"",
"invalidCredentials": "Não há uma conta associada a esses dados.",
"accountSuspended": "Essa conta, (ID de Usuário: \"<%= userId %>\") foi bloqueada por violar as Diretrizes da Comunidade (https://habitica.com/static/community-guidelines) ou os Termos de Serviço (https://habitica.com/static/terms). Para detalhes ou solicitar o desbloqueio, por favor, entre em contato com nosso Administrador de Comunidade através do e-mail <%= communityManagerEmail %> ou peça para seu pais ou tutores para enviar o e-mail. Por gentileza, não se esqueça de colocar no conteúdo do e-mail o seu @NomeDeUsuário.",
"accountSuspendedTitle": "Conta suspensa",
@@ -132,12 +132,12 @@
"invalidReqParams": "Parâmetros de requerimento inválidos.",
"memberIdRequired": "\"member\" precisa ser um UUID válido.",
"heroIdRequired": "\"heroId\" precisa ser um UUID válido.",
"cannotFulfillReq": "Sua solicitação não pode ser cumprida. Mande um e-mail para admin@habitica.com se esse erro persistir.",
"cannotFulfillReq": "Insira um endereço de e-mail válido. Envie um e-mail para admin@habitica.com se o erro persistir.",
"modelNotFound": "Este modelo não existe.",
"signUpWithSocial": "Cadastre-se com <%= social %>",
"signUpWithSocial": "Continue com <%= social %>",
"loginWithSocial": "Entre com <%= social %>",
"confirmPassword": "Confirmar Senha",
"usernameLimitations": "O nome de usuário deve conter entre 1 e 20 caracteres; dentre eles, apenas letras de A a Z, números de 0 a 9, hifens ou underlines, não podendo ser incluso quaisquer termos inapropriados.",
"usernameLimitations": "Os nomes de usuário podem ser alterados a qualquer momento. Eles devem ter de 1 a 20 caracteres, contendo apenas letras de A a Z, números de 0 a 9, hífens ou sublinhados.",
"usernamePlaceholder": "Ex: HabitIcante",
"emailPlaceholder": "Ex: habiticante@exemplo.com",
"passwordPlaceholder": "Ex: ******************",
@@ -179,5 +179,13 @@
"socialAlreadyExists": "Este login já está vinculado a uma conta existente do Habitica.",
"footerProduct": "Produto",
"translateHabitica": "Traduza o Habitica",
"incorrectResetPhrase": "Por favor, escreva <%= magicWord %> em letras maiúsculas para redefinir sua conta."
"incorrectResetPhrase": "Por favor, escreva <%= magicWord %> em letras maiúsculas para redefinir sua conta.",
"minPasswordLengthLogin": "Sua senha possui pelo menos 8 caracteres.",
"enterValidEmail": "Por favor, insira um endereço de email válido.",
"emailBlockedRegistration": "Este E-mail está bloqueado para cadastro. Se você acha que isso é um engano, entre em contato conosco pelo admin@habitica.com.",
"whatToCallYou": "Como devemos te chamar?",
"acceptPrivacyTOS": "Você confirma que tem pelo menos 18 anos de idade e que leu e concorda com nossos <a href='/static/terms' target='_blank'>Termos de Serviço</a> e <a href='/static/privacy' target='_blank'>Política de Privacidade</a>",
"marketing3Lead1Title": "Aplicativos Android e iOS",
"marketing4Lead3Button": "Comece hoje mesmo",
"missingClientHeader": "Cabeçalhos x-client ausentes."
}

View File

@@ -3084,7 +3084,7 @@
"armorArmoireDragonKnightsArmorNotes": "Canalize a força e o poder de um dragão com esta armadura feita de prata e escamas desprendidas. Aumenta a Força em <%= str %>. Armário Encantado: Conjunto do Cavaleiro-Dragão (Item 2 de 3)",
"armorArmoireDragonKnightsArmorText": "Armadura do Cavaleiro-Dragão",
"weaponArmoireDragonKnightsLanceText": "Lança do Cavaleiro-Dragão",
"weaponArmoireDragonKnightsLanceNotes": "Esta lança vermelha e prateada derrubou muitos oponentes de suas montarias. Aumenta a Constituição em <%= con %>. Armário Encantado: Conjunto do Cavaleiro-Dragão (Item 3 de 3)",
"weaponArmoireDragonKnightsLanceNotes": "Esta lança vermelha e prateada derrubou muitos oponentes de suas montarias. Aumenta a Constituição em <%= con %>. Armário Encantado: Conjunto do Cavaleiro-Dragão (Item 3 de 3).",
"headArmoireDragonKnightsHelmText": "Elmo do Cavaleiro-Dragão",
"armorMystery202502Text": "Traje de Amável Arlequim",
"headMystery202502Text": "Chapéu de Amável Arlequim",
@@ -3101,7 +3101,7 @@
"weaponSpecialSpring2025RogueNotes": "Com um golpe, você pode destruir qualquer obstáculo no caminho das suas metas. Aumenta Força em <%= str %>. Equipamento de Edição Limitada da primavera de 2025.",
"weaponSpecialSpring2025MageText": "Cajado de Louva-a-deus",
"armorSpecialWinter2025MageText": "Capa da Aurora",
"weaponArmoireSpookyCandyBucketNotes": "Com uma fantasia dessas, você vai pegar muito doce! Sorte que você tem esse balde infinito para guardar todos eles. Tente não belisca-los até chegar em casa. Aumenta Inteligência em <%=int%>.Armário Encantado Conjunto Noite dos Sustos (Item 2 de 2)",
"weaponArmoireSpookyCandyBucketNotes": "Com uma fantasia dessas, você vai pegar muito doce! Sorte que você tem esse balde infinito para guardar todos eles. Tente não belisca-los até chegar em casa. Aumenta Inteligência em <%=int%>.Armário Encantado Conjunto Noite dos Sustos (Item 2 de 2).",
"armorSpecialWinter2025RogueText": "Fantasia de Neve",
"armorSpecialSummer2024MageText": "Cauda de Anêmona",
"weaponArmoireSpookyCandyBucketText": "Balde de Doces Assustador",
@@ -3119,5 +3119,44 @@
"weaponSpecialSpring2025RogueText": "Mangual de Ponta de Cristal",
"weaponSpecialSpring2025HealerNotes": "Com uma onda, você pode invocar polinizadores para ajudá-lo em suas aventuras. Aumenta a Inteligência em <%= int %>. Equipamento de Edição Limitada da Primavera de 2025.",
"weaponSpecialSpring2025MageNotes": "Com um único golpe, você pode usar magia elemental para controlar o ambiente ao seu redor. Aproveite e avance! Aumenta a Inteligência em <%= int %> e a Percepção em <%= per %>. Edição Limitada Equipamentos da primavera de 2025",
"weaponSpecialSummer2025WarriorText": "Lança de vieira"
"weaponSpecialSummer2025WarriorText": "Lança de vieira",
"armorSpecialSpring2024MageNotes": "Essas pétalas bonitinhas vão te ajudar a mostrar seu poder em estilo. Aumenta Inteligência em <%=int%>. Equipamento de Edição Limitada Primavera 2024.",
"weaponSpecialSpring2025HealerText": "Cajado de Flor Pluma",
"weaponArmoireGildedKnightsSpearNotes": "Com esta arma, você pode garantir que todos sempre paguem suas dívidas. Aumenta a Força em <%= str %>. Armário Encantado: Conjunto do Cavaleiro Dourado (Item 3 de 3).",
"weaponArmoireGildedKnightsSpearText": "Lança de Cavaleiro Dourado",
"weaponSpecialSummer2025RogueText": "Tentáculo de Lula",
"weaponSpecialSummer2025RogueNotes": "Este tentáculo se agarrará firmemente aos seus objetivos para que você não perca o ritmo ao concluir tarefas. Aumenta a Força em <%= str %>. Equipamento de Edição Limitada do Verão de 2025.",
"weaponSpecialSummer2025HealerText": "Remo de Asa de Anjo Marinho",
"weaponSpecialSummer2025HealerNotes": "Desenhe um oito conforme avança, progredindo bastante em suas tarefas. Aumenta a Inteligência em <%= int %>. Equipamento de Edição Limitada Verão 2025.",
"weaponSpecialSummer2025MageText": "Ramo Coral",
"weaponSpecialSummer2025MageNotes": "Expanda seus talentos e habilidades para realizar uma variedade de tarefas. Aumenta a Inteligência em <%= int %> e a Percepção em <%= per %>. Equipamento de Edição Limitada Verão 2025.",
"armorSpecialSummer2024WarriorNotes": "Depois de se transformar em um verdadeiro Guerreiro Tubarão-Baleia, nade corajosamente em direção às suas tarefas! Aumenta a Constituição em <%= con %>. Equipamento de Edição Limitada Verão 2024.",
"weaponArmoireFunnyFoolBatonText": "Bastão de Bobo Engraçado",
"weaponSpecialFall2025WarriorText": "Machado de Pé Grande",
"weaponSpecialFall2025RogueText": "Espada Esqueleto",
"weaponSpecialFall2025RogueNotes": "Uma arma poderosa para abrir caminho com segurança por uma floresta outonal repleta de obstáculos. Aumenta a Força em <%= str %>. Equipamento de Edição Limitada Outono 2025.",
"weaponSpecialFall2025HealerText": "Machado Kobold",
"weaponSpecialFall2025MageText": "Machado do Fantasma Mascarado",
"weaponArmoireFunnyFoolBatonNotes": "Com um aceno de bastão, você pode dar uma piada, redirecionar a atenção ou atrair aplausos. Aumenta a Constituição e a Força em <%= attrs %> cada. Armário Encantado: Conjunto do Tolo Engraçado (Item 3 de 3).",
"weaponArmoireStormKnightAxeNotes": "Reúna sua fúria e desfira um golpe como um trovão! Aumenta a Força em <%= str %>. Armário Encantado: Conjunto do Cavaleiro da Tempestade (Item 3 de 3).",
"weaponArmoireBeekeepersSmokerNotes": "Use isto para acalmar suas abelhas e recuperar um pouco de mel. As abelhas não vão se importar. Sinceramente, todos nós precisamos de alguns minutos extras de calma de vez em quando. Aumenta a Inteligência em <%= int %>. Armário Encantado: Conjunto de Apicultor (Item 3 de 4).",
"weaponArmoireBlacksmithsHammerText": "Martelo de Ferreiro",
"weaponArmoireBlacksmithsHammerNotes": "Este martelo é para metalurgia, mas também é perfeitamente adequado para brasas e tarefas diárias em brasa. Aumenta a Força em <%= str %>. Armário Encantado: Conjunto de Ferreiro (Item 3 de 3).",
"armorSpecialSpring2024RogueNotes": "Este manto rústico te protege mesmo com a mudança das estações. Aumenta a Percepção em <%= por %>. Equipamento de Edição Limitada Primavera 2024.",
"armorSpecialSpring2024WarriorNotes": "Esta armadura de pedra estabilizadora ajudará você a se firmar enquanto ofusca tudo o que encontrar pela frente. Aumenta a Constituição em <%= con %>. Equipamento de Edição Limitada Primavera 2024.",
"armorSpecialSummer2024WarriorText": "Cauda de Tubarão-Baleia",
"weaponSpecialSummer2025WarriorNotes": "Não há como dizer a idade, mas ele permanecerá com você em muitas tarefas difíceis. Aumenta a Força em <%= str %>. Equipamento de Edição Limitada do Verão de 2025.",
"weaponSpecialFall2025WarriorNotes": "Uma arma poderosa para abrir caminho com segurança por uma floresta outonal cheia de complicações. Aumenta a Força em <%= str %>. Equipamento de Edição Limitada Outono 2025.",
"weaponSpecialFall2025HealerNotes": "Uma arma poderosa para abrir caminho com segurança por uma floresta outonal repleta de obstáculos. Aumenta a Inteligência em <%= int %>. Equipamento de Edição Limitada Outono 2025.",
"weaponSpecialFall2025MageNotes": "Uma arma poderosa para abrir caminho em segurança por uma floresta outonal cheia de sustos. Aumenta a Inteligência em <%= int %> e a Percepção em <%= per %>. Equipamento de Edição Limitada Outono 2025.",
"weaponMystery202511Text": "Espada Congelada",
"weaponMystery202511Notes": "O brilho gélido desta espada realizará rapidamente até mesmo tarefas vermelho-escuras. Não concede nenhum benefício. Item de assinante de novembro 2025.",
"weaponArmoireCorsairsBladeNotes": "Quer você use esta lâmina poderosa para saques ou proteção, fique feliz por tê-la trazido de terra firme. Apenas certifique-se de guardá-la em segurança quando não estiver em uso. Aumenta a Força em <%= str %>. Armário Encantado: Conjunto Corsário (Item 3 de 3).",
"armorSpecialSpring2024WarriorText": "Armadura de Fluorita",
"armorSpecialSpring2024HealerNotes": "Estas penas fabulosas ajudarão você a realizar seus sonhos mais felizes. Aumenta a Constituição em <%= con %>. Equipamento de Edição Limitada Primavera 2024.",
"weaponMystery202508Text": "Lâmina Carmesim Brilhante",
"weaponMystery202508Notes": "Esta lâmina giratória aterrorizará qualquer monstro ou item diário vermelho que cruzar seu caminho! Não concede nenhum benefício. Item de assinante de agosto 2025.",
"weaponArmoireBeekeepersSmokerText": "Fumante",
"armorSpecialSpring2024RogueText": "Manto Derrete Neves",
"armorSpecialSpring2024MageText": "Vestes de Hibisco"
}

View File

@@ -320,16 +320,16 @@ api.updateHero = {
if (plan.extraMonths || plan.extraMonths === 0) {
hero.purchased.plan.extraMonths = plan.extraMonths;
}
if (plan.customerId) {
if (plan.customerId || plan.customerId === '') {
hero.purchased.plan.customerId = plan.customerId;
}
if (plan.paymentMethod) {
if (plan.paymentMethod || plan.customerId === '') {
hero.purchased.plan.paymentMethod = plan.paymentMethod;
}
if (plan.planId) {
if (plan.planId || plan.customerId === '') {
hero.purchased.plan.planId = plan.planId;
}
if (plan.owner) {
if (plan.owner || plan.customerId === '') {
hero.purchased.plan.owner = plan.owner;
}
if (plan.hourglassPromoReceived) {
@@ -341,8 +341,7 @@ api.updateHero = {
const group = await Group.getGroup({ user: hero, groupId: groupID });
if (!group) throw new NotFound(res.t('groupNotFound'));
if (group.hasNotCancelled()) {
hero.purchased.plan.customerId = null;
hero.purchased.plan.paymentMethod = null;
hero.purchased.plan.paymentMethod = 'groupPlan';
await addSubToGroupUser(hero, group);
await group.updateGroupPlan();
} else {
@@ -352,34 +351,34 @@ api.updateHero = {
}
if (updateData.stats) {
if (updateData.stats.hp) {
if (updateData.stats.hp || updateData.stats.hp === 0) {
hero.stats.hp = updateData.stats.hp;
}
if (updateData.stats.mp) {
if (updateData.stats.mp || updateData.stats.mp === 0) {
hero.stats.mp = updateData.stats.mp;
}
if (updateData.stats.exp) {
if (updateData.stats.exp || updateData.stats.exp === 0) {
hero.stats.exp = updateData.stats.exp;
}
if (updateData.stats.gp) {
if (updateData.stats.gp || updateData.stats.gp === 0) {
hero.stats.gp = updateData.stats.gp;
}
if (updateData.stats.lvl) {
if (updateData.stats.lvl || updateData.stats.lvl === 0) {
hero.stats.lvl = updateData.stats.lvl;
}
if (updateData.stats.points) {
if (updateData.stats.points || updateData.stats.points === 0) {
hero.stats.points = updateData.stats.points;
}
if (updateData.stats.str) {
if (updateData.stats.str || updateData.stats.str === 0) {
hero.stats.str = updateData.stats.str;
}
if (updateData.stats.int) {
if (updateData.stats.int || updateData.stats.int === 0) {
hero.stats.int = updateData.stats.int;
}
if (updateData.stats.per) {
if (updateData.stats.per || updateData.stats.per === 0) {
hero.stats.per = updateData.stats.per;
}
if (updateData.stats.con) {
if (updateData.stats.con || updateData.stats.con === 0) {
hero.stats.con = updateData.stats.con;
}
if (updateData.stats.buffs) {

View File

@@ -1,14 +1,22 @@
import validator from 'validator';
import merge from 'lodash/merge';
import uniqBy from 'lodash/uniqBy';
import { v4 as uuid } from 'uuid';
import { authWithHeaders } from '../../middlewares/auth';
import { ensurePermission } from '../../middlewares/ensureAccessRight';
import { model as User } from '../../models/user';
import { model as UserHistory } from '../../models/userHistory';
import { model as Group } from '../../models/group';
import { model as Blocker } from '../../models/blocker';
import {
NotFound,
} from '../../libs/errors';
import apple from '../../libs/payments/apple';
import google from '../../libs/payments/google';
import paypal from '../../libs/payments/paypal';
import {
getSubscriptionPaymentDetails as getStripeSubscriptionPaymentDetails,
} from '../../libs/payments/stripe/subscriptions';
const api = {};
@@ -40,8 +48,6 @@ api.searchHero = {
const { userIdentifier } = req.params;
const re = new RegExp(String.raw`^${userIdentifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`);
let query;
let users = [];
if (validator.isUUID(userIdentifier)) {
@@ -54,7 +60,7 @@ api.searchHero = {
'auth.facebook.emails.value',
];
for (const field of emailFields) {
const emailQuery = { [field]: userIdentifier };
const emailQuery = { [field]: userIdentifier.toLowerCase() };
// eslint-disable-next-line no-await-in-loop
const found = await User.findOne(emailQuery)
.select('contributor backer profile auth')
@@ -65,6 +71,7 @@ api.searchHero = {
}
}
} else {
const re = new RegExp(String.raw`^${userIdentifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`);
query = { 'auth.local.lowerCaseUsername': { $regex: re, $options: 'i' } };
}
@@ -76,7 +83,8 @@ api.searchHero = {
.lean()
.exec();
}
res.respond(200, users);
res.respond(200, uniqBy(users, '_id'));
},
};
@@ -188,4 +196,68 @@ api.deleteBlocker = {
},
};
api.validateSubscriptionPaymentDetails = {
method: 'GET',
url: '/admin/user/:userId/subscription-payment-details',
middlewares: [authWithHeaders(), ensurePermission('userSupport')],
async handler (req, res) {
req.checkParams('userId', res.t('heroIdRequired')).notEmpty().isUUID();
const validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
const { userId } = req.params;
const user = await User.findById(userId)
.select('purchased')
.lean()
.exec();
if (!user) throw new NotFound(res.t('userWithIDNotFound', { userId }));
if (!user.purchased || !user.purchased.plan || !user.purchased.plan.paymentMethod || !user.purchased.plan.paymentMethod === '') {
throw new NotFound(res.t('subscriptionNotFoundForUser', { userId }));
}
let paymentDetails;
if (user.purchased.plan.paymentMethod === 'Apple') {
paymentDetails = await apple.getSubscriptionPaymentDetails(userId, user.purchased.plan);
} else if (user.purchased.plan.paymentMethod === 'Google') {
paymentDetails = await google.getSubscriptionPaymentDetails(userId, user.purchased.plan);
} else if (user.purchased.plan.paymentMethod === 'Paypal') {
paymentDetails = await paypal.getSubscriptionPaymentDetails({ user });
} else if (user.purchased.plan.paymentMethod === 'Stripe') {
paymentDetails = await getStripeSubscriptionPaymentDetails(user);
} else if (user.purchased.plan.paymentMethod === 'Amazon Payments') {
throw new NotFound(res.t('amazonSubscriptionNotValidated'));
} else if (user.purchased.plan.paymentMethod === 'Gift') {
throw new NotFound(res.t('giftSubscriptionNotValidated'));
} else {
throw new NotFound(res.t('unknownSubscriptionPaymentMethod', { method: user.purchased.paymentMethod }));
}
res.respond(200, paymentDetails);
},
};
api.getGroup = {
method: 'GET',
url: '/admin/groups/:groupId',
middlewares: [authWithHeaders(), ensurePermission('groupSupport')],
async handler (req, res) {
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty().isUUID();
const validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
const { groupId } = req.params;
const group = await Group.findById(groupId)
.lean()
.exec();
if (!group) throw new NotFound(res.t('groupNotFound'));
res.respond(200, group);
},
};
export default api;

View File

@@ -1,3 +1,4 @@
import { sendJob } from '../../libs/worker';
import { authWithHeaders } from '../../middlewares/auth';
import { ensurePermission } from '../../middlewares/ensureAccessRight';
import { TransactionModel as Transaction } from '../../models/transaction';
@@ -5,9 +6,9 @@ import { TransactionModel as Transaction } from '../../models/transaction';
const api = {};
/**
* @api {get} /api/v4/user/purchase-history Get users purchase history
* @apiName UserGetPurchaseHistory
* @apiGroup User
* @api {get} /api/v4/members/:memberId/purchase-history Get members purchase history
* @apiName MemberGetPurchaseHistory
* @apiGroup Member
*
*/
api.purchaseHistory = {
@@ -31,4 +32,31 @@ api.purchaseHistory = {
},
};
/**
* @api {delete} /api/v4/members/:memberId Delete a user
* @apiName DeleteMember
* @apiGroup Member
*
*/
api.deleteMember = {
method: 'DELETE',
middlewares: [authWithHeaders(), ensurePermission('userSupport')],
url: '/members/:memberId',
async handler (req, res) {
req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID();
req.checkQuery('deleteAccount').optional().isIn(['true', 'false']);
req.checkQuery('deleteAmplitude').optional().isIn(['true', 'false']);
const validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
sendJob('delete-user', {
data: {
userId: req.params.memberId,
deleteAccount: req.query.deleteAccount === 'true',
deleteAmplitude: req.query.deleteAmplitude === 'true',
},
});
res.respond(200, {});
},
};
export default api;

View File

@@ -17,7 +17,11 @@ export function loginRes (user, req, res) {
if (user.auth.blocked) {
throw new NotAuthorized(res.t(
'accountSuspended',
{ communityManagerEmail: COMMUNITY_MANAGER_EMAIL, userId: user._id },
{
communityManagerEmail: COMMUNITY_MANAGER_EMAIL,
userId: user._id,
username: user.auth.local.username,
},
));
}
const urlPath = url.parse(req.url).pathname;

View File

@@ -1,18 +1,10 @@
import nconf from 'nconf';
import got from 'got';
import { TAVERN_ID } from '../models/group'; // eslint-disable-line import/no-cycle
import { encrypt } from './encryption';
import logger from './logger';
import common from '../../common';
import { sendJob } from './worker';
const IS_PROD = nconf.get('IS_PROD');
const EMAIL_SERVER = {
url: nconf.get('EMAIL_SERVER_URL'),
auth: {
user: nconf.get('EMAIL_SERVER_AUTH_USER'),
password: nconf.get('EMAIL_SERVER_AUTH_PASSWORD'),
},
};
const BASE_URL = nconf.get('BASE_URL');
export function getUserInfo (user, fields = []) {
@@ -156,29 +148,14 @@ export async function sendTxn (mailingInfoArray, emailType, variables, personalV
}
if (IS_PROD && mailingInfoArray.length > 0) {
return got.post(`${EMAIL_SERVER.url}/job`, {
retry: 5, // retry the http request to the email server 5 times
timeout: 60000, // wait up to 60s before timing out
username: EMAIL_SERVER.auth.user,
password: EMAIL_SERVER.auth.password,
json: {
type: 'email',
data: {
emailType,
to: mailingInfoArray,
variables,
personalVariables,
},
options: {
priority: 'high',
attempts: 5,
backoff: { delay: 10 * 60 * 1000, type: 'fixed' },
},
return sendJob('email', {
data: {
emailType,
to: mailingInfoArray,
variables,
personalVariables,
},
}).json().catch(err => logger.error(err, {
extraMessage: 'Error while sending an email.',
emailType,
}));
});
}
return null;

View File

@@ -74,7 +74,7 @@ api.verifyPurchase = async function verifyPurchase (options) {
return appleRes;
};
api.subscribe = async function subscribe (user, receipt, headers, nextPaymentProcessing) {
async function findSubscriptionPurchase (receipt, onlyActive = true) {
await iap.setup();
const appleRes = await iap.validate(iap.APPLE, receipt);
@@ -85,18 +85,56 @@ api.subscribe = async function subscribe (user, receipt, headers, nextPaymentPro
if (purchaseDataList.length === 0) {
throw new NotAuthorized(api.constants.RESPONSE_NO_ITEM_PURCHASED);
}
let purchase;
let newestDate;
for (const purchaseData of purchaseDataList) {
const datePurchased = new Date(Number(purchaseData.purchaseDate));
const dateTerminated = new Date(Number(purchaseData.expirationDate));
if ((!newestDate || datePurchased > newestDate) && dateTerminated > new Date()) {
purchase = purchaseData;
newestDate = datePurchased;
let datePurchased;
if (purchaseData.purchaseDate instanceof Date) {
datePurchased = purchaseData.purchaseDate;
} else {
datePurchased = new Date(Number(purchaseData.purchaseDateMs || purchaseData.purchaseDate));
}
const dateTerminated = new Date(Number(purchaseData.expirationDate || 0));
if ((!newestDate || datePurchased > newestDate)) {
if (!onlyActive || dateTerminated > new Date()) {
purchase = purchaseData;
newestDate = datePurchased;
}
}
}
if (!purchase) {
throw new NotAuthorized(api.constants.RESPONSE_NO_ITEM_PURCHASED);
}
return {
purchase,
isCanceled: iap.isCanceled(purchase),
isExpired: iap.isExpired(purchase),
expirationDate: new Date(Number(purchase.expirationDate)),
};
}
api.getSubscriptionPaymentDetails = async function getDetails (userId, subscriptionPlan) {
if (!subscriptionPlan || !subscriptionPlan.additionalData) {
throw new NotAuthorized(shared.i18n.t('missingSubscription'));
}
const details = await findSubscriptionPurchase(subscriptionPlan.additionalData);
return {
customerId: details.purchase.originalTransactionId || details.purchase.transactionId,
purchaseDate: new Date(Number(details.purchase.purchaseDateMs)),
originalPurchaseDate: new Date(Number(details.purchase.originalPurchaseDateMs)),
expirationDate: details.isCanceled || details.isExpired ? details.expirationDate : null,
nextPaymentDate: details.isCanceled || details.isExpired ? null : details.expirationDate,
productId: details.purchase.productId,
transactionId: details.purchase.transactionId,
isCanceled: details.isCanceled,
isExpired: details.isExpired,
};
};
api.subscribe = async function subscribe (user, receipt, headers, nextPaymentProcessing) {
const details = await findSubscriptionPurchase(receipt);
const { purchase } = details;
let subCode;
switch (purchase.productId) { // eslint-disable-line default-case
@@ -250,37 +288,17 @@ api.noRenewSubscribe = async function noRenewSubscribe (options) {
api.cancelSubscribe = async function cancelSubscribe (user, headers) {
const { plan } = user.purchased;
if (plan.paymentMethod !== api.constants.PAYMENT_METHOD_APPLE) throw new NotAuthorized(shared.i18n.t('missingSubscription'));
await iap.setup();
try {
const appleRes = await iap.validate(iap.APPLE, plan.additionalData);
const isValidated = iap.isValidated(appleRes);
if (!isValidated) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT);
const purchases = iap.getPurchaseData(appleRes);
if (purchases.length === 0) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT);
let newestDate;
let newestPurchase;
for (const purchaseData of purchases) {
const datePurchased = new Date(Number(purchaseData.purchaseDate));
if (!newestDate || datePurchased > newestDate) {
newestDate = datePurchased;
newestPurchase = purchaseData;
}
}
if (!iap.isCanceled(newestPurchase) && !iap.isExpired(newestPurchase)) {
const details = await findSubscriptionPurchase(plan.additionalData, false);
if (!details.isCanceled && !details.isExpired) {
throw new NotAuthorized(this.constants.RESPONSE_STILL_VALID);
}
await payments.cancelSubscription({
user,
nextBill: new Date(Number(newestPurchase.expirationDate)),
nextBill: new Date(Number(details.expirationDate)),
paymentMethod: this.constants.PAYMENT_METHOD_APPLE,
headers,
});

View File

@@ -72,6 +72,53 @@ api.verifyPurchase = async function verifyPurchase (options) {
return googleRes;
};
async function findSubscriptionPurchase (additionalData) {
const googleRes = await iap.validate(iap.GOOGLE, additionalData);
const isValidated = iap.isValidated(googleRes);
if (!isValidated) throw new NotAuthorized(api.constants.RESPONSE_INVALID_RECEIPT);
const purchases = iap.getPurchaseData(googleRes);
if (purchases.length === 0) throw new NotAuthorized(api.constants.RESPONSE_INVALID_RECEIPT);
let purchase;
let newestDate;
for (const i in purchases) {
if (Object.prototype.hasOwnProperty.call(purchases, i)) {
const thisPurchase = purchases[i];
const purchaseDate = new Date(Number(thisPurchase.startTimeMillis));
if (!newestDate || purchaseDate > newestDate) {
newestDate = purchaseDate;
purchase = purchases[i];
}
}
}
return {
purchase,
isCanceled: iap.isCanceled(purchase),
isExpired: iap.isExpired(purchase),
expirationDate: new Date(Number(purchase.expirationDate)),
};
}
api.getSubscriptionPaymentDetails = async function getDetails (userId, subscriptionPlan) {
if (!subscriptionPlan || !subscriptionPlan.additionalData) {
throw new NotAuthorized(shared.i18n.t('missingSubscription'));
}
const details = await findSubscriptionPurchase(subscriptionPlan.additionalData);
return {
customerId: details.purchase.purchaseToken,
originalPurchaseDate: new Date(Number(details.purchase.startTimeMillis)),
expirationDate: details.isCanceled || details.isExpired ? details.expirationDate : null,
nextPaymentDate: details.isCanceled || details.isExpired ? null : details.expirationDate,
productId: details.purchase.productId,
transactionId: details.purchase.orderId,
isCanceled: details.isCanceled,
isExpired: details.isExpired,
};
};
api.subscribe = async function subscribe (
sku,
user,
@@ -213,22 +260,11 @@ api.cancelSubscribe = async function cancelSubscribe (user, headers) {
let dateTerminated;
try {
const googleRes = await iap.validate(iap.GOOGLE, plan.additionalData);
const isValidated = iap.isValidated(googleRes);
if (!isValidated) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT);
const purchases = iap.getPurchaseData(googleRes);
if (purchases.length === 0) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT);
for (const i in purchases) {
if (Object.prototype.hasOwnProperty.call(purchases, i)) {
const purchase = purchases[i];
if (purchase.autoRenewing !== false) return;
if (!dateTerminated || Number(purchase.expirationDate) > Number(dateTerminated)) {
dateTerminated = new Date(Number(purchase.expirationDate));
}
}
const details = await findSubscriptionPurchase(plan.additionalData);
if (!details.isCanceled && !details.isExpired) {
throw new NotAuthorized(this.constants.RESPONSE_STILL_VALID);
}
dateTerminated = details.expirationDate;
} catch (err) {
// Status:410 means that the subsctiption isn't active anymore and we can safely delete it
if (err && err.message === 'Status:410') {

View File

@@ -180,7 +180,6 @@ async function addSubToGroupUser (member, group) {
}
// save unused hourglass and mystery items
plan.perkMonthCount = memberPlan.perkMonthCount;
plan.consecutive.trinkets = memberPlan.consecutive.trinkets;
plan.mysteryItems = memberPlan.mysteryItems;

View File

@@ -223,6 +223,51 @@ api.subscribeSuccess = async function subscribeSuccess (options = {}) {
});
};
api.getSubscriptionPaymentDetails = async function getSubscriptionPaymentDetails (options = {}) {
const { user, groupId } = options;
let customerId;
if (groupId) {
const groupFields = basicGroupFields.concat(' purchased');
const group = await Group.getGroup({
user, groupId, populateLeader: false, groupFields,
});
if (!group) {
throw new NotFound(i18n.t('groupNotFound'));
}
if (group.leader !== user._id) {
throw new NotAuthorized(i18n.t('onlyGroupLeaderCanManageSubscription'));
}
customerId = group.purchased.plan.customerId;
} else {
customerId = user.purchased.plan.customerId;
}
if (!customerId) throw new NotAuthorized(i18n.t('missingSubscription'));
const customer = await this.paypalBillingAgreementGet(customerId);
if (!customer) throw new NotFound(i18n.t('subscriptionNotFound'));
console.log('PayPal subscription details:', customer);
return {
customerId: customer.id,
originalPurchaseDate: customer.start_date,
expirationDate: customer.agreement_details.ended_at
? customer.agreement_details.ended_at
: null,
nextPaymentDate: customer.agreement_details.next_billing_date
? customer.agreement_details.next_billing_date
: null,
lastPaymentDate: customer.agreement_details.last_payment_date
? customer.agreement_details.last_payment_date
: null,
productId: customer.description,
transactionId: customer.id,
isCanceled: customer.agreement_details.state === 'Inactive',
failedPayments: customer.agreement_details.failed_payment_count,
};
};
/**
* Cancel a PayPal Subscription
*

View File

@@ -33,6 +33,26 @@ export async function checkSubData (sub, isGroup = false, coupon) {
}
}
export async function getSubscriptionPaymentDetails (user) {
const stripeApi = getStripeApi();
const { plan } = user.purchased;
const customer = await stripeApi.customers.retrieve(plan.customerId);
const paymentIntents = await stripeApi.paymentIntents.search({
query: `customer:'${plan.customerId}'`,
});
const lastPayment = paymentIntents.data.length > 0
? paymentIntents.data[0]
: null;
console.log(paymentIntents.data);
console.log(customer);
return {
customerId: customer.id,
originalPurchaseDate: new Date(Number(customer.created) * 1000),
lastPaymentDate: new Date(Number(lastPayment.created) * 1000),
};
}
export async function applySubscription (session) {
const { metadata, customer: customerId, subscription: subscriptionId } = session;
const {

View File

@@ -0,0 +1,33 @@
import got from 'got';
import nconf from 'nconf';
import logger from './logger';
const EMAIL_SERVER = {
url: nconf.get('EMAIL_SERVER_URL'),
auth: {
user: nconf.get('EMAIL_SERVER_AUTH_USER'),
password: nconf.get('EMAIL_SERVER_AUTH_PASSWORD'),
},
};
export function sendJob (type, config) {
const { data, options } = config;
const usedOptions = {
backoff: { delay: 10 * 60 * 1000, type: 'exponential' },
...options,
};
return got.post(`${EMAIL_SERVER.url}/job`, {
retry: 5, // retry the http request to the email server 5 times
timeout: 60000, // wait up to 60s before timing out
username: EMAIL_SERVER.auth.user,
password: EMAIL_SERVER.auth.password,
json: {
type,
data,
options: usedOptions,
},
}).json().catch(err => logger.error(err, {
extraMessage: 'Error while sending an email.',
}));
}

View File

@@ -100,6 +100,7 @@ export function authWithHeaders (options = {}) {
throw new NotAuthorized(common.i18n.t('accountSuspended', {
communityManagerEmail: COMMUNITY_MANAGER_EMAIL,
userId: user._id,
username: user.auth.local.username,
}, language));
}