Compare commits

..

23 Commits

Author SHA1 Message Date
Phillip Thelen
40d5172972 fix anonymizing properties 2025-11-24 15:55:07 +01:00
Phillip Thelen
ebb58e4470 send anonymized data if opted out 2025-09-19 11:52:57 +02:00
Phillip Thelen
c3ef26b2f3 remove invitee field from analytics 2025-09-18 11:22:41 +02:00
Phillip Thelen
108bd59296 call correct method to update user data 2025-09-18 11:15:33 +02:00
Kalista Payne
5228ed40d1 fix(lint): remove undef function call 2025-09-17 15:58:44 -05:00
Kalista Payne
becb6e49f0 fix(merge): put back settings mixins 2025-09-17 15:51:43 -05:00
Kalista Payne
aa8f0f0c4e chore(subproj): merge habitica-images 2025-09-17 15:47:04 -05:00
Kalista Payne
d1891f4c43 Merge branch 'develop' into phillip/server-analytics 2025-09-17 15:45:45 -05:00
Kalista Payne
95494c685b 5.41.1 2025-09-16 22:05:58 -05:00
Weblate
10978d46ab Translated using Weblate (Spanish)
Currently translated at 100.0% (3441 of 3441 strings)

Translated using Weblate (Japanese)

Currently translated at 95.1% (820 of 862 strings)

Translated using Weblate (Polish)

Currently translated at 51.6% (1776 of 3441 strings)

Translated using Weblate (German)

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Russian)

Currently translated at 99.4% (192 of 193 strings)

Translated using Weblate (German)

Currently translated at 100.0% (3441 of 3441 strings)

Translated using Weblate (German)

Currently translated at 100.0% (3441 of 3441 strings)

Translated using Weblate (German)

Currently translated at 100.0% (3441 of 3441 strings)

Translated using Weblate (German)

Currently translated at 100.0% (3441 of 3441 strings)

Translated using Weblate (Japanese)

Currently translated at 96.3% (236 of 245 strings)

Translated using Weblate (Japanese)

Currently translated at 95.1% (820 of 862 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 36.3% (89 of 245 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 35.1% (86 of 245 strings)

Translated using Weblate (Dutch)

Currently translated at 84.0% (776 of 923 strings)

Translated using Weblate (German)

Currently translated at 100.0% (3441 of 3441 strings)

Translated using Weblate (Japanese)

Currently translated at 94.8% (818 of 862 strings)

Translated using Weblate (Japanese)

Currently translated at 92.6% (3189 of 3441 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (271 of 271 strings)

Translated using Weblate (Japanese)

Currently translated at 94.6% (232 of 245 strings)

Translated using Weblate (German)

Currently translated at 99.9% (3440 of 3441 strings)

Co-authored-by: Alexandre Le Mercier <couzinemile@gmail.com>
Co-authored-by: Jaime Martí <jaumemarti77@icloud.com>
Co-authored-by: Liu leoyve <leoyve@gmail.com>
Co-authored-by: Ri Vargas <goldenhaitang@gmail.com>
Co-authored-by: Shchudrov Yaroslav Maksimovich <separatationally@mail.ru>
Co-authored-by: Sven Baumann <svenbaumann1996@gmail.com>
Co-authored-by: Toro Mor <thomas.bizer@gmx.de>
Co-authored-by: Uwe B <hbtca@tunixgut.de>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: nagase daichi <daihachi10sub@gmail.com>
Co-authored-by: いんこ <ayakabooker@gmail.com>
Co-authored-by: インコ <ayakabooker@gmail.com>
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/character/ru/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/zh_Hant/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/de/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/es/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/pl/
Translate-URL: https://translate.habitica.com/projects/habitica/pets/de/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/ja/
Translation: Habitica/Backgrounds
Translation: Habitica/Character
Translation: Habitica/Faq
Translation: Habitica/Gear
Translation: Habitica/Pets
Translation: Habitica/Questscontent
Translation: Habitica/Subscriber
2025-09-17 02:09:45 +02:00
Phillip Thelen
2381be8c46 remove old import 2025-09-10 17:50:33 +02:00
Phillip Thelen
5c7545f32a cleanup 2025-09-10 17:38:52 +02:00
Phillip Thelen
ffed5a9a97 update package locks 2025-09-10 12:58:39 +02:00
Phillip Thelen
cd58ce2233 remove google analytics 2025-09-10 12:50:41 +02:00
Phillip Thelen
a2b5e3621e refactor amplitude event properties 2025-09-10 12:44:17 +02:00
Phillip Thelen
a06dfc9ed8 allow mobile to send analytics calls 2025-09-05 16:12:23 +02:00
Phillip Thelen
58b0e323a3 anonymize all uuids 2025-09-05 16:10:41 +02:00
Phillip Thelen
9ca60d7551 anonymize user data if they didn’t consent to analytics 2025-09-05 12:55:56 +02:00
Phillip Thelen
6f63583a12 use ip-lookup-api to determine users country 2025-09-05 12:51:54 +02:00
Phillip Thelen
d952239d35 fix imports 2025-09-04 14:37:42 +02:00
Phillip Thelen
2c7f6fd9e3 properly update user properties in events 2025-09-04 12:57:35 +02:00
Phillip Thelen
ddba450630 add new api call to allow client to update amplitude events 2025-09-04 12:57:18 +02:00
Phillip Thelen
187238d39a remove amplitude from client 2025-09-04 12:56:57 +02:00
77 changed files with 8315 additions and 5695 deletions

13398
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,12 @@
{
"name": "habitica",
"description": "A habit tracker app which treats your goals like a Role Playing Game.",
"version": "5.41.0",
"version": "5.41.1",
"main": "./website/server/index.js",
"dependencies": {
"@babel/core": "^7.22.10",
"@babel/preset-env": "^7.22.10",
"@babel/register": "^7.22.15",
"@google-analytics/data": "^4.12.1",
"@google-cloud/trace-agent": "^7.1.2",
"@parse/node-apn": "^5.2.3",
"@slack/webhook": "^6.1.0",
@@ -43,6 +42,7 @@
"habitica-markdown": "^3.0.0",
"helmet": "^4.6.0",
"in-app-purchase": "^1.11.3",
"ip-location-api": "^4.0.0",
"js2xmlparser": "^5.0.0",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^2.1.5",

View File

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

View File

@@ -12,7 +12,6 @@
"@froxz/vite-plugin-s3": "^1.6.0",
"@vitejs/plugin-vue2": "^2.3.3",
"@vue/test-utils": "1.0.0-beta.29",
"amplitude-js": "^8.21.3",
"assert": "^2.1.0",
"autoprefixer": "^10.4.20",
"axios": "^0.28.0",
@@ -68,49 +67,6 @@
"node": ">=0.10.0"
}
},
"node_modules/@amplitude/analytics-connector": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@amplitude/analytics-connector/-/analytics-connector-1.5.0.tgz",
"integrity": "sha512-T8mOYzB9RRxckzhL0NTHwdge9xuFxXEOplC8B1Y3UX3NHa3BLh7DlBUZlCOwQgMc2nxDfnSweDL5S3bhC+W90g=="
},
"node_modules/@amplitude/types": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/@amplitude/types/-/types-1.10.2.tgz",
"integrity": "sha512-I8qenRI7uU6wKNb9LiZrAosSHVoNHziXouKY81CrqxH9xhVTEIJFXeuCV0hbtBr0Al/8ejnGjQRx+S2SvU/pPg==",
"engines": {
"node": ">=10"
}
},
"node_modules/@amplitude/ua-parser-js": {
"version": "0.7.33",
"resolved": "https://registry.npmjs.org/@amplitude/ua-parser-js/-/ua-parser-js-0.7.33.tgz",
"integrity": "sha512-wKEtVR4vXuPT9cVEIJkYWnlF++Gx3BdLatPBM+SZ1ztVIvnhdGBZR/mn9x/PzyrMcRlZmyi6L56I2J3doVBnjA==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/ua-parser-js"
},
{
"type": "paypal",
"url": "https://paypal.me/faisalman"
}
],
"engines": {
"node": "*"
}
},
"node_modules/@amplitude/utils": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/@amplitude/utils/-/utils-1.10.2.tgz",
"integrity": "sha512-tVsHXu61jITEtRjB7NugQ5cVDd4QDzne8T3ifmZye7TiJeUfVRvqe44gDtf55A+7VqhDhyEIIXTA1iVcDGqlEw==",
"dependencies": {
"@amplitude/types": "^1.10.2",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@ampproject/remapping": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz",
@@ -1231,6 +1187,7 @@
"version": "7.23.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.6.tgz",
"integrity": "sha512-zHd0eUrf5GZoOWVCXp6koAKQTfZV07eit6bGPmJgnZdnSAvvZee6zniW2XMF7Cmc4ISOOnPy3QaSiIJGJkVEDQ==",
"dev": true,
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
@@ -3050,19 +3007,6 @@
"ajv": "^6.9.1"
}
},
"node_modules/amplitude-js": {
"version": "8.21.9",
"resolved": "https://registry.npmjs.org/amplitude-js/-/amplitude-js-8.21.9.tgz",
"integrity": "sha512-d0jJH00wbXu7sxKtVwkdSXtVffjqdUrxuACKlnzP7jU5qt9wriXXMgHifdH5Oq+buKmyF8wKL9S02gAykysURA==",
"dependencies": {
"@amplitude/analytics-connector": "^1.4.6",
"@amplitude/ua-parser-js": "0.7.33",
"@amplitude/utils": "^1.10.2",
"@babel/runtime": "^7.21.0",
"blueimp-md5": "^2.19.0",
"query-string": "8.1.0"
}
},
"node_modules/ansi-colors": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
@@ -3351,11 +3295,6 @@
"node": ">=8"
}
},
"node_modules/blueimp-md5": {
"version": "2.19.0",
"resolved": "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.19.0.tgz",
"integrity": "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w=="
},
"node_modules/bootstrap": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.6.2.tgz",
@@ -3768,14 +3707,6 @@
"integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==",
"dev": true
},
"node_modules/decode-uri-component": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.4.1.tgz",
"integrity": "sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==",
"engines": {
"node": ">=14.16"
}
},
"node_modules/deep-eql": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.1.tgz",
@@ -4987,17 +4918,6 @@
"node": ">=8"
}
},
"node_modules/filter-obj": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-5.1.0.tgz",
"integrity": "sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng==",
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -7283,22 +7203,6 @@
"node": ">=6"
}
},
"node_modules/query-string": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-8.1.0.tgz",
"integrity": "sha512-BFQeWxJOZxZGix7y+SByG3F36dA0AbTy9o6pSmKFcFz7DAj0re9Frkty3saBn3nHo3D0oZJ/+rx3r8H8r8Jbpw==",
"dependencies": {
"decode-uri-component": "^0.4.1",
"filter-obj": "^5.1.0",
"split-on-first": "^3.0.0"
},
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ramda": {
"version": "0.26.1",
"resolved": "https://registry.npmjs.org/ramda/-/ramda-0.26.1.tgz",
@@ -7344,7 +7248,8 @@
"node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"dev": true
},
"node_modules/regexp.prototype.flags": {
"version": "1.5.1",
@@ -7858,17 +7763,6 @@
"source-map": "^0.6.0"
}
},
"node_modules/split-on-first": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-3.0.0.tgz",
"integrity": "sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",

