Compare commits

..

99 Commits

Author SHA1 Message Date
Phillip Thelen 9aed479296 add semicolon 2026-03-25 11:54:56 +01:00
Phillip Thelen 47c156d9b1 add key prefix 2026-03-25 11:51:51 +01:00
Phillip Thelen e6418e4356 add space 2026-03-25 11:48:03 +01:00
Phillip Thelen 3c23989e99 use bullmq directly to schedule jobs 2026-03-25 11:37:02 +01:00
Phillip Thelen 2d1f341256 refactor redis setup into own file and use ioredis 2026-03-25 11:35:35 +01:00
Kalista Payne 44adfd611a fix(lint): no-undef 2026-03-23 19:31:12 -05:00
Kalista Payne ab50c41287 fix(tests): remove tests
These can potentially be tested in the worker's suite? They target functionality that the group leave route handles within the deletion flow
2026-03-23 17:18:01 -05:00
Kalista Payne c43abe82fe Revert "fix(deletion): handle group leave logic on app server still"
This reverts commit 9db541f4c3.
2026-03-23 08:51:53 -05:00
Kalista Payne ac0b4a324f fix(deletion): remove orphaned chat messages 2026-03-20 11:17:30 -05:00
Kalista Payne efa0a325a2 fix(text): don't break to new paragraph about Gems 2026-03-20 11:17:30 -05:00
Kalista Payne bc970d33ac fix(deletion): update delete/feedback form copy 2026-03-20 11:17:30 -05:00
Kalista Payne 09e432cf32 fix(test): adapt test for worker flow 2026-03-20 11:17:30 -05:00
Kalista Payne 40aa2e214d fix(import): bracket syntax 2026-03-20 11:17:30 -05:00
Kalista Payne 9f563b741d fix(lint): unused import 2026-03-20 11:17:30 -05:00
Kalista Payne 9db541f4c3 fix(deletion): handle group leave logic on app server still 2026-03-20 11:17:30 -05:00
Kalista Payne ce4a20e3d8 WIP(delete): remove business logic from controller 2026-03-20 11:17:30 -05:00
Kalista Payne cc7683a871 chore(git): update submodule 2026-03-19 18:09:22 -05:00
Kalista Payne 31b2781333 Squashed commit of the following:
commit 866f074a15
Author: Kalista Payne <kalista@habitica.com>
Date:   Wed Mar 18 15:51:18 2026 -0500

    fix(quests): remove backticks from text

commit d06fc2825e
Author: Kalista Payne <kalista@habitica.com>
Date:   Wed Mar 18 13:16:32 2026 -0500

    fix(background): add missing data

commit 55156c5f80
Author: Kalista Payne <kalista@habitica.com>
Date:   Wed Mar 18 13:00:43 2026 -0500

    fix(lint): max-len

commit db88092acf
Author: Kalista Payne <kalista@habitica.com>
Date:   Wed Mar 18 12:56:30 2026 -0500

    fix(customization): show event backgrounds for AF

commit d6fd1ce7fa
Author: Phillip Thelen <phillip@habitica.com>
Date:   Tue Mar 17 15:55:53 2026 +0100

    set release date

commit b876d8c789
Author: Phillip Thelen <phillip@habitica.com>
Date:   Tue Mar 17 11:40:17 2026 +0100

    Improve swap handling

commit f75a4d0147
Author: Phillip Thelen <phillip@habitica.com>
Date:   Tue Mar 17 11:22:42 2026 +0100

    use correct name for bear sprites

commit 195db1b132
Author: Phillip Thelen <phillip@habitica.com>
Date:   Mon Mar 16 23:01:35 2026 +0100

    more fix

commit a42d3f08d7
Author: Phillip Thelen <phillip@habitica.com>
Date:   Mon Mar 16 18:43:21 2026 +0100

    fix test

commit 9c06299c34
Author: Phillip Thelen <phillip@habitica.com>
Date:   Mon Mar 16 09:13:13 2026 +0100

    AF tweaks

commit 5465e23e67
Author: Kalista Payne <kalista@habitica.com>
Date:   Wed Mar 11 19:43:01 2026 -0500

    fix(sprites): add missing gif redirects

commit 5721ecc6f2
Author: Kalista Payne <kalista@habitica.com>
Date:   Tue Mar 10 16:34:26 2026 -0500

    chore(css): run sprites

commit 2184ff2e69
Author: Kalista Payne <kalista@habitica.com>
Date:   Tue Mar 10 10:38:34 2026 -0500

    fix(test): date

commit d684a7297c
Author: Kalista Payne <kalista@habitica.com>
Date:   Tue Mar 10 10:32:04 2026 -0500

    fix(test): update for 2026, also more lint

commit 82e7947fd7
Author: Kalista Payne <kalista@habitica.com>
Date:   Tue Mar 10 10:22:05 2026 -0500

    fix(event): lint and missing pieces

commit 96818b2d77
Author: Kalista Payne <kalista@habitica.com>
Date:   Mon Mar 9 20:11:22 2026 -0500

    feat(event): finished Alien build

commit 4c9763b676
Author: Kalista Payne <kalista@habitica.com>
Date:   Fri Mar 6 16:30:36 2026 -0600

    wip(event): April Fools 2026 build

commit 2f16a016e6
Author: Phillip Thelen <phillip@habitica.com>
Date:   Wed Feb 4 14:16:50 2026 +0100

    add april fools tests

commit cd1d926c98
Author: Phillip Thelen <phillip@habitica.com>
Date:   Wed Feb 4 14:09:16 2026 +0100

    make april fools cycle through

commit d8a4216c41
Author: Phillip Thelen <phillip@habitica.com>
Date:   Mon Feb 2 14:41:26 2026 +0100

    fix lint

commit 8265a15923
Author: Phillip Thelen <phillip@habitica.com>
Date:   Mon Feb 2 12:34:46 2026 +0100

    name key more generic

commit 9c7bde8ad5
Author: Phillip Thelen <phillip@habitica.com>
Date:   Mon Feb 2 12:26:43 2026 +0100

    right date for april fools

commit c2b92c6311
Author: Phillip Thelen <phillip@habitica.com>
Date:   Mon Feb 2 12:26:19 2026 +0100

    rework how april fools works
