Compare commits

..

33 Commits

Author SHA1 Message Date
Phillip Thelen bbf0e1609d Improve rate limiter options 2026-05-07 15:43:55 +02:00
Phillip Thelen 95e2247050 Improve rate limit handling 2026-05-06 13:19:02 +02:00
Fiz 1178da3a26 fix(quests): stuck "you were invited" banner after accept/reject (#15647)
wrap the conditional group update and the user save in a single transaction so a failure                                                                                                              between them can't leave members[uid] and RSVPNeeded out of sync
2026-05-05 11:09:30 -05:00
Kalista Payne 819ed2b355 Task dropdown and keyboard navigation fixes (#15648)
* fix(css): kebab z, focus highlights

* fix(nav): better tab behavior
2026-04-30 17:00:21 -05:00
Kalista Payne a92999fc11 5.47.6 2026-04-10 11:49:43 -05:00
Kalista Payne 3489b88752 fix(auth): downgrade helmet 2026-04-10 11:42:27 -05:00
Kalista Payne 94bda30385 5.47.5 2026-04-09 12:54:31 -05:00
Kalista Payne e8bbdc2cb8 fix(auth): disable broken CSP for now 2026-04-09 12:44:27 -05:00
Kalista Payne d465efaf96 fix(test): we have 1 item sets now 2026-04-08 15:35:47 -05:00
Kalista Payne 3a08de7ab3 fix(sorting): rerender task column for accuracy 2026-04-08 15:35:47 -05:00
Phillip Thelen e6ffd69148 feat(analytics): initial Habitica-owned solution 2026-04-08 15:35:47 -05:00
Kalista Payne 746fcfff49 Warning to avoid SPI (sensitive personal information) (#15638)
* feat(tasks): warn about adding SPI

* fix(links): unmangle, distinct jumps

* fix(spi): unlink

* fix(lint): punctuations
2026-04-08 15:35:47 -05:00
Kalista Payne 8aa343d390 5.47.4 2026-04-08 15:35:33 -05:00
Kalista Payne d80c43c82a test(ipn): log IPN data for troubleshooting 2026-04-08 15:35:03 -05:00
Kalista Payne 80e4b8617a fix(csp): habitica.com isn't *.habitica.com 2026-04-07 16:00:36 -05:00
Kalista Payne b3fac011a9 5.47.3 2026-04-07 15:04:01 -05:00
Weblate 8f4d871911 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (279 of 279 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (292 of 292 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (191 of 191 strings)

Translated using Weblate (French)

Currently translated at 100.0% (191 of 191 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (202 of 202 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (191 of 191 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (413 of 413 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (943 of 943 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 98.0% (854 of 871 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 97.8% (852 of 871 strings)

Translated using Weblate (Japanese)

Currently translated at 97.7% (3472 of 3551 strings)

Translated using Weblate (Japanese)

Currently translated at 97.7% (432 of 442 strings)

Translated using Weblate (Japanese)

Currently translated at 97.6% (3467 of 3551 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (279 of 279 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (292 of 292 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (145 of 145 strings)

Translated using Weblate (Japanese)

Currently translated at 99.6% (868 of 871 strings)

Translated using Weblate (Japanese)

Currently translated at 99.2% (251 of 253 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (191 of 191 strings)

Translated using Weblate (English (United Kingdom))

Currently translated at 65.0% (13 of 20 strings)

Translated using Weblate (English (United Kingdom))

Currently translated at 97.2% (284 of 292 strings)

Translated using Weblate (English (United Kingdom))

Currently translated at 72.4% (2572 of 3551 strings)

Translated using Weblate (English (United Kingdom))

Currently translated at 98.4% (188 of 191 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 98.7% (860 of 871 strings)

Translated using Weblate (English (United Kingdom))

Currently translated at 96.5% (195 of 202 strings)

Translated using Weblate (English (United Kingdom))

Currently translated at 97.3% (111 of 114 strings)

Translated using Weblate (Japanese)

Currently translated at 97.2% (284 of 292 strings)

Translated using Weblate (Japanese)

Currently translated at 99.3% (144 of 145 strings)

Translated using Weblate (Japanese)

Currently translated at 99.6% (868 of 871 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (943 of 943 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (413 of 413 strings)

Translated using Weblate (English (United Kingdom))

Currently translated at 98.9% (187 of 189 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 95.9% (836 of 871 strings)

Translated using Weblate (English (United Kingdom))

Currently translated at 95.0% (192 of 202 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (943 of 943 strings)

Co-authored-by: Céu <marcel.ufscar@gmail.com>
Co-authored-by: Jitske van Meerten <jitskevmeerten@hotmail.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: Weblate <noreply@weblate.org>
Co-authored-by: いんこ <ayakabooker@gmail.com>
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/en_GB/
Translate-URL: https://translate.habitica.com/projects/habitica/character/en_GB/
Translate-URL: https://translate.habitica.com/projects/habitica/character/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/content/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/content/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/en_GB/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/front/en_GB/
Translate-URL: https://translate.habitica.com/projects/habitica/front/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/front/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/front/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/en_GB/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/en_GB/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/en_GB/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/rebirth/en_GB/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/ja/
Translation: Habitica/Backgrounds
Translation: Habitica/Challenge
Translation: Habitica/Character
Translation: Habitica/Content
Translation: Habitica/Faq
Translation: Habitica/Front
Translation: Habitica/Gear
Translation: Habitica/Groups
Translation: Habitica/Limited
Translation: Habitica/Questscontent
Translation: Habitica/Rebirth
Translation: Habitica/Subscriber
Translation: Habitica/Tasks
2026-04-07 16:51:25 +02:00
Hafiz 19373ce84d remove comment 2026-04-06 16:14:24 -05:00
Hafiz 55bfca20d9 comment 2026-04-06 16:04:43 -05:00
Kalista Payne 2ee2b05d1c Implement Content-Security-Policy (#15567)
* feat(security): implement CSP

* fix(csp): update helmet version to latest

* Squashed commit of the following:

commit cc6a35e61db07759c1f32716185543bc48bce760
Author: Kalista Payne <kalista@habitica.com>
Date:   Fri Dec 12 17:27:50 2025 -0600

    fix(CSP): more Amazon domains

commit 985b86c29af866b2df942c21217d99390a2c6e92
Author: Kalista Payne <kalista@habitica.com>
Date:   Fri Dec 12 17:18:08 2025 -0600

    fix(csp): more loggly allowance

commit 166bd315272f9c3a42652f3a026c27a88ed1a549
Author: Kalista Payne <kalista@habitica.com>
Date:   Fri Dec 12 17:12:00 2025 -0600

    fix(csp): data, inline, some refactoring

commit 1a0a6c1806a53d43a7199bb2ef72cff610e908be
Author: Kalista Payne <kalista@habitica.com>
Date:   Fri Dec 12 17:05:44 2025 -0600

    fix(CSP): override default script-src

commit 023d9886c835989da9c5901c168d66b572097dcf
Author: Kalista Payne <kalista@habitica.com>
Date:   Fri Dec 12 16:56:24 2025 -0600

    fix(CSP): unsafe-eval in default-src

commit f51f0a0c93b60dfec7ce02be0ecd2587fc882fe0
Author: Kalista Payne <kalista@habitica.com>
Date:   Fri Dec 12 16:52:14 2025 -0600

    fix(CSP): move trusted list to default-src

commit 83b2ba7688dea38abb651cf5c27482a7a3648374
Author: Kalista Payne <kalista@habitica.com>
Date:   Fri Dec 12 16:38:05 2025 -0600

    fix(CSP): explicit habitica/aws in script-src

commit d5ca5172d5ad2fd8cec9402d7d2c9452c6ece7a1
Author: Kalista Payne <kalista@habitica.com>
Date:   Fri Dec 12 16:31:38 2025 -0600

    fix(CSP): need escaped single quotes

commit c677a1ffeff5793b6da228924e68d2c2e47794b2
Author: Kalista Payne <kalista@habitica.com>
Date:   Fri Dec 12 16:27:46 2025 -0600

    fix(CSP): unsafe-eval

commit 6ef35c3f7281c8426d9c333686be6bb65f00b3a8
Author: Kalista Payne <kalista@habitica.com>
Date:   Fri Dec 12 16:15:07 2025 -0600

    fix(CSP): might need to skip entirely in dev but try no 'self'

commit 5759fb37d82fa61b474f01e9ce5e2dc461f6ceba
Author: Kalista Payne <kalista@habitica.com>
Date:   Fri Dec 12 15:51:26 2025 -0600

    fix(csp): permit AWS in default-src

commit 9f238abf9373bc29715657b945b2247ef23c9224
Author: Kalista Payne <kalista@habitica.com>
Date:   Fri Dec 5 17:22:25 2025 -0600

    fix(csp): update helmet version to latest

commit 9462e90f4f3058f4014137b3178b9751c5280e97
Author: Kalista Payne <kalista@habitica.com>
Date:   Tue Nov 25 09:27:05 2025 -0600

    feat(security): implement CSP

commit 72539f9ba3
Author: Kalista Payne <kalista@habitica.com>
Date:   Wed Dec 10 14:16:53 2025 -0600

    5.42.2

commit dabd466719
Author: Kalista Payne <kalista@habitica.com>
Date:   Wed Dec 10 14:16:48 2025 -0600

    Revert "Chat optimization (#15545)"

    This reverts commit 2917955ef0.

commit 8bf2304330
Author: Kalista Payne <kalista@habitica.com>
Date:   Wed Dec 10 14:15:48 2025 -0600

    chore(event): G1G1 date tweaks

commit 6937dc4e4e
Author: Kalista Payne <kalista@habitica.com>
Date:   Mon Dec 8 16:37:04 2025 -0600

    fix(subscription): couple more layout tweaks

* fix(csp): move unsafe-eval to default? ig?

* Revert "fix(csp): move unsafe-eval to default? ig?"

This reverts commit 90476cbf6c.

* fix(security): no unsafe! yay!

* fix(packages): remove webpack

* fix(lint): object destructuring

* fix(csp): remove Vue-Fragment

* wip(i18n): load Moment locale from cache

* fix(gulp): remove unneeded cache task

* fix(i18n): add Moment weekday abbrevs to translations

* fix(lint): destructuring
...why is this happening here and not develop lol

* fix(csp): add amplitude to whitelist

---------

Co-authored-by: Phillip Thelen <phillip@thelen.space>
2026-04-02 15:19:35 -05:00
Kalista Payne 3d3db1bdd9 Require email in social reg edge case (#15634)
* apply email passed via body if it is missing from apple profile

* Add Web UI to set email if apple does not provide one

* fix lint

* remove trailing space

* fix(register): show field if social auth without email

* fix(ux): add explanatory text

* fix(lint): max-len

* fix(data): remove unused field

* fix(auth): pass email around as necessary in Apple flow

* fix(auth): still wrong place argh

* Fix(auth): handle email in Apple registration flow

---------

Co-authored-by: Phillip Thelen <phillip@habitica.com>
Co-authored-by: Hafiz <hafizbhamidi@gmail.com>
2026-04-02 15:16:43 -05:00
Kalista Payne a5dff99fa1 5.47.2 2026-04-02 15:07:13 -05:00
Weblate 25435218e1 Translated using Weblate (Portuguese)
Currently translated at 59.5% (2116 of 3551 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 81.8% (239 of 292 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 66.9% (2379 of 3551 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (20 of 20 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 80.8% (236 of 292 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 66.3% (2357 of 3551 strings)

Translated using Weblate (Russian)

Currently translated at 82.0% (2912 of 3551 strings)

Translated using Weblate (Russian)

Currently translated at 99.5% (939 of 943 strings)

Translated using Weblate (Dutch)

Currently translated at 80.2% (699 of 871 strings)

Translated using Weblate (French)

Currently translated at 100.0% (413 of 413 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (871 of 871 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (413 of 413 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (943 of 943 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (279 of 279 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (20 of 20 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (292 of 292 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (442 of 442 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (3551 of 3551 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (871 of 871 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (413 of 413 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (943 of 943 strings)

Translated using Weblate (Korean)

Currently translated at 90.3% (103 of 114 strings)

Translated using Weblate (Korean)

Currently translated at 60.0% (12 of 20 strings)

Co-authored-by: Aleksandr <aichernyaev@yandex.ru>
Co-authored-by: Jildau Bras <jildaubras@gmail.com>
Co-authored-by: Maria Morant <luisa.morant@yahoo.com>
Co-authored-by: Sophie LE MASLE <sophiesuff@gmail.com>
Co-authored-by: Summer_GUI <heyang94@163.com>
Co-authored-by: Viktor Révész <rviktor@ivankapal.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: 윤태연 <bestpow123@naver.com>
Co-authored-by: ? <importantdata78@gmail.com>
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/ru/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/ko/
Translate-URL: https://translate.habitica.com/projects/habitica/content/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/content/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/content/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/ru/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/zh_Hant/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/zh_Hant/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/rebirth/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/rebirth/ko/
Translate-URL: https://translate.habitica.com/projects/habitica/rebirth/ru/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/hu/
Translation: Habitica/Backgrounds
Translation: Habitica/Challenge
Translation: Habitica/Content
Translation: Habitica/Gear
Translation: Habitica/Groups
Translation: Habitica/Limited
Translation: Habitica/Questscontent
Translation: Habitica/Rebirth
Translation: Habitica/Subscriber
2026-04-01 16:15:29 +02:00
Fiz 7746049bb4 fix(groups): expired guild plans not showing in upgrade modal (#15637)
use dateCreated instead of customerId to identify previously upgraded groups. Team cron clears customerId on expired plans as a query optimization, which makes them invisible to the upgrade flow. dateCreated is set on every group plan since the feature was introduced and is never cleared, so it reliably indicates a group was previously upgraded.
2026-03-31 12:17:42 -05:00
Kalista Payne 036bf43cf3 5.47.1 2026-03-27 14:55:06 -05:00
Weblate c4e0127a37 Merge branch 'origin/develop' into Weblate. 2026-03-27 20:51:22 +01:00
Kalista Payne 7ae059e243 fix(sprites): restore missing animations 2026-03-27 14:44:57 -05:00
Weblate 81b9a0b92d Translated using Weblate (Dutch)
Currently translated at 72.9% (2589 of 3551 strings)

Translated using Weblate (Czech)

Currently translated at 92.9% (106 of 114 strings)

Translated using Weblate (Czech)

Currently translated at 99.4% (166 of 167 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 90.0% (18 of 20 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 92.4% (270 of 292 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.3% (439 of 442 strings)

Translated using Weblate (Portuguese)

Currently translated at 59.5% (2116 of 3551 strings)

Translated using Weblate (German)

Currently translated at 98.5% (3498 of 3551 strings)

Translated using Weblate (French)

Currently translated at 100.0% (871 of 871 strings)

Translated using Weblate (Portuguese)

Currently translated at 54.4% (152 of 279 strings)

Translated using Weblate (Portuguese)

Currently translated at 59.5% (2116 of 3551 strings)

Translated using Weblate (Portuguese)

Currently translated at 45.8% (116 of 253 strings)

Translated using Weblate (Portuguese)

Currently translated at 45.8% (116 of 253 strings)

Translated using Weblate (Portuguese)

Currently translated at 93.3% (14 of 15 strings)

Co-authored-by: Azuthrax <azuthrax@gmail.com>
Co-authored-by: Daniel Costa Carvalho <danielcostacarvalho@gmail.com>
Co-authored-by: Maria Morant <luisa.morant@yahoo.com>
Co-authored-by: Matej Boura <B.Matej@email.cz>
Co-authored-by: Mausam <mausam_b@protonmail.com>
Co-authored-by: Sophie LE MASLE <sophiesuff@gmail.com>
Co-authored-by: Toro Mor <thomas.bizer@gmx.de>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/cs/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/cs/
Translate-URL: https://translate.habitica.com/projects/habitica/death/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/de/
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/groups/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/rebirth/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/pt/
Translation: Habitica/Achievements
Translation: Habitica/Challenge
Translation: Habitica/Death
Translation: Habitica/Faq
Translation: Habitica/Gear
Translation: Habitica/Groups
Translation: Habitica/Limited
Translation: Habitica/Questscontent
Translation: Habitica/Rebirth
Translation: Habitica/Subscriber
2026-03-27 19:07:01 +01:00
Kalista Payne a6e87452a6 fix(background): add price for mobile logic 2026-03-27 09:42:06 -05:00
Kalista Payne c40d384913 5.47.0 2026-03-24 12:58:53 -05:00
Weblate 65144aef28 Translated using Weblate (Spanish)
Currently translated at 100.0% (279 of 279 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (20 of 20 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 90.7% (401 of 442 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (292 of 292 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (442 of 442 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (3551 of 3551 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (871 of 871 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (943 of 943 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (167 of 167 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 93.6% (816 of 871 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 78.3% (2782 of 3551 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 91.5% (185 of 202 strings)

Translated using Weblate (French)

Currently translated at 99.7% (869 of 871 strings)

Translated using Weblate (French)

Currently translated at 100.0% (943 of 943 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (167 of 167 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 75.8% (2693 of 3551 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 100.0% (941 of 941 strings)

Translated using Weblate (French)

Currently translated at 99.0% (863 of 871 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 51.6% (47 of 91 strings)

Translated using Weblate (French)

Currently translated at 98.0% (854 of 871 strings)

Translated using Weblate (French)

Currently translated at 100.0% (20 of 20 strings)

Translated using Weblate (Japanese)

Currently translated at 99.3% (865 of 871 strings)

Translated using Weblate (Swedish)

Currently translated at 52.3% (146 of 279 strings)

Translated using Weblate (Swedish)

Currently translated at 80.0% (16 of 20 strings)

Translated using Weblate (Swedish)

Currently translated at 49.6% (1762 of 3551 strings)

Translated using Weblate (Swedish)

Currently translated at 81.6% (200 of 245 strings)

Translated using Weblate (Swedish)

Currently translated at 5.5% (14 of 253 strings)

Translated using Weblate (Swedish)

Currently translated at 70.7% (610 of 862 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 70.0% (14 of 20 strings)

Co-authored-by: Dayra G.R <ale.ro.bless1501@gmail.com>
Co-authored-by: Isabela de França <ifranceg@gmail.com>
Co-authored-by: Sam WIlson <sam.wils.2008@gmail.com>
Co-authored-by: Sophie LE MASLE <sophiesuff@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: いんこ <ayakabooker@gmail.com>
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/es/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/es/
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/character/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/communityguidelines/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/es/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/es/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/es/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/es/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/rebirth/es/
Translate-URL: https://translate.habitica.com/projects/habitica/rebirth/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/rebirth/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/rebirth/sv/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/es/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/sv/
Translation: Habitica/Achievements
Translation: Habitica/Backgrounds
Translation: Habitica/Character
Translation: Habitica/Communityguidelines
Translation: Habitica/Faq
Translation: Habitica/Gear
Translation: Habitica/Generic
Translation: Habitica/Groups
Translation: Habitica/Limited
Translation: Habitica/Questscontent
Translation: Habitica/Rebirth
Translation: Habitica/Subscriber
2026-03-24 01:38:59 +01: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
238 changed files with 2818 additions and 4158 deletions
+1
View File
@@ -21,3 +21,4 @@ services:
timeout: 30s
start_period: 0s
retries: 30
+169 -281
View File
@@ -1,12 +1,12 @@
{
"name": "habitica",
"version": "5.46.4",
"version": "5.47.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "habitica",
"version": "5.46.4",
"version": "5.47.6",
"hasInstallScript": true,
"dependencies": {
"@babel/core": "^7.22.10",
@@ -57,7 +57,7 @@
"micromustache": "^8.0.3",
"moment": "^2.29.4",
"moment-recur": "git://github.com/HabitRPG/moment-recur.git#d3e8e6da0806f13b74dd2e4d7d9053e6a63db119",
"mongoose": "^8.9.5",
"mongoose": "^8.23.0",
"morgan": "^1.10.1",
"nan": "^2.25.0",
"nconf": "^0.12.1",
@@ -81,7 +81,6 @@
"useragent": "^2.1.9",
"uuid": "^9.0.0",
"validator": "^13.11.0",
"webpack-bundle-analyzer": "^4.10.2",
"winston": "^3.10.0",
"winston-loggly-bulk": "^3.3.0",
"xml2js": "^0.6.2"
@@ -2624,6 +2623,19 @@
"node": ">=12.0.0"
}
},
"node_modules/@google-cloud/trace-agent/node_modules/gcp-metadata": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz",
"integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==",
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^5.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@google-cloud/trace-agent/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@@ -3052,9 +3064,9 @@
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
},
"node_modules/@mongodb-js/saslprep": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.2.tgz",
"integrity": "sha512-QgA5AySqB27cGTXBFmnpifAi7HxoGUeezwo6p9dI03MuDB6Pp33zgclqVb6oVK3j6I9Vesg0+oojW2XxB59SGg==",
"version": "1.4.6",
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz",
"integrity": "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==",
"license": "MIT",
"dependencies": {
"sparse-bitfield": "^3.0.3"
@@ -3265,11 +3277,6 @@
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
},
"node_modules/@polka/url": {
"version": "1.0.0-next.25",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz",
"integrity": "sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ=="
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
@@ -3932,17 +3939,6 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/acorn-walk": {
"version": "8.3.3",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.3.tgz",
"integrity": "sha512-MxXdReSRhGO7VlFe1bRG/oI7/mdLV9B9JJT0N8vZOhF7gFRR5l3M8W9G8JxmKV+JC5mGqJ0QvqfSOLsCPa4nUw==",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@@ -6316,6 +6312,7 @@
"resolved": "https://registry.npmjs.org/bl/-/bl-2.2.1.tgz",
"integrity": "sha512-6Pesp1w0DEX1N550i/uGV/TqucVL4AM/pgThFSN/Qq9si1/DF9aIHs1BxD8V/QU0HoeHO6cQRTAuYnLPKq1e4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"readable-stream": "^2.3.5",
"safe-buffer": "^5.1.1"
@@ -6325,13 +6322,15 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/bl/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dev": true,
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
@@ -6347,6 +6346,7 @@
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
@@ -7751,11 +7751,6 @@
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-0.0.3.tgz",
"integrity": "sha512-Cp+jOa8QJef5nXS5hU7M1DWzXPEIoVR3kbV0dQuVGwROZg8bGf1DcCnkmajBTnvghTtSNMUdRrPjgaT6ZQucbw=="
},
"node_modules/debounce": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
"integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug=="
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -11229,18 +11224,6 @@
"node": ">=12"
}
},
"node_modules/gcp-metadata": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz",
"integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==",
"dependencies": {
"gaxios": "^5.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -11984,6 +11967,19 @@
"node": ">=12"
}
},
"node_modules/google-auth-library/node_modules/gcp-metadata": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz",
"integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==",
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^5.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/google-auth-library/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@@ -12516,20 +12512,6 @@
"node": ">= 0.10"
}
},
"node_modules/gzip-size": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz",
"integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==",
"dependencies": {
"duplexer": "^0.1.2"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/habitica-markdown": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/habitica-markdown/-/habitica-markdown-4.1.0.tgz",
@@ -12819,6 +12801,7 @@
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-4.6.0.tgz",
"integrity": "sha512-HVqALKZlR95ROkrnesdhbbZJFi/rIVSoNq6f3jA/9u6MIbTsPh3xZwihjeI5+DO/2sOV6HMHooXcEOuwskHpTg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
@@ -12871,7 +12854,8 @@
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true
},
"node_modules/http-cache-semantics": {
"version": "4.1.1",
@@ -15557,128 +15541,14 @@
}
},
"node_modules/mongodb": {
"version": "3.7.4",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.7.4.tgz",
"integrity": "sha512-K5q8aBqEXMwWdVNh94UQTwZ6BejVbFhh1uB6c5FKtPE9eUMZPUO3sRZdgIEcHSrAWmxzpG/FeODDKL388sqRmw==",
"dev": true,
"dependencies": {
"bl": "^2.2.1",
"bson": "^1.1.4",
"denque": "^1.4.1",
"optional-require": "^1.1.8",
"safe-buffer": "^5.1.2"
},
"engines": {
"node": ">=4"
},
"optionalDependencies": {
"saslprep": "^1.0.0"
},
"peerDependenciesMeta": {
"aws4": {
"optional": true
},
"bson-ext": {
"optional": true
},
"kerberos": {
"optional": true
},
"mongodb-client-encryption": {
"optional": true
},
"mongodb-extjson": {
"optional": true
},
"snappy": {
"optional": true
}
}
},
"node_modules/mongodb-connection-string-url": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz",
"integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==",
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.20.0.tgz",
"integrity": "sha512-Tl6MEIU3K4Rq3TSHd+sZQqRBoGlFsOgNrH5ltAcFBV62Re3Fd+FcaVf8uSEQFOJ51SDowDVttBTONMfoYWrWlQ==",
"license": "Apache-2.0",
"dependencies": {
"@types/whatwg-url": "^11.0.2",
"whatwg-url": "^14.1.0 || ^13.0.0"
}
},
"node_modules/mongodb-connection-string-url/node_modules/tr46": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz",
"integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==",
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/mongodb-connection-string-url/node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/mongodb-connection-string-url/node_modules/whatwg-url": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.1.0.tgz",
"integrity": "sha512-jlf/foYIKywAt3x/XWKZ/3rz8OSJPiWktjmk891alJUEjiVxKX9LEO92qH3hv4aJ0mN3MWPvGMCy8jQi95xK4w==",
"license": "MIT",
"dependencies": {
"tr46": "^5.0.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/mongodb/node_modules/bson": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/bson/-/bson-1.1.6.tgz",
"integrity": "sha512-EvVNVeGo4tHxwi8L6bPj3y3itEvStdwvvlojVxxbyYfoaxJ6keLgrTuKdyfEAszFK+H3olzBuafE0yoh0D1gdg==",
"dev": true,
"engines": {
"node": ">=0.6.19"
}
},
"node_modules/mongoose": {
"version": "8.9.7",
"resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.9.7.tgz",
"integrity": "sha512-mvNXmU0V8qZzMR/qoK2mjT4Ti2ALdtfS0teK+twxhlGkwzOD76V02/zWajTu2MJ7QyEmZe9OWvnJsIY0iAuX3Q==",
"license": "MIT",
"dependencies": {
"bson": "^6.10.1",
"kareem": "2.6.3",
"mongodb": "~6.12.0",
"mpath": "0.9.0",
"mquery": "5.0.0",
"ms": "2.1.3",
"sift": "17.1.3"
},
"engines": {
"node": ">=16.20.1"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mongoose"
}
},
"node_modules/mongoose/node_modules/mongodb": {
"version": "6.12.0",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.12.0.tgz",
"integrity": "sha512-RM7AHlvYfS7jv7+BXund/kR64DryVI+cHbVAy9P61fnb1RcWZqOW1/Wj2YhqMCx+MuYhqTRGv7AwHBzmsCKBfA==",
"license": "Apache-2.0",
"dependencies": {
"@mongodb-js/saslprep": "^1.1.9",
"bson": "^6.10.1",
"mongodb-connection-string-url": "^3.0.0"
"@mongodb-js/saslprep": "^1.3.0",
"bson": "^6.10.4",
"mongodb-connection-string-url": "^3.0.2"
},
"engines": {
"node": ">=16.20.1"
@@ -15689,7 +15559,7 @@
"gcp-metadata": "^5.2.0",
"kerberos": "^2.0.1",
"mongodb-client-encryption": ">=6.0.0 <7",
"snappy": "^7.2.2",
"snappy": "^7.3.2",
"socks": "^2.7.1"
},
"peerDependenciesMeta": {
@@ -15716,6 +15586,72 @@
}
}
},
"node_modules/mongodb-connection-string-url": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz",
"integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==",
"license": "Apache-2.0",
"dependencies": {
"@types/whatwg-url": "^11.0.2",
"whatwg-url": "^14.1.0 || ^13.0.0"
}
},
"node_modules/mongodb-connection-string-url/node_modules/tr46": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/mongodb-connection-string-url/node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/mongodb-connection-string-url/node_modules/whatwg-url": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"license": "MIT",
"dependencies": {
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/mongoose": {
"version": "8.23.0",
"resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.23.0.tgz",
"integrity": "sha512-Bul4Ha6J8IqzFrb0B1xpVzkC3S0sk43dmLSnhFOn8eJlZiLwL5WO6cRymmjaADdCMjUcCpj2ce8hZI6O4ZFSug==",
"license": "MIT",
"dependencies": {
"bson": "^6.10.4",
"kareem": "2.6.3",
"mongodb": "~6.20.0",
"mpath": "0.9.0",
"mquery": "5.0.0",
"ms": "2.1.3",
"sift": "17.1.3"
},
"engines": {
"node": ">=16.20.1"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mongoose"
}
},
"node_modules/mongoose/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -15775,6 +15711,56 @@
"integrity": "sha512-jSTz73B/+pGTTvhu5Ym8xsG6+QqaWab53UXnXdNNlTijTdLvcHABCLJXudQiJxob5N1Mzr5EOSx5ziwn2sihPQ==",
"dev": true
},
"node_modules/monk/node_modules/bson": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/bson/-/bson-1.1.6.tgz",
"integrity": "sha512-EvVNVeGo4tHxwi8L6bPj3y3itEvStdwvvlojVxxbyYfoaxJ6keLgrTuKdyfEAszFK+H3olzBuafE0yoh0D1gdg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.6.19"
}
},
"node_modules/monk/node_modules/mongodb": {
"version": "3.7.4",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.7.4.tgz",
"integrity": "sha512-K5q8aBqEXMwWdVNh94UQTwZ6BejVbFhh1uB6c5FKtPE9eUMZPUO3sRZdgIEcHSrAWmxzpG/FeODDKL388sqRmw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"bl": "^2.2.1",
"bson": "^1.1.4",
"denque": "^1.4.1",
"optional-require": "^1.1.8",
"safe-buffer": "^5.1.2"
},
"engines": {
"node": ">=4"
},
"optionalDependencies": {
"saslprep": "^1.0.0"
},
"peerDependenciesMeta": {
"aws4": {
"optional": true
},
"bson-ext": {
"optional": true
},
"kerberos": {
"optional": true
},
"mongodb-client-encryption": {
"optional": true
},
"mongodb-extjson": {
"optional": true
},
"snappy": {
"optional": true
}
}
},
"node_modules/morgan": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
@@ -15852,14 +15838,6 @@
"node": ">=14.0.0"
}
},
"node_modules/mrmime": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz",
"integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==",
"engines": {
"node": ">=10"
}
},
"node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@@ -17128,19 +17106,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/opener": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
"integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==",
"bin": {
"opener": "bin/opener-bin.js"
}
},
"node_modules/optional-require": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/optional-require/-/optional-require-1.1.8.tgz",
"integrity": "sha512-jq83qaUb0wNg9Krv1c5OQ+58EK+vHde6aBPzLvPPqJm89UQWsvSuFy9X/OSNJnFeSOKo7btE0n8Nl2+nE+z5nA==",
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/optional-require/-/optional-require-1.1.10.tgz",
"integrity": "sha512-0r3OB9EIQsP+a5HVATHq2ExIy2q/Vaffoo4IAikW1spCYswhLxqWQS0i3GwS3AdY/OIP4SWZHLGz8CMU558PGw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"require-at": "^1.0.6"
},
@@ -18622,6 +18593,7 @@
"resolved": "https://registry.npmjs.org/require-at/-/require-at-1.0.6.tgz",
"integrity": "sha512-7i1auJbMUrXEAZCOQ0VNJgmcT2VOKPRl2YGJwgpHpC9CE91Mv4/4UYIUm4chGJaI381ZDq1JUicFii64Hapd8g==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=4"
}
@@ -18920,6 +18892,7 @@
"resolved": "https://registry.npmjs.org/saslprep/-/saslprep-1.0.3.tgz",
"integrity": "sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"sparse-bitfield": "^3.0.3"
@@ -19346,19 +19319,6 @@
"node": ">=8"
}
},
"node_modules/sirv": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz",
"integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==",
"dependencies": {
"@polka/url": "^1.0.0-next.24",
"mrmime": "^2.0.0",
"totalist": "^3.0.0"
},
"engines": {
"node": ">= 10"
}
},
"node_modules/slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
@@ -20849,14 +20809,6 @@
"node": ">=0.6"
}
},
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
"engines": {
"node": ">=6"
}
},
"node_modules/touch": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz",
@@ -21870,50 +21822,6 @@
}
}
},
"node_modules/webpack-bundle-analyzer": {
"version": "4.10.2",
"resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz",
"integrity": "sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==",
"dependencies": {
"@discoveryjs/json-ext": "0.5.7",
"acorn": "^8.0.4",
"acorn-walk": "^8.0.0",
"commander": "^7.2.0",
"debounce": "^1.2.1",
"escape-string-regexp": "^4.0.0",
"gzip-size": "^6.0.0",
"html-escaper": "^2.0.2",
"opener": "^1.5.2",
"picocolors": "^1.0.0",
"sirv": "^2.0.3",
"ws": "^7.3.1"
},
"bin": {
"webpack-bundle-analyzer": "lib/bin/analyzer.js"
},
"engines": {
"node": ">= 10.13.0"
}
},
"node_modules/webpack-bundle-analyzer/node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
"engines": {
"node": ">= 10"
}
},
"node_modules/webpack-bundle-analyzer/node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/webpack-cli": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.10.0.tgz",
@@ -22279,26 +22187,6 @@
"typedarray-to-buffer": "^3.1.5"
}
},
"node_modules/ws": {
"version": "7.5.10",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
"engines": {
"node": ">=8.3.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xml-crypto": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-0.10.1.tgz",
+2 -3
View File
@@ -1,7 +1,7 @@
{
"name": "habitica",
"description": "A habit tracker app which treats your goals like a Role Playing Game.",
"version": "5.46.4",
"version": "5.47.6",
"main": "./website/server/index.js",
"dependencies": {
"@babel/core": "^7.22.10",
@@ -52,7 +52,7 @@
"micromustache": "^8.0.3",
"moment": "^2.29.4",
"moment-recur": "git://github.com/HabitRPG/moment-recur.git#d3e8e6da0806f13b74dd2e4d7d9053e6a63db119",
"mongoose": "^8.9.5",
"mongoose": "^8.23.0",
"morgan": "^1.10.1",
"nan": "^2.25.0",
"nconf": "^0.12.1",
@@ -76,7 +76,6 @@
"useragent": "^2.1.9",
"uuid": "^9.0.0",
"validator": "^13.11.0",
"webpack-bundle-analyzer": "^4.10.2",
"winston": "^3.10.0",
"winston-loggly-bulk": "^3.3.0",
"xml2js": "^0.6.2"
-560
View File
@@ -1,560 +0,0 @@
/* eslint-disable camelcase */
import nconf from 'nconf';
import Amplitude from 'amplitude';
import * as analyticsService from '../../../../website/server/libs/analyticsService';
describe('analyticsService', () => {
beforeEach(() => {
sandbox.stub(Amplitude.prototype, 'track').returns(Promise.resolve());
});
afterEach(() => {
sandbox.restore();
});
describe('#getServiceByEnvironment', () => {
it('returns mock methods when not in production', () => {
sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(false);
expect(analyticsService.getAnalyticsServiceByEnvironment())
.to.equal(analyticsService.mockAnalyticsService);
});
it('returns real methods when in production', () => {
sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true);
expect(analyticsService.getAnalyticsServiceByEnvironment().track)
.to.equal(analyticsService.track);
expect(analyticsService.getAnalyticsServiceByEnvironment().trackPurchase)
.to.equal(analyticsService.trackPurchase);
});
});
describe('#track', () => {
let eventType; let
data;
beforeEach(() => {
eventType = 'Cron';
data = {
category: 'behavior',
uuid: 'unique-user-id',
resting: true,
cronCount: 5,
headers: {
'x-client': 'habitica-web',
'user-agent': '',
},
user: {
preferences: {
analyticsConsent: true,
},
},
};
});
context('Amplitude', () => {
it('calls out to amplitude', () => analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledOnce;
}));
it('uses a dummy user id if none is provided', () => {
delete data.uuid;
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
user_id: 'no-user-id-was-provided',
});
});
});
context('platform', () => {
it('logs web platform', () => {
data.headers = { 'x-client': 'habitica-web' };
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: 'Web',
});
});
});
it('logs iOS platform', () => {
data.headers = { 'x-client': 'habitica-ios' };
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: 'iOS',
});
});
});
it('logs Android platform', () => {
data.headers = { 'x-client': 'habitica-android' };
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: 'Android',
});
});
});
it('logs 3rd Party platform', () => {
data.headers = { 'x-client': 'some-third-party' };
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: '3rd Party',
});
});
});
it('logs unknown if headers are not passed in', () => {
delete data.headers;
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: 'Unknown',
});
});
});
});
context('Operating System', () => {
it('sets default', () => {
data.headers = {
'x-client': 'third-party',
'user-agent': 'foo',
};
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
os_name: 'Other',
os_version: '0',
});
});
});
it('sets iOS', () => {
data.headers = {
'x-client': 'habitica-ios',
'user-agent': 'Habitica/148 (iPhone; iOS 9.3; Scale/2.00)',
};
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
os_name: 'iOS',
os_version: '9.3.0',
});
});
});
it('sets Android', () => {
data.headers = {
'x-client': 'habitica-android',
'user-agent': 'Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.133 Mobile Safari/535.19',
};
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
os_name: 'Android',
os_version: '4.0.4',
});
});
});
it('sets Unknown if headers are not passed in', () => {
delete data.headers;
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
os_name: undefined,
os_version: undefined,
});
});
});
});
it('sends details about event', () => analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
event_properties: {
category: 'behavior',
resting: true,
cronCount: 5,
},
});
}));
it('sends english item name for gear if itemKey is provided', () => {
data.itemKey = 'headAccessory_special_foxEars';
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
event_properties: {
itemKey: data.itemKey,
itemName: 'Fox Ears',
},
});
});
});
it('sends english item name for egg if itemKey is provided', () => {
data.itemKey = 'Wolf';
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
event_properties: {
itemKey: data.itemKey,
itemName: 'Wolf Egg',
},
});
});
});
it('sends english item name for food if itemKey is provided', () => {
data.itemKey = 'Cake_Skeleton';
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
event_properties: {
itemKey: data.itemKey,
itemName: 'Bare Bones Cake',
},
});
});
});
it('sends english item name for hatching potion if itemKey is provided', () => {
data.itemKey = 'Golden';
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
event_properties: {
itemKey: data.itemKey,
itemName: 'Golden Hatching Potion',
},
});
});
});
it('sends english item name for quest if itemKey is provided', () => {
data.itemKey = 'atom1';
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
event_properties: {
itemKey: data.itemKey,
itemName: 'Attack of the Mundane, Part 1: Dish Disaster!',
},
});
});
});
it('sends english item name for purchased spell if itemKey is provided', () => {
data.itemKey = 'seafoam';
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
event_properties: {
itemKey: data.itemKey,
itemName: 'Seafoam',
},
});
});
});
it('sends user data if provided', () => {
const stats = {
class: 'wizard', exp: 5, gp: 23, hp: 10, lvl: 4, mp: 30,
};
const user = {
stats,
contributor: { level: 1 },
purchased: { plan: { planId: 'foo-plan' } },
flags: { tour: { intro: -2 } },
habits: [{ _id: 'habit' }],
dailys: [{ _id: 'daily' }],
todos: [{ _id: 'todo' }],
rewards: [{ _id: 'reward' }],
balance: 12,
loginIncentives: 1,
preferences: {
analyticsConsent: true,
},
};
data.user = user;
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
user_properties: {
Class: 'wizard',
Experience: 5,
Gold: 23,
Health: 10,
Level: 4,
Mana: 30,
tutorialComplete: true,
'Number Of Tasks': {
habits: 1,
dailys: 1,
todos: 1,
rewards: 1,
},
contributorLevel: 1,
subscription: 'foo-plan',
balance: 12,
balanceGemAmount: 48,
loginIncentives: 1,
},
});
});
});
});
});
describe('#trackPurchase', () => {
let data;
beforeEach(() => {
data = {
uuid: 'user-id',
sku: 'paypal-checkout',
paymentMethod: 'PayPal',
itemPurchased: 'Gems',
purchaseValue: 8,
purchaseType: 'checkout',
gift: false,
quantity: 1,
headers: {
'x-client': 'habitica-web',
'user-agent': '',
},
user: {
preferences: {
analyticsConsent: true,
},
},
};
});
context('Amplitude', () => {
it('calls out to amplitude', () => analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledOnce;
}));
it('uses a dummy user id if none is provided', () => {
delete data.uuid;
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
user_id: 'no-user-id-was-provided',
});
});
});
context('platform', () => {
it('logs web platform', () => {
data.headers = { 'x-client': 'habitica-web' };
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: 'Web',
});
});
});
it('logs iOS platform', () => {
data.headers = { 'x-client': 'habitica-ios' };
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: 'iOS',
});
});
});
it('logs Android platform', () => {
data.headers = { 'x-client': 'habitica-android' };
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: 'Android',
});
});
});
it('logs 3rd Party platform', () => {
data.headers = { 'x-client': 'some-third-party' };
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: '3rd Party',
});
});
});
it('logs unknown if headers are not passed in', () => {
delete data.headers;
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: 'Unknown',
});
});
});
});
context('Operating System', () => {
it('sets default', () => {
data.headers = {
'x-client': 'third-party',
'user-agent': 'foo',
};
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
os_name: 'Other',
os_version: '0',
});
});
});
it('sets iOS', () => {
data.headers = {
'x-client': 'habitica-ios',
'user-agent': 'Habitica/148 (iPhone; iOS 9.3; Scale/2.00)',
};
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
os_name: 'iOS',
os_version: '9.3.0',
});
});
});
it('sets Android', () => {
data.headers = {
'x-client': 'habitica-android',
'user-agent': 'Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.133 Mobile Safari/535.19',
};
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
os_name: 'Android',
os_version: '4.0.4',
});
});
});
it('sets Unknown if headers are not passed in', () => {
delete data.headers;
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
os_name: undefined,
os_version: undefined,
});
});
});
});
it('sends details about purchase', () => analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
event_properties: {
gift: false,
itemPurchased: 'Gems',
paymentMethod: 'PayPal',
purchaseType: 'checkout',
quantity: 1,
sku: 'paypal-checkout',
},
});
}));
it('sends user data if provided', () => {
const stats = {
class: 'wizard', exp: 5, gp: 23, hp: 10, lvl: 4, mp: 30,
};
const user = {
stats,
contributor: { level: 1 },
purchased: { plan: { planId: 'foo-plan' } },
flags: { tour: { intro: -2 } },
habits: [{ _id: 'habit' }],
dailys: [{ _id: 'daily' }],
todos: [{ _id: 'todo' }],
rewards: [{ _id: 'reward' }],
preferences: {
analyticsConsent: true,
},
};
data.user = user;
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
user_properties: {
Class: 'wizard',
Experience: 5,
Gold: 23,
Health: 10,
Level: 4,
Mana: 30,
tutorialComplete: true,
'Number Of Tasks': {
habits: 1,
dailys: 1,
todos: 1,
rewards: 1,
},
contributorLevel: 1,
subscription: 'foo-plan',
},
});
});
});
});
});
describe('mockAnalyticsService', () => {
it('has stubbed track method', () => {
expect(analyticsService.mockAnalyticsService).to.respondTo('track');
});
it('has stubbed trackPurchase method', () => {
expect(analyticsService.mockAnalyticsService).to.respondTo('trackPurchase');
});
});
});
+116 -136
View File
@@ -13,7 +13,6 @@ import { cron, cronWrapper } from '../../../../website/server/libs/cron';
import { model as User } from '../../../../website/server/models/user';
import * as Tasks from '../../../../website/server/models/task';
import common from '../../../../website/common';
import * as analytics from '../../../../website/server/libs/analyticsService';
import { model as Group } from '../../../../website/server/models/group';
const CRON_TIMEOUT_WAIT = new Date(5 * 60 * 1000).getTime();
@@ -41,20 +40,17 @@ describe('cron', async () => {
},
},
});
sinon.spy(analytics, 'track');
});
afterEach(async () => {
if (clock !== null) clock.restore();
analytics.track.restore();
});
it('updates user.preferences.timezoneOffsetAtLastCron', async () => {
const timezoneUtcOffsetFromUserPrefs = -1;
await cron({
user, tasksByType, daysMissed, analytics, timezoneUtcOffsetFromUserPrefs,
user, tasksByType, daysMissed, timezoneUtcOffsetFromUserPrefs,
});
expect(user.preferences.timezoneOffsetAtLastCron).to.equal(1);
@@ -63,7 +59,7 @@ describe('cron', async () => {
it('resets user.items.lastDrop.count', async () => {
user.items.lastDrop.count = 4;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.items.lastDrop.count).to.equal(0);
});
@@ -71,26 +67,11 @@ describe('cron', async () => {
it('increments user cron count', async () => {
const cronCountBefore = user.flags.cronCount;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.flags.cronCount).to.be.greaterThan(cronCountBefore);
});
it('calls analytics', async () => {
await cron({
user, tasksByType, daysMissed, analytics,
});
expect(analytics.track.callCount).to.equal(1);
});
it('calls analytics when user is sleeping', async () => {
user.preferences.sleep = true;
await cron({
user, tasksByType, daysMissed, analytics,
});
expect(analytics.track.callCount).to.equal(1);
});
describe('end of the month perks', async () => {
beforeEach(async () => {
user.purchased.plan.customerId = 'subscribedId';
@@ -101,7 +82,7 @@ describe('cron', async () => {
user.purchased.plan.dateUpdated = new Date('2018-12-11');
clock = sinon.useFakeTimers(new Date('2019-01-29'));
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.mysteryItems.length).to.eql(2);
const filteredNotifications = user.notifications.filter(n => n.type === 'NEW_MYSTERY_ITEMS');
@@ -112,7 +93,7 @@ describe('cron', async () => {
user.purchased.plan.dateUpdated = new Date('2018-11-11');
clock = sinon.useFakeTimers(new Date('2019-01-29'));
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.mysteryItems.length).to.eql(4);
const filteredNotifications = user.notifications.filter(n => n.type === 'NEW_MYSTERY_ITEMS');
@@ -122,7 +103,7 @@ describe('cron', async () => {
it('resets plan.gemsBought on a new month', async () => {
user.purchased.plan.gemsBought = 10;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.gemsBought).to.equal(0);
});
@@ -131,7 +112,7 @@ describe('cron', async () => {
user.purchased.plan.gemsBought = 10;
user.purchased.plan.dateUpdated = undefined;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.gemsBought).to.equal(0);
});
@@ -142,7 +123,7 @@ describe('cron', async () => {
user.purchased.plan.gemsBought = 10;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.gemsBought).to.equal(10);
});
@@ -150,7 +131,7 @@ describe('cron', async () => {
it('resets plan.dateUpdated on a new month', async () => {
const currentMonth = moment().startOf('month');
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(moment(user.purchased.plan.dateUpdated).startOf('month').isSame(currentMonth)).to.eql(true);
});
@@ -158,7 +139,7 @@ describe('cron', async () => {
it('increments plan.consecutive.count', async () => {
user.purchased.plan.consecutive.count = 0;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.consecutive.count).to.equal(1);
});
@@ -166,7 +147,7 @@ describe('cron', async () => {
it('increments plan.cumulativeCount', async () => {
user.purchased.plan.cumulativeCount = 0;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.cumulativeCount).to.equal(1);
});
@@ -175,7 +156,7 @@ describe('cron', async () => {
user.purchased.plan.dateUpdated = moment().subtract(2, 'months').toDate();
user.purchased.plan.consecutive.count = 0;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.consecutive.count).to.equal(2);
});
@@ -184,7 +165,7 @@ describe('cron', async () => {
user.purchased.plan.dateUpdated = moment().subtract(3, 'months').toDate();
user.purchased.plan.cumulativeCount = 0;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.cumulativeCount).to.equal(3);
});
@@ -196,7 +177,7 @@ describe('cron', async () => {
user.purchased.plan.consecutive.trinkets = 1;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.consecutive.trinkets).to.equal(1);
@@ -206,7 +187,7 @@ describe('cron', async () => {
user.purchased.plan.consecutive.gemCapExtra = 26;
user.purchased.plan.consecutive.count = 5;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.consecutive.gemCapExtra).to.equal(26);
});
@@ -214,7 +195,7 @@ describe('cron', async () => {
it('does not reset plan stats if we are before the last day of the cancelled month', async () => {
user.purchased.plan.dateTerminated = moment(new Date()).add({ days: 1 });
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.customerId).to.exist;
});
@@ -225,7 +206,7 @@ describe('cron', async () => {
user.purchased.plan.consecutive.count = 5;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.customerId).to.not.exist;
@@ -264,7 +245,7 @@ describe('cron', async () => {
// Add 2 days so that we're sure we're not affected by any start-of-month effects
// e.g., from time zone oddness.
await cron({
user: user1, tasksByType, daysMissed, analytics,
user: user1, tasksByType, daysMissed,
});
expect(user1.purchased.plan.consecutive.count).to.equal(1);
expect(user1.purchased.plan.consecutive.trinkets).to.equal(2);
@@ -276,7 +257,7 @@ describe('cron', async () => {
.add(2, 'days')
.toDate());
await cron({
user: user1, tasksByType, daysMissed, analytics,
user: user1, tasksByType, daysMissed,
});
expect(user1.purchased.plan.consecutive.count).to.equal(10);
expect(user1.purchased.plan.consecutive.trinkets).to.equal(11);
@@ -311,7 +292,7 @@ describe('cron', async () => {
.add(2, 'days')
.toDate());
await cron({
user: user3, tasksByType, daysMissed, analytics,
user: user3, tasksByType, daysMissed,
});
expect(user3.purchased.plan.consecutive.count).to.equal(1);
expect(user3.purchased.plan.consecutive.trinkets).to.equal(2);
@@ -323,7 +304,7 @@ describe('cron', async () => {
.add(2, 'days')
.toDate());
await cron({
user: user3, tasksByType, daysMissed, analytics,
user: user3, tasksByType, daysMissed,
});
expect(user3.purchased.plan.consecutive.count).to.equal(10);
expect(user3.purchased.plan.consecutive.trinkets).to.equal(11);
@@ -358,7 +339,7 @@ describe('cron', async () => {
.add(2, 'days')
.toDate());
await cron({
user: user6, tasksByType, daysMissed, analytics,
user: user6, tasksByType, daysMissed,
});
expect(user6.purchased.plan.consecutive.count).to.equal(1);
expect(user6.purchased.plan.consecutive.trinkets).to.equal(2);
@@ -391,7 +372,7 @@ describe('cron', async () => {
.add(2, 'days')
.toDate());
await cron({
user: user12, tasksByType, daysMissed, analytics,
user: user12, tasksByType, daysMissed,
});
expect(user12.purchased.plan.consecutive.count).to.equal(1);
expect(user12.purchased.plan.consecutive.trinkets).to.equal(2);
@@ -403,7 +384,7 @@ describe('cron', async () => {
.add(2, 'days')
.toDate());
await cron({
user: user12, tasksByType, daysMissed, analytics,
user: user12, tasksByType, daysMissed,
});
expect(user12.purchased.plan.consecutive.count).to.equal(10);
expect(user12.purchased.plan.consecutive.trinkets).to.equal(11);
@@ -439,7 +420,7 @@ describe('cron', async () => {
.add(2, 'days')
.toDate());
await cron({
user: user3g, tasksByType, daysMissed, analytics,
user: user3g, tasksByType, daysMissed,
});
expect(user3g.purchased.plan.consecutive.count).to.equal(1);
expect(user3g.purchased.plan.cumulativeCount).to.equal(1);
@@ -452,7 +433,7 @@ describe('cron', async () => {
.add(2, 'days')
.toDate());
await cron({
user: user3g, tasksByType, daysMissed, analytics,
user: user3g, tasksByType, daysMissed,
});
// subscription has been erased by now
expect(user3g.purchased.plan.consecutive.count).to.equal(0);
@@ -471,7 +452,7 @@ describe('cron', async () => {
it('resets plan.gemsBought on a new month', async () => {
user.purchased.plan.gemsBought = 10;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.gemsBought).to.equal(0);
});
@@ -482,14 +463,14 @@ describe('cron', async () => {
user.purchased.plan.gemsBought = 10;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.gemsBought).to.equal(10);
});
it('does not reset plan.dateUpdated on a new month', async () => {
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.dateUpdated).to.be.empty;
});
@@ -497,7 +478,7 @@ describe('cron', async () => {
it('does not increment plan.consecutive.count', async () => {
user.purchased.plan.consecutive.count = 0;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.consecutive.count).to.equal(0);
});
@@ -505,7 +486,7 @@ describe('cron', async () => {
it('does not increment plan.cumulativeCount', async () => {
user.purchased.plan.cumulativeCount = 0;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.cumulativeCount).to.equal(0);
});
@@ -513,7 +494,7 @@ describe('cron', async () => {
it('does not increment plan.consecutive.trinkets when user has reached a month that is a multiple of 3', async () => {
user.purchased.plan.consecutive.count = 5;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.consecutive.trinkets).to.equal(0);
});
@@ -521,7 +502,7 @@ describe('cron', async () => {
it('does not increment plan.consecutive.gemCapExtra when user has reached a month that is a multiple of 3', async () => {
user.purchased.plan.consecutive.count = 5;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.consecutive.gemCapExtra).to.equal(0);
});
@@ -530,7 +511,7 @@ describe('cron', async () => {
user.purchased.plan.consecutive.gemCapExtra = 26;
user.purchased.plan.consecutive.count = 5;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.consecutive.gemCapExtra).to.equal(26);
});
@@ -538,7 +519,7 @@ describe('cron', async () => {
it('does nothing to plan stats if we are before the last day of the cancelled month', async () => {
user.purchased.plan.dateTerminated = moment(new Date()).add({ days: 1 });
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.purchased.plan.customerId).to.not.exist;
});
@@ -564,7 +545,7 @@ describe('cron', async () => {
it('should make uncompleted todos redder', async () => {
const valueBefore = tasksByType.todos[0].value;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.todos[0].value).to.be.lessThan(valueBefore);
});
@@ -573,7 +554,7 @@ describe('cron', async () => {
tasksByType.todos[0].completed = true;
const valueBefore = tasksByType.todos[0].value;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.todos[0].value).to.equal(valueBefore);
});
@@ -582,7 +563,7 @@ describe('cron', async () => {
tasksByType.todos[0].completed = true;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.history.todos).to.be.lengthOf(1);
@@ -608,7 +589,7 @@ describe('cron', async () => {
expect(user.tasksOrder.todos).to.be.lengthOf(3);
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
// user.tasksOrder.todos should be filtered while tasks by type remains unchanged
@@ -635,7 +616,7 @@ describe('cron', async () => {
const original = user.tasksOrder.todos; // Preserve the original order
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
let listsAreEqual = true;
@@ -675,7 +656,7 @@ describe('cron', async () => {
tasksByType.dailys[0].everyX = 5;
tasksByType.dailys[0].startDate = moment().add(1, 'days').toDate();
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.dailys[0].isDue).to.be.false;
});
@@ -686,7 +667,7 @@ describe('cron', async () => {
tasksByType.dailys[0].everyX = 5;
tasksByType.dailys[0].startDate = moment().toDate();
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.dailys[0].isDue).to.exist;
});
@@ -696,14 +677,14 @@ describe('cron', async () => {
tasksByType.dailys[0].everyX = 5;
tasksByType.dailys[0].startDate = moment().add(1, 'days').toDate();
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.dailys[0].nextDue.length).to.eql(6);
});
it('should add history', async () => {
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.dailys[0].history).to.be.lengthOf(1);
});
@@ -711,7 +692,7 @@ describe('cron', async () => {
it('should set tasks completed to false', async () => {
tasksByType.dailys[0].completed = true;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.dailys[0].completed).to.be.false;
});
@@ -720,7 +701,7 @@ describe('cron', async () => {
user.preferences.sleep = true;
tasksByType.dailys[0].completed = true;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.dailys[0].completed).to.be.false;
});
@@ -729,7 +710,7 @@ describe('cron', async () => {
tasksByType.dailys[0].checklist.push({ title: 'test', completed: false });
tasksByType.dailys[0].completed = true;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.dailys[0].checklist[0].completed).to.be.false;
});
@@ -739,7 +720,7 @@ describe('cron', async () => {
tasksByType.dailys[0].checklist.push({ title: 'test', completed: false });
tasksByType.dailys[0].completed = true;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.dailys[0].checklist[0].completed).to.be.false;
});
@@ -749,7 +730,7 @@ describe('cron', async () => {
tasksByType.dailys[0].checklist.push({ title: 'test', completed: false });
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.dailys[0].checklist[0].completed).to.be.false;
});
@@ -759,7 +740,7 @@ describe('cron', async () => {
const hpBefore = user.stats.hp;
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.stats.hp).to.be.lessThan(hpBefore);
});
@@ -770,7 +751,7 @@ describe('cron', async () => {
const hpBefore = user.stats.hp;
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.stats.hp).to.equal(hpBefore);
});
@@ -784,7 +765,7 @@ describe('cron', async () => {
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
cronOverride({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.stats.hp).to.equal(hpBefore);
@@ -797,7 +778,7 @@ describe('cron', async () => {
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.stats.hp).to.equal(hpBefore);
@@ -808,7 +789,7 @@ describe('cron', async () => {
let hpBefore = user.stats.hp;
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
const hpDifferenceOfFullyIncompleteDaily = hpBefore - user.stats.hp;
@@ -816,7 +797,7 @@ describe('cron', async () => {
tasksByType.dailys[0].checklist.push({ title: 'test', completed: true });
tasksByType.dailys[0].checklist.push({ title: 'test2', completed: false });
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
const hpDifferenceOfPartiallyIncompleteDaily = hpBefore - user.stats.hp;
@@ -829,7 +810,7 @@ describe('cron', async () => {
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
const progress = await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(progress.down).to.equal(-1);
@@ -841,7 +822,7 @@ describe('cron', async () => {
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
const progress = await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(progress.down).to.equal(0);
@@ -862,7 +843,7 @@ describe('cron', async () => {
tasksByType.dailys[1].frequency = 'daily';
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.stats.hp).to.equal(48);
@@ -886,7 +867,7 @@ describe('cron', async () => {
tasksByType.habits[0].down = false;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.habits[0].value).to.be.lessThan(1);
@@ -897,7 +878,7 @@ describe('cron', async () => {
tasksByType.habits[0].up = false;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.habits[0].value).to.be.lessThan(1);
@@ -909,7 +890,7 @@ describe('cron', async () => {
tasksByType.habits[0].down = true;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.habits[0].value).to.equal(1);
@@ -928,7 +909,7 @@ describe('cron', async () => {
tasksByType.habits[0].counterDown = 1;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.habits[0].counterUp).to.equal(0);
@@ -941,7 +922,7 @@ describe('cron', async () => {
tasksByType.habits[0].counterDown = 1;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.habits[0].counterUp).to.equal(0);
@@ -955,7 +936,7 @@ describe('cron', async () => {
// should not reset
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.habits[0].counterUp).to.equal(1);
@@ -964,7 +945,7 @@ describe('cron', async () => {
// should reset
daysMissed = 8;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.habits[0].counterUp).to.equal(0);
@@ -988,7 +969,7 @@ describe('cron', async () => {
// should not reset
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.habits[0].counterUp).to.equal(1);
@@ -1002,7 +983,7 @@ describe('cron', async () => {
// should reset after user CDS
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.habits[0].counterUp).to.equal(0);
@@ -1026,7 +1007,7 @@ describe('cron', async () => {
// should not reset
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.habits[0].counterUp).to.equal(1);
@@ -1036,7 +1017,7 @@ describe('cron', async () => {
// should reset
daysMissed = 2;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.habits[0].counterUp).to.equal(0);
@@ -1060,7 +1041,7 @@ describe('cron', async () => {
// should reset
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.habits[0].counterUp).to.equal(0);
@@ -1084,7 +1065,7 @@ describe('cron', async () => {
// should not reset
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.habits[0].counterUp).to.equal(1);
@@ -1098,7 +1079,7 @@ describe('cron', async () => {
// should not reset
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.habits[0].counterUp).to.equal(1);
@@ -1107,7 +1088,7 @@ describe('cron', async () => {
// should reset
daysMissed = 32;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.habits[0].counterUp).to.equal(0);
@@ -1132,7 +1113,7 @@ describe('cron', async () => {
// should reset
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.habits[0].counterUp).to.equal(0);
@@ -1156,7 +1137,7 @@ describe('cron', async () => {
// should not reset
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.habits[0].counterUp).to.equal(1);
@@ -1166,7 +1147,7 @@ describe('cron', async () => {
// should reset
daysMissed = 2;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(tasksByType.habits[0].counterUp).to.equal(0);
@@ -1199,7 +1180,7 @@ describe('cron', async () => {
user.stats.lvl = 2;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.history.exp).to.have.lengthOf(1);
@@ -1212,7 +1193,7 @@ describe('cron', async () => {
tasksByType.dailys[0].isDue = true;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.achievements.perfect).to.equal(1);
@@ -1224,7 +1205,7 @@ describe('cron', async () => {
tasksByType.dailys[0].isDue = false;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.achievements.perfect).to.equal(0);
@@ -1238,7 +1219,7 @@ describe('cron', async () => {
const previousBuffs = user.stats.buffs.toObject();
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.stats.buffs.str).to.be.greaterThan(previousBuffs.str);
@@ -1256,7 +1237,7 @@ describe('cron', async () => {
const previousBuffs = user.stats.buffs.toObject();
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.stats.buffs.str).to.be.greaterThan(previousBuffs.str);
@@ -1280,7 +1261,7 @@ describe('cron', async () => {
};
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.stats.buffs.str).to.equal(0);
@@ -1307,7 +1288,7 @@ describe('cron', async () => {
};
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.stats.buffs.str).to.equal(0);
@@ -1333,7 +1314,7 @@ describe('cron', async () => {
};
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.stats.buffs.str).to.equal(0);
@@ -1360,7 +1341,7 @@ describe('cron', async () => {
};
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.stats.buffs.str).to.equal(0);
@@ -1381,7 +1362,7 @@ describe('cron', async () => {
const previousBuffs = user.stats.buffs.toObject();
cronOverride({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.stats.buffs.str).to.be.greaterThan(previousBuffs.str);
@@ -1401,7 +1382,7 @@ describe('cron', async () => {
const previousBuffs = user.stats.buffs.toObject();
cronOverride({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.stats.buffs.str).to.be.greaterThan(previousBuffs.str);
@@ -1420,7 +1401,7 @@ describe('cron', async () => {
tasksByType.dailys[0].completed = true;
stubbedStatsComputed.returns(Object.assign(statsComputedRes, { maxMP: 100 }));
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.stats.mp).to.be.greaterThan(mpBefore);
@@ -1436,7 +1417,7 @@ describe('cron', async () => {
tasksByType.dailys[0].completed = true;
stubbedStatsComputed.returns(Object.assign(statsComputedRes, { maxMP: 100 }));
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.stats.mp).to.equal(mpBefore);
@@ -1449,7 +1430,7 @@ describe('cron', async () => {
user.stats.mp = 120;
stubbedStatsComputed.returns(Object.assign(statsComputedRes, { maxMP: 100 }));
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.stats.mp).to.equal(common.statsComputed(user).maxMP);
@@ -1482,7 +1463,7 @@ describe('cron', async () => {
it('resets user progress', async () => {
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.party.quest.progress.up).to.equal(0);
expect(user.party.quest.progress.down).to.equal(0);
@@ -1491,7 +1472,7 @@ describe('cron', async () => {
it('applies the user progress', async () => {
const progress = await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(progress.down).to.equal(-1);
});
@@ -1529,19 +1510,19 @@ describe('cron', async () => {
describe('login incentives', async () => {
it('increments incentive counter each cron', async () => {
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(1);
user.lastCron = moment(new Date()).subtract({ days: 1 });
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(2);
});
it('pushes a notification of the day\'s incentive each cron', async () => {
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.notifications.length).to.eql(1);
expect(user.notifications[0].type).to.eql('LOGIN_INCENTIVE');
@@ -1549,13 +1530,13 @@ describe('cron', async () => {
it('replaces previous notifications', async () => {
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
const filteredNotifications = user.notifications.filter(n => n.type === 'LOGIN_INCENTIVE');
@@ -1566,7 +1547,7 @@ describe('cron', async () => {
it('increments loginIncentives by 1 even if days are skipped in between', async () => {
daysMissed = 3;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(1);
});
@@ -1574,14 +1555,14 @@ describe('cron', async () => {
it('increments loginIncentives by 1 even if user is sleeping', async () => {
user.preferences.sleep = true;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(1);
});
it('awards user bard robes if login incentive is 1', async () => {
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(1);
expect(user.items.gear.owned.armor_special_bardRobes).to.eql(true);
@@ -1591,7 +1572,7 @@ describe('cron', async () => {
it('awards user incentive backgrounds if login incentive is 2', async () => {
user.loginIncentives = 1;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(2);
expect(user.purchased.background.blue).to.eql(true);
@@ -1605,7 +1586,7 @@ describe('cron', async () => {
it('awards user Bard Hat if login incentive is 3', async () => {
user.loginIncentives = 2;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(3);
expect(user.items.gear.owned.head_special_bardHat).to.eql(true);
@@ -1615,7 +1596,7 @@ describe('cron', async () => {
it('awards user RoyalPurple Hatching Potion if login incentive is 4', async () => {
user.loginIncentives = 3;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(4);
expect(user.items.hatchingPotions.RoyalPurple).to.eql(1);
@@ -1625,7 +1606,7 @@ describe('cron', async () => {
it('awards user a Chocolate, Meat and Pink Contton Candy if login incentive is 5', async () => {
user.loginIncentives = 4;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(5);
@@ -1639,7 +1620,7 @@ describe('cron', async () => {
it('awards user moon quest if login incentive is 7', async () => {
user.loginIncentives = 6;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(7);
expect(user.items.quests.moon1).to.eql(1);
@@ -1649,7 +1630,7 @@ describe('cron', async () => {
it('awards user RoyalPurple Hatching Potion if login incentive is 10', async () => {
user.loginIncentives = 9;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(10);
expect(user.items.hatchingPotions.RoyalPurple).to.eql(1);
@@ -1659,7 +1640,7 @@ describe('cron', async () => {
it('awards user a Strawberry, Patato and Blue Contton Candy if login incentive is 14', async () => {
user.loginIncentives = 13;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(14);
@@ -1673,7 +1654,7 @@ describe('cron', async () => {
it('awards user a bard instrument if login incentive is 18', async () => {
user.loginIncentives = 17;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(18);
expect(user.items.gear.owned.weapon_special_bardInstrument).to.eql(true);
@@ -1683,7 +1664,7 @@ describe('cron', async () => {
it('awards user second moon quest if login incentive is 22', async () => {
user.loginIncentives = 21;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(22);
expect(user.items.quests.moon2).to.eql(1);
@@ -1693,7 +1674,7 @@ describe('cron', async () => {
it('awards user a RoyalPurple hatching potion if login incentive is 26', async () => {
user.loginIncentives = 25;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(26);
expect(user.items.hatchingPotions.RoyalPurple).to.eql(1);
@@ -1703,7 +1684,7 @@ describe('cron', async () => {
it('awards user Fish, Milk, Rotten Meat and Honey if login incentive is 30', async () => {
user.loginIncentives = 29;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(30);
@@ -1718,7 +1699,7 @@ describe('cron', async () => {
it('awards user a RoyalPurple hatching potion if login incentive is 35', async () => {
user.loginIncentives = 34;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(35);
expect(user.items.hatchingPotions.RoyalPurple).to.eql(1);
@@ -1728,7 +1709,7 @@ describe('cron', async () => {
it('awards user the third moon quest if login incentive is 40', async () => {
user.loginIncentives = 39;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(40);
expect(user.items.quests.moon3).to.eql(1);
@@ -1738,7 +1719,7 @@ describe('cron', async () => {
it('awards user a RoyalPurple hatching potion if login incentive is 45', async () => {
user.loginIncentives = 44;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(45);
expect(user.items.hatchingPotions.RoyalPurple).to.eql(1);
@@ -1748,7 +1729,7 @@ describe('cron', async () => {
it('awards user a saddle if login incentive is 50', async () => {
user.loginIncentives = 49;
await cron({
user, tasksByType, daysMissed, analytics,
user, tasksByType, daysMissed,
});
expect(user.loginIncentives).to.eql(50);
expect(user.items.food.Saddle).to.eql(1);
@@ -1766,7 +1747,6 @@ describe('cron wrapper', () => {
res = generateRes();
req = generateReq();
user = await res.locals.user.save();
res.analytics = analytics;
});
afterEach(() => {
+100
View File
@@ -0,0 +1,100 @@
import nconf from 'nconf';
import requireAgain from 'require-again';
import { model as User } from '../../../../website/server/models/user';
import { RegistrationEventModel } from '../../../../website/server/models/analytics/registrationEvent';
import { SubscriptionEventModel } from '../../../../website/server/models/analytics/subscriptionEvent';
describe('localAnalytics', () => {
let user;
let localAnalytics;
before(() => {
const nconfGetStub = sandbox.stub(nconf, 'get');
nconfGetStub.withArgs('ANALYTICS_DB').returns('analytics');
nconfGetStub.withArgs('DISABLE_LOCAL_ANALYTICS').returns(false);
localAnalytics = requireAgain('../../../../website/server/libs/localAnalytics');
});
beforeEach(async () => {
user = new User({
auth: {
local: {
username: 'username',
email: 'email@example.com',
},
},
registeredThrough: 'habitica-web',
});
});
describe('trackRegistrationEvent', () => {
afterEach(async () => {
await RegistrationEventModel.deleteMany({});
});
it('creates a registration event when a user registers', async () => {
user._id = '00000000-0000-0000-0000-000000000001';
await localAnalytics.trackRegistrationEvent({ user, ipAddress: '127.0.0.1' });
const registrationEvents = await RegistrationEventModel.find({ userId: user._id });
expect(registrationEvents).to.have.lengthOf(1);
expect(registrationEvents[0]).to.have.property('userId', user._id);
expect(registrationEvents[0]).to.have.property('ipAddress', '127.0.0.1');
});
it('saves the correct data to the database', async () => {
user._id = '00000000-0000-0000-0000-000000000002';
user.auth.google = { id: 'abc', emails: [{ value: 'email@example.com' }] };
await localAnalytics.trackRegistrationEvent({ user, ipAddress: '127.0.0.2' });
const registrationEvent = await RegistrationEventModel.findOne({ userId: user._id });
expect(registrationEvent).to.have.property('userId', user._id);
expect(registrationEvent).to.have.property('ipAddress', '127.0.0.2');
expect(registrationEvent).to.have.property('authenticationMethod', 'google');
});
});
describe('trackSubscriptionEvent', () => {
afterEach(async () => {
await SubscriptionEventModel.deleteMany({});
});
it('creates a subscription event when a user subscribes', async () => {
user._id = '00000000-0000-0000-0000-000000000003';
await localAnalytics.trackSubscriptionEvent({
eventType: 'subscribed',
user,
paymentMethod: 'stripe',
customerId: 'cus_123',
planId: 'plan_123',
});
const subscriptionEvents = await SubscriptionEventModel.find({ userId: user._id });
expect(subscriptionEvents).to.have.lengthOf(1);
expect(subscriptionEvents[0]).to.have.property('userId', user._id);
expect(subscriptionEvents[0]).to.have.property('eventType', 'subscribed');
expect(subscriptionEvents[0]).to.have.property('paymentMethod', 'stripe');
expect(subscriptionEvents[0]).to.have.property('customerId', 'cus_123');
expect(subscriptionEvents[0]).to.have.property('planId', 'plan_123');
});
it('creates a subscription event with cancellation reason when a user cancels', async () => {
user._id = '00000000-0000-0000-0000-000000000004';
await localAnalytics.trackSubscriptionEvent({
eventType: 'cancelled',
user,
paymentMethod: 'stripe',
customerId: 'cus_456',
planId: 'plan_456',
cancellationReason: 'No longer needed',
});
const subscriptionEvent = await SubscriptionEventModel.findOne({ userId: user._id });
expect(subscriptionEvent).to.have.property('userId', user._id);
expect(subscriptionEvent).to.have.property('eventType', 'cancelled');
expect(subscriptionEvent).to.have.property('paymentMethod', 'stripe');
expect(subscriptionEvent).to.have.property('customerId', 'cus_456');
expect(subscriptionEvent).to.have.property('planId', 'plan_456');
expect(subscriptionEvent).to.have.property('cancellationReason', 'No longer needed');
});
});
});
+64 -47
View File
@@ -3,7 +3,6 @@ import moment from 'moment';
import * as sender from '../../../../../website/server/libs/email';
import common from '../../../../../website/common';
import api from '../../../../../website/server/libs/payments/payments';
import * as analytics from '../../../../../website/server/libs/analyticsService';
import * as notifications from '../../../../../website/server/libs/pushNotifications';
import { model as User } from '../../../../../website/server/models/user';
import { translate as t } from '../../../../helpers/api-integration/v3';
@@ -13,6 +12,7 @@ import {
import * as worldState from '../../../../../website/server/libs/worldState';
import { TransactionModel } from '../../../../../website/server/models/transaction';
import { REPEATING_EVENTS } from '../../../../../website/common/script/content/constants/events';
import { SubscriptionEventModel } from '../../../../../website/server/models/analytics/subscriptionEvent';
describe('payments/index', () => {
let user;
@@ -36,8 +36,6 @@ describe('payments/index', () => {
sandbox.stub(sender, 'sendTxn');
sandbox.stub(user, 'sendMessage');
sandbox.stub(analytics.mockAnalyticsService, 'trackPurchase');
sandbox.stub(analytics.mockAnalyticsService, 'track');
sandbox.stub(notifications, 'sendNotification');
data = {
@@ -97,6 +95,16 @@ describe('payments/index', () => {
expect(recipient.items.pets['Jackalope-RoyalPurple']).to.eql(5);
});
it('tracks subscription events', async () => {
await api.createSubscription(data);
const subscriptionEvent = await SubscriptionEventModel.findOne({ userId: recipient._id });
expect(subscriptionEvent).to.exist;
expect(subscriptionEvent).to.have.property('eventType', 'subscribed');
expect(subscriptionEvent).to.have.property('userId', recipient._id);
expect(subscriptionEvent).to.have.property('planId', 'basic_3mo');
expect(subscriptionEvent).to.have.property('paymentMethod', 'Payment Method');
});
it('adds extra months to an existing subscription', async () => {
recipient.purchased.plan = plan;
@@ -298,28 +306,6 @@ describe('payments/index', () => {
expect(notifications.sendNotification).to.be.calledOnce;
});
it('tracks subscription purchase as gift', async () => {
await api.createSubscription(data);
expect(analytics.mockAnalyticsService.trackPurchase).to.be.calledOnce;
expect(analytics.mockAnalyticsService.trackPurchase).to.be.calledWith({
uuid: user._id,
groupId: undefined,
itemPurchased: 'Subscription',
sku: 'payment method-subscription',
purchaseType: 'subscribe',
paymentMethod: data.paymentMethod,
quantity: 1,
gift: true,
purchaseValue: 15,
firstPurchase: true,
headers: {
'x-client': 'habitica-web',
'user-agent': '',
},
});
});
context('No Active Promotion', () => {
beforeEach(() => {
sinon.stub(worldState, 'getCurrentEventList').returns([]);
@@ -455,6 +441,16 @@ describe('payments/index', () => {
expect(user.purchased.plan.dateCreated).to.exist;
});
it('tracks subscription events', async () => {
await api.createSubscription(data);
const subscriptionEvent = await SubscriptionEventModel.findOne({ userId: user._id });
expect(subscriptionEvent).to.exist;
expect(subscriptionEvent).to.have.property('userId', user._id);
expect(subscriptionEvent).to.have.property('ipAddress');
expect(subscriptionEvent).to.have.property('planId', 'basic_3mo');
expect(subscriptionEvent).to.have.property('paymentMethod', 'Payment Method');
});
it('sets plan.dateCreated if it did not previously exist', async () => {
expect(user.purchased.plan.dateCreated).to.not.exist;
@@ -543,29 +539,24 @@ describe('payments/index', () => {
expect(sender.sendTxn).to.be.calledWith(data.user, 'subscription-begins');
});
it('tracks subscription purchase', async () => {
await api.createSubscription(data);
expect(analytics.mockAnalyticsService.trackPurchase).to.be.calledOnce;
expect(analytics.mockAnalyticsService.trackPurchase).to.be.calledWith({
uuid: user._id,
groupId: undefined,
itemPurchased: 'Subscription',
sku: 'payment method-subscription',
purchaseType: 'subscribe',
paymentMethod: data.paymentMethod,
quantity: 1,
gift: false,
purchaseValue: 15,
firstPurchase: true,
headers: {
'x-client': 'habitica-web',
'user-agent': '',
},
});
});
context('Upgrades subscription', () => {
it('tracks subscription events', async () => {
data.sub.key = 'basic_earned';
expect(user.purchased.plan.planId).to.not.exist;
await api.createSubscription(data);
data.sub.key = 'basic_6mo';
data.updatedFrom = { key: 'basic_earned' };
await api.createSubscription(data);
const subscriptionEvent = await SubscriptionEventModel.findOne({ userId: user._id, planId: 'basic_6mo' });
expect(subscriptionEvent).to.exist;
expect(subscriptionEvent).to.have.property('eventType', 'upgraded');
expect(subscriptionEvent).to.have.property('userId', user._id);
expect(subscriptionEvent).to.have.property('paymentMethod', 'Payment Method');
});
it('from basic_earned to basic_6mo', async () => {
data.sub.key = 'basic_earned';
expect(user.purchased.plan.planId).to.not.exist;
@@ -608,6 +599,23 @@ describe('payments/index', () => {
});
context('Downgrades subscription', () => {
it('tracks subscription events', async () => {
data.sub.key = 'basic_6mo';
expect(user.purchased.plan.planId).to.not.exist;
await api.createSubscription(data);
data.sub.key = 'basic_earned';
data.updatedFrom = { key: 'basic_6mo' };
await api.createSubscription(data);
const subscriptionEvent = await SubscriptionEventModel.findOne({ userId: user._id, planId: 'basic_earned' });
expect(subscriptionEvent).to.exist;
expect(subscriptionEvent).to.have.property('eventType', 'downgraded');
expect(subscriptionEvent).to.have.property('userId', user._id);
expect(subscriptionEvent).to.have.property('paymentMethod', 'Payment Method');
});
it('from basic_6mo to basic_earned', async () => {
data.sub.key = 'basic_6mo';
expect(user.purchased.plan.planId).to.not.exist;
@@ -1136,6 +1144,15 @@ describe('payments/index', () => {
expect(daysTillTermination).to.be.within(29, 30); // 1 month +/- 1 days
});
it('tracks subscription events', async () => {
await api.cancelSubscription(data);
const subscriptionEvent = await SubscriptionEventModel.findOne({ userId: user._id });
expect(subscriptionEvent).to.exist;
expect(subscriptionEvent).to.have.property('eventType', 'cancelled');
expect(subscriptionEvent).to.have.property('userId', user._id);
});
it('adds extraMonths to dateTerminated value', async () => {
user.purchased.plan.extraMonths = 2;
@@ -1,50 +0,0 @@
/* eslint-disable global-require */
import nconf from 'nconf';
import requireAgain from 'require-again';
import {
generateRes,
generateReq,
generateNext,
} from '../../../helpers/api-unit.helper';
import * as analyticsService from '../../../../website/server/libs/analyticsService';
describe('analytics middleware', () => {
let res; let req; let
next;
const pathToAnalyticsMiddleware = '../../../../website/server/middlewares/analytics';
beforeEach(() => {
res = generateRes();
req = generateReq();
next = generateNext();
});
it('attaches analytics object to res', () => {
const attachAnalytics = requireAgain(pathToAnalyticsMiddleware).default;
attachAnalytics(req, res, next);
expect(res.analytics).to.exist;
});
it('attaches stubbed methods for non-prod environments', () => {
sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(false);
const attachAnalytics = requireAgain(pathToAnalyticsMiddleware).default;
attachAnalytics(req, res, next);
expect(res.analytics.track).to.eql(analyticsService.mockAnalyticsService.track);
expect(res.analytics.trackPurchase).to.eql(analyticsService.mockAnalyticsService.trackPurchase);
});
it('attaches real methods for prod environments', () => {
sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true);
const attachAnalytics = requireAgain(pathToAnalyticsMiddleware).default;
attachAnalytics(req, res, next);
expect(res.analytics.track).to.eql(analyticsService.track);
expect(res.analytics.trackPurchase).to.eql(analyticsService.trackPurchase);
});
});
+25 -12
View File
@@ -32,7 +32,8 @@ describe('rateLimiter middleware', () => {
it('is disabled when the env var is not defined', () => {
nconfGetStub.withArgs('RATE_LIMITER_ENABLED').returns(undefined);
const attachRateLimiter = requireAgain(pathToRateLimiter).default;
const setupRateLimiter = requireAgain(pathToRateLimiter).default;
const attachRateLimiter = setupRateLimiter();
attachRateLimiter(req, res, next);
expect(next).to.have.been.calledOnce;
@@ -43,7 +44,8 @@ describe('rateLimiter middleware', () => {
it('is disabled when the env var is an not "true"', () => {
nconfGetStub.withArgs('RATE_LIMITER_ENABLED').returns('false');
const attachRateLimiter = requireAgain(pathToRateLimiter).default;
const setupRateLimiter = requireAgain(pathToRateLimiter).default;
const attachRateLimiter = setupRateLimiter();
attachRateLimiter(req, res, next);
expect(next).to.have.been.calledOnce;
@@ -55,7 +57,8 @@ describe('rateLimiter middleware', () => {
it('does not throw when there are available points', async () => {
nconfGetStub.withArgs('RATE_LIMITER_ENABLED').returns('true');
nconfGetStub.withArgs('RATE_LIMITER_IP_COST').returns(1);
const attachRateLimiter = requireAgain(pathToRateLimiter).default;
const setupRateLimiter = requireAgain(pathToRateLimiter).default;
const attachRateLimiter = setupRateLimiter();
await attachRateLimiter(req, res, next);
expect(next).to.have.been.calledOnce;
@@ -77,7 +80,8 @@ describe('rateLimiter middleware', () => {
sandbox.stub(RateLimiterMemory.prototype, 'consume')
.returns(Promise.reject(new Error('Unknown error.')));
const attachRateLimiter = requireAgain(pathToRateLimiter).default;
const setupRateLimiter = requireAgain(pathToRateLimiter).default;
const attachRateLimiter = setupRateLimiter();
await attachRateLimiter(req, res, next);
expect(next).to.have.been.calledOnce;
@@ -92,7 +96,9 @@ describe('rateLimiter middleware', () => {
it('does not throw when LIVELINESS_PROBE_KEY is correct', async () => {
nconfGetStub.withArgs('RATE_LIMITER_ENABLED').returns('true');
nconfGetStub.withArgs('LIVELINESS_PROBE_KEY').returns('abc');
const attachRateLimiter = requireAgain(pathToRateLimiter).default;
const setupRateLimiter = requireAgain(pathToRateLimiter).default;
const attachRateLimiter = setupRateLimiter();
req.query.liveliness = 'abc';
await attachRateLimiter(req, res, next);
@@ -107,7 +113,8 @@ describe('rateLimiter middleware', () => {
nconfGetStub.withArgs('RATE_LIMITER_ENABLED').returns('true');
nconfGetStub.withArgs('LIVELINESS_PROBE_KEY').returns('abc');
nconfGetStub.withArgs('RATE_LIMITER_IP_COST').returns(1);
const attachRateLimiter = requireAgain(pathToRateLimiter).default;
const setupRateLimiter = requireAgain(pathToRateLimiter).default;
const attachRateLimiter = setupRateLimiter();
req.query.liveliness = 'das';
await attachRateLimiter(req, res, next);
@@ -124,7 +131,8 @@ describe('rateLimiter middleware', () => {
nconfGetStub.withArgs('RATE_LIMITER_ENABLED').returns('true');
nconfGetStub.withArgs('LIVELINESS_PROBE_KEY').returns(undefined);
nconfGetStub.withArgs('RATE_LIMITER_IP_COST').returns(1);
const attachRateLimiter = requireAgain(pathToRateLimiter).default;
const setupRateLimiter = requireAgain(pathToRateLimiter).default;
const attachRateLimiter = setupRateLimiter();
await attachRateLimiter(req, res, next);
@@ -140,7 +148,8 @@ describe('rateLimiter middleware', () => {
nconfGetStub.withArgs('RATE_LIMITER_ENABLED').returns('true');
nconfGetStub.withArgs('LIVELINESS_PROBE_KEY').returns('');
nconfGetStub.withArgs('RATE_LIMITER_IP_COST').returns(1);
const attachRateLimiter = requireAgain(pathToRateLimiter).default;
const setupRateLimiter = requireAgain(pathToRateLimiter).default;
const attachRateLimiter = setupRateLimiter();
req.query.liveliness = '';
await attachRateLimiter(req, res, next);
@@ -156,7 +165,8 @@ describe('rateLimiter middleware', () => {
it('throws when there are no available points remaining', async () => {
nconfGetStub.withArgs('RATE_LIMITER_ENABLED').returns('true');
nconfGetStub.withArgs('RATE_LIMITER_IP_COST').returns(1);
const attachRateLimiter = requireAgain(pathToRateLimiter).default;
const setupRateLimiter = requireAgain(pathToRateLimiter).default;
const attachRateLimiter = setupRateLimiter();
// call for 31 times
for (let i = 0; i < 31; i += 1) {
@@ -180,7 +190,8 @@ describe('rateLimiter middleware', () => {
it('uses the user id if supplied or the ip address', async () => {
nconfGetStub.withArgs('RATE_LIMITER_ENABLED').returns('true');
nconfGetStub.withArgs('RATE_LIMITER_IP_COST').returns(1);
const attachRateLimiter = requireAgain(pathToRateLimiter).default;
const setupRateLimiter = requireAgain(pathToRateLimiter).default;
const attachRateLimiter = setupRateLimiter();
req.ip = 1;
await attachRateLimiter(req, res, next);
@@ -210,7 +221,8 @@ describe('rateLimiter middleware', () => {
it('applies increased cost for registration calls with and without user id', async () => {
nconfGetStub.withArgs('RATE_LIMITER_ENABLED').returns('true');
nconfGetStub.withArgs('RATE_LIMITER_REGISTRATION_COST').returns(3);
const attachRateLimiter = requireAgain(pathToRateLimiter).default;
const setupRateLimiter = requireAgain(pathToRateLimiter).default;
const attachRateLimiter = setupRateLimiter();
req.path = '/api/v4/user/auth/local/register';
req.ip = 1;
@@ -241,7 +253,8 @@ describe('rateLimiter middleware', () => {
it('applies increased cost for unauthenticated API calls', async () => {
nconfGetStub.withArgs('RATE_LIMITER_ENABLED').returns('true');
nconfGetStub.withArgs('RATE_LIMITER_IP_COST').returns(10);
const attachRateLimiter = requireAgain(pathToRateLimiter).default;
const setupRateLimiter = requireAgain(pathToRateLimiter).default;
const attachRateLimiter = setupRateLimiter();
req.ip = 1;
await attachRateLimiter(req, res, next);
@@ -1,19 +0,0 @@
import {
generateUser,
requester,
} from '../../../../helpers/api-integration/v3';
import { mockAnalyticsService as analytics } from '../../../../../website/server/libs/analyticsService';
describe('POST /analytics/track/:eventName', () => {
it('calls res.analytics', async () => {
const user = await generateUser();
sandbox.spy(analytics, 'track');
const requestWithHeaders = requester(user, { 'x-client': 'habitica-web' });
await requestWithHeaders.post('/analytics/track/eventName', { data: 'example' }, { 'x-client': 'habitica-web' });
expect(analytics.track).to.be.calledOnce;
expect(analytics.track).to.be.calledWith('eventName', sandbox.match({ data: 'example' }));
sandbox.restore();
});
});
@@ -91,6 +91,23 @@ describe('POST /groups/:groupId/quests/accept', () => {
expect(partyMembers[0].party.quest.RSVPNeeded).to.be.false;
});
it('heals stuck RSVPNeeded when group already has the user accepted', async () => {
await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
await partyMembers[0].updateOne({ 'party.quest.RSVPNeeded': true });
await partyMembers[0].sync();
expect(partyMembers[0].party.quest.RSVPNeeded).to.be.true;
const res = await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
expect(res).to.exist;
await partyMembers[0].sync();
await questingGroup.sync();
expect(partyMembers[0].party.quest.RSVPNeeded).to.equal(false);
expect(questingGroup.quest.members[partyMembers[0]._id]).to.equal(true);
});
it('does not accept invite for a quest already underway', async () => {
await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
@@ -100,6 +100,23 @@ describe('POST /groups/:groupId/quests/reject', () => {
expect(partyMembers[0].party.quest.RSVPNeeded).to.be.false;
});
it('heals stuck RSVPNeeded when group already has the user rejected', async () => {
await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
await partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`);
await partyMembers[0].updateOne({ 'party.quest.RSVPNeeded': true });
await partyMembers[0].sync();
expect(partyMembers[0].party.quest.RSVPNeeded).to.be.true;
const res = await partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`);
expect(res).to.exist;
await partyMembers[0].sync();
await questingGroup.sync();
expect(partyMembers[0].party.quest.RSVPNeeded).to.equal(false);
expect(questingGroup.quest.members[partyMembers[0]._id]).to.equal(false);
});
it('return an error when a user rejects an invite already accepted', async () => {
await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
@@ -1,7 +1,6 @@
import {
generateUser,
} from '../../../../helpers/api-integration/v3';
import { mockAnalyticsService as analytics } from '../../../../../website/server/libs/analyticsService';
describe('POST /user/sleep', () => {
let user;
@@ -23,15 +22,4 @@ describe('POST /user/sleep', () => {
await user.sync();
expect(user.preferences.sleep).to.be.false;
});
it('sends sleep status to analytics service', async () => {
sandbox.spy(analytics, 'track');
await user.post('/user/sleep');
await user.sync();
expect(analytics.track).to.be.calledOnce;
expect(analytics.track).to.be.calledWith('sleep', sandbox.match.has('status', user.preferences.sleep));
sandbox.restore();
});
});
@@ -9,6 +9,7 @@ import {
} from '../../../../../helpers/api-integration/v3';
import { ApiUser } from '../../../../../helpers/api-integration/api-classes';
import { encrypt } from '../../../../../../website/server/libs/encryption';
import { RegistrationEventModel } from '../../../../../../website/server/models/analytics/registrationEvent';
function generateRandomUserName () {
return (Date.now() + uuid()).substring(0, 20);
@@ -41,6 +42,25 @@ describe('POST /user/auth/local/register', () => {
expect(user.newUser).to.eql(true);
});
it('tracks a registration event', async () => {
const username = generateRandomUserName();
const email = `${username}@example.com`;
const password = 'password';
const user = await api.post('/user/auth/local/register', {
username,
email,
password,
confirmPassword: password,
});
const registrationEvent = await RegistrationEventModel.findOne({ userId: user._id });
expect(registrationEvent).to.exist;
expect(registrationEvent).to.have.property('userId', user._id);
expect(registrationEvent).to.have.property('ipAddress');
expect(registrationEvent).to.have.property('authenticationMethod', 'local');
});
it('registers a new user and sets verifiedUsername to true', async () => {
const username = generateRandomUserName();
const email = `${username}@example.com`;
@@ -7,6 +7,7 @@ import {
getProperty,
} from '../../../../../helpers/api-integration/v3';
import apiErrorMessages from '../../../../../../website/common/script/errors/apiErrorMessages';
import { RegistrationEventModel } from '../../../../../../website/server/models/analytics/registrationEvent';
describe('POST /user/auth/social', () => {
let api;
@@ -65,6 +66,19 @@ describe('POST /user/auth/social', () => {
await expect(getProperty('users', response.id, 'profile.name')).to.eventually.equal('a google user');
});
it('tracks a registration event', async () => {
const socialUser = await api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
network,
});
const registrationEvent = await RegistrationEventModel.findOne({ userId: socialUser.id });
expect(registrationEvent).to.exist;
expect(registrationEvent).to.have.property('userId', socialUser.id);
expect(registrationEvent).to.have.property('ipAddress');
expect(registrationEvent).to.have.property('authenticationMethod', 'google');
});
it('includes sanitized version of provided username', async () => {
const response = await api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
@@ -231,6 +245,17 @@ describe('POST /user/auth/social', () => {
expect(response.newUser).to.be.false;
});
it('does not track a registration event for existing users', async () => {
const beforeEvents = await RegistrationEventModel.find({ userId: user._id });
await user.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
network,
});
const registrationEvents = await RegistrationEventModel.find({ userId: user._id });
expect(registrationEvents).to.have.lengthOf(beforeEvents.length);
});
it('does not log into other account if social auth already exists', async () => {
const registerResponse = await api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
+1 -10
View File
@@ -13,7 +13,6 @@ import { errorMessage } from '../../../../website/common/script/libs/errorMessag
describe('shared.ops.buy', () => {
let user;
const analytics = { track () {} };
beforeEach(() => {
user = generateUser({
@@ -32,12 +31,6 @@ describe('shared.ops.buy', () => {
},
},
});
sinon.stub(analytics, 'track');
});
afterEach(() => {
analytics.track.restore();
});
it('returns error when key is not provided', async () => {
@@ -51,10 +44,8 @@ describe('shared.ops.buy', () => {
it('buys health potion', async () => {
user.stats.hp = 30;
await buy(user, { params: { key: 'potion' } }, analytics);
await buy(user, { params: { key: 'potion' } });
expect(user.stats.hp).to.eql(45);
expect(analytics.track).to.be.calledOnce;
});
it('adds equipment to inventory', async () => {
+3 -7
View File
@@ -29,10 +29,9 @@ describe('shared.ops.buyArmoire', () => {
const YIELD_EQUIPMENT = 0.5;
const YIELD_FOOD = 0.7;
const YIELD_EXP = 0.9;
const analytics = { track () {} };
async function buyArmoire (_user, _req, _analytics) {
const buyOp = new BuyArmoireOperation(_user, _req, _analytics);
async function buyArmoire (_user, _req) {
const buyOp = new BuyArmoireOperation(_user, _req);
return buyOp.purchase();
}
@@ -50,12 +49,10 @@ describe('shared.ops.buyArmoire', () => {
user.items.food = {};
sandbox.stub(randomValFns, 'trueRandom');
sinon.stub(analytics, 'track');
});
afterEach(() => {
randomValFns.trueRandom.restore();
analytics.track.restore();
});
context('failure conditions', () => {
@@ -147,7 +144,7 @@ describe('shared.ops.buyArmoire', () => {
expect(_.size(user.items.gear.owned)).to.equal(2);
await buyArmoire(user, {}, analytics);
await buyArmoire(user, {});
expect(_.size(user.items.gear.owned)).to.equal(3);
@@ -155,7 +152,6 @@ describe('shared.ops.buyArmoire', () => {
expect(armoireCount).to.eql(_.size(getFullArmoire()) - 2);
expect(user.stats.gp).to.eql(100);
expect(analytics.track).to.be.calledTwice;
});
});
});
+3 -12
View File
@@ -1,6 +1,5 @@
/* eslint-disable camelcase */
import sinon from 'sinon'; // eslint-disable-line no-shadow
import {
generateUser,
} from '../../../helpers/common.helper';
@@ -11,15 +10,14 @@ import i18n from '../../../../website/common/script/i18n';
import { BuyGemOperation } from '../../../../website/common/script/ops/buy/buyGem';
import planGemLimits from '../../../../website/common/script/libs/planGemLimits';
async function buyGem (user, req, analytics) {
const buyOp = new BuyGemOperation(user, req, analytics);
async function buyGem (user, req) {
const buyOp = new BuyGemOperation(user, req);
return buyOp.purchase();
}
describe('shared.ops.buyGem', () => {
let user;
const analytics = { track () {} };
const goldPoints = 40;
const gemsBought = 40;
const userGemAmount = 10;
@@ -35,23 +33,16 @@ describe('shared.ops.buyGem', () => {
},
},
});
sinon.stub(analytics, 'track');
});
afterEach(() => {
analytics.track.restore();
});
context('Gems', () => {
it('purchases gems', async () => {
const [, message] = await buyGem(user, { params: { type: 'gems', key: 'gem' } }, analytics);
const [, message] = await buyGem(user, { params: { type: 'gems', key: 'gem' } });
expect(message).to.equal(i18n.t('plusGem', { count: 1 }));
expect(user.balance).to.equal(userGemAmount + 0.25);
expect(user.purchased.plan.gemsBought).to.equal(1);
expect(user.stats.gp).to.equal(goldPoints - planGemLimits.convRate);
expect(analytics.track).to.be.calledOnce;
});
it('purchases gems with a different language than the default', async () => {
+3 -10
View File
@@ -10,10 +10,9 @@ import i18n from '../../../../website/common/script/i18n';
describe('shared.ops.buyHealthPotion', () => {
let user;
const analytics = { track () {} };
async function buyHealthPotion (_user, _req, _analytics) {
const buyOp = new BuyHealthPotionOperation(_user, _req, _analytics);
async function buyHealthPotion (_user, _req) {
const buyOp = new BuyHealthPotionOperation(_user, _req);
return buyOp.purchase();
}
@@ -32,19 +31,13 @@ describe('shared.ops.buyHealthPotion', () => {
},
stats: { gp: 200 },
});
sinon.stub(analytics, 'track');
});
afterEach(() => {
analytics.track.restore();
});
context('Potion', () => {
it('recovers 15 hp', async () => {
user.stats.hp = 30;
await buyHealthPotion(user, {}, analytics);
await buyHealthPotion(user, {});
expect(user.stats.hp).to.eql(45);
expect(analytics.track).to.be.calledOnce;
});
it('does not increase hp above 50', async () => {
+5 -9
View File
@@ -13,15 +13,14 @@ import {
import i18n from '../../../../website/common/script/i18n';
import { errorMessage } from '../../../../website/common/script/libs/errorMessage';
async function buyGear (user, req, analytics) {
const buyOp = new BuyMarketGearOperation(user, req, analytics);
async function buyGear (user, req) {
const buyOp = new BuyMarketGearOperation(user, req);
return buyOp.purchase();
}
describe('shared.ops.buyMarketGear', () => {
let user;
const analytics = { track () {} };
let clock;
beforeEach(() => {
@@ -47,14 +46,12 @@ describe('shared.ops.buyMarketGear', () => {
sinon.stub(shared, 'randomVal');
sinon.stub(shared.onboarding, 'checkOnboardingStatus');
sinon.stub(shared.fns, 'predictableRandom');
sinon.stub(analytics, 'track');
});
afterEach(() => {
shared.randomVal.restore();
shared.fns.predictableRandom.restore();
shared.onboarding.checkOnboardingStatus.restore();
analytics.track.restore();
if (clock) {
clock.restore();
@@ -65,7 +62,7 @@ describe('shared.ops.buyMarketGear', () => {
it('adds equipment to inventory', async () => {
user.stats.gp = 31;
await buyGear(user, { params: { key: 'armor_warrior_1' } }, analytics);
await buyGear(user, { params: { key: 'armor_warrior_1' } });
expect(user.items.gear.owned).to.eql({
weapon_warrior_0: true,
@@ -92,13 +89,12 @@ describe('shared.ops.buyMarketGear', () => {
eyewear_special_whiteHalfMoon: true,
eyewear_special_yellowHalfMoon: true,
});
expect(analytics.track).to.be.calledOnce;
});
it('adds the onboarding achievement to the user and checks the onboarding status', async () => {
user.stats.gp = 31;
await buyGear(user, { params: { key: 'armor_warrior_1' } }, analytics);
await buyGear(user, { params: { key: 'armor_warrior_1' } });
expect(user.addAchievement).to.be.calledOnce;
expect(user.addAchievement).to.be.calledWith('purchasedEquipment');
@@ -111,7 +107,7 @@ describe('shared.ops.buyMarketGear', () => {
user.stats.gp = 31;
user.achievements.purchasedEquipment = true;
await buyGear(user, { params: { key: 'armor_warrior_1' } }, analytics);
await buyGear(user, { params: { key: 'armor_warrior_1' } });
expect(user.addAchievement).to.not.be.called;
});
+2 -5
View File
@@ -14,7 +14,6 @@ import { errorMessage } from '../../../../website/common/script/libs/errorMessag
describe('shared.ops.buyMysterySet', () => {
let user;
const analytics = { track () {} };
let clock;
beforeEach(() => {
@@ -27,11 +26,9 @@ describe('shared.ops.buyMysterySet', () => {
},
},
});
sinon.stub(analytics, 'track');
});
afterEach(() => {
analytics.track.restore();
if (clock) {
clock.restore();
}
@@ -93,7 +90,7 @@ describe('shared.ops.buyMysterySet', () => {
context('successful purchases', () => {
it('buys Steampunk Accessories Set', async () => {
user.purchased.plan.consecutive.trinkets = 1;
await buyMysterySet(user, { params: { key: '301404' } }, analytics);
await buyMysterySet(user, { params: { key: '301404' } });
expect(user.purchased.plan.consecutive.trinkets).to.eql(0);
expect(user.items.gear.owned).to.have.property('weapon_warrior_0', true);
@@ -106,7 +103,7 @@ describe('shared.ops.buyMysterySet', () => {
it('buys mystery set if it is available', async () => {
clock = sinon.useFakeTimers(new Date('2024-01-16'));
user.purchased.plan.consecutive.trinkets = 1;
await buyMysterySet(user, { params: { key: '201601' } }, analytics);
await buyMysterySet(user, { params: { key: '201601' } });
expect(user.purchased.plan.consecutive.trinkets).to.eql(0);
expect(user.items.gear.owned).to.have.property('shield_mystery_201601', true);
+2 -5
View File
@@ -12,10 +12,9 @@ describe('shared.ops.buyQuestGems', () => {
let user;
let clock;
const goldPoints = 40;
const analytics = { track () {} };
async function buyQuest (_user, _req, _analytics) {
const buyOp = new BuyQuestWithGemOperation(_user, _req, _analytics);
async function buyQuest (_user, _req) {
const buyOp = new BuyQuestWithGemOperation(_user, _req);
return buyOp.purchase();
}
@@ -25,13 +24,11 @@ describe('shared.ops.buyQuestGems', () => {
});
beforeEach(() => {
sinon.stub(analytics, 'track');
sinon.spy(pinnedGearUtils, 'removeItemByPath');
clock = sinon.useFakeTimers(new Date('2024-01-16'));
});
afterEach(() => {
analytics.track.restore();
pinnedGearUtils.removeItemByPath.restore();
clock.restore();
});
+8 -17
View File
@@ -12,21 +12,15 @@ import { errorMessage } from '../../../../website/common/script/libs/errorMessag
describe('shared.ops.buyQuest', () => {
let user;
const analytics = { track () {} };
async function buyQuest (_user, _req, _analytics) {
const buyOp = new BuyQuestWithGoldOperation(_user, _req, _analytics);
async function buyQuest (_user, _req) {
const buyOp = new BuyQuestWithGoldOperation(_user, _req);
return buyOp.purchase();
}
beforeEach(() => {
user = generateUser();
sinon.stub(analytics, 'track');
});
afterEach(() => {
analytics.track.restore();
});
it('buys a Quest scroll', async () => {
@@ -35,12 +29,11 @@ describe('shared.ops.buyQuest', () => {
params: {
key: 'dilatoryDistress1',
},
}, analytics);
});
expect(user.items.quests).to.eql({
dilatoryDistress1: 1,
});
expect(user.stats.gp).to.equal(5);
expect(analytics.track).to.be.calledOnce;
});
it('if a user\'s count of a quest scroll is negative, it will be reset to 0 before incrementing when they buy a new one.', async () => {
@@ -49,10 +42,9 @@ describe('shared.ops.buyQuest', () => {
user.items.quests[key] = -1;
await buyQuest(user, {
params: { key },
}, analytics);
});
expect(user.items.quests[key]).to.equal(1);
expect(user.stats.gp).to.equal(5);
expect(analytics.track).to.be.calledOnce;
});
it('buys a Quest scroll with the right quantity if a string is passed for quantity', async () => {
@@ -61,13 +53,13 @@ describe('shared.ops.buyQuest', () => {
params: {
key: 'dilatoryDistress1',
},
}, analytics);
});
await buyQuest(user, {
params: {
key: 'dilatoryDistress1',
},
quantity: '3',
}, analytics);
});
expect(user.items.quests).to.eql({
dilatoryDistress1: 4,
@@ -82,7 +74,7 @@ describe('shared.ops.buyQuest', () => {
key: 'dilatoryDistress1',
},
quantity: 'a',
}, analytics);
});
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(err.message).to.equal(i18n.t('invalidQuantity'));
@@ -187,12 +179,11 @@ describe('shared.ops.buyQuest', () => {
params: {
key: 'dilatoryDistress3',
},
}, analytics);
});
expect(user.items.quests).to.eql({
dilatoryDistress3: 1,
});
expect(user.stats.gp).to.equal(100);
expect(analytics.track).to.be.calledOnce;
});
});
+5 -11
View File
@@ -14,20 +14,17 @@ import { errorMessage } from '../../../../website/common/script/libs/errorMessag
describe('shared.ops.buySpecialSpell', () => {
let user;
let clock;
const analytics = { track () {} };
async function buySpecialSpell (_user, _req, _analytics) {
const buyOp = new BuySpellOperation(_user, _req, _analytics);
async function buySpecialSpell (_user, _req) {
const buyOp = new BuySpellOperation(_user, _req);
return buyOp.purchase();
}
beforeEach(() => {
user = generateUser();
sinon.stub(analytics, 'track');
});
afterEach(() => {
analytics.track.restore();
if (clock) {
clock.restore();
}
@@ -78,7 +75,7 @@ describe('shared.ops.buySpecialSpell', () => {
params: {
key: 'thankyou',
},
}, analytics);
});
expect(user.stats.gp).to.equal(1);
expect(user.items.special.thankyou).to.equal(1);
@@ -89,7 +86,6 @@ describe('shared.ops.buySpecialSpell', () => {
expect(message).to.equal(i18n.t('messageBought', {
itemText: item.text(),
}));
expect(analytics.track).to.be.calledOnce;
});
it('buys a limited card when it is available', async () => {
@@ -101,7 +97,7 @@ describe('shared.ops.buySpecialSpell', () => {
params: {
key: 'nye',
},
}, analytics);
});
expect(user.stats.gp).to.equal(1);
expect(user.items.special.nye).to.equal(1);
@@ -112,7 +108,6 @@ describe('shared.ops.buySpecialSpell', () => {
expect(message).to.equal(i18n.t('messageBought', {
itemText: item.text(),
}));
expect(analytics.track).to.be.calledOnce;
});
it('throws an error if the card is not currently available', async () => {
@@ -140,7 +135,7 @@ describe('shared.ops.buySpecialSpell', () => {
params: {
key: 'seafoam',
},
}, analytics);
});
expect(user.stats.gp).to.equal(1);
expect(user.items.special.seafoam).to.equal(1);
@@ -151,7 +146,6 @@ describe('shared.ops.buySpecialSpell', () => {
expect(message).to.equal(i18n.t('messageBought', {
itemText: item.text(),
}));
expect(analytics.track).to.be.calledOnce;
});
it('throws an error if the spell is not currently available', async () => {
+3 -10
View File
@@ -13,21 +13,15 @@ import { BuyHourglassMountOperation } from '../../../../website/common/script/op
describe('common.ops.hourglassPurchase', () => {
let user;
const analytics = { track () {} };
async function buyMount (_user, _req, _analytics) {
const buyOp = new BuyHourglassMountOperation(_user, _req, _analytics);
async function buyMount (_user, _req) {
const buyOp = new BuyHourglassMountOperation(_user, _req);
return buyOp.purchase();
}
beforeEach(() => {
user = generateUser();
sinon.stub(analytics, 'track');
});
afterEach(() => {
analytics.track.restore();
});
context('failure conditions', () => {
@@ -131,12 +125,11 @@ describe('common.ops.hourglassPurchase', () => {
it('buys a pet', async () => {
user.purchased.plan.consecutive.trinkets = 2;
const [, message] = await hourglassPurchase(user, { params: { type: 'pets', key: 'MantisShrimp-Base' } }, analytics);
const [, message] = await hourglassPurchase(user, { params: { type: 'pets', key: 'MantisShrimp-Base' } });
expect(message).to.eql(i18n.t('hourglassPurchase'));
expect(user.purchased.plan.consecutive.trinkets).to.eql(1);
expect(user.items.pets).to.eql({ 'MantisShrimp-Base': 5 });
expect(analytics.track).to.be.calledOnce;
});
it('buys a mount', async () => {
+4 -8
View File
@@ -17,20 +17,17 @@ describe('shared.ops.purchase', () => {
let user;
let clock;
const goldPoints = 40;
const analytics = { track () {} };
before(() => {
user = generateUser({ 'stats.class': 'rogue' });
});
beforeEach(() => {
sinon.stub(analytics, 'track');
sinon.spy(pinnedGearUtils, 'removeItemByPath');
clock = sandbox.useFakeTimers(new Date('2024-01-10'));
});
afterEach(() => {
analytics.track.restore();
pinnedGearUtils.removeItemByPath.restore();
clock.restore();
});
@@ -187,11 +184,10 @@ describe('shared.ops.purchase', () => {
const type = 'eggs';
const key = 'Wolf';
await purchase(user, { params: { type, key } }, analytics);
await purchase(user, { params: { type, key } });
expect(user.items[type][key]).to.equal(1);
expect(pinnedGearUtils.removeItemByPath.notCalled).to.equal(true);
expect(analytics.track).to.be.calledOnce;
});
it('purchases hatchingPotions', async () => {
@@ -332,7 +328,7 @@ describe('shared.ops.purchase', () => {
const key = 'Wolf';
try {
await purchase(user, { params: { type, key }, quantity: 'jamboree' }, analytics);
await purchase(user, { params: { type, key }, quantity: 'jamboree' });
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(err.message).to.equal(i18n.t('invalidQuantity'));
@@ -345,7 +341,7 @@ describe('shared.ops.purchase', () => {
user.balance = 10;
try {
await purchase(user, { params: { type, key }, quantity: -2 }, analytics);
await purchase(user, { params: { type, key }, quantity: -2 });
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(err.message).to.equal(i18n.t('invalidQuantity'));
@@ -358,7 +354,7 @@ describe('shared.ops.purchase', () => {
user.balance = 10;
try {
await purchase(user, { params: { type, key }, quantity: 2.9 }, analytics);
await purchase(user, { params: { type, key }, quantity: 2.9 });
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(err.message).to.equal(i18n.t('invalidQuantity'));
+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');
});
});
});
-15
View File
@@ -54,19 +54,4 @@ describe('armoire', () => {
const febuaryItems = armoire.all;
expect(febuaryItems.length).to.equal(384);
});
it('sets have at least 2 items', () => {
const setMap = {};
forEach(armoire.all, item => {
// Gotta have one outlier
if (!item.set || item.set.startsWith('armoire-')) return;
if (setMap[item.set] === undefined) {
setMap[item.set] = 0;
}
setMap[item.set] += 1;
});
Object.keys(setMap).forEach(set => {
expect(setMap[set], set).to.be.at.least(2);
});
});
});
@@ -40,7 +40,6 @@ function _requestMaker (user, method, additionalSets = {}) {
|| route.indexOf('/paypal') === 0
|| route.indexOf('/amazon') === 0
|| route.indexOf('/stripe') === 0
|| route.indexOf('/analytics') === 0
) {
url += `${route}`;
} else {
-8
View File
@@ -12,20 +12,12 @@ module.exports = {
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
// TODO find a way to let eslint understand webpack aliases
'import/no-unresolved': 'off',
'import/no-extraneous-dependencies': 'off',
'import/extensions': 'off',
'prefer-regex-literals': 'warn',
'vue/no-v-html': 'off',
'vue/no-mutating-props': 'warn',
// this creates issues with the current way we have to push the process.env vars to webpack
// https://github.com/eslint/eslint/issues/14918
// https://github.com/webpack/webpack/issues/5392
// off for now, because any eslint --fix will then still do it anyway
// maybe this can be turned on again once we switch to newer vue/vite
// Important! process.env.XYZ should not be destructured
'prefer-destructuring': 'off',
'vue/html-self-closing': ['error', {
html: {
void: 'never',
+20 -589
View File
@@ -41,7 +41,6 @@
"vite": "^6.3.6",
"vite-plugin-compression2": "^1.3.3",
"vue": "^2.7.10",
"vue-fragment": "^1.6.0",
"vue-mugen-scroll": "^0.2.6",
"vue-router": "^3.6.5",
"vuedraggable": "^2.24.3",
@@ -55,9 +54,7 @@
"jsdom": "^26.0.0",
"mocha": "^11.1.0",
"playwright": "^1.50.1",
"terser-webpack-plugin": "^5.3.10",
"vitest": "^3.0.5",
"webpack": "^5.94.0"
"vitest": "^3.0.5"
}
},
"node_modules/@amplitude/analytics-connector": {
@@ -2111,8 +2108,9 @@
"version": "0.3.11",
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
"integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
"devOptional": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25"
@@ -3634,41 +3632,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/eslint": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "*",
"@types/json-schema": "*"
}
},
"node_modules/@types/eslint-scope": {
"version": "3.7.7",
"resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz",
"integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/eslint": "*",
"@types/estree": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json5": {
"version": "0.0.29",
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
@@ -3679,8 +3648,9 @@
"version": "24.10.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
"integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
"devOptional": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -3876,181 +3846,6 @@
"vue-template-compiler": "^2.x"
}
},
"node_modules/@webassemblyjs/ast": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
"integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@webassemblyjs/helper-numbers": "1.13.2",
"@webassemblyjs/helper-wasm-bytecode": "1.13.2"
}
},
"node_modules/@webassemblyjs/floating-point-hex-parser": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz",
"integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==",
"dev": true,
"license": "MIT"
},
"node_modules/@webassemblyjs/helper-api-error": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz",
"integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@webassemblyjs/helper-buffer": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz",
"integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==",
"dev": true,
"license": "MIT"
},
"node_modules/@webassemblyjs/helper-numbers": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz",
"integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@webassemblyjs/floating-point-hex-parser": "1.13.2",
"@webassemblyjs/helper-api-error": "1.13.2",
"@xtuc/long": "4.2.2"
}
},
"node_modules/@webassemblyjs/helper-wasm-bytecode": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz",
"integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==",
"dev": true,
"license": "MIT"
},
"node_modules/@webassemblyjs/helper-wasm-section": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz",
"integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
"@webassemblyjs/helper-buffer": "1.14.1",
"@webassemblyjs/helper-wasm-bytecode": "1.13.2",
"@webassemblyjs/wasm-gen": "1.14.1"
}
},
"node_modules/@webassemblyjs/ieee754": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz",
"integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@xtuc/ieee754": "^1.2.0"
}
},
"node_modules/@webassemblyjs/leb128": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz",
"integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@xtuc/long": "4.2.2"
}
},
"node_modules/@webassemblyjs/utf8": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz",
"integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@webassemblyjs/wasm-edit": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz",
"integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
"@webassemblyjs/helper-buffer": "1.14.1",
"@webassemblyjs/helper-wasm-bytecode": "1.13.2",
"@webassemblyjs/helper-wasm-section": "1.14.1",
"@webassemblyjs/wasm-gen": "1.14.1",
"@webassemblyjs/wasm-opt": "1.14.1",
"@webassemblyjs/wasm-parser": "1.14.1",
"@webassemblyjs/wast-printer": "1.14.1"
}
},
"node_modules/@webassemblyjs/wasm-gen": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz",
"integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
"@webassemblyjs/helper-wasm-bytecode": "1.13.2",
"@webassemblyjs/ieee754": "1.13.2",
"@webassemblyjs/leb128": "1.13.2",
"@webassemblyjs/utf8": "1.13.2"
}
},
"node_modules/@webassemblyjs/wasm-opt": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz",
"integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
"@webassemblyjs/helper-buffer": "1.14.1",
"@webassemblyjs/wasm-gen": "1.14.1",
"@webassemblyjs/wasm-parser": "1.14.1"
}
},
"node_modules/@webassemblyjs/wasm-parser": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz",
"integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
"@webassemblyjs/helper-api-error": "1.13.2",
"@webassemblyjs/helper-wasm-bytecode": "1.13.2",
"@webassemblyjs/ieee754": "1.13.2",
"@webassemblyjs/leb128": "1.13.2",
"@webassemblyjs/utf8": "1.13.2"
}
},
"node_modules/@webassemblyjs/wast-printer": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz",
"integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
"@xtuc/long": "4.2.2"
}
},
"node_modules/@xtuc/ieee754": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
"integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@xtuc/long": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz",
"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/acorn": {
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz",
@@ -4098,48 +3893,6 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/ajv-formats/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT"
},
"node_modules/amplitude-js": {
"version": "8.21.9",
"resolved": "https://registry.npmjs.org/amplitude-js/-/amplitude-js-8.21.9.tgz",
@@ -4617,8 +4370,9 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"devOptional": true,
"license": "MIT"
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/cac": {
"version": "6.7.14",
@@ -4783,16 +4537,6 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/chrome-trace-event": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz",
"integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0"
}
},
"node_modules/cli-cursor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
@@ -4859,8 +4603,9 @@
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"devOptional": true,
"license": "MIT"
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/concat-map": {
"version": "0.0.1",
@@ -5196,20 +4941,6 @@
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/enhanced-resolve": {
"version": "5.18.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
"integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.2.0"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/enquirer": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz",
@@ -6384,16 +6115,6 @@
"node": ">=0.10.0"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/expect-type": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz",
@@ -6871,13 +6592,6 @@
"node": ">= 6"
}
},
"node_modules/glob-to-regexp": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
"integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/globals": {
"version": "13.24.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
@@ -6921,13 +6635,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/habitica-markdown": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/habitica-markdown/-/habitica-markdown-4.1.0.tgz",
@@ -7726,37 +7433,6 @@
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/jest-worker": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz",
"integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"merge-stream": "^2.0.0",
"supports-color": "^8.0.0"
},
"engines": {
"node": ">= 10.13.0"
}
},
"node_modules/jest-worker/node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/jquery": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz",
@@ -7841,13 +7517,6 @@
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
"license": "MIT"
},
"node_modules/json-parse-even-better-errors": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
"dev": true,
"license": "MIT"
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -7911,20 +7580,6 @@
"uc.micro": "^2.0.0"
}
},
"node_modules/loader-runner": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz",
"integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.11.5"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -8128,13 +7783,6 @@
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
"license": "MIT"
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
"dev": true,
"license": "MIT"
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@@ -8458,13 +8106,6 @@
"node": ">= 0.4.0"
}
},
"node_modules/neo-async": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"dev": true,
"license": "MIT"
},
"node_modules/nice-try": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz",
@@ -9504,63 +9145,6 @@
"node": ">=v12.22.7"
}
},
"node_modules/schema-utils": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz",
"integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/json-schema": "^7.0.9",
"ajv": "^8.9.0",
"ajv-formats": "^2.1.1",
"ajv-keywords": "^5.1.0"
},
"engines": {
"node": ">= 10.13.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/schema-utils/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/schema-utils/node_modules/ajv-keywords": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
"integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3"
},
"peerDependencies": {
"ajv": "^8.8.2"
}
},
"node_modules/schema-utils/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT"
},
"node_modules/secure-keys": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/secure-keys/-/secure-keys-1.0.0.tgz",
@@ -9838,8 +9422,9 @@
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"devOptional": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
@@ -10130,20 +9715,6 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tar-mini": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/tar-mini/-/tar-mini-0.2.0.tgz",
@@ -10154,8 +9725,9 @@
"version": "5.44.1",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz",
"integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==",
"devOptional": true,
"license": "BSD-2-Clause",
"optional": true,
"peer": true,
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.15.0",
@@ -10169,47 +9741,13 @@
"node": ">=10"
}
},
"node_modules/terser-webpack-plugin": {
"version": "5.3.14",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz",
"integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.25",
"jest-worker": "^27.4.5",
"schema-utils": "^4.3.0",
"serialize-javascript": "^6.0.2",
"terser": "^5.31.1"
},
"engines": {
"node": ">= 10.13.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
},
"peerDependencies": {
"webpack": "^5.1.0"
},
"peerDependenciesMeta": {
"@swc/core": {
"optional": true
},
"esbuild": {
"optional": true
},
"uglify-js": {
"optional": true
}
}
},
"node_modules/terser/node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"devOptional": true,
"license": "MIT",
"optional": true,
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -10585,8 +10123,9 @@
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"devOptional": true,
"license": "MIT"
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/update-browserslist-db": {
"version": "1.1.4",
@@ -10965,15 +10504,6 @@
"node": ">=6.0.0"
}
},
"node_modules/vue-fragment": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/vue-fragment/-/vue-fragment-1.6.0.tgz",
"integrity": "sha512-a5T8ZZZK/EQzgVShEl374HbobUJ0a7v12BzOzS6Z/wd/5EE/5SffcyHC+7bf9hP3L7Yc0hhY/GhMdwFQ25O/8A==",
"license": "MIT",
"peerDependencies": {
"vue": "^2.5.16"
}
},
"node_modules/vue-functional-data-merge": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/vue-functional-data-merge/-/vue-functional-data-merge-3.1.0.tgz",
@@ -11041,20 +10571,6 @@
"node": ">=18"
}
},
"node_modules/watchpack": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz",
"integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"glob-to-regexp": "^0.4.1",
"graceful-fs": "^4.1.2"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
@@ -11065,91 +10581,6 @@
"node": ">=12"
}
},
"node_modules/webpack": {
"version": "5.102.1",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz",
"integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",
"@types/json-schema": "^7.0.15",
"@webassemblyjs/ast": "^1.14.1",
"@webassemblyjs/wasm-edit": "^1.14.1",
"@webassemblyjs/wasm-parser": "^1.14.1",
"acorn": "^8.15.0",
"acorn-import-phases": "^1.0.3",
"browserslist": "^4.26.3",
"chrome-trace-event": "^1.0.2",
"enhanced-resolve": "^5.17.3",
"es-module-lexer": "^1.2.1",
"eslint-scope": "5.1.1",
"events": "^3.2.0",
"glob-to-regexp": "^0.4.1",
"graceful-fs": "^4.2.11",
"json-parse-even-better-errors": "^2.3.1",
"loader-runner": "^4.2.0",
"mime-types": "^2.1.27",
"neo-async": "^2.6.2",
"schema-utils": "^4.3.3",
"tapable": "^2.3.0",
"terser-webpack-plugin": "^5.3.11",
"watchpack": "^2.4.4",
"webpack-sources": "^3.3.3"
},
"bin": {
"webpack": "bin/webpack.js"
},
"engines": {
"node": ">=10.13.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
},
"peerDependenciesMeta": {
"webpack-cli": {
"optional": true
}
}
},
"node_modules/webpack-sources": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz",
"integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/webpack/node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/webpack/node_modules/acorn-import-phases": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz",
"integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.13.0"
},
"peerDependencies": {
"acorn": "^8.14.0"
}
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+1 -4
View File
@@ -46,7 +46,6 @@
"vite": "^6.3.6",
"vite-plugin-compression2": "^1.3.3",
"vue": "^2.7.10",
"vue-fragment": "^1.6.0",
"vue-mugen-scroll": "^0.2.6",
"vue-router": "^3.6.5",
"vuedraggable": "^2.24.3",
@@ -60,8 +59,6 @@
"jsdom": "^26.0.0",
"mocha": "^11.1.0",
"playwright": "^1.50.1",
"terser-webpack-plugin": "^5.3.10",
"vitest": "^3.0.5",
"webpack": "^5.94.0"
"vitest": "^3.0.5"
}
}
+12 -1
View File
@@ -22,8 +22,15 @@
height: 219px;
}
.quest_alien {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/quest_alien.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 {
.Pet_HatchingPotion_VirtualPet, .Pet_HatchingPotion_Fungi, .Pet_HatchingPotion_Cryptid,
.Pet_HatchingPotion_Alien {
width: 68px;
height: 68px;
}
@@ -52,6 +59,10 @@
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_Cryptid.gif") no-repeat;
}
.Pet_HatchingPotion_Alien {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_Alien.gif") no-repeat;
}
.Gems {
display:inline-block;
margin-right:5px;
@@ -1801,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;
@@ -31485,8 +31490,8 @@
width: 114px;
height: 90px;
}
.slim_armor_armoire_handstandOutfit {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_armoire_handstandOutfit .png');
.slim_armor_armoire_handstandOutfit {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_armoire_handstandOutfit.png');
width: 114px;
height: 90px;
}
@@ -53223,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;
@@ -53713,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;
@@ -54503,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;
@@ -54998,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;
@@ -55333,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;
@@ -56113,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;
@@ -56718,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;
@@ -58113,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;
@@ -58758,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;
+4
View File
@@ -16,6 +16,10 @@
border-bottom: 0;
}
.d-content {
display: contents;
}
* {
transition: none;
}
@@ -108,15 +108,15 @@ export default {
const allEmails = [];
if (user.auth.local.email) allEmails.push(user.auth.local.email);
if (user.auth.google && user.auth.google.emails) {
const emails = user.auth.google.emails;
const { emails } = user.auth.google;
allEmails.push(...this.findSocialEmails(emails));
}
if (user.auth.apple && user.auth.apple.emails) {
const emails = user.auth.apple.emails;
const { emails } = user.auth.apple;
allEmails.push(...this.findSocialEmails(emails));
}
if (user.auth.facebook && user.auth.facebook.emails) {
const emails = user.auth.facebook.emails;
const { emails } = user.auth.facebook;
allEmails.push(...this.findSocialEmails(emails));
}
return allEmails;
@@ -609,7 +609,7 @@ import subscriptionBlocks from '@/../../common/script/content/subscriptionBlocks
import saveHero from '../mixins/saveHero';
import LoadingSpinner from '@/components/ui/loadingSpinner';
const PLAY_CONSOLE_ORDERS_BASE_URL = import.meta.env.PLAY_CONSOLE_ORDERS_BASE_URL;
const { PLAY_CONSOLE_ORDERS_BASE_URL } = import.meta.env;
const humanReadablePaymentDetails = {
customerId: {
@@ -20,6 +20,29 @@
class="form mx-auto"
@submit.prevent.stop="register()"
>
<div v-if="needsEmailField">
<input
id="emailInput"
v-model="email"
class="form-control dark"
type="text"
:placeholder="$t('emailAddress')"
:class="{
'mb-3': !emailError,
'input-invalid input-with-error mb-2': emailError,
'input-valid': email && emailValid,
}"
>
<div
v-if="emailError"
class="input-error"
>
{{ emailError }}
</div>
<p class="purple-600 mb-3">
{{ $t('emailRequiredForSupport') }}
</p>
</div>
<input
id="usernameInput"
v-model="username"
@@ -58,8 +81,9 @@
></label>
</div>
<button
class="btn btn-info d-block w-100 sign-up mx-auto mb-5"
:disabled="!username || usernameInvalid || !privacyAccepted"
class="btn btn-info d-flex justify-content-center
align-items-center w-100 sign-up mx-auto mb-5"
:disabled="!email || emailError || !username || usernameInvalid || !privacyAccepted"
type="submit"
>
{{ $t('getStarted') }}
@@ -133,10 +157,12 @@
border: 2px solid transparent;
box-shadow: 0 1px 3px 0 rgba($black, 0.16), 0 1px 3px 0 rgba($black, 0.24);
&:focus, &:active {
background-color: $blue-50;
border: 2px solid $purple-400;
box-shadow: 0 3px 6px 0 rgba($black, 0.16), 0 3px 6px 0 rgba($black, 0.24);
&:not(:disabled):not(.disabled) {
&:focus, &:active {
background-color: $blue-50;
border: 2px solid $purple-400;
box-shadow: 0 3px 6px 0 rgba($black, 0.16), 0 3px 6px 0 rgba($black, 0.24);
}
}
}
@@ -148,23 +174,19 @@
<script>
import debounce from 'lodash/debounce';
import PrivacyBanner from '@/components/header/banners/privacy';
import accountCreation from '@/mixins/accountCreation';
import sanitizeRedirect from '@/mixins/sanitizeRedirect';
export default {
components: {
PrivacyBanner,
},
mixins: [sanitizeRedirect],
mixins: [accountCreation, sanitizeRedirect],
data () {
return {
authData: {},
email: '',
password: '',
passwordConfirm: '',
privacyAccepted: false,
registrationMethod: null,
username: '',
usernameIssues: [],
needsEmailField: false,
};
},
computed: {
@@ -183,30 +205,40 @@ export default {
},
},
mounted () {
if (window.sessionStorage.getItem('apple-token')) {
this.registrationMethod = 'apple';
} else if (!this.$store.state.registrationOptions.registrationMethod) {
this.$router.push('/');
} else {
this.registrationMethod = this.$store.state.registrationOptions.registrationMethod;
}
this.authData = this.$store.state.registrationOptions.authData;
this.email = this.$store.state.registrationOptions.email;
this.username = this.$store.state.registrationOptions.username;
this.password = this.$store.state.registrationOptions.password;
this.passwordConfirm = this.$store.state.registrationOptions.passwordConfirm;
if (!this.email) {
if (window.sessionStorage.getItem('apple-token')) {
this.registrationMethod = 'apple';
if (!this.email) {
this.email = window.sessionStorage.getItem('apple-email');
}
} else if (!this.$store.state.registrationOptions.registrationMethod) {
this.$router.push('/');
} else {
this.registrationMethod = this.$store.state.registrationOptions.registrationMethod;
}
if (!this.email && this.registrationMethod !== 'apple') {
return;
}
const usernameToCheck = this.email.split('@')[0].replace(/[^a-zA-Z0-9\-_]/g, '');
this.$store.dispatch('auth:verifyUsername', {
username: usernameToCheck,
}).then(res => {
if (!res.issues) {
this.username = usernameToCheck;
}
});
if ((!this.email || this.email === '') && this.registrationMethod === 'apple') {
this.needsEmailField = true;
}
if (this.email) {
const usernameToCheck = this.email.split('@')[0].replace(/[^a-zA-Z0-9\-_]/g, '');
this.$store.dispatch('auth:verifyUsername', {
username: usernameToCheck,
}).then(res => {
if (!res.issues) {
this.username = usernameToCheck;
}
});
}
document.getElementById('usernameInput').focus();
},
methods: {
@@ -237,6 +269,7 @@ export default {
idToken: window.sessionStorage.getItem('apple-token'),
name: window.sessionStorage.getItem('apple-name'),
username: this.username,
email: this.email,
allowRegister: true,
});
} else {
+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 '';
@@ -189,7 +189,7 @@ export default {
this.cancel();
return [];
}
this.currentSearch = regexRes[1];
this.currentSearch = regexRes[1]; // eslint-disable-line prefer-destructuring
if (this.currentSearch.length === 0) return [];
@@ -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"
@@ -470,7 +470,7 @@ export default {
return this.userGuilds.filter(group => {
const leaderId = group.leader?._id || group.leader;
if (leaderId !== this.user._id) return false;
const purchased = group.purchased;
const { purchased } = group;
if (!purchased?.wasUpgraded) return false;
if (this.activeGroupPlanIds.includes(group._id)) return false;
if (!purchased.dateTerminated) return false;
@@ -492,7 +492,7 @@ export default {
},
isPartyPreviouslyUpgraded () {
if (!this.userParty) return false;
const purchased = this.userParty.purchased;
const { purchased } = this.userParty;
if (!purchased?.wasUpgraded) return false;
if (!purchased.dateTerminated) return false;
return new Date(purchased.dateTerminated) < new Date();
@@ -533,7 +533,7 @@ export default {
this.$nextTick(() => {
if (this.upgradeableGuilds.length > 0) {
this.selectedOption = this.upgradeableGuilds[0];
[this.selectedOption] = this.upgradeableGuilds;
} else if (this.upgradeableParty) {
this.selectedOption = this.upgradeableParty;
} else {
@@ -198,7 +198,6 @@ import dailyIcon from '@/assets/svg/daily.svg?raw';
import todoIcon from '@/assets/svg/todo.svg?raw';
import rewardIcon from '@/assets/svg/reward.svg?raw';
import * as Analytics from '@/libs/analytics';
import { mapState } from '@/libs/store';
export default {
@@ -438,14 +437,6 @@ export default {
return false;
},
changeMirrorPreference (newVal) {
Analytics.track({
eventName: 'mirror tasks',
eventAction: 'mirror tasks',
eventCategory: 'behavior',
hitType: 'event',
mirror: newVal,
group: this.group._id,
}, { trackOnClient: true });
const groupsToMirror = this.user.preferences.tasks.mirrorGroupTasks || [];
if (newVal) { // we're turning copy ON for this group
groupsToMirror.push(this.group._id);
@@ -240,7 +240,6 @@
<script>
import { mapState } from '@/libs/store';
import * as Analytics from '@/libs/analytics';
import notifications from '@/mixins/notifications';
import closeX from '../ui/closeX';
@@ -276,11 +275,6 @@ export default {
this.$store.state.party.data = party;
this.user.party._id = party._id;
Analytics.updateUser({
partyID: party._id,
partySize: 1,
});
this.$root.$emit('bv::hide::modal', 'create-party-modal');
await this.$router.push('/party');
},
@@ -314,7 +314,6 @@ import extend from 'lodash/extend';
import groupUtilities from '@/mixins/groupsUtilities';
import styleHelper from '@/mixins/styleHelper';
import { mapGetters } from '@/libs/store';
import * as Analytics from '@/libs/analytics';
import participantListModal from './participantListModal';
import groupFormModal from './groupFormModal';
import groupGemsModal from '@/components/groups/groupGemsModal';
@@ -560,7 +559,6 @@ export default {
if (this.isParty) {
data.type = 'party';
Analytics.updateUser({ partySize: null, partyID: null });
this.$store.state.partyMembers = [];
}
@@ -334,7 +334,6 @@ import orderBy from 'lodash/orderBy';
import * as quests from '@/../../common/script/content/quests';
import getItemInfo from '@/../../common/script/libs/getItemInfo';
import { mapState } from '@/libs/store';
import * as Analytics from '@/libs/analytics';
import navigationBack from '@/assets/svg/navigation_back.svg?raw';
import questDialogContent from '../shops/quests/questDialogContent';
@@ -421,11 +420,6 @@ export default {
async questInit () {
this.loading = true;
Analytics.updateUser({
partyID: this.group._id,
partySize: this.group.memberCount,
});
const groupId = this.group._id || this.user.party._id;
const key = this.selectedQuest;
@@ -123,7 +123,6 @@
<script>
import orderBy from 'lodash/orderBy';
import * as Analytics from '@/libs/analytics';
import { mapGetters, mapActions } from '@/libs/store';
import MemberDetails from '../memberDetails';
import createPartyModal from '../groups/createPartyModal';
@@ -236,22 +235,8 @@ export default {
},
async createOrInviteParty () {
if (this.user.party._id) {
await Analytics.track({
eventName: 'Header Party CTA',
eventAction: 'Header Party CTA',
eventCategory: 'behavior',
hitType: 'event',
state: 'Find Party Members',
});
this.$router.push('/looking-for-party');
} else {
await Analytics.track({
eventName: 'Header Party CTA',
eventAction: 'Header Party CTA',
eventCategory: 'behavior',
hitType: 'event',
state: 'Get Started',
});
this.$root.$emit('bv::show::modal', 'create-party-modal');
}
},
@@ -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())) {
@@ -114,7 +114,6 @@ import { mapState } from '@/libs/store';
import notifications from '@/mixins/notifications';
import guide from '@/mixins/guide';
import { CONSTANTS, setLocalSetting } from '@/libs/userlocalManager';
import * as Analytics from '@/libs/analytics';
import yesterdailyModal from './tasks/yesterdailyModal';
import newStuff from './news/modal';
@@ -648,15 +647,6 @@ export default {
// Reset daily analytics actions
setLocalSetting(CONSTANTS.keyConstants.TASKS_SCORED_COUNT, 0);
setLocalSetting(CONSTANTS.keyConstants.TASKS_CREATED_COUNT, 0);
} else {
// Note a failed cron event, for our records and investigation
Analytics.track({
eventName: 'cron failed',
eventAction: 'cron failed',
eventCategory: 'behavior',
hitType: 'event',
responseCode: response.status,
}, { trackOnClient: true });
}
// Sync
@@ -433,9 +433,6 @@ import lockableLabel from '@/components/tasks/modal-controls/lockableLabel';
import notificationsMixin from '@/mixins/notifications';
import paymentsMixin from '@/mixins/payments';
// analytics
import * as Analytics from '@/libs/analytics';
export default {
components: {
selectTranslatedArray,
@@ -536,16 +533,6 @@ export default {
this.close();
},
submit () {
if (this.paymentData.group && !this.paymentData.newGroup) {
Analytics.track({
hitType: 'event',
eventName: 'group plan upgrade',
eventAction: 'group plan upgrade',
eventCategory: 'behavior',
demographics: this.upgradedGroup.demographics,
type: this.paymentData.group.type,
}, { trackOnClient: true });
}
this.paymentData = {};
this.$root.$emit('bv::hide::modal', 'payments-success-modal');
},
@@ -37,6 +37,9 @@ export default {
window.location.href = '/';
} else {
window.sessionStorage.setItem('apple-token', response.idToken);
if (response.email) {
window.sessionStorage.setItem('apple-email', response.email);
}
window.location.href = '/username';
}
},
@@ -83,7 +83,7 @@
</div>
</div>
<draggable
v-if="taskList.length > 0"
v-if="taskList.length > 0 && !rerendering"
ref="tasksList"
class="sortable-tasks"
:disabled="activeFilter.label === 'scheduled' || !canBeDragged()"
@@ -432,6 +432,7 @@ export default {
selectedItemToBuy: {},
dragging: false,
rerendering: false,
};
},
computed: {
@@ -548,8 +549,8 @@ export default {
if (this.taskListOverride) originTasks = this.taskListOverride;
// Server
const taskIdToReplace = filteredList[data.newIndex];
const newIndexOnServer = originTasks.findIndex(taskId => taskId === taskIdToReplace);
const taskIdToReplace = filteredList[data.newIndex]._id;
const newIndexOnServer = originTasks.findIndex(task => task._id === taskIdToReplace);
let newOrder;
if (taskToMove.group.id && !this.isUser) {
@@ -568,6 +569,9 @@ export default {
// Client
const deleted = originTasks.splice(data.oldIndex, 1);
originTasks.splice(data.newIndex, 0, deleted[0]);
this.rerendering = true;
await this.$nextTick();
this.rerendering = false;
},
async moveTo (task, where) { // where is 'top' or 'bottom'
const taskIdToMove = task._id;
+5 -17
View File
@@ -13,6 +13,8 @@
}, `type_${task.type}`
]"
@click="castEnd($event, task)"
tabindex="0"
@keypress.enter="$emit('editTask', task)"
>
<div
class="d-flex"
@@ -98,9 +100,7 @@
<div
class="task-clickable-area pt-1 pl-75 pb-0"
:class="{ 'cursor-auto': !teamManagerAccess }"
tabindex="0"
@click="edit($event, task)"
@keypress.enter="edit($event, task)"
>
<div class="d-flex justify-content-between">
<h3
@@ -432,10 +432,6 @@
outline: none;
transition: none;
border: $purple-400 solid 1px;
:not(task-best-control-inner-habit) { // round icon
border-radius: 4px;
}
}
.control-bottom-box {
@@ -462,16 +458,13 @@
&:hover:not(.task-not-editable.task-not-scoreable),
&:focus-within:not(.task-not-editable.task-not-scoreable) {
box-shadow: 0 1px 8px 0 rgba($black, 0.12), 0 4px 4px 0 rgba($black, 0.16);
z-index: 11;
}
}
.task:not(.groupTask) {
&:hover,
&:focus-within {
.left-control, .right-control, .task-content {
border-color: $purple-400;
}
&:hover, &:focus {
border: none;
outline: 1px solid $purple-400;
}
}
@@ -522,11 +515,6 @@
&-user {
padding-right: 0px;
}
&:focus {
border-radius: 4px;
border: $purple-400 solid 1px;
}
}
.task-title + .task-dropdown ::v-deep .dropdown-menu {
@@ -55,11 +55,31 @@
</div>
</div>
<div class="form-group">
<lockable-label
:class-override="cssClass('headings')"
:locked="challengeAccessRequired"
:text="`${$t('text')}*`"
/>
<div class="d-flex align-items-center">
<lockable-label
class="mr-auto"
:class-override="cssClass('headings')"
:locked="challengeAccessRequired"
:text="`${$t('text')}*`"
/>
<div
id="spi-alert"
class="d-flex align-items-center"
:class="cssClass('headings')"
>
<div
class="svg svg-icon color icon-16 mr-1"
v-html="icons.alert"
></div>
<small
class="my-1"
>
<a
:class="cssClass('headings')"
>{{ $t('avoidSPI') }}</a>
</small>
</div>
</div>
<input
ref="inputToFocus"
v-model="task.text"
@@ -79,10 +99,20 @@
@keydown.esc="autoCompleteMixinHandleEscape($event)"
>
</div>
<b-popover
:target="'spi-alert'"
triggers="hover"
placement="bottom"
offset="-128"
>
<div
v-html="$t('avoidSPIDetails', spiLinkData)">
</div>
</b-popover>
<div
class="form-group mb-0"
>
<div class="d-flex">
<div class="d-flex align-items-center">
<lockable-label
class="mr-auto"
:class-override="cssClass('headings')"
@@ -963,6 +993,20 @@
box-shadow: 0px 1px 3px 0px rgba(26, 24, 29, 0.12), 0px 1px 2px 0px rgba(26, 24, 29, 0.24);
}
}
.b-popover {
margin-top: -5px;
max-width: 330px;
}
.popover-body {
text-align: left;
a {
color: $gray-500;
text-decoration: underline;
}
}
}
@media only screen and (max-width: 768px) {
@@ -1196,6 +1240,7 @@ import chevronIcon from '@/assets/svg/chevron.svg?raw';
import calendarIcon from '@/assets/svg/calendar.svg?raw';
import gripIcon from '@/assets/svg/grip.svg?raw';
import InformationIcon from '@/components/ui/informationIcon.vue';
import alertIcon from '@/assets/svg/for-css/alert-white.svg?raw';
export default {
components: {
@@ -1231,6 +1276,7 @@ export default {
streak: streakIcon,
calendar: calendarIcon,
grip: gripIcon,
alert: alertIcon,
}),
members: [],
membersNameAndId: [],
@@ -1251,6 +1297,11 @@ export default {
{ key: 'per', label: 'perception', description: 'perTaskText' },
],
calendarHighlights: { dates: [new Date()] },
spiLinkData: {
firstLink: '<a href="/static/privacy#section_1" target="_blank">',
secondLink: '<a href="/static/privacy" target="_blank">',
linkClose: '</a>',
},
};
},
computed: {
@@ -1399,7 +1450,7 @@ export default {
this.task.down = !this.task.down;
},
weekdaysMin (dayNumber) {
return moment.weekdaysMin(dayNumber);
return this.$t(`weekdaysMin${dayNumber}`);
},
formattedDate (date) {
return moment(date).format('MM/DD/YYYY');
+1 -1
View File
@@ -6,7 +6,7 @@ import amplitude from 'amplitude-js';
import Vue from 'vue';
import getStore from '@/store';
const AMPLITUDE_KEY = import.meta.env.AMPLITUDE_KEY;
const { AMPLITUDE_KEY } = import.meta.env;
const REQUIRED_FIELDS = ['eventCategory', 'eventAction'];
let analyticsLoading = false;
-18
View File
@@ -1,28 +1,10 @@
// Vue plugin to globally expose a '$t' method that calls common/i18n.t.
// Can be anywhere inside vue as 'this.$t' or '$t' in templates.
import moment from 'moment';
import i18n from '@/../../common/script/i18n';
function loadLocale (i18nData) {
// Load i18n strings
i18n.strings = i18nData.strings;
// Load Moment.js locale
const { language } = i18nData;
if (language && i18nData.momentLang && language.momentLangCode) {
// Make moment available under `window` so that the locale can be set
window.moment = moment;
// Execute the script and set the locale
const head = document.getElementsByTagName('head')[0];
const script = document.createElement('script');
script.type = 'text/javascript';
script.text = i18nData.momentLang;
head.appendChild(script);
moment.updateLocale(language.momentLangCode);
}
}
export default {
-2
View File
@@ -11,7 +11,6 @@ import {
NavbarPlugin,
CollapsePlugin,
} from 'bootstrap-vue';
import Fragment from 'vue-fragment';
import AppComponent from './app';
import { setUpLogging } from '@/libs/logging';
import router from './router/index';
@@ -44,7 +43,6 @@ Vue.use(FormRadioPlugin);
Vue.use(TooltipPlugin);
Vue.use(NavbarPlugin);
Vue.use(CollapsePlugin);
Vue.use(Fragment.Plugin);
setUpLogging();
const store = getStore();
+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;
},
},
};
+1 -12
View File
@@ -6,9 +6,8 @@ import { mapState } from '@/libs/store';
import encodeParams from '@/libs/encodeParams';
import notificationsMixin from '@/mixins/notifications';
import { CONSTANTS, setLocalSetting } from '@/libs/userlocalManager';
import * as Analytics from '@/libs/analytics';
const STRIPE_PUB_KEY = import.meta.env.STRIPE_PUB_KEY;
const { STRIPE_PUB_KEY } = import.meta.env;
let stripeInstance = null;
@@ -207,16 +206,6 @@ export default {
alert(`Error while redirecting to Stripe: ${checkoutSessionResult.error.message}`);
throw checkoutSessionResult.error;
}
if (paymentType === 'groupPlan') {
Analytics.track({
hitType: 'event',
eventName: 'group plan create',
eventAction: 'group plan create',
eventCategory: 'behavior',
demographics: appState.newGroup.demographics,
type: appState.newGroup.type,
}, { trackOnClient: true });
}
} catch (err) {
console.error('Error while redirecting to Stripe', err); // eslint-disable-line
alert(`Error while redirecting to Stripe: ${err.message}`);
-10
View File
@@ -3,7 +3,6 @@ import Vue from 'vue';
import scoreTask from '@/../../common/script/ops/scoreTask';
import notifications from './notifications';
import { mapState } from '@/libs/store';
import * as Analytics from '@/libs/analytics';
import { CONSTANTS, getLocalSetting, setLocalSetting } from '@/libs/userlocalManager';
export default {
@@ -58,15 +57,6 @@ export default {
const tasksScoredCount = getLocalSetting(CONSTANTS.keyConstants.TASKS_SCORED_COUNT);
if (!tasksScoredCount || tasksScoredCount < 2) {
Analytics.track({
eventName: 'task scored',
eventAction: 'task scored',
eventCategory: 'behavior',
hitType: 'event',
uuid: user._id,
taskType: task.type,
direction,
}, { trackOnClient: true });
if (!tasksScoredCount) {
setLocalSetting(CONSTANTS.keyConstants.TASKS_SCORED_COUNT, 1);
} else {
@@ -1,5 +1,5 @@
<template>
<fragment>
<div class="d-content">
<tr
v-if="!mixinData.inlineSettingMixin.modalVisible"
>
@@ -90,7 +90,7 @@
/>
</td>
</tr>
</fragment>
</div>
</template>
<style lang="scss" scoped>
@@ -1,5 +1,5 @@
<template>
<fragment v-if="allowedToChangeClass">
<div class="d-content" v-if="allowedToChangeClass">
<tr
v-if="!mixinData.inlineSettingMixin.modalVisible"
>
@@ -71,7 +71,7 @@
</div>
</td>
</tr>
</fragment>
</div>
</template>
<style lang="scss" scoped>
@@ -1,5 +1,5 @@
<template>
<fragment>
<div class="d-content">
<tr
v-if="!mixinData.inlineSettingMixin.modalVisible"
>
@@ -55,7 +55,7 @@
/>
</td>
</tr>
</fragment>
</div>
</template>
<style lang="scss" scoped>
@@ -1,5 +1,5 @@
<template>
<fragment>
<div class="d-content">
<tr
v-if="!mixinData.inlineSettingMixin.modalVisible"
>
@@ -77,7 +77,7 @@
</div>
</td>
</tr>
</fragment>
</div>
</template>
<style lang="scss" scoped>
@@ -1,5 +1,5 @@
<template>
<fragment>
<div class="d-content">
<tr
v-if="!mixinData.inlineSettingMixin.modalVisible"
>
@@ -94,7 +94,7 @@
</div>
</td>
</tr>
</fragment>
</div>
</template>
<style lang="scss" scoped>
@@ -1,5 +1,5 @@
<template>
<fragment>
<div class="d-content">
<tr
v-if="!mixinData.inlineSettingMixin.modalVisible"
>
@@ -78,7 +78,7 @@
</div>
</td>
</tr>
</fragment>
</div>
</template>
<style lang="scss" scoped>
@@ -1,5 +1,5 @@
<template>
<fragment>
<div class="d-content">
<tr
v-if="!mixinData.inlineSettingMixin.modalVisible"
>
@@ -83,7 +83,7 @@
/>
</td>
</tr>
</fragment>
</div>
</template>
<style lang="scss" scoped>
@@ -1,5 +1,5 @@
<template>
<fragment>
<div class="d-content">
<tr>
<td class="settings-label">
{{ $t("showHeader") }}
@@ -26,7 +26,7 @@
/>
</td>
</tr>
</fragment>
</div>
</template>
<style lang="scss" scoped>
@@ -1,5 +1,5 @@
<template>
<fragment>
<div class="d-content">
<tr
v-if="!mixinData.inlineSettingMixin.modalVisible"
>
@@ -67,7 +67,7 @@
/>
</td>
</tr>
</fragment>
</div>
</template>
<style lang="scss" scoped>
@@ -1,5 +1,5 @@
<template>
<fragment>
<div class="d-content">
<tr
v-for="network in SOCIAL_AUTH_NETWORKS"
:key="network.key"
@@ -39,7 +39,7 @@
</a>
</td>
</tr>
</fragment>
</div>
</template>
<script>
@@ -1,5 +1,5 @@
<template>
<fragment>
<div class="d-content">
<tr
v-if="!mixinData.inlineSettingMixin.modalVisible"
>
@@ -66,7 +66,7 @@
/>
</td>
</tr>
</fragment>
</div>
</template>
<style lang="scss" scoped>
@@ -1,5 +1,5 @@
<template>
<fragment>
<div class="d-content">
<tr
v-if="!mixinData.inlineSettingMixin.modalVisible"
>
@@ -111,7 +111,7 @@
</div>
</td>
</tr>
</fragment>
</div>
</template>
<style lang="scss" scoped>
@@ -1,5 +1,5 @@
<template>
<fragment>
<div class="d-content">
<tr
v-if="!mixinData.inlineSettingMixin.modalVisible"
>
@@ -56,7 +56,7 @@
</div>
</td>
</tr>
</fragment>
</div>
</template>
<style lang="scss" scoped>
@@ -1,5 +1,5 @@
<template>
<fragment>
<div class="d-content">
<tr
v-if="!mixinData.inlineSettingMixin.modalVisible"
>
@@ -60,7 +60,7 @@
</div>
</td>
</tr>
</fragment>
</div>
</template>
<style lang="scss" scoped>
@@ -1,5 +1,5 @@
<template>
<fragment>
<div class="d-content">
<tr
v-if="!mixinData.inlineSettingMixin.modalVisible"
>
@@ -54,7 +54,7 @@
</div>
</td>
</tr>
</fragment>
</div>
</template>
<style lang="scss" scoped>
@@ -1,5 +1,5 @@
<template>
<fragment>
<div class="d-content">
<tr
v-if="!mixinData.inlineSettingMixin.modalVisible"
>
@@ -48,7 +48,7 @@
/>
</td>
</tr>
</fragment>
</div>
</template>
<style lang="scss" scoped>
@@ -1,5 +1,5 @@
<template>
<fragment>
<div class="d-content">
<tr
v-if="!mixinData.inlineSettingMixin.modalVisible"
>
@@ -76,7 +76,7 @@
/>
</td>
</tr>
</fragment>
</div>
</template>
<style lang="scss" scoped>
+1 -2
View File
@@ -4,6 +4,7 @@
:class="{
'casting-spell': castingSpell,
}"
@dragover.prevent
>
<!-- <banned-account-modal /> -->
<amazon-payments-modal v-if="!isStaticPage" />
@@ -130,7 +131,6 @@ import PrivacyBanner from '@/components/header/banners/privacy';
import AppFooter from '@/components/appFooter';
import notificationsDisplay from '@/components/notifications';
import { mapState } from '@/libs/store';
import * as Analytics from '@/libs/analytics';
import BuyModal from '@/components/shops/buyModal.vue';
import SelectMembersModal from '@/components/selectMembersModal.vue';
import notifications from '@/mixins/notifications';
@@ -276,7 +276,6 @@ export default {
}
}
Analytics.updateUser();
return this.loadAllTranslations();
}).then(() => {
this.$store.state.isUserLoaded = true;
+47 -43
View File
@@ -1,6 +1,5 @@
import Vue from 'vue';
import VueRouter from 'vue-router';
import * as Analytics from '@/libs/analytics';
import getStore from '@/store';
import handleRedirect from './handleRedirect';
@@ -11,56 +10,56 @@ import { DEPRECATED_ROUTES } from '@/router/deprecated-routes';
// NOTE: when adding a page make sure to implement the `common:setTitle` action
const Logout = () => import(/* webpackChunkName: "auth" */'@/components/auth/logout');
const Logout = () => import('@/components/auth/logout');
// Hall
const HallPage = () => import(/* webpackChunkName: "hall" */'@/components/hall/index');
const PatronsPage = () => import(/* webpackChunkName: "hall" */'@/components/hall/patrons');
const HeroesPage = () => import(/* webpackChunkName: "hall" */'@/components/hall/heroes');
const HallPage = () => import('@/components/hall/index');
const PatronsPage = () => import('@/components/hall/patrons');
const HeroesPage = () => import('@/components/hall/heroes');
// Admin Pages
const AdminContainerPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin/container');
const AdminPanelPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin/admin-panel');
const AdminPanelUserPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin/admin-panel/user-support');
const AdminPanelSearchPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin/admin-panel/search');
const GroupAdminPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin/groups');
const GroupAdminGroupPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin/groups/group-support');
const BlockerPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin/blocker');
const AdminContainerPage = () => import('@/components/admin/container');
const AdminPanelPage = () => import('@/components/admin/admin-panel');
const AdminPanelUserPage = () => import('@/components/admin/admin-panel/user-support');
const AdminPanelSearchPage = () => import('@/components/admin/admin-panel/search');
const GroupAdminPage = () => import('@/components/admin/groups');
const GroupAdminGroupPage = () => import('@/components/admin/groups/group-support');
const BlockerPage = () => import('@/components/admin/blocker');
// Tasks
const UserTasks = () => import(/* webpackChunkName: "userTasks" */'@/components/tasks/user');
const UserTasks = () => import('@/components/tasks/user');
// Inventory
const InventoryContainer = () => import(/* webpackChunkName: "inventory" */'@/components/inventory/index');
const ItemsPage = () => import(/* webpackChunkName: "inventory" */'@/components/inventory/items/index');
const EquipmentPage = () => import(/* webpackChunkName: "inventory" */'@/components/inventory/equipment/index');
const StablePage = () => import(/* webpackChunkName: "inventory" */'@/components/inventory/stable/index');
const InventoryContainer = () => import('@/components/inventory/index');
const ItemsPage = () => import('@/components/inventory/items/index');
const EquipmentPage = () => import('@/components/inventory/equipment/index');
const StablePage = () => import('@/components/inventory/stable/index');
// Guilds & Parties
const GroupPage = () => import(/* webpackChunkName: "guilds" */ '@/components/groups/group');
const GroupPlansAppPage = () => import(/* webpackChunkName: "guilds" */ '@/components/static/groupPlans');
const LookingForParty = () => import(/* webpackChunkName: "guilds" */ '@/components/groups/lookingForParty');
const GroupPage = () => import('@/components/groups/group');
const GroupPlansAppPage = () => import('@/components/static/groupPlans');
const LookingForParty = () => import('@/components/groups/lookingForParty');
// Group Plans
const GroupPlanIndex = () => import(/* webpackChunkName: "group-plans" */ '@/components/group-plans/index');
const GroupPlanTaskInformation = () => import(/* webpackChunkName: "group-plans" */ '@/components/group-plans/taskInformation');
const GroupPlanBilling = () => import(/* webpackChunkName: "group-plans" */ '@/components/group-plans/billing');
const GroupPlanIndex = () => import('@/components/group-plans/index');
const GroupPlanTaskInformation = () => import('@/components/group-plans/taskInformation');
const GroupPlanBilling = () => import('@/components/group-plans/billing');
const MessagesIndex = () => import(/* webpackChunkName: "private-messages" */ '@/pages/private-messages/index.vue');
const MessagesIndex = () => import('@/pages/private-messages/index.vue');
// Challenges
const ChallengeIndex = () => import(/* webpackChunkName: "challenges" */ '@/components/challenges/index');
const MyChallenges = () => import(/* webpackChunkName: "challenges" */ '@/components/challenges/myChallenges');
const FindChallenges = () => import(/* webpackChunkName: "challenges" */ '@/components/challenges/findChallenges');
const ChallengeDetail = () => import(/* webpackChunkName: "challenges" */ '@/components/challenges/challengeDetail');
const ChallengeIndex = () => import('@/components/challenges/index');
const MyChallenges = () => import('@/components/challenges/myChallenges');
const FindChallenges = () => import('@/components/challenges/findChallenges');
const ChallengeDetail = () => import('@/components/challenges/challengeDetail');
// Shops
const ShopsContainer = () => import(/* webpackChunkName: "shops" */'@/components/shops/index');
const MarketPage = () => import(/* webpackChunkName: "shops-market" */'@/components/shops/market/index');
const QuestsPage = () => import(/* webpackChunkName: "shops-quest" */'@/components/shops/quests/index');
const CustomizationsPage = () => import(/* webpackChunkName: "shops-customizations" */'@/components/shops/customizations/index');
const SeasonalPage = () => import(/* webpackChunkName: "shops-seasonal" */'@/components/shops/seasonal/index');
const TimeTravelersPage = () => import(/* webpackChunkName: "shops-timetravelers" */'@/components/shops/timeTravelers/index');
const ShopsContainer = () => import('@/components/shops/index');
const MarketPage = () => import('@/components/shops/market/index');
const QuestsPage = () => import('@/components/shops/quests/index');
const CustomizationsPage = () => import('@/components/shops/customizations/index');
const SeasonalPage = () => import('@/components/shops/seasonal/index');
const TimeTravelersPage = () => import('@/components/shops/timeTravelers/index');
Vue.use(VueRouter);
@@ -85,6 +84,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' },
{
@@ -311,15 +317,6 @@ router.beforeEach(async (to, from, next) => {
router.app.$root.$emit('update-party');
}
if (to.name === 'lookingForParty') {
Analytics.track({
hitType: 'event',
eventName: 'View Find Members',
eventAction: 'View Find Members',
eventCategory: 'behavior',
}, { trackOnClient: true });
}
// Redirect old guild urls
if (to.hash.indexOf('#/options/groups/guilds/') !== -1) {
const splits = to.hash.split('/');
@@ -410,6 +407,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();
});
+2 -2
View File
@@ -21,8 +21,8 @@ const NewsPage = () => import('@/components/static/newStuff');
const OverviewPage = () => import('@/components/static/overview');
const PressKitPage = () => import('@/components/static/pressKit');
const PrivacyPage = () => import('@/components/static/privacy');
const RegisterLoginReset = () => import(/* webpackChunkName: "auth" */'@/components/auth/registerLoginReset');
const RegisterUsername = () => import(/* webpackChunkName: "auth" */'@/components/auth/registerUsername');
const RegisterLoginReset = () => import('@/components/auth/registerLoginReset');
const RegisterUsername = () => import('@/components/auth/registerUsername');
const SubscriptionBenefitsFaq = () => import('@/components/static/subscriptionBenefitsFaq');
const TermsPage = () => import('@/components/static/terms');
+5 -1
View File
@@ -101,6 +101,7 @@ export async function appleAuth (store, params) {
id_token: params.idToken,
name: params.name,
username: params.username,
email: params.email,
},
});
@@ -109,7 +110,10 @@ export async function appleAuth (store, params) {
}
if (result.data.message && result.data.id_token) {
return { idToken: result.data.id_token };
return {
idToken: result.data.id_token,
email: result.data.email,
};
}
const user = result.data.data;
-8
View File
@@ -1,6 +1,5 @@
import axios from 'axios';
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?limit=400`);
@@ -17,13 +16,6 @@ export async function postChat (store, payload) {
url += `?previousMsg=${payload.previousMsg}`;
}
if (group.type === 'party') {
Analytics.updateUser({
partyID: group.id,
partySize: group.memberCount,
});
}
const response = await axios.post(url, {
message: payload.message,
});
@@ -1,7 +1,6 @@
import axios from 'axios';
import omit from 'lodash/omit';
import findIndex from 'lodash/findIndex';
import * as Analytics from '@/libs/analytics';
import { loadAsyncResource } from '@/libs/asyncResource';
export async function getPublicGuilds (store, payload) {
@@ -74,7 +73,6 @@ export async function join (store, payload) {
if (invitationI !== -1) invitations.parties.splice(invitationI, 1);
user.party._id = groupId;
Analytics.updateUser({ partyID: groupId });
// load the party members so that they get shown in the header
store.dispatch('party:getMembers');
}
@@ -18,7 +18,6 @@ import * as shops from './shops';
import * as snackbars from './snackbars';
import * as worldState from './worldState';
import * as news from './news';
import * as analytics from './analytics';
import * as faq from './faq';
import * as blockers from './blockers';
@@ -44,7 +43,6 @@ const actions = flattenAndNamespace({
snackbars,
worldState,
news,
analytics,
faq,
blockers,
});
@@ -1,26 +1,6 @@
import axios from 'axios';
import * as Analytics from '@/libs/analytics';
// export async function initQuest (store) {
// }
export async function sendAction (store, payload) { // eslint-disable-line import/prefer-default-export, max-len
// @TODO: Maybe move this to server
let partyData = {};
if (store.state.party && store.state.party.data) {
partyData = {
partyID: store.state.party.data._id,
partySize: store.state.party.data.memberCount,
};
} else {
partyData = {
partyID: store.state.user.data.party._id,
partySize: store.state.partyMembers.data.length,
};
}
Analytics.updateUser(partyData);
const response = await axios.post(`/api/v4/groups/${payload.groupId}/${payload.action}`);
// @TODO: Update user?
+8 -14
View File
@@ -3,7 +3,6 @@ import Vue from 'vue';
import compact from 'lodash/compact';
import omit from 'lodash/omit';
import { loadAsyncResource } from '@/libs/asyncResource';
import * as Analytics from '@/libs/analytics';
import { CONSTANTS, getLocalSetting, setLocalSetting } from '@/libs/userlocalManager';
export function fetchUserTasks (store, options = {}) {
@@ -112,15 +111,6 @@ export async function create (store, createdTask) {
}
const tasksCreatedCount = getLocalSetting(CONSTANTS.keyConstants.TASKS_CREATED_COUNT);
if (!tasksCreatedCount || tasksCreatedCount < 2) {
const uuid = store.state.user.data._id;
Analytics.track({
eventName: 'task created',
eventAction: 'task created',
eventCategory: 'behavior',
hitType: 'event',
uuid,
taskType: taskRes.type,
}, { trackOnClient: true });
if (!tasksCreatedCount) {
setLocalSetting(CONSTANTS.keyConstants.TASKS_CREATED_COUNT, 1);
} else {
@@ -168,11 +158,15 @@ export async function collapseChecklist (store, task) {
}
export async function destroy (store, task) {
const list = store.state.tasks.data[`${task.type}s`];
const taskIndex = list.findIndex(t => t._id === task._id);
const type = `${task.type}s`;
const listIndex = store.state.tasks.data[type].findIndex(t => t._id === task._id);
const orderIndex = store.state.user.data.tasksOrder[type].indexOf(task._id);
if (taskIndex > -1) {
list.splice(taskIndex, 1);
if (listIndex > -1) {
store.state.tasks.data[type].splice(listIndex, 1);
}
if (orderIndex > -1) {
store.state.user.data.tasksOrder[type].splice(orderIndex, 1);
}
await axios.delete(`/api/v4/tasks/${task._id}`);
-4
View File
@@ -159,10 +159,6 @@ export default defineConfig({
target: DEV_BASE_URL,
changeOrigin: true,
},
'^/analytics': {
target: DEV_BASE_URL,
changeOrigin: true,
},
}
}
})
+3 -1
View File
@@ -162,5 +162,7 @@
"achievementCatsModalText": "Nasbíral jsi všechny kočičí mazlíčky!",
"achievementRoughRiderModalText": "Nasbíral jsi všechny základní barvy nepohodlných mazlíčků a mountů!",
"achievementRodentRulerModalText": "Nasbíral jsi všechny hlodavce!",
"achievementCatsText": "Vylíhly se všechny standardní barvy kočičích mazlíčků: gepard, lev, šavlozubý tygr a tygr!"
"achievementCatsText": "Vylíhly se všechny standardní barvy kočičích mazlíčků: gepard, lev, šavlozubý tygr a tygr!",
"achievementRodentRuler": "Vládce hlodavců",
"achievementCats": "Pasák koček"
}
+14 -3
View File
@@ -4,7 +4,7 @@
"brokenChaLink": "Nefunkční odkaz na výzvu",
"keepIt": "Ponechat",
"removeIt": "Odstranit",
"brokenChallenge": "Nefunkční odkaz na výzvu: tento úkol byl součástí výzvy, ale ta (nebo skupina, která ji vytvořila) byla odstraněna. Co chceš dělat s osiřelými úkoly?",
"brokenChallenge": "Neplatný odkaz na výzvu",
"challengeCompleted": "Výzva byla ukončena a vítězem se stal <span class=\"badge\"><%= user %></span>! Co chceš dělat s osiřelými úkoly?",
"unsubChallenge": "Nefunkční odkaz na výzvu: tento úkol byl součástí výzvy, ze které jsi se odhlásil/a. Co chceš dělat s osiřelými úkoly?",
"challenges": "Výzvy",
@@ -85,7 +85,7 @@
"summaryRequired": "Je požadováno shrnutí",
"summaryTooLong": "Shrnutí je příliš dlouhé",
"descriptionRequired": "Je požadován popis",
"locationRequired": "Je požadováno vybrat lokaci výzvy ('Přidat k')",
"locationRequired": "Je nutné vybrat umístění výzvy (Přidat do“)",
"categoiresRequired": "Musí být vybrána jedna nebo více kategorií",
"viewProgressOf": "Zobrazit pokrok",
"viewProgress": "Zobrazit pokrok",
@@ -94,5 +94,16 @@
"selectParticipant": "Zvol účastníka",
"filters": "Filtry",
"wonChallengeDesc": "Vyhrál/a jsi výzvu <%= challengeName %>! Tvá výhra je zaznamenána ve tvých úspěších.",
"yourReward": "Tvá odměna"
"yourReward": "Tvá odměna",
"brokenTaskDescription": "Tento úkol byl součástí výzvy, ale byl z ní odstraněn. Co chceš udělat?",
"brokenChallengeDescription": "Tento úkol byl součástí výzvy, ale výzva (nebo skupina) byla smazána. Co chceš udělat s osiřelými úkoly?",
"challengeCompletedDescription": "Vítězem je <%= user %>! Co chceš udělat s osiřelými úkoly?",
"messageChallengeFlagAlreadyReported": "Tuto výzvu jsi už nahlásil.",
"flaggedNotHidden": "Výzva byla nahlášena jednou, není skrytá",
"flaggedAndHidden": "Výzva byla nahlášena a je skrytá",
"resetFlagCount": "Resetovat počet nahlášení",
"deleteChallengeRefundDescription": "Pokud tuto výzvu smažeš, bude ti vrácena odměna v drahokamech a úkoly z výzvy zůstanou na nástěnkách úkolů účastníků.",
"messageChallengeFlagOfficial": "Oficiální výzvy nelze nahlásit.",
"brokenTask": "Nefunkční odkaz na výzvu",
"removeTasks": "Odstranit Úkoly"
}
+3 -1
View File
@@ -3495,5 +3495,7 @@
"backMystery202601Notes": "Dieses Zeichen gewährt dem Anwender die Kontrolle über die Elemente der Jahreszeit von Kälte und Frost. Gewährt keinen Attributbonus. Januar 2026 Abonnentengegenstand.",
"backMystery202602Text": "Fünf Schweife der Sakura",
"backMystery202602Notes": "Diese flauschigen Schweife haben die Farbe der Kirschblüte, eine Erinnerung, dass der Frühling auf dem Weg ist. Gewährt keinen Autobusbonus. Februar 2026 Abonnentengegenstand.",
"backArmoireHarpsichordText": "Cembalo"
"backArmoireHarpsichordText": "Cembalo",
"weaponSpecialSpring2026HealerText": "Schneeglöckchen Stab",
"weaponSpecialSpring2026HealerNotes": "Eine Gelegenheit für einen Neuanfang liegt direkt vor dir, und mit diesem prächtigen Stab wirst du bereit sein! Erhöht Intelligenz um <%= int %>. Limitierte Ausgabe Frühlingsausrüstung 2026."
}
@@ -1086,6 +1086,8 @@
"eventBackgrounds": "Event Backgrounds",
"backgroundBirthdayBashText": "Birthday Bash",
"backgroundBirthdayBashNotes": "Habitica's having a birthday party, and everyone's invited!",
"backgroundOnAStrangePlanetText": "On a Strange Planet",
"backgroundOnAStrangePlanetNotes": "Venture where no Habitican has gone before: On a Strange Planet.",
"monthlyBackgrounds": "Monthly Backgrounds"
}
+1
View File
@@ -356,6 +356,7 @@
"hatchingPotionBalloon": "Balloon",
"hatchingPotionCryptid": "Cryptid",
"hatchingPotionOpal": "Opal",
"hatchingPotionAlien": "Alien",
"hatchingPotionNotes": "Pour this on an egg, and it will hatch as a <%= potText %> Pet.",
"premiumPotionUnlimitedNotes": "Not usable on Quest Pet eggs.",
+3 -1
View File
@@ -187,5 +187,7 @@
"learnMore": "Learn More",
"translateHabitica": "Translate Habitica",
"whatToCallYou": "What should we call you?",
"acceptPrivacyTOS": "You confirm that you are at least 18 years old, and that you have read and agree to our <a href='/static/terms' target='_blank'>Terms of Service</a> and <a href='/static/privacy' target='_blank'>Privacy Policy</a>"
"acceptPrivacyTOS": "You confirm that you are at least 18 years old, and that you have read and agree to our <a href='/static/terms' target='_blank'>Terms of Service</a> and <a href='/static/privacy' target='_blank'>Privacy Policy</a>",
"emailAddress": "Email address",
"emailRequiredForSupport": "We require an email address for user support. Please enter an email address to continue creating your account."
}
+3 -1
View File
@@ -243,5 +243,7 @@
"playerReportModalBody": "You should only report a player who violates the <%= firstLinkStart %>Community Guidelines<%= linkEnd %> and/or <%= secondLinkStart %>Terms of Service<%= linkEnd %>. Submitting a false report is a violation of Habiticas Community Guidelines.",
"targetUserNotExist": "Target User: '<%= userName %>' does not exist.",
"rememberToBeKind": "Please remember to be kind, respectful, and follow the <a href='/static/community-guidelines' target='_blank'>Community Guidelines</a>.",
"confirmPurchase": "Confirm Purchase"
"confirmPurchase": "Confirm Purchase",
"avoidSPI": "Avoid SPI",
"avoidSPIDetails": "For your privacy, avoid including <%= firstLink %>sensitive personal information<%= linkClose %> (SPI) when using Habitica. Your account data, including tasks, is stored on our servers so you can access it from any device.<br><br>To learn more, review our <%= secondLink %>Privacy Policy<%= linkClose %>."
}
+22 -12
View File
@@ -221,7 +221,7 @@
"questTRexUndeadBoss": "Skeletal Tyrannosaur",
"questTRexUndeadRageTitle": "Skeleton Healing",
"questTRexUndeadRageDescription": "This bar fills when you don't complete your Dailies. When it is full, the Skeletal Tyrannosaur will heal 30% of its remaining health!",
"questTRexUndeadRageEffect": "`Skeletal Tyrannosaur uses SKELETON HEALING!`\n\nThe monster lets forth an unearthly roar, and some of its damaged bones knit back together!",
"questTRexUndeadRageEffect": "Skeletal Tyrannosaur uses SKELETON HEALING!\n\nThe monster lets forth an unearthly roar, and some of its damaged bones knit back together!",
"questTRexDropTRexEgg": "Tyrannosaur (Egg)",
"questTRexUnlockText": "Unlocks Tyrannosaur Eggs for purchase in the Market",
@@ -282,7 +282,7 @@
"questDilatoryDistress2Boss": "Water Skull Swarm",
"questDilatoryDistress2RageTitle": "Swarm Respawn",
"questDilatoryDistress2RageDescription": "Swarm Respawn: This bar fills when you don't complete your Dailies. When it is full, the Water Skull Swarm will heal 30% of its remaining health!",
"questDilatoryDistress2RageEffect": "`Water Skull Swarm uses SWARM RESPAWN!`\n\nEmboldened by their victories, more skulls pour forth from the crevasse, bolstering the swarm!",
"questDilatoryDistress2RageEffect": "Water Skull Swarm uses SWARM RESPAWN!\n\nEmboldened by their victories, more skulls pour forth from the crevasse, bolstering the swarm!",
"questDilatoryDistress2DropSkeletonPotion": "Skeleton Hatching Potion",
"questDilatoryDistress2DropCottonCandyBluePotion": "Cotton Candy Blue Hatching Potion",
"questDilatoryDistress2DropHeadgear": "Fire Coral Circlet (Headgear)",
@@ -398,7 +398,7 @@
"questAxolotlUnlockText": "Unlocks Axolotl Eggs for purchase in the Market",
"questAxolotlRageTitle": "Axolotl Regeneration",
"questAxolotlRageDescription": "This bar fills when you don't complete your Dailies. When it is full, the Magical Axolotl will heal 30% of its remaining health!",
"questAxolotlRageEffect": "`Magical Axolotl uses AXOLOTL REGENERATION!`\n\n`A curtain of colorful bubbles obscures the monster for a moment, and when it clears, some of its wounds have vanished!`",
"questAxolotlRageEffect": "Magical Axolotl uses AXOLOTL REGENERATION!\n\nA curtain of colorful bubbles obscures the monster for a moment, and when it clears, some of its wounds have vanished!",
"questTurtleText": "Guide the Turtle",
"questTurtleNotes": "Help! This giant sea turtle cannot find her way to her nesting beach. She returns there every year to lay her eggs, but this year Inkomplete Bay is filled with toxic Task Flotsam made of red Dailies and unchecked To Do's. \"She's thrashing in a panic!\" @JessicaChase says.<br><br>@UncommonCriminal nods. \"It's because her guiding senses are fogged and confused.\"<br><br>@Scarabsi grabs your arm. \"Can you help clear the Task Flotsam blocking her path? It may be hazardous, but we have to help her!\"",
@@ -435,7 +435,7 @@
"questTaskwoodsTerror1Boss": "Fire Skull Swarm",
"questTaskwoodsTerror1RageTitle": "Swarm Respawn",
"questTaskwoodsTerror1RageDescription": "Swarm Respawn: This bar fills when you don't complete your Dailies. When it is full, the Fire Skull Swarm will heal 30% of its remaining health!",
"questTaskwoodsTerror1RageEffect": "`Fire Skull Swarm uses SWARM RESPAWN!`\n\nEmboldened by their victories, more skulls swirl around you in a gout of flame!",
"questTaskwoodsTerror1RageEffect": "Fire Skull Swarm uses SWARM RESPAWN!\n\nEmboldened by their victories, more skulls swirl around you in a gout of flame!",
"questTaskwoodsTerror1DropSkeletonPotion": "Skeleton Hatching Potion",
"questTaskwoodsTerror1DropRedPotion": "Red Hatching Potion",
"questTaskwoodsTerror1DropHeadgear": "Pyromancer's Turban (Headgear)",
@@ -507,7 +507,7 @@
"questStoikalmCalamity1Boss": "Earth Skull Swarm",
"questStoikalmCalamity1RageTitle": "Swarm Respawn",
"questStoikalmCalamity1RageDescription": "Swarm Respawn: This bar fills when you don't complete your Dailies. When it is full, the Earth Skull Swarm will heal 30% of its remaining health!",
"questStoikalmCalamity1RageEffect": "`Earth Skull Swarm uses SWARM RESPAWN!`\n\nMore skulls break free from the ground, their teeth chattering in the cold!",
"questStoikalmCalamity1RageEffect": "Earth Skull Swarm uses SWARM RESPAWN!\n\nMore skulls break free from the ground, their teeth chattering in the cold!",
"questStoikalmCalamity1DropSkeletonPotion": "Skeleton Hatching Potion",
"questStoikalmCalamity1DropDesertPotion": "Desert Hatching Potion",
"questStoikalmCalamity1DropArmor": "Mammoth Rider Armor",
@@ -554,7 +554,7 @@
"questMayhemMistiflying1Boss": "Air Skull Swarm",
"questMayhemMistiflying1RageTitle": "Swarm Respawn",
"questMayhemMistiflying1RageDescription": "Swarm Respawn: This bar fills when you don't complete your Dailies. When it is full, the Air Skull Swarm will heal 30% of its remaining health!",
"questMayhemMistiflying1RageEffect": "`Air Skull Swarm uses SWARM RESPAWN!`\n\nEmboldened by their victories, more skulls come whirling out of the clouds!",
"questMayhemMistiflying1RageEffect": "Air Skull Swarm uses SWARM RESPAWN!\n\nEmboldened by their victories, more skulls come whirling out of the clouds!",
"questMayhemMistiflying1DropSkeletonPotion": "Skeleton Hatching Potion",
"questMayhemMistiflying1DropWhitePotion": "White Hatching Potion",
"questMayhemMistiflying1DropArmor": "Roguish Rainbow Messenger Robes (Armor)",
@@ -623,7 +623,7 @@
"questLostMasterclasser3Boss": "Void Skull Swarm",
"questLostMasterclasser3RageTitle": "Swarm Respawn",
"questLostMasterclasser3RageDescription": "Swarm Respawn: This bar fills when you don't complete your Dailies. When it is full, the Void Skull Swarm will heal 30% of its remaining health!",
"questLostMasterclasser3RageEffect": "`Void Skull Swarm uses SWARM RESPAWN!`\n\nEmboldened by their victories, more skulls scream down from the heavens, bolstering the swarm!",
"questLostMasterclasser3RageEffect": "Void Skull Swarm uses SWARM RESPAWN!\n\nEmboldened by their victories, more skulls scream down from the heavens, bolstering the swarm!",
"questLostMasterclasser3DropBodyAccessory": "Aether Amulet (Body Accessory)",
"questLostMasterclasser3DropBasePotion": "Base Hatching Potion",
"questLostMasterclasser3DropGoldenPotion": "Golden Hatching Potion",
@@ -637,7 +637,7 @@
"questLostMasterclasser4Boss": "Anti'zinnya",
"questLostMasterclasser4RageTitle": "Siphoning Void",
"questLostMasterclasser4RageDescription": "Siphoning Void: This bar fills when you don't complete your Dailies. When it is full, Anti'zinnya will remove the party's Mana!",
"questLostMasterclasser4RageEffect": "`Anti'zinnya uses SIPHONING VOID!` In a twisted inversion of the Ethereal Surge spell, you feel your magic drain away into the darkness!",
"questLostMasterclasser4RageEffect": "Anti'zinnya uses SIPHONING VOID! In a twisted inversion of the Ethereal Surge spell, you feel your magic drain away into the darkness!",
"questLostMasterclasser4DropBackAccessory": "Aether Cloak (Back Accessory)",
"questLostMasterclasser4DropWeapon": "Aether Crystals (Two-Handed Weapon)",
"questLostMasterclasser4DropMount": "Invisible Aether Mount",
@@ -804,7 +804,7 @@
"questWaffleBoss": "Awful Waffle",
"questWaffleRageTitle": "Maple Mire",
"questWaffleRageDescription": "Maple Mire: This bar fills when you don't complete your Dailies. When it is full, the Awful Waffle will subtract from the pending damage that party members have built up!",
"questWaffleRageEffect": "`Awful Waffle uses MAPLE MIRE!` Sticky sappy syrup slows your swings and spells! Pending damage reduced.",
"questWaffleRageEffect": "Awful Waffle uses MAPLE MIRE! Sticky sappy syrup slows your swings and spells! Pending damage reduced.",
"questWaffleDropDessertPotion": "Confection Hatching Potion",
"questWaffleUnlockText": "Unlocks Confection Hatching Potions for purchase in the Market",
@@ -875,7 +875,7 @@
"questVirtualPetBoss": "Wotchimon",
"questVirtualPetRageTitle": "The Beepening",
"questVirtualPetRageDescription": "This bar fills when you don't complete your Dailies. When it is full, the Wotchimon will take away some of your party's pending damage!",
"questVirtualPetRageEffect": "`Wotchimon uses Bothersome Beep!` Wotchimon sounds a bothersome beep, and its happiness bar suddenly disappears! Pending damage reduced.",
"questVirtualPetRageEffect": "Wotchimon uses Bothersome Beep! Wotchimon sounds a bothersome beep, and its happiness bar suddenly disappears! Pending damage reduced.",
"questVirtualPetDropVirtualPetPotion": "Virtual Pet Hatching Potion",
"questVirtualPetUnlockText": "Unlocks Virtual Pet Hatching Potion for purchase in the Market",
@@ -885,7 +885,7 @@
"questPinkMarbleBoss": "Cupido",
"questPinkMarbleRageTitle": "Pink Punch",
"questPinkMarbleRageDescription": "This bar fills when you don't complete your Dailies. When it is full, Cupido will take away some of your party's pending damage!",
"questPinkMarbleRageEffect": "`Cupido uses Pink Punch!` That wasn't affectionate at all! Your partymates are taken aback. Pending damage reduced.",
"questPinkMarbleRageEffect": "Cupido uses Pink Punch! That wasn't affectionate at all! Your partymates are taken aback. Pending damage reduced.",
"questPinkMarbleDropPinkMarblePotion": "Pink Marble Hatching Potion",
"questPinkMarbleUnlockText": "Unlocks Pink Marble Hatching Potions for purchase in the Market.",
@@ -997,5 +997,15 @@
"questFungiRageDescription": "This bar fills when you don't complete your Dailies. When it's full, the Moody Mushroom will take away some of your party's pending damage",
"questFungiRageEffect": "A Mist emanates from the Moody Mushroom and surrounds your party, dampening the mood and subduing your magic. The party's MP is reduced!",
"questFungiDropFungiPotion": "Fungi Hatching Potion",
"questFungiUnlockText": "Unlocks Fungi Hatching Potions for purchase in the Market."
"questFungiUnlockText": "Unlocks Fungi Hatching Potions for purchase in the Market.",
"questAlienText": "Invasion of the Motivation Snatchers",
"questAlienNotes": "Its been a strange few days in Habitica. The great flying saucer still hovers near the Flourishing Fields. It hums oddly. Why is it lingering? April Fools Day has passed, and the Master of Rogues' time in the spotlight has ended.<br><br>You wander toward the light of the space ship. You may as well check it out and get a few steps in while youre at it.<br><br>As you get closer you see the April Fool, looking a bit grim. His face appears greenish in the light of the ships beam.<br><br>”'Twas my plan to get some potions for everyone, a little gift so all can enjoy their little extraterrestrial pals again! But I just cant work up the gumption… I do believe I know why,” the Fool says, nodding toward the beam.<br><br>Little symbols are being sucked up into the ship. Its all your checked off tasks! No wonder your motivations been lackluster.<br><br>”Our motivation is being abducted!” you exclaim. “We have to rescue it before it ends up in deep space somewhere!”<br><br>The Fool smiles. “Concentrate your thoughts on the tasks you know you need to finish! Ill do the rest with a bit of magic.”",
"questAlienCompletion": "Youve managed to wrestle back the stolen motivation with your determination and the Fools magic power. As you feel your drive returning, the UFO descends, and a ramp slowly comes out along with a large, green, one-eyed creature. While strange-looking, it doesnt seem threatening.<br><br>“Looks like we went a little far trying to harvest a little extra encouragement from your fine city,” it says. “Apologies for that, and fantastic work getting it back. The extra aura of your efforts actually charged up the ships engine enough to get us home! Please, take these with our thanks.”<br><br>“Ooh potions,” says the Fool, “how delightful, and how convenient for me that you have them all ready to go!”",
"questAlienBoss": "Encouragement Thief, the Extraterrestrial",
"questAlienRageTitle": "Intergalactic Impediment",
"questAlienRageDescription": "This bar fills when you don't complete your Dailies. When it is full, the Extraterrestrial will discourage you by recovering some of its Health!",
"questAlienRageEffect": "Encouragement Thief uses Intergalactic Impediment! You've backslid right through hyperspace. Your opponent recovers HP!",
"questAlienDropAlienPotion": "Alien Hatching Potion",
"questAlienUnlockText": "Unlocks Alien Hatching Potion for purchase in the Market"
}
+1 -1
View File
@@ -4,7 +4,7 @@
"brokenChaLink": "Broken Challenge Link",
"keepIt": "Keep It",
"removeIt": "Remove It",
"brokenChallenge": "Broken Challenge Link: this task was part of a challenge, but the challenge (or group) has been deleted. What to do with the orphan tasks?",
"brokenChallenge": "Broken Challenge Link",
"challengeCompleted": "This challenge has been completed, and the winner was <span class=\"badge\"><%= user %></span>! What to do with the orphan tasks?",
"unsubChallenge": "Broken Challenge Link: this task was part of a challenge, but you have unsubscribed from the challenge. What to do with the orphan tasks?",
"challenges": "Challenges",
+4 -4
View File
@@ -114,8 +114,8 @@
"unallocated": "Unallocated Stat Points",
"autoAllocation": "Automatic Allocation",
"autoAllocationPop": "Places Points into Stats according to your preferences, when you level up.",
"evenAllocation": "Distribute Stat Points evenly",
"evenAllocationPop": "Assigns the same number of Points to each Stat.",
"evenAllocation": "Distribute evenly",
"evenAllocationPop": "Assigns the same number of points to each attribute",
"classAllocation": "Distribute based on class",
"classAllocationPop": "Assigns more points to the attributes important to your class",
"taskAllocation": "Distribute based on task activity",
@@ -123,7 +123,7 @@
"distributePoints": "Distribute Unallocated Points",
"distributePointsPop": "Assigns all unallocated Stat Points according to the selected allocation scheme.",
"warriorText": "Warriors score more and better \"critical hits\", which randomly give bonus Gold, Experience, and drop chance for scoring a task. They also deal heavy damage to boss monsters. Play a Warrior if you find motivation from unpredictable jackpot-style rewards, or want to dish out the hurt in boss Quests!",
"wizardText": "Mages learn swiftly, gaining Experience and Levels faster than other classes. They also get a great deal of Mana for using special abilities. Play a Mage if you enjoy the tactical game aspects of Habitica, or if you are strongly motivated by leveling up and unlocking advanced features!",
"wizardText": "Mages learn swiftly, gaining Experience and Levels faster than other classes. They also get a great deal of Mana for using special abilities. Play a Mage if you enjoy the tactical game aspects of Habitica, or if you are strongly motivated by levelling up and unlocking advanced features!",
"mageText": "Mages learn swiftly, gaining Experience and Levels faster than other classes. They also get a great deal of Mana for using special abilities. Play a Mage if you enjoy the tactical game aspects of Habitica, or if you are strongly motivated by levelling up and unlocking advanced features!",
"rogueText": "Rogues love to accumulate wealth, gaining more Gold than anyone else, and are adept at finding random items. Their iconic Stealth ability lets them duck the consequences of missed Dailies. Play a Rogue if you find strong motivation from Rewards and Achievements, striving for loot and badges!",
"healerText": "Healers stand impervious against harm, and extend that protection to others. Missed Dailies and bad Habits don't faze them much, and they have ways to recover Health from failure. Play a Healer if you enjoy assisting others in your Party, or if the idea of cheating Death through hard work inspires you!",
@@ -178,7 +178,7 @@
"mainHand": "Main-Hand",
"offHand": "Off-Hand",
"statPoints": "Stat Points",
"pts": "pts",
"pts": "PTS",
"chatCastSpellUser": "<%= username %> casts <%= spell %> on <%= target %>.",
"chatCastSpellParty": "<%= username %> casts <%= spell %> for the party.",
"purchasePetItemConfirm": "This purchase would exceed the number of items you need to hatch all possible <%= itemText %> pets. Are you sure?",
+2 -2
View File
@@ -134,7 +134,7 @@
"subscriptionDetail48": "Are there any changes to other subscription benefits, like Mystery Gear Sets?",
"subscriptionDetail480": "These changes only affect Mystic Hourglasses and subscriber Gems. All other benefits will remain the same.",
"subscriptionPara2": "If you have any questions not covered by the answers above, you can always contact our team at <%= mailto %>.",
"contentAnswer01": "<strong>Grand Galas are being extended</strong> to be active throughout the whole season, along with all their Class gear, Avatar Customizations, and other goodies.",
"contentAnswer01": "<strong>Grand Galas are being extended</strong> to be active throughout the whole season, along with all their Class gear, Avatar Customisations, and other goodies.",
"contentAnswer10": "Habitica has been around since 2013 (wow!) and over time weve released thousands of items players can collect. This can be overwhelming, especially for new players. We want to be sure that we showcase everything we have to offer, and that excellent items released earlier in our history arent overlooked.",
"contentAnswer11": "When new players join between Grand Galas they are often unaware of these events and miss out on the fun. We want to be sure all new players can join in on our seasonal festivities no matter when they choose to start their journeys.",
"subscriptionDetail00": "All subscribers, including those with gifted subscriptions, will receive 1 Mystic Hourglass at the start of each month they have subscriber benefits.",
@@ -192,7 +192,7 @@
"faqQuestion63": "How do unassigned tasks work?",
"webFaqAnswer63": "Unassigned tasks can be completed by any member. For example, taking out the trash. Whoever takes out the trash can complete the unassigned task and it will show as completed for everyone.",
"faqQuestion64": "How does the synchronised day reset work?",
"webFaqAnswer64": "Shared tasks will reset at the same time for everyone to keep the shared task board in sync. This time is visible on the shared task board and is determined by the Group Plan leaders day start time. Because shared tasks reset automatically, you will not get a chance to complete yesterdays uncompleted shared Dailies when you check in the next morning.\n\nShared Dailies will not do damage if they are missed, however they will degrade in colour to help visualize progress.",
"webFaqAnswer64": "Shared tasks will reset at the same time for everyone to keep the shared task board in sync. This time is visible on the shared task board and is determined by the Group Plan leaders day start time. Because shared tasks reset automatically, you will not get a chance to complete yesterdays uncompleted shared Dailies when you check in the next morning.\n\nShared Dailies will not do damage if they are missed, however they will degrade in colour to help visualise progress.",
"webFaqAnswer65": "While the mobile apps dont fully support all Group Plans functionality yet, you can still complete shared tasks from the iOS and Android apps!\n\nOn Android, you can tap your Display Name at the top of the screen when viewing your tasks to switch to your shared task board. From there you can view members, access your chat, and create, complete, or assign tasks.\n\nYou can also switch on a preference to copy shared tasks to your personal task board so you can complete all your tasks from one place.\n\nTo do this on the mobile apps:\n * Open Settings and switch on “Copy shared tasks”\n\nTo do this on Habiticas website:\n * Navigate to your Group Plan and switch on the “Copy tasks” toggle on the shared task board",
"faqQuestion66": "Whats the difference between a Group Plans shared tasks and Challenge tasks?",
"webFaqAnswer66": "Group Plan shared task boards are more dynamic than Challenges, in that they can constantly be updated and interacted with. Challenges are great if you have one set of tasks to send out to many people.\n\nGroup Plans are also a paid feature, while Challenges are available free to everyone.\n\nYou cannot assign specific tasks in Challenges, and Challenges do not have a shared day reset. In general, Challenges offer less control and direct interaction.",
+2 -2
View File
@@ -132,7 +132,7 @@
"invalidReqParams": "Invalid request parameters.",
"memberIdRequired": "\"member\" must be a valid UUID.",
"heroIdRequired": "\"heroId\" must be a valid UUID.",
"cannotFulfillReq": "Please enter a valid email address. Email admin@habitica.com if this error persists.",
"cannotFulfillReq": "This email address is already in use. You can try logging in or use a different email to register. If you need help, reach out to admin@habitica.com.",
"modelNotFound": "This model does not exist.",
"signUpWithSocial": "Continue with <%= social %>",
"loginWithSocial": "Log in with <%= social %>",
@@ -163,7 +163,7 @@
"schoolAndWork": "School and Work",
"schoolAndWorkDesc": "Whether you're preparing a report for your teacher or your boss, it's easy to keep track of your progress as you tackle your toughest tasks.",
"muchmuchMore": "And much, much more!",
"muchmuchMoreDesc": "Our fully customizable task list means that you can shape Habitica to fit your personal goals. Work on creative projects, emphasise self-care, or pursue a different dream -- it's all up to you.",
"muchmuchMoreDesc": "Our fully customisable task list means that you can shape Habitica to fit your personal goals. Work on creative projects, emphasise self-care, or pursue a different dream -- it's all up to you.",
"levelUpAnywhere": "Level Up Anywhere",
"levelUpAnywhereDesc": "Our mobile apps make it simple to keep track of your tasks on-the-go. Accomplish your goals with a single tap, no matter where you are.",
"joinMany": "Join over <%= userCountInMillions %> million people having fun while accomplishing their goals!",

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