View File

@@ -16,7 +16,6 @@
"@froxz/vite-plugin-s3": "^1.6.0",
"@vitejs/plugin-vue2": "^2.3.3",
"@vue/test-utils": "1.0.0-beta.29",
"amplitude-js": "^8.21.3",
"assert": "^2.1.0",
"autoprefixer": "^10.4.20",
"axios": "^0.28.0",

View File

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

View File

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

View File

@@ -445,7 +445,7 @@ export default {
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);

View File

@@ -1,8 +1,8 @@
<template>
<div
class="banner d-flex align-items-center justify-content-between py-3 px-4"
id="privacy-banner"
v-if="!hidden"
id="privacy-banner"
class="banner d-flex align-items-center justify-content-between py-3 px-4"
>
<p
class="mr-3 mb-0"

View File

@@ -546,7 +546,7 @@ export default {
eventCategory: 'behavior',
demographics: this.upgradedGroup.demographics,
type: this.paymentData.group.type,
}, { trackOnClient: true });
});
}
this.paymentData = {};
this.$root.$emit('bv::hide::modal', 'payments-success-modal');

View File

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

View File

@@ -64,9 +64,11 @@
<li>sexual orientation; and</li>
<li>information collected from a known child.</li>
</ul>
<p><strong>
NOTE: Please do not provide us sensitive personal information or sensitive personal data, as those terms are defined under applicable privacy laws, unless we directly request that you do so. If you feel, after careful consideration, that it is necessary to provide us certain sensitive personal information or data, please provide us the minimum amount of such information or data that is necessary.
</strong></p>
<p>
<strong>
NOTE: Please do not provide us sensitive personal information or sensitive personal data, as those terms are defined under applicable privacy laws, unless we directly request that you do so. If you feel, after careful consideration, that it is necessary to provide us certain sensitive personal information or data, please provide us the minimum amount of such information or data that is necessary.
</strong>
</p>
<h3 id="section_1_1">
1.1 Information You Provide Directly
</h3>
@@ -617,7 +619,7 @@
7. General Audience Services
</h2>
<p>
The Service is intended for users 18 years or older; you are not permitted to access or use the Service if you are younger than 18. We do not knowingly collect personal information from children under the age of 18 through the Service. We encourage parents and legal guardians to monitor their childrens Internet usage and to help enforce our Privacy Policy by instructing their children to never provide personal information without their permission. If you have reason to believe that a child under the age of 18 has provided personal information to us, please contact us at <a href='mailto:privacy@habitica.com'>privacy@habitica.com</a>, and we will delete that information from our databases.
The Service is intended for users 18 years or older; you are not permitted to access or use the Service if you are younger than 18. We do not knowingly collect personal information from children under the age of 18 through the Service. We encourage parents and legal guardians to monitor their childrens Internet usage and to help enforce our Privacy Policy by instructing their children to never provide personal information without their permission. If you have reason to believe that a child under the age of 18 has provided personal information to us, please contact us at <a href="mailto:privacy@habitica.com">privacy@habitica.com</a>, and we will delete that information from our databases.
</p>
<h2 id="section_8">
@@ -708,7 +710,7 @@
<p><strong><u>Nevada Residents</u></strong></p>
<p>
Nevada residents may opt out of the sale of certain “covered information” collected by operators of websites or online services. We currently do not sell covered information, as “sale” is defined by such law, and do not have plans to do so. In accordance with Nevada law, you may submit to us a verified request instructing us not to sell your covered information by sending an email to <a href='mailto:privacy@habitica.com'>privacy@habitica.com</a>.
Nevada residents may opt out of the sale of certain “covered information” collected by operators of websites or online services. We currently do not sell covered information, as “sale” is defined by such law, and do not have plans to do so. In accordance with Nevada law, you may submit to us a verified request instructing us not to sell your covered information by sending an email to <a href="mailto:privacy@habitica.com">privacy@habitica.com</a>.
</p>
<p><strong><u>Notice to United Kingdom/European/Switzerland Residents.</u></strong></p>
<p>

View File

@@ -15,8 +15,8 @@
<router-view />
</div>
<div
id="bottom-background"
v-if="loginFlow"
id="bottom-background"
class="bg-purple-300"
>
<div class="seamless_mountains_demo_repeat"></div>
@@ -31,7 +31,10 @@
id="bottom-wrap"
class="purple-4"
>
<div id="bottom-background" v-if="!loginFlow">
<div
v-if="!loginFlow"
id="bottom-background"
>
<div class="seamless_mountains_demo_repeat"></div>
<div class="midground_foreground_extended2"></div>
</div>

View File

@@ -158,7 +158,7 @@
BY PURCHASING PREMIUM YOU EXPRESSLY UNDERSTAND AND AGREE TO OUR REFUND POLICY:
</p>
<p>
YOU CAN REQUEST A REFUND OF YOUR MOST RECENT PAYMENT TO US BY CONTACTING US AT <a href='mailto:admin@habitica.com'>ADMIN@HABITICA.COM</a>. THE AMOUNT OF YOUR REFUND, IF ANY, WILL BE BASED ON (1) THE AMOUNT OF YOUR PURCHASED BUT UNUSED SUBSCRIPTION BENEFITS AND (2) THE TERMS IMPOSED ON US BY OUR PAYMENT PROCESSING VENDORS (E.G., WITH RESPECT TO THE DURATION OF THE REFUND PERIOD).
YOU CAN REQUEST A REFUND OF YOUR MOST RECENT PAYMENT TO US BY CONTACTING US AT <a href="mailto:admin@habitica.com">ADMIN@HABITICA.COM</a>. THE AMOUNT OF YOUR REFUND, IF ANY, WILL BE BASED ON (1) THE AMOUNT OF YOUR PURCHASED BUT UNUSED SUBSCRIPTION BENEFITS AND (2) THE TERMS IMPOSED ON US BY OUR PAYMENT PROCESSING VENDORS (E.G., WITH RESPECT TO THE DURATION OF THE REFUND PERIOD).
</p>
<p>
FOR ANY CUSTOMER WHO PURCHASED PREMIUM IN APPLE INC.'s APP STORE ("APP STORE"), PLEASE CONTACT APPLE INC.'s SUPPORT TEAM: <a

View File

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

View File

@@ -25,8 +25,8 @@
type="checkbox"
:checked="isChecked"
:value="value"
@change="handleChange"
:disabled="disabled"
@change="handleChange"
>
<label
class="toggle-switch-label"
@@ -116,7 +116,7 @@
.toggle-switch-inner:before {
content: "";
padding-left: 10px;
background-color: $green-50;
background-color: $green-10;
}
.toggle-switch-inner:after {

View File

@@ -1,21 +1,11 @@
import forEach from 'lodash/forEach';
import isEqual from 'lodash/isEqual';
import keys from 'lodash/keys';
import pick from 'lodash/pick';
import amplitude from 'amplitude-js';
import { gtag, install } from 'ga-gtag';
import Vue from 'vue';
import getStore from '@/store';
const AMPLITUDE_KEY = import.meta.env.AMPLITUDE_KEY;
const DEBUG_ENABLED = import.meta.env.DEBUG_ENABLED === 'true';
const GA_ID = import.meta.env.GA_ID;
const IS_PRODUCTION = import.meta.env.NODE_ENV === 'production';
const REQUIRED_FIELDS = ['eventCategory', 'eventAction'];
let analyticsLoading = false;
let analyticsReady = false;
function _getConsentedUser () {
const store = getStore();
const user = store.state.user.data;
@@ -66,49 +56,24 @@ function _gatherUserStats (properties) {
if (user.purchased.plan.planId) properties.subscription = user.purchased.plan.planId;
}
export function safeSetup (userId) {
if (analyticsLoading || analyticsReady) return;
analyticsLoading = true;
install(GA_ID, {
debug_mode: DEBUG_ENABLED || !IS_PRODUCTION,
user_id: userId,
});
amplitude.getInstance().init(AMPLITUDE_KEY, userId);
analyticsReady = true;
analyticsLoading = false;
}
export function track (properties, options = {}) {
export function track (properties) {
const user = _getConsentedUser();
if (!user) return;
safeSetup(user._id);
// Use nextTick to avoid blocking the UI
Vue.nextTick(() => {
if (_doesNotHaveRequiredFields(properties)) return;
const trackOnClient = options && options.trackOnClient === true;
// Track events on the server by default
if (trackOnClient === true) {
amplitude.getInstance().logEvent(properties.eventAction, properties);
gtag('event', properties.eventAction, properties);
} else {
const store = getStore();
store.dispatch('analytics:trackEvent', properties);
}
const store = getStore();
store.dispatch('analytics:trackEvent', properties);
});
}
export function updateUser (properties = {}) {
const user = _getConsentedUser();
if (!user) return;
safeSetup(user._id);
// Use nextTick to avoid blocking the UI
Vue.nextTick(() => {
_gatherUserStats(properties);
gtag('set', 'user_properties', properties);
forEach(properties, (value, key) => {
const identify = new amplitude.Identify().set(key, value);
amplitude.getInstance().identify(identify);
});
const store = getStore();
store.dispatch('analytics:updateUserProperties', properties);
});
}

View File

@@ -215,7 +215,7 @@ export default {
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

View File

@@ -66,7 +66,7 @@ export default {
uuid: user._id,
taskType: task.type,
direction,
}, { trackOnClient: true });
});
if (!tasksScoredCount) {
setLocalSetting(CONSTANTS.keyConstants.TASKS_SCORED_COUNT, 1);
} else {

View File

@@ -1,7 +1,8 @@
<template>
<tr>
<td colspan="3"
<td
v-if="!mixinData.inlineSettingMixin.modalVisible"
colspan="3"
>
<div class="d-flex justify-content-between align-items-center">
<h3
@@ -18,8 +19,9 @@
</a>
</div>
</td>
<td colspan="3"
<td
v-if="mixinData.inlineSettingMixin.modalVisible"
colspan="3"
>
<h3
v-once
@@ -59,8 +61,8 @@
{{ $t('performanceAnalytics') }}
</label>
<toggle-switch
class="mb-auto"
v-model="user.preferences.analyticsConsent"
class="mb-auto"
@change="prefToggled()"
/>
</div>

View File

@@ -128,7 +128,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';
@@ -280,7 +279,6 @@ export default {
return null;
}
}
Analytics.updateUser();
return axios.get(
'/api/v4/i18n/browser-script',
{

View File

@@ -320,7 +320,7 @@ router.beforeEach(async (to, from, next) => {
eventName: 'View Find Members',
eventAction: 'View Find Members',
eventCategory: 'behavior',
}, { trackOnClient: true });
});
}
// Redirect old guild urls