2026-03-19 15:09:32 -05:00
Kalista Payne d37d3bc5ac 5.46.4 2026-03-17 14:51:06 -05:00
Weblate ef3a28791e Translated using Weblate (Dutch)
Currently translated at 72.8% (2586 of 3551 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (941 of 941 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 95.0% (19 of 20 strings)

Translated using Weblate (Dutch)

Currently translated at 63.6% (161 of 253 strings)

Translated using Weblate (Dutch)

Currently translated at 70.0% (14 of 20 strings)

Translated using Weblate (Russian)

Currently translated at 98.5% (927 of 941 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (20 of 20 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (275 of 275 strings)

Translated using Weblate (Korean)

Currently translated at 43.3% (82 of 189 strings)

Translated using Weblate (Korean)

Currently translated at 33.8% (64 of 189 strings)

Co-authored-by: Alexander Tschigir <tchi.gugl@gmail.com>
Co-authored-by: Jildau Bras <jildaubras@gmail.com>
Co-authored-by: Serhii <serzh.photograf@gmail.com>
Co-authored-by: Summer_GUI <heyang94@163.com>
Co-authored-by: Tijl Casteleyn <casteleyntijl@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: 성명 <phjk2536@gmail.com>
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/ru/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/front/ko/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/rebirth/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/rebirth/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/rebirth/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/uk/
Translation: Habitica/Backgrounds
Translation: Habitica/Faq
Translation: Habitica/Front
Translation: Habitica/Gear
Translation: Habitica/Rebirth
Translation: Habitica/Settings
2026-03-17 10:33:27 +01:00
Kalista Payne c3c2607bca Remove join retired public guild loophole (#15626)
* fix(guilds): remove join retired public guild loophole

* fix(groups): adjust test expectations
and move some business logic back out of the controller

* test(challenges): add cases related to public Guilds, Tavern and otherwise

* fix(tests): more erroneously public test guilds, lint

* fix(tests): still more setup tweaks

* fix(lint): whitespace

* fix(tests): couple more adjustments

* fix(test): last challenge issue??
2026-03-12 19:15:00 -05:00
Tanmay Nalawade 7a6d64f158 Duplicate tags Bug solved (#15615)
* added hasExactMatch function

* changed the handleSubmit logic to check for match

* changed the conditional in class for dropdown

* added hasExactMatch conditional to display the AddTag text

* one more conditional to check if string is empty

* changed styling to display hidden text of "Press Enter to add tag"

* lint error fixed

* fix(tags): don't allow same tag spam within add session

---------

Co-authored-by: Kalista Payne <kalista@habitica.com>
Co-authored-by: Kalista Payne <sabrecat@gmail.com>
2026-03-12 19:09:30 -05:00
Kalista Payne 836e63246d fix(emails): correct variable name in group plan invites 2026-03-12 18:58:49 -05:00
Kalista Payne 61585b2549 5.46.3 2026-03-12 16:51:19 -05:00
Corban Villa 07275bd522 fix(security): don't reuse IV for cryptography 2026-03-12 14:53:30 -05:00
Fiz 74fc543ef2 Orb of Rebirth Updates (Count usage) (#15629)
* Update orb of rebirth to count every usage

* Orb of rebirth modal UI updates

* Fix rebirth modal showing again after page refresh

* remove unused MAX_LEVEL

* scale orb of rebirth on modal

* UI & wording updates for Orb of Rebirth

* UI & wording updates for Orb of Rebirth cont.

* Orb of rebirth UI tweaks

* Orb of rebirth UI tweaks

* Orb of Rebirth modal UI tweak

* Extend modal waves

* Continued Orb of Rebirth UI Updates

* Orb of rebirth margin tweak

* rebirth-orb asset
2026-03-12 14:51:48 -05:00
Kalista Payne 8c90e5472b 5.46.2 2026-03-10 12:02:09 -05:00
Kalista Payne c8d9ba6c8e chore(git): update submodule 2026-03-10 12:02:06 -05:00
Weblate 1675c2749b Merge branch 'origin/develop' into Weblate. 2026-03-10 17:58:10 +01:00
Rohithgowda K e85a2bae14 fix: prevent duplicate streak achievement notifications (#13325) (#15550)
* fix: prevent duplicate streak achievement notifications (#13325)

* fix(lint): whitespace
also remove unnecessary file and comments

---------

Co-authored-by: Kalista Payne <kalista@habitica.com>
2026-03-10 10:03:28 -05:00
Weblate 3355500fba Translated using Weblate (French)
Currently translated at 100.0% (279 of 279 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (279 of 279 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 99.2% (277 of 279 strings)

Translated using Weblate (Czech)

Currently translated at 78.5% (739 of 941 strings)

Translated using Weblate (Japanese)

Currently translated at 99.6% (278 of 279 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (941 of 941 strings)

Translated using Weblate (Portuguese)

Currently translated at 68.7% (189 of 275 strings)

Translated using Weblate (Portuguese)

Currently translated at 86.6% (383 of 442 strings)

Translated using Weblate (Portuguese)

Currently translated at 59.5% (2115 of 3551 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (442 of 442 strings)

Translated using Weblate (Turkish)

Currently translated at 49.5% (1732 of 3497 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 92.1% (269 of 292 strings)

Translated using Weblate (Portuguese)

Currently translated at 91.4% (267 of 292 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.0% (438 of 442 strings)

Translated using Weblate (Portuguese)

Currently translated at 59.4% (2112 of 3551 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (941 of 941 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (941 of 941 strings)

Translated using Weblate (Korean)

Currently translated at 99.2% (934 of 941 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (292 of 292 strings)

Translated using Weblate (Portuguese)

Currently translated at 63.0% (184 of 292 strings)

Translated using Weblate (Portuguese)

Currently translated at 46.2% (117 of 253 strings)

Translated using Weblate (Turkish)

Currently translated at 99.0% (932 of 941 strings)

Co-authored-by: Anderson Ferreira <freiitas.dev@gmail.com>
Co-authored-by: Begümay Çınar <begumay@proton.me>
Co-authored-by: Duggu Ghosh <duggu52d@gmail.com>
Co-authored-by: Isabela de França <ifranceg@gmail.com>
Co-authored-by: Kim Sihyung <kimsihyung@u.nus.edu>
Co-authored-by: Matej Boura <B.Matej@email.cz>
Co-authored-by: Sophie LE MASLE <sophiesuff@gmail.com>
Co-authored-by: Summer_GUI <heyang94@163.com>
Co-authored-by: Translatlantic Translation <translatlanticom@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: いんこ <ayakabooker@gmail.com>
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/cs/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/ko/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/zh_Hans/
Translation: Habitica/Backgrounds
Translation: Habitica/Faq
Translation: Habitica/Gear
Translation: Habitica/Groups
Translation: Habitica/Limited
Translation: Habitica/Settings
Translation: Habitica/Subscriber
2026-03-10 12:48:18 +01:00
Kalista Payne 486f15df0f fix(news): allow X dismiss for everyone 2026-03-09 15:00:47 -05:00
Kalista Payne f0b6b5611c feat(news): allow quick dismiss for admins 2026-03-06 19:07:48 -06:00
Kalista Payne 7e45c79714 chore(npm): update lockfiles 2026-03-06 11:58:02 -06:00
Kalista Payne 8da6065355 fix(pins): don't erase pinned quest potions 2026-03-06 11:22:29 -06:00
Kalista Payne a212363bda 5.46.1 2026-03-05 14:59:58 -06:00
Weblate 2e19e73b9e Translated using Weblate (Dutch)
Currently translated at 72.7% (2585 of 3551 strings)

Translated using Weblate (Japanese)

Currently translated at 97.6% (3466 of 3551 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (13 of 13 strings)

Translated using Weblate (Czech)

Currently translated at 13.0% (33 of 253 strings)

Translated using Weblate (Czech)

Currently translated at 98.2% (164 of 167 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (442 of 442 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (3551 of 3551 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 98.4% (3497 of 3551 strings)

Translated using Weblate (Portuguese)

Currently translated at 24.1% (61 of 253 strings)

Translated using Weblate (Portuguese)

Currently translated at 50.6% (148 of 292 strings)

Translated using Weblate (German)

Currently translated at 98.1% (434 of 442 strings)

Translated using Weblate (Turkish)

Currently translated at 49.5% (1732 of 3497 strings)

Translated using Weblate (French)

Currently translated at 100.0% (3551 of 3551 strings)

Translated using Weblate (Portuguese)

Currently translated at 24.1% (61 of 253 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 99.8% (940 of 941 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 100.0% (167 of 167 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 99.7% (441 of 442 strings)

Translated using Weblate (Turkish)

Currently translated at 49.5% (1732 of 3497 strings)

Translated using Weblate (Turkish)

Currently translated at 49.5% (1732 of 3497 strings)

Translated using Weblate (Russian)

Currently translated at 74.7% (189 of 253 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 82.1% (708 of 862 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 82.1% (708 of 862 strings)

Translated using Weblate (German)

Currently translated at 92.8% (271 of 292 strings)

Translated using Weblate (French)

Currently translated at 100.0% (442 of 442 strings)

Translated using Weblate (German)

Currently translated at 99.5% (937 of 941 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (292 of 292 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (430 of 430 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (941 of 941 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (941 of 941 strings)

Translated using Weblate (French)

Currently translated at 100.0% (292 of 292 strings)

Translated using Weblate (French)

Currently translated at 100.0% (941 of 941 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (131 of 131 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (288 of 288 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (245 of 245 strings)

Translated using Weblate (Ukrainian)

Currently translated at 80.4% (152 of 189 strings)

Translated using Weblate (Ukrainian)

Currently translated at 54.5% (138 of 253 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (412 of 412 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (932 of 932 strings)

Translated using Weblate (Turkish)

Currently translated at 39.1% (99 of 253 strings)

Translated using Weblate (Turkish)

Currently translated at 35.5% (90 of 253 strings)

Translated using Weblate (Portuguese)

Currently translated at 24.1% (61 of 253 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (276 of 276 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (276 of 276 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (276 of 276 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (276 of 276 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (276 of 276 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (276 of 276 strings)

Translated using Weblate (Turkish)

Currently translated at 98.8% (425 of 430 strings)

Translated using Weblate (Turkish)

Currently translated at 98.8% (425 of 430 strings)

Translated using Weblate (Turkish)

Currently translated at 98.8% (425 of 430 strings)

Translated using Weblate (Turkish)

Currently translated at 98.8% (425 of 430 strings)

Translated using Weblate (Turkish)

Currently translated at 98.8% (425 of 430 strings)

Translated using Weblate (Turkish)

Currently translated at 98.8% (425 of 430 strings)

Translated using Weblate (Turkish)

Currently translated at 98.8% (425 of 430 strings)

Translated using Weblate (Turkish)

Currently translated at 98.8% (425 of 430 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (288 of 288 strings)

Translated using Weblate (Turkish)

Currently translated at 98.8% (425 of 430 strings)

Translated using Weblate (English (United Kingdom))

Currently translated at 100.0% (245 of 245 strings)

Translated using Weblate (English (United Kingdom))

Currently translated at 95.0% (192 of 202 strings)

Translated using Weblate (French)

Currently translated at 100.0% (253 of 253 strings)

Translated using Weblate (Turkish)

Currently translated at 75.7% (653 of 862 strings)

Translated using Weblate (Turkish)

Currently translated at 75.7% (653 of 862 strings)

Translated using Weblate (Turkish)

Currently translated at 33.9% (86 of 253 strings)

Translated using Weblate (Turkish)

Currently translated at 98.8% (425 of 430 strings)

Translated using Weblate (Turkish)

Currently translated at 95.3% (410 of 430 strings)

Translated using Weblate (Turkish)

Currently translated at 94.8% (408 of 430 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (131 of 131 strings)

Translated using Weblate (Turkish)

Currently translated at 94.6% (407 of 430 strings)

Translated using Weblate (Dutch)

Currently translated at 73.4% (2567 of 3497 strings)

Translated using Weblate (Turkish)

Currently translated at 82.3% (354 of 430 strings)

Translated using Weblate (Turkish)

Currently translated at 49.5% (1732 of 3497 strings)

Translated using Weblate (Turkish)

Currently translated at 49.5% (1732 of 3497 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 97.3% (111 of 114 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 67.1% (2349 of 3497 strings)

Translated using Weblate (Turkish)

Currently translated at 49.4% (1731 of 3497 strings)

Translated using Weblate (Turkish)

Currently translated at 49.4% (1731 of 3497 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 86.2% (238 of 276 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (276 of 276 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 97.3% (111 of 114 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 100.0% (131 of 131 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 67.1% (2349 of 3497 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (276 of 276 strings)

Translated using Weblate (Dutch)

Currently translated at 88.8% (382 of 430 strings)

Translated using Weblate (Dutch)

Currently translated at 88.6% (381 of 430 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Dutch)

Currently translated at 88.3% (380 of 430 strings)

Translated using Weblate (Dutch)

Currently translated at 73.1% (2557 of 3497 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (167 of 167 strings)

Translated using Weblate (Dutch)

Currently translated at 58.8% (149 of 253 strings)

Translated using Weblate (Japanese)

Currently translated at 98.6% (143 of 145 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (862 of 862 strings)

Translated using Weblate (Dutch)

Currently translated at 96.9% (127 of 131 strings)

Translated using Weblate (German)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (German)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (German)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (German)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (German)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Dutch)

Currently translated at 76.0% (210 of 276 strings)

Translated using Weblate (Dutch)

Currently translated at 56.5% (143 of 253 strings)

Translated using Weblate (Dutch)

Currently translated at 85.5% (368 of 430 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (932 of 932 strings)

Translated using Weblate (German)

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (276 of 276 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (288 of 288 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (430 of 430 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (3497 of 3497 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (245 of 245 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (189 of 189 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (253 of 253 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (412 of 412 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (932 of 932 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (275 of 275 strings)

Translated using Weblate (Portuguese)

Currently translated at 79.3% (150 of 189 strings)

Translated using Weblate (English (United Kingdom))

Currently translated at 96.4% (110 of 114 strings)

Translated using Weblate (Dutch)

Currently translated at 95.9% (894 of 932 strings)

Translated using Weblate (Japanese)

Currently translated at 99.5% (201 of 202 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (131 of 131 strings)

Translated using Weblate (Hungarian)

Currently translated at 96.8% (183 of 189 strings)

Translated using Weblate (Hungarian)

Currently translated at 96.8% (245 of 253 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (15 of 15 strings)

Translated using Weblate (Hungarian)

Currently translated at 95.5% (193 of 202 strings)

Translated using Weblate (Hungarian)

Currently translated at 95.6% (109 of 114 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (English (United Kingdom))

Currently translated at 98.6% (143 of 145 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (276 of 276 strings)

Translated using Weblate (Turkish)

Currently translated at 49.0% (1717 of 3497 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (245 of 245 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (131 of 131 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 76.4% (2672 of 3497 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 100.0% (253 of 253 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 51.6% (47 of 91 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 61.4% (169 of 275 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (167 of 167 strings)

Translated using Weblate (Swedish)

Currently translated at 59.6% (164 of 275 strings)

Translated using Weblate (Swedish)

Currently translated at 87.7% (115 of 131 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (94 of 94 strings)

Translated using Weblate (Swedish)

Currently translated at 87.5% (7 of 8 strings)

Translated using Weblate (Swedish)

Currently translated at 92.9% (106 of 114 strings)

Translated using Weblate (Swedish)

Currently translated at 91.4% (86 of 94 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Swedish)

Currently translated at 91.4% (86 of 94 strings)

Translated using Weblate (Japanese)

Currently translated at 98.1% (3434 of 3497 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (245 of 245 strings)

Translated using Weblate (Japanese)

Currently translated at 99.2% (251 of 253 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Swedish)

Currently translated at 92.4% (134 of 145 strings)

Translated using Weblate (Swedish)

Currently translated at 52.1% (144 of 276 strings)

Translated using Weblate (Turkish)

Currently translated at 63.5% (183 of 288 strings)

Translated using Weblate (Swedish)

Currently translated at 48.2% (139 of 288 strings)

Translated using Weblate (Turkish)

Currently translated at 47.2% (1653 of 3497 strings)

Translated using Weblate (English (United Kingdom))

Currently translated at 69.9% (2447 of 3497 strings)

Translated using Weblate (German)

Currently translated at 100.0% (3497 of 3497 strings)

Translated using Weblate (Swedish)

Currently translated at 4.7% (12 of 253 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Swedish)

Currently translated at 91.0% (152 of 167 strings)

Translated using Weblate (German)

Currently translated at 100.0% (3497 of 3497 strings)

Translated using Weblate (German)

Currently translated at 100.0% (3497 of 3497 strings)

Translated using Weblate (German)

Currently translated at 100.0% (3497 of 3497 strings)

Translated using Weblate (German)

Currently translated at 100.0% (3497 of 3497 strings)

Translated using Weblate (German)

Currently translated at 100.0% (3497 of 3497 strings)

Translated using Weblate (Dutch)

Currently translated at 54.1% (137 of 253 strings)

Translated using Weblate (Dutch)

Currently translated at 93.5% (872 of 932 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (245 of 245 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (253 of 253 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Dutch)

Currently translated at 92.5% (187 of 202 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Dutch)

Currently translated at 52.5% (133 of 253 strings)

Translated using Weblate (German)

Currently translated at 100.0% (189 of 189 strings)

Translated using Weblate (Dutch)

Currently translated at 51.7% (131 of 253 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (15 of 15 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (47 of 47 strings)

Translated using Weblate (Dutch)

Currently translated at 91.8% (856 of 932 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (275 of 275 strings)

Translated using Weblate (Dutch)

Currently translated at 91.6% (854 of 932 strings)

Translated using Weblate (Portuguese)

Currently translated at 52.7% (1844 of 3497 strings)

Translated using Weblate (Portuguese)

Currently translated at 24.1% (61 of 253 strings)

Translated using Weblate (Swedish)

Currently translated at 51.0% (141 of 276 strings)

Translated using Weblate (Swedish)

Currently translated at 77.6% (73 of 94 strings)

Translated using Weblate (Swedish)

Currently translated at 89.9% (170 of 189 strings)

Translated using Weblate (Swedish)

Currently translated at 4.7% (12 of 253 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (47 of 47 strings)

Translated using Weblate (Swedish)

Currently translated at 58.5% (546 of 932 strings)

Translated using Weblate (Swedish)

Currently translated at 86.2% (163 of 189 strings)

Translated using Weblate (Swedish)

Currently translated at 85.7% (162 of 189 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (58 of 58 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (58 of 58 strings)

Translated using Weblate (Swedish)

Currently translated at 70.3% (133 of 189 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (56 of 56 strings)

Translated using Weblate (German)

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (Dutch)

Currently translated at 86.1% (248 of 288 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (15 of 15 strings)

Translated using Weblate (Swedish)

Currently translated at 59.2% (163 of 275 strings)

Translated using Weblate (Czech)

Currently translated at 9.0% (23 of 253 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Dutch)

Currently translated at 73.0% (2556 of 3497 strings)

Translated using Weblate (French)

Currently translated at 100.0% (253 of 253 strings)

Translated using Weblate (German)

Currently translated at 100.0% (3497 of 3497 strings)

Translated using Weblate (Czech)

Currently translated at 8.6% (22 of 253 strings)

Translated using Weblate (Spanish)

Currently translated at 99.0% (200 of 202 strings)

Translated using Weblate (Dutch)

Currently translated at 79.8% (230 of 288 strings)

Translated using Weblate (German)

Currently translated at 100.0% (3497 of 3497 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (245 of 245 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (189 of 189 strings)

Translated using Weblate (Dutch)

Currently translated at 90.4% (843 of 932 strings)

Translated using Weblate (German)

Currently translated at 99.6% (3485 of 3497 strings)

Translated using Weblate (German)

Currently translated at 100.0% (245 of 245 strings)

Translated using Weblate (Portuguese)

Currently translated at 24.1% (61 of 253 strings)

Translated using Weblate (Portuguese)

Currently translated at 24.1% (61 of 253 strings)

Translated using Weblate (Portuguese)

Currently translated at 24.1% (61 of 253 strings)

Translated using Weblate (Portuguese)

Currently translated at 24.1% (61 of 253 strings)

Translated using Weblate (Portuguese)

Currently translated at 24.1% (61 of 253 strings)

Translated using Weblate (Portuguese)

Currently translated at 24.1% (61 of 253 strings)

Translated using Weblate (German)

Currently translated at 99.5% (3480 of 3497 strings)

Translated using Weblate (German)

Currently translated at 99.3% (3476 of 3497 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 98.0% (198 of 202 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Japanese)

Currently translated at 98.2% (112 of 114 strings)

Translated using Weblate (Dutch)

Currently translated at 96.4% (110 of 114 strings)

Translated using Weblate (Dutch)

Currently translated at 85.9% (801 of 932 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (3497 of 3497 strings)

Translated using Weblate (German)

Currently translated at 99.3% (3475 of 3497 strings)

Translated using Weblate (German)

Currently translated at 100.0% (253 of 253 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (German)

Currently translated at 100.0% (253 of 253 strings)

Translated using Weblate (German)

Currently translated at 100.0% (253 of 253 strings)

Translated using Weblate (German)

Currently translated at 99.6% (252 of 253 strings)

Translated using Weblate (German)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (German)

Currently translated at 98.8% (250 of 253 strings)

Translated using Weblate (German)

Currently translated at 98.8% (250 of 253 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (91 of 91 strings)

Translated using Weblate (German)

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (Dutch)

Currently translated at 84.0% (783 of 932 strings)

Translated using Weblate (German)

Currently translated at 100.0% (932 of 932 strings)

Translated using Weblate (Dutch)

Currently translated at 49.4% (125 of 253 strings)

Translated using Weblate (Dutch)

Currently translated at 96.7% (88 of 91 strings)

Translated using Weblate (German)

Currently translated at 99.3% (3473 of 3497 strings)

Translated using Weblate (German)

Currently translated at 99.3% (3473 of 3497 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 98.6% (3451 of 3497 strings)

Translated using Weblate (Dutch)

Currently translated at 47.0% (119 of 253 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 98.3% (3440 of 3497 strings)

Translated using Weblate (Dutch)

Currently translated at 72.5% (2536 of 3497 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 96.6% (3380 of 3497 strings)

Translated using Weblate (Dutch)

Currently translated at 72.2% (2528 of 3497 strings)

Translated using Weblate (Dutch)

Currently translated at 99.1% (243 of 245 strings)

Translated using Weblate (Dutch)

Currently translated at 99.4% (188 of 189 strings)

Translated using Weblate (Dutch)

Currently translated at 47.0% (119 of 253 strings)

Translated using Weblate (Dutch)

Currently translated at 47.0% (119 of 253 strings)

Translated using Weblate (Dutch)

Currently translated at 47.0% (119 of 253 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (412 of 412 strings)

Translated using Weblate (German)

Currently translated at 99.2% (3470 of 3497 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (862 of 862 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (862 of 862 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (862 of 862 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 98.2% (112 of 114 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (15 of 15 strings)

Translated using Weblate (Portuguese)

Currently translated at 24.1% (61 of 253 strings)

Translated using Weblate (German)

Currently translated at 96.5% (140 of 145 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 82.1% (708 of 862 strings)

Translated using Weblate (German)

Currently translated at 99.1% (3468 of 3497 strings)

Translated using Weblate (French)

Currently translated at 100.0% (3497 of 3497 strings)

Translated using Weblate (German)

Currently translated at 99.1% (3466 of 3497 strings)

Translated using Weblate (Swedish)

Currently translated at 94.6% (53 of 56 strings)

Translated using Weblate (German)

Currently translated at 99.0% (3464 of 3497 strings)

Translated using Weblate (Portuguese)

Currently translated at 93.3% (14 of 15 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (862 of 862 strings)

Translated using Weblate (Portuguese)

Currently translated at 68.5% (591 of 862 strings)

Translated using Weblate (Portuguese)

Currently translated at 91.0% (184 of 202 strings)

Translated using Weblate (Ukrainian)

Currently translated at 53.9% (1885 of 3497 strings)

Translated using Weblate (Ukrainian)

Currently translated at 95.0% (192 of 202 strings)

Translated using Weblate (Ukrainian)

Currently translated at 99.5% (928 of 932 strings)

Co-authored-by: Adam Rimanek <adamrimanek.imsp@gmail.com>
Co-authored-by: Alison Alex <spamkari@hotmail.com>
Co-authored-by: Andrés Leiva Barco <andresleibar@gmail.com>
Co-authored-by: Artemis <circlegohard@gmail.com>
Co-authored-by: BMA <simpintis@gmail.com>
Co-authored-by: Begümay Çınar <begumay@proton.me>
Co-authored-by: Céu <marcel.ufscar@gmail.com>
Co-authored-by: Daisy Hsu <HsuD@stmaryscambridge.co.uk>
Co-authored-by: Deleted User <noreply+908@weblate.org>
Co-authored-by: Duggu Ghosh <duggu52d@gmail.com>
Co-authored-by: DumbDump <Schernova13@yandex.ru>
Co-authored-by: Fake Name <alpasalp220@gmail.com>
Co-authored-by: Gizem <gizem100296@gmail.com>
Co-authored-by: Harry Erickson <harry3rickson@gmail.com>
Co-authored-by: Jamie Herbert <pth23tpg@bangor.ac.uk>
Co-authored-by: Jan Freihöfer <jan.stauch.is@gmail.com>
Co-authored-by: Jeremia Hölscher <holscherjury@gmail.com>
Co-authored-by: Jildau Bras <jildaubras@gmail.com>
Co-authored-by: Julius Eikmans <jcs.e@icloud.com>
Co-authored-by: Lawan Fathullah <fathullahlawan@gmail.com>
Co-authored-by: Lizard <li07369427zard@gmail.com>
Co-authored-by: Lucas Vitor de Farias <Lucasvi90robloxbr@gmail.com>
Co-authored-by: Luidson Alejandro Froz Paiva <Luidpah1@gmail.com>
Co-authored-by: Léa <ambredf@outlook.fr>
Co-authored-by: Maria Morant <luisa.morant@yahoo.com>
Co-authored-by: Mausam <mausam_b@protonmail.com>
Co-authored-by: Mia S <miveliina@gmail.com>
Co-authored-by: Nora <yenyisun@gmail.com>
Co-authored-by: Prayaag Thakkar <prayaag13@outlook.com>
Co-authored-by: Rafael Hoyos Arango <rafa131842@gmail.com>
Co-authored-by: SY <smartyeti@proton.me>
Co-authored-by: Sebastian Ramorino <sebarc2005@gmail.com>
Co-authored-by: Sergey <sergejepihin323@gmail.com>
Co-authored-by: Serhii <serzh.photograf@gmail.com>
Co-authored-by: Sonia <sophishport@gmail.com>
Co-authored-by: Sophie LE MASLE <sophiesuff@gmail.com>
Co-authored-by: Summer_GUI <heyang94@163.com>
Co-authored-by: Toro Mor <thomas.bizer@gmx.de>
Co-authored-by: Translatlantic Translation <translatlanticom@gmail.com>
Co-authored-by: Uwe B <hbtca@tunixgut.de>
Co-authored-by: Viktor Révész <rviktor@ivankapal.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: Yaezch <dkrovel@gmail.com>
Co-authored-by: Yorick Steffens <yorick.steffens@gmail.com>
Co-authored-by: Zarah Lundberg <sar_lun@hotmail.com>
Co-authored-by: donkie <lllllllovinllllll@gmail.com>
Co-authored-by: kim ham <kim@pfts.se>
Co-authored-by: viyu viyu <gnaremoob@binotuz.com>
Co-authored-by: いんこ <ayakabooker@gmail.com>
Co-authored-by: ? <importantdata78@gmail.com>
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/cs/
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/de/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/es_419/
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/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/en_GB/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/es/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/zh_Hant/
Translate-URL: https://translate.habitica.com/projects/habitica/character/de/
Translate-URL: https://translate.habitica.com/projects/habitica/character/en_GB/
Translate-URL: https://translate.habitica.com/projects/habitica/character/es/
Translate-URL: https://translate.habitica.com/projects/habitica/character/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/character/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/character/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/character/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/character/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/character/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/character/zh_Hant/
Translate-URL: https://translate.habitica.com/projects/habitica/communityguidelines/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/communityguidelines/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/content/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/content/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/content/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/contrib/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/contrib/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/death/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/death/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/death/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/death/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/death/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/cs/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/de/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/es/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/ru/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/front/de/
Translate-URL: https://translate.habitica.com/projects/habitica/front/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/front/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/front/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/front/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/front/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/de/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/en_GB/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/ja/
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/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/zh_Hant/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/de/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/en_GB/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/es/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/de/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/de/
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/limited/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/messages/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/zh_Hant/
Translate-URL: https://translate.habitica.com/projects/habitica/overview/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/pets/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/pets/zh_Hant/
Translate-URL: https://translate.habitica.com/projects/habitica/quests/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/zh_Hant/
Translate-URL: https://translate.habitica.com/projects/habitica/rebirth/cs/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/spells/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/ru/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/zh_Hant/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/de/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/en_GB/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/es/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/ru/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/uk/
Translation: Habitica/Achievements
Translation: Habitica/Backgrounds
Translation: Habitica/Challenge
Translation: Habitica/Character
Translation: Habitica/Communityguidelines
Translation: Habitica/Content
Translation: Habitica/Contrib
Translation: Habitica/Death
Translation: Habitica/Faq
Translation: Habitica/Front
Translation: Habitica/Gear
Translation: Habitica/Generic
Translation: Habitica/Groups
Translation: Habitica/Limited
Translation: Habitica/Messages
Translation: Habitica/Npc
Translation: Habitica/Overview
Translation: Habitica/Pets
Translation: Habitica/Quests
Translation: Habitica/Questscontent
Translation: Habitica/Rebirth
Translation: Habitica/Settings
Translation: Habitica/Spells
Translation: Habitica/Subscriber
Translation: Habitica/Tasks
2026-03-05 11:27:29 +01:00
Hafiz 1047b0e03b up habitica-markdown -> 4.1.0 & defensive check 2026-03-02 14:32:58 -06:00
Kalista Payne 159f850bd1 fix(avatar): correct margin override in buy modal 2026-02-27 15:09:11 -06:00
Fiz 42083efb7e Emojis Update (#15620)
* Add group plan selection modal for upgrades

Allow users to select an existing group to upgrade before creating a new one.

* crlf -> lf lint

* set selection of group plan

Also tiny UI fixes

* Update group plan selection to include expired plans

* Add includeExpiredPlans option to group fetching

* force flag when fetching group plans

* Update group plan eligibility check

* Fix eslint error in push notification import

* replace chaining (?.) w/null check

* Remove comment

* set initial selected group plan, and fix card rounding

* format member count

* Show warning for pending party invites when upgrading to paid group plan

Show warning for pending party invites when upgrading to paid group plan. If user upgrades from party to group, remove any pending invites

* suppress error toasts for group modal, and UI tweaks for group modal

suppress error toast for 404 on party fetch for users without a party (for group modal), Increase check SVG size in selectableCard, and show "Previously upgraded" label for parties that were canceled group plans

* Clear upgradingGroup state after group plan payment

* Update emoji system to native Unicode rendering

* Fix line endings in habiticaMarkdown test

* fix indented code block detection for markdown-it v14

* update habitica-markdown to include v3 emoji dataset  (pointed towards test branch)

* size emoji in markdown

* emoji autocomplete to chat, messages, tasks, and profile

add :emoji shortcode autocomplete dropdown (reusing existing autocomplete mixin w/new helper)

* try upping github-action fix

* trying another github actions fix

* update habitica-markdown package version (v3.0.0 -> v4.0.0)

* Fix emoji autocomplete overlapping actual text

position dropdown below text

* update group-plans info card styles

* Support Melior emoji autocomplete & more places for emoji autocomplete

Include emoji autocomplete in task checklists, tags, challenge name/summary/description

* position emoji autocomplete dropdown below text area

* fix: replace nested ternary

* Emoji autocomplete fixes

Fix emoji autocomplete overlapping checklist text, and add short name emoji autocomplete

* Have emoji autocomplete dropdown directly below text, add to task tag

* Fix emoji autocomplete starting at beginning/end initially

* lint/line length

* Add group plan selection modal for upgrades

Allow users to select an existing group to upgrade before creating a new one.

* crlf -> lf lint

* set selection of group plan

Also tiny UI fixes

* Update group plan selection to include expired plans

* Add includeExpiredPlans option to group fetching

* force flag when fetching group plans

* Update group plan eligibility check

* Fix eslint error in push notification import

* replace chaining (?.) w/null check

* Remove comment

* set initial selected group plan, and fix card rounding

* format member count

* Show warning for pending party invites when upgrading to paid group plan

Show warning for pending party invites when upgrading to paid group plan. If user upgrades from party to group, remove any pending invites

* suppress error toasts for group modal, and UI tweaks for group modal

suppress error toast for 404 on party fetch for users without a party (for group modal), Increase check SVG size in selectableCard, and show "Previously upgraded" label for parties that were canceled group plans

* Clear upgradingGroup state after group plan payment

* Update emoji system to native Unicode rendering

* Fix line endings in habiticaMarkdown test

* fix indented code block detection for markdown-it v14

* update habitica-markdown to include v3 emoji dataset  (pointed towards test branch)

* size emoji in markdown

* emoji autocomplete to chat, messages, tasks, and profile

add :emoji shortcode autocomplete dropdown (reusing existing autocomplete mixin w/new helper)

* try upping github-action fix

* trying another github actions fix

* update habitica-markdown package version (v3.0.0 -> v4.0.0)

* Fix emoji autocomplete overlapping actual text

position dropdown below text

* update group-plans info card styles

* Support Melior emoji autocomplete & more places for emoji autocomplete

Include emoji autocomplete in task checklists, tags, challenge name/summary/description

* position emoji autocomplete dropdown below text area

* fix: replace nested ternary

* Emoji autocomplete fixes

Fix emoji autocomplete overlapping checklist text, and add short name emoji autocomplete

* Have emoji autocomplete dropdown directly below text, add to task tag

* Fix emoji autocomplete starting at beginning/end initially

* lint/line length

* Revert "trying another github actions fix"

This reverts commit 72fc7fc20e.

* Revert "try upping github-action fix"

This reverts commit 70e48a57aa.

* fix(git): revert ci changes

---------

Co-authored-by: Kalista Payne <kalista@habitica.com>
2026-02-26 16:36:37 -06:00
Fiz f21e800b0b Group Plan Modal (#15588)
* Add group plan selection modal for upgrades

Allow users to select an existing group to upgrade before creating a new one.

* crlf -> lf lint

* set selection of group plan

Also tiny UI fixes

* Update group plan selection to include expired plans

* Add includeExpiredPlans option to group fetching

* force flag when fetching group plans

* Update group plan eligibility check

* Fix eslint error in push notification import

* replace chaining (?.) w/null check

* Remove comment

* set initial selected group plan, and fix card rounding

* format member count

* Show warning for pending party invites when upgrading to paid group plan

Show warning for pending party invites when upgrading to paid group plan. If user upgrades from party to group, remove any pending invites

* suppress error toasts for group modal, and UI tweaks for group modal

suppress error toast for 404 on party fetch for users without a party (for group modal), Increase check SVG size in selectableCard, and show "Previously upgraded" label for parties that were canceled group plans

* Clear upgradingGroup state after group plan payment
2026-02-26 16:02:47 -06:00
Kalista Payne 40122e5621 5.46.0 2026-02-26 12:08:13 -06:00
Kalista Payne 0ae19d9107 Squashed commit of the following:
commit 963b4133ec
Author: Kalista Payne <kalista@habitica.com>
Date:   Thu Feb 19 15:48:41 2026 -0600

    fix(text): clean up some gear descriptions

commit 53999a5b80
Author: Kalista Payne <kalista@habitica.com>
Date:   Wed Feb 4 17:18:07 2026 -0600

    fix(content): add seasonal set tokens

commit 4510c90e41
Author: Kalista Payne <kalista@habitica.com>
Date:   Wed Feb 4 17:10:24 2026 -0600

    feat(content): March-May 2026
2026-02-24 12:02:52 -06:00
Kalista Payne 68bfebcf30 5.45.0 2026-02-24 10:18:25 -06:00
Phillip Thelen 3e93911e70 Phillip/prod perf2 (#15596)
* Add new api call for kubernetes startup probe

* add hostname as tag for loggly

* Only listen to one change

* increase vite min chunk size

* respond gracefully to shutdown signal

* update server readiness according to mongodb and redis connection

* make larger vite chunks

* fix lint
2026-02-24 10:17:21 -06:00
Kalista Payne 4ea8636f03 fix(profile): correct stat display for class gear 2026-02-20 13:19:56 -06:00
Kalista Payne 9f97a09b8c fix(export): remove deprecated routes 2026-02-19 15:45:38 -06:00
Phillip Thelen eccc115b73 Admin Panel fixes (#15613)
* fix profile link to admin panel

* fix profile looking broken when no background is equipped
2026-02-19 11:11:25 -06:00
Tanmay Nalawade 2b26eb2bd1 Habit and Daily task counts not displaying for certain filters (#15595)
* habits-all fixed

* dailies section fixed

* to do counter fixed

* Remove linting changes and apply logic fix with single quotes

* refactor(tasks): simpler badgeCount logic

* fix(lint): remove unused import

---------

Co-authored-by: Kalista Payne <kalista@habitica.com>
2026-02-19 11:09:40 -06:00
Kalista Payne 8e042cabc4 5.44.3 2026-02-10 17:37:48 -06:00
Kalista Payne 8abe167848 chore(subproj): update habitica-images 2026-02-10 17:37:45 -06:00
Kalista Payne 3414f962e2 fix(lint): string style and whitespace 2026-02-10 17:13:02 -06:00
Kalista Payne 1b68e6d4d3 fix(event): show Spring Seasonal Shop on Valentines 2026-02-10 17:07:47 -06:00
Kalista Payne 5dd9711413 Update local dev MongoDB versions (#15334)
* chore(mongodb): update local dev MongoDB versions

* fix(github): update mongodb-github-action

* test(api): attempt to gather more detail on failures

* Revert "test(api): attempt to gather more detail on failures"

This reverts commit 215e768e90.

* WIP mongodb-memory-serve

* fix(mongo): start replica set

* run mongo as gh action service

* remove matrix for mongo

* try npm -> docker instead of services

* try "docker compose"

* disable mongo bootstrap from build

* try gh action again

* try newer action version

* working mongo docker compose 🎉

* fix(lint): leave out unused imports

* update lock

* cleanup previous workflow changes

* remove previous code, dont share mongo data folders on runtype (rs and docker)

* mongo docker for testing; align mongodb directory naming

* remove run-rs, add docker:aio script call, use healthcheck to initiate again

* merge docker-compose.yml, fix client port listening

* fix oudated healthcheck param

* chore(mongodb): update local dev MongoDB versions

* fix(github): update mongodb-github-action

* test(api): attempt to gather more detail on failures

* Revert "test(api): attempt to gather more detail on failures"

This reverts commit 215e768e90.

* WIP mongodb-memory-serve

* run mongo as gh action service

* fix(mongo): start replica set

* remove matrix for mongo

* try npm -> docker instead of services

* try "docker compose"

* disable mongo bootstrap from build

* try gh action again

* try newer action version

* working mongo docker compose 🎉

* fix(lint): leave out unused imports

* update lock

* cleanup previous workflow changes

* remove previous code, dont share mongo data folders on runtype (rs and docker)

* mongo docker for testing; align mongodb directory naming

* remove run-rs, add docker:aio script call, use healthcheck to initiate again

* merge docker-compose.yml, fix client port listening

* fix oudated healthcheck param

* fix(config): remove dup keys

* using npx vite during docker aio run

---------

Co-authored-by: negue <eugen.bolz@gmail.com>
2026-01-30 17:19:35 -06:00
Kalista Payne a542277a41 5.44.2 2026-01-26 11:46:03 -06:00
Phillip Thelen cdf8556fd6 Improve performance in production setup (#15594)
* build cached content files for mobile during gulp build

* load already cached content files during startup

* add option for mongoose to define minPoolSize

* cache client index.html for 10 minutes. Improves initial load times

* add option to auth to use lean version of user doc

* add a way to produce a heapdump from the command line

* fix lint
2026-01-26 11:45:44 -06:00
Kalista Payne 3d93390a7a fix(languages): update template characters 2026-01-23 13:09:14 -06:00
Kalista Payne 59f9cfa0f4 5.44.1 2026-01-23 13:03:30 -06:00
Kalista Payne 80d7804f69 fix(strings): use consistent template characters 2026-01-23 12:53:29 -06:00
Kalista Payne 4e5efe09a3 5.44.0 2026-01-22 11:53:20 -06:00
Fiz d42a597672 Merge pull request #15590 from HabitRPG/kalista/paypal-debug
PayPal debug
2026-01-21 16:11:41 -06:00
Phillip Thelen ea17b2e9c7 Rework how strings are localized (#15589)
* replace lodash template usage with micromustache

* remove function brackets from translations

* add newline

* remove old test

* split core translations from content translations

* fix directory not existing

* fix lint
2026-01-21 14:34:25 -06:00
Phillip Thelen f56708cd88 fix most customizations not being pinnable (#15578)
* fix most customizations not being pinnable

* set correct pinTypes

* fix(pinning): correct purchase types for base hair and mustaches

* automatically unpin purchased customizations

* ability to pin customization items

* Fix pin not showing on buy modal

* Pin on buy modal tweak

---------

Co-authored-by: Kalista Payne <kalista@habitica.com>
Co-authored-by: Hafiz <hafizbhamidi@gmail.com>
2026-01-21 13:57:42 -06:00
Kalista Payne 005d14f6e8 5.43.4 2026-01-20 14:36:36 -06:00
Weblate c05a96ce6c Translated using Weblate (Polish)
Currently translated at 100.0% (131 of 131 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (131 of 131 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (131 of 131 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (245 of 245 strings)

Translated using Weblate (Portuguese)

Currently translated at 23.7% (60 of 253 strings)

Translated using Weblate (Portuguese)

Currently translated at 23.7% (60 of 253 strings)

Translated using Weblate (Portuguese)

Currently translated at 23.7% (60 of 253 strings)

Translated using Weblate (Portuguese)

Currently translated at 90.5% (183 of 202 strings)

Translated using Weblate (Ukrainian)

Currently translated at 99.1% (113 of 114 strings)

Translated using Weblate (Portuguese)

Currently translated at 27.6% (70 of 253 strings)

Translated using Weblate (Portuguese)

Currently translated at 92.9% (106 of 114 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (253 of 253 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 94.8% (3318 of 3497 strings)

Translated using Weblate (Ukrainian)

Currently translated at 98.6% (143 of 145 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 94.8% (3317 of 3497 strings)

Translated using Weblate (English (United Kingdom))

Currently translated at 96.8% (245 of 253 strings)

Translated using Weblate (English (United Kingdom))

Currently translated at 94.0% (190 of 202 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (245 of 245 strings)

Translated using Weblate (Portuguese)

Currently translated at 50.1% (1753 of 3497 strings)

Translated using Weblate (French)

Currently translated at 100.0% (3497 of 3497 strings)

Translated using Weblate (Indonesian)

Currently translated at 97.1% (238 of 245 strings)

Translated using Weblate (Ukrainian)

Currently translated at 79.3% (150 of 189 strings)

Translated using Weblate (Ukrainian)

Currently translated at 99.5% (928 of 932 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (430 of 430 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (932 of 932 strings)

Co-authored-by: Ashlynn <ashlynn.samuella@gmail.com>
Co-authored-by: Cezary Polakowski <arutha86@gmail.com>
Co-authored-by: Daniel Costa Carvalho <danielcostacarvalho@gmail.com>
Co-authored-by: Illana Beatriz Rocha de Oliveira <dev.illanabeatriz@gmail.com>
Co-authored-by: Lu dG <ludgs@outlook.fr>
Co-authored-by: Lyam Santos Peres <karinesanper@gmail.com>
Co-authored-by: Maria Morant <luisa.morant@yahoo.com>
Co-authored-by: Nina Łapaj <ninapaj@gmail.com>
Co-authored-by: Ri Vargas <goldenhaitang@gmail.com>
Co-authored-by: Sonia <sophishport@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: いんこ <ayakabooker@gmail.com>
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/character/en_GB/
Translate-URL: https://translate.habitica.com/projects/habitica/character/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/en_GB/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/front/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/fr/
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/generic/id/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/pl/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/pl/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/uk/
Translation: Habitica/Backgrounds
Translation: Habitica/Challenge
Translation: Habitica/Character
Translation: Habitica/Faq
Translation: Habitica/Front
Translation: Habitica/Gear
Translation: Habitica/Generic
Translation: Habitica/Groups
Translation: Habitica/Npc
Translation: Habitica/Tasks
2026-01-20 21:33:14 +01:00
Kalista Payne 8fdbfb9dc6 fix(lint): import order 2026-01-16 17:47:05 -06:00
Kalista Payne 057a642baa fix(logger): path 2026-01-16 17:43:54 -06:00
Kalista Payne 6c522157a7 feat(payments): log some anonymous data to chase a bug 2026-01-16 17:38:16 -06:00
Fiz ba9a1ab2a9 Merge pull request #15583 from HabitRPG/kalista/username-sanitize
Sanitize usernames come from social auth
2026-01-15 12:00:56 -06:00
Kalista Payne 4767461c4f 5.43.3 2026-01-14 14:19:54 -06:00
Weblate 847c97dc8f Translated using Weblate (Portuguese (Brazil))
Currently translated at 94.7% (3314 of 3497 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% (932 of 932 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (932 of 932 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (932 of 932 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (430 of 430 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (862 of 862 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (430 of 430 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (288 of 288 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (430 of 430 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (288 of 288 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (275 of 275 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (430 of 430 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (276 of 276 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (94 of 94 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (8 of 8 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (288 of 288 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (430 of 430 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (430 of 430 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 94.7% (3314 of 3497 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (253 of 253 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (862 of 862 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (91 of 91 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (932 of 932 strings)

Translated using Weblate (Indonesian)

Currently translated at 93.0% (228 of 245 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (932 of 932 strings)

Translated using Weblate (Polish)

Currently translated at 98.9% (272 of 275 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (Danish)

Currently translated at 88.1% (178 of 202 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (131 of 131 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (288 of 288 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (58 of 58 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (15 of 15 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (932 of 932 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (932 of 932 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (932 of 932 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (932 of 932 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (932 of 932 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (167 of 167 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (167 of 167 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (167 of 167 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (167 of 167 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (167 of 167 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (167 of 167 strings)

Translated using Weblate (Danish)

Currently translated at 66.5% (183 of 275 strings)

Translated using Weblate (German)

Currently translated at 98.9% (3461 of 3497 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (253 of 253 strings)

Translated using Weblate (Danish)

Currently translated at 92.5% (175 of 189 strings)

Translated using Weblate (Danish)

Currently translated at 9.0% (23 of 253 strings)

Translated using Weblate (German)

Currently translated at 99.0% (200 of 202 strings)

Translated using Weblate (German)

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Danish)

Currently translated at 62.5% (172 of 275 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (91 of 91 strings)

Translated using Weblate (Japanese)

Currently translated at 96.5% (195 of 202 strings)

Translated using Weblate (Japanese)

Currently translated at 98.4% (249 of 253 strings)

Translated using Weblate (French)

Currently translated at 100.0% (3497 of 3497 strings)

Translated using Weblate (German)

Currently translated at 98.3% (3440 of 3497 strings)

Translated using Weblate (French)

Currently translated at 100.0% (253 of 253 strings)

Translated using Weblate (French)

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 94.7% (3314 of 3497 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (253 of 253 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (253 of 253 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (202 of 202 strings)

Co-authored-by: Ashlynn <ashlynn.samuella@gmail.com>
Co-authored-by: Douglas Meneghetti <douglasrizzom@gmail.com>
Co-authored-by: Gabriela <gabisouzars5@gmail.com>
Co-authored-by: Helene Bæck Christensen <helene.baeck@gmail.com>
Co-authored-by: Henrique Ferreira <pedroferreira217.ph@gmail.com>
Co-authored-by: Illana Beatriz Rocha de Oliveira <dev.illanabeatriz@gmail.com>
Co-authored-by: Isabela de Carvalho <isabela.c.escritora@gmail.com>
Co-authored-by: Joanna K <joanna.kociolek0@gmail.com>
Co-authored-by: Kenvinn <kevinsavio514@gmail.com>
Co-authored-by: Lucas Rafaldini <lucas.rafaldini@gmail.com>
Co-authored-by: Luã Fhelyp Guimarães <fhelypg@gmail.com>
Co-authored-by: Lyam Santos Peres <kaka1213spaenrteoss@gmail.com>
Co-authored-by: Lyam Santos Peres <karinesanper@gmail.com>
Co-authored-by: Maycon Douglas Silva Santos <maycondss@live.com>
Co-authored-by: Ri Vargas <goldenhaitang@gmail.com>
Co-authored-by: Rodrigo Gonçalves Braga <rgbraga@gmail.com>
Co-authored-by: Sophie LE MASLE <sophiesuff@gmail.com>
Co-authored-by: Summer_GUI <heyang94@163.com>
Co-authored-by: Thiago Braga <thibraga06@gmail.com>
Co-authored-by: Toro Mor <thomas.bizer@gmx.de>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: purea <g@agaric.eu>
Co-authored-by: いんこ <ayakabooker@gmail.com>
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/pl/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/de/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/id/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/pl/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/character/da/
Translate-URL: https://translate.habitica.com/projects/habitica/character/de/
Translate-URL: https://translate.habitica.com/projects/habitica/character/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/character/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/character/pl/
Translate-URL: https://translate.habitica.com/projects/habitica/character/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/character/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/communityguidelines/id/
Translate-URL: https://translate.habitica.com/projects/habitica/communityguidelines/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/death/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/da/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/id/
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/faq/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/front/da/
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/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/id/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/messages/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/overview/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/pets/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/quests/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/da/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/pl/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/pl/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/pt_BR/
Translation: Habitica/Achievements
Translation: Habitica/Backgrounds
Translation: Habitica/Challenge
Translation: Habitica/Character
Translation: Habitica/Communityguidelines
Translation: Habitica/Death
Translation: Habitica/Faq
Translation: Habitica/Front
Translation: Habitica/Gear
Translation: Habitica/Generic
Translation: Habitica/Groups
Translation: Habitica/Limited
Translation: Habitica/Messages
Translation: Habitica/Npc
Translation: Habitica/Overview
Translation: Habitica/Pets
Translation: Habitica/Quests
Translation: Habitica/Questscontent
Translation: Habitica/Settings
Translation: Habitica/Subscriber
Translation: Habitica/Tasks
2026-01-14 21:18:58 +01:00
Kalista Payne 215b26acac fix(auth): run the actual verify check 2026-01-13 17:01:09 -06:00
Phillip Thelen e223e7821a add config to disable ssl and base_url enforcement (#15587) 2026-01-13 16:44:23 -06:00
Fiz 8134fa7c00 Chat optimization (#15545)
* fix(content): textual tweaks and updates

* fix(link): direct to FAQ instead of wiki

* fix(faq): correct Markdown

* Show orb of rebirth confirmation modal after use (window refresh)

* Set and check rebirth confirmation modal from localstorage

Set and check rebirth confirmation modal from localstorage after window reload

* Don't show orb of rebirth confirmation modal until page reloads

* message effective limit optimization

* Keep max limit for web (400 recent messages)

* Fix amount of messages initially being shown

* PM_PER_PAGE set to 50

* Increases number of messages in inbox test

* Increases number of messages for inbox pagination test

* Set and check rebirth confirmation modal from localstorage

Set and check rebirth confirmation modal from localstorage after window reload

* Don't show orb of rebirth confirmation modal until page reloads

* message effective limit optimization

* Keep max limit for web (400 recent messages)

* Add UUID validation for 'before' query parameter

* add party message stress test tool in admin panel

* lint

* add MAX_PM_COUNT of 400, admin tool for stress testing messages

* comment

* update stress test inbox message tool to use logged in user

* comment

---------

Co-authored-by: Kalista Payne <kalista@habitica.com>
2026-01-13 14:43:09 -06:00
Kalista Payne 84208f612e 5.43.2 2026-01-09 10:43:36 -06:00
Hafiz 57e06334c0 Update allocation option label 2026-01-09 09:05:00 -06:00
Kalista Payne be695d25b3 5.43.1 2026-01-08 16:08:52 -06:00
Kalista Payne 77ee83f467 fix(privacy): ensure opt-in for analytics in modal 2026-01-08 15:40:09 -06:00
Kalista Payne 86556e346b 5.43.0 2026-01-08 14:22:27 -06:00
Weblate 2007a872c6 Merge branch 'origin/develop' into Weblate. 2026-01-08 21:20:01 +01:00
Weblate a8348038de Translated using Weblate (Italian)
Currently translated at 80.8% (2829 of 3497 strings)

Translated using Weblate (Italian)

Currently translated at 80.4% (2813 of 3497 strings)

Translated using Weblate (French)

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Italian)

Currently translated at 79.5% (2781 of 3497 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (245 of 245 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Indonesian)

Currently translated at 96.4% (110 of 114 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (288 of 288 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 94.6% (3310 of 3497 strings)

Translated using Weblate (German)

Currently translated at 98.3% (3438 of 3497 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (245 of 245 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (French)

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (French)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (French)

Currently translated at 100.0% (245 of 245 strings)

Translated using Weblate (French)

Currently translated at 93.0% (188 of 202 strings)

Translated using Weblate (French)

Currently translated at 100.0% (114 of 114 strings)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Translated using Weblate (Italian)

Currently translated at 100.0% (932 of 932 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 93.4% (3268 of 3497 strings)

Translated using Weblate (Korean)

Currently translated at 99.7% (930 of 932 strings)

Translated using Weblate (Korean)

Currently translated at 72.1% (622 of 862 strings)

Translated using Weblate (Korean)

Currently translated at 75.8% (326 of 430 strings)

Translated using Weblate (Korean)

Currently translated at 97.7% (129 of 132 strings)

Translated using Weblate (Korean)

Currently translated at 37.2% (92 of 247 strings)

Translated using Weblate (Korean)

Currently translated at 93.6% (104 of 111 strings)

Translated using Weblate (Korean)

Currently translated at 97.5% (909 of 932 strings)

Translated using Weblate (Korean)

Currently translated at 91.4% (86 of 94 strings)

Translated using Weblate (Korean)

Currently translated at 60.9% (173 of 284 strings)

Translated using Weblate (Korean)

Currently translated at 72.5% (312 of 430 strings)

Translated using Weblate (Korean)

Currently translated at 49.7% (1739 of 3497 strings)

Translated using Weblate (Korean)

Currently translated at 100.0% (15 of 15 strings)

Translated using Weblate (Korean)

Currently translated at 63.7% (58 of 91 strings)

Translated using Weblate (Turkish)

Currently translated at 55.5% (160 of 288 strings)

Translated using Weblate (Turkish)

Currently translated at 34.8% (86 of 247 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 76.1% (188 of 247 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 92.7% (3243 of 3497 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 92.6% (3240 of 3497 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 92.4% (3233 of 3497 strings)

Translated using Weblate (Turkish)

Currently translated at 72.7% (627 of 862 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (276 of 276 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (862 of 862 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (288 of 288 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (430 of 430 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 92.3% (3229 of 3497 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 92.3% (3229 of 3497 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (932 of 932 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 90.5% (3165 of 3497 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 90.5% (3165 of 3497 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 98.6% (284 of 288 strings)

Translated using Weblate (Korean)

Currently translated at 70.7% (610 of 862 strings)

Translated using Weblate (Korean)

Currently translated at 71.3% (307 of 430 strings)

Translated using Weblate (Korean)

Currently translated at 49.6% (1737 of 3497 strings)

Translated using Weblate (Turkish)

Currently translated at 71.6% (618 of 862 strings)

Translated using Weblate (Romanian)

Currently translated at 56.5% (156 of 276 strings)

Translated using Weblate (Romanian)

Currently translated at 91.2% (104 of 114 strings)

Translated using Weblate (Romanian)

Currently translated at 98.2% (57 of 58 strings)

Translated using Weblate (Japanese)

Currently translated at 98.1% (3432 of 3497 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Turkish)

Currently translated at 63.9% (551 of 862 strings)

Translated using Weblate (Japanese)

Currently translated at 97.9% (3427 of 3497 strings)

Translated using Weblate (English (United Kingdom))

Currently translated at 100.0% (430 of 430 strings)

Translated using Weblate (English (United Kingdom))

Currently translated at 100.0% (275 of 275 strings)

Translated using Weblate (Japanese)

Currently translated at 97.8% (3423 of 3497 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 98.2% (57 of 58 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 89.3% (218 of 244 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 77.2% (146 of 189 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 82.1% (708 of 862 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 96.7% (88 of 91 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 100.0% (193 of 193 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 95.4% (106 of 111 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 71.6% (197 of 275 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (3497 of 3497 strings)

Translated using Weblate (Spanish)

Currently translated at 99.9% (3495 of 3497 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (132 of 132 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (47 of 47 strings)

Translated using Weblate (Turkish)

Currently translated at 87.3% (241 of 276 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (91 of 91 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (111 of 111 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (276 of 276 strings)

Translated using Weblate (English (United Kingdom))

Currently translated at 69.9% (2446 of 3497 strings)

Translated using Weblate (German)

Currently translated at 98.2% (3436 of 3497 strings)

Translated using Weblate (Turkish)

Currently translated at 74.6% (206 of 276 strings)

Translated using Weblate (Turkish)

Currently translated at 74.6% (206 of 276 strings)

Translated using Weblate (Turkish)

Currently translated at 69.5% (192 of 276 strings)

Translated using Weblate (Turkish)

Currently translated at 65.9% (182 of 276 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (8 of 8 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (193 of 193 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (932 of 932 strings)

Translated using Weblate (Turkish)

Currently translated at 85.5% (797 of 932 strings)

Co-authored-by: Ashlynn <ashlynn.samuella@gmail.com>
Co-authored-by: Begümay Çınar <begumay@proton.me>
Co-authored-by: Céu <marcel.ufscar@gmail.com>
Co-authored-by: Douglas Meneghetti <douglasrizzom@gmail.com>
Co-authored-by: Frodo <Zatriel@outlook.com.br>
Co-authored-by: Israel Silva Araújo <yisrael.araujo@gmail.com>
Co-authored-by: Jaime Martí <jaumemarti77@icloud.com>
Co-authored-by: John B <habitica.quarters574@passmail.net>
Co-authored-by: Lucas Lo <lucas1020.rabbit@gmail.com>
Co-authored-by: Luizo <t.czj2019@gmail.com>
Co-authored-by: Lyam Santos Peres <kaka1213spaenrteoss@gmail.com>
Co-authored-by: Márcio Ramos Corrêa <marcio.ramos.correa@gmail.com>
Co-authored-by: Omar Bertolla <scaram@icloud.com>
Co-authored-by: Sefa Uğurlu <ugurlusefa2@gmail.com>
Co-authored-by: Seohee <ka011215@naver.com>
Co-authored-by: Sophie LE MASLE <sophiesuff@gmail.com>
Co-authored-by: Summer_GUI <heyang94@163.com>
Co-authored-by: Toro Mor <thomas.bizer@gmx.de>
Co-authored-by: Trần Thu Trúc <ngw.nah@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: ‌ <e0gla0f@gmail.com>
Co-authored-by: いんこ <ayakabooker@gmail.com>
Co-authored-by: 張鈞崴 <qoo94230@gmail.com>
Co-authored-by: 강아름 <sick_kar@naver.com>
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/it/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/ko/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/id/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/ko/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/zh_Hant/
Translate-URL: https://translate.habitica.com/projects/habitica/character/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/character/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/character/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/character/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/character/zh_Hant/
Translate-URL: https://translate.habitica.com/projects/habitica/communityguidelines/ko/
Translate-URL: https://translate.habitica.com/projects/habitica/communityguidelines/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/communityguidelines/zh_Hant/
Translate-URL: https://translate.habitica.com/projects/habitica/contrib/id/
Translate-URL: https://translate.habitica.com/projects/habitica/death/ko/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/ko/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/zh_Hant/
Translate-URL: https://translate.habitica.com/projects/habitica/front/zh_Hant/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/de/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/en_GB/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/es/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/it/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/ko/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/zh_Hant/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/en_GB/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/ko/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/ko/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/messages/ro/
Translate-URL: https://translate.habitica.com/projects/habitica/messages/zh_Hant/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/id/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/ko/
Translate-URL: https://translate.habitica.com/projects/habitica/overview/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/pets/id/
Translate-URL: https://translate.habitica.com/projects/habitica/pets/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/pets/ro/
Translate-URL: https://translate.habitica.com/projects/habitica/quests/ko/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/ko/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/zh_Hant/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/en_GB/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/zh_Hant/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/ro/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/zh_Hans/
Translation: Habitica/Backgrounds
Translation: Habitica/Challenge
Translation: Habitica/Character
Translation: Habitica/Communityguidelines
Translation: Habitica/Contrib
Translation: Habitica/Death
Translation: Habitica/Faq
Translation: Habitica/Front
Translation: Habitica/Gear
Translation: Habitica/Generic
Translation: Habitica/Groups
Translation: Habitica/Limited
Translation: Habitica/Messages
Translation: Habitica/Npc
Translation: Habitica/Overview
Translation: Habitica/Pets
Translation: Habitica/Quests
Translation: Habitica/Questscontent
Translation: Habitica/Settings
Translation: Habitica/Subscriber
Translation: Habitica/Tasks
2026-01-08 20:28:08 +01:00
Hafiz 87bcd69979 stat allocation wording 2026-01-08 12:35:31 -06:00
Hafiz 8f8e84d0c7 Stat allocation selection lowercase/capitalization 2026-01-08 11:51:21 -06:00
Hafiz 2c18cb00cc capitalization 2026-01-08 11:27:35 -06:00
Hafiz daa0fd18c0 Remove "points" from task allocation dropdown item title 2026-01-07 19:30:20 -06:00
Kalista Payne 5c555cbf88 fix(auth): revert attempted perf tweak 2026-01-07 17:52:33 -06:00
Kalista Payne 7379c7b230 fix(auth): handle potential collisions on username 2026-01-07 17:26:42 -06:00
Kalista Payne c055537c38 fix(lint): whitespace 2026-01-07 16:49:22 -06:00
Kalista Payne 7559feec8e Revert "fix(lint): whitespace; also revert username fix for further QA"
This reverts commit dcd15a58ebf41d9e44c89c675419ecb7d36b946d.
2026-01-07 16:49:22 -06:00
Kalista Payne 43808696a8 fix(lint): whitespace; also revert username fix for further QA 2026-01-07 16:49:22 -06:00
Kalista Payne 72fb41c7e0 fix(lint): missing variable declaration 2026-01-07 16:45:19 -06:00
Kalista Payne 3bf18e09ed fix(auth): strip invalid characters during social reg 2026-01-07 16:45:19 -06:00
Kalista Payne 407a901883 feat(faq): add detail on stats and allocation 2026-01-07 15:30:37 -06:00
Kalista Payne 81a008906b fix(lint): max-len 2026-01-07 14:36:51 -06:00
Kalista Payne 992a978923 Squashed commit of the following:
commit dcd15a58ebf41d9e44c89c675419ecb7d36b946d
Author: Kalista Payne <kalista@habitica.com>
Date:   Wed Jan 7 14:11:29 2026 -0600

    fix(lint): whitespace; also revert username fix for further QA

commit 8af109f3c358418a778d4dc8387524840343f046
Author: Kalista Payne <kalista@habitica.com>
Date:   Mon Jan 5 15:04:30 2026 -0600

    fix(footer): better hover looks

commit 87518d335967a90d3049cfde9160b341cbf55417
Author: Kalista Payne <kalista@habitica.com>
Date:   Tue Dec 30 15:10:14 2025 -0600

    fix(footer): change to flex layout for better alignment

commit 45ab33c1afaa5b34c56fb09cdcd0f4c12fee0893
Author: Kalista Payne <kalista@habitica.com>
Date:   Tue Dec 30 13:03:56 2025 -0600

    fix(lint): whitespace

commit c9d20e86d29d89b881813a2579ec1ba61acc82cc
Author: Kalista Payne <kalista@habitica.com>
Date:   Tue Dec 30 12:54:04 2025 -0600

    fix(lint): unused var and whitespace

commit 05dc11fd11a530b0b460a951a8be32fe6f4e190e
Author: Kalista Payne <kalista@habitica.com>
Date:   Tue Dec 30 12:48:26 2025 -0600

    fix(footer): rearrange social block, clean up unused donate css

commit 131cda6bb99b4eb53fdadf551bfddc3f712e81e8
Author: Kalista Payne <kalista@habitica.com>
Date:   Mon Dec 29 16:19:18 2025 -0600

    fix(login): move password flows into static wrapper

commit 563ed9fa02a34bbad53421795415e43c955813c1
Author: Kalista Payne <kalista@habitica.com>
Date:   Mon Dec 29 16:02:41 2025 -0600

    fix(login): resetPassword not reset-password route name

commit d23b64a03ec48b5634f05b4500153ed54a0ba6e7
Author: Kalista Payne <kalista@habitica.com>
Date:   Mon Dec 29 15:48:51 2025 -0600

    fix(login): add missing footer, remove dono button

commit 55e841252fbaafdccf75828b7ce87946c185792a
Author: Kalista Payne <kalista@habitica.com>
Date:   Fri Dec 19 15:58:52 2025 -0600

    fix(passwords): standardize styling for reset pass form

commit 7e84a54f104191d6e6d2e86f25070847e1c1c674
Author: Kalista Payne <kalista@habitica.com>
Date:   Tue Dec 16 16:21:50 2025 -0600

    fix(lint): missing variable declaration

commit 777f8f13d7b3b889b5637102b8258b20020c6c6d
Author: Kalista Payne <kalista@habitica.com>
Date:   Tue Dec 16 16:17:19 2025 -0600

    fix(auth): strip invalid characters during social reg
2026-01-07 14:13:47 -06:00
Fiz a8062ad615 Implement stat allocation & auto stat allocation (#15551)
* fix(responsive): adjustments for small screens by @Hafizzle

* Fixed cut off modal issues

- Fixed limited time banner being cut off on smaller devices
- Fixed Pin on purchase quest modal being cut off

* Update order of views on Stats tab

* Update styling of stat boxes & add stat allocation info

* Live stat allocation & auto allocation implemented

- Update stat allocation UI/UX cont.
- Update stats live (versus pressing save)
- Dynamically show the distribute dropdown based on if auto allocate switch is enabled

* Remove duplicate string value, convert line endings

* Fix auto allocation toggle alignment

* lint fixes

* Auto allocate UI fixes

- remove info icon in each dropdown option, and replace info below each option
- disable auto allocate dropdown instead of hiding it
- removed radio buttons drop auto allocate options dropdown
- Points available & auto allocate text share same baseline now

* Stat allocation updates

* Assigned Stat

* lint fixes

* stat feedback UI updates

* tweak

* stat allocation UI tweaks

refactor allocation mode selection with selectList component & other UI tweaks

* Fix hover dropdown padding & stat allocation chip always showing

* Update stat allocation dropdown styling

* Remove bold text on hover

* Stat allocation by task dropdown

* remove empty lines

* show selected attribute on component itself

* task stat selection UI updates

* fix(cr): remove weird line endings

---------

Co-authored-by: Kalista Payne <kalista@habitica.com>
2026-01-06 16:08:34 -06:00
Fiz 781a904583 Replace browser confirmations with confirmation modals (#15544)
* Set and check rebirth confirmation modal from localstorage

Set and check rebirth confirmation modal from localstorage after window reload

* Don't show orb of rebirth confirmation modal until page reloads

* Confirmation prompts (replace browser confirmation prompts)

* lint

* lint

* Fix confirmation modal not showing for items w/gems

* Updated delete and confirmation modals

* lint

* Fix top bar on color on confirmation prompt clipping

* Show currency amount on purchase confirmation modals

* confirmation modals using existing components

* lint

* Broken task confirmation modal

* Replace class change confirmation with modal

* fix(style): correct indentation

* Replace warning svg w/ existing alert svg, and reset language strings

* fix(json): json doesn't like comma dangles

---------

Co-authored-by: Kalista Payne <kalista@habitica.com>
Co-authored-by: Kalista Payne <sabrecat@gmail.com>
2026-01-06 15:19:30 -06:00
Kalista Payne d87946d912 5.42.4 2025-12-19 14:11:46 -06:00
Fiz 7456ff2def Fix g1g1 notification not staying dismissed (#15580)
- use localStorage instead of sessionStorage so dismissal persists across browser sessions
- Add .stop modifier to close button click
2025-12-19 13:57:35 -06:00
519 changed files with 17197 additions and 7610 deletions
+18 -8
View File
@@ -82,7 +82,7 @@ jobs:
CI: true
NODE_ENV: test
- run: npm run test:sanity
common:
runs-on: ubuntu-latest
strategy:
@@ -129,13 +129,13 @@ jobs:
CI: true
NODE_ENV: test
- run: npm run test:content
api-unit:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [21.x]
mongodb-version: [4.2]
mongodb-version: [7.0]
steps:
- uses: actions/checkout@v4
with:
@@ -144,11 +144,13 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Start MongoDB ${{ matrix.mongodb-version }} Replica Set
uses: supercharge/mongodb-github-action@1.3.0
uses: supercharge/mongodb-github-action@1.11.0
with:
mongodb-version: ${{ matrix.mongodb-version }}
mongodb-replica-set: rs
- run: sudo apt update
- run: sudo apt -y install libkrb5-dev
- run: cp config.json.example config.json
@@ -158,15 +160,17 @@ jobs:
env:
CI: true
NODE_ENV: test
- run: npm run test:api:unit
env:
REQUIRES_SERVER=true: true
api-v3-integration:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [21.x]
mongodb-version: [4.2]
mongodb-version: [7.0]
steps:
- uses: actions/checkout@v4
with:
@@ -176,10 +180,11 @@ jobs:
with:
node-version: ${{ matrix.node-version }}
- name: Start MongoDB ${{ matrix.mongodb-version }} Replica Set
uses: supercharge/mongodb-github-action@1.3.0
uses: supercharge/mongodb-github-action@1.11.0
with:
mongodb-version: ${{ matrix.mongodb-version }}
mongodb-replica-set: rs
- run: sudo apt update
- run: sudo apt -y install libkrb5-dev
- run: cp config.json.example config.json
@@ -189,15 +194,18 @@ jobs:
env:
CI: true
NODE_ENV: test
- run: npm run test:api-v3:integration
env:
REQUIRES_SERVER=true: true
api-v4-integration:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [21.x]
mongodb-version: [4.2]
mongodb-version: [7.0]
steps:
- uses: actions/checkout@v4
with:
@@ -207,10 +215,11 @@ jobs:
with:
node-version: ${{ matrix.node-version }}
- name: Start MongoDB ${{ matrix.mongodb-version }} Replica Set
uses: supercharge/mongodb-github-action@1.3.0
uses: supercharge/mongodb-github-action@1.11.0
with:
mongodb-version: ${{ matrix.mongodb-version }}
mongodb-replica-set: rs
- run: sudo apt update
- run: sudo apt -y install libkrb5-dev
- run: cp config.json.example config.json
@@ -220,6 +229,7 @@ jobs:
env:
CI: true
NODE_ENV: test
- run: npm run test:api-v4:integration
env:
REQUIRES_SERVER=true: true
+1 -1
View File
@@ -47,5 +47,5 @@ webpack.webstorm.config
# mongodb replica set for local dev
mongodb-*.tgz
/mongodb-data*
/mongodb-*
/.nyc_output
+2 -3
View File
@@ -46,7 +46,7 @@
"MAINTENANCE_MODE": "false",
"MONGODB_POOL_SIZE": "10",
"MONGODB_SOCKET_TIMEOUT": "20000",
"NODE_DB_URI": "mongodb://localhost:27017/habitica-dev?replicaSet=rs",
"NODE_DB_URI": "mongodb://localhost:27017/habitica-dev?replicaSet=rs&directConnection=true&readPreference=secondary",
"NODE_ENV": "development",
"PATH": "bin:node_modules/.bin:/usr/local/bin:/usr/bin:/bin",
"PAYPAL_BILLING_PLANS_basic_12mo": "basic_12mo",
@@ -75,7 +75,6 @@
"S3_ACCESS_KEY_ID": "accessKeyId",
"S3_BUCKET": "bucket",
"S3_SECRET_ACCESS_KEY": "secretAccessKey",
"SESSION_SECRET_IV": "12345678912345678912345678912345",
"SESSION_SECRET_KEY": "1234567891234567891234567891234567891234567891234567891234567891",
"SESSION_SECRET": "YOUR SECRET HERE",
"SITE_HTTP_AUTH_ENABLED": "false",
@@ -90,7 +89,7 @@
"STRIPE_API_KEY": "aaaabbbbccccddddeeeeffff00001111",
"STRIPE_PUB_KEY": "22223333444455556666777788889999",
"STRIPE_WEBHOOKS_ENDPOINT_SECRET": "111111",
"TEST_DB_URI": "mongodb://localhost:27017/habitica-test?replicaSet=rs",
"TEST_DB_URI": "mongodb://localhost:27017/habitica-test?replicaSet=rs&directConnection=true&readPreference=secondary",
"TIME_TRAVEL_ENABLED": "false",
"TRUSTED_DOMAINS": "localhost,https://habitica.com",
"WEB_CONCURRENCY": 1
-53
View File
@@ -1,53 +0,0 @@
services:
client:
build:
context: .
dockerfile: ./Dockerfile-Dev
command: ["npm", "run", "client:dev"]
depends_on:
- server
environment:
- BASE_URL=http://server:3000
networks:
- habitica
ports:
- "8080:8080"
volumes:
- .:/usr/src/habitica
- /usr/src/habitica/node_modules
- /usr/src/habitica/website/client/node_modules
server:
build:
context: .
dockerfile: ./Dockerfile-Dev
command: ["npm", "start"]
depends_on:
mongo:
condition: service_healthy
environment:
- NODE_DB_URI=mongodb://mongo/habitrpg
networks:
- habitica
ports:
- "3000:3000"
volumes:
- .:/usr/src/habitica
- /usr/src/habitica/node_modules
mongo:
image: mongo:5.0.23
restart: unless-stopped
command: ["--replSet", "rs", "--bind_ip_all", "--port", "27017"]
healthcheck:
test: echo "try { rs.status() } catch (err) { rs.initiate() }" | mongosh --port 27017 --quiet
interval: 10s
timeout: 30s
start_period: 0s
start_interval: 1s
retries: 30
networks:
- habitica
ports:
- "27017:27017"
networks:
habitica:
driver: bridge
+23
View File
@@ -0,0 +1,23 @@
networks:
mongodb-network:
name: "mongodb-network"
driver: bridge
services:
mongodb:
image: "mongo:7.0"
container_name: "habitica-mongodb-only"
networks:
- mongodb-network
hostname: "mongodb"
ports:
- "27017:27017"
restart: "unless-stopped"
volumes:
- "./mongodb-data-docker:/data/db"
entrypoint: [ "/usr/bin/mongod", "--bind_ip_all", "--replSet", "rs" ]
healthcheck:
test: echo "try { rs.status() } catch (err) { rs.initiate() }" | mongosh --port 27017 --quiet
interval: 10s
timeout: 30s
start_period: 0s
retries: 30
+23
View File
@@ -0,0 +1,23 @@
networks:
mongodb-network:
name: "mongodb-network"
driver: bridge
services:
mongodb:
image: "mongo:7.0"
container_name: "habitica-mongodb-test"
networks:
- mongodb-network
hostname: "mongodb"
ports:
- "27017:27017"
restart: "unless-stopped"
volumes:
- "./mongodb-data-docker-testing:/data/db"
entrypoint: [ "/usr/bin/mongod", "--bind_ip_all", "--replSet", "rs" ]
healthcheck:
test: echo "try { rs.status() } catch (err) { rs.initiate() }" | mongosh --port 27017 --quiet
interval: 10s
timeout: 30s
start_period: 0s
retries: 30
+43 -22
View File
@@ -1,35 +1,56 @@
version: "3"
services:
client:
build: .
networks:
- habitica
environment:
- BASE_URL=http://server:3000
ports:
- "8080:8080"
command: ["npm", "run", "client:dev"]
build:
context: .
dockerfile: ./Dockerfile-Dev
command: ["npm", "run", "client:dev:docker"]
depends_on:
- server
server:
build: .
ports:
- "3000:3000"
environment:
- BASE_URL=http://server:3000
networks:
- habitica
ports:
- "5173:5173"
volumes:
- .:/usr/src/habitica
- /usr/src/habitica/node_modules
- /usr/src/habitica/website/client/node_modules
server:
build:
context: .
dockerfile: ./Dockerfile-Dev
command: ["npm", "start"]
depends_on:
mongo:
condition: service_healthy
environment:
- NODE_DB_URI=mongodb://mongo/habitrpg
depends_on:
- mongo
mongo:
image: mongo:3.6
ports:
- "27017:27017"
networks:
- habitica
ports:
- "3000:3000"
volumes:
- .:/usr/src/habitica
- /usr/src/habitica/node_modules
mongo:
image: "mongo:7.0"
container_name: "habitica-mongodb"
networks:
- habitica
hostname: "mongodb"
ports:
- "27017:27017"
restart: "unless-stopped"
volumes:
- "./mongodb-data-docker:/data/db"
entrypoint: [ "/usr/bin/mongod", "--bind_ip_all", "--replSet", "rs" ]
healthcheck:
test: echo "try { rs.status() } catch (err) { rs.initiate() }" | mongosh --port 27017 --quiet
interval: 10s
timeout: 30s
start_period: 0s
retries: 30
networks:
habitica:
+12 -9
View File
@@ -5,7 +5,7 @@ import path from 'path';
import babel from 'gulp-babel';
import os from 'os';
import fs from 'fs';
import spawn from 'cross-spawn'; // eslint-disable-line import/no-extraneous-dependencies
import spawn from 'cross-spawn';
import clean from 'rimraf';
gulp.task('build:babel:server', () => gulp.src('website/server/**/*.js')
@@ -35,7 +35,7 @@ gulp.task('build:prod', gulp.series(
// When used on windows `run-rs` must first be run without the `--keep` option
// in order to be setup correctly, afterwards it can be used.
const MONGO_PATH = path.join(__dirname, '/../mongodb-data/');
const MONGO_PATH = path.join(__dirname, '/../mongodb-data-docker/');
gulp.task('build:prepare-mongo', async () => {
if (fs.existsSync(MONGO_PATH)) {
@@ -51,29 +51,32 @@ gulp.task('build:prepare-mongo', async () => {
console.log('MongoDB data folder is missing, setting up.'); // eslint-disable-line no-console
// use run-rs without --keep, kill it as soon as the replica set starts
const runRsProcess = spawn('run-rs', ['-v', '4.1.1', '-l', 'ubuntu1804', '--dbpath', 'mongodb-data', '--number', '1', '--quiet']);
const dockerMongoProcess = spawn('npm', ['run', 'docker:mongo:dev']);
for await (const chunk of runRsProcess.stdout) {
let manuallyStopped = false;
for await (const chunk of dockerMongoProcess.stdout) {
const stringChunk = chunk.toString();
console.log(stringChunk); // eslint-disable-line no-console
// kills the process after the replica set is setup
if (stringChunk.includes('Started replica set')) {
if (stringChunk.includes('mongod startup complete')) {
console.log('MongoDB setup correctly.'); // eslint-disable-line no-console
runRsProcess.kill();
dockerMongoProcess.kill();
manuallyStopped = true;
}
}
let error = '';
for await (const chunk of runRsProcess.stderr) {
for await (const chunk of dockerMongoProcess.stderr) {
const stringChunk = chunk.toString();
error += stringChunk;
}
const exitCode = await new Promise(resolve => {
runRsProcess.on('close', resolve);
dockerMongoProcess.on('close', resolve);
});
if (exitCode || error.length > 0) {
if (!manuallyStopped && (exitCode || error.length > 0)) {
// remove any leftover files
clean.sync(MONGO_PATH);
+61 -26
View File
@@ -6,9 +6,21 @@ gulp.task('cache:content', done => {
// Requiring at runtime because these files access `common`
// code which in production works only if transpiled so after
// gulp build:babel:common has run
const { CONTENT_CACHE_PATH, getLocalizedContentResponse } = require('../website/server/libs/content'); // eslint-disable-line global-require
const {
CONTENT_CACHE_PATH,
getLocalizedContentResponse,
IOS_FILTER,
ANDROID_FILTER,
buildFilterObject,
hashForFilter,
} = require('../website/server/libs/content'); // eslint-disable-line global-require
const { langCodes } = require('../website/server/libs/i18n'); // eslint-disable-line global-require
const iosHash = hashForFilter(IOS_FILTER);
const iosFilterObj = buildFilterObject(IOS_FILTER);
const androidHash = hashForFilter(ANDROID_FILTER);
const androidFilterObj = buildFilterObject(ANDROID_FILTER);
try {
// create the cache folder (if it doesn't exist)
try {
@@ -26,33 +38,56 @@ gulp.task('cache:content', done => {
getLocalizedContentResponse(langCode),
'utf8',
);
});
done();
} catch (err) {
done(err);
}
});
gulp.task('cache:i18n', done => {
// Requiring at runtime because these files access `common`
// code which in production works only if transpiled so after
// gulp build:babel:common has run
const { BROWSER_SCRIPT_CACHE_PATH, geti18nBrowserScript } = require('../website/server/libs/i18n'); // eslint-disable-line global-require
const { langCodes } = require('../website/server/libs/i18n'); // eslint-disable-line global-require
try {
// create the cache folder (if it doesn't exist)
try {
fs.mkdirSync(BROWSER_SCRIPT_CACHE_PATH);
} catch (err) {
if (err.code !== 'EEXIST') throw err;
}
// create and save the i18n browser script for each language
langCodes.forEach(languageCode => {
fs.writeFileSync(
`${BROWSER_SCRIPT_CACHE_PATH}${languageCode}.js`,
geti18nBrowserScript(languageCode),
`${CONTENT_CACHE_PATH}${langCode}${iosHash}.json`,
getLocalizedContentResponse(langCode, iosFilterObj),
'utf8',
);
fs.writeFileSync(
`${CONTENT_CACHE_PATH}${langCode}${androidHash}.json`,
getLocalizedContentResponse(langCode, androidFilterObj),
'utf8',
);
});
done();
} catch (err) {
done(err);
}
});
function safeMkdir (path) {
try {
fs.mkdirSync(path);
} catch (err) {
if (err.code !== 'EEXIST') throw err;
}
}
gulp.task('cache:i18n', done => {
// Requiring at runtime because these files access `common`
// code which in production works only if transpiled so after
// gulp build:babel:common has run
const { BROWSER_SCRIPT_CACHE_PATH, geti18nCoreBrowserScript, geti18nContentBrowserScript } = require('../website/server/libs/i18n'); // eslint-disable-line global-require
const { langCodes } = require('../website/server/libs/i18n'); // eslint-disable-line global-require
try {
// create the cache folders (if they doesn't exist)
safeMkdir(BROWSER_SCRIPT_CACHE_PATH);
safeMkdir(`${BROWSER_SCRIPT_CACHE_PATH}core/`);
safeMkdir(`${BROWSER_SCRIPT_CACHE_PATH}content/`);
// create and save the i18n browser script for each language
langCodes.forEach(languageCode => {
fs.writeFileSync(
`${BROWSER_SCRIPT_CACHE_PATH}core/${languageCode}.js`,
geti18nCoreBrowserScript(languageCode),
'utf8',
);
fs.writeFileSync(
`${BROWSER_SCRIPT_CACHE_PATH}content/${languageCode}.js`,
geti18nContentBrowserScript(languageCode),
'utf8',
);
});
+5
View File
@@ -53,6 +53,11 @@ gulp.task('test:prepare:mongo', cb => {
const mongooseOptions = getDefaultConnectionOptions();
const connectionUrl = getDevelopmentConnectionUrl(TEST_DB_URI);
console.info({
mongooseOptions,
connectionUrl,
});
mongoose.connect(connectionUrl, mongooseOptions)
.then(() => mongoose.connection.dropDatabase())
.then(() => mongoose.connection.close()).then(() => {
+827 -704
View File
File diff suppressed because it is too large Load Diff
+13 -7
View File
@@ -1,7 +1,7 @@
{
"name": "habitica",
"description": "A habit tracker app which treats your goals like a Role Playing Game.",
"version": "5.42.3",
"version": "5.46.4",
"main": "./website/server/index.js",
"dependencies": {
"@babel/core": "^7.22.10",
@@ -19,6 +19,7 @@
"bcrypt": "^5.1.1",
"body-parser": "^1.20.3",
"bootstrap": "^4.6.2",
"bullmq": "^5.71.1",
"compression": "^1.8.1",
"cookie-session": "^2.1.1",
"coupon-code": "^0.4.5",
@@ -39,19 +40,23 @@
"gulp-filter": "^7.0.0",
"gulp-imagemin": "^7.1.0",
"gulp.spritesmith": "^6.13.0",
"habitica-markdown": "^3.0.0",
"habitica-markdown": "^4.1.0",
"heapdump": "^0.3.15",
"helmet": "^4.6.0",
"in-app-purchase": "^1.11.3",
"ioredis": "^5.10.1",
"js2xmlparser": "^5.0.0",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^2.1.5",
"lodash": "^4.17.21",
"merge-stream": "^2.0.0",
"method-override": "^3.0.0",
"micromustache": "^8.0.3",
"moment": "^2.29.4",
"moment-recur": "git://github.com/HabitRPG/moment-recur.git#d3e8e6da0806f13b74dd2e4d7d9053e6a63db119",
"mongoose": "^8.9.5",
"morgan": "^1.10.1",
"nan": "^2.25.0",
"nconf": "^0.12.1",
"node-gcm": "^1.0.5",
"on-headers": "^1.1.0",
@@ -63,7 +68,6 @@
"pp-ipn": "^1.1.0",
"ps-tree": "^1.0.0",
"rate-limiter-flexible": "^2.4.2",
"redis": "^3.1.2",
"remove-markdown": "^0.5.0",
"rimraf": "^3.0.2",
"short-uuid": "^4.2.2",
@@ -100,13 +104,16 @@
"coverage": "nyc report --reporter=html --report-dir coverage/results; open coverage/results/index.html",
"sprites": "gulp sprites:compile",
"client:dev": "cd website/client && npm run serve",
"client:dev:docker": "cd website/client && npm run serve:docker",
"client:build": "cd website/client && npm run build",
"client:unit": "cd website/client && npm run test:unit",
"start": "node --watch ./website/server/index.js",
"start:simple": "node ./website/server/index.js",
"debug": "node --watch --inspect ./website/server/index.js",
"mongo:dev": "run-rs -v 7.0.23 -l ubuntu2214 --keep --dbpath mongodb-data --number 1 --quiet",
"mongo:test": "run-rs -v 7.0.23 -l ubuntu2214 --keep --dbpath mongodb-data-testing --number 1 --quiet",
"docker:aio": "docker compose -f docker-compose.yml up",
"docker:mongo:dev": "docker compose -f docker-compose.mongo-only.yml up",
"docker:mongo:dev:down": "docker compose -f docker-compose.mongo-only.yml down",
"docker:mongo:test": "docker compose -f docker-compose.mongo-test-local.yml up",
"mongo:test": "node scripts/start-local-mongo.mjs --test-db",
"postinstall": "git config --global url.\"https://\".insteadOf git:// && gulp build && cd website/client && npm install",
"apidoc": "gulp apidoc",
"heroku-postbuild": ".heroku/report_deploy.sh"
@@ -122,7 +129,6 @@
"monk": "^7.3.4",
"nyc": "^15.1.0",
"require-again": "^2.0.0",
"run-rs": "^0.7.7",
"sinon-chai": "^3.7.0",
"sinon-stub-promise": "^4.0.0"
}
+22 -25
View File
@@ -1,9 +1,9 @@
/* eslint-disable global-require */
import got from 'got';
import nconf from 'nconf';
import requireAgain from 'require-again';
import { TAVERN_ID } from '../../../../website/server/models/group';
import { defer } from '../../../helpers/api-unit.helper';
import worker from '../../../../website/server/libs/worker';
function getUser () {
return {
@@ -127,7 +127,7 @@ describe('emails', () => {
let sendTxn = null;
beforeEach(() => {
sandbox.stub(got, 'post').returns(defer().promise);
sandbox.stub(worker, 'sendJob').returns(defer().promise);
const nconfGetStub = sandbox.stub(nconf, 'get');
nconfGetStub.withArgs('IS_PROD').returns(true);
@@ -149,13 +149,12 @@ describe('emails', () => {
};
sendTxn(mailingInfo, emailType);
expect(got.post).to.be.called;
expect(got.post).to.be.calledWith('http://example.com/job', sinon.match({
json: {
data: {
emailType: sinon.match.same(emailType),
to: sinon.match(value => Array.isArray(value) && value[0].name === mailingInfo.name, 'matches mailing info array'),
},
expect(worker.sendJob).to.be.called;
expect(worker.sendJob).to.be.calledWith('email', sinon.match({
identifier: emailType,
data: {
emailType: sinon.match.same(emailType),
to: sinon.match(value => Array.isArray(value) && value[0].name === mailingInfo.name, 'matches mailing info array'),
},
}));
});
@@ -168,7 +167,7 @@ describe('emails', () => {
};
sendTxn(mailingInfo, emailType);
expect(got.post).not.to.be.called;
expect(worker.sendJob).not.to.be.called;
});
it('throws error when mail target is only a string', async () => {
@@ -233,13 +232,12 @@ describe('emails', () => {
const mailingInfo = getUser();
sendTxn(mailingInfo, emailType);
expect(got.post).to.be.called;
expect(got.post).to.be.calledWith('http://example.com/job', sinon.match({
json: {
data: {
emailType: sinon.match.same(emailType),
to: sinon.match(val => val[0]._id === mailingInfo._id),
},
expect(worker.sendJob).to.be.called;
expect(worker.sendJob).to.be.calledWith('email', sinon.match({
identifier: emailType,
data: {
emailType: sinon.match.same(emailType),
to: sinon.match(val => val[0]._id === mailingInfo._id),
},
}));
});
@@ -253,15 +251,14 @@ describe('emails', () => {
const variables = [];
sendTxn(mailingInfo, emailType, variables);
expect(got.post).to.be.called;
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'),
personalVariables: sinon.match(value => value[0].rcpt === mailingInfo.email
&& value[0].vars[0].name === 'RECIPIENT_NAME'
expect(worker.sendJob).to.be.called;
expect(worker.sendJob).to.be.calledWith('email', sinon.match({
identifier: emailType,
data: {
variables: sinon.match(value => value[0].name === 'BASE_URL', 'matches variables'),
personalVariables: sinon.match(value => value[0].rcpt === mailingInfo.email
&& value[0].vars[0].name === 'RECIPIENT_NAME'
&& value[0].vars[1].name === 'RECIPIENT_UNSUB_URL', 'matches personal variables'),
},
},
}));
});
@@ -66,13 +66,15 @@ describe('Amazon Payments - Cancel Subscription', () => {
group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'public',
privacy: 'private',
leader: user._id,
});
group.purchased.plan.customerId = 'customer-id';
group.purchased.plan.planId = subKey;
group.purchased.plan.lastBillingDate = new Date();
await group.save();
user.guilds.push(group._id);
await user.save();
subscriptionBlock = common.content.subscriptionBlocks[subKey];
subscriptionLength = subscriptionBlock.months * 30;
@@ -30,12 +30,14 @@ describe('Amazon Payments - Subscribe', () => {
group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'public',
privacy: 'private',
leader: user._id,
});
group.purchased.plan.customerId = 'customer-id';
group.purchased.plan.planId = subKey;
await group.save();
user.guilds.push(group._id);
await user.save();
amount = common.content.subscriptionBlocks[subKey].price;
billingAgreementId = 'billingAgreementId';
@@ -246,11 +248,6 @@ describe('Amazon Payments - Subscribe', () => {
user.guilds.push(groupId);
await user.save();
// Add existing users
user = new User();
user.guilds.push(groupId);
await user.save();
// Set expected amount
sub.key = 'group_monthly';
sub.price = 9;
@@ -128,11 +128,12 @@ describe('Purchasing a group plan for group', () => {
expect(publicGroup.purchased.plan.planId).to.not.exist;
data.groupId = publicGroup._id;
// Public Guilds are no longer even findable
await expect(api.createSubscription(data))
.to.eventually.be.rejected.and.to.eql({
httpCode: 401,
name: 'NotAuthorized',
message: i18n.t('onlyPrivateGuildsCanUpgrade'),
httpCode: 404,
name: 'NotFound',
message: i18n.t('groupNotFound'),
});
const updatedGroup = await Group.findById(publicGroup._id).exec();
@@ -30,13 +30,15 @@ describe('paypal - subscribeCancel', () => {
group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'public',
privacy: 'private',
leader: user._id,
});
group.purchased.plan.customerId = groupCustomerId;
group.purchased.plan.planId = subKey;
group.purchased.plan.lastBillingDate = new Date();
await group.save();
user.guilds.push(group._id);
await user.save();
nextBillingDate = new Date();
@@ -236,7 +236,7 @@ describe('Stripe - Checkout', () => {
const group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'public',
privacy: 'private',
leader: user._id,
});
const groupId = group._id;
@@ -376,11 +376,13 @@ describe('Stripe - Checkout', () => {
group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'public',
privacy: 'private',
leader: user._id,
});
groupId = group._id;
await group.save();
user.guilds.push(group._id);
await user.save();
});
it('throws if user is not allowed to change group plan', async () => {
@@ -136,7 +136,7 @@ describe('Stripe - Subscriptions', () => {
group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'public',
privacy: 'private',
leader: user._id,
});
groupId = group._id;
@@ -315,12 +315,14 @@ describe('Stripe - Subscriptions', () => {
group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'public',
privacy: 'private',
leader: user._id,
});
group.purchased.plan.customerId = 'customer-id';
group.purchased.plan.planId = subKey;
await group.save();
user.guilds.push(group._id);
await user.save();
groupId = group._id;
});
@@ -50,5 +50,59 @@ describe('UserNotification Model', () => {
expect(safeNotifications[0].type).to.equal('NEW_CHAT_MESSAGE');
expect(safeNotifications[0].id).to.equal('123');
});
it('removes duplicate STREAK_ACHIEVEMENT notifications', () => {
// Fixes issue #13325 - Users receiving duplicate streak achievement notifications
const notifications = [
new UserNotification({
type: 'STREAK_ACHIEVEMENT',
id: 123,
data: {},
}),
new UserNotification({
type: 'STREAK_ACHIEVEMENT',
id: 456,
data: {},
}),
new UserNotification({
type: 'CRON',
id: 789,
data: {},
}), // different type, should be kept
];
const safeNotifications = UserNotification.cleanupCorruptData(notifications);
expect(safeNotifications.length).to.equal(2);
expect(safeNotifications[0].type).to.equal('STREAK_ACHIEVEMENT');
expect(safeNotifications[0].id).to.equal('123');
expect(safeNotifications[1].type).to.equal('CRON');
expect(safeNotifications[1].id).to.equal('789');
});
it('handles multiple STREAK_ACHIEVEMENT duplicates correctly', () => {
// Test case: 3 duplicate STREAK_ACHIEVEMENT notifications
const notifications = [
new UserNotification({
type: 'STREAK_ACHIEVEMENT',
id: 111,
data: {},
}),
new UserNotification({
type: 'STREAK_ACHIEVEMENT',
id: 222,
data: {},
}),
new UserNotification({
type: 'STREAK_ACHIEVEMENT',
id: 333,
data: {},
}),
];
const safeNotifications = UserNotification.cleanupCorruptData(notifications);
expect(safeNotifications.length).to.equal(1);
expect(safeNotifications[0].type).to.equal('STREAK_ACHIEVEMENT');
expect(safeNotifications[0].id).to.equal('111'); // Keep first one
});
});
});
@@ -5,6 +5,8 @@ import {
createAndPopulateGroup,
translate as t,
} from '../../../../helpers/api-integration/v3';
import { model as Group } from '../../../../../website/server/models/group';
import { TAVERN_ID } from '../../../../../website/common/script/constants';
describe('POST /challenges/:challengeId/join', () => {
it('returns error when challengeId is not a valid UUID', async () => {
@@ -27,6 +29,37 @@ describe('POST /challenges/:challengeId/join', () => {
});
});
context('public Guild', () => {
let group;
let groupLeader;
let members;
let challenge;
before(async () => {
({ group, groupLeader, members } = await createAndPopulateGroup({
groupDetails: {
name: 'test group',
type: 'guild',
privacy: 'private',
},
members: 1,
upgradeToGroupPlan: true,
}));
challenge = await generateChallenge(groupLeader, group);
// Creation API is shut down, we need to simulate an extant public group
await Group.updateOne({ _id: group._id }, { $set: { privacy: 'public' }, $unset: { 'purchased.plan': 1 } }).exec();
});
it('returns error when challengeId is in an old public Guild', async () => {
const authorizedUser = members[0]; // eslint-disable-line prefer-destructuring
await expect(authorizedUser.post(`/challenges/${challenge._id}/join`)).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('challengeNotFound'),
});
});
});
context('Joining a valid challenge', () => {
let groupLeader;
let group;
@@ -66,6 +99,15 @@ describe('POST /challenges/:challengeId/join', () => {
expect(res.name).to.equal(challenge.name);
});
it('succeeds when it\'s a Tavern challenge, even if the user isn\'t a "member" of Tavern', async () => {
const tavern = await groupLeader.get(`/groups/${TAVERN_ID}`);
const tavernChallenge = await generateChallenge(groupLeader, tavern, { prize: 1 });
const generalUser = await generateUser();
const res = await generalUser.post(`/challenges/${tavernChallenge._id}/join`);
expect(res.name).to.equal(tavernChallenge.name);
});
it('returns challenge data', async () => {
const res = await authorizedUser.post(`/challenges/${challenge._id}/join`);
@@ -62,9 +62,9 @@ describe('GET /groups/:groupId/chat', () => {
it('returns error if user attempts to fetch a sunset Guild', async () => {
await expect(user.get(`/groups/${group._id}/chat`)).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('featureRetired'),
code: 404,
error: 'NotFound',
message: t('groupNotFound'),
});
});
});
@@ -121,9 +121,9 @@ describe('POST /chat/:chatId/like', () => {
await expect(user.post(`/groups/${groupWithChat._id}/chat/${message.message.id}/like`))
.to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('featureRetired'),
code: 404,
error: 'NotFound',
message: t('groupNotFound'),
});
});
});
@@ -1,35 +0,0 @@
import { v4 as generateUUID } from 'uuid';
import {
generateUser,
translate as t,
} from '../../../../helpers/api-integration/v3';
xdescribe('GET /export/avatar-:memberId.html', () => {
let user;
before(async () => {
user = await generateUser();
});
it('validates req.params.memberId', async () => {
await expect(user.get('/export/avatar-:memberId.html')).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('invalidReqParams'),
});
});
it('handles non-existing members', async () => {
const dummyId = generateUUID();
await expect(user.get(`/export/avatar-${dummyId}.html`)).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('userWithIDNotFound', { userId: dummyId }),
});
});
it('returns an html page', async () => {
const res = await user.get(`/export/avatar-${user._id}.html`);
expect(res.substring(0, 100).indexOf('<!DOCTYPE html>')).to.equal(0);
});
});
@@ -1,3 +0,0 @@
// TODO how to test this route since it points to a file on AWS s3?
describe('GET /export/avatar-:memberId.png', () => {});
@@ -38,7 +38,7 @@ describe('GET /export/inbox.html', () => {
it('renders the markdown messages as html', async () => {
const res = await user.get('/export/inbox.html');
expect(res).to.include('img class="habitica-emoji"');
expect(res).to.include('😄');
expect(res).to.include('<h1>Hello!</h1>');
expect(res).to.include('<li>list 1</li>');
});
@@ -46,7 +46,7 @@ describe('GET /export/inbox.html', () => {
it('sorts messages from newest to oldest', async () => {
const res = await user.get('/export/inbox.html');
const emojiPosition = res.indexOf('img class="habitica-emoji"');
const emojiPosition = res.indexOf('😄');
const headingPosition = res.indexOf('<h1>Hello!</h1>');
const listPosition = res.indexOf('<li>list 1</li>');
@@ -47,7 +47,7 @@ describe('GET /inbox/messages', () => {
it('returns four messages when using page-query ', async () => {
const promises = [];
for (let i = 0; i < 10; i += 1) {
for (let i = 0; i < 50; i += 1) {
promises.push(user.post('/members/send-private-message', {
toUserId: user.id,
message: 'fourth',
@@ -193,23 +193,6 @@ describe('POST /groups/:groupId/quests/force-start', () => {
expect(questingGroup.quest.members[notInPartyUser._id]).to.not.exist;
});
it('removes users who have been deleted from quest.members', async () => {
await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
await partyMembers[0].del('/user', {
password: 'password',
});
await leader.post(`/groups/${questingGroup._id}/quests/force-start`);
await sleep(0.5);
await questingGroup.sync();
expect(questingGroup.quest.members[partyMembers[0]._id]).to.not.exist;
});
it('removes users who don\'t have true value in quest.members from quest.members', async () => {
const partyMemberThatRejects = partyMembers[1];
const partyMemberThatIgnores = partyMembers[2];
@@ -1,13 +1,7 @@
import {
each,
map,
} from 'lodash';
import {
checkExistence,
createAndPopulateGroup,
generateGroup,
generateUser,
generateChallenge,
translate as t,
} from '../../../../helpers/api-integration/v3';
import {
@@ -15,6 +9,7 @@ import {
sha1Encrypt as sha1EncryptPassword,
} from '../../../../../website/server/libs/password';
import * as email from '../../../../../website/server/libs/email';
import sendJob from '../../../../../website/server/libs/worker';
const DELETE_CONFIRMATION = 'DELETE';
@@ -47,12 +42,13 @@ describe('DELETE /user', () => {
});
});
it('deletes the user', async () => {
await expect(checkExistence('users', user._id)).to.eventually.eql(true);
it('sends deletion job to worker', async () => {
const workerStub = sandbox.stub(sendJob, 'sendJob');
await user.del('/user', {
password,
});
await expect(checkExistence('users', user._id)).to.eventually.eql(false);
expect(workerStub).to.be.calledOnce;
workerStub.restore();
});
it('returns an error if excessive feedback is supplied', async () => {
@@ -84,53 +80,6 @@ describe('DELETE /user', () => {
});
});
it('deletes the user\'s tasks', async () => {
await user.post('/tasks/user', {
text: 'test habit',
type: 'habit',
});
await user.sync();
// gets the user's tasks ids
const ids = [];
each(user.tasksOrder, idsForOrder => {
ids.push(...idsForOrder);
});
expect(ids.length).to.be.above(0); // make sure the user has some task to delete
await user.del('/user', {
password,
});
await Promise.all(map(ids, id => expect(checkExistence('tasks', id)).to.eventually.eql(false)));
});
it('reduces memberCount in challenges user is linked to', async () => {
const populatedGroup = await createAndPopulateGroup({
members: 2,
});
const { group } = populatedGroup;
const authorizedUser = populatedGroup.members[1];
const challenge = await generateChallenge(populatedGroup.groupLeader, group);
await populatedGroup.groupLeader.post(`/challenges/${challenge._id}/join`);
await authorizedUser.post(`/challenges/${challenge._id}/join`);
await challenge.sync();
expect(challenge.memberCount).to.eql(2);
await authorizedUser.del('/user', {
password,
});
await challenge.sync();
expect(challenge.memberCount).to.eql(1);
});
it('sends feedback to the admin email', async () => {
sandbox.spy(email, 'sendTxn');
@@ -158,10 +107,10 @@ describe('DELETE /user', () => {
});
it('deletes the user with a legacy sha1 password', async () => {
await expect(checkExistence('users', user._id)).to.eventually.eql(true);
const textPassword = 'mySecretPassword';
const salt = sha1MakeSalt();
const sha1HashedPassword = sha1EncryptPassword(textPassword, salt);
const workerStub = sandbox.stub(sendJob, 'sendJob');
await user.updateOne({
'auth.local.hashed_password': sha1HashedPassword,
@@ -179,7 +128,8 @@ describe('DELETE /user', () => {
await user.del('/user', {
password: textPassword,
});
await expect(checkExistence('users', user._id)).to.eventually.eql(false);
expect(workerStub).to.be.calledOnce;
workerStub.restore();
});
context('last member of a party', () => {
@@ -213,11 +163,12 @@ describe('DELETE /user', () => {
});
it('deletes a Google user', async () => {
await expect(checkExistence('users', user._id)).to.eventually.eql(true);
const workerStub = sandbox.stub(sendJob, 'sendJob');
await user.del('/user', {
password: DELETE_CONFIRMATION,
});
await expect(checkExistence('users', user._id)).to.eventually.eql(false);
expect(workerStub).to.be.calledOnce;
workerStub.restore();
});
});
@@ -232,12 +183,13 @@ describe('DELETE /user', () => {
});
});
it('deletes a Apple user', async () => {
await expect(checkExistence('users', user._id)).to.eventually.eql(true);
it('deletes an Apple user', async () => {
const workerStub = sandbox.stub(sendJob, 'sendJob');
await user.del('/user', {
password: DELETE_CONFIRMATION,
});
await expect(checkExistence('users', user._id)).to.eventually.eql(false);
expect(workerStub).to.be.calledOnce;
workerStub.restore();
});
});
});
@@ -65,6 +65,52 @@ describe('POST /user/auth/social', () => {
await expect(getProperty('users', response.id, 'profile.name')).to.eventually.equal('a google user');
});
it('includes sanitized version of provided username', async () => {
const response = await api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
network,
username: 'Google User Name',
});
await expect(getProperty('users', response.id, 'auth.local.username')).to.eventually.equal('GoogleUserName');
await expect(getProperty('users', response.id, 'auth.local.lowerCaseUsername')).to.eventually.equal('googleusername');
});
it('generates a random username if provided username contains only disallowed characters', async () => {
const response = await api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
network,
username: 'Áîüè',
});
await expect(getProperty('users', response.id, 'auth.local.username')).to.eventually.contain('hb-');
await expect(getProperty('users', response.id, 'auth.local.lowerCaseUsername')).to.eventually.contain('hb-');
});
it('generates a random username if provided username contains a disallowed word', async () => {
const response = await api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
network,
username: 'i am a TESTPLACEHOLDERSLURWORDHERE',
});
await expect(getProperty('users', response.id, 'auth.local.username')).to.eventually.contain('hb-');
await expect(getProperty('users', response.id, 'auth.local.lowerCaseUsername')).to.eventually.contain('hb-');
});
it('generates a random username if sanitized username conflicts with an extant user', async () => {
user = await generateUser({ 'auth.local.username': 'GoogleUserName' });
const response = await api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
network,
username: 'Google User Name',
});
await expect(getProperty('users', response.id, 'auth.local.username')).to.eventually.contain('hb-');
await expect(getProperty('users', response.id, 'auth.local.lowerCaseUsername')).to.eventually.contain('hb-');
});
it('fails if allowRegister is false and user does not exist', async () => {
await expect(api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
@@ -66,7 +66,7 @@ describe('GET /inbox/conversations', () => {
it('returns five messages when using page-query ', async () => {
const promises = [];
for (let i = 0; i < 10; i += 1) {
for (let i = 0; i < 50; i += 1) {
promises.push(user.post('/members/send-private-message', {
toUserId: user.id,
message: 'fourth',
+67
View File
@@ -0,0 +1,67 @@
import md from 'habitica-markdown';
describe('habiticaMarkdown emoji plugin', () => {
it('renders standard emoji as Unicode', () => {
const result = md.render(':smile:');
expect(result).to.include('😄');
expect(result).not.to.include('img');
});
it('renders thumbsup emoji as Unicode', () => {
const result = md.render(':thumbsup:');
expect(result).to.include('👍');
});
it('renders +1 emoji as Unicode', () => {
const result = md.render(':+1:');
expect(result).to.include('👍');
});
it('renders melior as an img tag', () => {
const result = md.render(':melior:');
expect(result).to.include('<img class="habitica-emoji"');
expect(result).to.include('src="https://s3.amazonaws.com/habitica-assets/cdn/emoji/melior.png"');
expect(result).to.include('alt="melior"');
});
it('does NOT convert emoji inside markdown links', () => {
const result = md.render('[:smile: link](http://example.com)');
expect(result).to.include(':smile: link');
expect(result).not.to.include('😄');
});
it('converts emoji outside of links normally', () => {
const result = md.render(':smile: [link](http://example.com)');
expect(result).to.include('😄');
expect(result).to.include('link');
});
it('leaves removed custom emoji (bowtie) as literal text', () => {
const result = md.render(':bowtie:');
expect(result).to.include(':bowtie:');
expect(result).not.to.include('img');
});
it('leaves unknown shortcodes as literal text', () => {
const result = md.render(':nonexistent_emoji_xyz:');
expect(result).to.include(':nonexistent_emoji_xyz:');
});
it('renders new emoji not in the old dataset', () => {
const result = md.render(':yawning_face:');
expect(result).to.include('🥱');
});
it('supports unsafeHTMLRender', () => {
const result = md.unsafeHTMLRender('<b>bold</b> :smile:');
expect(result).to.include('<b>bold</b>');
expect(result).to.include('😄');
});
it('supports renderWithMentions', () => {
const result = md.renderWithMentions(':smile: @testuser', { userName: 'testuser' });
expect(result).to.include('😄');
expect(result).to.include('at-text');
expect(result).to.include('at-highlight');
});
});
+17 -8
View File
@@ -211,22 +211,32 @@ describe('shared.ops.rebirth', () => {
expect(user.achievements.rebirthLevel).to.equal(2);
});
it('does not increment rebirth achievements when level is lower than previous', async () => {
it('increments rebirth achievements even when level is lower than previous', async () => {
user.stats.lvl = 2;
user.achievements.rebirths = 1;
user.achievements.rebirthLevel = 3;
await rebirth(user);
expect(user.achievements.rebirths).to.equal(1);
expect(user.achievements.rebirths).to.equal(2);
expect(user.achievements.rebirthLevel).to.equal(3);
});
it('always increments rebirth achievements when level is MAX_LEVEL', async () => {
it('updates rebirthLevel when current level is higher than previous', async () => {
user.stats.lvl = 5;
user.achievements.rebirths = 1;
user.achievements.rebirthLevel = 3;
await rebirth(user);
expect(user.achievements.rebirths).to.equal(2);
expect(user.achievements.rebirthLevel).to.equal(5);
});
it('increments rebirth achievements when level is MAX_LEVEL', async () => {
user.stats.lvl = MAX_LEVEL;
user.achievements.rebirths = 1;
// this value is not actually possible (actually capped at MAX_LEVEL) but makes a good test
user.achievements.rebirthLevel = MAX_LEVEL + 1;
user.achievements.rebirthLevel = MAX_LEVEL;
await rebirth(user);
@@ -234,11 +244,10 @@ describe('shared.ops.rebirth', () => {
expect(user.achievements.rebirthLevel).to.equal(MAX_LEVEL);
});
it('always increments rebirth achievements when level is greater than MAX_LEVEL', async () => {
it('increments rebirth achievements when level is greater than MAX_LEVEL', async () => {
user.stats.lvl = MAX_LEVEL + 1;
user.achievements.rebirths = 1;
// this value is not actually possible (actually capped at MAX_LEVEL) but makes a good test
user.achievements.rebirthLevel = MAX_LEVEL + 2;
user.achievements.rebirthLevel = MAX_LEVEL;
await rebirth(user);
+9 -1
View File
@@ -20,6 +20,9 @@ describe('shared.ops.unlock', () => {
beforeEach(() => {
user = generateUser();
user.balance = usersStartingGems;
user.pinnedItems.push({ type: 'background', path: 'backgrounds.backgrounds042016.giant_florals' });
user.pinnedItems.push({ type: 'haircolor', path: 'hair.color.rainbow' });
user.pinnedItems.push({ type: 'shirt', path: 'shirt.convict' });
clock = sandbox.useFakeTimers(new Date('2024-04-10'));
});
@@ -272,6 +275,7 @@ describe('shared.ops.unlock', () => {
});
it('unlocks an item (appearance)', async () => {
expect(user.pinnedItems.findIndex(item => item.type === 'shirt')).to.not.equal(-1);
const path = unlockPath.split(',')[0];
const initialShirts = Object.keys(user.purchased.shirt).length;
const [, message] = await unlock(user, { query: { path } });
@@ -282,11 +286,12 @@ describe('shared.ops.unlock', () => {
);
expect(get(user.purchased, path)).to.be.true;
expect(user.balance).to.equal(usersStartingGems - 0.5);
expect(user.pinnedItems.findIndex(item => item.type === 'shirt')).to.equal(-1);
});
it('unlocks an item (hair color)', async () => {
user.purchased.hair.color = {};
expect(user.pinnedItems.findIndex(item => item.type === 'haircolor')).to.not.equal(-1);
const path = hairUnlockPath.split(',')[0];
const initialColorHair = Object.keys(user.purchased.hair.color).length;
const [, message] = await unlock(user, { query: { path } });
@@ -297,6 +302,7 @@ describe('shared.ops.unlock', () => {
);
expect(get(user.purchased, path)).to.be.true;
expect(user.balance).to.equal(usersStartingGems - 0.5);
expect(user.pinnedItems.findIndex(item => item.type === 'haircolor')).to.equal(-1);
});
it('unlocks an item (facial hair)', async () => {
@@ -334,6 +340,7 @@ describe('shared.ops.unlock', () => {
it('unlocks an item (background)', async () => {
const initialBackgrounds = Object.keys(user.purchased.background).length;
expect(user.pinnedItems.findIndex(item => item.type === 'background')).to.not.equal(-1);
const [, message] = await unlock(user, {
query: { path: backgroundUnlockPath },
});
@@ -344,6 +351,7 @@ describe('shared.ops.unlock', () => {
);
expect(get(user.purchased, backgroundUnlockPath)).to.be.true;
expect(user.balance).to.equal(usersStartingGems - 1.75);
expect(user.pinnedItems.findIndex(item => item.type === 'background')).to.equal(-1);
});
it('handles an invalid hair path gracefully', async () => {
+41
View File
@@ -0,0 +1,41 @@
import { getMatchingSwap, makeSubstitutionMap } from '../../website/common/script/content/constants/aprilFools';
describe('April Fools', () => {
describe('getMatchingSwap', () => {
it('returns Veggie for 2020', () => {
const swap = getMatchingSwap(new Date('2020-04-01'));
expect(swap).to.equal('Veggie');
});
it('returns Alien for 2026', () => {
const swap = getMatchingSwap(new Date('2026-04-01'));
expect(swap).to.equal('Alien');
});
it('Cycles through swaps correctly', () => {
const swap = getMatchingSwap(new Date('2027-04-01'));
expect(swap).to.equal('Veggie');
});
});
describe('makeSubstitutionMap', () => {
it('returns correct substitution for Veggie', () => {
const substitutions = makeSubstitutionMap('Veggie');
expect(substitutions.pets['Pet-Wolf-']).to.equal('Pet-Wolf-Veggie');
expect(substitutions.pets['Pet-TigerCub-']).to.equal('Pet-TigerCub-Veggie');
expect(substitutions.pets['Pet-Yarn-']).to.equal('Pet-BearCub-Veggie');
expect(substitutions.pets.default).to.equal('Pet-Dragon-Veggie');
expect(substitutions.pets.noPet).to.equal('Pet-Wolf-Veggie');
expect(substitutions.pets.noPetIOS).to.equal('Pet-TigerCub-Veggie');
expect(substitutions.pets.noPetAndroid).to.equal('Pet-Cactus-Veggie');
});
it('returns correct substitution for Cryptid', () => {
const substitutions = makeSubstitutionMap('Cryptid');
expect(substitutions.pets['Pet-Fox-']).to.equal('Pet-Fox-Cryptid');
expect(substitutions.pets['Pet-FlyingPig-']).to.equal('Pet-FlyingPig-Cryptid');
expect(substitutions.pets['Pet-Yarn-']).to.equal('Pet-BearCub-Cryptid');
expect(substitutions.pets.default).to.equal('Pet-Dragon-Cryptid');
expect(substitutions.pets.noPet).to.equal('Pet-Wolf-Cryptid');
expect(substitutions.pets.noPetAndroid).to.equal('Pet-Cactus-Cryptid');
});
});
});
+1 -6
View File
@@ -1,12 +1,7 @@
import { STRING_ERROR_MSG, STRING_DOES_NOT_EXIST_MSG } from '../helpers/content.helper';
import { STRING_DOES_NOT_EXIST_MSG } from '../helpers/content.helper';
import translator from '../../website/common/script/content/translation';
describe('Translator', () => {
it('returns error message if string is not properly formatted', () => {
const improperlyFormattedString = translator('petName', { attr: 0 })();
expect(improperlyFormattedString).to.match(STRING_ERROR_MSG);
});
it('returns an error message if string does not exist', () => {
const stringDoesNotExist = translator('stringDoesNotExist')();
expect(stringDoesNotExist).to.match(STRING_DOES_NOT_EXIST_MSG);
+2 -2
View File
@@ -1,8 +1,8 @@
import i18n from '../../website/common/script/i18n';
import './globals.helper';
import { translations } from '../../website/server/libs/i18n';
import { contentTranslations } from '../../website/server/libs/i18n';
i18n.translations = translations;
i18n.translations = contentTranslations;
export const STRING_ERROR_MSG = /^Error processing the string ".*". Please see Help > Report a Bug.$/;
export const STRING_DOES_NOT_EXIST_MSG = /^String '.*' not found.$/;
+1
View File
@@ -21,6 +21,7 @@ export async function getProperty (collectionName, id, path) {
// Specifically helpful for the GET /groups tests,
// resets the db to an empty state and creates a tavern document
export async function resetHabiticaDB () {
console.info('Resetting Habitica DB');
const groups = mongoose.connection.db.collection('groups');
const users = mongoose.connection.db.collection('users');
return mongoose.connection.dropDatabase()
+1 -1
View File
@@ -32,6 +32,6 @@
<script type="text/javascript" src="//cloudfront.loggly.com/js/loggly.tracker-latest.min.js" async></script>
<!-- Translations -->
<script type='text/javascript' src='/api/v4/i18n/browser-script' vite-ignore></script>
<script type='text/javascript' src='/api/v4/i18n/core' vite-ignore></script>
</body>
</html>
+4979 -2777
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -4,6 +4,7 @@
"private": true,
"scripts": {
"serve": "vite",
"serve:docker": "npx vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"test:unit": "vitest run",
@@ -27,12 +28,13 @@
"eslint-config-habitrpg": "6.2.0",
"eslint-plugin-mocha": "5.3.0",
"eslint-plugin-vue": "7.20.0",
"habitica-markdown": "^3.0.0",
"habitica-markdown": "^4.0.0",
"hellojs": "^1.20.0",
"intro.js": "^7.2.0",
"jquery": "^3.7.1",
"lodash": "^4.17.21",
"markdown-it": "^14.0.0",
"micromustache": "^8.0.3",
"moment": "^2.29.4",
"nconf": "^0.12.1",
"sass": "^1.63.4",
+5
View File
@@ -229,6 +229,11 @@ export default {
}
return Promise.resolve(error);
}
if (error.response.status === 404
&& error.response.config.method === 'get'
&& error.response.config.url.indexOf('/api/v4/groups/party') !== -1) {
return Promise.reject(error);
}
}
const errorData = error.response.data;
+2 -123
View File
@@ -1,57 +1,3 @@
.quest_lostMasterclasser4 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/quest_lostMasterclasser4.gif") no-repeat;
width: 219px;
height: 219px;
}
.quest_windup {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/quest_windup.gif") no-repeat;
width: 219px;
height: 219px;
}
.quest_solarSystem {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/quest_solarSystem.gif") no-repeat;
width: 219px;
height: 219px;
}
.quest_virtualpet {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/quest_virtualpet.gif") no-repeat;
width: 219px;
height: 219px;
}
.Pet_HatchingPotion_Dessert, .Pet_HatchingPotion_Veggie, .Pet_HatchingPotion_Windup,
.Pet_HatchingPotion_VirtualPet, .Pet_HatchingPotion_Fungi, .Pet_HatchingPotion_Cryptid {
width: 68px;
height: 68px;
}
.Pet_HatchingPotion_Dessert {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_Dessert.gif") no-repeat;
}
.Pet_HatchingPotion_Veggie {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_Veggie.gif") no-repeat;
}
.Pet_HatchingPotion_Windup {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_Windup.gif") no-repeat;
}
.Pet_HatchingPotion_VirtualPet {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_VirtualPet.gif") no-repeat;
}
.Pet_HatchingPotion_Fungi {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_Fungi.gif") no-repeat;
}
.Pet_HatchingPotion_Cryptid {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_Cryptid.gif") no-repeat;
}
.Gems {
display:inline-block;
margin-right:5px;
@@ -80,6 +26,7 @@
margin-left: -3px;
margin-top: -18px;
}
.slim_armor_special_0, .broad_armor_special_0, .shield_special_0 {
width: 90px;
height: 90px;
@@ -87,7 +34,6 @@
/* Critical */
.weapon_special_critical {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/weapon_special_critical.gif") no-repeat;
width: 90px;
height: 90px;
margin-left:-12px;
@@ -98,6 +44,7 @@
.weapon_special_1 {
margin-left: -12px;
}
.broad_armor_special_1, .slim_armor_special_1, .head_special_1 {
width: 90px;
height: 90px;
@@ -106,36 +53,15 @@
.back_special_heroicAureole {
width: 114px;
height: 90px;
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/back_special_heroicAureole.gif") no-repeat;
}
.head_special_0 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Equip-ShadeHelmet.gif") no-repeat;
}
.head_special_1 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/ContributorOnly-Equip-CrystalHelmet.gif") no-repeat;
margin-top: 3px;
}
.broad_armor_special_0,.slim_armor_special_0 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Equip-ShadeArmor.gif") no-repeat;
}
.broad_armor_special_1,.slim_armor_special_1 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/ContributorOnly-Equip-CrystalArmor.gif") no-repeat;
}
.shield_special_0 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Shield-TormentedSkull.gif") no-repeat;
}
.weapon_special_0 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Weapon-DarkSoulsBlade.gif") no-repeat;
}
.Pet-Wolf-Cerberus {
width: 105px;
height: 72px;
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Pet-CerberusPup.gif") no-repeat;
}
.broad_armor_special_ks2019, .slim_armor_special_ks2019, .eyewear_special_ks2019, .head_special_ks2019, .shield_special_ks2019 {
@@ -143,36 +69,17 @@
height: 120px;
}
.broad_armor_special_ks2019, .slim_armor_special_ks2019 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Equip-MythicGryphonArmor.gif") no-repeat;
}
.eyewear_special_ks2019 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Equip-MythicGryphonVisor.gif") no-repeat;
}
.head_special_ks2019 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Equip-MythicGryphonHelm.gif") no-repeat;
}
.shield_special_ks2019 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Equip-MythicGryphonShield.gif") no-repeat;
}
.weapon_special_ks2019 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Equip-MythicGryphonGlaive.gif") no-repeat;
width: 120px;
height: 120px;
}
.Pet-Gryphon-Gryphatrice {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Pet-Gryphatrice.gif") no-repeat;
width: 81px;
height: 99px;
}
.Pet-Gryphatrice-Jubilant {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-Gryphatrice-Jubilant.gif") no-repeat;
width: 81px;
height: 96px;
}
@@ -182,39 +89,11 @@
height: 135px;
}
.Mount_Head_Gryphon-Gryphatrice {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Mount-Head-Gryphatrice.gif") no-repeat;
}
.Mount_Body_Gryphon-Gryphatrice {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Mount-Body-Gryphatrice.gif") no-repeat;
}
.Mount_Head_Dragon-Hydra {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Mount_Head_Dragon-Hydra.gif") no-repeat;
}
.Mount_Body_Dragon-Hydra {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Mount_Body_Dragon-Hydra.gif") no-repeat;
}
.background_airship, .background_clocktower, .background_steamworks {
width: 141px;
height: 147px;
}
.background_airship {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_airship.gif") no-repeat;
}
.background_clocktower {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_clocktower.gif") no-repeat;
}
.background_steamworks {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_steamworks.gif") no-repeat;
}
[class*="Mount_Head_"],
[class*="Mount_Body_"] {
margin-top:18px; /* Sprite accommodates 105x123 box */
@@ -1060,6 +1060,11 @@
width: 141px;
height: 147px;
}
.background_elven_citadel {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_elven_citadel.png');
width: 141px;
height: 147px;
}
.background_enchanted_music_room {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_enchanted_music_room.png');
width: 141px;
@@ -1796,6 +1801,11 @@
width: 141px;
height: 147px;
}
.background_on_a_strange_planet {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_on_a_strange_planet.png');
width: 141px;
height: 147px;
}
.background_on_tree_branch {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_on_tree_branch.png');
width: 141px;
@@ -1931,6 +1941,11 @@
width: 141px;
height: 147px;
}
.background_riding_a_comet {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_riding_a_comet.png');
width: 141px;
height: 147px;
}
.background_rime_ice {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_rime_ice.png');
width: 141px;
@@ -2427,6 +2442,11 @@
width: 141px;
height: 147px;
}
.background_waterfall_with_rainbow {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_waterfall_with_rainbow.png');
width: 141px;
height: 147px;
}
.background_wedding_arch {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_wedding_arch.png');
width: 141px;
@@ -29800,6 +29820,11 @@
width: 114px;
height: 90px;
}
.broad_armor_armoire_handstandOutfit {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_armoire_handstandOutfit.png');
width: 114px;
height: 90px;
}
.broad_armor_armoire_hattersSuit {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_armoire_hattersSuit.png');
width: 114px;
@@ -30075,6 +30100,11 @@
width: 114px;
height: 90px;
}
.broad_armor_armoire_softYellowSuit {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_armoire_softYellowSuit.png');
width: 114px;
height: 90px;
}
.broad_armor_armoire_springPetalYukata {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_armoire_springPetalYukata.png');
width: 114px;
@@ -30385,6 +30415,11 @@
width: 114px;
height: 90px;
}
.head_armoire_floppyYellowHat {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_armoire_floppyYellowHat.png');
width: 114px;
height: 90px;
}
.head_armoire_flutteryWig {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_armoire_flutteryWig.png');
width: 114px;
@@ -30705,6 +30740,11 @@
width: 114px;
height: 90px;
}
.head_armoire_verdantArmingCap {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_armoire_verdantArmingCap.png');
width: 114px;
height: 90px;
}
.head_armoire_vermilionArcherHelm {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_armoire_vermilionArcherHelm.png');
width: 90px;
@@ -31120,6 +31160,11 @@
width: 114px;
height: 90px;
}
.shield_armoire_softYellowPillow {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/shield_armoire_softYellowPillow.png');
width: 114px;
height: 90px;
}
.shield_armoire_spanishGuitar {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/shield_armoire_spanishGuitar.png');
width: 114px;
@@ -31170,6 +31215,11 @@
width: 114px;
height: 90px;
}
.shield_armoire_verdantBanner {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/shield_armoire_verdantBanner.png');
width: 114px;
height: 90px;
}
.shield_armoire_vikingShield {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/shield_armoire_vikingShield.png');
width: 90px;
@@ -31440,6 +31490,11 @@
width: 114px;
height: 90px;
}
.slim_armor_armoire_handstandOutfit {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_armoire_handstandOutfit.png');
width: 114px;
height: 90px;
}
.slim_armor_armoire_hattersSuit {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_armoire_hattersSuit.png');
width: 114px;
@@ -31715,6 +31770,11 @@
width: 114px;
height: 90px;
}
.slim_armor_armoire_softYellowSuit {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_armoire_softYellowSuit.png');
width: 114px;
height: 90px;
}
.slim_armor_armoire_springPetalYukata {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_armoire_springPetalYukata.png');
width: 114px;
@@ -34125,11 +34185,21 @@
width: 114px;
height: 90px;
}
.back_mystery_202605 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/back_mystery_202605.png');
width: 114px;
height: 90px;
}
.broad_armor_mystery_202512 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_mystery_202512.png');
width: 114px;
height: 90px;
}
.broad_armor_mystery_202604 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_mystery_202604.png');
width: 114px;
height: 90px;
}
.head_mystery_202512 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_mystery_202512.png');
width: 114px;
@@ -34140,11 +34210,31 @@
width: 114px;
height: 90px;
}
.head_mystery_202603 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_mystery_202603.png');
width: 114px;
height: 90px;
}
.head_mystery_202604 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_mystery_202604.png');
width: 114px;
height: 90px;
}
.shield_mystery_202605 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/shield_mystery_202605.png');
width: 114px;
height: 90px;
}
.slim_armor_mystery_202512 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_mystery_202512.png');
width: 114px;
height: 90px;
}
.slim_armor_mystery_202604 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_mystery_202604.png');
width: 114px;
height: 90px;
}
.weapon_mystery_202512 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/weapon_mystery_202512.png');
width: 114px;
@@ -34155,6 +34245,11 @@
width: 114px;
height: 90px;
}
.weapon_mystery_202603 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/weapon_mystery_202603.png');
width: 114px;
height: 90px;
}
.back_mystery_201402 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/back_mystery_201402.png');
width: 90px;
@@ -36275,6 +36370,26 @@
width: 114px;
height: 90px;
}
.broad_armor_special_spring2026Healer {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_special_spring2026Healer.png');
width: 114px;
height: 90px;
}
.broad_armor_special_spring2026Mage {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_special_spring2026Mage.png');
width: 114px;
height: 90px;
}
.broad_armor_special_spring2026Rogue {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_special_spring2026Rogue.png');
width: 114px;
height: 90px;
}
.broad_armor_special_spring2026Warrior {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_special_spring2026Warrior.png');
width: 114px;
height: 90px;
}
.broad_armor_special_springHealer {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_special_springHealer.png');
width: 90px;
@@ -36595,6 +36710,26 @@
width: 114px;
height: 90px;
}
.head_special_spring2026Healer {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_special_spring2026Healer.png');
width: 114px;
height: 90px;
}
.head_special_spring2026Mage {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_special_spring2026Mage.png');
width: 114px;
height: 90px;
}
.head_special_spring2026Rogue {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_special_spring2026Rogue.png');
width: 114px;
height: 90px;
}
.head_special_spring2026Warrior {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_special_spring2026Warrior.png');
width: 114px;
height: 90px;
}
.head_special_springHealer {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_special_springHealer.png');
width: 90px;
@@ -36780,6 +36915,21 @@
width: 114px;
height: 90px;
}
.shield_special_spring2026Healer {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/shield_special_spring2026Healer.png');
width: 114px;
height: 90px;
}
.shield_special_spring2026Rogue {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/shield_special_spring2026Rogue.png');
width: 114px;
height: 90px;
}
.shield_special_spring2026Warrior {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/shield_special_spring2026Warrior.png');
width: 114px;
height: 90px;
}
.shield_special_springHealer {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/shield_special_springHealer.png');
width: 90px;
@@ -37015,6 +37165,26 @@
width: 114px;
height: 90px;
}
.slim_armor_special_spring2026Healer {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_special_spring2026Healer.png');
width: 114px;
height: 90px;
}
.slim_armor_special_spring2026Mage {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_special_spring2026Mage.png');
width: 114px;
height: 90px;
}
.slim_armor_special_spring2026Rogue {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_special_spring2026Rogue.png');
width: 114px;
height: 90px;
}
.slim_armor_special_spring2026Warrior {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_special_spring2026Warrior.png');
width: 114px;
height: 90px;
}
.slim_armor_special_springHealer {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_special_springHealer.png');
width: 90px;
@@ -37255,6 +37425,26 @@
width: 114px;
height: 90px;
}
.weapon_special_spring2026Healer {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/weapon_special_spring2026Healer.png');
width: 114px;
height: 90px;
}
.weapon_special_spring2026Mage {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/weapon_special_spring2026Mage.png');
width: 114px;
height: 90px;
}
.weapon_special_spring2026Rogue {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/weapon_special_spring2026Rogue.png');
width: 114px;
height: 90px;
}
.weapon_special_spring2026Warrior {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/weapon_special_spring2026Warrior.png');
width: 114px;
height: 90px;
}
.weapon_special_springHealer {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/weapon_special_springHealer.png');
width: 90px;
@@ -53038,6 +53228,11 @@
width: 81px;
height: 99px;
}
.Pet-BearCub-Alien {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-BearCub-Alien.png');
width: 81px;
height: 99px;
}
.Pet-BearCub-Amber {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-BearCub-Amber.png');
width: 81px;
@@ -53528,6 +53723,11 @@
width: 81px;
height: 99px;
}
.Pet-Cactus-Alien {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-Cactus-Alien.png');
width: 81px;
height: 99px;
}
.Pet-Cactus-Amber {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-Cactus-Amber.png');
width: 81px;
@@ -54318,6 +54518,11 @@
width: 81px;
height: 99px;
}
.Pet-Dragon-Alien {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-Dragon-Alien.png');
width: 81px;
height: 99px;
}
.Pet-Dragon-Amber {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-Dragon-Amber.png');
width: 81px;
@@ -54813,6 +55018,11 @@
width: 81px;
height: 99px;
}
.Pet-FlyingPig-Alien {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-FlyingPig-Alien.png');
width: 81px;
height: 99px;
}
.Pet-FlyingPig-Amber {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-FlyingPig-Amber.png');
width: 81px;
@@ -55148,6 +55358,11 @@
width: 81px;
height: 99px;
}
.Pet-Fox-Alien {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-Fox-Alien.png');
width: 81px;
height: 99px;
}
.Pet-Fox-Amber {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-Fox-Amber.png');
width: 81px;
@@ -55928,6 +56143,11 @@
width: 81px;
height: 99px;
}
.Pet-LionCub-Alien {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-LionCub-Alien.png');
width: 81px;
height: 99px;
}
.Pet-LionCub-Amber {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-LionCub-Amber.png');
width: 81px;
@@ -56533,6 +56753,11 @@
width: 81px;
height: 99px;
}
.Pet-PandaCub-Alien {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-PandaCub-Alien.png');
width: 81px;
height: 99px;
}
.Pet-PandaCub-Amber {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-PandaCub-Amber.png');
width: 81px;
@@ -57928,6 +58153,11 @@
width: 81px;
height: 99px;
}
.Pet-TigerCub-Alien {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-TigerCub-Alien.png');
width: 81px;
height: 99px;
}
.Pet-TigerCub-Amber {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-TigerCub-Amber.png');
width: 81px;
@@ -58573,6 +58803,11 @@
width: 81px;
height: 99px;
}
.Pet-Wolf-Alien {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-Wolf-Alien.png');
width: 81px;
height: 99px;
}
.Pet-Wolf-Amber {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-Wolf-Amber.png');
width: 81px;
Binary file not shown.

After

Width:  |  Height:  |  Size: 375 B

@@ -58,6 +58,11 @@ h3.markdown {
img {
max-width: 100%;
}
.emoji-native {
font-size: 0.85em;
vertical-align: middle;
}
blockquote {
padding: 0 16px;
@@ -0,0 +1,5 @@
<svg width="330" height="80" viewBox="0 0 330 80" preserveAspectRatio="none" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M159.797 60.5502C165.534 61.8466 171.631 62.7536 178.221 63.1767C208.94 65.1492 233.733 56.6838 260.68 47.483C282.33 40.091 305.37 32.2243 333.99 28.9144L336 16.3018C260.81 7.08865 233.373 23.1672 205.362 39.5825C192.037 47.3908 178.583 55.2753 159.797 60.5502Z" fill="#BDA8FF"/>
<path d="M0 80L331.948 79.9998V29.1594C268.976 36.9871 233.03 66.6959 178.221 63.1767C112.951 58.9858 95.9516 7.31934 0.000104656 0L0 80Z" fill="#925CF3"/>
<path d="M203.54 40.6496C166.339 36.8525 141.531 39.6251 122.334 45.4666C133.94 51.8989 145.792 57.3851 159.797 60.5502C177.727 55.5155 190.801 48.1036 203.54 40.6496Z" fill="#D5C8FF"/>
</svg>

After

Width:  |  Height:  |  Size: 803 B

@@ -1,53 +1,228 @@
<template>
<b-modal
id="rebirth"
:title="$t('modalAchievement')"
size="md"
:hide-footer="true"
size="sm"
:hide-header="true"
>
<div class="modal-body">
<div class="col-12">
<!-- @TODO: +achievementAvatar('sun',0)--><achievement-avatar class="avatar" />
</div><div class="col-6 offset-3 text-center">
<div v-if="user.achievements.rebirthLevel < 100">
{{ $t('rebirthAchievement', {
number: user.achievements.rebirths,
level: user.achievements.rebirthLevel}) }}
</div><div v-if="user.achievements.rebirthLevel >= 100">
{{ $t('rebirthAchievement100', {number: user.achievements.rebirths}) }}
</div><br><button
class="btn btn-primary"
@click="close()"
>
{{ $t('huzzah') }}
</button>
<div
class="close-x"
@click.stop="close()"
>
<div
class="svg-icon svg-close"
v-html="icons.close"
></div>
</div>
<div class="content text-center">
<h2
v-once
class="header"
>
{{ $t('rebirthNewAchievement') }}
</h2>
<div class="d-flex align-items-center justify-content-center icon-area">
<div
v-once
class="svg-icon sparkles mirror"
v-html="icons.starGroup"
></div>
<Sprite
class="achievement-icon"
image-name="achievement-sun2x"
/>
<div
v-once
class="svg-icon sparkles"
v-html="icons.starGroup"
></div>
</div>
</div><achievement-footer />
<p class="subtitle">
{{ $t('rebirthNewAdventure') }}
</p>
<p
class="description"
v-html="achievementText"
></p>
<p
v-once
class="stack-info"
>
{{ $t('rebirthStackInfo') }}
</p>
<button
v-once
class="btn btn-primary"
@click="close()"
>
{{ $t('onwards') }}
</button>
</div>
<div
slot="modal-footer"
class="footer-wave"
v-html="icons.purpleWaves"
></div>
</b-modal>
</template>
<style scoped>
.avatar {
width: 140px;
margin: 0 auto;
margin-bottom: 1.5em;
margin-top: 1.5em;
<style lang="scss">
@import '@/assets/scss/colors.scss';
#rebirth {
.modal-dialog {
width: 330px;
}
.modal-content {
border: none;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 14px 28px 0 rgba($black, 0.24), 0 10px 10px 0 rgba($black, 0.28);
}
.modal-body {
padding: 0;
}
.modal-footer {
padding: 0;
border-top: none;
border-radius: 0;
margin: 0;
line-height: 0;
}
}
</style>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
.content {
padding: 24px 24px 0;
}
.header {
font-size: 1.25rem;
line-height: 1.4;
color: $purple-200;
margin-top: 8px;
margin-bottom: 16px;
}
.icon-area {
margin-bottom: 16px;
}
.sparkles {
width: 40px;
height: 64px;
&.mirror {
transform: scaleX(-1);
}
}
.close-x {
position: absolute;
right: 16px;
top: 16px;
cursor: pointer;
z-index: 2;
&:hover .svg-close {
opacity: 0.75;
}
.svg-close {
width: 16px;
height: 16px;
opacity: 0.5;
transition: opacity 0.2s ease;
pointer-events: none;
}
}
.achievement-icon {
margin: 0 24px;
}
.subtitle {
font-family: 'Roboto', sans-serif;
font-weight: 700;
font-style: normal;
font-size: 14px;
line-height: 24px;
letter-spacing: 0;
margin-bottom: 12px;
color: $gray-10;
}
.description {
font-size: 0.875rem;
line-height: 1.71;
margin-bottom: 12px;
color: $gray-50;
}
.stack-info {
font-size: 0.875rem;
line-height: 1.71;
color: $gray-50;
margin-bottom: 24px;
}
.btn-primary {
margin-bottom: 8px;
}
.footer-wave {
width: 100%;
::v-deep svg {
display: block;
width: calc(100% + 8px);
height: auto;
margin: 0 -4px -4px;
}
}
</style>
<script>
import achievementFooter from './achievementFooter';
import achievementAvatar from './achievementAvatar';
import closeIcon from '@/assets/svg/close.svg?raw';
import Sprite from '@/components/ui/sprite';
import starGroup from '@/assets/svg/star-group.svg?raw';
import purpleWaves from '@/assets/svg/purple-waves.svg?raw';
import { mapState } from '@/libs/store';
export default {
components: {
achievementFooter,
achievementAvatar,
Sprite,
},
data () {
return {
icons: Object.freeze({
starGroup,
purpleWaves,
close: closeIcon,
}),
};
},
computed: {
...mapState({ user: 'user.data' }),
achievementText () {
const rebirths = this.user.achievements.rebirths || 0;
const level = this.user.achievements.rebirthLevel || 0;
if (level >= 100) {
return this.$t('rebirthAchievement100', { number: rebirths, level });
}
if (rebirths === 1) {
return this.$t('rebirthAchievement', { number: rebirths, level });
}
return this.$t('rebirthAchievementPlural', { number: rebirths, level });
},
},
methods: {
close () {
@@ -1,41 +1,186 @@
<template>
<b-modal
id="rebirth-enabled"
:title="$t('rebirthNew')"
size="md"
:hide-footer="true"
size="sm"
:hide-header="true"
>
<div class="modal-body">
<div class="col-12">
<div class="rebirth_orb"></div>
<p>
<span>{{ $t('rebirthUnlock') }}</span>
</p>
</div>
<div
class="close-x"
@click.stop="close()"
>
<div
class="svg-icon svg-close"
v-html="icons.close"
></div>
</div>
<div class="modal-footer">
<div class="col-12 text-center">
<button
class="btn btn-primary"
@click="close()"
<div class="content text-center">
<h2
v-once
class="header"
>
{{ $t('rebirthUnlockedNewItem') }}
</h2>
<div class="d-flex align-items-center justify-content-center icon-area">
<div
v-once
class="svg-icon sparkles mirror"
v-html="icons.starGroup"
></div>
<img
class="orb-icon"
src="@/assets/images/rebirth-orb.png"
alt="Orb of Rebirth"
>
{{ $t('close') }}
</button>
<div
v-once
class="svg-icon sparkles"
v-html="icons.starGroup"
></div>
</div>
<p
v-once
class="subtitle"
>
{{ $t('rebirthUnlockedOrb') }}
</p>
<p
v-once
class="description"
>
{{ $t('rebirthUnlockedDesc') }}
</p>
<button
v-once
class="btn btn-primary"
@click="close()"
>
{{ $t('onwards') }}
</button>
</div>
<div
slot="modal-footer"
class="clearfix"
></div>
</b-modal>
</template>
<style scoped>
.rebirth_orb {
margin: 0 auto;
<style lang="scss">
@import '@/assets/scss/colors.scss';
#rebirth-enabled {
.modal-dialog {
width: 330px;
}
.modal-content {
border-radius: 8px;
overflow: hidden;
box-shadow: 0 14px 28px 0 rgba($black, 0.24), 0 10px 10px 0 rgba($black, 0.28);
}
.modal-body {
padding: 0;
}
.modal-footer {
padding: 0;
border-top: none;
}
}
</style>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
.content {
padding: 24px 24px 0;
}
.header {
font-size: 1.25rem;
line-height: 1.4;
color: $purple-200;
margin-top: 8px;
margin-bottom: 12px;
}
.icon-area {
margin-bottom: 12px;
}
.sparkles {
width: 40px;
height: 64px;
&.mirror {
transform: scaleX(-1);
}
}
.close-x {
position: absolute;
right: 16px;
top: 16px;
cursor: pointer;
z-index: 2;
&:hover .svg-close {
opacity: 0.75;
}
.svg-close {
width: 16px;
height: 16px;
opacity: 0.5;
transition: opacity 0.2s ease;
pointer-events: none;
}
}
.orb-icon {
width: 62px;
height: 62px;
margin: 0 24px;
image-rendering: pixelated;
}
.subtitle {
font-family: 'Roboto', sans-serif;
font-weight: 700;
font-style: normal;
font-size: 14px;
line-height: 24px;
letter-spacing: 0;
margin-bottom: 12px;
color: $gray-50;
}
.description {
font-size: 0.875rem;
line-height: 1.71;
margin-bottom: 24px;
color: $gray-100;
}
.btn-primary {
margin-bottom: 24px;
}
</style>
<script>
import closeIcon from '@/assets/svg/close.svg?raw';
import starGroup from '@/assets/svg/star-group.svg?raw';
import { mapState } from '@/libs/store';
export default {
data () {
return {
icons: Object.freeze({
starGroup,
close: closeIcon,
}),
};
},
computed: {
...mapState({ user: 'user.data' }),
},
File diff suppressed because it is too large Load Diff
@@ -311,7 +311,7 @@
<input
id="passwordInput"
v-model="password"
class="form-control input-with-error"
class="form-control dark input-with-error"
type="password"
:placeholder="$t('password')"
:class="{'input-invalid': passwordInvalid, 'input-valid': passwordValid}"
@@ -323,7 +323,7 @@
{{ $t('minPasswordLength') }}
</div>
</div>
<div class="form-group">
<div class="form-group mb-4">
<label
v-once
for="confirmPasswordInput"
@@ -331,7 +331,7 @@
<input
id="confirmPasswordInput"
v-model="passwordConfirm"
class="form-control input-with-error"
class="form-control dark input-with-error"
type="password"
:placeholder="$t('confirmPasswordPlaceholder')"
:class="{'input-invalid': passwordConfirmInvalid, 'input-valid': passwordConfirmValid}"
@@ -344,13 +344,14 @@
</div>
</div>
<div class="text-center">
<div
class="btn btn-info"
:enabled="!resetPasswordSetNewOneData.hasError"
<button
class="btn btn-info w-100"
:disabled="!password || !passwordConfirm
|| password !== passwordConfirm || resetPasswordSetNewOneData.hasError"
@click="resetPasswordSetNewOneLink()"
>
{{ $t('setNewPass') }}
</div>
</button>
</div>
</form>
</div>
+5 -4
View File
@@ -321,10 +321,11 @@ export default {
return null;
},
petClass () {
const foolEvent = this.currentEventList?.find(event => event.aprilFools && moment()
.isBetween(event.start, event.end));
if (foolEvent) {
return this.foolPet(this.member.items.currentPet, foolEvent.aprilFools);
const substitutionEvent = this.currentEventList?.find(event => event.spriteSubstitutions
&& moment().isBetween(event.start, event.end));
if (substitutionEvent && substitutionEvent.spriteSubstitutions.pets) {
return this.foolPet(`Pet-${this.member.items.currentPet}`,
substitutionEvent.spriteSubstitutions.pets);
}
if (this.member?.items.currentPet) return `Pet-${this.member.items.currentPet}`;
return '';
@@ -12,23 +12,39 @@
<label>
<strong v-once>{{ $t('name') }} *</strong>
</label>
<b-form-input
<input
ref="nameInput"
v-model="workingChallenge.name"
class="form-control"
type="text"
:placeholder="$t('challengeNamePlaceholder')"
@keydown="enableSubmit"
/>
@focus="setActiveField('name')"
@keydown="onFieldKeydown($event)"
@keydown.tab="autoCompleteMixinHandleTab($event)"
@keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)"
@keydown.down="autoCompleteMixinSelectNextAutocomplete($event)"
@keypress.enter="autoCompleteMixinSelectAutocomplete($event)"
@keydown.esc="autoCompleteMixinHandleEscape($event)"
>
</div>
<div class="form-group">
<label>
<strong v-once>{{ $t('shortName') }} *</strong>
</label>
<b-form-input
<input
ref="shortNameInput"
v-model="workingChallenge.shortName"
class="form-control"
type="text"
:placeholder="$t('shortNamePlaceholder')"
@keydown="enableSubmit"
/>
@focus="setActiveField('shortName')"
@keydown="onFieldKeydown($event)"
@keydown.tab="autoCompleteMixinHandleTab($event)"
@keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)"
@keydown.down="autoCompleteMixinSelectNextAutocomplete($event)"
@keypress.enter="autoCompleteMixinSelectAutocomplete($event)"
@keydown.esc="autoCompleteMixinHandleEscape($event)"
>
</div>
<div class="form-group">
<label>
@@ -40,10 +56,17 @@
{{ $t('charactersRemaining', {characters: charactersRemaining}) }}
</div>
<textarea
ref="summaryTextarea"
v-model="workingChallenge.summary"
class="summary-textarea form-control"
:placeholder="$t('challengeSummaryPlaceholder')"
@keydown="enableSubmit"
@focus="setActiveField('summary')"
@keydown="onFieldKeydown($event)"
@keydown.tab="autoCompleteMixinHandleTab($event)"
@keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)"
@keydown.down="autoCompleteMixinSelectNextAutocomplete($event)"
@keypress.enter="autoCompleteMixinSelectAutocomplete($event)"
@keydown.esc="autoCompleteMixinHandleEscape($event)"
></textarea>
</div>
<div class="form-group">
@@ -55,11 +78,26 @@
class="float-right"
></a>
<textarea
ref="descriptionTextarea"
v-model="workingChallenge.description"
class="description-textarea form-control"
:placeholder="$t('challengeDescriptionPlaceholder')"
@keydown="enableSubmit"
@focus="setActiveField('description')"
@keydown="onFieldKeydown($event)"
@keydown.tab="autoCompleteMixinHandleTab($event)"
@keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)"
@keydown.down="autoCompleteMixinSelectNextAutocomplete($event)"
@keypress.enter="autoCompleteMixinSelectAutocomplete($event)"
@keydown.esc="autoCompleteMixinHandleEscape($event)"
></textarea>
<emoji-auto-complete
ref="emojiAutocomplete"
:text="activeFieldText"
:textbox="textbox"
:coords="mixinData.autoComplete.coords"
:caret-position="mixinData.autoComplete.caretPosition"
@select="selectedAutocomplete"
/>
</div>
<div
v-if="creating"
@@ -280,12 +318,17 @@ import { TAVERN_ID, MIN_SHORTNAME_SIZE_FOR_CHALLENGES, MAX_SUMMARY_SIZE_FOR_CHAL
import CategoryOptions from '@/../../common/script/content/categoryOptions';
import markdownDirective from '@/directives/markdown';
import { userStateMixin } from '../../mixins/userState';
import emojiAutoComplete from '@/components/chat/emojiAutoComplete';
import { autoCompleteHelperMixin } from '@/mixins/autoCompleteHelper';
export default {
components: {
emojiAutoComplete,
},
directives: {
markdown: markdownDirective,
},
mixins: [userStateMixin],
mixins: [userStateMixin, autoCompleteHelperMixin],
props: ['groupId'],
data () {
const categoryOptions = CategoryOptions;
@@ -319,9 +362,14 @@ export default {
categoriesHashByKey,
loading: false,
groups: [],
textbox: null,
activeField: 'name',
};
},
computed: {
activeFieldText () {
return this.workingChallenge[this.activeField] || '';
},
creating () {
return !this.workingChallenge.id;
},
@@ -589,6 +637,29 @@ export default {
toggleCategorySelect () {
this.showCategorySelect = !this.showCategorySelect;
},
setActiveField (field) {
this.activeField = field;
const refMap = {
name: 'nameInput',
shortName: 'shortNameInput',
summary: 'summaryTextarea',
description: 'descriptionTextarea',
};
this.textbox = this.$refs[refMap[field]] || null;
},
onFieldKeydown (e) {
this.enableSubmit();
this.autoCompleteMixinUpdateCarretPosition(e);
},
selectedAutocomplete (newText, newCaret) {
this.workingChallenge[this.activeField] = newText;
this.$nextTick(() => {
if (this.textbox) {
this.textbox.setSelectionRange(newCaret, newCaret);
this.textbox.focus();
}
});
},
enableSubmit: throttle(function enableSubmit () {
/* Enables the submit button if it was disabled */
if (this.loading) {
@@ -0,0 +1,282 @@
<template>
<div
v-if="searchResults.length > 0"
class="autocomplete-selection"
:style="autocompleteStyle"
>
<div
v-for="result in searchResults"
:key="result.shortcode"
class="autocomplete-results d-flex align-items-center"
:class="{'hover-background': result.hover}"
@click="select(result)"
@mouseenter="setHover(result)"
@mouseleave="resetSelection()"
>
<img
v-if="result.imageUrl"
class="emoji-img"
:src="result.imageUrl"
:alt="result.shortcode"
>
<span
v-else
class="emoji-char"
>{{ result.emoji }}</span>
<span
class="shortcode ml-2"
:class="{'hover-foreground': result.hover}"
>:{{ result.shortcode }}:</span>
</div>
</div>
</template>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
.autocomplete-results {
padding: .5em;
}
.autocomplete-selection {
box-shadow: 1px 1px 1px #efefef;
}
.hover-background {
background-color: rgba(213, 200, 255, 0.32);
cursor: pointer;
}
.hover-foreground {
color: $purple-300 !important;
}
.emoji-char {
font-size: 20px;
line-height: 1;
}
.emoji-img {
height: 20px;
width: 20px;
}
.shortcode {
color: $gray-200;
font-size: 14px;
}
</style>
<script>
import habiticaMarkdown from 'habitica-markdown';
export default {
props: ['text', 'caretPosition', 'coords', 'textbox'],
data () {
return {
colonRegex: /:([a-zA-Z0-9_+]*)$/,
currentSearch: '',
searchActive: false,
searchResults: [],
selected: null,
emojiList: [],
renderTick: 0,
internalCoords: { TOP: 0, LEFT: 0 },
};
},
computed: {
autocompleteStyle () {
// eslint-disable-next-line no-unused-vars
const _tick = this.renderTick;
const isTextarea = this.textbox.tagName === 'TEXTAREA';
const dropdownPA = (this.$el && this.$el.nodeType === 1) ? this.$el.offsetParent : null;
const textboxOP = this.textbox.offsetParent;
const needsRectCalc = dropdownPA && textboxOP && dropdownPA !== textboxOP;
let top;
let left;
const caretLeft = this.internalCoords.LEFT - (this.textbox.scrollLeft || 0);
if (needsRectCalc) {
const textboxRect = this.textbox.getBoundingClientRect();
const parentRect = dropdownPA.getBoundingClientRect();
const parentScrollTop = dropdownPA.scrollTop || 0;
if (isTextarea) {
const computedStyle = window.getComputedStyle(this.textbox);
const lineHeight = parseFloat(computedStyle.lineHeight)
|| (parseFloat(computedStyle.fontSize) * 1.4);
const caretTopInTextbox = this.internalCoords.TOP
- (this.textbox.scrollTop || 0) + lineHeight;
const clamped = Math.min(Math.max(caretTopInTextbox, 0), this.textbox.offsetHeight);
top = (textboxRect.top - parentRect.top) + parentScrollTop + clamped + 2;
} else {
top = (textboxRect.bottom - parentRect.top) + parentScrollTop + 2;
}
left = (textboxRect.left - parentRect.left) + caretLeft;
} else {
if (isTextarea) {
const computedStyle = window.getComputedStyle(this.textbox);
const lineHeight = parseFloat(computedStyle.lineHeight)
|| (parseFloat(computedStyle.fontSize) * 1.4);
const caretTopInTextbox = this.internalCoords.TOP
- (this.textbox.scrollTop || 0) + lineHeight;
const clamped = Math.min(Math.max(caretTopInTextbox, 0), this.textbox.offsetHeight);
top = this.textbox.offsetTop + clamped + 2;
} else {
top = this.textbox.offsetTop + this.textbox.offsetHeight + 2;
}
left = this.textbox.offsetLeft + caretLeft;
}
return {
top: `${top}px`,
left: `${left}px`,
position: 'absolute',
minWidth: '150px',
zIndex: 100,
backgroundColor: 'white',
};
},
},
watch: {
searchResults (results, oldResults) {
if (results.length > 0 && (!oldResults || oldResults.length === 0)) {
this.$nextTick(() => {
this.renderTick += 1;
});
}
},
text (newText, prevText) {
if (!this.textbox) return;
this._measureCaretCoords();
const delCharsBool = prevText.length > newText.length;
const caretPosition = this.textbox.selectionEnd;
const lastFocusChar = delCharsBool ? prevText[caretPosition] : newText[caretPosition - 1];
if (
newText.length === 0
|| (lastFocusChar === ':' && delCharsBool)
) {
this.cancel();
} else {
if (lastFocusChar === ':') this.searchActive = true;
if (this.searchActive) {
this.searchResults = this.solveSearchResults(newText.substring(0, caretPosition));
}
}
},
},
created () {
const defs = habiticaMarkdown.emojiDefs;
if (!defs) return;
const customEmojis = habiticaMarkdown.customEmojis || {};
const list = [];
const keys = Object.keys(defs);
keys.sort();
for (const key of keys) {
const entry = { shortcode: key, emoji: defs[key], hover: false };
if (customEmojis[key]) {
entry.imageUrl = customEmojis[key];
}
list.push(entry);
}
this.emojiList = list;
},
methods: {
solveSearchResults (textFocus) {
const regexRes = this.colonRegex.exec(textFocus);
if (!regexRes) {
this.cancel();
return [];
}
this.currentSearch = regexRes[1];
if (this.currentSearch.length === 0) return [];
const lowerSearch = this.currentSearch.toLowerCase();
return this.emojiList
.filter(entry => entry.shortcode.startsWith(lowerSearch))
.slice(0, 6)
.map(entry => ({ ...entry, hover: false }));
},
select (result) {
const { text } = this;
const targetName = `${result.shortcode}: `;
const oldCaret = this.caretPosition;
const escapedSearch = this.currentSearch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
let newText = text.substring(0, this.caretPosition)
.replace(new RegExp(`${escapedSearch}$`), targetName);
const newCaret = newText.length;
newText += text.substring(oldCaret, text.length);
this.$emit('select', newText, newCaret);
this.cancel();
},
setHover (result) {
this.resetSelection();
result.hover = true;
},
clearHover () {
for (const selection of this.searchResults) {
selection.hover = false;
}
},
resetSelection () {
this.clearHover();
this.selected = null;
},
selectNext () {
if (this.searchResults.length > 0) {
this.clearHover();
this.selected = this.selected === null
? 0
: (this.selected + 1) % this.searchResults.length;
this.searchResults[this.selected].hover = true;
}
},
selectPrevious () {
if (this.searchResults.length > 0) {
this.clearHover();
this.selected = this.selected === null
? this.searchResults.length - 1
: (this.selected - 1 + this.searchResults.length) % this.searchResults.length;
this.searchResults[this.selected].hover = true;
}
},
makeSelection () {
if (this.searchResults.length > 0 && this.selected !== null) {
const result = this.searchResults[this.selected];
this.select(result);
}
},
_measureCaretCoords () {
const el = this.textbox;
const caretPosition = el.selectionEnd;
const div = document.createElement('div');
const span = document.createElement('span');
const copyStyle = getComputedStyle(el);
[].forEach.call(copyStyle, prop => {
div.style[prop] = copyStyle[prop];
});
div.style.position = 'absolute';
div.style.visibility = 'hidden';
document.body.appendChild(div);
div.textContent = el.value.substr(0, caretPosition);
span.textContent = el.value.substr(caretPosition) || '.';
div.appendChild(span);
this.internalCoords = {
TOP: span.offsetTop,
LEFT: span.offsetLeft,
};
document.body.removeChild(div);
},
cancel () {
this.searchActive = false;
this.searchResults = [];
this.resetSelection();
},
},
};
</script>
@@ -187,7 +187,8 @@
</div>
</div>
<div
v-if="user.purchased.background.birthday_bash"
v-if="user.purchased.background.birthday_bash
|| user.purchased.background.on_a_strange_planet"
>
<div
class="row justify-content-center title-row mb-3"
@@ -0,0 +1,577 @@
<template>
<b-modal
id="group-plan-selection"
:hide-footer="true"
:hide-header="true"
size="md"
@show="loadData"
@hide="onHide"
>
<div class="selection-modal">
<div class="modal-header-row">
<h2 class="title">
{{ $t('chooseAnOption') }}
</h2>
<div class="header-actions">
<span
class="cancel-text"
@click="close"
>
{{ $t('cancel') }}
</span>
<button
class="btn btn-primary next-button"
:class="{ disabled: !selectedOption }"
:disabled="!selectedOption"
@click="continueFlow"
>
{{ $t('next') }}
</button>
</div>
</div>
<div
v-if="loading"
class="loading-container"
>
<div class="spinner-border text-secondary"></div>
</div>
<template v-else>
<div
v-if="hasUpgradeableGroups"
class="section-header"
>
{{ $t('upgradeExistingGroup') }}
</div>
<selectable-card
v-for="group in upgradeableGuilds"
:key="group._id"
class="option-card"
:selected="isSelected(group)"
@click="selectOption(group)"
>
<div class="option-content">
<div class="option-info">
<div class="option-name">
{{ group.name }}
</div>
<div class="option-members">
{{ formatMemberCount(group.memberCount) }}
</div>
<div class="option-label previously-upgraded">
<div
class="svg-icon sparkle-icon"
v-html="icons.sparkles"
></div>
{{ $t('previouslyUpgradedGroup') }}
</div>
</div>
<div class="option-price">
${{ calculatePrice(group.memberCount) }}.00/mo
</div>
</div>
</selectable-card>
<selectable-card
v-if="upgradeableParty"
class="option-card"
:class="{ 'has-pending-warning': partyPendingInviteCount > 0 }"
:selected="isSelected(upgradeableParty)"
@click="selectOption(upgradeableParty)"
>
<div class="option-content">
<div class="option-info">
<div class="option-name">
{{ upgradeableParty.name }}
</div>
<div class="option-members">
{{ formatMemberCount(upgradeableParty.memberCount) }}
<span
v-if="partyPendingInviteCount > 0"
class="pending-count"
>
{{ $t('pendingCount', { count: partyPendingInviteCount }) }}
</span>
</div>
<div
v-if="isPartyPreviouslyUpgraded"
class="option-label previously-upgraded"
>
<div
class="svg-icon sparkle-icon"
v-html="icons.sparkles"
></div>
{{ $t('previouslyUpgradedGroup') }}
</div>
<div
v-else
class="option-label your-party"
>
<div
class="svg-icon member-icon"
v-html="icons.member"
></div>
{{ $t('yourParty') }}
</div>
</div>
<div class="option-price">
${{ calculatePrice(upgradeableParty.memberCount) }}.00/mo
</div>
</div>
<div
v-if="partyPendingInviteCount > 0"
class="pending-warning-banner"
>
<div
class="svg-icon alert-icon"
v-html="icons.alert"
></div>
<span class="warning-text">{{ $t('upgradeCancelsPendingInvites') }}</span>
</div>
</selectable-card>
<div
v-if="hasUpgradeableGroups"
class="or-divider"
>
<div class="divider-line"></div>
<span class="or-text">{{ $t('or') }}</span>
<div class="divider-line"></div>
</div>
<selectable-card
class="option-card create-new"
:selected="selectedOption === 'new'"
@click="selectOption('new')"
>
<div class="option-content">
<div class="option-info">
<div class="option-name">
{{ $t('createNewGroup') }}
</div>
<div class="option-description">
{{ $t('inviteOthersForAdditional') }}
<span class="price-highlight">${{ perMemberPrice }}.00</span>
{{ $t('perMember') }}.
</div>
</div>
<div class="option-price">
${{ basePrice }}.00/mo
</div>
</div>
</selectable-card>
<div class="footer-note">
{{ $t('additionalMembersProrated') }}
</div>
</template>
</div>
</b-modal>
</template>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
.selection-modal {
padding: 24px;
}
.modal-header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.title {
font-family: 'Roboto Condensed', sans-serif;
font-weight: 700;
font-size: 20px;
line-height: 28px;
color: $purple-200;
margin: 0;
}
.header-actions {
display: flex;
align-items: center;
}
.cancel-text {
color: $blue-10;
font-size: 0.875rem;
margin-right: 16px;
cursor: pointer;
}
.next-button {
min-width: 64px;
&.disabled {
background-color: $gray-300;
border-color: $gray-300;
cursor: not-allowed;
}
}
.loading-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
}
.section-header {
font-family: 'Roboto', sans-serif;
font-weight: 700;
font-size: 14px;
line-height: 24px;
color: $gray-10;
margin-bottom: 12px;
}
.option-card {
margin-bottom: 12px;
::v-deep .option-name {
color: $gray-50;
}
&.selected ::v-deep .option-name {
color: $purple-200;
}
}
.pending-warning-banner {
display: flex;
align-items: center;
justify-content: center;
height: 32px;
background-color: $yellow-50;
border-radius: 0 0 6px 6px;
margin: 16px -16px 0 -16px;
gap: 4px;
.selected & {
margin: 15px -15px 0 -15px;
}
.alert-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
::v-deep path {
fill: $gray-10;
}
}
.warning-text {
font-family: 'Roboto', sans-serif;
font-weight: 400;
font-size: 12px;
line-height: 16px;
color: $gray-10;
}
}
.option-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding-left: 32px;
padding-right: 8px;
}
.option-info {
flex: 1;
}
.option-name {
font-family: 'Roboto', sans-serif;
font-size: 14px;
font-weight: 700;
line-height: 24px;
margin-bottom: 4px;
}
.option-members {
font-family: 'Roboto', sans-serif;
font-weight: 400;
font-size: 12px;
line-height: 16px;
color: $gray-100;
margin-bottom: 8px;
.pending-count {
font-weight: 700;
color: $yellow-5;
}
}
.option-label {
display: flex;
align-items: center;
font-family: 'Roboto', sans-serif;
font-size: 12px;
line-height: 16px;
gap: 4px;
&.previously-upgraded {
font-weight: 700;
color: $blue-10;
}
&.your-party {
font-weight: 700;
color: $gray-100;
}
.svg-icon {
width: 14px;
height: 14px;
}
.sparkle-icon {
color: $blue-10;
}
.member-icon {
color: $gray-100;
::v-deep path {
fill: $gray-100;
stroke: $gray-100;
stroke-width: 0.5px;
}
}
}
.option-description {
font-family: 'Roboto', sans-serif;
font-weight: 400;
font-size: 12px;
line-height: 16px;
color: $gray-100;
.price-highlight {
font-weight: 700;
}
}
.option-price {
font-family: 'Roboto', sans-serif;
font-weight: 700;
font-size: 20px;
line-height: 24px;
color: $purple-200;
white-space: nowrap;
}
.or-divider {
display: flex;
align-items: center;
margin: 20px 0;
.divider-line {
flex: 1;
height: 1px;
background-color: $gray-500;
}
.or-text {
padding: 0 16px;
font-family: 'Roboto', sans-serif;
font-weight: 700;
font-size: 12px;
line-height: 16px;
letter-spacing: 0.2em;
text-transform: uppercase;
color: $gray-100;
}
}
.create-new {
.option-name {
margin-bottom: 8px;
}
}
.footer-note {
font-family: 'Roboto', sans-serif;
font-weight: 400;
font-size: 12px;
line-height: 16px;
color: $gray-100;
text-align: center;
margin-top: 16px;
margin-left: 24px;
margin-right: 24px;
}
</style>
<style lang="scss">
#group-plan-selection {
.modal-dialog {
max-width: 504px;
}
.modal-content {
border-radius: 8px;
box-shadow: 0 14px 28px 0 rgba(26, 24, 29, 0.24), 0 10px 10px 0 rgba(26, 24, 29, 0.28);
}
.modal-body {
padding: 0;
}
.option-card.has-pending-warning.selectable-card {
padding-bottom: 0;
}
}
</style>
<script>
import axios from 'axios';
import paymentsMixin from '@/mixins/payments';
import { mapState } from '@/libs/store';
import SelectableCard from '@/components/ui/selectableCard.vue';
import svgSparkles from '@/assets/svg/sparkles.svg?raw';
import svgMember from '@/assets/svg/member-icon.svg?raw';
import svgAlert from '@/assets/svg/for-css/alert.svg?raw';
export default {
components: {
SelectableCard,
},
mixins: [paymentsMixin],
data () {
return {
selectedOption: null,
userGuilds: [],
userParty: null,
activeGroupPlanIds: [],
loading: true,
basePrice: 9,
perMemberPrice: 3,
icons: Object.freeze({
sparkles: svgSparkles,
member: svgMember,
alert: svgAlert,
}),
partyPendingInviteCount: 0,
};
},
computed: {
...mapState({ user: 'user.data' }),
upgradeableGuilds () {
return this.userGuilds.filter(group => {
const leaderId = group.leader?._id || group.leader;
if (leaderId !== this.user._id) return false;
const purchased = group.purchased;
if (!purchased?.wasUpgraded) return false;
if (this.activeGroupPlanIds.includes(group._id)) return false;
if (!purchased.dateTerminated) return false;
return new Date(purchased.dateTerminated) < new Date();
});
},
upgradeableParty () {
if (!this.userParty) return null;
const leaderId = this.userParty.leader?._id || this.userParty.leader;
if (leaderId !== this.user._id) return null;
if (this.activeGroupPlanIds.includes(this.userParty._id)) return null;
return this.userParty;
},
hasUpgradeableGroups () {
return this.upgradeableGuilds.length > 0 || this.upgradeableParty !== null;
},
isPartyPreviouslyUpgraded () {
if (!this.userParty) return false;
const purchased = this.userParty.purchased;
if (!purchased?.wasUpgraded) return false;
if (!purchased.dateTerminated) return false;
return new Date(purchased.dateTerminated) < new Date();
},
},
methods: {
async loadData () {
this.loading = true;
this.selectedOption = null;
this.partyPendingInviteCount = 0;
try {
const [guildsResponse, partyResponse] = await Promise.all([
axios.get('/api/v4/groups', { params: { type: 'guilds', includeExpiredPlans: 'true' } }),
axios.get('/api/v4/groups/party').catch(() => ({ data: { data: null } })),
]);
this.userGuilds = guildsResponse.data.data || [];
this.userParty = partyResponse.data.data;
if (this.userParty) {
try {
const invitesResponse = await axios.get(`/api/v4/groups/${this.userParty._id}/invites`);
this.partyPendingInviteCount = invitesResponse.data.data?.length || 0;
} catch (e) {
this.partyPendingInviteCount = 0;
}
}
await this.$store.dispatch('guilds:getGroupPlans', true);
const groupPlans = this.$store.state.groupPlans?.data || [];
this.activeGroupPlanIds = groupPlans.map(g => g._id);
} catch (e) {
console.error('Error loading group data:', e);
}
this.loading = false;
this.$nextTick(() => {
if (this.upgradeableGuilds.length > 0) {
this.selectedOption = this.upgradeableGuilds[0];
} else if (this.upgradeableParty) {
this.selectedOption = this.upgradeableParty;
} else {
this.selectedOption = 'new';
}
});
},
selectOption (option) {
this.selectedOption = option;
},
isSelected (group) {
if (!this.selectedOption || this.selectedOption === 'new') return false;
return this.selectedOption._id === group._id;
},
calculatePrice (memberCount) {
return this.basePrice + (this.perMemberPrice * (memberCount - 1));
},
formatMemberCount (count) {
return count === 1 ? this.$t('oneMember') : this.$t('membersCount', { count });
},
continueFlow () {
if (!this.selectedOption) return;
const selection = this.selectedOption;
this.close();
if (selection === 'new') {
this.$root.$emit('bv::show::modal', 'create-group');
} else {
this.stripeGroup({ group: selection, upgrade: true });
}
},
close () {
this.$root.$emit('bv::hide::modal', 'group-plan-selection');
},
onHide () {
this.selectedOption = null;
},
},
};
</script>
@@ -41,6 +41,14 @@
:chat="group.chat"
@select="selectedAutocomplete"
/>
<emoji-auto-complete
ref="emojiAutocomplete"
:text="newMessage"
:textbox="textbox"
:coords="mixinData.autoComplete.coords"
:caret-position="mixinData.autoComplete.caretPosition"
@select="selectedAutocomplete"
/>
</div>
<community-guidelines />
<div class="row chat-actions">
@@ -90,6 +98,7 @@ import { MAX_MESSAGE_LENGTH } from '@/../../common/script/constants';
import externalLinks from '../../mixins/externalLinks';
import autocomplete from '../chat/autoComplete';
import emojiAutoComplete from '../chat/emojiAutoComplete';
import communityGuidelines from './communityGuidelines';
import chatMessages from '../chat/chatMessages';
import { mapState } from '@/libs/store';
@@ -102,6 +111,7 @@ export default {
},
components: {
autocomplete,
emojiAutoComplete,
communityGuidelines,
chatMessages,
},
+82 -57
View File
@@ -25,53 +25,61 @@
<div class="col-12 col-md-6">
<div class="row icon-row">
<div
class="item-with-icon"
class="item-with-icon p-2"
tabindex="0"
role="button"
@keyup.enter="showMemberModal()"
@click="showMemberModal()"
>
<div
v-if="group.memberCount > 1000"
class="svg-icon shield"
v-html="icons.goldGuildBadgeIcon"
></div>
<div
v-if="group.memberCount > 100 && group.memberCount < 999"
class="svg-icon shield"
v-html="icons.silverGuildBadgeIcon"
></div>
<div
v-if="group.memberCount < 100"
class="svg-icon shield"
v-html="icons.bronzeGuildBadgeIcon"
></div>
<span class="number">{{ group.memberCount | abbrNum }}</span>
<div
v-once
class="member-list label"
>
{{ $t('memberList') }}
<div class="box-content">
<div class="icon-number-row">
<div
v-if="group.memberCount > 1000"
class="svg-icon shield"
v-html="icons.goldGuildBadgeIcon"
></div>
<div
v-if="group.memberCount > 100 && group.memberCount < 999"
class="svg-icon shield"
v-html="icons.silverGuildBadgeIcon"
></div>
<div
v-if="group.memberCount < 100"
class="svg-icon shield"
v-html="icons.bronzeGuildBadgeIcon"
></div>
<span class="number">{{ group.memberCount | abbrNum }}</span>
</div>
<div
v-once
class="details"
>
{{ $t('memberList') }}
</div>
</div>
</div>
<div v-if="!isParty">
<div
class="item-with-icon"
class="item-with-icon p-2"
tabindex="0"
role="button"
@keyup.enter="showGroupGems()"
@click="showGroupGems()"
>
<div
class="svg-icon gem"
v-html="icons.gem"
></div>
<span class="number">{{ group.balance * 4 }}</span>
<div
v-once
class="label"
>
{{ $t('guildBank') }}
<div class="box-content">
<div class="icon-number-row">
<div
class="svg-icon gem"
v-html="icons.gem"
></div>
<span class="number">{{ group.balance * 4 }}</span>
</div>
<div
v-once
class="details"
>
{{ $t('guildBank') }}
</div>
</div>
</div>
</div>
@@ -128,35 +136,57 @@
}
.item-with-icon {
display: inline-block;
border-radius: 2px;
background-color: #ffffff;
background-color: $white;
box-shadow: 0 2px 2px 0 rgba(26, 24, 29, 0.16), 0 1px 4px 0 rgba(26, 24, 29, 0.12);
padding: 1em;
text-align: center;
min-width: 120px;
margin-left: 1em;
width: 120px;
height: 76px;
margin-right: 1rem;
text-align: center;
font-size: 20px;
vertical-align: bottom;
overflow: hidden;
position: relative;
&:last-of-type {
margin-left: 0.5rem;
.box-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
}
.svg-icon.shield, .svg-icon.gem {
width: 28px;
height: auto;
margin: 0 auto;
.icon-number-row {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.1em;
.number {
font-size: 18px;
font-weight: normal;
margin-left: 0.2em;
}
}
.svg-icon {
width: 24px;
height: 24px;
display: inline-block;
vertical-align: bottom;
margin-right: 0.5em;
}
.number {
font-size: 22px;
font-weight: bold;
}
.label {
margin-top: .5em;
.details {
font-size: 11px;
color: $gray-200;
width: 100%;
padding: 0 4px;
line-height: 1.1;
word-break: break-word;
max-height: 2.2em;
overflow: visible;
}
}
@@ -215,11 +245,6 @@
.icon-row {
margin-top: 1em;
justify-content: flex-end;
.number {
font-size: 22px;
font-weight: bold;
}
}
.chat-row {
@@ -1,8 +1,8 @@
<template>
<div
class="banner d-flex align-items-center justify-content-between py-3 px-4"
id="privacy-banner"
v-if="!hidden"
id="privacy-banner"
class="banner d-flex align-items-center justify-content-between py-3 px-4"
>
<p
class="mr-3 mb-0"
@@ -34,7 +34,7 @@
>
<div
class="close-x"
@click="remove()"
@click.stop="remove()"
>
<div
class="svg-icon svg-close"
@@ -140,7 +140,7 @@ export default {
methods: {
remove () {
if (this.eventKey) {
window.sessionStorage.setItem(`hide-g1g1-${this.eventKey}`, 'true');
window.localStorage.setItem(`hide-g1g1-${this.eventKey}`, 'true');
}
this.$emit('notification-removed');
},
@@ -318,7 +318,7 @@ export default {
shouldShowG1g1 () {
if (!this.currentG1g1Event) return false;
const eventKey = this.g1g1EventKey;
if (eventKey && window.sessionStorage.getItem(`hide-g1g1-${eventKey}`) === 'true') {
if (eventKey && window.localStorage.getItem(`hide-g1g1-${eventKey}`) === 'true') {
return false;
}
return !this.g1g1Hidden;
@@ -182,12 +182,10 @@ export default {
return 'GreyedOut';
},
imageName () {
const foolEvent = this.currentEventList?.find(event => moment()
.isBetween(event.start, event.end) && event.aprilFools);
if (this.isOwned() && foolEvent) {
if (this.isSpecial()) return `stable_${this.foolPet(this.item.key, foolEvent.aprilFools)}`;
const petString = `${this.item.eggKey}-${this.item.key}`;
return `stable_${this.foolPet(petString, foolEvent.aprilFools)}`;
const substitutionEvent = this.currentEventList?.find(event => moment()
.isBetween(event.start, event.end) && event.spriteSubstitutions);
if (this.isOwned() && substitutionEvent && substitutionEvent.spriteSubstitutions.pets) {
return `stable_${this.foolPet(`Pet-${this.item.key}`, substitutionEvent.spriteSubstitutions.pets)}`;
}
if (this.isOwned() || (this.mountOwned() && this.isHatchable())) {
@@ -10,6 +10,9 @@
>
<div class="modal-body">
<news-content ref="newsContent" />
<close-x
@close="dismissAlert()"
/>
</div>
<div class="modal-footer d-flex align-items-center pb-0">
@@ -30,12 +33,18 @@
</template>
<script>
import { mapState } from '@/libs/store';
import newsContent from './newsContent';
import closeX from '../ui/closeX.vue';
export default {
components: {
closeX,
newsContent,
},
computed: {
...mapState({ user: 'user.data' }),
},
methods: {
async onShow () {
this.$refs.newsContent.getPosts();
@@ -330,6 +330,7 @@ export default {
handledNotifications,
isInitialLoadComplete: false,
pendingRebirthNotification: null,
lastShownStreakCount: null, // Track last shown streak to prevent duplicates
};
},
computed: {
@@ -726,17 +727,24 @@ export default {
this.$root.$emit('habitica:won-challenge', notification);
break;
case 'REBIRTH_ACHIEVEMENT':
if (localStorage.getItem('show-rebirth-confirmation') === 'true') {
markAsRead = false;
} else if (!this.isInitialLoadComplete) {
this.pendingRebirthNotification = notification;
markAsRead = false;
} else {
this.playSound('Achievement_Unlocked');
this.$root.$emit('bv::show::modal', 'rebirth');
if (localStorage.getItem('show-rebirth-confirmation') !== 'true') {
if (!this.isInitialLoadComplete) {
this.pendingRebirthNotification = notification;
markAsRead = false;
} else {
this.playSound('Achievement_Unlocked');
this.$root.$emit('bv::show::modal', 'rebirth');
}
}
break;
case 'STREAK_ACHIEVEMENT':
// Client-side deduplication: prevent showing duplicate streak achievements
if (this.lastShownStreakCount === this.user.achievements.streak) {
// Same streak already shown, skip this notification
break;
}
this.lastShownStreakCount = this.user.achievements.streak;
this.text(`${this.$t('streaks')}: ${this.user.achievements.streak}`, () => {
this.$root.$emit('bv::show::modal', 'streak');
}, this.user.preferences.suppressModals.streak);
@@ -105,7 +105,7 @@ export default {
},
data () {
return {
privacyConsent: true,
privacyConsent: false,
};
},
methods: {
@@ -42,7 +42,7 @@
:hide-class-badge="true"
:with-background="true"
:override-avatar-gear="getAvatarOverrides(item)"
:sprites-margin="'0px auto 0px -24px'"
:sprites-margin="'0px auto 0px -2px'"
/>
</div>
<item
@@ -281,6 +281,11 @@
.badge-dialog {
left: -8px;
top: -8px;
.badge-pin {
width: 32px;
height: 32px;
}
}
.avatar {
@@ -851,10 +856,17 @@ export default {
- ownedMounts
- ownedItems;
if (
petsRemaining < 0
&& !window.confirm(this.$t('purchasePetItemConfirm', { itemText: this.item.text })) // eslint-disable-line no-alert
) return;
if (petsRemaining < 0) {
const confirmed = await new Promise(resolve => {
this.$root.$emit('habitica:purchase-confirm', {
message: this.$t('purchasePetItemConfirm', { itemText: this.item.text }),
currency: this.item.currency,
cost: this.item.value * this.selectedAmountToBuy,
resolve,
});
});
if (!confirmed) return;
}
}
if (this.item.purchaseType === 'customization') {
@@ -866,11 +878,14 @@ export default {
this.purchased(this.item.text);
} else {
const shouldConfirmPurchase = this.item.currency === 'gems' || this.item.currency === 'hourglasses';
if (
shouldConfirmPurchase
&& !this.confirmPurchase(this.item.currency, this.item.value * this.selectedAmountToBuy)
) {
return;
if (shouldConfirmPurchase) {
const confirmed = await this.confirmPurchase(
this.item.currency,
this.item.value * this.selectedAmountToBuy,
);
if (!confirmed) {
return;
}
}
if (this.genericPurchase) {
if (this.item.key === 'rebirth_orb') {
@@ -893,8 +908,8 @@ export default {
purchaseGems () {
this.$root.$emit('bv::show::modal', 'buy-gems');
},
togglePinned () {
this.isPinned = this.$store.dispatch('user:togglePinnedItem', { type: this.item.pinType, path: this.item.path });
async togglePinned () {
this.isPinned = await this.$store.dispatch('user:togglePinnedItem', { type: this.item.pinType, path: this.item.path });
if (!this.isPinned) {
this.text(this.$t('unpinnedItem', { item: this.item.text }));
@@ -76,7 +76,21 @@
:empty-item="false"
:show-popover="Boolean(ctx.item.text)"
@click="selectItem(ctx.item)"
/>
>
<template
slot="itemBadge"
slot-scope="slotProps"
>
<span
class="badge-top"
@click.prevent.stop="togglePinned(slotProps.item)"
>
<pin-badge
:pinned="slotProps.item.pinned"
/>
</span>
</template>
</shop-item>
</template>
</item-rows>
</div>
@@ -108,6 +122,16 @@
}
</style>
<style lang="scss">
.market .badge-pin:not(.pinned) {
display: none;
}
.market .item:hover .badge-pin {
display: block;
}
</style>
<script>
import find from 'lodash/find';
import shops from '@/../../common/script/libs/shops';
@@ -118,7 +142,9 @@ import Checkbox from '@/components/ui/checkbox';
import FilterGroup from '@/components/ui/filterGroup';
import FilterSidebar from '@/components/ui/filterSidebar';
import ItemRows from '@/components/ui/itemRows';
import PinBadge from '@/components/ui/pinBadge';
import ShopItem from '../shopItem';
import pinUtils from '@/mixins/pinUtils';
export default {
components: {
@@ -126,8 +152,10 @@ export default {
FilterGroup,
FilterSidebar,
ItemRows,
PinBadge,
ShopItem,
},
mixins: [pinUtils],
data () {
return {
searchText: null,
@@ -184,8 +212,12 @@ export default {
methods: {
customizationsItems (options = {}) {
const { category, searchBy } = options;
return category.items.filter(item => !searchBy
|| item.text.toLowerCase().includes(searchBy));
return category.items
.filter(item => !searchBy || item.text.toLowerCase().includes(searchBy))
.map(item => ({
...item,
pinned: this.isPinned(item),
}));
},
emptyClick (identifier, event) {
if (event.target.tagName !== 'A') return;
@@ -0,0 +1,207 @@
<template>
<b-modal
id="purchase-confirm-modal"
:hide-footer="true"
:hide-header="true"
modal-class="purchase-confirm-modal"
centered
>
<div class="modal-content-wrapper">
<div class="top-bar"></div>
<div class="modal-body-content">
<div
class="currency-chip"
:class="currency"
>
<span
class="svg-icon icon-24"
v-html="icons[currency]"
></span>
<span class="cost-value">{{ cost }}</span>
</div>
<h2 class="modal-title">
{{ $t('confirmPurchase') }}
</h2>
<p class="modal-subtitle">
{{ confirmationMessage }}
</p>
<div class="button-wrapper">
<button
class="btn btn-primary"
@click="confirm()"
>
{{ $t('confirm') }}
</button>
<button
class="btn-cancel"
@click="cancel()"
>
{{ $t('cancel') }}
</button>
</div>
</div>
</div>
</b-modal>
</template>
<script>
import svgGem from '@/assets/svg/gem.svg?raw';
import svgHourglass from '@/assets/svg/hourglass.svg?raw';
export default {
data () {
return {
confirmationMessage: '',
currency: 'gems',
cost: 0,
resolveCallback: null,
icons: Object.freeze({
gems: svgGem,
hourglasses: svgHourglass,
}),
};
},
mounted () {
this.$root.$on('habitica:purchase-confirm', config => {
this.confirmationMessage = config.message;
this.currency = config.currency || 'gems';
this.cost = config.cost || 0;
this.resolveCallback = config.resolve;
this.$root.$emit('bv::show::modal', 'purchase-confirm-modal');
});
},
beforeDestroy () {
this.$root.$off('habitica:purchase-confirm');
},
methods: {
confirm () {
if (this.resolveCallback) {
this.resolveCallback(true);
}
this.close();
},
cancel () {
if (this.resolveCallback) {
this.resolveCallback(false);
}
this.close();
},
close () {
this.$root.$emit('bv::hide::modal', 'purchase-confirm-modal');
},
},
};
</script>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
::v-deep .purchase-confirm-modal {
.modal-dialog {
max-width: 330px;
margin: auto;
}
.modal-content {
border-radius: 8px;
overflow: hidden;
border: none;
}
.modal-body {
padding: 0;
}
}
.modal-content-wrapper {
display: flex;
flex-direction: column;
}
.top-bar {
height: 8px;
background-color: $purple-300;
}
.modal-body-content {
display: flex;
flex-direction: column;
align-items: center;
padding: 0 24px 24px;
}
.currency-chip {
display: flex;
align-items: center;
gap: 8px;
margin-top: 40px;
padding: 8px 20px;
border-radius: 20px;
font-size: 1.25rem;
font-weight: bold;
line-height: 1.4;
&.gems {
color: $gems-color;
background-color: rgba($green-10, 0.15);
}
&.hourglasses {
color: $hourglass-color;
background-color: rgba($blue-10, 0.15);
}
.icon-24 {
width: 24px;
height: 24px;
}
}
.modal-title {
margin-top: 16px;
margin-bottom: 0;
color: $purple-300;
font-family: 'Roboto Condensed', sans-serif;
font-weight: 700;
font-size: 20px;
text-align: center;
}
.modal-subtitle {
margin-top: 12px;
margin-bottom: 0;
font-family: Roboto, sans-serif;
font-weight: 700;
font-size: 14px;
line-height: 24px;
letter-spacing: 0;
text-align: center;
color: $gray-50;
}
.button-wrapper {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
margin-top: 16px;
gap: 8px;
}
.btn-cancel {
background: none;
border: none;
color: $purple-300;
font-family: Roboto, sans-serif;
font-weight: 400;
font-size: 14px;
line-height: 24px;
letter-spacing: 0;
cursor: pointer;
padding: 8px 16px;
&:hover {
text-decoration: underline;
}
}
</style>
@@ -180,6 +180,11 @@
.badge-dialog {
left: -8px;
top: -8px;
.badge-pin {
width: 32px;
height: 32px;
}
}
.modal-content {
@@ -510,8 +515,12 @@ export default {
this.selectedAmountToBuy = 1;
this.$emit('change', $event);
},
buyItem () {
if (!this.confirmPurchase(this.item.currency, this.item.value * this.selectedAmountToBuy)) {
async buyItem () {
const confirmed = await this.confirmPurchase(
this.item.currency,
this.item.value * this.selectedAmountToBuy,
);
if (!confirmed) {
return;
}
this.makeGenericPurchase(this.item, 'buyQuestModal', this.selectedAmountToBuy);
@@ -498,8 +498,13 @@ export default {
await this.triggerGetWorldState();
this.currentEvent = _find(this.currentEventList, event => Boolean(event.season));
this.imageURLs.background = `url(/static/npc/${this.currentEvent.season}/seasonal_shop_opened_background.png)`;
this.imageURLs.npc = `url(/static/npc/${this.currentEvent.season}/seasonal_shop_opened_npc.png)`;
if (this.currentEvent.season === 'valentines') {
this.imageURLs.background = 'url(/static/npc/spring/seasonal_shop_opened_background.png)';
this.imageURLs.npc = 'url(/static/npc/spring/seasonal_shop_opened_npc.png)';
} else {
this.imageURLs.background = `url(/static/npc/${this.currentEvent.season}/seasonal_shop_opened_background.png)`;
this.imageURLs.npc = `url(/static/npc/${this.currentEvent.season}/seasonal_shop_opened_npc.png)`;
}
},
beforeDestroy () {
this.$root.$off('buyModal::boughtItem');
@@ -1,5 +1,6 @@
<template>
<div>
<group-plan-selection-modal />
<group-plan-creation-modal />
<div class="d-flex justify-content-center">
<div
@@ -315,10 +316,12 @@
import { setup as setupPayments } from '@/libs/payments';
import paymentsMixin from '../../mixins/payments';
import GroupPlanCreationModal from '../group-plans/groupPlanCreationModal.vue';
import GroupPlanSelectionModal from '../group-plans/groupPlanSelectionModal.vue';
export default {
components: {
GroupPlanCreationModal,
GroupPlanSelectionModal,
},
mixins: [paymentsMixin],
data () {
@@ -359,7 +362,7 @@ export default {
if (this.upgradingGroup._id) {
return this.stripeGroup({ group: this.upgradingGroup, upgrade: true });
}
return this.$root.$emit('bv::show::modal', 'create-group');
return this.$root.$emit('bv::show::modal', 'group-plan-selection');
},
},
};
@@ -64,9 +64,11 @@
<li>sexual orientation; and</li>
<li>information collected from a known child.</li>
</ul>
<p><strong>
NOTE: Please do not provide us sensitive personal information or sensitive personal data, as those terms are defined under applicable privacy laws, unless we directly request that you do so. If you feel, after careful consideration, that it is necessary to provide us certain sensitive personal information or data, please provide us the minimum amount of such information or data that is necessary.
</strong></p>
<p>
<strong>
NOTE: Please do not provide us sensitive personal information or sensitive personal data, as those terms are defined under applicable privacy laws, unless we directly request that you do so. If you feel, after careful consideration, that it is necessary to provide us certain sensitive personal information or data, please provide us the minimum amount of such information or data that is necessary.
</strong>
</p>
<h3 id="section_1_1">
1.1 Information You Provide Directly
</h3>
@@ -617,7 +619,7 @@
7. General Audience Services
</h2>
<p>
The Service is intended for users 18 years or older; you are not permitted to access or use the Service if you are younger than 18. We do not knowingly collect personal information from children under the age of 18 through the Service. We encourage parents and legal guardians to monitor their childrens Internet usage and to help enforce our Privacy Policy by instructing their children to never provide personal information without their permission. If you have reason to believe that a child under the age of 18 has provided personal information to us, please contact us at <a href='mailto:privacy@habitica.com'>privacy@habitica.com</a>, and we will delete that information from our databases.
The Service is intended for users 18 years or older; you are not permitted to access or use the Service if you are younger than 18. We do not knowingly collect personal information from children under the age of 18 through the Service. We encourage parents and legal guardians to monitor their childrens Internet usage and to help enforce our Privacy Policy by instructing their children to never provide personal information without their permission. If you have reason to believe that a child under the age of 18 has provided personal information to us, please contact us at <a href="mailto:privacy@habitica.com">privacy@habitica.com</a>, and we will delete that information from our databases.
</p>
<h2 id="section_8">
@@ -708,7 +710,7 @@
<p><strong><u>Nevada Residents</u></strong></p>
<p>
Nevada residents may opt out of the sale of certain covered information collected by operators of websites or online services. We currently do not sell covered information, as sale is defined by such law, and do not have plans to do so. In accordance with Nevada law, you may submit to us a verified request instructing us not to sell your covered information by sending an email to <a href='mailto:privacy@habitica.com'>privacy@habitica.com</a>.
Nevada residents may opt out of the sale of certain covered information collected by operators of websites or online services. We currently do not sell covered information, as sale is defined by such law, and do not have plans to do so. In accordance with Nevada law, you may submit to us a verified request instructing us not to sell your covered information by sending an email to <a href="mailto:privacy@habitica.com">privacy@habitica.com</a>.
</p>
<p><strong><u>Notice to United Kingdom/European/Switzerland Residents.</u></strong></p>
<p>
@@ -15,8 +15,8 @@
<router-view />
</div>
<div
id="bottom-background"
v-if="loginFlow"
id="bottom-background"
class="bg-purple-300"
>
<div class="seamless_mountains_demo_repeat"></div>
@@ -31,7 +31,10 @@
id="bottom-wrap"
class="purple-4"
>
<div id="bottom-background" v-if="!loginFlow">
<div
v-if="!loginFlow"
id="bottom-background"
>
<div class="seamless_mountains_demo_repeat"></div>
<div class="midground_foreground_extended2"></div>
</div>
@@ -104,9 +107,10 @@
footer, footer a {
background: transparent;
color: $purple-500;
&:hover {
color: $white;
}
}
footer a:hover {
color: $white;
}
h3 {
@@ -117,10 +121,6 @@
border-top-color: $purple-100;
}
.donate-text {
color: $purple-500;
}
.logo {
color: $purple-300;
}
@@ -129,42 +129,27 @@
color: $purple-500;
}
.social .d-flex:hover {
a {
color: $white;
}
svg {
fill: $white;
}
}
.social-circle {
background: $purple-50;
color: $purple-500;
.instagram svg {
svg {
background-color: $purple-50;
fill: $purple-500;
&:hover {
fill: $white;
}
}
.bluesky svg {
background-color: $purple-50;
fill: $purple-500;
&:hover {
fill: $white;
}
}
.facebook svg {
background-color: $purple-50;
fill: $purple-500;
&:hover {
fill: $white;
}
}
.tumblr svg {
background-color: $purple-50;
fill: $purple-500;
&:hover {
fill: $white;
}
width: 24px;
height: 24px;
}
}
.btn-contribute {
background: $white;
box-shadow: none;
@@ -274,7 +259,8 @@ export default {
return 'purple-footer';
},
loginFlow () {
return ['login', 'register', 'username'].indexOf(this.$route.name) !== -1;
const loginRoutes = ['forgotPassword', 'login', 'register', 'resetPassword', 'username'];
return loginRoutes.indexOf(this.$route.name) !== -1;
},
showContentWrap () {
return this.$route.name !== 'news';
@@ -158,7 +158,7 @@
BY PURCHASING PREMIUM YOU EXPRESSLY UNDERSTAND AND AGREE TO OUR REFUND POLICY:
</p>
<p>
YOU CAN REQUEST A REFUND OF YOUR MOST RECENT PAYMENT TO US BY CONTACTING US AT <a href='mailto:admin@habitica.com'>ADMIN@HABITICA.COM</a>. THE AMOUNT OF YOUR REFUND, IF ANY, WILL BE BASED ON (1) THE AMOUNT OF YOUR PURCHASED BUT UNUSED SUBSCRIPTION BENEFITS AND (2) THE TERMS IMPOSED ON US BY OUR PAYMENT PROCESSING VENDORS (E.G., WITH RESPECT TO THE DURATION OF THE REFUND PERIOD).
YOU CAN REQUEST A REFUND OF YOUR MOST RECENT PAYMENT TO US BY CONTACTING US AT <a href="mailto:admin@habitica.com">ADMIN@HABITICA.COM</a>. THE AMOUNT OF YOUR REFUND, IF ANY, WILL BE BASED ON (1) THE AMOUNT OF YOUR PURCHASED BUT UNUSED SUBSCRIPTION BENEFITS AND (2) THE TERMS IMPOSED ON US BY OUR PAYMENT PROCESSING VENDORS (E.G., WITH RESPECT TO THE DURATION OF THE REFUND PERIOD).
</p>
<p>
FOR ANY CUSTOMER WHO PURCHASED PREMIUM IN APPLE INC.'s APP STORE ("APP STORE"), PLEASE CONTACT APPLE INC.'s SUPPORT TEAM: <a
@@ -1,65 +1,45 @@
<template>
<b-modal
id="broken-task-modal"
title="Broken Challenge"
size="sm"
:hide-footer="true"
:hide-header="true"
modal-class="broken-task-confirm-modal"
centered
>
<div
v-if="brokenChallengeTask && brokenChallengeTask.challenge"
class="modal-body"
class="modal-content-wrapper"
>
<div
v-if="brokenChallengeTask.challenge.broken === 'TASK_DELETED'
|| brokenChallengeTask.challenge.broken === 'CHALLENGE_TASK_NOT_FOUND'"
>
<h2>{{ $t('brokenTask') }}</h2>
<div>
<div class="top-bar"></div>
<div class="modal-body-content">
<div
class="icon-wrapper"
v-html="icons.alertIcon"
></div>
<h2 class="modal-title">
{{ modalTitle }}
</h2>
<p class="modal-subtitle">
{{ modalSubtitle }}
</p>
<div class="button-wrapper">
<button
class="btn btn-primary"
@click="unlink('keep')"
@click="keepAction()"
>
{{ $t('keepIt') }}
{{ keepButtonText }}
</button>
<button
class="btn btn-danger"
@click="removeTask(obj)"
@click="removeAction()"
>
{{ $t('removeIt') }}
</button>
</div>
</div>
<div v-if="brokenChallengeTask.challenge.broken === 'CHALLENGE_DELETED'">
<h2>{{ $t('brokenChallenge') }}</h2>
<div>
<button
class="btn btn-primary"
@click="unlink('keep-all')"
>
{{ $t('keepTasks') }}
{{ removeButtonText }}
</button>
<button
class="btn btn-danger"
@click="unlink('remove-all')"
class="btn-cancel"
@click="close()"
>
{{ $t('removeTasks') }}
</button>
</div>
</div>
<div v-if="brokenChallengeTask.challenge.broken === 'CHALLENGE_CLOSED'">
<h2 v-html="$t('challengeCompleted', {user: brokenChallengeTask.challenge.winner})"></h2>
<div>
<button
class="btn btn-primary"
@click="unlink('keep-all')"
>
{{ $t('keepTasks') }}
</button>
<button
class="btn btn-danger"
@click="unlink('remove-all')"
>
{{ $t('removeTasks') }}
{{ $t('cancel') }}
</button>
</div>
</div>
@@ -67,23 +47,175 @@
</b-modal>
</template>
<style scoped>
.modal-body {
padding-bottom: 2em;
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
::v-deep .broken-task-confirm-modal {
.modal-dialog {
max-width: 330px;
margin: auto;
}
.modal-content {
border-radius: 8px;
overflow: hidden;
border: none;
}
.modal-body {
padding: 0;
}
}
.modal-content-wrapper {
display: flex;
flex-direction: column;
}
.top-bar {
height: 8px;
background-color: $maroon-100;
}
.modal-body-content {
display: flex;
flex-direction: column;
align-items: center;
padding: 0 24px 24px;
}
.icon-wrapper {
margin-top: 40px;
width: 48px;
height: 48px;
::v-deep svg {
width: 48px;
height: 48px;
path {
fill: #DE3F3F;
}
}
}
.modal-title {
margin-top: 16px;
margin-bottom: 0;
color: $maroon-100;
font-family: 'Roboto Condensed', sans-serif;
font-weight: 700;
font-size: 20px;
text-align: center;
}
.modal-subtitle {
margin-top: 12px;
margin-bottom: 0;
font-family: Roboto, sans-serif;
font-weight: 400;
font-size: 14px;
line-height: 24px;
letter-spacing: 0;
text-align: center;
color: $gray-50;
}
.button-wrapper {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
margin-top: 16px;
gap: 8px;
}
.btn-cancel {
background: none;
border: none;
color: $purple-300;
font-family: Roboto, sans-serif;
font-weight: 400;
font-size: 14px;
line-height: 24px;
letter-spacing: 0;
cursor: pointer;
padding: 8px 16px;
&:hover {
text-decoration: underline;
}
}
</style>
<script>
import { mapActions } from '@/libs/store';
import notifications from '@/mixins/notifications';
import alertIcon from '@/assets/svg/for-css/alert.svg?raw';
export default {
mixins: [notifications],
data () {
return {
brokenChallengeTask: {},
icons: Object.freeze({
alertIcon,
}),
};
},
computed: {
brokenType () {
return this.brokenChallengeTask.challenge?.broken;
},
isSingleTask () {
return this.brokenType === 'TASK_DELETED'
|| this.brokenType === 'CHALLENGE_TASK_NOT_FOUND';
},
brokenChallengeTaskCount () {
if (!this.brokenChallengeTask.challenge?.id) return 0;
const challengeId = this.brokenChallengeTask.challenge.id;
const tasksData = this.$store.state.tasks.data;
let count = 0;
['habits', 'dailys', 'todos', 'rewards'].forEach(type => {
if (tasksData[type]) {
count += tasksData[type].filter(
t => t.challenge && t.challenge.id === challengeId,
).length;
}
});
return count;
},
modalTitle () {
if (this.isSingleTask) {
return this.$t('brokenTask');
}
if (this.brokenType === 'CHALLENGE_CLOSED') {
return this.$t('challengeCompleted');
}
return this.$t('brokenChallenge');
},
modalSubtitle () {
if (this.isSingleTask) {
return this.$t('brokenTaskDescription');
}
if (this.brokenType === 'CHALLENGE_CLOSED') {
return this.$t('challengeCompletedDescription', { user: this.brokenChallengeTask.challenge?.winner });
}
return this.$t('brokenChallengeDescription');
},
keepButtonText () {
if (this.isSingleTask) {
return this.$t('keepIt');
}
return this.$t('keepTasks');
},
removeButtonText () {
if (this.isSingleTask) {
return this.$t('removeIt');
}
return this.$t('removeTasks');
},
},
mounted () {
this.$root.$on('handle-broken-task', task => {
this.brokenChallengeTask = { ...task };
@@ -99,8 +231,36 @@ export default {
unlinkOneTask: 'tasks:unlinkOneTask',
unlinkAllTasks: 'tasks:unlinkAllTasks',
}),
keepAction () {
if (this.isSingleTask) {
this.unlink('keep');
} else {
this.unlink('keep-all');
}
},
async removeAction () {
if (this.isSingleTask) {
await this.removeTask();
} else {
await this.unlink('remove-all');
}
},
async unlink (keepOption) {
if (keepOption.indexOf('-all') !== -1) {
if (keepOption === 'remove-all') {
const count = this.brokenChallengeTaskCount;
const confirmed = await new Promise(resolve => {
this.$root.$emit('habitica:delete-task-confirm', {
title: count === 1 ? this.$t('deleteTask') : this.$t('deleteXTasks', { count }),
description: this.$t('brokenChallengeTaskCount', { count }),
message: this.$t('confirmDeleteTasks'),
buttonText: count === 1 ? this.$t('deleteTask') : this.$t('deleteXTasks', { count }),
resolve,
});
});
if (!confirmed) return;
}
await this.unlinkAllTasks({
challengeId: this.brokenChallengeTask.challenge.id,
keep: keepOption,
@@ -122,8 +282,14 @@ export default {
});
this.close();
},
removeTask () {
if (!window.confirm('Are you sure you want to delete this task?')) return; // eslint-disable-line no-alert
async removeTask () {
const confirmed = await new Promise(resolve => {
this.$root.$emit('habitica:delete-task-confirm', {
message: this.$t('sureDelete'),
resolve,
});
});
if (!confirmed) return;
this.destroyTask(this.brokenChallengeTask);
this.close();
},
+4 -20
View File
@@ -87,7 +87,7 @@
ref="tasksList"
class="sortable-tasks"
:disabled="activeFilter.label === 'scheduled' || !canBeDragged()"
scrollSensitivity="64"
scroll-sensitivity="64"
:delay-on-touch-only="true"
:delay="100"
@update="taskSorted"
@@ -348,7 +348,6 @@
import throttle from 'lodash/throttle';
import isEmpty from 'lodash/isEmpty';
import draggable from 'vuedraggable';
import { shouldDo } from '@/../../common/script/cron';
import inAppRewards from '@/../../common/script/libs/inAppRewards';
import taskDefaults from '@/../../common/script/libs/taskDefaults';
import Task from './task';
@@ -482,25 +481,10 @@ export default {
return this.$t('addATask', { type });
},
badgeCount () {
// 0 means the badge will not be shown
// It is shown for the all and due views of dailies
// and for the active and scheduled views of todos.
if (this.type === 'todo' && this.activeFilter.label !== 'complete2') {
return this.taskList.length;
} if (this.type === 'daily') {
if (this.activeFilter.label === 'due') {
return this.taskList.length;
} if (this.activeFilter.label === 'all') {
return this.taskList
.reduce(
(count, t) => (!t.completed
&& shouldDo(new Date(), t, this.getUserPreferences) ? count + 1 : count),
0,
);
}
if (this.type === 'reward') {
return 0;
}
return 0;
return this.taskList.length;
},
},
watch: {
@@ -0,0 +1,223 @@
<template>
<b-modal
id="delete-task-confirm-modal"
:hide-footer="true"
:hide-header="true"
modal-class="delete-confirm-modal"
centered
>
<div class="modal-content-wrapper">
<div class="top-bar"></div>
<div class="modal-body-content">
<div
class="icon-wrapper"
v-html="icons.alertIcon"
></div>
<h2 class="modal-title">
{{ displayTitle }}
</h2>
<p
v-if="description"
class="modal-description"
>
{{ description }}
</p>
<p class="modal-subtitle">
{{ confirmationMessage }}
</p>
<div class="button-wrapper">
<button
class="btn btn-danger"
@click="confirm()"
>
{{ buttonText }}
</button>
<button
class="btn-cancel"
@click="cancel()"
>
{{ $t('cancel') }}
</button>
</div>
</div>
</div>
</b-modal>
</template>
<script>
import alertIcon from '@/assets/svg/for-css/alert.svg?raw';
export default {
data () {
return {
confirmationMessage: '',
taskType: '',
description: '',
customTitle: '',
customButtonText: '',
resolveCallback: null,
icons: Object.freeze({
alertIcon,
}),
};
},
computed: {
displayTitle () {
if (this.customTitle) return this.customTitle;
return this.$t('deleteType', { type: this.taskType });
},
buttonText () {
if (this.customButtonText) return this.customButtonText;
return this.displayTitle;
},
},
mounted () {
this.$root.$on('habitica:delete-task-confirm', config => {
this.confirmationMessage = config.message;
this.taskType = config.taskType || '';
this.description = config.description || '';
this.customTitle = config.title || '';
this.customButtonText = config.buttonText || '';
this.resolveCallback = config.resolve;
this.$root.$emit('bv::show::modal', 'delete-task-confirm-modal');
});
},
beforeDestroy () {
this.$root.$off('habitica:delete-task-confirm');
},
methods: {
confirm () {
if (this.resolveCallback) {
this.resolveCallback(true);
}
this.close();
},
cancel () {
if (this.resolveCallback) {
this.resolveCallback(false);
}
this.close();
},
close () {
this.$root.$emit('bv::hide::modal', 'delete-task-confirm-modal');
},
},
};
</script>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
::v-deep .delete-confirm-modal {
.modal-dialog {
max-width: 330px;
margin: auto;
}
.modal-content {
border-radius: 8px;
overflow: hidden;
border: none;
}
.modal-body {
padding: 0;
}
}
.modal-content-wrapper {
display: flex;
flex-direction: column;
}
.top-bar {
height: 8px;
background-color: $maroon-100;
}
.modal-body-content {
display: flex;
flex-direction: column;
align-items: center;
padding: 0 24px 24px;
}
.icon-wrapper {
margin-top: 40px;
width: 48px;
height: 48px;
::v-deep svg {
width: 48px;
height: 48px;
path {
fill: #DE3F3F;
}
}
}
.modal-title {
margin-top: 16px;
margin-bottom: 0;
color: $maroon-100;
font-family: 'Roboto Condensed', sans-serif;
font-weight: 700;
font-size: 20px;
text-align: center;
}
.modal-description {
margin-top: 12px;
margin-bottom: 0;
font-family: Roboto, sans-serif;
font-weight: 400;
font-size: 14px;
line-height: 24px;
letter-spacing: 0;
text-align: center;
color: $gray-50;
}
.modal-description + .modal-subtitle {
margin-top: 16px;
}
.modal-subtitle {
margin-top: 12px;
margin-bottom: 0;
font-family: Roboto, sans-serif;
font-weight: 700;
font-size: 14px;
line-height: 24px;
letter-spacing: 0;
text-align: center;
color: $gray-50;
}
.button-wrapper {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
margin-top: 16px;
gap: 8px;
}
.btn-cancel {
background: none;
border: none;
color: $purple-300;
font-family: Roboto, sans-serif;
font-weight: 400;
font-size: 14px;
line-height: 24px;
letter-spacing: 0;
cursor: pointer;
padding: 8px 16px;
&:hover {
text-decoration: underline;
}
}
</style>
@@ -48,11 +48,19 @@
/>
<input
:ref="'checklistItem-' + $index"
v-model="item.text"
class="inline-edit-input checklist-item form-control"
type="text"
:disabled="disabled || disableEdit"
:class="summaryClass(item)"
@focus="setActiveItem($index)"
@keydown="autoCompleteMixinUpdateCarretPosition"
@keydown.tab="autoCompleteMixinHandleTab($event)"
@keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)"
@keydown.down="autoCompleteMixinSelectNextAutocomplete($event)"
@keypress.enter="autoCompleteMixinSelectAutocomplete($event)"
@keydown.esc="autoCompleteMixinHandleEscape($event)"
>
<span
v-if="!disabled && !disableEdit"
@@ -81,15 +89,30 @@
</span>
<input
ref="newChecklistInput"
v-model="newChecklistItem"
class="inline-edit-input checklist-item form-control"
type="text"
:placeholder="$t('newChecklistItem')"
@keypress.enter="setHasPossibilityOfIMEConversion(false)"
@focus="setActiveItem(-1)"
@keydown="autoCompleteMixinUpdateCarretPosition"
@keydown.tab="autoCompleteMixinHandleTab($event)"
@keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)"
@keydown.down="autoCompleteMixinSelectNextAutocomplete($event)"
@keypress.enter="newChecklistEnterHandler($event)"
@keyup.enter="addChecklistItem($event, true)"
@keydown.esc="autoCompleteMixinHandleEscape($event)"
@blur="addChecklistItem($event, false)"
>
</div>
<emoji-auto-complete
ref="emojiAutocomplete"
:text="activeFieldText"
:textbox="textbox"
:coords="mixinData.autoComplete.coords"
:caret-position="mixinData.autoComplete.caretPosition"
@select="selectedAutocomplete"
/>
</b-collapse>
</div>
</template>
@@ -105,6 +128,8 @@ import chevronIcon from '@/assets/svg/chevron.svg?raw';
import gripIcon from '@/assets/svg/grip.svg?raw';
import checkbox from '@/components/ui/checkbox';
import lockableLabel from './lockableLabel';
import emojiAutoComplete from '@/components/chat/emojiAutoComplete';
import { autoCompleteHelperMixin } from '@/mixins/autoCompleteHelper';
export default {
name: 'Checklist',
@@ -112,7 +137,9 @@ export default {
checkbox,
draggable,
lockableLabel,
emojiAutoComplete,
},
mixins: [autoCompleteHelperMixin],
props: {
disabled: {
type: Boolean,
@@ -133,6 +160,8 @@ export default {
showChecklist: true,
hasPossibilityOfIMEConversion: true,
newChecklistItem: null,
textbox: null,
activeItemIndex: -1,
icons: Object.freeze({
positive: positiveIcon,
destroy: deleteIcon,
@@ -141,6 +170,15 @@ export default {
}),
};
},
computed: {
activeFieldText () {
if (this.activeItemIndex === -1) {
return this.newChecklistItem || '';
}
const item = this.checklist[this.activeItemIndex];
return item ? item.text || '' : '';
},
},
methods: {
summaryClass (item) {
if (!this.disableEdit) return '';
@@ -179,6 +217,40 @@ export default {
this.checklist.splice(i, 1);
this.updateChecklist();
},
setActiveItem (index) {
this.activeItemIndex = index;
if (index === -1) {
this.textbox = this.$refs.newChecklistInput;
} else {
const refArr = this.$refs[`checklistItem-${index}`];
this.textbox = refArr ? refArr[0] || refArr : null;
}
},
newChecklistEnterHandler (e) {
const ac = this._getActiveAutocomplete();
if (ac && ac.selected !== null) {
e.preventDefault();
ac.makeSelection();
} else if (ac) {
ac.cancel();
this.setHasPossibilityOfIMEConversion(false);
} else {
this.setHasPossibilityOfIMEConversion(false);
}
},
selectedAutocomplete (newText, newCaret) {
if (this.activeItemIndex === -1) {
this.newChecklistItem = newText;
} else {
this.checklist[this.activeItemIndex].text = newText;
}
this.$nextTick(() => {
if (this.textbox) {
this.textbox.setSelectionRange(newCaret, newCaret);
this.textbox.focus();
}
});
},
},
};
</script>
@@ -187,6 +259,7 @@ export default {
@import '@/assets/scss/colors.scss';
.checklist-component {
position: relative;
.chevron-flip {
transform: translateY(-5px) rotate(180deg);
@@ -9,12 +9,27 @@
@toggle="openOrClose($event)"
>
<b-dropdown-header>
<div class="mb-2">
<div class="mb-2 search-input-wrapper">
<b-form-input
ref="searchInput"
v-model="search"
type="text"
:placeholder="searchPlaceholder"
@keyup.enter="handleSubmit"
@focus="setTextbox"
@keydown="autoCompleteMixinUpdateCarretPosition"
@keydown.tab="autoCompleteMixinHandleTab($event)"
@keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)"
@keydown.down="autoCompleteMixinSelectNextAutocomplete($event)"
@keydown.enter="searchEnterHandler($event)"
@keydown.esc="searchEscHandler($event)"
/>
<emoji-auto-complete
ref="emojiAutocomplete"
:text="search"
:textbox="textbox"
:coords="mixinData.autoComplete.coords"
:caret-position="mixinData.autoComplete.caretPosition"
@select="selectedAutocomplete"
/>
</div>
@@ -41,7 +56,7 @@
v-if="addNew || availableToSelect.length > 0"
:class="{
'item-group': true,
'add-new': availableToSelect.length === 0 && search !== '',
'add-new': search !== '' && !hasExactMatch,
'scroll': availableToSelect.length > 5
}"
>
@@ -71,7 +86,7 @@
</b-dropdown-item-button>
<div
v-if="addNew"
v-if="addNew && search !== '' && !hasExactMatch"
class="hint"
>
{{ $t('pressEnterToAddTag', { tagName: search }) }}
@@ -94,6 +109,10 @@ $itemHeight: 2rem;
}
.select-multi {
.search-input-wrapper {
position: relative;
}
.dropdown-toggle {
padding-left: 0.75rem;
}
@@ -152,7 +171,8 @@ $itemHeight: 2rem;
max-height: #{5*$itemHeight};
&.add-new {
height: 30px;
min-height: 30px;
height: auto;
.hint {
display: block;
@@ -185,6 +205,8 @@ $itemHeight: 2rem;
import Vue from 'vue';
import MultiList from '@/components/tasks/modal-controls/multiList';
import markdownDirective from '@/directives/markdown';
import emojiAutoComplete from '@/components/chat/emojiAutoComplete';
import { autoCompleteHelperMixin } from '@/mixins/autoCompleteHelper';
export default {
directives: {
@@ -192,7 +214,9 @@ export default {
},
components: {
MultiList,
emojiAutoComplete,
},
mixins: [autoCompleteHelperMixin],
props: {
addNew: {
type: Boolean,
@@ -221,6 +245,8 @@ export default {
wasTagAdded: false,
selected: this.selectedItems,
search: '',
textbox: null,
itemsAdded: [],
};
},
computed: {
@@ -248,6 +274,16 @@ export default {
return filteredItems;
},
hasExactMatch () {
const searchTerm = this.search.trim().toLowerCase();
if (!searchTerm) return false;
if (this.itemsAdded.indexOf(searchTerm) !== -1) return true;
if (this.availableToSelect.length === 0) return false;
if (this.availableToSelect[0].name.toLowerCase() === searchTerm) {
return true;
}
return false;
},
},
watch: {
selected () {
@@ -286,6 +322,7 @@ export default {
this.closeSelectPopup();
},
selectItem (item) {
if (!item) return;
this.selectedItems.push(item.id);
this.$emit('toggle', item.id);
this.preventHide = true;
@@ -312,12 +349,51 @@ export default {
this.closeSelectPopup();
}
},
setTextbox () {
const ref = this.$refs.searchInput;
this.textbox = ref ? (ref.$el || ref) : null;
},
searchEnterHandler (e) {
const ac = this._getActiveAutocomplete();
if (ac && ac.selected !== null) {
e.preventDefault();
e.stopPropagation();
ac.makeSelection();
} else {
if (ac) ac.cancel();
this.handleSubmit();
}
},
searchEscHandler (e) {
const ac = this._getActiveAutocomplete();
if (ac && ac.searchActive) {
e.preventDefault();
e.stopPropagation();
ac.cancel();
}
},
selectedAutocomplete (newText, newCaret) {
this.search = newText;
this.$nextTick(() => {
if (this.textbox) {
this.textbox.setSelectionRange(newCaret, newCaret);
this.textbox.focus();
}
});
},
handleSubmit () {
if (!this.addNew) return;
const { search } = this;
this.$emit('addNew', search);
this.search = '';
// If there is a existing tag
if (this.hasExactMatch) {
this.selectItem(this.availableToSelect[0]);
this.search = '';
} else {
// Creating a new tag as there is no existing tag present
this.$emit('addNew', search);
this.itemsAdded.push(search.toLowerCase());
this.search = '';
}
},
},
};
+9 -2
View File
@@ -1177,9 +1177,16 @@ export default {
moveToBottom () {
this.$emit('moveTo', this.task, 'bottom');
},
destroy () {
async destroy () {
const type = this.$t(this.task.type);
if (!window.confirm(this.$t('sureDeleteType', { type }))) return; // eslint-disable-line no-alert
const confirmed = await new Promise(resolve => {
this.$root.$emit('habitica:delete-task-confirm', {
message: this.$t('sureDeleteType', { type }),
taskType: type,
resolve,
});
});
if (!confirmed) return;
this.destroyTask(this.task);
this.$emit('taskDestroyed', this.task);
},
@@ -70,6 +70,13 @@
spellcheck="true"
:disabled="challengeAccessRequired"
:placeholder="$t('addATitle')"
@focus="setActiveField('title')"
@keydown="autoCompleteMixinUpdateCarretPosition"
@keydown.tab="autoCompleteMixinHandleTab($event)"
@keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)"
@keydown.down="autoCompleteMixinSelectNextAutocomplete($event)"
@keypress.enter="titleEnterHandler($event)"
@keydown.esc="autoCompleteMixinHandleEscape($event)"
>
</div>
<div
@@ -92,11 +99,27 @@
</small>
</div>
<textarea
ref="notesTextarea"
v-model="task.notes"
class="form-control input-notes"
:class="cssClass('input')"
:placeholder="$t('addNotes')"
@focus="setActiveField('notes')"
@keydown="autoCompleteMixinUpdateCarretPosition"
@keydown.tab="autoCompleteMixinHandleTab($event)"
@keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)"
@keydown.down="autoCompleteMixinSelectNextAutocomplete($event)"
@keypress.enter="autoCompleteMixinSelectAutocomplete($event)"
@keydown.esc="autoCompleteMixinHandleEscape($event)"
></textarea>
<emoji-auto-complete
ref="emojiAutocomplete"
:text="activeFieldText"
:textbox="textbox"
:coords="mixinData.autoComplete.coords"
:caret-position="mixinData.autoComplete.caretPosition"
@select="selectedAutocomplete"
/>
</div>
</div>
<div
@@ -150,14 +173,14 @@
<button
type="button"
class="habit-option-container no-transition
d-flex flex-column justify-content-center align-items-center"
d-flex flex-column justify-content-center align-items-center"
:class="!task.up ? cssClass('habit-control-disabled') : ''"
:disabled="challengeAccessRequired"
@click="toggleUpDirection()"
>
<div
class="habit-option-button no-transition
d-flex justify-content-center align-items-center mb-2"
d-flex justify-content-center align-items-center mb-2"
:class="task.up ? cssClass('bg') : ''"
>
<div
@@ -176,14 +199,14 @@
<button
type="button"
class="habit-option-container no-transition
d-flex flex-column justify-content-center align-items-center"
d-flex flex-column justify-content-center align-items-center"
:class="!task.down ? cssClass('habit-control-disabled') : ''"
:disabled="challengeAccessRequired"
@click="toggleDownDirection()"
>
<div
class="habit-option-button no-transition
d-flex justify-content-center align-items-center mb-2"
d-flex justify-content-center align-items-center mb-2"
:class="task.down ? cssClass('bg') : ''"
>
<div
@@ -382,6 +405,45 @@
</div>
</div>
</div>
<div
v-if="showStatAssignment"
class="stat-assignment option mt-3"
>
<div class="form-group row">
<label
v-once
class="col-12 mb-1"
>{{ $t('assignedStat') }}</label>
<div class="col-12">
<div class="stat-dropdown-container">
<select-list
:items="statOptions"
:value="task.attribute"
key-prop="key"
active-key-prop="key"
@select="task.attribute = $event.key"
>
<template #item="{ item, button }">
<div class="stat-option-content">
<span
class="stat-option-title"
:class="item.key"
>
{{ $t(item.label) }}
</span>
<span
v-if="!button"
class="stat-option-description"
>
{{ $t(item.description) }}
</span>
</div>
</template>
</select-list>
</div>
</div>
</div>
</div>
<div
v-if="task.type === 'habit' && !groupId"
class="option mt-3"
@@ -591,7 +653,7 @@
>
<button
class="btn btn-primary btn-footer
d-flex align-items-center justify-content-center"
d-flex align-items-center justify-content-center"
:class="{'btn-disabled': !canSave}"
type="button"
@click="submit()"
@@ -673,6 +735,7 @@
}
.task-modal-header {
position: relative;
color: $white;
width: 100%;
border-top-left-radius: 8px;
@@ -911,6 +974,87 @@
.streak-addon path {
fill: $gray-200;
}
.stat-dropdown-container {
.select-list {
.selectListItem {
margin-bottom: 0;
&:last-child {
margin-bottom: 0;
}
}
.selectListItem .dropdown-item {
padding: 8px 16px !important;
height: auto !important;
white-space: normal;
word-wrap: break-word;
&:hover,
&:focus {
background-color: rgba($purple-600, 0.25) !important;
}
}
.dropdown-toggle {
display: flex;
align-items: center;
.stat-option-content {
display: flex;
align-items: center;
.stat-option-title {
font-weight: normal;
color: $gray-50;
margin-bottom: 0;
}
}
}
}
.stat-option-content {
display: block;
width: 100%;
.stat-option-title {
display: block;
font-family: Roboto;
font-weight: 700;
font-size: 14px;
line-height: 1.71;
text-transform: capitalize;
margin-bottom: 4px;
&.str {
color: $maroon-100;
}
&.int {
color: $blue-50;
}
&.con {
color: $yellow-5;
}
&.per {
color: $purple-300;
}
}
.stat-option-description {
display: block;
font-family: Roboto;
font-weight: 400;
font-size: 12px;
line-height: 16px;
color: $gray-100;
margin-bottom: 0;
}
}
}
</style>
<style lang="scss" scoped>
@@ -1023,7 +1167,6 @@
.input-group-outer.disabled .input-group-text {
color: $gray-200;
}
</style>
<script>
@@ -1038,8 +1181,11 @@ import SelectMulti from './modal-controls/selectMulti';
import selectDifficulty from '@/components/tasks/modal-controls/selectDifficulty';
import selectTranslatedArray from '@/components/tasks/modal-controls/selectTranslatedArray';
import lockableLabel from '@/components/tasks/modal-controls/lockableLabel';
import selectList from '@/components/ui/selectList';
import syncTask from '../../mixins/syncTask';
import emojiAutoComplete from '@/components/chat/emojiAutoComplete';
import { autoCompleteHelperMixin } from '@/mixins/autoCompleteHelper';
import positiveIcon from '@/assets/svg/positive.svg?raw';
import negativeIcon from '@/assets/svg/negative.svg?raw';
@@ -1061,15 +1207,19 @@ export default {
selectTranslatedArray,
toggleCheckbox,
lockableLabel,
selectList,
emojiAutoComplete,
},
directives: {
markdown: markdownDirective,
},
mixins: [syncTask],
mixins: [syncTask, autoCompleteHelperMixin],
// purpose is either create or edit, task is the task created or edited
props: ['task', 'purpose', 'challengeId', 'groupId'],
data () {
return {
textbox: null,
activeField: 'title',
showAssignedSelect: false,
newChecklistItem: null,
icons: Object.freeze({
@@ -1094,6 +1244,12 @@ export default {
con: 'constitution',
per: 'perception',
},
statOptions: [
{ key: 'str', label: 'strength', description: 'strTaskText' },
{ key: 'int', label: 'intelligence', description: 'intTaskText' },
{ key: 'con', label: 'constitution', description: 'conTaskText' },
{ key: 'per', label: 'perception', description: 'perTaskText' },
],
calendarHighlights: { dates: [new Date()] },
};
},
@@ -1187,6 +1343,16 @@ export default {
selectedTags () {
return this.getTagsFor(this.task);
},
activeFieldText () {
if (!this.task) return '';
return this.activeField === 'title' ? (this.task.text || '') : (this.task.notes || '');
},
showStatAssignment () {
return this.task.type !== 'reward'
&& !this.groupId
&& this.user.preferences.automaticAllocation === true
&& this.user.preferences.allocationMode === 'taskbased';
},
},
watch: {
task () {
@@ -1305,9 +1471,16 @@ export default {
}
this.$root.$emit('bv::hide::modal', 'task-modal');
},
destroy () {
async destroy () {
const type = this.$t(this.task.type);
if (!window.confirm(this.$t('sureDeleteType', { type }))) return; // eslint-disable-line no-alert
const confirmed = await new Promise(resolve => {
this.$root.$emit('habitica:delete-task-confirm', {
message: this.$t('sureDeleteType', { type }),
taskType: type,
resolve,
});
});
if (!confirmed) return;
this.destroyTask(this.task);
this.$emit('taskDestroyed', this.task);
this.$root.$emit('bv::hide::modal', 'task-modal');
@@ -1349,6 +1522,35 @@ export default {
},
focusInput () {
this.$refs.inputToFocus.focus();
this.setActiveField('title');
},
setActiveField (field) {
this.activeField = field;
if (field === 'title') {
this.textbox = this.$refs.inputToFocus;
} else {
this.textbox = this.$refs.notesTextarea;
}
},
titleEnterHandler (e) {
const ac = this._getActiveAutocomplete();
if (ac && ac.selected !== null) {
e.preventDefault();
ac.makeSelection();
} else if (ac) {
ac.cancel();
}
},
selectedAutocomplete (newText, newCaret) {
if (this.activeField === 'title') {
this.task.text = newText;
} else {
this.task.notes = newText;
}
this.$nextTick(() => {
this.textbox.setSelectionRange(newCaret, newCaret);
this.textbox.focus();
});
},
async addTag (name) {
const tagResult = await this.createTag({ name });
+75 -1
View File
@@ -80,9 +80,17 @@
v-html="icons.drag"
></div>
<input
:ref="'tagInput-' + tagIndex"
v-model="tag.name"
class="tag-edit-input inline-edit-input form-control"
type="text"
@focus="setActiveTag(tagIndex)"
@keydown="autoCompleteMixinUpdateCarretPosition"
@keydown.tab="autoCompleteMixinHandleTab($event)"
@keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)"
@keydown.down="autoCompleteMixinSelectNextAutocomplete($event)"
@keypress.enter="autoCompleteMixinSelectAutocomplete($event)"
@keydown.esc="autoCompleteMixinHandleEscape($event)"
>
<div
class="input-group-append"
@@ -100,11 +108,18 @@
class="col-6 dragSpace"
>
<input
ref="newTagInput"
v-model="newTag"
class="new-tag-item edit-tag-item inline-edit-input form-control"
type="text"
:placeholder="$t('newTag')"
@keydown.enter="addTag($event, tagsType.key)"
@focus="setActiveTag(-1)"
@keydown="autoCompleteMixinUpdateCarretPosition"
@keydown.tab="autoCompleteMixinHandleTab($event)"
@keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)"
@keydown.down="autoCompleteMixinSelectNextAutocomplete($event)"
@keypress.enter="newTagEnterHandler($event, tagsType.key)"
@keydown.esc="autoCompleteMixinHandleEscape($event)"
>
</div>
</draggable>
@@ -134,6 +149,15 @@
</div>
</div>
</div>
<emoji-auto-complete
v-if="editingTags"
ref="emojiAutocomplete"
:text="activeTagText"
:textbox="textbox"
:coords="mixinData.autoComplete.coords"
:caret-position="mixinData.autoComplete.caretPosition"
@select="selectedTagAutocomplete"
/>
<div class="filter-panel-footer clearfix">
<template v-if="editingTags === true">
<div class="text-center">
@@ -405,6 +429,8 @@ import dragIcon from '@/assets/svg/drag_indicator.svg?raw';
import { mapState, mapActions } from '@/libs/store';
import brokenTaskModal from './brokenTaskModal';
import emojiAutoComplete from '@/components/chat/emojiAutoComplete';
import { autoCompleteHelperMixin } from '@/mixins/autoCompleteHelper';
export default {
components: {
@@ -414,10 +440,12 @@ export default {
spells,
brokenTaskModal,
draggable,
emojiAutoComplete,
},
directives: {
markdown,
},
mixins: [autoCompleteHelperMixin],
data () {
return {
columns: ['habit', 'daily', 'todo', 'reward'],
@@ -445,10 +473,19 @@ export default {
newTag: null,
editingTask: null,
creatingTask: null,
textbox: null,
activeTagIndex: -1,
};
},
computed: {
...mapState({ user: 'user.data' }),
activeTagText () {
if (this.activeTagIndex === -1) {
return this.newTag || '';
}
const tag = this.tagsSnap.tags[this.activeTagIndex];
return tag ? tag.name || '' : '';
},
tagsByType () {
const userTags = this.user.tags;
const tagsByType = {
@@ -514,6 +551,43 @@ export default {
this.tagsSnap[key].push({ id: uuid(), name: this.newTag });
this.newTag = null;
},
setActiveTag (index) {
this.activeTagIndex = index;
if (index === -1) {
const refArr = this.$refs.newTagInput;
this.textbox = Array.isArray(refArr) ? refArr[0] : refArr;
} else {
const refArr = this.$refs[`tagInput-${index}`];
if (!refArr) {
this.textbox = null;
} else {
this.textbox = Array.isArray(refArr) ? refArr[0] : refArr;
}
}
},
newTagEnterHandler (e, key) {
const ac = this._getActiveAutocomplete();
if (ac && ac.selected !== null) {
e.preventDefault();
ac.makeSelection();
} else {
if (ac) ac.cancel();
this.addTag(e, key);
}
},
selectedTagAutocomplete (newText, newCaret) {
if (this.activeTagIndex === -1) {
this.newTag = newText;
} else {
this.tagsSnap.tags[this.activeTagIndex].name = newText;
}
this.$nextTick(() => {
if (this.textbox) {
this.textbox.setSelectionRange(newCaret, newCaret);
this.textbox.focus();
}
});
},
removeTag (index, key) {
const tagId = this.tagsSnap[key][index].id;
const indexInSelected = this.selectedTags.indexOf(tagId);
@@ -16,7 +16,7 @@
.badge-pin {
background-color: $white;
color: $gray-200;
color: $gray-100;
transition: none;
display: flex;
cursor: pointer;
@@ -32,8 +32,8 @@
}
.svg-icon {
width: 100%;
height: 100%;
width: 16px;
height: 16px;
}
}
@@ -12,7 +12,7 @@
<template #button-content>
<slot
name="item"
:item="selected || placeholder"
:item="selectedItem || placeholder"
:button="true"
>
<!-- Fallback content -->
@@ -134,6 +134,14 @@ export default {
}),
};
},
computed: {
selectedItem () {
if (this.activeKeyProp) {
return this.items.find(item => item[this.activeKeyProp] === this.selected);
}
return this.items.find(item => item === this.selected);
},
},
methods: {
getKeyProp (item) {
return this.keyProp ? item[this.keyProp] : item.key || item.identifier;
@@ -0,0 +1,92 @@
<template>
<div
class="selectable-card"
:class="{ selected }"
@click="$emit('click')"
>
<div
v-if="selected"
class="checkmark-corner"
>
<div
class="svg-icon check-icon"
v-html="icons.check"
></div>
</div>
<slot></slot>
</div>
</template>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
.selectable-card {
position: relative;
background: $white;
border: 1px solid $gray-400;
border-radius: 8px;
padding: 16px;
cursor: pointer;
box-shadow: 0px 1px 2px 0px rgba(26, 24, 29, 0.08);
&:hover {
box-shadow: 0px 3px 6px 0px rgba(26, 24, 29, 0.16), 0px 3px 6px 0px rgba(26, 24, 29, 0.24);
}
&.selected {
border: 2px solid $purple-300;
padding: 15px;
box-shadow: 0px 3px 6px 0px rgba(26, 24, 29, 0.16), 0px 3px 6px 0px rgba(26, 24, 29, 0.24);
}
}
.checkmark-corner {
position: absolute;
top: 0;
left: 0;
width: 48px;
height: 48px;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
border-style: solid;
border-width: 48px 48px 0 0;
border-color: $purple-300 transparent transparent transparent;
border-radius: 6px 0 0 0;
}
.check-icon {
position: absolute;
top: 8px;
left: 8px;
width: 16px;
height: 16px;
color: $white;
}
}
</style>
<script>
import svgCheck from '@/assets/svg/check.svg?raw';
export default {
props: {
selected: {
type: Boolean,
default: false,
},
},
emits: ['click'],
data () {
return {
icons: Object.freeze({
check: svgCheck,
}),
};
},
};
</script>
@@ -25,8 +25,8 @@
type="checkbox"
:checked="isChecked"
:value="value"
@change="handleChange"
:disabled="disabled"
@change="handleChange"
>
<label
class="toggle-switch-label"
@@ -398,14 +398,29 @@
:placeholder="$t('imageUrl')"
>
</div>
<div class="form-group">
<div class="form-group" style="position: relative;">
<label>{{ $t('about') }}</label>
<textarea
ref="blurbTextarea"
v-model="editingProfile.blurb"
class="form-control"
rows="5"
:placeholder="$t('displayBlurbPlaceholder')"
@keydown="autoCompleteMixinUpdateCarretPosition"
@keydown.tab="autoCompleteMixinHandleTab($event)"
@keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)"
@keydown.down="autoCompleteMixinSelectNextAutocomplete($event)"
@keypress.enter="autoCompleteMixinSelectAutocomplete($event)"
@keydown.esc="autoCompleteMixinHandleEscape($event)"
></textarea>
<emoji-auto-complete
ref="emojiAutocomplete"
:text="editingProfile.blurb"
:textbox="textbox"
:coords="mixinData.autoComplete.coords"
:caret-position="mixinData.autoComplete.caretPosition"
@select="selectedAutocomplete"
/>
<!-- include ../../shared/formatting-help-->
</div>
</div>
@@ -1001,6 +1016,8 @@ import mute from '@/assets/svg/mute.svg?raw';
import shadowMute from '@/assets/svg/shadow-mute.svg?raw';
import externalLinks from '../../mixins/externalLinks';
import { userCustomStateMixin } from '../../mixins/userState';
import emojiAutoComplete from '@/components/chat/emojiAutoComplete';
import { autoCompleteHelperMixin } from '@/mixins/autoCompleteHelper';
// @TODO: EMAILS.COMMUNITY_MANAGER_EMAIL
const COMMUNITY_MANAGER_EMAIL = 'admin@habitica.com';
@@ -1012,8 +1029,9 @@ export default {
MemberDetails,
profileStats,
toggleSwitch,
emojiAutoComplete,
},
mixins: [externalLinks, userCustomStateMixin('userLoggedIn')],
mixins: [externalLinks, userCustomStateMixin('userLoggedIn'), autoCompleteHelperMixin],
props: ['userId', 'startingPage'],
data () {
return {
@@ -1033,6 +1051,7 @@ export default {
mute,
shadowMute,
}),
textbox: null,
userIdToMessage: '',
editing: false,
editingProfile: {
@@ -1121,6 +1140,13 @@ export default {
userLoggedIn () {
this.loadUser();
},
editing (val) {
if (val) {
this.$nextTick(() => {
this.textbox = this.$refs.blurbTextarea;
});
}
},
},
mounted () {
this.loadUser();
@@ -1331,6 +1357,13 @@ export default {
this.$emit('toggled', this.isOpened);
},
selectedAutocomplete (newText, newCaret) {
this.editingProfile.blurb = newText;
this.$nextTick(() => {
this.textbox.setSelectionRange(newCaret, newCaret);
this.textbox.focus();
});
},
reportPlayer () {
this.$root.$emit('habitica::report-profile', {
memberId: this.user._id,
@@ -1340,7 +1373,7 @@ export default {
},
openAdminPanel () {
this.$router.push(`/admin-panel/${this.hero._id}`);
this.$router.push(`/admin/panel/${this.hero._id}`);
},
},
};
File diff suppressed because it is too large Load Diff
+28 -14
View File
@@ -15,46 +15,60 @@ export const autoCompleteHelperMixin = {
};
},
methods: {
_getActiveAutocomplete () {
if (this.$refs.autocomplete && this.$refs.autocomplete.searchActive) {
return this.$refs.autocomplete;
}
if (this.$refs.emojiAutocomplete && this.$refs.emojiAutocomplete.searchActive) {
return this.$refs.emojiAutocomplete;
}
return null;
},
autoCompleteMixinHandleTab (e) {
if (this.$refs.autocomplete.searchActive) {
const ac = this._getActiveAutocomplete();
if (ac) {
e.preventDefault();
if (e.shiftKey) {
this.$refs.autocomplete.selectPrevious();
ac.selectPrevious();
} else {
this.$refs.autocomplete.selectNext();
ac.selectNext();
}
}
},
autoCompleteMixinHandleEscape (e) {
if (this.$refs.autocomplete.searchActive) {
const ac = this._getActiveAutocomplete();
if (ac) {
e.preventDefault();
this.$refs.autocomplete.cancel();
ac.cancel();
}
},
autoCompleteMixinSelectNextAutocomplete (e) {
if (this.$refs.autocomplete.searchActive) {
const ac = this._getActiveAutocomplete();
if (ac) {
e.preventDefault();
this.$refs.autocomplete.selectNext();
ac.selectNext();
}
},
autoCompleteMixinSelectPreviousAutocomplete (e) {
if (this.$refs.autocomplete.searchActive) {
const ac = this._getActiveAutocomplete();
if (ac) {
e.preventDefault();
this.$refs.autocomplete.selectPrevious();
ac.selectPrevious();
}
},
autoCompleteMixinSelectAutocomplete (e) {
if (this.$refs.autocomplete.searchActive) {
if (this.$refs.autocomplete.selected !== null) {
const ac = this._getActiveAutocomplete();
if (ac) {
if (ac.selected !== null) {
e.preventDefault();
this.$refs.autocomplete.makeSelection();
ac.makeSelection();
} else {
// no autocomplete selected, newline instead
this.$refs.autocomplete.cancel();
ac.cancel();
}
}
},
+9 -1
View File
@@ -39,7 +39,15 @@ export default {
};
const purchaseForKey = currencyToPurchaseForKey[currency];
return window.confirm(this.$t(purchaseForKey, { cost })); // eslint-disable-line no-alert
return new Promise(resolve => {
this.$root.$emit('habitica:purchase-confirm', {
message: this.$t(purchaseForKey, { cost }),
currency,
cost,
resolve,
});
});
},
},
};
+8 -50
View File
@@ -1,56 +1,14 @@
import includes from 'lodash/includes';
export default {
methods: {
foolPet (pet, prank) {
const SPECIAL_PETS = [
'Bear-Veteran',
'BearCub-Polar',
'Cactus-Veteran',
'Dragon-Hydra',
'Dragon-Veteran',
'Fox-Veteran',
'Gryphatrice-Jubilant',
'Gryphon-Gryphatrice',
'Gryphon-RoyalPurple',
'Hippogriff-Hopeful',
'Jackalope-RoyalPurple',
'JackOLantern-Base',
'JackOLantern-Ghost',
'JackOLantern-Glow',
'JackOLantern-RoyalPurple',
'Lion-Veteran',
'MagicalBee-Base',
'Mammoth-Base',
'MantisShrimp-Base',
'Orca-Base',
'Phoenix-Base',
'Tiger-Veteran',
'Turkey-Base',
'Turkey-Gilded',
'Wolf-Cerberus',
'Wolf-Veteran',
];
const BASE_PETS = [
'BearCub',
'Cactus',
'Dragon',
'FlyingPig',
'Fox',
'LionCub',
'PandaCub',
'TigerCub',
'Wolf',
];
if (!pet) return `Pet-TigerCub-${prank}`;
if (SPECIAL_PETS.indexOf(pet) !== -1) {
return `Pet-Dragon-${prank}`;
foolPet (pet, substitutions) {
if (!pet || pet === 'Pet-') return substitutions.noPet;
if (substitutions[pet]) return substitutions[pet];
for (const key in substitutions) {
if (pet.startsWith(key)) {
return substitutions[key];
}
}
const species = pet.slice(0, pet.indexOf('-'));
if (includes(BASE_PETS, species)) {
return `Pet-${species}-${prank}`;
}
return `Pet-BearCub-${prank}`;
return substitutions.default;
},
},
};
@@ -153,9 +153,23 @@
:placeholder="$t('needsTextPlaceholder')"
:maxlength="MAX_MESSAGE_LENGTH"
:class="{'has-content': newMessage.trim() !== '', 'disabled': newMessageDisabled}"
@keydown="autoCompleteMixinUpdateCarretPosition"
@keyup.ctrl.enter="sendPrivateMessage()"
@keydown.tab="autoCompleteMixinHandleTab($event)"
@keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)"
@keydown.down="autoCompleteMixinSelectNextAutocomplete($event)"
@keypress.enter="autoCompleteMixinSelectAutocomplete($event)"
@keydown.esc="autoCompleteMixinHandleEscape($event)"
>
</textarea>
<emoji-auto-complete
ref="emojiAutocomplete"
:text="newMessage"
:textbox="textbox"
:coords="mixinData.autoComplete.coords"
:caret-position="mixinData.autoComplete.caretPosition"
@select="selectedAutocomplete"
/>
</div>
<div
class="sub-new-message-row d-flex"
@@ -540,6 +554,7 @@ h3 {
}
.new-message-row {
position: relative;
width: 100%;
padding-left: 1.5rem;
padding-top: 1.5rem;
@@ -676,10 +691,12 @@ import PmNewMessageStarted from './pm-new-message-started.vue';
import StartNewConversationInputHeader from './start-new-conversation-input-header.vue';
import positiveIcon from '@/assets/svg/positive.svg?raw';
import NotificationMixins from '@/mixins/notifications';
import emojiAutoComplete from '@/components/chat/emojiAutoComplete';
import { autoCompleteHelperMixin } from '@/mixins/autoCompleteHelper';
// extract to a shared path
const CONVERSATIONS_PER_PAGE = 10;
const PM_PER_PAGE = 10;
const PM_PER_PAGE = 50;
const UI_STATES = Object.freeze({
LOADING: 'LOADING',
@@ -700,13 +717,14 @@ export default defineComponent({
toggleSwitch,
userLink,
faceAvatar,
emojiAutoComplete,
},
filters: {
timeAgo (value) {
return moment(new Date(value)).fromNow();
},
},
mixins: [styleHelper, NotificationMixins],
mixins: [styleHelper, NotificationMixins, autoCompleteHelperMixin],
beforeRouteEnter (to, from, next) {
next(vm => {
const data = vm.$store.state.privateMessageOptions;
@@ -751,6 +769,7 @@ export default defineComponent({
/** @type {Record<string, PrivateMessages.PrivateMessageEntry[]>} */
messagesByConversation: {}, // cache {uuid: []}
textbox: null,
newMessage: '',
messages: [],
messagesLoading: false,
@@ -963,6 +982,15 @@ export default defineComponent({
}
},
},
watch: {
shouldShowInputPanel (val) {
if (val) {
this.$nextTick(() => {
this.textbox = this.$refs.textarea;
});
}
},
},
async mounted () {
this.$store.dispatch('common:setTitle', {
section: this.$t('messages'),
@@ -1224,6 +1252,13 @@ export default defineComponent({
triggerStartNewConversationState () {
this.showStartNewConversationInput = true;
},
selectedAutocomplete (newText, newCaret) {
this.newMessage = newText;
this.$nextTick(() => {
this.textbox.setSelectionRange(newCaret, newCaret);
this.textbox.focus();
});
},
async startConversationByUsername (targetUserName) {
// check if the target user exists in current conversations, select that conversation
/** @type {PrivateMessages.ConversationSummaryMessageEntry} */
@@ -217,8 +217,18 @@ export default {
}
},
async changeClassAndClose () {
if (!this.classDisabled && !window.confirm(this.$t('changeClassConfirmCost'))) {
return;
if (!this.classDisabled) {
const confirmed = await new Promise(resolve => {
this.$root.$emit('habitica:purchase-confirm', {
message: this.$t('changeClassConfirmCost'),
currency: 'gems',
cost: 3,
resolve,
});
});
if (!confirmed) {
return;
}
}
this.$root.$once('bv::hide::modal', () => {
@@ -67,7 +67,7 @@
<div
v-once
class="feedback"
class="feedback mt-3"
v-html="$t('feedback')"
>
</div>
@@ -1,7 +1,8 @@
<template>
<tr>
<td colspan="3"
<td
v-if="!mixinData.inlineSettingMixin.modalVisible"
colspan="3"
>
<div class="d-flex justify-content-between align-items-center">
<h3
@@ -18,8 +19,9 @@
</a>
</div>
</td>
<td colspan="3"
<td
v-if="mixinData.inlineSettingMixin.modalVisible"
colspan="3"
>
<h3
v-once
@@ -59,8 +61,8 @@
{{ $t('performanceAnalytics') }}
</label>
<toggle-switch
class="mb-auto"
v-model="user.preferences.analyticsConsent"
class="mb-auto"
@change="prefToggled()"
/>
</div>
@@ -151,14 +153,14 @@ import { mapState } from '@/libs/store';
import alert from '@/assets/svg/for-css/alert.svg?raw';
export default {
mixins: [
GenericUserPreferencesMixin,
InlineSettingMixin,
],
components: {
SaveCancelButtons,
ToggleSwitch,
},
mixins: [
GenericUserPreferencesMixin,
InlineSettingMixin,
],
data () {
return {
icons: Object.freeze({
+43 -24
View File
@@ -14,6 +14,8 @@
<bug-report-success-modal v-if="isUserLoaded" />
<external-link-modal />
<birthday-modal />
<purchase-confirm-modal v-if="isUserLoaded" />
<delete-task-confirm-modal v-if="isUserLoaded" />
<template v-if="isUserLoaded">
<privacy-banner />
<chat-banner />
@@ -138,6 +140,8 @@ import paymentsSuccessModal from '@/components/payments/successModal';
import subCancelModalConfirm from '@/components/payments/cancelModalConfirm';
import subCanceledModal from '@/components/payments/canceledModal';
import externalLinkModal from '@/components/externalLinkModal.vue';
import purchaseConfirmModal from '@/components/shops/purchaseConfirmModal.vue';
import deleteTaskConfirmModal from '@/components/tasks/deleteTaskConfirmModal.vue';
import spellsMixin from '@/mixins/spells';
import {
@@ -172,6 +176,8 @@ export default {
bugReportModal,
bugReportSuccessModal,
externalLinkModal,
purchaseConfirmModal,
deleteTaskConfirmModal,
},
mixins: [notifications, spellsMixin],
data () {
@@ -262,7 +268,6 @@ export default {
this.$store.dispatch('user:fetch'),
this.$store.dispatch('tasks:fetchUserTasks'),
]).then(() => {
this.$store.state.isUserLoaded = true;
let analyticsConsent = localStorage.getItem('analyticsConsent');
if (analyticsConsent !== null) {
analyticsConsent = analyticsConsent === 'true';
@@ -270,31 +275,11 @@ export default {
this.$store.dispatch('user:set', { 'preferences.analyticsConsent': analyticsConsent });
}
}
if (window && window['habitica-i18n']) {
if (this.user.preferences.language === window['habitica-i18n'].language.code) {
return null;
}
}
if (window && window['habitica-i18n']) {
if (this.user.preferences.language === window['habitica-i18n'].language.code) {
return null;
}
}
Analytics.updateUser();
return axios.get(
'/api/v4/i18n/browser-script',
{
language: this.user.preferences.language,
headers: {
'Cache-Control': 'no-cache',
Pragma: 'no-cache',
Expires: '0',
},
},
);
return this.loadAllTranslations();
}).then(() => {
const i18nData = window && window['habitica-i18n'];
this.$loadLocale(i18nData);
this.$store.state.isUserLoaded = true;
this.hideLoadingScreen();
// Adjust the timezone offset
@@ -310,6 +295,10 @@ export default {
appState = JSON.parse(appState);
if (appState.paymentCompleted) {
removeLocalSetting(CONSTANTS.savedAppStateValues.SAVED_APP_STATE);
if (appState.paymentType === 'groupPlan') {
this.$store.state.upgradingGroup = {};
this.$store.dispatch('guilds:getGroupPlans', true);
}
this.$root.$emit('habitica:payment-success', appState);
}
}
@@ -374,6 +363,36 @@ export default {
hideLoadingScreen () {
this.loading = false;
},
async loadContentTranslations () {
const contentTranslations = await axios.get(
'/api/v4/i18n/content',
{
language: this.user.preferences.language,
},
);
const i18nData = window && window['habitica-i18n'];
i18nData.strings = { ...i18nData.strings, ...contentTranslations.data };
this.$loadLocale(i18nData);
},
async loadAllTranslations () {
if (window && window['habitica-i18n']) {
if (this.user.preferences.language === window['habitica-i18n'].language.code) {
return this.loadContentTranslations();
}
}
await axios.get(
'/api/v4/i18n/core',
{
language: this.user.preferences.language,
headers: {
'Cache-Control': 'no-cache',
Pragma: 'no-cache',
Expires: '0',
},
},
);
return this.loadContentTranslations();
},
},
};
</script>
+14 -6
View File
@@ -11,7 +11,6 @@ import { DEPRECATED_ROUTES } from '@/router/deprecated-routes';
// NOTE: when adding a page make sure to implement the `common:setTitle` action
const RegisterLoginReset = () => import(/* webpackChunkName: "auth" */'@/components/auth/registerLoginReset');
const Logout = () => import(/* webpackChunkName: "auth" */'@/components/auth/logout');
// Hall
@@ -79,11 +78,6 @@ const router = new VueRouter({
// in the route component to set a specific subtitle for the page.
routes: [
{ name: 'logout', path: '/logout', component: Logout },
{
name: 'resetPassword', path: '/reset-password', component: RegisterLoginReset, meta: { requiresLogin: false },
}, {
name: 'forgotPassword', path: '/forgot-password', component: RegisterLoginReset, meta: { requiresLogin: false },
},
{ name: 'tasks', path: '/', component: UserTasks },
{
name: 'userProfile',
@@ -91,6 +85,13 @@ const router = new VueRouter({
props: true,
},
{ name: 'profile', path: '/user/profile' },
{
name: 'avatar',
path: '/avatar',
children: [
{ name: 'backgrounds', path: 'backgrounds' },
],
},
{ name: 'stats', path: '/user/stats' },
{ name: 'achievements', path: '/user/achievements' },
{
@@ -416,6 +417,13 @@ router.beforeEach(async (to, from, next) => {
router.app.$root.$emit('bv::hide::modal', 'profile');
}
if (to.name === 'backgrounds') {
store.state.avatarEditorOptions.editingUser = true;
store.state.avatarEditorOptions.startingPage = 'backgrounds';
router.app.$root.$emit('bv::show::modal', 'avatar-modal');
return null;
}
return next();
});
@@ -57,6 +57,9 @@ export const STATIC_ROUTES = {
{
name: 'features', path: 'features', component: FeaturesPage, meta: { requiresLogin: false },
},
{
name: 'forgotPassword', path: '/forgot-password', component: RegisterLoginReset, meta: { requiresLogin: false },
},
{
name: 'front', path: 'front', component: HomePage, meta: { requiresLogin: false },
},
@@ -90,6 +93,9 @@ export const STATIC_ROUTES = {
{
name: 'register', path: '/register', component: RegisterLoginReset, meta: { requiresLogin: false },
},
{
name: 'resetPassword', path: '/reset-password', component: RegisterLoginReset, meta: { requiresLogin: false },
},
{
name: 'terms', path: 'terms', component: TermsPage, meta: { requiresLogin: false },
},
+1 -1
View File
@@ -3,7 +3,7 @@ import Vue from 'vue';
import * as Analytics from '@/libs/analytics';
export async function getChat (store, payload) {
const response = await axios.get(`/api/v4/groups/${payload.groupId}/chat`);
const response = await axios.get(`/api/v4/groups/${payload.groupId}/chat?limit=400`);
return response.data.data;
}

Some files were not shown because too many files have changed in this diff Show More