View File

@@ -2,6 +2,10 @@ import axios from 'axios';
export async function trackEvent (store, params) {
const url = `/analytics/track/${params.eventAction}`;
await axios.post(url, params);
}
export async function updateUserProperties (store, params) {
const url = '/analytics/update';
await axios.post(url, params);
}

View File

@@ -1,8 +1,6 @@
import axios from 'axios';
import { authAsCredentialsState, LOCALSTORAGE_AUTH_KEY } from '@/libs/auth';
const GA_ID = import.meta.env.GA_ID;
function saveLocalDataAuth (store, apiId, apiToken) {
const credentialsObj = {
auth: {
@@ -123,9 +121,6 @@ export async function appleAuth (store, params) {
export function logout (store, options = {}) {
localStorage.clear();
sessionStorage.clear();
if (window.gtag) {
window.gtag('config', GA_ID, { user_id: null });
}
const query = options.redirectToLogin === true ? '?redirectToLogin=true' : '';
window.location.href = `/logout-server${query}`;
}

View File

@@ -120,7 +120,7 @@ export async function create (store, createdTask) {
hitType: 'event',
uuid,
taskType: taskRes.type,
}, { trackOnClient: true });
});
if (!tasksCreatedCount) {
setLocalSetting(CONSTANTS.keyConstants.TASKS_CREATED_COUNT, 1);
} else {

View File

@@ -26,11 +26,9 @@ const envVars = [
'EMAILS_COMMUNITY_MANAGER_EMAIL',
'EMAILS_TECH_ASSISTANCE_EMAIL',
'EMAILS_PRESS_ENQUIRY_EMAIL',
'GA_ID',
'STRIPE_PUB_KEY',
'GOOGLE_CLIENT_ID',
'APPLE_AUTH_CLIENT_ID',
'AMPLITUDE_KEY',
'LOGGLY_CLIENT_TOKEN',
'TRUSTED_DOMAINS',
'TIME_TRAVEL_ENABLED',

View File

@@ -3437,5 +3437,7 @@
"shieldSpecialFall2025WarriorText": "Sasquatch Schild",
"shieldSpecialFall2025WarriorNotes": "Verschaffe dir etwas mehr Zeit zum Nachdenken und Planen, indem du dich vor deinen nächsten Tagesaufgaben abschirmst. Erhöht die Konstitution um <%= con %>. Limitierte Auflage Herbst 2025 Ausrüstung.",
"shieldSpecialFall2025HealerNotes": "Verschaffe dir etwas mehr Zeit, um Vorräte zu sammeln, indem du dich vor deinen Aufgaben abschirmst. Erhöht die Konstitution um <%= con %>. Limitierte Ausgabe Herbst 2025 Ausrüstung.",
"shieldArmoireSoftOrangePillowNotes": "Der vorbereitete Krieger packt für jede Expedition ein Kissen ein. Mach dich bereit, neue Verpflichtungen zu übernehmen ... sogar während du ein Nickerchen machst. Erhöht Intelligenz und Wahrnehmung um jeweils <%= attrs %>. Verzauberter Kleiderschrank: Orangenes Loungewear-Set (Gegenstand 3 von 3)."
"shieldArmoireSoftOrangePillowNotes": "Der vorbereitete Krieger packt für jede Expedition ein Kissen ein. Mach dich bereit, neue Verpflichtungen zu übernehmen ... sogar während du ein Nickerchen machst. Erhöht Intelligenz und Wahrnehmung um jeweils <%= attrs %>. Verzauberter Kleiderschrank: Orangenes Loungewear-Set (Gegenstand 3 von 3).",
"bodyMystery202509Text": "Schal des windgepeitschten Wanderers",
"armorSpecialFall2025RogueNotes": "Ein hartes und schmales Ziel in dieser saisonalen Rüstung ist am schwersten zu treffen. Erhöht die Wahrnehmung um <%= per %>. Limitierte Ausgabe Herbst 2025 Ausrüstung."
}

View File

@@ -1,5 +1,5 @@
{
"stable": "Haus- und Reittiere",
"stable": "Haustiere und Reittiere",
"pets": "Haustiere",
"activePet": "Aktives Haustier",
"noActivePet": "Kein aktives Haustier",

View File

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

View File

@@ -3389,7 +3389,7 @@
"weaponSpecialFall2025MageNotes": "Una poderosa arma capaz de trazar una senda segura a través de los terrores del Bosque Negro. Aumenta la Inteligencia en <%= int %> y la Percepción en <%= per %>. Equipamiento de Edición Limitada Otoño 2025.",
"weaponSpecialFall2025WarriorText": "Hacha de Bigfoot",
"weaponSpecialFall2025HealerText": "Hacha Kobold",
"weaponSpecialFall2025HealerNotes": "Una poderosa arma capaz de trazar una senda segura a través de los obstáculos del Bosque Negro. Aumenta la Fuerza en <%= str %>. Equipamiento de Edición Limitada Otoño 2025.",
"weaponSpecialFall2025HealerNotes": "Una poderosa arma capaz de trazar una senda segura a través de los obstáculos del Bosque Negro. Aumenta la Inteligencia en <%= int %>. Equipamiento de Edición Limitada Otoño 2025.",
"weaponSpecialFall2025MageText": "Hacha de Fantasma Enmascarado",
"weaponMystery202511Text": "Espada Escarcha",
"weaponMystery202511Notes": "El halo helado de esta espada te permitirá realizar con rapidez incluso las tareas rojas más oscuras. No otorga ningún beneficio. Artículo de Suscriptor Noviembre 2025.",

View File

@@ -227,5 +227,12 @@
"subscriptionDetail24": "有料会員が「タイムトラベラーズショップ」からアイテムを収集できる機会を、年間4回以上に増やしたいと考えました。",
"subscriptionHeading3": "リリース当日特典",
"subscriptionPara1": "新しいスケジュールへの移行をスムーズにするため、既存の有料会員にはリリース当日に追加の特典が用意されます。この変更に伴い、引き続きサポートしてくださる皆様に心から感謝いたします!",
"subscriptionDetail4400": "現在、毎月ジェムを<%= initialNumber %>個アンロックしている場合、ジェムの上限は<%= roundedNumber %>個に調整されます。"
"subscriptionDetail4400": "現在、毎月ジェムを<%= initialNumber %>個アンロックしている場合、ジェムの上限は<%= roundedNumber %>個に調整されます。",
"subscriptionDetail48": "ミステリー装備セットなど、他の有料プランの特典には変更ありますか?",
"subscriptionDetail33": "このご褒美は、11月19日以前に有料プランを始めたアカウントしか受け取れません。",
"subscriptionDetail42": "有料プランに登録している間、1ヶ月間ログインしない場合、この特典はまだ受け取れますか",
"subscriptionDetail400": "現在の有料会員には、リリース後の最初の月の初回ログイン時に、最初のミスティック砂時計と毎月のジェム上限が+2増加します。つまり、もしすでに11月にログインしている場合、最初の定期的な増加は12月に行われます。",
"subscriptionDetail40": "私は有料会員ですが、新しいスケジュールでは最初の定期的な神秘の砂時計とジェム上限の増加はいつ受け取れますか?",
"subscriptionDetail420": "ミステリーギアセットと同様に、登録している間にログインしなくても神秘の砂時計やジェムの上限アップを見逃すことはありません。次回ログインしたときに、登録していた期間のすべての特典を受け取ることができます。",
"subscriptionDetail43": "有料プランを申し込んでからキャンセルした場合でも、特典は受けられますか?"
}

View File

@@ -3185,5 +3185,7 @@
"shieldSpecialFall2025HealerText": "コボルドの盾",
"shieldArmoireHattersPocketWatchText": "ピカピカなポケットウォッチ",
"shieldSpecialSummer2024HealerText": "海貝の盾",
"shieldSpecialSummer2024HealerNotes": "このぴかぴかする盾は、海貝の杖よりも強いです。体質が<%= con %>上がります。2024年夏の限定装備。"
"shieldSpecialSummer2024HealerNotes": "このぴかぴかする盾は、海貝の杖よりも強いです。体質が<%= con %>上がります。2024年夏の限定装備。",
"armorMystery202504Notes": "「汚れた雪男」、だって本当は可愛らしいでしょ効果なし。2025年4月の有料会員アイテム。",
"armorArmoireSpringPetalYukataText": "春の花びらの浴衣"
}

View File

@@ -6,13 +6,13 @@
"questEvilSantaDropBearCubPolarMount": "シロクマ(乗騎)",
"questEvilSanta2Text": "子グマの捜索",
"questEvilSanta2Notes": "猟師のサンタが乗騎のシロクマを捕まえたとき、子グマは、氷原に逃げていきました。たしかに森の中の氷の結晶の音に交じって、小枝をふむ音や雪がくだける音が聞こえます。足あとだ!それを追いかけようと走り出します。足あとと折れた小枝を見のがしてはいけません。子グマを見つけ出しましょう!<br><br><strong>注意</strong>:「子グマの捜索」のクエストは何回でも挑戦できますが、クエスト報酬の特別なペットが手に入るのは最初の一回だけです。",
"questEvilSanta2Completion": "子グマを見つけました! ずっとあなたから離れないでしょう。",
"questEvilSanta2Completion": "子グマを見つけました!ずっとあなたから離れないでしょう。",
"questEvilSanta2CollectTracks": "足跡",
"questEvilSanta2CollectBranches": "折れた小枝",
"questEvilSanta2DropBearCubPolarPet": "シロクマ(ペット)",
"questGryphonText": "炎のグリフォン",
"questGryphonNotes": "偉大な猛獣使い、<strong>baconsaur</strong>があなたのパーティーに助けを求めてきました。「冒険者よ、どうか私を助けてください 大切なグリフォンが逃げてしまい、Habit シティーを自由気ままに飛び回り、人びと に恐怖を与えているのです。もしグリフォンを止められたら、お礼にグリフォンのたまごを差し上げます!」",
"questGryphonCompletion": "やりました! 強い獣はこそこそとはずかしそうに主人の元に帰りました。「驚いた! 冒険者よ、よくやってくれましたね!」 <strong>baconsaur</strong> は声を上げました。 「どうかこのグリフォンのたまごを受けとってください。あなたならきっとうまく育てられるでしょう!」",
"questGryphonCompletion": "やりました!強い獣はこそこそとはずかしそうに主人の元に帰りま。「驚いた!冒険者よ、よくやってくれましたね!」<strong>baconsaur</strong> は声を上げま。 「どうかこのグリフォンのたまごを受けとってください。あなたならきっとうまく育てられるでしょう!」",
"questGryphonBoss": "炎のグリフォン",
"questGryphonDropGryphonEgg": "グリフォン ( たまご )",
"questGryphonUnlockText": "市場でグリフォンのたまごを買えるようになります",
@@ -815,5 +815,8 @@
"questChameleonNotes": "タスクの森の暖かく雨の降る一角で、美しい一日が始まります。あなたは葉のコレクションに新しい蒐集物を探していると、目の前の枝が予告なしに色を変えました!しかも、その枝が動いたのです!<br><br>後ろにひっくり返りそうになりながら気づくと、それは枝ではなく巨大なカメレオンでした。体のあらゆる部分が色を変え続け、目はあちこちにキョロキョロと動いています。<br><br>「大丈夫ですか?」とあなたはカメレオンに尋ねます。<br><br>「うーん、ええと…」と、少しあわてた様子で彼は答えます。「隠れようとしているんだけど…色が次々に現れては消えるから圧倒されちゃって…ひとつに集中するのが難しいんだ…」<br><br>「なるほど」とあなた。「それなら手伝えるかも。小さなチャレンジで集中力を鍛えよう!色の準備はいい?」<br><br>「任せて!」とカメレオンは答えました。",
"questPlatypusRageEffect": "完璧主義者のカモノハシは川に潜って、パーティーに水をかけます!パーティーのマナが減ってしまいました!",
"questOpalCollectLibraRunes": "てんびん座のルーン",
"questOpalCollectMercuryRunes": "水星のルーン"
"questOpalCollectMercuryRunes": "水星のルーン",
"questPlatypusNotes": "コンクエスト入り江の天気は晴れているが、宿題プリントのせいで台無しになっています。なんでいつも冒険したいときに宿題があるんだろう、とあなたは考えます。河川の生態系についての問題を解いていると、作文が出てきます。<br><br>「動物は川に住むためにどのように工夫しているか?知らんよ...」<br><br>どこから始めたらいいのかもわからず戸惑っていると、下流からバタバタする音が聞こえてきます。<br><br>「やれやれ」と、水面のしたからため息。すると、疲れたカモノハシが浮かび上がります。「全然巣穴が進まない。どうしても思い通りに作れないわ。」彼女は再び水中に潜り込み、幅広い尾が水面にあたるとあなたに大きな水しぶきがかかります。<br><br>「ちょっと待って、全部崩さないで!」とあなたは叫びます。早く助けてあげないと!(ついでに作文のアイデアが出るかも!)",
"questCatRageTitle": "怒りの猫パンチ",
"questRaccoonRageEffect": "欲張りなアライグマはあなたが救出したアイテムを奪い取り、木の中に詰め込みます。ボスは体力を30回復してしまいました"
}

View File

@@ -268,5 +268,6 @@
"earn2Gems": "登録している間、毎月<strong>+2個のジェム</strong>を獲得",
"mysterySet202502": "親切なアルレッキーノセット",
"maxGemCapGift": "受取人は<strong>最大限のジェム</strong>をゲットします",
"subscribeAgainContinueHourglasses": "神秘の砂時計をゲットし続けるには、再び有料プランに登録してください"
"subscribeAgainContinueHourglasses": "神秘の砂時計をゲットし続けるには、再び有料プランに登録してください",
"immediate12Hourglasses": "初めて12ヶ月間の有料プランに登録したとき、<strong>12個の神秘の砂時計</strong> をすぐにゲットしよう!"
}

View File

@@ -773,5 +773,7 @@
"backgroundInsideACrystalNotes": "Kijk vanuit een Kristal naar buiten.",
"backgroundSnowyVillageText": "Besneeuwd Dorp",
"backgroundSnowyVillageNotes": "Bewonder een Besneeuwd Dorp.",
"backgrounds122022": "SET 103: Uitgebracht december 2022"
"backgrounds122022": "SET 103: Uitgebracht december 2022",
"backgroundSpringtimeShowerText": "Lentedouche",
"backgroundSpringtimeShowerNotes": "Zie een bloemrijke lentedouche."
}

View File

@@ -1772,7 +1772,7 @@
"eyewearArmoireComedyMaskText": "Komediowa Maska",
"eyewearArmoireJewelersEyeLoupeText": "Lupa Jubilerska",
"moreArmoireGearAvailable": "Do tego czasu, zostało do znalezienia <%= armoireCount %> części wyposażenia w Zaczarowanej Skrzyni!",
"gearItemsCompleted": "Jesteś w posiadaniu całego ekwipunku klasy <%= class %>! Nowy ekwipunek jest wydawany podczas Gal sezonowych.",
"gearItemsCompleted": "Jesteś w posiadaniu całego ekwipunku klasy <%= klass %>! Nowy ekwipunek jest wydawany podczas Gal sezonowych.",
"moreArmoireGearComing": "Zaczarowana Skrzynia również comiesięcznie otrzymuje nowy asortyment!",
"weaponSpecialFall2019MageNotes": "Niezależnie od tego, czy chodzi o wykuwanie piorunów, wznoszenie fortyfikacji, czy po prostu wzbudzanie przerażenia w sercach śmiertelników, ta laska daje moc gigantów do czynienia cudów. Zwiększa inteligencję o <%= int %> i percepcję o <%= per %>. Limitowana edycja 2019 Jesienne Wyposażenie.",
"weaponSpecialFall2019HealerText": "Przerażające Filakterium",

View File

@@ -130,7 +130,7 @@
"optOutOfClasses": "Отказаться",
"chooseClass": "Выберите свой класс",
"chooseClassLearnMarkdown": "[Узнать больше о системе классов в стране Habitica](https://habitica.fandom.com/ru/wiki/Система_классов)",
"optOutOfClassesText": "Не хотите утруждать себя выбором класса? Или предпочитаете определиться позже? Отказавшись от класса, вы останетесь простым воином без специальных способностей. Вы также можете прочитать про систему классов на нашей вики позже и включить классы в любое время на странице Пользователь -> Настройки.",
"optOutOfClassesText": "Пока не готовы выбрать? Это не к спеху. Если откажетесь, вы можете ознакомиться с каждым классом в <a href='/static/faq#what-classes' target='_blank'>нашем FAQ</a> и по готовности зайти в «Настройки» для выбора класса.",
"selectClass": "Выбрать <%= heroClass %>",
"select": "Выбрать",
"stealth": "Хитрость",

View File

@@ -5,7 +5,7 @@
"webFaqStillNeedHelp": "如果您有任何疑問,但沒出現在以上列表中或是 [Wiki FAQ](https://habitica.fandom.com/wiki/FAQ)中,請至 [Habitica Help guild](https://habitica.com/groups/guild/5481ccf3-5d2d-48a9-a871-70a7380cee5a)裡詢問,我們相當樂意協助。",
"commonQuestions": "常見問題",
"faqQuestion25": "這些不同的任務類型是什麼?",
"webFaqAnswer25": "習慣可以用於你希望每天做多次的事情,或是一些不確定的計劃。 它們可以被選擇完成或完不成,選擇完成會帶來金幣和經驗的獎勵,而另一種則會對你造成生命值傷害。\n\n每日任務是你希望更嚴謹地按計畫進行的重複任務例如每天一次、每週三次或是月四次。 錯過完成每日任務會導致你受到生命值傷害,但這些任務越艱難,帶來的獎勵越豐厚!\n\n待辦事項是一次性任務當你完成會提供獎勵。 待辦事項可以設定截止時間,但你不會因為錯過時間而失去生命值。\n\n選擇最適合你想要完成的任務類型吧!",
"webFaqAnswer25": "Habitica 使用三種不同的任務類型來滿足您的需求,分別是:習慣 (Habits)、每日任務 (Dailies),以及待辦事項 (To-Do's)。\n\n習慣 (Habits)\n「習慣」分為正面與負面兩種適合用來追蹤那些您想在一天內多次執行、或沒有固定時程的行為。正面的習慣會為您帶來獎勵如金幣和經驗值而負面的習慣則會讓您失去生命值。\n\n每日任務 (Dailies)\n「每日任務」是您想要在更有結構的時程上完成的重複任務例如每天一次、每週三次或是一個月四次。若未完成每日任務會導致您失去生命值,但任務的難度越高,完成後的獎勵越豐厚!\n\n待辦事項 (To-Dos)\n「待辦事項」是一次性任務,在您完成會提供獎勵。待辦事項可以設定截止日期,但即使錯過了,您也不會因此失去生命值。\n\n請挑選最適合您想達成目標的任務類型吧!",
"faqQuestion26": "什麼是範例任務?",
"contentAnswer62": "情人節魔術孵化藥水現在已納入每月的時間表中。",
"contentFaqPara3": "如果您有任何上述答案未涵蓋的問題,您可以隨時透過 <%= mailto %> 聯絡我們的團隊!我們對於新內容發行計畫感到非常興奮,並期待未來有更多的計畫能幫助所有玩家讓 Habitica 變得更好。",
@@ -29,7 +29,7 @@
"subscriptionHeading2": "我們為什麼要做這些改變?",
"subscriptionDetail20": "在目前的架構下,您很難了解您會收到多少個神秘沙漏,以及何時會收到。",
"faqQuestion27": "為什麼任務會改變顏色?",
"faqQuestion28": "如果我需要休息,可以暫停每日任務嗎?",
"faqQuestion28": "如果我需要休息一下,可以暫停我的每日任務嗎?",
"faqQuestion29": "如何恢復 HP生命值",
"faqQuestion30": "HP生命值歸零時會發生什麼事",
"faqQuestion32": "要怎麼選擇職業?",
@@ -64,8 +64,8 @@
"sunsetFaqHeader12": "公會銀行寶石會如何?",
"sunsetFaqPara21": "公會銀行中的寶石將在 8 月 8 日公會服務結束時退還給公會領袖。",
"sunsetFaqHeader2": "為什麼酒館和公會服務要結束?",
"webFaqAnswer26": "正習慣(你想要培養的行為;應有「」按鈕\n\n * 吃維他命\n * 使用牙線\n * 學習一小時\n\n負向習慣你想要限制或避免的行為應該有「」按鈕\n\n * 抽菸\n * 無止盡滑手機\n * 咬指甲\n\n雙向習慣同時包含正面與負面選項的行為應有「」與「」按鈕\n\n * 喝水 vs. 喝汽水\n * 學習 vs. 拖延\n\n每日任務範例(你想定期執行的事情)\n * 洗碗\n * 澆花\n * 30 分鐘的運動\n\n待辦事項範例只需要完成一次的事情\n\n * 預約時間\n * 整理衣櫃\n * 完成報告",
"webFaqAnswer27": "任務的顏色能夠一目了然地看出其價值。所有任務的初始顏色都是黃色(代表中性)、藍色代表較好)、紅色代表較差)。以下是決定每種任務類型價值的方式:\n\n習慣的顏色會根據你是按下「」或「」按鈕而變藍或紅。如果沒完成某個正向習慣和負向習慣,久了它們就會逐漸變成黃色;雙向習慣的顏色則只會根據你按下的按鈕而改變。\n\n每日任務的顏色會根據完成頻率而變化完成的每日任務顏色會變藍未完成的每日任務顏色會變紅。\n\n待辦事項的顏色會隨著未完成時間的延長而逐漸變紅。\n\n任務的紅色愈深完成它獲得的金幣和經驗值就愈多所以即使是最艱的任務,也一定要挑戰",
"webFaqAnswer26": "正習慣 (您想要鼓勵的行為;應有「+」按鈕)\n\n * 吃維他命\n * 使用牙線\n * 學習一小時\n\n負向習慣你想要限制或避免的行為應該有「」按鈕\n\n * 抽菸\n * 無止盡滑手機\n * 咬指甲\n\n雙向習慣同時包含正面與負面選項的行為同時帶有「+」與「-」按鈕)\n\n * 喝水 vs. 喝汽水\n * 學習 vs. 拖延\n\n每日任務範例 (您想依循規律時程重複的任務)\n * 洗碗\n * 澆花\n * 30 分鐘的體能運動\n\n待辦事項範例只需要完成一次的事情)\n\n * 預約時間\n * 整理衣櫃\n * 完成報告",
"webFaqAnswer27": "任務的顏色直觀地呈現了該任務的價值。所有任務的初始顏色都是都是代表中性的黃色,藍色代表良好,紅色代表不良。以下是各任務類型決定其顏色的方式:\n\n習慣會根據您點擊「+」或「-」按鈕而變得更藍或紅。如果您長時間沒有點擊,正面與負面的習慣都會隨著時間逐漸退回至黃色。而雙向習慣的顏色則只會依據您的點擊而改變。\n\n每日任務的顏色會根據完成頻率而變化完成的每日任務顏色會變藍未完成的每日任務顏色會變紅。\n\n待辦事項在未完成的狀態下放置越久,顏色就會逐漸變得越紅。\n\n任務的紅色愈深完成它獲得的金幣和經驗值就愈多所以,請務必去挑戰那些您最艱的任務!",
"webFaqAnswer28": "當然可以! 在「設定」中有個「暫停傷害」按鈕,可以防止你因為沒完成每日任務而損失生命值。如果你正在度假、需要休息,或因為其他原因需要暫停一下,這個功能可以幫得上忙。如果你正在打某個副本,你自己的待結算進度會暫停,但你仍然會因隊友沒完成每日任務而受到傷害。\n\n如果要暫停特定的每日任務你可以編輯排程改為「每 0 天到期一次」,直到你準備好重新開始為止。",
"webFaqAnswer29": "你可以從「獎勵」欄花費 25 金幣購買「治療藥水」,即可恢復 15 點生命值。另外,每次升級後都會重新滿血!",
"webFaqAnswer30": "如果 HP生命值歸零你就會降級一等、失去所有金幣以及一件可以重新購買的裝備。",
@@ -118,5 +118,19 @@
"webFaqAnswer59": "Habitica團隊計劃提供了一種共享體驗允許隊員輕鬆地在共享任務板上添加、分配和完成任務。憑借成員角色、狀態視圖和任務分配等功能團隊計劃非常適合擁有共同目標的家庭或同事團隊。這也是一種在與怪物戰鬥和改善生活旅程中互相激勵的好方式。",
"webFaqAnswer60": "這裡有一些小提示來幫助您開啟Habitica團隊計劃的使用 \n\n * 可以提拔成員為管理員,讓他們能夠創建和編輯任務 \n * 如果是任何人都能完成且只需完成1次的任務請讓該任務保持未分配狀態 \n * 可以將任務分配給某人可以確保其他人無法完成該任務 \n * 如果某個任務需要多人完成,可以將該任務分給多人 \n * 為防止遺漏完成多人共享任務,你的個人任務版面也可以切換顯示共享任務 \n * 即使是分配給多人的任務,你也能因為完成任務而獲得獎勵 \n * 完成任務的獎勵不會在隊員之間拆分 \n * 可以在團隊任務板上使用任務顏色來判斷任務的平均完成率 \n * 記得定期檢查共享任務板上的任務,確保共享任務仍然有效 \n * 錯過完成完成每日任务不会对你或你的团队造成伤害,但该任务的颜色会逐渐变差",
"webFaqAnswer61": "只有團隊隊長和管理員可以創建共享任務。如果你想要某位成員也能創建任務,需要將其提升為管理員。\n\n在網頁端將團隊計劃的成員提升為管理員需要\n 1. 導航到團隊計劃頁面並切換到“團隊信息”菜單\n 2. 查看成員列表並點擊要提升成員旁的原型按鈕\n 3. 選擇“指定為管理員”",
"faqQuestion62": "該怎麼指派任務?"
"faqQuestion62": "該怎麼指派任務?",
"webFaqAnswer62": "群組方案讓你能夠將共享任務分配給群組計劃中的其他成員。當共享任務被分配給某個成員時,其他成員將無法完成該任務。\n\n你也可以將任務分配給多個成員。例如如果每個人都必須刷牙建立一個任務並將它分配給每位成員。每位成員都可以完成任務並獲得各自的獎勵。當所有人都完成後主要任務將顯示為已完成。",
"faqQuestion63": "未分配的任務是如何運作的?",
"webFaqAnswer63": "未分配的任務可以由任何成員完成。例如:倒垃圾。無論誰去倒垃圾,都可以完成這個未分配的任務,並且它會顯示為所有人都已完成。",
"faqQuestion64": "同步的每日重置是如何運作的?",
"webFaqAnswer64": "共享任務會在同一時間為所有人重置,以保持共享任務板的同步。這個時間會顯示在共享任務板上,並由群組計劃領導者的「一天開始時間」決定。\n由於共享任務會自動重置所以當你在隔天早上登入時將沒有機會完成前一天未完成的共享每日任務。\n\n未完成的共享每日任務不會造成傷害但它們的顏色會逐漸退化以幫助視覺化進度。",
"faqQuestion65": "群組方案在行動應用程式上有支援嗎?",
"webFaqAnswer65": "雖然行動應用程式尚未完全支援所有群組方案的功能,但你仍然可以透過 iOS 和 Android 應用程式完成共享任務!\n\n在 Android 上,檢視任務時可以點擊螢幕頂端的顯示名稱,切換到共享任務板。從那裡你可以查看成員、進入聊天,以及建立、完成或分配任務。\n\n你也可以開啟一個偏好設定把共享任務複製到你的個人任務板這樣就能在同一個地方完成所有任務。\n\n在行動應用程式上操作方式\n打開設定開啟「複製共享任務」\n\n在 Habitica 網站上操作方式:\n前往你的群組方案並在共享任務板上開啟「複製任務」切換鍵",
"faqQuestion66": "群組方案的共享任務與挑戰任務有什麼不同?",
"webFaqAnswer66": "群組方案的共享任務板比挑戰更具動態性,因為它們可以不斷更新與互動。挑戰則適合在有一組固定任務要分送給許多人時使用。\n\n群組方案同時也是付費功能而挑戰對所有人都是免費的。\n\n在挑戰中你無法指定特定成員去完成某些任務而且挑戰也沒有共享的每日重置機制。總體來說挑戰提供的控制與直接互動會比較少。",
"sunsetFaqTitle": "Habitica 酒館與公會服務終止 FAQ",
"sunsetFaqPara1": "由於多種因素,包括我們玩家群體與 Habitica 的互動方式改變,以及新的內容規範,我們做出了艱難的決定,將於 <strong> 2023 年 8 月 8 日</strong> 終止酒館與公會服務。",
"sunsetFaqPara4": "為了紀念我們共同度過的時光,在邁入這個新時代之際,我們將贈送所有人一隻老兵寵物。至於我們出色的貢獻者們,我們也會送上一套特別的裝備組,以紀念他們在 Habitica 社群中的所有努力。",
"sunsetFaqPara5": "如果你想了解更多即將變動的內容,可以閱讀以下的詳細資訊。",
"sunsetFaqPara3": "我們做出這項決定,是為了能更好地將資源集中在 Habitica 玩家最依賴的部分,同時不會影響任何人的使用權限。"
}

View File

@@ -3,7 +3,6 @@ import isFunction from 'lodash/isFunction';
import min from 'lodash/min';
import reduce from 'lodash/reduce';
import filter from 'lodash/filter';
import pick from 'lodash/pick';
import pickBy from 'lodash/pickBy';
import size from 'lodash/size';
import moment from 'moment';
@@ -160,7 +159,7 @@ export default function randomDrop (user, options, req = {}, analytics) {
if (analytics && moment().diff(user.auth.timestamps.created, 'days') < 7) {
analytics.track('dropped item', {
user: pick(user, ['preferences', 'registeredThrough']),
user,
uuid: user._id,
itemKey: drop.key,
category: 'behavior',

View File

@@ -1,5 +1,3 @@
import pick from 'lodash/pick';
export function hasCompletedOnboarding (user) {
return (
user.achievements.createdTask === true
@@ -21,7 +19,7 @@ export function checkOnboardingStatus (user, req, analytics) {
user.addNotification('ONBOARDING_COMPLETE');
if (analytics) {
analytics.track('onboarding complete', {
user: pick(user, ['preferences', 'registeredThrough']),
user,
uuid: user._id,
hitType: 'event',
category: 'behavior',

View File

@@ -1,7 +1,6 @@
/* eslint-disable max-classes-per-file */
import get from 'lodash/get';
import merge from 'lodash/merge';
import pick from 'lodash/pick';
import i18n from '../../i18n';
import {
NotAuthorized,
@@ -114,7 +113,7 @@ export class AbstractBuyOperation {
sendToAnalytics (additionalData = {}) {
// spread-operator produces an "unexpected token" error
const analyticsData = merge(additionalData, {
user: pick(this.user, ['preferences', 'registeredThrough']),
user: this.user,
uuid: this.user._id,
category: 'behavior',
headers: this.req.headers,

View File

@@ -73,7 +73,7 @@ export class BuyArmoireOperation extends AbstractGoldItemOperation { // eslint-d
this.analytics.track(
'Enchanted Armoire',
{
user: pick(user, ['preferences', 'registeredThrough']),
user,
uuid: user._id,
itemKey: key,
category: 'behavior',

View File

@@ -1,6 +1,5 @@
import get from 'lodash/get';
import each from 'lodash/each';
import pick from 'lodash/pick';
import i18n from '../../i18n';
import content from '../../content/index';
import {
@@ -37,7 +36,7 @@ export default async function buyMysterySet (user, req = {}, analytics) {
if (analytics) {
analytics.track('buy', {
user: pick(user, ['preferences', 'registeredThrough']),
user,
uuid: user._id,
itemKey: mysterySet.key,
itemType: 'Subscriber Gear',

View File

@@ -1,7 +1,6 @@
import get from 'lodash/get';
import includes from 'lodash/includes';
import keys from 'lodash/keys';
import pick from 'lodash/pick';
import i18n from '../../i18n';
import content from '../../content/index';
import {
@@ -96,7 +95,7 @@ export default async function purchaseHourglass (user, req = {}, analytics, quan
if (analytics) {
analytics.track('buy', {
user: pick(user, ['preferences', 'registeredThrough']),
user,
uuid: user._id,
itemKey: key,
itemType: type,

View File

@@ -132,7 +132,7 @@ export default async function purchase (user, req = {}, analytics) {
/* eslint-enable no-await-in-loop */
if (analytics) {
analytics.track('buy', {
user: pick(user, ['preferences', 'registeredThrough']),
user,
uuid: user._id,
itemKey: key,
itemType: type,

View File

@@ -70,7 +70,7 @@ export default async function changeClass (user, req = {}, analytics) {
if (analytics) {
analytics.track('change class', {
user: pick(user, ['preferences', 'registeredThrough']),
user,
uuid: user._id,
class: klass,
currency: balanceRemoved === 0 ? 'Free' : 'Gems',

View File

@@ -2,7 +2,6 @@ import forEach from 'lodash/forEach';
import findIndex from 'lodash/findIndex';
import get from 'lodash/get';
import keys from 'lodash/keys';
import pick from 'lodash/pick';
import upperFirst from 'lodash/upperFirst';
import moment from 'moment';
import i18n from '../i18n';
@@ -143,7 +142,7 @@ export default function feed (user, req = {}, analytics) {
if (analytics && moment().diff(user.auth.timestamps.created, 'days') < 7) {
analytics.track('pet feed', {
user: pick(user, ['preferences', 'registeredThrough']),
user,
uuid: user._id,
foodKey: food.key,
petKey: pet.key,

View File

@@ -2,7 +2,6 @@ import findIndex from 'lodash/findIndex';
import forEach from 'lodash/forEach';
import get from 'lodash/get';
import keys from 'lodash/keys';
import pick from 'lodash/pick';
import upperFirst from 'lodash/upperFirst';
import moment from 'moment';
import i18n from '../i18n';
@@ -154,7 +153,7 @@ export default function hatch (user, req = {}, analytics) {
if (analytics && moment().diff(user.auth.timestamps.created, 'days') < 7) {
analytics.track('pet hatch', {
user: pick(user, ['preferences', 'registeredThrough']),
user,
uuid: user._id,
petKey: pet,
category: 'behavior',

View File

@@ -1,5 +1,4 @@
import each from 'lodash/each';
import pick from 'lodash/pick';
import i18n from '../i18n';
import { capByLevel } from '../statHelpers';
import { MAX_LEVEL } from '../constants';
@@ -23,7 +22,7 @@ export default async function rebirth (user, tasks = [], req = {}, analytics) {
const analyticsData = {
uuid: user._id,
user: pick(user, ['preferences', 'registeredThrough']),
user,
category: 'behavior',
};

View File

@@ -1,4 +1,3 @@
import pick from 'lodash/pick';
import content from '../content/index';
import { mountMasterProgress } from '../count';
import i18n from '../i18n';
@@ -44,7 +43,7 @@ export default async function releaseMounts (user, req = {}, analytics) {
if (analytics) {
analytics.track('release mounts', {
user: pick(user, ['preferences', 'registeredThrough']),
user,
uuid: user._id,
currency: 'Gems',
gemCost: 4,

View File

@@ -1,4 +1,3 @@
import pick from 'lodash/pick';
import content from '../content/index';
import { beastMasterProgress } from '../count';
import i18n from '../i18n';
@@ -44,7 +43,7 @@ export default function releasePets (user, req = {}, analytics) {
if (analytics) {
analytics.track('release pets', {
user: pick(user, ['preferences', 'registeredThrough']),
user,
uuid: user._id,
currency: 'Gems',
gemCost: 4,

View File

@@ -1,5 +1,4 @@
import each from 'lodash/each';
import pick from 'lodash/pick';
import i18n from '../i18n';
import {
NotAuthorized,
@@ -24,7 +23,7 @@ export default async function reroll (user, tasks = [], req = {}, analytics) {
if (analytics) {
analytics.track('Fortify Potion', {
user: pick(user, ['preferences', 'registeredThrough']),
user,
uuid: user._id,
currency: 'Gems',
gemCost: 4,

View File

@@ -1,5 +1,4 @@
import merge from 'lodash/merge';
import pick from 'lodash/pick';
import reduce from 'lodash/reduce';
import each from 'lodash/each';
import i18n from '../i18n';
@@ -112,7 +111,7 @@ export default function revive (user, req = {}, analytics) {
if (analytics) {
analytics.track('Death', {
user: pick(user, ['preferences', 'registeredThrough']),
user,
uuid: user._id,
lostItem,
category: 'behavior',

View File

@@ -1,11 +1,9 @@
import pick from 'lodash/pick';
export function sleep (user, req = {}, analytics) {
user.preferences.sleep = !user.preferences.sleep;
if (analytics) {
analytics.track('sleep', {
user: pick(user, ['preferences', 'registeredThrough']),
user,
uuid: user._id,
status: user.preferences.sleep,
category: 'behavior',

View File

@@ -1,5 +1,4 @@
import get from 'lodash/get';
import pick from 'lodash/pick';
import setWith from 'lodash/setWith';
import i18n from '../i18n';
import { NotAuthorized, BadRequest } from '../libs/errors';
@@ -318,7 +317,7 @@ export default async function unlock (user, req = {}, analytics) {
if (analytics) {
analytics.track('buy', {
user: pick(user, ['preferences', 'registeredThrough']),
user,
uuid: user._id,
itemKey: path,
itemType: 'customization',

View File

@@ -1,6 +1,5 @@
import validator from 'validator';
import moment from 'moment';
import pick from 'lodash/pick';
import sortBy from 'lodash/sortBy';
import nconf from 'nconf';
import {
@@ -128,7 +127,7 @@ api.loginLocal = {
await user.save();
res.analytics.track('login', {
user: pick(user, ['preferences', 'registeredThrough']),
user,
category: 'behavior',
type: 'local',
uuid: user._id,

View File

@@ -1,7 +1,6 @@
import cloneDeep from 'lodash/cloneDeep';
import escapeRegExp from 'lodash/escapeRegExp';
import merge from 'lodash/merge';
import pick from 'lodash/pick';
import reduce from 'lodash/reduce';
import times from 'lodash/times';
import { authWithHeaders, authWithSession } from '../../middlewares/auth';
@@ -291,7 +290,7 @@ api.createChallenge = {
response.group = getChallengeGroupResponse(group);
res.analytics.track('challenge create', {
user: pick(user, ['preferences', 'registeredThrough']),
user,
uuid: user._id,
hitType: 'event',
category: 'behavior',
@@ -360,7 +359,7 @@ api.joinChallenge = {
response.leader = chalLeader ? chalLeader.toJSON({ minimize: true }) : null;
res.analytics.track('challenge join', {
user: pick(user, ['preferences', 'registeredThrough']),
user,
uuid: user._id,
hitType: 'event',
category: 'behavior',
@@ -411,7 +410,7 @@ api.leaveChallenge = {
await challenge.unlinkTasks(user, keep);
res.analytics.track('challenge leave', {
user: pick(user, ['preferences', 'registeredThrough']),
user,
uuid: user._id,
hitType: 'event',
category: 'behavior',
@@ -896,7 +895,7 @@ api.deleteChallenge = {
await challenge.closeChal({ broken: 'CHALLENGE_DELETED' });
res.analytics.track('challenge delete', {
user: pick(user, ['preferences', 'registeredThrough']),
user,
uuid: user._id,
hitType: 'event',
category: 'behavior',
@@ -957,7 +956,7 @@ api.selectChallengeWinner = {
await challenge.closeChal({ broken: 'CHALLENGE_CLOSED', winner });
res.analytics.track('challenge close', {
user: pick(user, ['preferences', 'registeredThrough']),
user,
uuid: user._id,
hitType: 'event',
category: 'behavior',

View File

@@ -1,4 +1,3 @@
import pick from 'lodash/pick';
import moment from 'moment';
import nconf from 'nconf';
import { authWithHeaders } from '../../middlewares/auth';
@@ -187,7 +186,7 @@ api.postChat = {
// Check if account is newer than the minimum age for chat participation
if (moment().diff(user.auth.timestamps.created, 'minutes') < ACCOUNT_MIN_CHAT_AGE) {
analytics.track('chat age error', {
user: pick(user, ['preferences', 'registeredThrough']),
user,
uuid: user._id,
hitType: 'event',
category: 'behavior',
@@ -239,7 +238,7 @@ api.postChat = {
await Promise.all(toSave);
const analyticsObject = {
user: pick(user, ['preferences', 'registeredThrough']),
user,
uuid: user._id,
hitType: 'event',
category: 'behavior',

View File

@@ -5,7 +5,6 @@ import findIndex from 'lodash/findIndex';
import includes from 'lodash/includes';
import isArray from 'lodash/isArray';
import mergeWith from 'lodash/mergeWith';
import pick from 'lodash/pick';
import uniqBy from 'lodash/uniqBy';
import nconf from 'nconf';
import moment from 'moment';
@@ -169,7 +168,7 @@ api.createGroup = {
};
const analyticsObject = {
user: pick(user, ['preferences', 'registeredThrough']),
user,
uuid: user._id,
hitType: 'event',
category: 'behavior',
@@ -220,7 +219,7 @@ api.createGroupPlan = {
const savedGroup = results[1];
res.analytics.track('join group', {
user: pick(user, ['preferences', 'registeredThrough']),
user,
uuid: user._id,
hitType: 'event',
category: 'behavior',
@@ -705,7 +704,7 @@ api.joinGroup = {
promises.push(group.save());
const analyticsObject = {
user: pick(user, ['preferences', 'registeredThrough']),
user,
uuid: user._id,
hitType: 'event',
category: 'behavior',

View File

@@ -1,5 +1,4 @@
import escapeRegExp from 'lodash/escapeRegExp';
import pick from 'lodash/pick';
import { authWithHeaders } from '../../middlewares/auth';
import {
model as User,
@@ -737,7 +736,7 @@ api.transferGems = {
if (res.analytics) {
res.analytics.track('transfer gems', {
user: pick(sender, ['preferences', 'registeredThrough']),
user: sender,
uuid: sender._id,
hitType: 'event',
category: 'behavior',

View File

@@ -1,7 +1,6 @@
import each from 'lodash/each';
import every from 'lodash/every';
import isBoolean from 'lodash/isBoolean';
import pick from 'lodash/pick';
import { authWithHeaders } from '../../middlewares/auth';
import { getAnalyticsServiceByEnvironment } from '../../libs/analyticsService';
import {
@@ -168,7 +167,7 @@ api.inviteToQuest = {
// track that the inviting user has accepted the quest
analytics.track('quest', {
user: pick(user, ['preferences', 'registeredThrough']),
user,
uuid: user._id,
category: 'behavior',
headers: req.headers,
@@ -233,7 +232,7 @@ api.acceptQuest = {
// track that a user has accepted the quest
analytics.track('quest', {
user: pick(user, ['preferences', 'registeredThrough']),
user,
category: 'behavior',
owner: false,
response: 'accept',
@@ -298,7 +297,7 @@ api.rejectQuest = {
res.respond(200, savedGroup.quest);
analytics.track('quest', {
user: pick(user, ['preferences', 'registeredThrough']),
user,
category: 'behavior',
owner: false,
response: 'reject',
@@ -362,7 +361,7 @@ api.forceStart = {
res.respond(200, savedGroup.quest);
analytics.track('quest', {
user: pick(user, ['preferences', 'registeredThrough']),
user,
category: 'behavior',
owner: user._id === group.quest.leader,
response: 'force-start',

View File

@@ -1,7 +1,6 @@
import assign from 'lodash/assign';
import find from 'lodash/find';
import merge from 'lodash/merge';
import pick from 'lodash/pick';
import moment from 'moment';
import { authWithHeaders } from '../../middlewares/auth';
import {
@@ -333,7 +332,7 @@ api.createChallengeTasks = {
tasks.forEach(task => {
res.analytics.track('challenge task created', {
user: pick(user, ['preferences', 'registeredThrough']),
user,
uuid: user._id,
hitType: 'event',
category: 'behavior',
@@ -703,7 +702,7 @@ api.updateTask = {
if (group) {
res.analytics.track('task edit', {
user: pick(user, ['preferences', 'registeredThrough']),
user,
uuid: user._id,
hitType: 'event',
category: 'behavior',

View File

@@ -1,4 +1,3 @@
import pick from 'lodash/pick';
import isUUID from 'validator/lib/isUUID';
import { authWithHeaders } from '../../../middlewares/auth';
import * as Tasks from '../../../models/task';
@@ -64,7 +63,7 @@ api.createGroupTasks = {
tasks.forEach(task => {
res.analytics.track('team task created', {
user: pick(user, ['preferences', 'registeredThrough']),
user,
uuid: user._id,
hitType: 'event',
category: 'behavior',
@@ -253,7 +252,7 @@ api.assignTask = {
res.respond(200, task);
res.analytics.track('task assign', {
user: pick(user, ['preferences', 'registeredThrough']),
user,
uuid: user._id,
hitType: 'event',
category: 'behavior',

View File

@@ -1,7 +1,6 @@
import cloneDeep from 'lodash/cloneDeep';
import forEach from 'lodash/forEach';
import isFunction from 'lodash/isFunction';
import pick from 'lodash/pick';
import nconf from 'nconf';
import get from 'lodash/get';
import { authWithHeaders } from '../../middlewares/auth';
@@ -326,7 +325,7 @@ api.deleteUser = {
}
res.analytics.track('account delete', {
user: pick(user, ['preferences', 'registeredThrough']),
user,
uuid: user._id,
hitType: 'event',
category: 'behavior',

View File

@@ -1,4 +1,3 @@
import pick from 'lodash/pick';
import {
NotAuthorized,
} from '../../libs/errors';
@@ -22,16 +21,17 @@ api.trackEvent = {
// we authenticate these requests to make sure they actually came from a real user
middlewares: [authWithHeaders()],
async handler (req, res) {
// As of now only web can track events using this route
if (req.headers['x-client'] !== 'habitica-web') {
throw new NotAuthorized('Only habitica.com is allowed to track analytics events.');
if (req.headers['x-client'] !== 'habitica-web'
&& req.headers['x-client'] !== 'habitica-ios'
&& req.headers['x-client'] !== 'habitica-android') {
throw new NotAuthorized('Only official clients are allowed to track analytics events.');
}
const { user } = res.locals;
const eventProperties = req.body;
res.analytics.track(req.params.eventName, {
user: pick(user, ['preferences', 'registeredThrough']),
user,
uuid: user._id,
headers: req.headers,
category: 'behavior',
@@ -44,4 +44,31 @@ api.trackEvent = {
},
};
api.updateUserProperties = {
method: 'POST',
url: '/analytics/update',
// we authenticate these requests to make sure they actually came from a real user
middlewares: [authWithHeaders()],
async handler (req, res) {
if (req.headers['x-client'] !== 'habitica-web'
&& req.headers['x-client'] !== 'habitica-ios'
&& req.headers['x-client'] !== 'habitica-android') {
throw new NotAuthorized('Only official clients are allowed to track analytics events.');
}
const { user } = res.locals;
const properties = req.body;
res.analytics.updateUserData({
user,
uuid: user._id,
properties,
});
// not using res.respond
// because we don't want to send back notifications and other user-related data
res.status(200).send({});
},
};
export default api;

View File

@@ -2,6 +2,9 @@
import nconf from 'nconf';
import Amplitude from 'amplitude';
import useragent from 'useragent';
import validator from 'validator';
import { lookup } from 'ip-location-api';
import { createHash } from 'crypto';
import {
omit,
toArray,
@@ -27,6 +30,36 @@ if (AMPLITUDE_TOKEN) amplitude = new Amplitude(AMPLITUDE_TOKEN);
const Content = common.content;
function _hashUUID (uuid) {
return createHash('sha256').update(uuid).digest('hex');
}
function _anonymizeProperties (properties) {
if (Array.isArray(properties)) {
return properties.map(userProp => {
if (typeof userProp === 'string' && validator.isEmail(userProp)) {
return _hashUUID(userProp);
}
return userProp;
});
}
if (typeof properties === 'object' && properties !== null) {
const anonymizedProps = {};
Object.keys(properties).forEach(key => {
const value = properties[key];
if (typeof value === 'string' && validator.isEmail(value)) {
anonymizedProps[key] = _hashUUID(value);
} else if (typeof value === 'object' && value !== null) {
anonymizedProps[key] = _anonymizeProperties(value);
} else {
anonymizedProps[key] = value;
}
});
return anonymizedProps;
}
return properties;
}
function _lookUpItemName (itemKey) {
if (!itemKey) return null;
@@ -56,7 +89,7 @@ function _lookUpItemName (itemKey) {
return itemName;
}
function _formatUserData (user) {
function _formatUserData (user, ipaddress, anonymize = false) {
const properties = {};
if (user.stats) {
@@ -72,7 +105,7 @@ function _formatUserData (user) {
properties.balanceGemAmount = properties.balance * 4;
properties.tutorialComplete = user.flags && user.flags.tour && user.flags.tour.intro === -2;
properties.verifiedUsername = user.flags && user.flags.verifiedUsername;
if (properties.verifiedUsername && user.auth && user.auth.local) {
if (properties.verifiedUsername && user.auth && user.auth.local && !anonymize) {
properties.username = user.auth.local.lowerCaseUsername;
}
@@ -89,10 +122,12 @@ function _formatUserData (user) {
properties.contributorLevel = user.contributor.level;
}
if (user.purchased && user.purchased.plan.planId) {
properties.subscription = user.purchased.plan.planId;
} else {
properties.subscription = null;
if (!anonymize) {
if (user.purchased && user.purchased.plan.planId) {
properties.subscription = user.purchased.plan.planId;
} else {
properties.subscription = null;
}
}
if (user._ABtests) {
@@ -103,6 +138,16 @@ function _formatUserData (user) {
properties.loginIncentives = user.loginIncentives;
}
if (ipaddress) {
const location = lookup(ipaddress);
properties.country = location.country;
properties.region = location.region1;
}
if (anonymize) {
return _anonymizeProperties(properties);
}
return properties;
}
@@ -139,16 +184,20 @@ function _formatUserAgentForAmplitude (platform, agentString) {
return formattedAgent;
}
function _formatUUIDForAmplitude (uuid) {
function _formatUUIDForAmplitude (uuid, anonymize = false) {
if (anonymize) {
return _hashUUID(uuid);
}
return uuid || 'no-user-id-was-provided';
}
function _formatDataForAmplitude (data) {
const consented = data.user && data.user.preferences && data.user.preferences.analyticsConsent;
const event_properties = omit(data, AMPLITUDE_PROPERTIES_TO_SCRUB);
const platform = _formatPlatformForAmplitude(data.headers && data.headers['x-client']);
const agent = _formatUserAgentForAmplitude(platform, data.headers && data.headers['user-agent']);
const ampData = {
user_id: _formatUUIDForAmplitude(data.uuid),
user_id: _formatUUIDForAmplitude(data.uuid, !consented),
platform,
os_name: agent.name,
os_version: agent.version,
@@ -156,7 +205,12 @@ function _formatDataForAmplitude (data) {
};
if (data.user) {
ampData.user_properties = _formatUserData(data.user);
const ipaddress = data.ipaddress || (data.headers && data.headers['x-forwarded-for']);
ampData.user_properties = _formatUserData(data.user, ipaddress, !consented);
}
if (!consented) {
ampData.event_properties = _anonymizeProperties(ampData.event_properties);
}
const itemName = _lookUpItemName(data.itemKey);
@@ -215,12 +269,18 @@ function _setOnce (dataToSetOnce, uuid) {
.catch(err => logger.error(err, 'Error while sending data to Amplitude.'));
}
// There's no error handling directly here because it's handled inside _sendDataTo{Amplitude|Google}
function _updateProperties (properties, uuid) {
return amplitude
.identify({
user_id: _formatUUIDForAmplitude(uuid),
user_properties: properties,
})
.catch(err => logger.error(err, 'Error while sending data to Amplitude.'));
}
// There's no error handling directly here because it's handled inside _sendDataToAmplitude
async function track (eventType, data, loggerOnly = false) {
const { user } = data;
if (!user || !user.preferences || !user.preferences.analyticsConsent) {
return null;
}
const promises = [
_sendDataToAmplitude(eventType, data, loggerOnly),
];
@@ -234,29 +294,37 @@ async function track (eventType, data, loggerOnly = false) {
}
// There's no error handling directly here because
// it's handled inside _sendPurchaseDataTo{Amplitude|Google}
// it's handled inside _sendPurchaseDataToAmplitude
async function trackPurchase (data) {
const { user } = data;
if (!user || !user.preferences || !user.preferences.analyticsConsent) {
return null;
}
return Promise.all([
_sendPurchaseDataToAmplitude(data),
]);
}
async function updateUserData (data) {
const { user, properties } = data;
const toUpdate = {
..._formatUserData(user, data.ipaddress),
...properties,
};
return _updateProperties(toUpdate, user._id);
}
// Stub for non-prod environments
const mockAnalyticsService = {
track: () => { },
trackPurchase: () => { },
updateUserData: () => { },
};
// Return the production or mock service based on the current environment
function getServiceByEnvironment () {
if (nconf.get('IS_PROD') || (nconf.get('DEBUG_ENABLED') && !nconf.get('BASE_URL').includes('localhost'))) {
if (nconf.get('IS_PROD') || nconf.get('USE_PROD_ANALYTICS') || (nconf.get('DEBUG_ENABLED') && !nconf.get('BASE_URL').includes('localhost'))) {
return {
track,
trackPurchase,
updateUserData,
};
}
return mockAnalyticsService;

View File

@@ -1,5 +1,4 @@
import moment from 'moment';
import pick from 'lodash/pick';
import {
BadRequest,
NotAuthorized,
@@ -219,7 +218,7 @@ async function registerLocal (req, res, { isV3 = false }) {
if (!existingUser) {
res.analytics.track('register', {
user: pick(savedUser, ['preferences', 'registeredThrough']),
user: savedUser,
category: 'acquisition',
type: 'local',
uuid: savedUser._id,

View File

@@ -1,4 +1,3 @@
import pick from 'lodash/pick';
import passport from 'passport';
import common from '../../../common';
import { BadRequest, NotAuthorized, NotFound } from '../errors';
@@ -158,7 +157,7 @@ export async function loginSocial (req, res) { // eslint-disable-line import/pre
if (!existingUser) {
res.analytics.track('register', {
user: pick(savedUser, ['preferences', 'registeredThrough']),
user: savedUser,
uuid: savedUser._id,
category: 'acquisition',
type: network,

View File

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

View File

@@ -1,6 +1,5 @@
import moment from 'moment';
import mongoose from 'mongoose';
import pick from 'lodash/pick';
import nconf from 'nconf';
import { model as User } from '../models/user';
import * as Tasks from '../models/task';
@@ -104,7 +103,7 @@ function trackCronAnalytics (analytics, user, _progress, options) {
analytics.track('Cron', {
category: 'behavior',
uuid: user._id,
user: pick(user, ['preferences', 'registeredThrough']),
user,
resting: user.preferences.sleep,
cronCount: user.flags.cronCount,
progressUp: Math.min(_progress.up, 900),

View File

@@ -1,6 +1,5 @@
import find from 'lodash/find';
import includes from 'lodash/includes';
import pick from 'lodash/pick';
import { encrypt } from '../encryption';
import { sendNotification as sendPushNotification } from '../pushNotifications';
@@ -144,11 +143,10 @@ async function inviteByUUID (uuid, group, inviter, req, res) {
}
const analyticsObject = {
user: pick(inviter, ['preferences', 'registeredThrough']),
user: inviter,
uuid: inviter._id,
hitType: 'event',
category: 'behavior',
invitee: uuid,
groupId: group._id,
groupType: group.type,
headers: req.headers,
@@ -209,11 +207,10 @@ async function inviteByEmail (invite, group, inviter, req, res) {
if (!userIsUnsubscribed) sendTxnEmail(invite, `invite-friend${groupLabel}`, variables);
const analyticsObject = {
user: pick(inviter, ['preferences', 'registeredThrough']),
user: inviter,
uuid: inviter._id,
hitType: 'event',
category: 'behavior',
invitee: 'email',
groupId: group._id,
groupType: group.type,
headers: req.headers,
@@ -247,11 +244,10 @@ async function inviteByUserName (username, group, inviter, req, res) {
}
const analyticsObject = {
user: pick(inviter, ['preferences', 'registeredThrough']),
user: inviter,
uuid: inviter._id,
hitType: 'event',
category: 'behavior',
invitee: userToInvite._id,
groupId: group._id,
groupType: group.type,
headers: req.headers,

View File

@@ -1,5 +1,4 @@
import find from 'lodash/find';
import pick from 'lodash/pick';
import { getAnalyticsServiceByEnvironment } from '../analyticsService';
import { getCurrentEventList } from '../worldState'; // eslint-disable-line import/no-cycle
import { // eslint-disable-line import/no-cycle
@@ -115,7 +114,7 @@ export async function buyGems (data) {
if (!data.gift) txnEmail(data.user, 'donation');
analytics.trackPurchase({
user: pick(data.user, ['preferences', 'registeredThrough']),
user: data.user,
uuid: data.user._id,
itemPurchased: 'Gems',
sku: `${data.paymentMethod.toLowerCase()}-checkout`,

View File

@@ -1,4 +1,3 @@
import pick from 'lodash/pick';
import moment from 'moment';
import {
BadRequest,
@@ -32,7 +31,7 @@ async function buyGryphatrice (data) {
data.user.purchased.txnCount += 1;
analytics.trackPurchase({
user: pick(data.user, ['preferences', 'registeredThrough']),
user: data.user,
uuid: data.user._id,
itemPurchased: 'Gryphatrice',
sku: `${data.paymentMethod.toLowerCase()}-checkout`,

View File

@@ -3,7 +3,6 @@
import defaults from 'lodash/defaults';
import each from 'lodash/each';
import find from 'lodash/find';
import pick from 'lodash/pick';
import moment from 'moment';
import { getAnalyticsServiceByEnvironment } from '../analyticsService';
@@ -465,7 +464,7 @@ async function cancelSubscription (data) {
analytics.track(cancelType, {
uuid: data.user._id,
user: pick(data.user, ['preferences', 'registeredThrough']),
user: data.user,
groupId,
paymentMethod: data.paymentMethod,
headers: data.headers,

View File

@@ -3,7 +3,6 @@ import cloneDeep from 'lodash/cloneDeep';
import compact from 'lodash/compact';
import forEach from 'lodash/forEach';
import keys from 'lodash/keys';
import pick from 'lodash/pick';
import remove from 'lodash/remove';
import validator from 'validator';
import {
@@ -516,7 +515,7 @@ async function scoreTask (user, task, direction, req, res) {
role = 'member';
}
res.analytics.track('team task scored', {
user: pick(user, ['preferences', 'registeredThrough']),
user,
uuid: user._id,
hitType: 'event',
category: 'behavior',

View File

@@ -117,7 +117,7 @@ export async function update (req, res, { isV3 = false }) {
user.invitations.party = {};
user.invitations.parties = [];
res.analytics.track('Starts Looking for Party', {
user: _.pick(user, ['preferences', 'registeredThrough']),
user,
uuid: user._id,
hitType: 'event',
category: 'behavior',
@@ -201,7 +201,7 @@ export async function update (req, res, { isV3 = false }) {
if (key === 'party.seeking' && val === null) {
user.party.seeking = undefined;
res.analytics.track('Leaves Looking for Party', {
user: _.pick(user, ['preferences', 'registeredThrough']),
user,
uuid: user._id,
hitType: 'event',
category: 'behavior',
@@ -292,7 +292,7 @@ export async function reset (req, res, { isV3 = false }) {
]);
res.analytics.track('account reset', {
user: _.pick(user, ['preferences', 'registeredThrough']),
user,
uuid: user._id,
hitType: 'event',
category: 'behavior',

View File

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