Compare commits

..

1 Commits

Author SHA1 Message Date
Phillip Thelen b39d41ace6 update gulp and dependencies 2025-09-05 12:33:56 +02:00
702 changed files with 18290 additions and 34422 deletions
+8 -18
View File
@@ -82,7 +82,7 @@ jobs:
CI: true
NODE_ENV: test
- run: npm run test:sanity
common:
runs-on: ubuntu-latest
strategy:
@@ -129,13 +129,13 @@ jobs:
CI: true
NODE_ENV: test
- run: npm run test:content
api-unit:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [21.x]
mongodb-version: [7.0]
mongodb-version: [4.2]
steps:
- uses: actions/checkout@v4
with:
@@ -144,13 +144,11 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Start MongoDB ${{ matrix.mongodb-version }} Replica Set
uses: supercharge/mongodb-github-action@1.11.0
uses: supercharge/mongodb-github-action@1.3.0
with:
mongodb-version: ${{ matrix.mongodb-version }}
mongodb-replica-set: rs
- run: sudo apt update
- run: sudo apt -y install libkrb5-dev
- run: cp config.json.example config.json
@@ -160,17 +158,15 @@ jobs:
env:
CI: true
NODE_ENV: test
- run: npm run test:api:unit
env:
REQUIRES_SERVER=true: true
api-v3-integration:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [21.x]
mongodb-version: [7.0]
mongodb-version: [4.2]
steps:
- uses: actions/checkout@v4
with:
@@ -180,11 +176,10 @@ jobs:
with:
node-version: ${{ matrix.node-version }}
- name: Start MongoDB ${{ matrix.mongodb-version }} Replica Set
uses: supercharge/mongodb-github-action@1.11.0
uses: supercharge/mongodb-github-action@1.3.0
with:
mongodb-version: ${{ matrix.mongodb-version }}
mongodb-replica-set: rs
- run: sudo apt update
- run: sudo apt -y install libkrb5-dev
- run: cp config.json.example config.json
@@ -194,18 +189,15 @@ jobs:
env:
CI: true
NODE_ENV: test
- run: npm run test:api-v3:integration
env:
REQUIRES_SERVER=true: true
api-v4-integration:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [21.x]
mongodb-version: [7.0]
mongodb-version: [4.2]
steps:
- uses: actions/checkout@v4
with:
@@ -215,11 +207,10 @@ jobs:
with:
node-version: ${{ matrix.node-version }}
- name: Start MongoDB ${{ matrix.mongodb-version }} Replica Set
uses: supercharge/mongodb-github-action@1.11.0
uses: supercharge/mongodb-github-action@1.3.0
with:
mongodb-version: ${{ matrix.mongodb-version }}
mongodb-replica-set: rs
- run: sudo apt update
- run: sudo apt -y install libkrb5-dev
- run: cp config.json.example config.json
@@ -229,7 +220,6 @@ jobs:
env:
CI: true
NODE_ENV: test
- run: npm run test:api-v4:integration
env:
REQUIRES_SERVER=true: true
+1 -1
View File
@@ -47,5 +47,5 @@ webpack.webstorm.config
# mongodb replica set for local dev
mongodb-*.tgz
/mongodb-*
/mongodb-data*
/.nyc_output
+3 -2
View File
@@ -46,7 +46,7 @@
"MAINTENANCE_MODE": "false",
"MONGODB_POOL_SIZE": "10",
"MONGODB_SOCKET_TIMEOUT": "20000",
"NODE_DB_URI": "mongodb://localhost:27017/habitica-dev?replicaSet=rs&directConnection=true&readPreference=secondary",
"NODE_DB_URI": "mongodb://localhost:27017/habitica-dev?replicaSet=rs",
"NODE_ENV": "development",
"PATH": "bin:node_modules/.bin:/usr/local/bin:/usr/bin:/bin",
"PAYPAL_BILLING_PLANS_basic_12mo": "basic_12mo",
@@ -75,6 +75,7 @@
"S3_ACCESS_KEY_ID": "accessKeyId",
"S3_BUCKET": "bucket",
"S3_SECRET_ACCESS_KEY": "secretAccessKey",
"SESSION_SECRET_IV": "12345678912345678912345678912345",
"SESSION_SECRET_KEY": "1234567891234567891234567891234567891234567891234567891234567891",
"SESSION_SECRET": "YOUR SECRET HERE",
"SITE_HTTP_AUTH_ENABLED": "false",
@@ -89,7 +90,7 @@
"STRIPE_API_KEY": "aaaabbbbccccddddeeeeffff00001111",
"STRIPE_PUB_KEY": "22223333444455556666777788889999",
"STRIPE_WEBHOOKS_ENDPOINT_SECRET": "111111",
"TEST_DB_URI": "mongodb://localhost:27017/habitica-test?replicaSet=rs&directConnection=true&readPreference=secondary",
"TEST_DB_URI": "mongodb://localhost:27017/habitica-test?replicaSet=rs",
"TIME_TRAVEL_ENABLED": "false",
"TRUSTED_DOMAINS": "localhost,https://habitica.com",
"WEB_CONCURRENCY": 1
+53
View File
@@ -0,0 +1,53 @@
services:
client:
build:
context: .
dockerfile: ./Dockerfile-Dev
command: ["npm", "run", "client:dev"]
depends_on:
- server
environment:
- BASE_URL=http://server:3000
networks:
- habitica
ports:
- "8080:8080"
volumes:
- .:/usr/src/habitica
- /usr/src/habitica/node_modules
- /usr/src/habitica/website/client/node_modules
server:
build:
context: .
dockerfile: ./Dockerfile-Dev
command: ["npm", "start"]
depends_on:
mongo:
condition: service_healthy
environment:
- NODE_DB_URI=mongodb://mongo/habitrpg
networks:
- habitica
ports:
- "3000:3000"
volumes:
- .:/usr/src/habitica
- /usr/src/habitica/node_modules
mongo:
image: mongo:5.0.23
restart: unless-stopped
command: ["--replSet", "rs", "--bind_ip_all", "--port", "27017"]
healthcheck:
test: echo "try { rs.status() } catch (err) { rs.initiate() }" | mongosh --port 27017 --quiet
interval: 10s
timeout: 30s
start_period: 0s
start_interval: 1s
retries: 30
networks:
- habitica
ports:
- "27017:27017"
networks:
habitica:
driver: bridge
-23
View File
@@ -1,23 +0,0 @@
networks:
mongodb-network:
name: "mongodb-network"
driver: bridge
services:
mongodb:
image: "mongo:7.0"
container_name: "habitica-mongodb-only"
networks:
- mongodb-network
hostname: "mongodb"
ports:
- "27017:27017"
restart: "unless-stopped"
volumes:
- "./mongodb-data-docker:/data/db"
entrypoint: [ "/usr/bin/mongod", "--bind_ip_all", "--replSet", "rs" ]
healthcheck:
test: echo "try { rs.status() } catch (err) { rs.initiate() }" | mongosh --port 27017 --quiet
interval: 10s
timeout: 30s
start_period: 0s
retries: 30
-23
View File
@@ -1,23 +0,0 @@
networks:
mongodb-network:
name: "mongodb-network"
driver: bridge
services:
mongodb:
image: "mongo:7.0"
container_name: "habitica-mongodb-test"
networks:
- mongodb-network
hostname: "mongodb"
ports:
- "27017:27017"
restart: "unless-stopped"
volumes:
- "./mongodb-data-docker-testing:/data/db"
entrypoint: [ "/usr/bin/mongod", "--bind_ip_all", "--replSet", "rs" ]
healthcheck:
test: echo "try { rs.status() } catch (err) { rs.initiate() }" | mongosh --port 27017 --quiet
interval: 10s
timeout: 30s
start_period: 0s
retries: 30
+20 -41
View File
@@ -1,56 +1,35 @@
version: "3"
services:
client:
build:
context: .
dockerfile: ./Dockerfile-Dev
command: ["npm", "run", "client:dev:docker"]
depends_on:
- server
build: .
networks:
- habitica
environment:
- BASE_URL=http://server:3000
networks:
- habitica
ports:
- "5173:5173"
volumes:
- .:/usr/src/habitica
- /usr/src/habitica/node_modules
- /usr/src/habitica/website/client/node_modules
server:
build:
context: .
dockerfile: ./Dockerfile-Dev
command: ["npm", "start"]
- "8080:8080"
command: ["npm", "run", "client:dev"]
depends_on:
mongo:
condition: service_healthy
environment:
- NODE_DB_URI=mongodb://mongo/habitrpg
networks:
- habitica
- server
server:
build: .
ports:
- "3000:3000"
volumes:
- .:/usr/src/habitica
- /usr/src/habitica/node_modules
mongo:
image: "mongo:7.0"
container_name: "habitica-mongodb"
networks:
- habitica
hostname: "mongodb"
environment:
- NODE_DB_URI=mongodb://mongo/habitrpg
depends_on:
- mongo
mongo:
image: mongo:3.6
ports:
- "27017:27017"
restart: "unless-stopped"
volumes:
- "./mongodb-data-docker:/data/db"
entrypoint: [ "/usr/bin/mongod", "--bind_ip_all", "--replSet", "rs" ]
healthcheck:
test: echo "try { rs.status() } catch (err) { rs.initiate() }" | mongosh --port 27017 --quiet
interval: 10s
timeout: 30s
start_period: 0s
retries: 30
networks:
- habitica
networks:
habitica:
+9 -12
View File
@@ -5,7 +5,7 @@ import path from 'path';
import babel from 'gulp-babel';
import os from 'os';
import fs from 'fs';
import spawn from 'cross-spawn';
import spawn from 'cross-spawn'; // eslint-disable-line import/no-extraneous-dependencies
import clean from 'rimraf';
gulp.task('build:babel:server', () => gulp.src('website/server/**/*.js')
@@ -35,7 +35,7 @@ gulp.task('build:prod', gulp.series(
// When used on windows `run-rs` must first be run without the `--keep` option
// in order to be setup correctly, afterwards it can be used.
const MONGO_PATH = path.join(__dirname, '/../mongodb-data-docker/');
const MONGO_PATH = path.join(__dirname, '/../mongodb-data/');
gulp.task('build:prepare-mongo', async () => {
if (fs.existsSync(MONGO_PATH)) {
@@ -51,32 +51,29 @@ gulp.task('build:prepare-mongo', async () => {
console.log('MongoDB data folder is missing, setting up.'); // eslint-disable-line no-console
// use run-rs without --keep, kill it as soon as the replica set starts
const dockerMongoProcess = spawn('npm', ['run', 'docker:mongo:dev']);
const runRsProcess = spawn('run-rs', ['-v', '4.1.1', '-l', 'ubuntu1804', '--dbpath', 'mongodb-data', '--number', '1', '--quiet']);
let manuallyStopped = false;
for await (const chunk of dockerMongoProcess.stdout) {
for await (const chunk of runRsProcess.stdout) {
const stringChunk = chunk.toString();
console.log(stringChunk); // eslint-disable-line no-console
// kills the process after the replica set is setup
if (stringChunk.includes('mongod startup complete')) {
if (stringChunk.includes('Started replica set')) {
console.log('MongoDB setup correctly.'); // eslint-disable-line no-console
dockerMongoProcess.kill();
manuallyStopped = true;
runRsProcess.kill();
}
}
let error = '';
for await (const chunk of dockerMongoProcess.stderr) {
for await (const chunk of runRsProcess.stderr) {
const stringChunk = chunk.toString();
error += stringChunk;
}
const exitCode = await new Promise(resolve => {
dockerMongoProcess.on('close', resolve);
runRsProcess.on('close', resolve);
});
if (!manuallyStopped && (exitCode || error.length > 0)) {
if (exitCode || error.length > 0) {
// remove any leftover files
clean.sync(MONGO_PATH);
+10 -45
View File
@@ -6,21 +6,9 @@ gulp.task('cache:content', done => {
// Requiring at runtime because these files access `common`
// code which in production works only if transpiled so after
// gulp build:babel:common has run
const {
CONTENT_CACHE_PATH,
getLocalizedContentResponse,
IOS_FILTER,
ANDROID_FILTER,
buildFilterObject,
hashForFilter,
} = require('../website/server/libs/content'); // eslint-disable-line global-require
const { CONTENT_CACHE_PATH, getLocalizedContentResponse } = require('../website/server/libs/content'); // eslint-disable-line global-require
const { langCodes } = require('../website/server/libs/i18n'); // eslint-disable-line global-require
const iosHash = hashForFilter(IOS_FILTER);
const iosFilterObj = buildFilterObject(IOS_FILTER);
const androidHash = hashForFilter(ANDROID_FILTER);
const androidFilterObj = buildFilterObject(ANDROID_FILTER);
try {
// create the cache folder (if it doesn't exist)
try {
@@ -38,18 +26,6 @@ gulp.task('cache:content', done => {
getLocalizedContentResponse(langCode),
'utf8',
);
fs.writeFileSync(
`${CONTENT_CACHE_PATH}${langCode}${iosHash}.json`,
getLocalizedContentResponse(langCode, iosFilterObj),
'utf8',
);
fs.writeFileSync(
`${CONTENT_CACHE_PATH}${langCode}${androidHash}.json`,
getLocalizedContentResponse(langCode, androidFilterObj),
'utf8',
);
});
done();
} catch (err) {
@@ -57,37 +33,26 @@ gulp.task('cache:content', done => {
}
});
function safeMkdir (path) {
try {
fs.mkdirSync(path);
} catch (err) {
if (err.code !== 'EEXIST') throw err;
}
}
gulp.task('cache:i18n', done => {
// Requiring at runtime because these files access `common`
// code which in production works only if transpiled so after
// gulp build:babel:common has run
const { BROWSER_SCRIPT_CACHE_PATH, geti18nCoreBrowserScript, geti18nContentBrowserScript } = require('../website/server/libs/i18n'); // eslint-disable-line global-require
const { BROWSER_SCRIPT_CACHE_PATH, geti18nBrowserScript } = require('../website/server/libs/i18n'); // eslint-disable-line global-require
const { langCodes } = require('../website/server/libs/i18n'); // eslint-disable-line global-require
try {
// create the cache folders (if they doesn't exist)
safeMkdir(BROWSER_SCRIPT_CACHE_PATH);
safeMkdir(`${BROWSER_SCRIPT_CACHE_PATH}core/`);
safeMkdir(`${BROWSER_SCRIPT_CACHE_PATH}content/`);
// create the cache folder (if it doesn't exist)
try {
fs.mkdirSync(BROWSER_SCRIPT_CACHE_PATH);
} catch (err) {
if (err.code !== 'EEXIST') throw err;
}
// create and save the i18n browser script for each language
langCodes.forEach(languageCode => {
fs.writeFileSync(
`${BROWSER_SCRIPT_CACHE_PATH}core/${languageCode}.js`,
geti18nCoreBrowserScript(languageCode),
'utf8',
);
fs.writeFileSync(
`${BROWSER_SCRIPT_CACHE_PATH}content/${languageCode}.js`,
geti18nContentBrowserScript(languageCode),
`${BROWSER_SCRIPT_CACHE_PATH}${languageCode}.js`,
geti18nBrowserScript(languageCode),
'utf8',
);
});
+1 -1
View File
@@ -80,7 +80,7 @@ async function createSpritesStream (name, src) {
const stream = mergeStream();
// need to import this way bc of weird dependency things
// eslint-disable-next-line global-require
const filter = require('gulp-filter');
const filter = require('gulp-filter').default;
const f = filter(filterFile);
-5
View File
@@ -53,11 +53,6 @@ gulp.task('test:prepare:mongo', cb => {
const mongooseOptions = getDefaultConnectionOptions();
const connectionUrl = getDevelopmentConnectionUrl(TEST_DB_URI);
console.info({
mongooseOptions,
connectionUrl,
});
mongoose.connect(connectionUrl, mongooseOptions)
.then(() => mongoose.connection.dropDatabase())
.then(() => mongoose.connection.close()).then(() => {
+1 -1
View File
@@ -73,7 +73,7 @@ export default async function processUsers () {
break;
} else {
query._id = {
$gt: users[users.length - 1]._id,
$gt: users[users.length - 1],
};
}
+7243 -7860
View File
File diff suppressed because it is too large Load Diff
+9 -14
View File
@@ -1,12 +1,13 @@
{
"name": "habitica",
"description": "A habit tracker app which treats your goals like a Role Playing Game.",
"version": "5.47.0",
"version": "5.40.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",
@@ -34,13 +35,11 @@
"firebase-admin": "^12.1.1",
"glob": "^8.1.0",
"got": "^11.8.6",
"gulp": "^4.0.0",
"gulp": "5.0.1",
"gulp-babel": "^8.0.0",
"gulp-filter": "^7.0.0",
"gulp-imagemin": "^7.1.0",
"gulp-filter": "^9.0.0",
"gulp.spritesmith": "^6.13.0",
"habitica-markdown": "^4.1.0",
"heapdump": "^0.3.15",
"habitica-markdown": "^3.0.0",
"helmet": "^4.6.0",
"in-app-purchase": "^1.11.3",
"js2xmlparser": "^5.0.0",
@@ -49,12 +48,10 @@
"lodash": "^4.17.21",
"merge-stream": "^2.0.0",
"method-override": "^3.0.0",
"micromustache": "^8.0.3",
"moment": "^2.29.4",
"moment-recur": "git://github.com/HabitRPG/moment-recur.git#d3e8e6da0806f13b74dd2e4d7d9053e6a63db119",
"mongoose": "^8.9.5",
"morgan": "^1.10.1",
"nan": "^2.25.0",
"nconf": "^0.12.1",
"node-gcm": "^1.0.5",
"on-headers": "^1.1.0",
@@ -103,16 +100,13 @@
"coverage": "nyc report --reporter=html --report-dir coverage/results; open coverage/results/index.html",
"sprites": "gulp sprites:compile",
"client:dev": "cd website/client && npm run serve",
"client:dev:docker": "cd website/client && npm run serve:docker",
"client:build": "cd website/client && npm run build",
"client:unit": "cd website/client && npm run test:unit",
"start": "node --watch ./website/server/index.js",
"start:simple": "node ./website/server/index.js",
"debug": "node --watch --inspect ./website/server/index.js",
"docker:aio": "docker compose -f docker-compose.yml up",
"docker:mongo:dev": "docker compose -f docker-compose.mongo-only.yml up",
"docker:mongo:dev:down": "docker compose -f docker-compose.mongo-only.yml down",
"docker:mongo:test": "docker compose -f docker-compose.mongo-test-local.yml up",
"mongo:test": "node scripts/start-local-mongo.mjs --test-db",
"mongo:dev": "run-rs -v 7.0.23 -l ubuntu2204 --keep --dbpath mongodb-data --number 1 --quiet",
"mongo:test": "run-rs -v 7.0.23 -l ubuntu2204 --keep --dbpath mongodb-data-testing --number 1 --quiet",
"postinstall": "git config --global url.\"https://\".insteadOf git:// && gulp build && cd website/client && npm install",
"apidoc": "gulp apidoc",
"heroku-postbuild": ".heroku/report_deploy.sh"
@@ -128,6 +122,7 @@
"monk": "^7.3.4",
"nyc": "^15.1.0",
"require-again": "^2.0.0",
"run-rs": "^0.7.7",
"sinon-chai": "^3.7.0",
"sinon-stub-promise": "^4.0.0"
}
+3 -3
View File
@@ -150,7 +150,7 @@ describe('emails', () => {
sendTxn(mailingInfo, emailType);
expect(got.post).to.be.called;
expect(got.post).to.be.calledWith('http://example.com/job', sinon.match({
expect(got.post).to.be.calledWith('undefined/job', sinon.match({
json: {
data: {
emailType: sinon.match.same(emailType),
@@ -234,7 +234,7 @@ describe('emails', () => {
sendTxn(mailingInfo, emailType);
expect(got.post).to.be.called;
expect(got.post).to.be.calledWith('http://example.com/job', sinon.match({
expect(got.post).to.be.calledWith('undefined/job', sinon.match({
json: {
data: {
emailType: sinon.match.same(emailType),
@@ -254,7 +254,7 @@ describe('emails', () => {
sendTxn(mailingInfo, emailType, variables);
expect(got.post).to.be.called;
expect(got.post).to.be.calledWith('http://example.com/job', sinon.match({
expect(got.post).to.be.calledWith('undefined/job', sinon.match({
json: {
data: {
variables: sinon.match(value => value[0].name === 'BASE_URL', 'matches variables'),
@@ -47,12 +47,6 @@ describe('highlightMentions', () => {
expect(result[0]).to.equal('[@user-dash](/profile/444): message [@user_underscore](/profile/555)');
});
it('highlights users with case-insensitive matching', async () => {
const text = '@USER: message @User2 @USER3';
const result = await highlightMentions(text);
expect(result[0]).to.equal('[@USER](/profile/111): message [@User2](/profile/222) [@USER3](/profile/333)');
});
it('doesn\'t highlight nonexisting users', async () => {
const text = '@nouser message';
const result = await highlightMentions(text);
@@ -66,15 +66,13 @@ describe('Amazon Payments - Cancel Subscription', () => {
group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'private',
privacy: 'public',
leader: user._id,
});
group.purchased.plan.customerId = 'customer-id';
group.purchased.plan.planId = subKey;
group.purchased.plan.lastBillingDate = new Date();
await group.save();
user.guilds.push(group._id);
await user.save();
subscriptionBlock = common.content.subscriptionBlocks[subKey];
subscriptionLength = subscriptionBlock.months * 30;
@@ -30,14 +30,12 @@ describe('Amazon Payments - Subscribe', () => {
group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'private',
privacy: 'public',
leader: user._id,
});
group.purchased.plan.customerId = 'customer-id';
group.purchased.plan.planId = subKey;
await group.save();
user.guilds.push(group._id);
await user.save();
amount = common.content.subscriptionBlocks[subKey].price;
billingAgreementId = 'billingAgreementId';
@@ -248,6 +246,11 @@ describe('Amazon Payments - Subscribe', () => {
user.guilds.push(groupId);
await user.save();
// Add existing users
user = new User();
user.guilds.push(groupId);
await user.save();
// Set expected amount
sub.key = 'group_monthly';
sub.price = 9;
+38 -58
View File
@@ -12,33 +12,11 @@ const { i18n } = common;
describe('Apple Payments', () => {
const subKey = 'basic_3mo';
let iapSetupStub;
let iapValidateStub;
let iapIsValidatedStub;
let iapIsCanceledStub;
let iapIsExpiredStub;
let paymentBuySkuStub;
let iapGetPurchaseDataStub;
let validateGiftMessageStub;
let paymentsCreateSubscritionStub;
beforeEach(() => {
iapSetupStub = sinon.stub(iap, 'setup').resolves();
iapValidateStub = sinon.stub(iap, 'validate').resolves({});
});
afterEach(() => {
iap.setup.restore();
iap.validate.restore();
iap.isValidated.restore();
iap.isExpired.restore();
iap.isCanceled.restore();
iap.getPurchaseData.restore();
});
describe('verifyPurchase', () => {
let sku; let user; let token; let receipt; let
headers;
let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let paymentBuySkuStub; let
iapGetPurchaseDataStub; let validateGiftMessageStub;
beforeEach(() => {
token = 'testToken';
@@ -47,9 +25,13 @@ describe('Apple Payments', () => {
receipt = `{"token": "${token}", "productId": "${sku}"}`;
headers = {};
iapSetupStub = sinon.stub(iap, 'setup')
.resolves();
iapValidateStub = sinon.stub(iap, 'validate')
.resolves({});
iapIsValidatedStub = sinon.stub(iap, 'isValidated').returns(true);
iapIsCanceledStub = sinon.stub(iap, 'isCanceled').returns(false);
iapIsExpiredStub = sinon.stub(iap, 'isExpired').returns(false);
sinon.stub(iap, 'isExpired').returns(false);
sinon.stub(iap, 'isCanceled').returns(false);
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
.returns([{
productId: 'com.habitrpg.ios.Habitica.21gems',
@@ -60,6 +42,12 @@ describe('Apple Payments', () => {
});
afterEach(() => {
iap.setup.restore();
iap.validate.restore();
iap.isValidated.restore();
iap.isExpired.restore();
iap.isCanceled.restore();
iap.getPurchaseData.restore();
payments.buySkuItem.restore();
gems.validateGiftMessage.restore();
});
@@ -221,6 +209,9 @@ describe('Apple Payments', () => {
describe('subscribe', () => {
let sub; let sku; let user; let token; let receipt; let headers; let
nextPaymentProcessing;
let iapSetupStub; let iapValidateStub; let iapIsValidatedStub;
let paymentsCreateSubscritionStub; let
iapGetPurchaseDataStub;
beforeEach(() => {
sub = common.content.subscriptionBlocks[subKey];
@@ -232,10 +223,12 @@ describe('Apple Payments', () => {
nextPaymentProcessing = moment.utc().add({ days: 2 });
user = new User();
iapSetupStub = sinon.stub(iap, 'setup')
.resolves();
iapValidateStub = sinon.stub(iap, 'validate')
.resolves({});
iapIsValidatedStub = sinon.stub(iap, 'isValidated')
.returns(true);
iapIsCanceledStub = sinon.stub(iap, 'isCanceled').returns(false);
iapIsExpiredStub = sinon.stub(iap, 'isExpired').returns(false);
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
.returns([{
expirationDate: moment.utc().subtract({ day: 1 }).toDate(),
@@ -257,6 +250,10 @@ describe('Apple Payments', () => {
});
afterEach(() => {
iap.setup.restore();
iap.validate.restore();
iap.isValidated.restore();
iap.getPurchaseData.restore();
if (payments.createSubscription.restore) payments.createSubscription.restore();
});
@@ -273,29 +270,6 @@ describe('Apple Payments', () => {
});
});
it('should throw an error if no active subscription is found', async () => {
iap.isCanceled.restore();
iapIsCanceledStub = sinon.stub(iap, 'isCanceled')
.returns(true);
iap.getPurchaseData.restore();
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
.returns([{
expirationDate: moment.utc().add({ day: -2 }).toDate(),
purchaseDate: new Date(),
productId: 'subscription1month',
transactionId: token,
originalTransactionId: token,
}]);
await expect(applePayments.subscribe(user, receipt, headers, nextPaymentProcessing))
.to.eventually.be.rejected.and.to.eql({
httpCode: 401,
name: 'NotAuthorized',
message: applePayments.constants.RESPONSE_NO_ITEM_PURCHASED,
});
});
const subOptions = [
{
sku: 'subscription1month',
@@ -600,7 +574,8 @@ describe('Apple Payments', () => {
describe('cancelSubscribe ', () => {
let user; let token; let receipt; let headers; let customerId; let
expirationDate;
let paymentCancelSubscriptionSpy;
let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let iapGetPurchaseDataStub; let
paymentCancelSubscriptionSpy;
beforeEach(async () => {
token = 'test-token';
@@ -609,7 +584,8 @@ describe('Apple Payments', () => {
customerId = 'test-customerId';
expirationDate = moment.utc();
iapValidateStub.restore();
iapSetupStub = sinon.stub(iap, 'setup')
.resolves();
iapValidateStub = sinon.stub(iap, 'validate')
.resolves({
expirationDate,
@@ -617,8 +593,8 @@ describe('Apple Payments', () => {
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
.returns([{ expirationDate: expirationDate.toDate() }]);
iapIsValidatedStub = sinon.stub(iap, 'isValidated').returns(true);
iapIsCanceledStub = sinon.stub(iap, 'isCanceled').returns(false);
iapIsExpiredStub = sinon.stub(iap, 'isExpired').returns(true);
sinon.stub(iap, 'isCanceled').returns(false);
sinon.stub(iap, 'isExpired').returns(true);
user = new User();
user.profile.name = 'sender';
user.purchased.plan.paymentMethod = applePayments.constants.PAYMENT_METHOD_APPLE;
@@ -630,7 +606,13 @@ describe('Apple Payments', () => {
});
afterEach(() => {
paymentCancelSubscriptionSpy.restore();
iap.setup.restore();
iap.validate.restore();
iap.isValidated.restore();
iap.isExpired.restore();
iap.isCanceled.restore();
iap.getPurchaseData.restore();
payments.cancelSubscription.restore();
});
it('should throw an error if we are missing a subscription', async () => {
@@ -713,8 +695,6 @@ describe('Apple Payments', () => {
expect(iapIsValidatedStub).to.be.calledWith({
expirationDate,
});
expect(iapIsCanceledStub).to.be.calledOnce;
expect(iapIsExpiredStub).to.be.calledOnce;
expect(iapGetPurchaseDataStub).to.be.calledOnce;
expect(paymentCancelSubscriptionSpy).to.be.calledOnce;
+39 -53
View File
@@ -11,36 +11,12 @@ const { i18n } = common;
describe('Google Payments', () => {
const subKey = 'basic_3mo';
let iapSetupStub;
let iapValidateStub;
let iapIsValidatedStub;
let paymentBuySkuStub;
let validateGiftMessageStub;
beforeEach(() => {
iapSetupStub = sinon.stub(iap, 'setup')
.resolves();
iapIsValidatedStub = sinon.stub(iap, 'isValidated')
.returns(true);
sinon.stub(iap, 'isCanceled').returns(false);
sinon.stub(iap, 'isExpired').returns(false);
paymentBuySkuStub = sinon.stub(payments, 'buySkuItem').resolves({});
validateGiftMessageStub = sinon.stub(gems, 'validateGiftMessage');
});
afterEach(() => {
iap.setup.restore();
iap.validate.restore();
iap.isValidated.restore();
iap.isCanceled.restore();
iap.isExpired.restore();
payments.buySkuItem.restore();
gems.validateGiftMessage.restore();
});
describe('verifyPurchase', () => {
let sku; let user; let token; let receipt; let signature; let
headers;
let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let
paymentBuySkuStub; let validateGiftMessageStub;
beforeEach(() => {
sku = 'com.habitrpg.android.habitica.iap.21gems';
@@ -49,7 +25,21 @@ describe('Google Payments', () => {
signature = '';
headers = {};
iapSetupStub = sinon.stub(iap, 'setup')
.resolves();
iapValidateStub = sinon.stub(iap, 'validate').resolves({ productId: sku });
iapIsValidatedStub = sinon.stub(iap, 'isValidated')
.returns(true);
paymentBuySkuStub = sinon.stub(payments, 'buySkuItem').resolves({});
validateGiftMessageStub = sinon.stub(gems, 'validateGiftMessage');
});
afterEach(() => {
iap.setup.restore();
iap.validate.restore();
iap.isValidated.restore();
payments.buySkuItem.restore();
gems.validateGiftMessage.restore();
});
it('should throw an error if receipt is invalid', async () => {
@@ -170,7 +160,8 @@ describe('Google Payments', () => {
describe('subscribe', () => {
let sub; let sku; let user; let token; let receipt; let signature; let headers; let
nextPaymentProcessing;
let paymentsCreateSubscritionStub;
let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let
paymentsCreateSubscritionStub;
beforeEach(() => {
sub = common.content.subscriptionBlocks[subKey];
@@ -182,12 +173,19 @@ describe('Google Payments', () => {
signature = '';
nextPaymentProcessing = moment.utc().add({ days: 2 });
iapSetupStub = sinon.stub(iap, 'setup')
.resolves();
iapValidateStub = sinon.stub(iap, 'validate')
.resolves({});
iapIsValidatedStub = sinon.stub(iap, 'isValidated')
.returns(true);
paymentsCreateSubscritionStub = sinon.stub(payments, 'createSubscription').resolves({});
});
afterEach(() => {
iap.setup.restore();
iap.validate.restore();
iap.isValidated.restore();
payments.createSubscription.restore();
});
@@ -245,7 +243,7 @@ describe('Google Payments', () => {
describe('cancelSubscribe ', () => {
let user; let token; let receipt; let signature; let headers; let customerId; let
expirationDate;
let iapGetPurchaseDataStub; let
let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let iapGetPurchaseDataStub; let
paymentCancelSubscriptionSpy;
beforeEach(async () => {
@@ -255,12 +253,17 @@ describe('Google Payments', () => {
signature = '';
customerId = 'test-customerId';
expirationDate = moment.utc();
iapSetupStub = sinon.stub(iap, 'setup')
.resolves();
iapValidateStub = sinon.stub(iap, 'validate')
.resolves({
expirationDate,
});
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
.returns([{ expirationDate: expirationDate.toDate(), autoRenewing: false }]);
iapIsValidatedStub = sinon.stub(iap, 'isValidated')
.returns(true);
user = new User();
user.profile.name = 'sender';
@@ -273,6 +276,9 @@ describe('Google Payments', () => {
});
afterEach(() => {
iap.setup.restore();
iap.validate.restore();
iap.isValidated.restore();
iap.getPurchaseData.restore();
payments.cancelSubscription.restore();
});
@@ -302,8 +308,6 @@ describe('Google Payments', () => {
});
it('should cancel a user subscription', async () => {
iap.isCanceled.restore();
iap.isCanceled = sinon.stub(iap, 'isCanceled').returns(true);
await googlePayments.cancelSubscribe(user, headers);
expect(iapSetupStub).to.be.calledOnce;
@@ -328,20 +332,11 @@ describe('Google Payments', () => {
});
it('should cancel a user subscription with multiple inactive subscriptions', async () => {
iap.isCanceled.restore();
iap.isCanceled = sinon.stub(iap, 'isCanceled').returns(true);
const laterDate = moment.utc().add(7, 'days');
iap.getPurchaseData.restore();
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
.returns([{
startTimeMillis: expirationDate.valueOf(),
expirationDate,
autoRenewing: false,
}, {
startTimeMillis: laterDate.valueOf(),
expirationDate: laterDate,
autoRenewing: false,
},
.returns([{ expirationDate, autoRenewing: false },
{ expirationDate: laterDate, autoRenewing: false },
]);
await googlePayments.cancelSubscribe(user, headers);
@@ -370,12 +365,7 @@ describe('Google Payments', () => {
iap.getPurchaseData.restore();
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
.returns([{ autoRenewing: true }]);
await expect(googlePayments.cancelSubscribe(user, headers))
.to.eventually.be.rejected.and.to.eql({
httpCode: 401,
name: 'NotAuthorized',
message: googlePayments.constants.RESPONSE_STILL_VALID,
});
await googlePayments.cancelSubscribe(user, headers);
expect(iapSetupStub).to.be.calledOnce;
expect(iapValidateStub).to.be.calledOnce;
@@ -398,12 +388,8 @@ describe('Google Payments', () => {
.returns([{ expirationDate, autoRenewing: false },
{ autoRenewing: true },
{ expirationDate, autoRenewing: false }]);
await expect(googlePayments.cancelSubscribe(user, headers))
.to.eventually.be.rejected.and.to.eql({
httpCode: 401,
name: 'NotAuthorized',
message: googlePayments.constants.RESPONSE_STILL_VALID,
});
await googlePayments.cancelSubscribe(user, headers);
expect(iapSetupStub).to.be.calledOnce;
expect(iapValidateStub).to.be.calledOnce;
expect(iapValidateStub).to.be.calledWith(iap.GOOGLE, {
@@ -128,12 +128,11 @@ describe('Purchasing a group plan for group', () => {
expect(publicGroup.purchased.plan.planId).to.not.exist;
data.groupId = publicGroup._id;
// Public Guilds are no longer even findable
await expect(api.createSubscription(data))
.to.eventually.be.rejected.and.to.eql({
httpCode: 404,
name: 'NotFound',
message: i18n.t('groupNotFound'),
httpCode: 401,
name: 'NotAuthorized',
message: i18n.t('onlyPrivateGuildsCanUpgrade'),
});
const updatedGroup = await Group.findById(publicGroup._id).exec();
@@ -30,15 +30,13 @@ describe('paypal - subscribeCancel', () => {
group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'private',
privacy: 'public',
leader: user._id,
});
group.purchased.plan.customerId = groupCustomerId;
group.purchased.plan.planId = subKey;
group.purchased.plan.lastBillingDate = new Date();
await group.save();
user.guilds.push(group._id);
await user.save();
nextBillingDate = new Date();
@@ -236,7 +236,7 @@ describe('Stripe - Checkout', () => {
const group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'private',
privacy: 'public',
leader: user._id,
});
const groupId = group._id;
@@ -376,13 +376,11 @@ describe('Stripe - Checkout', () => {
group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'private',
privacy: 'public',
leader: user._id,
});
groupId = group._id;
await group.save();
user.guilds.push(group._id);
await user.save();
});
it('throws if user is not allowed to change group plan', async () => {
@@ -136,7 +136,7 @@ describe('Stripe - Subscriptions', () => {
group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'private',
privacy: 'public',
leader: user._id,
});
groupId = group._id;
@@ -315,14 +315,12 @@ describe('Stripe - Subscriptions', () => {
group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'private',
privacy: 'public',
leader: user._id,
});
group.purchased.plan.customerId = 'customer-id';
group.purchased.plan.planId = subKey;
await group.save();
user.guilds.push(group._id);
await user.save();
groupId = group._id;
});
@@ -50,59 +50,5 @@ describe('UserNotification Model', () => {
expect(safeNotifications[0].type).to.equal('NEW_CHAT_MESSAGE');
expect(safeNotifications[0].id).to.equal('123');
});
it('removes duplicate STREAK_ACHIEVEMENT notifications', () => {
// Fixes issue #13325 - Users receiving duplicate streak achievement notifications
const notifications = [
new UserNotification({
type: 'STREAK_ACHIEVEMENT',
id: 123,
data: {},
}),
new UserNotification({
type: 'STREAK_ACHIEVEMENT',
id: 456,
data: {},
}),
new UserNotification({
type: 'CRON',
id: 789,
data: {},
}), // different type, should be kept
];
const safeNotifications = UserNotification.cleanupCorruptData(notifications);
expect(safeNotifications.length).to.equal(2);
expect(safeNotifications[0].type).to.equal('STREAK_ACHIEVEMENT');
expect(safeNotifications[0].id).to.equal('123');
expect(safeNotifications[1].type).to.equal('CRON');
expect(safeNotifications[1].id).to.equal('789');
});
it('handles multiple STREAK_ACHIEVEMENT duplicates correctly', () => {
// Test case: 3 duplicate STREAK_ACHIEVEMENT notifications
const notifications = [
new UserNotification({
type: 'STREAK_ACHIEVEMENT',
id: 111,
data: {},
}),
new UserNotification({
type: 'STREAK_ACHIEVEMENT',
id: 222,
data: {},
}),
new UserNotification({
type: 'STREAK_ACHIEVEMENT',
id: 333,
data: {},
}),
];
const safeNotifications = UserNotification.cleanupCorruptData(notifications);
expect(safeNotifications.length).to.equal(1);
expect(safeNotifications[0].type).to.equal('STREAK_ACHIEVEMENT');
expect(safeNotifications[0].id).to.equal('111'); // Keep first one
});
});
});
@@ -5,8 +5,6 @@ import {
createAndPopulateGroup,
translate as t,
} from '../../../../helpers/api-integration/v3';
import { model as Group } from '../../../../../website/server/models/group';
import { TAVERN_ID } from '../../../../../website/common/script/constants';
describe('POST /challenges/:challengeId/join', () => {
it('returns error when challengeId is not a valid UUID', async () => {
@@ -29,37 +27,6 @@ describe('POST /challenges/:challengeId/join', () => {
});
});
context('public Guild', () => {
let group;
let groupLeader;
let members;
let challenge;
before(async () => {
({ group, groupLeader, members } = await createAndPopulateGroup({
groupDetails: {
name: 'test group',
type: 'guild',
privacy: 'private',
},
members: 1,
upgradeToGroupPlan: true,
}));
challenge = await generateChallenge(groupLeader, group);
// Creation API is shut down, we need to simulate an extant public group
await Group.updateOne({ _id: group._id }, { $set: { privacy: 'public' }, $unset: { 'purchased.plan': 1 } }).exec();
});
it('returns error when challengeId is in an old public Guild', async () => {
const authorizedUser = members[0]; // eslint-disable-line prefer-destructuring
await expect(authorizedUser.post(`/challenges/${challenge._id}/join`)).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('challengeNotFound'),
});
});
});
context('Joining a valid challenge', () => {
let groupLeader;
let group;
@@ -99,15 +66,6 @@ describe('POST /challenges/:challengeId/join', () => {
expect(res.name).to.equal(challenge.name);
});
it('succeeds when it\'s a Tavern challenge, even if the user isn\'t a "member" of Tavern', async () => {
const tavern = await groupLeader.get(`/groups/${TAVERN_ID}`);
const tavernChallenge = await generateChallenge(groupLeader, tavern, { prize: 1 });
const generalUser = await generateUser();
const res = await generalUser.post(`/challenges/${tavernChallenge._id}/join`);
expect(res.name).to.equal(tavernChallenge.name);
});
it('returns challenge data', async () => {
const res = await authorizedUser.post(`/challenges/${challenge._id}/join`);
@@ -62,9 +62,9 @@ describe('GET /groups/:groupId/chat', () => {
it('returns error if user attempts to fetch a sunset Guild', async () => {
await expect(user.get(`/groups/${group._id}/chat`)).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('groupNotFound'),
code: 400,
error: 'BadRequest',
message: t('featureRetired'),
});
});
});
@@ -121,9 +121,9 @@ describe('POST /chat/:chatId/like', () => {
await expect(user.post(`/groups/${groupWithChat._id}/chat/${message.message.id}/like`))
.to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('groupNotFound'),
code: 400,
error: 'BadRequest',
message: t('featureRetired'),
});
});
});
+13 -31
View File
@@ -9,7 +9,7 @@ import {
import {
SPAM_MIN_EXEMPT_CONTRIB_LEVEL,
} from '../../../../../website/server/models/group';
import { MAX_MESSAGE_LENGTH, CHAT_FLAG_FROM_SHADOW_MUTE } from '../../../../../website/common/script/constants';
import { MAX_MESSAGE_LENGTH } from '../../../../../website/common/script/constants';
import * as email from '../../../../../website/server/libs/email';
describe('POST /chat', () => {
@@ -80,20 +80,17 @@ describe('POST /chat', () => {
member.updateOne({ 'flags.chatRevoked': false });
});
it('errors when chat privileges are revoked when sending a message to a private guild', async () => {
it('does not error when chat privileges are revoked when sending a message to a private guild', async () => {
await member.updateOne({
'flags.chatRevoked': true,
});
await expect(member.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage }))
.to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('chatPrivilegesRevoked'),
});
const message = await member.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage });
expect(message.message.id).to.exist;
});
it('errors when chat privileges are revoked when sending a message to a party', async () => {
it('does not error when chat privileges are revoked when sending a message to a party', async () => {
const { group, members } = await createAndPopulateGroup({
groupDetails: {
name: 'Party',
@@ -109,12 +106,9 @@ describe('POST /chat', () => {
'auth.timestamps.created': new Date('2022-01-01'),
});
await expect(privatePartyMemberWithChatsRevoked.post(`/groups/${group._id}/chat`, { message: testMessage }))
.to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('chatPrivilegesRevoked'),
});
const message = await privatePartyMemberWithChatsRevoked.post(`/groups/${group._id}/chat`, { message: testMessage });
expect(message.message.id).to.exist;
});
});
@@ -129,7 +123,7 @@ describe('POST /chat', () => {
member.updateOne({ 'flags.chatShadowMuted': false });
});
it('creates a chat with flagCount set when sending a message to a private guild', async () => {
it('creates a chat with zero flagCount when sending a message to a private guild', async () => {
await member.updateOne({
'flags.chatShadowMuted': true,
});
@@ -137,10 +131,10 @@ describe('POST /chat', () => {
const message = await member.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage });
expect(message.message.id).to.exist;
expect(message.message.flagCount).to.eql(CHAT_FLAG_FROM_SHADOW_MUTE);
expect(message.message.flagCount).to.eql(0);
});
it('creates a chat with flagCount set when sending a message to a party', async () => {
it('creates a chat with zero flagCount when sending a message to a party', async () => {
const { group, members } = await createAndPopulateGroup({
groupDetails: {
name: 'Party',
@@ -159,7 +153,7 @@ describe('POST /chat', () => {
const message = await userWithChatShadowMuted.post(`/groups/${group._id}/chat`, { message: testMessage });
expect(message.message.id).to.exist;
expect(message.message.flagCount).to.eql(CHAT_FLAG_FROM_SHADOW_MUTE);
expect(message.message.flagCount).to.eql(0);
});
});
@@ -244,18 +238,6 @@ describe('POST /chat', () => {
expect(groupMessages[0].id).to.exist;
});
it('creates a chat with case-insensitive mentions', async () => {
const originalUsername = member.auth.local.username;
const uppercaseUsername = originalUsername.toUpperCase();
const messageWithMentions = `hi @${uppercaseUsername}`;
const newMessage = await user.post(`/groups/${groupWithChat._id}/chat`, { message: messageWithMentions });
const groupMessages = await user.get(`/groups/${groupWithChat._id}/chat`);
expect(newMessage.message.id).to.exist;
expect(newMessage.message.text).to.include(`[@${uppercaseUsername}](/profile/${member._id})`);
expect(groupMessages[0].id).to.exist;
});
it('creates a chat with a max length of 3000 chars', async () => {
const veryLongMessage = `
123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789.
@@ -0,0 +1,35 @@
import { v4 as generateUUID } from 'uuid';
import {
generateUser,
translate as t,
} from '../../../../helpers/api-integration/v3';
xdescribe('GET /export/avatar-:memberId.html', () => {
let user;
before(async () => {
user = await generateUser();
});
it('validates req.params.memberId', async () => {
await expect(user.get('/export/avatar-:memberId.html')).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('invalidReqParams'),
});
});
it('handles non-existing members', async () => {
const dummyId = generateUUID();
await expect(user.get(`/export/avatar-${dummyId}.html`)).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('userWithIDNotFound', { userId: dummyId }),
});
});
it('returns an html page', async () => {
const res = await user.get(`/export/avatar-${user._id}.html`);
expect(res.substring(0, 100).indexOf('<!DOCTYPE html>')).to.equal(0);
});
});
@@ -0,0 +1,3 @@
// TODO how to test this route since it points to a file on AWS s3?
describe('GET /export/avatar-:memberId.png', () => {});
@@ -38,7 +38,7 @@ describe('GET /export/inbox.html', () => {
it('renders the markdown messages as html', async () => {
const res = await user.get('/export/inbox.html');
expect(res).to.include('😄');
expect(res).to.include('img class="habitica-emoji"');
expect(res).to.include('<h1>Hello!</h1>');
expect(res).to.include('<li>list 1</li>');
});
@@ -46,7 +46,7 @@ describe('GET /export/inbox.html', () => {
it('sorts messages from newest to oldest', async () => {
const res = await user.get('/export/inbox.html');
const emojiPosition = res.indexOf('😄');
const emojiPosition = res.indexOf('img class="habitica-emoji"');
const headingPosition = res.indexOf('<h1>Hello!</h1>');
const listPosition = res.indexOf('<li>list 1</li>');
@@ -61,24 +61,6 @@ describe('Post /groups/:groupId/invite', () => {
});
});
it('fakes sending an invite if user is shadow muted', async () => {
const inviterMuted = await inviter.updateOne({ 'flags.chatShadowMuted': true });
const userToInvite = await generateUser();
const response = await inviterMuted.post(`/groups/${group._id}/invite`, {
usernames: [userToInvite.auth.local.lowerCaseUsername],
});
expect(response).to.be.an('Array');
expect(response[0]).to.have.all.keys(['_id', 'id', 'name', 'inviter']);
expect(response[0]._id).to.be.a('String');
expect(response[0].id).to.eql(group._id);
expect(response[0].name).to.eql(groupName);
expect(response[0].inviter).to.eql(inviter._id);
await expect(userToInvite.get('/user'))
.to.eventually.not.have.nested.property('invitations.parties[0].id', group._id);
});
it('invites a user to a group by username', async () => {
const userToInvite = await generateUser();
@@ -227,24 +209,6 @@ describe('Post /groups/:groupId/invite', () => {
});
});
it('fakes sending an invite if user is shadow muted', async () => {
const inviterMuted = await inviter.updateOne({ 'flags.chatShadowMuted': true });
const userToInvite = await generateUser();
const response = await inviterMuted.post(`/groups/${group._id}/invite`, {
uuids: [userToInvite._id],
});
expect(response).to.be.an('Array');
expect(response[0]).to.have.all.keys(['_id', 'id', 'name', 'inviter']);
expect(response[0]._id).to.be.a('String');
expect(response[0].id).to.eql(group._id);
expect(response[0].name).to.eql(groupName);
expect(response[0].inviter).to.eql(inviter._id);
await expect(userToInvite.get('/user'))
.to.eventually.not.have.nested.property('invitations.parties[0].id', group._id);
});
it('invites a user to a group by uuid', async () => {
const userToInvite = await generateUser();
@@ -317,19 +281,6 @@ describe('Post /groups/:groupId/invite', () => {
});
});
it('fakes sending invite when inviter is shadow muted', async () => {
const inviterMuted = await inviter.updateOne({ 'flags.chatShadowMuted': true });
const res = await inviterMuted.post(`/groups/${group._id}/invite`, {
emails: [testInvite],
inviter: 'inviter name',
});
const updatedUser = await inviterMuted.sync();
expect(res).to.exist;
expect(updatedUser.invitesSent).to.eql(1);
});
it('returns an error when invite is missing an email', async () => {
await expect(inviter.post(`/groups/${group._id}/invite`, {
emails: [{ name: 'test' }],
@@ -454,19 +405,6 @@ describe('Post /groups/:groupId/invite', () => {
});
});
it('fakes sending an invite if user is shadow muted', async () => {
const inviterMuted = await inviter.updateOne({ 'flags.chatShadowMuted': true });
const newUser = await generateUser();
const invite = await inviterMuted.post(`/groups/${group._id}/invite`, {
uuids: [newUser._id],
emails: [{ name: 'test', email: 'test@habitica.com' }],
});
const invitedUser = await newUser.get('/user');
expect(invitedUser.invitations.parties[0]).to.not.exist;
expect(invite).to.exist;
});
it('invites users to a group by uuid and email', async () => {
const newUser = await generateUser();
const invite = await inviter.post(`/groups/${group._id}/invite`, {
@@ -47,7 +47,7 @@ describe('GET /inbox/messages', () => {
it('returns four messages when using page-query ', async () => {
const promises = [];
for (let i = 0; i < 50; i += 1) {
for (let i = 0; i < 10; i += 1) {
promises.push(user.post('/members/send-private-message', {
toUserId: user.id,
message: 'fourth',
@@ -43,7 +43,7 @@ describe('POST /members/send-private-message', () => {
});
});
it('returns error when recipient has blocked the sender', async () => {
it('returns error when to user has blocked the sender', async () => {
const receiver = await generateUser({ 'inbox.blocks': [userToSendMessage._id] });
await expect(userToSendMessage.post('/members/send-private-message', {
@@ -56,7 +56,7 @@ describe('POST /members/send-private-message', () => {
});
});
it('returns error when sender has blocked recipient', async () => {
it('returns error when sender has blocked to user', async () => {
const receiver = await generateUser();
const sender = await generateUser({ 'inbox.blocks': [receiver._id] });
@@ -70,7 +70,7 @@ describe('POST /members/send-private-message', () => {
});
});
it('returns error when recipient has opted out of messaging', async () => {
it('returns error when to user has opted out of messaging', async () => {
const receiver = await generateUser({ 'inbox.optOut': true });
await expect(userToSendMessage.post('/members/send-private-message', {
@@ -174,7 +174,7 @@ describe('POST /members/send-private-message', () => {
expect(notification.data.excerpt).to.equal(messageExcerpt);
});
it('allows admin to send when recipient has blocked the admin', async () => {
it('allows admin to send when sender has blocked the admin', async () => {
userToSendMessage = await generateUser({
'permissions.moderator': true,
});
@@ -202,7 +202,7 @@ describe('POST /members/send-private-message', () => {
expect(sendersMessageInSendersInbox).to.exist;
});
it('allows admin to send when recipient has opted out of messaging', async () => {
it('allows admin to send when to user has opted out of messaging', async () => {
userToSendMessage = await generateUser({
'permissions.moderator': true,
});
@@ -229,58 +229,4 @@ describe('POST /members/send-private-message', () => {
expect(sendersMessageInReceiversInbox).to.exist;
expect(sendersMessageInSendersInbox).to.exist;
});
describe('sender is shadow muted', () => {
beforeEach(async () => {
userToSendMessage = await generateUser({
'flags.chatShadowMuted': true,
});
});
it('does not save the message in the receiver inbox', async () => {
const receiver = await generateUser();
const response = await userToSendMessage.post('/members/send-private-message', {
message: messageToSend,
toUserId: receiver._id,
});
expect(response.message.uuid).to.equal(receiver._id);
const updatedReceiver = await receiver.get('/user');
const updatedSender = await userToSendMessage.get('/user');
const sendersMessageInReceiversInbox = _.find(
updatedReceiver.inbox.messages,
message => message.uuid === userToSendMessage._id && message.text === messageToSend,
);
const sendersMessageInSendersInbox = _.find(
updatedSender.inbox.messages,
message => message.uuid === receiver._id && message.text === messageToSend,
);
expect(sendersMessageInReceiversInbox).to.not.exist;
expect(sendersMessageInSendersInbox).to.exist;
});
it('does not save the message message twice if recipient is sender', async () => {
const response = await userToSendMessage.post('/members/send-private-message', {
message: messageToSend,
toUserId: userToSendMessage._id,
});
expect(response.message.uuid).to.equal(userToSendMessage._id);
const updatedSender = await userToSendMessage.get('/user');
const sendersMessageInSendersInbox = _.find(
updatedSender.inbox.messages,
message => message.uuid === userToSendMessage._id && message.text === messageToSend,
);
expect(sendersMessageInSendersInbox).to.exist;
expect(Object.keys(updatedSender.inbox.messages).length).to.equal(1);
});
});
});
@@ -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 }),
});
});
@@ -65,52 +65,6 @@ describe('POST /user/auth/social', () => {
await expect(getProperty('users', response.id, 'profile.name')).to.eventually.equal('a google user');
});
it('includes sanitized version of provided username', async () => {
const response = await api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
network,
username: 'Google User Name',
});
await expect(getProperty('users', response.id, 'auth.local.username')).to.eventually.equal('GoogleUserName');
await expect(getProperty('users', response.id, 'auth.local.lowerCaseUsername')).to.eventually.equal('googleusername');
});
it('generates a random username if provided username contains only disallowed characters', async () => {
const response = await api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
network,
username: 'Áîüè',
});
await expect(getProperty('users', response.id, 'auth.local.username')).to.eventually.contain('hb-');
await expect(getProperty('users', response.id, 'auth.local.lowerCaseUsername')).to.eventually.contain('hb-');
});
it('generates a random username if provided username contains a disallowed word', async () => {
const response = await api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
network,
username: 'i am a TESTPLACEHOLDERSLURWORDHERE',
});
await expect(getProperty('users', response.id, 'auth.local.username')).to.eventually.contain('hb-');
await expect(getProperty('users', response.id, 'auth.local.lowerCaseUsername')).to.eventually.contain('hb-');
});
it('generates a random username if sanitized username conflicts with an extant user', async () => {
user = await generateUser({ 'auth.local.username': 'GoogleUserName' });
const response = await api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
network,
username: 'Google User Name',
});
await expect(getProperty('users', response.id, 'auth.local.username')).to.eventually.contain('hb-');
await expect(getProperty('users', response.id, 'auth.local.lowerCaseUsername')).to.eventually.contain('hb-');
});
it('fails if allowRegister is false and user does not exist', async () => {
await expect(api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
@@ -66,7 +66,7 @@ describe('GET /inbox/conversations', () => {
it('returns five messages when using page-query ', async () => {
const promises = [];
for (let i = 0; i < 50; i += 1) {
for (let i = 0; i < 10; i += 1) {
promises.push(user.post('/members/send-private-message', {
toUserId: user.id,
message: 'fourth',
-67
View File
@@ -1,67 +0,0 @@
import md from 'habitica-markdown';
describe('habiticaMarkdown emoji plugin', () => {
it('renders standard emoji as Unicode', () => {
const result = md.render(':smile:');
expect(result).to.include('😄');
expect(result).not.to.include('img');
});
it('renders thumbsup emoji as Unicode', () => {
const result = md.render(':thumbsup:');
expect(result).to.include('👍');
});
it('renders +1 emoji as Unicode', () => {
const result = md.render(':+1:');
expect(result).to.include('👍');
});
it('renders melior as an img tag', () => {
const result = md.render(':melior:');
expect(result).to.include('<img class="habitica-emoji"');
expect(result).to.include('src="https://s3.amazonaws.com/habitica-assets/cdn/emoji/melior.png"');
expect(result).to.include('alt="melior"');
});
it('does NOT convert emoji inside markdown links', () => {
const result = md.render('[:smile: link](http://example.com)');
expect(result).to.include(':smile: link');
expect(result).not.to.include('😄');
});
it('converts emoji outside of links normally', () => {
const result = md.render(':smile: [link](http://example.com)');
expect(result).to.include('😄');
expect(result).to.include('link');
});
it('leaves removed custom emoji (bowtie) as literal text', () => {
const result = md.render(':bowtie:');
expect(result).to.include(':bowtie:');
expect(result).not.to.include('img');
});
it('leaves unknown shortcodes as literal text', () => {
const result = md.render(':nonexistent_emoji_xyz:');
expect(result).to.include(':nonexistent_emoji_xyz:');
});
it('renders new emoji not in the old dataset', () => {
const result = md.render(':yawning_face:');
expect(result).to.include('🥱');
});
it('supports unsafeHTMLRender', () => {
const result = md.unsafeHTMLRender('<b>bold</b> :smile:');
expect(result).to.include('<b>bold</b>');
expect(result).to.include('😄');
});
it('supports renderWithMentions', () => {
const result = md.renderWithMentions(':smile: @testuser', { userName: 'testuser' });
expect(result).to.include('😄');
expect(result).to.include('at-text');
expect(result).to.include('at-highlight');
});
});
+8 -17
View File
@@ -211,32 +211,22 @@ describe('shared.ops.rebirth', () => {
expect(user.achievements.rebirthLevel).to.equal(2);
});
it('increments rebirth achievements even when level is lower than previous', async () => {
it('does not increment rebirth achievements when level is lower than previous', async () => {
user.stats.lvl = 2;
user.achievements.rebirths = 1;
user.achievements.rebirthLevel = 3;
await rebirth(user);
expect(user.achievements.rebirths).to.equal(2);
expect(user.achievements.rebirths).to.equal(1);
expect(user.achievements.rebirthLevel).to.equal(3);
});
it('updates rebirthLevel when current level is higher than previous', async () => {
user.stats.lvl = 5;
user.achievements.rebirths = 1;
user.achievements.rebirthLevel = 3;
await rebirth(user);
expect(user.achievements.rebirths).to.equal(2);
expect(user.achievements.rebirthLevel).to.equal(5);
});
it('increments rebirth achievements when level is MAX_LEVEL', async () => {
it('always increments rebirth achievements when level is MAX_LEVEL', async () => {
user.stats.lvl = MAX_LEVEL;
user.achievements.rebirths = 1;
user.achievements.rebirthLevel = MAX_LEVEL;
// this value is not actually possible (actually capped at MAX_LEVEL) but makes a good test
user.achievements.rebirthLevel = MAX_LEVEL + 1;
await rebirth(user);
@@ -244,10 +234,11 @@ describe('shared.ops.rebirth', () => {
expect(user.achievements.rebirthLevel).to.equal(MAX_LEVEL);
});
it('increments rebirth achievements when level is greater than MAX_LEVEL', async () => {
it('always increments rebirth achievements when level is greater than MAX_LEVEL', async () => {
user.stats.lvl = MAX_LEVEL + 1;
user.achievements.rebirths = 1;
user.achievements.rebirthLevel = MAX_LEVEL;
// this value is not actually possible (actually capped at MAX_LEVEL) but makes a good test
user.achievements.rebirthLevel = MAX_LEVEL + 2;
await rebirth(user);
+1 -9
View File
@@ -20,9 +20,6 @@ describe('shared.ops.unlock', () => {
beforeEach(() => {
user = generateUser();
user.balance = usersStartingGems;
user.pinnedItems.push({ type: 'background', path: 'backgrounds.backgrounds042016.giant_florals' });
user.pinnedItems.push({ type: 'haircolor', path: 'hair.color.rainbow' });
user.pinnedItems.push({ type: 'shirt', path: 'shirt.convict' });
clock = sandbox.useFakeTimers(new Date('2024-04-10'));
});
@@ -275,7 +272,6 @@ describe('shared.ops.unlock', () => {
});
it('unlocks an item (appearance)', async () => {
expect(user.pinnedItems.findIndex(item => item.type === 'shirt')).to.not.equal(-1);
const path = unlockPath.split(',')[0];
const initialShirts = Object.keys(user.purchased.shirt).length;
const [, message] = await unlock(user, { query: { path } });
@@ -286,12 +282,11 @@ describe('shared.ops.unlock', () => {
);
expect(get(user.purchased, path)).to.be.true;
expect(user.balance).to.equal(usersStartingGems - 0.5);
expect(user.pinnedItems.findIndex(item => item.type === 'shirt')).to.equal(-1);
});
it('unlocks an item (hair color)', async () => {
user.purchased.hair.color = {};
expect(user.pinnedItems.findIndex(item => item.type === 'haircolor')).to.not.equal(-1);
const path = hairUnlockPath.split(',')[0];
const initialColorHair = Object.keys(user.purchased.hair.color).length;
const [, message] = await unlock(user, { query: { path } });
@@ -302,7 +297,6 @@ describe('shared.ops.unlock', () => {
);
expect(get(user.purchased, path)).to.be.true;
expect(user.balance).to.equal(usersStartingGems - 0.5);
expect(user.pinnedItems.findIndex(item => item.type === 'haircolor')).to.equal(-1);
});
it('unlocks an item (facial hair)', async () => {
@@ -340,7 +334,6 @@ describe('shared.ops.unlock', () => {
it('unlocks an item (background)', async () => {
const initialBackgrounds = Object.keys(user.purchased.background).length;
expect(user.pinnedItems.findIndex(item => item.type === 'background')).to.not.equal(-1);
const [, message] = await unlock(user, {
query: { path: backgroundUnlockPath },
});
@@ -351,7 +344,6 @@ describe('shared.ops.unlock', () => {
);
expect(get(user.purchased, backgroundUnlockPath)).to.be.true;
expect(user.balance).to.equal(usersStartingGems - 1.75);
expect(user.pinnedItems.findIndex(item => item.type === 'background')).to.equal(-1);
});
it('handles an invalid hair path gracefully', async () => {
-41
View File
@@ -1,41 +0,0 @@
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');
});
});
});
+6 -1
View File
@@ -1,7 +1,12 @@
import { STRING_DOES_NOT_EXIST_MSG } from '../helpers/content.helper';
import { STRING_ERROR_MSG, STRING_DOES_NOT_EXIST_MSG } from '../helpers/content.helper';
import translator from '../../website/common/script/content/translation';
describe('Translator', () => {
it('returns error message if string is not properly formatted', () => {
const improperlyFormattedString = translator('petName', { attr: 0 })();
expect(improperlyFormattedString).to.match(STRING_ERROR_MSG);
});
it('returns an error message if string does not exist', () => {
const stringDoesNotExist = translator('stringDoesNotExist')();
expect(stringDoesNotExist).to.match(STRING_DOES_NOT_EXIST_MSG);
+2 -2
View File
@@ -1,8 +1,8 @@
import i18n from '../../website/common/script/i18n';
import './globals.helper';
import { contentTranslations } from '../../website/server/libs/i18n';
import { translations } from '../../website/server/libs/i18n';
i18n.translations = contentTranslations;
i18n.translations = translations;
export const STRING_ERROR_MSG = /^Error processing the string ".*". Please see Help > Report a Bug.$/;
export const STRING_DOES_NOT_EXIST_MSG = /^String '.*' not found.$/;
-1
View File
@@ -21,7 +21,6 @@ export async function getProperty (collectionName, id, path) {
// Specifically helpful for the GET /groups tests,
// resets the db to an empty state and creates a tavern document
export async function resetHabiticaDB () {
console.info('Resetting Habitica DB');
const groups = mongoose.connection.db.collection('groups');
const users = mongoose.connection.db.collection('users');
return mongoose.connection.dropDatabase()
+1 -1
View File
@@ -32,6 +32,6 @@
<script type="text/javascript" src="//cloudfront.loggly.com/js/loggly.tracker-latest.min.js" async></script>
<!-- Translations -->
<script type='text/javascript' src='/api/v4/i18n/core' vite-ignore></script>
<script type='text/javascript' src='/api/v4/i18n/browser-script' vite-ignore></script>
</body>
</html>
+2758 -4984
View File
File diff suppressed because it is too large Load Diff
+3 -4
View File
@@ -4,7 +4,6 @@
"private": true,
"scripts": {
"serve": "vite",
"serve:docker": "npx vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"test:unit": "vitest run",
@@ -28,13 +27,13 @@
"eslint-config-habitrpg": "6.2.0",
"eslint-plugin-mocha": "5.3.0",
"eslint-plugin-vue": "7.20.0",
"habitica-markdown": "^4.0.0",
"ga-gtag": "^1.2.0",
"habitica-markdown": "^3.0.0",
"hellojs": "^1.20.0",
"intro.js": "^7.2.0",
"jquery": "^3.7.1",
"lodash": "^4.17.21",
"markdown-it": "^14.0.0",
"micromustache": "^8.0.3",
"moment": "^2.29.4",
"nconf": "^0.12.1",
"sass": "^1.63.4",
@@ -43,7 +42,7 @@
"timers-browserify": "^2.0.12",
"uuid": "^9.0.1",
"validator": "^13.9.0",
"vite": "^6.3.6",
"vite": "^6.0.0",
"vite-plugin-compression2": "^1.3.3",
"vue": "^2.7.10",
"vue-fragment": "^1.6.0",
-8
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
@@ -229,11 +226,6 @@ export default {
}
return Promise.resolve(error);
}
if (error.response.status === 404
&& error.response.config.method === 'get'
&& error.response.config.url.indexOf('/api/v4/groups/party') !== -1) {
return Promise.reject(error);
}
}
const errorData = error.response.data;
+123 -2
View File
@@ -1,3 +1,57 @@
.quest_lostMasterclasser4 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/quest_lostMasterclasser4.gif") no-repeat;
width: 219px;
height: 219px;
}
.quest_windup {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/quest_windup.gif") no-repeat;
width: 219px;
height: 219px;
}
.quest_solarSystem {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/quest_solarSystem.gif") no-repeat;
width: 219px;
height: 219px;
}
.quest_virtualpet {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/quest_virtualpet.gif") no-repeat;
width: 219px;
height: 219px;
}
.Pet_HatchingPotion_Dessert, .Pet_HatchingPotion_Veggie, .Pet_HatchingPotion_Windup,
.Pet_HatchingPotion_VirtualPet, .Pet_HatchingPotion_Fungi, .Pet_HatchingPotion_Cryptid {
width: 68px;
height: 68px;
}
.Pet_HatchingPotion_Dessert {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_Dessert.gif") no-repeat;
}
.Pet_HatchingPotion_Veggie {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_Veggie.gif") no-repeat;
}
.Pet_HatchingPotion_Windup {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_Windup.gif") no-repeat;
}
.Pet_HatchingPotion_VirtualPet {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_VirtualPet.gif") no-repeat;
}
.Pet_HatchingPotion_Fungi {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_Fungi.gif") no-repeat;
}
.Pet_HatchingPotion_Cryptid {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_Cryptid.gif") no-repeat;
}
.Gems {
display:inline-block;
margin-right:5px;
@@ -26,7 +80,6 @@
margin-left: -3px;
margin-top: -18px;
}
.slim_armor_special_0, .broad_armor_special_0, .shield_special_0 {
width: 90px;
height: 90px;
@@ -34,6 +87,7 @@
/* Critical */
.weapon_special_critical {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/weapon_special_critical.gif") no-repeat;
width: 90px;
height: 90px;
margin-left:-12px;
@@ -44,7 +98,6 @@
.weapon_special_1 {
margin-left: -12px;
}
.broad_armor_special_1, .slim_armor_special_1, .head_special_1 {
width: 90px;
height: 90px;
@@ -53,15 +106,36 @@
.back_special_heroicAureole {
width: 114px;
height: 90px;
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/back_special_heroicAureole.gif") no-repeat;
}
.head_special_0 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Equip-ShadeHelmet.gif") no-repeat;
}
.head_special_1 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/ContributorOnly-Equip-CrystalHelmet.gif") no-repeat;
margin-top: 3px;
}
.broad_armor_special_0,.slim_armor_special_0 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Equip-ShadeArmor.gif") no-repeat;
}
.broad_armor_special_1,.slim_armor_special_1 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/ContributorOnly-Equip-CrystalArmor.gif") no-repeat;
}
.shield_special_0 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Shield-TormentedSkull.gif") no-repeat;
}
.weapon_special_0 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Weapon-DarkSoulsBlade.gif") no-repeat;
}
.Pet-Wolf-Cerberus {
width: 105px;
height: 72px;
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Pet-CerberusPup.gif") no-repeat;
}
.broad_armor_special_ks2019, .slim_armor_special_ks2019, .eyewear_special_ks2019, .head_special_ks2019, .shield_special_ks2019 {
@@ -69,17 +143,36 @@
height: 120px;
}
.broad_armor_special_ks2019, .slim_armor_special_ks2019 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Equip-MythicGryphonArmor.gif") no-repeat;
}
.eyewear_special_ks2019 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Equip-MythicGryphonVisor.gif") no-repeat;
}
.head_special_ks2019 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Equip-MythicGryphonHelm.gif") no-repeat;
}
.shield_special_ks2019 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Equip-MythicGryphonShield.gif") no-repeat;
}
.weapon_special_ks2019 {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Equip-MythicGryphonGlaive.gif") no-repeat;
width: 120px;
height: 120px;
}
.Pet-Gryphon-Gryphatrice {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Pet-Gryphatrice.gif") no-repeat;
width: 81px;
height: 99px;
}
.Pet-Gryphatrice-Jubilant {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-Gryphatrice-Jubilant.gif") no-repeat;
width: 81px;
height: 96px;
}
@@ -89,11 +182,39 @@
height: 135px;
}
.Mount_Head_Gryphon-Gryphatrice {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Mount-Head-Gryphatrice.gif") no-repeat;
}
.Mount_Body_Gryphon-Gryphatrice {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Mount-Body-Gryphatrice.gif") no-repeat;
}
.Mount_Head_Dragon-Hydra {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Mount_Head_Dragon-Hydra.gif") no-repeat;
}
.Mount_Body_Dragon-Hydra {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Mount_Body_Dragon-Hydra.gif") no-repeat;
}
.background_airship, .background_clocktower, .background_steamworks {
width: 141px;
height: 147px;
}
.background_airship {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_airship.gif") no-repeat;
}
.background_clocktower {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_clocktower.gif") no-repeat;
}
.background_steamworks {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_steamworks.gif") no-repeat;
}
[class*="Mount_Head_"],
[class*="Mount_Body_"] {
margin-top:18px; /* Sprite accommodates 105x123 box */
@@ -1055,16 +1055,6 @@
width: 141px;
height: 147px;
}
.background_elegant_palace {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_elegant_palace.png');
width: 141px;
height: 147px;
}
.background_elven_citadel {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_elven_citadel.png');
width: 141px;
height: 147px;
}
.background_enchanted_music_room {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_enchanted_music_room.png');
width: 141px;
@@ -1761,11 +1751,6 @@
width: 141px;
height: 147px;
}
.background_nighttime_street_with_shops {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_nighttime_street_with_shops.png');
width: 141px;
height: 147px;
}
.background_ocean_sunrise {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_ocean_sunrise.png');
width: 141px;
@@ -1801,11 +1786,6 @@
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;
@@ -1941,11 +1921,6 @@
width: 141px;
height: 147px;
}
.background_riding_a_comet {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_riding_a_comet.png');
width: 141px;
height: 147px;
}
.background_rime_ice {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_rime_ice.png');
width: 141px;
@@ -2442,11 +2417,6 @@
width: 141px;
height: 147px;
}
.background_waterfall_with_rainbow {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_waterfall_with_rainbow.png');
width: 141px;
height: 147px;
}
.background_wedding_arch {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_wedding_arch.png');
width: 141px;
@@ -2467,11 +2437,6 @@
width: 141px;
height: 147px;
}
.background_winter_desert_with_saguaros {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_winter_desert_with_saguaros.png');
width: 141px;
height: 147px;
}
.background_winter_fireworks {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/background_winter_fireworks.png');
width: 141px;
@@ -29490,11 +29455,6 @@
width: 60px;
height: 60px;
}
.back_armoire_harpsichord {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/back_armoire_harpsichord.png');
width: 114px;
height: 90px;
}
.body_armoire_clownsBowtie {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/body_armoire_clownsBowtie.png');
width: 114px;
@@ -29820,11 +29780,6 @@
width: 114px;
height: 90px;
}
.broad_armor_armoire_handstandOutfit {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_armoire_handstandOutfit.png');
width: 114px;
height: 90px;
}
.broad_armor_armoire_hattersSuit {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_armoire_hattersSuit.png');
width: 114px;
@@ -29890,11 +29845,6 @@
width: 114px;
height: 90px;
}
.broad_armor_armoire_loneCowpokeOutfit {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_armoire_loneCowpokeOutfit.png');
width: 114px;
height: 90px;
}
.broad_armor_armoire_lunarArmor {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_armoire_lunarArmor.png');
width: 90px;
@@ -30100,11 +30050,6 @@
width: 114px;
height: 90px;
}
.broad_armor_armoire_softYellowSuit {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_armoire_softYellowSuit.png');
width: 114px;
height: 90px;
}
.broad_armor_armoire_springPetalYukata {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_armoire_springPetalYukata.png');
width: 114px;
@@ -30415,11 +30360,6 @@
width: 114px;
height: 90px;
}
.head_armoire_floppyYellowHat {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_armoire_floppyYellowHat.png');
width: 114px;
height: 90px;
}
.head_armoire_flutteryWig {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_armoire_flutteryWig.png');
width: 114px;
@@ -30540,11 +30480,6 @@
width: 114px;
height: 87px;
}
.head_armoire_loneCowpokeHat {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_armoire_loneCowpokeHat.png');
width: 114px;
height: 90px;
}
.head_armoire_lunarCrown {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_armoire_lunarCrown.png');
width: 90px;
@@ -30740,11 +30675,6 @@
width: 114px;
height: 90px;
}
.head_armoire_verdantArmingCap {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_armoire_verdantArmingCap.png');
width: 114px;
height: 90px;
}
.head_armoire_vermilionArcherHelm {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_armoire_vermilionArcherHelm.png');
width: 90px;
@@ -30860,11 +30790,6 @@
width: 114px;
height: 90px;
}
.shield_armoire_doubleBass {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/shield_armoire_doubleBass.png');
width: 114px;
height: 90px;
}
.shield_armoire_dragonTamerShield {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/shield_armoire_dragonTamerShield.png');
width: 90px;
@@ -31070,11 +30995,6 @@
width: 90px;
height: 90px;
}
.shield_armoire_prettyPinkGiftBox {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/shield_armoire_prettyPinkGiftBox.png');
width: 114px;
height: 90px;
}
.shield_armoire_ramHornShield {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/shield_armoire_ramHornShield.png');
width: 90px;
@@ -31160,11 +31080,6 @@
width: 114px;
height: 90px;
}
.shield_armoire_softYellowPillow {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/shield_armoire_softYellowPillow.png');
width: 114px;
height: 90px;
}
.shield_armoire_spanishGuitar {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/shield_armoire_spanishGuitar.png');
width: 114px;
@@ -31215,11 +31130,6 @@
width: 114px;
height: 90px;
}
.shield_armoire_verdantBanner {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/shield_armoire_verdantBanner.png');
width: 114px;
height: 90px;
}
.shield_armoire_vikingShield {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/shield_armoire_vikingShield.png');
width: 90px;
@@ -31490,11 +31400,6 @@
width: 114px;
height: 90px;
}
.slim_armor_armoire_handstandOutfit {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_armoire_handstandOutfit.png');
width: 114px;
height: 90px;
}
.slim_armor_armoire_hattersSuit {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_armoire_hattersSuit.png');
width: 114px;
@@ -31560,11 +31465,6 @@
width: 114px;
height: 90px;
}
.slim_armor_armoire_loneCowpokeOutfit {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_armoire_loneCowpokeOutfit.png');
width: 114px;
height: 90px;
}
.slim_armor_armoire_lunarArmor {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_armoire_lunarArmor.png');
width: 90px;
@@ -31770,11 +31670,6 @@
width: 114px;
height: 90px;
}
.slim_armor_armoire_softYellowSuit {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_armoire_softYellowSuit.png');
width: 114px;
height: 90px;
}
.slim_armor_armoire_springPetalYukata {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_armoire_springPetalYukata.png');
width: 114px;
@@ -31865,11 +31760,6 @@
width: 114px;
height: 90px;
}
.weapon_armoire_bambooFlute {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/weapon_armoire_bambooFlute.png');
width: 114px;
height: 90px;
}
.weapon_armoire_barristerGavel {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/weapon_armoire_barristerGavel.png');
width: 90px;
@@ -32280,11 +32170,6 @@
width: 114px;
height: 90px;
}
.weapon_armoire_prettyPinkParasol {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/weapon_armoire_prettyPinkParasol.png');
width: 114px;
height: 90px;
}
.weapon_armoire_pushBroom {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/weapon_armoire_pushBroom.png');
width: 114px;
@@ -34175,81 +34060,6 @@
width: 90px;
height: 90px;
}
.back_mystery_202601 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/back_mystery_202601.png');
width: 114px;
height: 90px;
}
.back_mystery_202602 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/back_mystery_202602.png');
width: 114px;
height: 90px;
}
.back_mystery_202605 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/back_mystery_202605.png');
width: 114px;
height: 90px;
}
.broad_armor_mystery_202512 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_mystery_202512.png');
width: 114px;
height: 90px;
}
.broad_armor_mystery_202604 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_mystery_202604.png');
width: 114px;
height: 90px;
}
.head_mystery_202512 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_mystery_202512.png');
width: 114px;
height: 90px;
}
.head_mystery_202602 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_mystery_202602.png');
width: 114px;
height: 90px;
}
.head_mystery_202603 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_mystery_202603.png');
width: 114px;
height: 90px;
}
.head_mystery_202604 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_mystery_202604.png');
width: 114px;
height: 90px;
}
.shield_mystery_202605 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/shield_mystery_202605.png');
width: 114px;
height: 90px;
}
.slim_armor_mystery_202512 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_mystery_202512.png');
width: 114px;
height: 90px;
}
.slim_armor_mystery_202604 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_mystery_202604.png');
width: 114px;
height: 90px;
}
.weapon_mystery_202512 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/weapon_mystery_202512.png');
width: 114px;
height: 90px;
}
.weapon_mystery_202601 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/weapon_mystery_202601.png');
width: 114px;
height: 90px;
}
.weapon_mystery_202603 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/weapon_mystery_202603.png');
width: 114px;
height: 90px;
}
.back_mystery_201402 {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/back_mystery_201402.png');
width: 90px;
@@ -36370,26 +36180,6 @@
width: 114px;
height: 90px;
}
.broad_armor_special_spring2026Healer {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_special_spring2026Healer.png');
width: 114px;
height: 90px;
}
.broad_armor_special_spring2026Mage {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_special_spring2026Mage.png');
width: 114px;
height: 90px;
}
.broad_armor_special_spring2026Rogue {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_special_spring2026Rogue.png');
width: 114px;
height: 90px;
}
.broad_armor_special_spring2026Warrior {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_special_spring2026Warrior.png');
width: 114px;
height: 90px;
}
.broad_armor_special_springHealer {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_special_springHealer.png');
width: 90px;
@@ -36710,26 +36500,6 @@
width: 114px;
height: 90px;
}
.head_special_spring2026Healer {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_special_spring2026Healer.png');
width: 114px;
height: 90px;
}
.head_special_spring2026Mage {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_special_spring2026Mage.png');
width: 114px;
height: 90px;
}
.head_special_spring2026Rogue {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_special_spring2026Rogue.png');
width: 114px;
height: 90px;
}
.head_special_spring2026Warrior {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_special_spring2026Warrior.png');
width: 114px;
height: 90px;
}
.head_special_springHealer {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_special_springHealer.png');
width: 90px;
@@ -36915,21 +36685,6 @@
width: 114px;
height: 90px;
}
.shield_special_spring2026Healer {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/shield_special_spring2026Healer.png');
width: 114px;
height: 90px;
}
.shield_special_spring2026Rogue {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/shield_special_spring2026Rogue.png');
width: 114px;
height: 90px;
}
.shield_special_spring2026Warrior {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/shield_special_spring2026Warrior.png');
width: 114px;
height: 90px;
}
.shield_special_springHealer {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/shield_special_springHealer.png');
width: 90px;
@@ -37165,26 +36920,6 @@
width: 114px;
height: 90px;
}
.slim_armor_special_spring2026Healer {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_special_spring2026Healer.png');
width: 114px;
height: 90px;
}
.slim_armor_special_spring2026Mage {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_special_spring2026Mage.png');
width: 114px;
height: 90px;
}
.slim_armor_special_spring2026Rogue {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_special_spring2026Rogue.png');
width: 114px;
height: 90px;
}
.slim_armor_special_spring2026Warrior {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_special_spring2026Warrior.png');
width: 114px;
height: 90px;
}
.slim_armor_special_springHealer {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_special_springHealer.png');
width: 90px;
@@ -37425,26 +37160,6 @@
width: 114px;
height: 90px;
}
.weapon_special_spring2026Healer {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/weapon_special_spring2026Healer.png');
width: 114px;
height: 90px;
}
.weapon_special_spring2026Mage {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/weapon_special_spring2026Mage.png');
width: 114px;
height: 90px;
}
.weapon_special_spring2026Rogue {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/weapon_special_spring2026Rogue.png');
width: 114px;
height: 90px;
}
.weapon_special_spring2026Warrior {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/weapon_special_spring2026Warrior.png');
width: 114px;
height: 90px;
}
.weapon_special_springHealer {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/weapon_special_springHealer.png');
width: 90px;
@@ -38925,26 +38640,6 @@
width: 114px;
height: 90px;
}
.broad_armor_special_winter2026Healer {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_special_winter2026Healer.png');
width: 114px;
height: 90px;
}
.broad_armor_special_winter2026Mage {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_special_winter2026Mage.png');
width: 114px;
height: 90px;
}
.broad_armor_special_winter2026Rogue {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_special_winter2026Rogue.png');
width: 117px;
height: 120px;
}
.broad_armor_special_winter2026Warrior {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_special_winter2026Warrior.png');
width: 114px;
height: 90px;
}
.broad_armor_special_yeti {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/broad_armor_special_yeti.png');
width: 90px;
@@ -39240,26 +38935,6 @@
width: 114px;
height: 90px;
}
.head_special_winter2026Healer {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_special_winter2026Healer.png');
width: 114px;
height: 90px;
}
.head_special_winter2026Mage {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_special_winter2026Mage.png');
width: 114px;
height: 90px;
}
.head_special_winter2026Rogue {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_special_winter2026Rogue.png');
width: 117px;
height: 120px;
}
.head_special_winter2026Warrior {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_special_winter2026Warrior.png');
width: 114px;
height: 90px;
}
.head_special_yeti {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/head_special_yeti.png');
width: 90px;
@@ -39440,21 +39115,6 @@
width: 114px;
height: 90px;
}
.shield_special_winter2026Healer {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/shield_special_winter2026Healer.png');
width: 114px;
height: 90px;
}
.shield_special_winter2026Rogue {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/shield_special_winter2026Rogue.png');
width: 117px;
height: 120px;
}
.shield_special_winter2026Warrior {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/shield_special_winter2026Warrior.png');
width: 114px;
height: 90px;
}
.shield_special_yeti {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/shield_special_yeti.png');
width: 90px;
@@ -39695,26 +39355,6 @@
width: 114px;
height: 90px;
}
.slim_armor_special_winter2026Healer {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_special_winter2026Healer.png');
width: 114px;
height: 90px;
}
.slim_armor_special_winter2026Mage {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_special_winter2026Mage.png');
width: 114px;
height: 90px;
}
.slim_armor_special_winter2026Rogue {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_special_winter2026Rogue.png');
width: 117px;
height: 120px;
}
.slim_armor_special_winter2026Warrior {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_special_winter2026Warrior.png');
width: 114px;
height: 90px;
}
.slim_armor_special_yeti {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/slim_armor_special_yeti.png');
width: 90px;
@@ -39955,26 +39595,6 @@
width: 114px;
height: 90px;
}
.weapon_special_winter2026Healer {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/weapon_special_winter2026Healer.png');
width: 114px;
height: 90px;
}
.weapon_special_winter2026Mage {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/weapon_special_winter2026Mage.png');
width: 114px;
height: 90px;
}
.weapon_special_winter2026Rogue {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/weapon_special_winter2026Rogue.png');
width: 117px;
height: 120px;
}
.weapon_special_winter2026Warrior {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/weapon_special_winter2026Warrior.png');
width: 114px;
height: 90px;
}
.weapon_special_yeti {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/weapon_special_yeti.png');
width: 90px;
@@ -53228,11 +52848,6 @@
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;
@@ -53723,11 +53338,6 @@
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;
@@ -54518,11 +54128,6 @@
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;
@@ -55018,11 +54623,6 @@
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;
@@ -55358,11 +54958,6 @@
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;
@@ -56143,11 +55738,6 @@
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;
@@ -56753,11 +56343,6 @@
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;
@@ -58153,11 +57738,6 @@
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;
@@ -58803,11 +58383,6 @@
width: 81px;
height: 99px;
}
.Pet-Wolf-Alien {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-Wolf-Alien.png');
width: 81px;
height: 99px;
}
.Pet-Wolf-Amber {
background-image: url('https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet-Wolf-Amber.png');
width: 81px;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -1,9 +0,0 @@
<svg width="378" height="176" viewBox="0 0 378 176" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0H378V174C378 175.105 377.105 176 376 176H1.99999C0.895423 176 0 175.105 0 174V0Z" fill="url(#paint0_linear_2257_239)"/>
<defs>
<linearGradient id="paint0_linear_2257_239" x1="378" y1="0" x2="0" y2="0" gradientUnits="userSpaceOnUse">
<stop stop-color="#925CF3"/>
<stop offset="1" stop-color="#34B5C1"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 448 B

@@ -1,37 +0,0 @@
<svg width="48" height="96" viewBox="0 0 48 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M-3.10104 12.0483C-2.82088 9.43721 -3.53422 6.57214 -5.6115 5.24584C-7.68877 3.91954 -9.89543 4.92709 -10.1422 6.808C-10.3891 8.68891 -9.06061 9.83066 -4.97737 13.9337C-3.81821 15.0985 -3.3812 14.6594 -3.10104 12.0483Z" stroke="#FFA624" stroke-width="4"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.34089 15.2054C4.45116 13.6561 7.27707 12.8443 9.45877 13.9889C11.6405 15.1334 11.8754 17.5575 10.3778 18.7127C8.88016 19.868 7.23193 19.2828 1.65411 17.781C0.0706697 17.3546 0.230624 16.7548 2.34089 15.2054Z" stroke="#FFBE5D" stroke-width="4"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.549002 12.0098C-3.61871 9.59194 -3.87667 15.8322 -2.20457 16.8023C-0.532473 17.7724 4.71671 14.4277 0.549002 12.0098Z" fill="#EE9109"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M-1.76917 16.0445L13.637 24.9825L9.18965 32.7229L-6.21656 23.785L-1.76917 16.0445Z" fill="#F8F9F9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M-6.90457 13.0652L3.36623 19.0238L-1.08116 26.7643L-11.352 20.8057L-6.90457 13.0652Z" fill="#FFBE5D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M-1.76917 16.0445L3.36623 19.0238L1.88377 21.604L-3.25163 18.6247L-1.76917 16.0445Z" fill="#FFA624"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M-6.21656 23.785L6.62195 31.2333L-3.75529 49.2944L-16.5938 41.8461L-6.21656 23.785Z" fill="#F8F9F9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M-3.64886 25.2747L6.62195 31.2333L5.13948 33.8134L-5.13132 27.8548L-3.64886 25.2747Z" fill="#DDF3F3"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.401307 24.1842L10.6721 30.1428L9.18965 32.7229L-1.08116 26.7643L0.401307 24.1842Z" fill="#DDF3F3"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.7924 38.4607L17.9387 42.0519L21.31 40.5834L24.8838 41.4413L23.4225 38.0537L24.2762 34.4625L20.9049 35.9309L17.3311 35.0731L18.7924 38.4607Z" fill="white" fill-opacity="0.5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M-3.93867 71.2331L-4.79238 74.8243L-1.42111 73.3559L2.15271 74.2137L0.691383 70.8261L1.54509 67.2349L-1.82618 68.7033L-5.4 67.8455L-3.93867 71.2331Z" fill="white" fill-opacity="0.5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M33.8949 25.3807L35.0583 29.8802L37.9424 26.2452L42.4202 25.0761L38.8028 22.178L37.6393 17.6786L34.7552 21.3135L30.2775 22.4826L33.8949 25.3807Z" fill="white" fill-opacity="0.5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.2596 71.999L40.579 68.1435L45.9507 88.2881L31.6312 92.1436L26.2596 71.999Z" fill="#F8F9F9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.9401 75.8545L26.2589 71.9966L31.6273 92.1421L17.3084 96L11.9401 75.8545Z" fill="#DDF3F3"/>
<rect width="2.96589" height="20.8485" transform="matrix(0.965611 -0.25999 0.257652 0.966238 23.3957 72.7701)" fill="#FFA624"/>
<rect width="2.96589" height="20.8485" transform="matrix(0.965611 -0.25999 0.257652 0.966238 26.2596 71.999)" fill="#FFBE5D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.9999 90.0369L30.8638 89.2658L31.6312 92.1436L28.7673 92.9147L27.9999 90.0369Z" fill="#EE9109"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.3957 72.7701L26.2596 71.999L27.0269 74.8768L24.163 75.6479L23.3957 72.7701Z" fill="#EE9109"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.9401 75.8545L23.3951 72.7682L24.162 75.6461L12.707 78.7325L11.9401 75.8545Z" fill="#C1E9E9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.5443 93.1213L27.9999 90.0369L28.7673 92.9147L17.3117 95.9991L16.5443 93.1213Z" fill="#C1E9E9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M29.1235 71.2279L40.579 68.1435L41.3464 71.0213L29.8908 74.1057L29.1235 71.2279Z" fill="#DDF3F3"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M33.7277 88.4947L45.1833 85.4103L45.9507 88.2881L34.4951 91.3725L33.7277 88.4947Z" fill="#DDF3F3"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M30.8638 89.2658L33.7277 88.4947L34.4951 91.3725L31.6312 92.1436L30.8638 89.2658Z" fill="#FFA624"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.2596 71.999L29.1235 71.2279L29.8908 74.1057L27.0269 74.8768L26.2596 71.999Z" fill="#FFA624"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.5224 56.3076C25.8087 53.7812 24.0792 51.3933 21.6588 50.9455C19.2383 50.4977 17.5679 52.2625 18.0403 54.0994C18.5126 55.9363 20.17 56.4948 25.4855 58.7621C26.9945 59.4057 27.236 58.834 26.5224 56.3076Z" stroke="#FFA624" stroke-width="4"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M32.745 57.1864C34.124 54.9555 36.4415 53.1391 38.8911 53.3791C41.3406 53.6191 42.4621 55.7782 41.5042 57.413C40.5463 59.0479 38.7999 59.1258 33.0684 59.8329C31.4413 60.0337 31.366 59.4173 32.745 57.1864Z" stroke="#FFBE5D" stroke-width="4"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M29.8923 54.898C25.1267 54.225 27.2139 60.108 29.1258 60.378C31.0378 60.648 34.6579 55.571 29.8923 54.898Z" fill="#EE9109"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M29.247 59.5115L46.8635 61.9994L45.6255 70.8503L28.0091 68.3625L29.247 59.5115Z" fill="#F8F9F9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.6306 57.0236L29.247 59.5114L28.0091 68.3624L10.3927 65.8745L11.6306 57.0236Z" fill="#DDF3F3"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.3749 58.6822L35.1192 60.3408L33.8813 69.1917L22.137 67.5332L23.3749 58.6822Z" fill="#FFBE5D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.3749 58.6822L29.247 59.5115L28.0091 68.3625L22.137 67.5332L23.3749 58.6822Z" fill="#FFA624"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M29.247 59.5115L35.1192 60.3408L34.7065 63.2911L28.8344 62.4618L29.247 59.5115Z" fill="#FFA624"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.3749 58.6822L29.247 59.5115L28.8344 62.4618L22.9622 61.6326L23.3749 58.6822Z" fill="#EE9109"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.8053 62.9241L22.5496 64.5827L22.137 67.533L10.3927 65.8745L10.8053 62.9241Z" fill="#C1E9E9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M34.2939 66.2414L46.0382 67.9L45.6255 70.8503L33.8813 69.1917L34.2939 66.2414Z" fill="#DDF3F3"/>
</svg>

Before

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 375 B

+4 -15
View File
@@ -30,23 +30,12 @@
cursor: default;
color: $gray-200;
opacity: 1;
background-color: transparent;
box-shadow: none;
background-color: $gray-700;
border: 2px solid transparent;
box-shadow:
0 1px 3px 0 rgba($black, 0.12),
0 1px 2px 0 rgba($black, 0.24);
font-family: 'Roboto', sans-serif;
font-weight: 700;
font-size: 14px;
line-height: 24px;
padding: 4px 12px;
min-height: 32px;
max-height: 32px;
gap: 8px;
border-radius: 4px;
.svg {
color: $gray-200;
color: $gray-300;
}
}
@@ -58,11 +58,6 @@ h3.markdown {
img {
max-width: 100%;
}
.emoji-native {
font-size: 0.85em;
vertical-align: middle;
}
blockquote {
padding: 0 16px;
@@ -123,10 +123,6 @@ h4 {
background-color: $purple-300 !important;
}
.bg-yellow-50 {
background-color: $yellow-50 !important;
}
.bg-white {
background-color: $white !important;
}
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g fill="#FFFFFF" fill-rule="nonzero">
<polygon points="12.1973467 2 14 3.80265326 9.80187117 8 14 12.1973467 12.1973467 14 8 9.80187117 3.80265326 14 2 12.1973467 6.19812883 8 2 3.80265326 3.80265326 2 8 6.19812883"></polygon>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 504 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.4 KiB

@@ -1,29 +0,0 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M58.1792 31.6843L46.8536 22.3769L23.918 28.6988L18.861 42.5218L44.341 58.5813L58.1792 31.6843Z" fill="#FF944C"/>
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M46.6218 34.5148L46.1108 26.1328L36.2812 28.8422L46.6218 34.5148Z" fill="white"/>
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M30.2393 39.0304L26.4518 31.5515L36.2813 28.8422L30.2393 39.0304Z" fill="white"/>
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M46.6218 34.5148L36.2813 28.8422L30.2393 39.0304L46.6218 34.5148Z" fill="white"/>
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M53.8301 32.5279L46.1108 26.1328L46.6218 34.5148L53.8301 32.5279Z" fill="white"/>
<path opacity="0.35" fill-rule="evenodd" clip-rule="evenodd" d="M23.0309 41.0173L26.4518 31.5516L30.2393 39.0304L23.0309 41.0173Z" fill="#FA8537"/>
<path opacity="0.35" fill-rule="evenodd" clip-rule="evenodd" d="M53.8301 32.5279L46.6218 34.5148L43.0424 53.79L53.8301 32.5279Z" fill="#FA8537"/>
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M23.0309 41.0173L30.2393 39.0304L43.0425 53.79L23.0309 41.0173Z" fill="white"/>
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M46.6218 34.5148L30.2393 39.0304L43.0425 53.79L46.6218 34.5148Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M50.555 4.15937L47.026 0.420004L38.7773 1.59601L36.4144 6.17539L44.5675 12.8919L50.555 4.15937Z" fill="#FFBE5D"/>
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M46.414 4.62854L46.6034 1.6924L43.0682 2.1964L46.414 4.62854Z" fill="white"/>
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M40.5221 5.46854L39.5331 2.7004L43.0682 2.1964L40.5221 5.46854Z" fill="white"/>
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M46.414 4.62854L43.0683 2.1964L40.5221 5.46855L46.414 4.62854Z" fill="white"/>
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M49.0064 4.25894L46.6034 1.6924L46.414 4.62854L49.0064 4.25894Z" fill="white"/>
<path opacity="0.35" fill-rule="evenodd" clip-rule="evenodd" d="M37.9296 5.83815L39.5331 2.70041L40.5221 5.46855L37.9296 5.83815Z" fill="#FFA624"/>
<path opacity="0.35" fill-rule="evenodd" clip-rule="evenodd" d="M49.0064 4.25893L46.414 4.62853L44.3259 11.1688L49.0064 4.25893Z" fill="#FFA624"/>
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M37.9297 5.83815L40.5221 5.46855L44.326 11.1688L37.9297 5.83815Z" fill="white"/>
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M46.414 4.62854L40.5221 5.46855L44.326 11.1688L46.414 4.62854Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.2986 16.7775L24.6513 8.36623L11.1016 3.94533L4.07056 9.19883L11.614 25.6769L27.2986 16.7775Z" fill="#FF6165"/>
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M20.5864 14.3719L23.0573 10.0026L17.2502 8.10789L20.5864 14.3719Z" fill="white"/>
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M10.908 11.2141L11.4432 6.21322L17.2502 8.10789L10.908 11.2141Z" fill="white"/>
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M20.5864 14.3719L17.2502 8.10789L10.9081 11.2141L20.5864 14.3719Z" fill="white"/>
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M24.8449 15.7613L23.0573 10.0026L20.5864 14.3719L24.8449 15.7613Z" fill="white"/>
<path opacity="0.35" fill-rule="evenodd" clip-rule="evenodd" d="M6.64955 9.82464L11.4432 6.21321L10.908 11.2141L6.64955 9.82464Z" fill="#F23035"/>
<path opacity="0.35" fill-rule="evenodd" clip-rule="evenodd" d="M24.8449 15.7613L20.5864 14.3719L12.5221 22.8464L24.8449 15.7613Z" fill="#F23035"/>
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M6.64959 9.82464L10.9081 11.2141L12.5221 22.8463L6.64959 9.82464Z" fill="white"/>
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M20.5864 14.3719L10.9081 11.2141L12.5221 22.8463L20.5864 14.3719Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

@@ -1,29 +0,0 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.82083 31.6843L17.1464 22.3769L40.082 28.6988L45.139 42.5218L19.659 58.5813L5.82083 31.6843Z" fill="#24CC8F"/>
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M17.3782 34.5148L17.8892 26.1328L27.7188 28.8422L17.3782 34.5148Z" fill="white"/>
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M33.7607 39.0304L37.5482 31.5515L27.7187 28.8422L33.7607 39.0304Z" fill="white"/>
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M17.3782 34.5148L27.7187 28.8422L33.7607 39.0304L17.3782 34.5148Z" fill="white"/>
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M10.1699 32.5279L17.8892 26.1328L17.3782 34.5148L10.1699 32.5279Z" fill="white"/>
<path opacity="0.35" fill-rule="evenodd" clip-rule="evenodd" d="M40.9691 41.0173L37.5482 31.5516L33.7607 39.0304L40.9691 41.0173Z" fill="#1CA372"/>
<path opacity="0.35" fill-rule="evenodd" clip-rule="evenodd" d="M10.1699 32.5279L17.3782 34.5148L20.9576 53.79L10.1699 32.5279Z" fill="#1CA372"/>
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M40.9691 41.0173L33.7607 39.0304L20.9575 53.79L40.9691 41.0173Z" fill="white"/>
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M17.3782 34.5148L33.7607 39.0304L20.9575 53.79L17.3782 34.5148Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.445 4.15937L16.974 0.420004L25.2227 1.59601L27.5856 6.17539L19.4325 12.8919L13.445 4.15937Z" fill="#925CF3"/>
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M17.586 4.62854L17.3966 1.6924L20.9318 2.1964L17.586 4.62854Z" fill="white"/>
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M23.4779 5.46854L24.4669 2.7004L20.9318 2.1964L23.4779 5.46854Z" fill="white"/>
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M17.586 4.62854L20.9317 2.1964L23.4779 5.46855L17.586 4.62854Z" fill="white"/>
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M14.9936 4.25894L17.3966 1.6924L17.586 4.62854L14.9936 4.25894Z" fill="white"/>
<path opacity="0.35" fill-rule="evenodd" clip-rule="evenodd" d="M26.0704 5.83815L24.4669 2.70041L23.4779 5.46855L26.0704 5.83815Z" fill="#4F2A93"/>
<path opacity="0.35" fill-rule="evenodd" clip-rule="evenodd" d="M14.9936 4.25893L17.586 4.62853L19.6741 11.1688L14.9936 4.25893Z" fill="#4F2A93"/>
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M26.0703 5.83815L23.4779 5.46855L19.674 11.1688L26.0703 5.83815Z" fill="white"/>
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M17.586 4.62854L23.4779 5.46855L19.674 11.1688L17.586 4.62854Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M36.7014 16.7775L39.3487 8.36623L52.8984 3.94533L59.9294 9.19883L52.386 25.6769L36.7014 16.7775Z" fill="#50B5E9"/>
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M43.4136 14.3719L40.9427 10.0026L46.7498 8.10789L43.4136 14.3719Z" fill="white"/>
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M53.092 11.2141L52.5568 6.21322L46.7498 8.10789L53.092 11.2141Z" fill="white"/>
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M43.4136 14.3719L46.7498 8.10789L53.0919 11.2141L43.4136 14.3719Z" fill="white"/>
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M39.1551 15.7613L40.9427 10.0026L43.4136 14.3719L39.1551 15.7613Z" fill="white"/>
<path opacity="0.35" fill-rule="evenodd" clip-rule="evenodd" d="M57.3504 9.82464L52.5568 6.21321L53.092 11.2141L57.3504 9.82464Z" fill="#46A7D9"/>
<path opacity="0.35" fill-rule="evenodd" clip-rule="evenodd" d="M39.1551 15.7613L43.4136 14.3719L51.4779 22.8464L39.1551 15.7613Z" fill="#46A7D9"/>
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M57.3504 9.82464L53.0919 11.2141L51.4779 22.8463L57.3504 9.82464Z" fill="white"/>
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M43.4136 14.3719L53.0919 11.2141L51.4779 22.8463L43.4136 14.3719Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

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

Before

Width:  |  Height:  |  Size: 803 B

@@ -117,7 +117,7 @@ export default {
closeWithAction () {
this.close();
setTimeout(() => {
this.$router.push(`/profile/${this.$store.state.user.data._id}#achievements`);
this.$router.push({ name: 'achievements' });
}, 200);
},
},
@@ -1,228 +1,53 @@
<template>
<b-modal
id="rebirth"
size="sm"
:hide-header="true"
:title="$t('modalAchievement')"
size="md"
:hide-footer="true"
>
<div
class="close-x"
@click.stop="close()"
>
<div
class="svg-icon svg-close"
v-html="icons.close"
></div>
</div>
<div class="content text-center">
<h2
v-once
class="header"
>
{{ $t('rebirthNewAchievement') }}
</h2>
<div class="d-flex align-items-center justify-content-center icon-area">
<div
v-once
class="svg-icon sparkles mirror"
v-html="icons.starGroup"
></div>
<Sprite
class="achievement-icon"
image-name="achievement-sun2x"
/>
<div
v-once
class="svg-icon sparkles"
v-html="icons.starGroup"
></div>
<div class="modal-body">
<div class="col-12">
<!-- @TODO: +achievementAvatar('sun',0)--><achievement-avatar class="avatar" />
</div><div class="col-6 offset-3 text-center">
<div v-if="user.achievements.rebirthLevel < 100">
{{ $t('rebirthAchievement', {
number: user.achievements.rebirths,
level: user.achievements.rebirthLevel}) }}
</div><div v-if="user.achievements.rebirthLevel >= 100">
{{ $t('rebirthAchievement100', {number: user.achievements.rebirths}) }}
</div><br><button
class="btn btn-primary"
@click="close()"
>
{{ $t('huzzah') }}
</button>
</div>
<p class="subtitle">
{{ $t('rebirthNewAdventure') }}
</p>
<p
class="description"
v-html="achievementText"
></p>
<p
v-once
class="stack-info"
>
{{ $t('rebirthStackInfo') }}
</p>
<button
v-once
class="btn btn-primary"
@click="close()"
>
{{ $t('onwards') }}
</button>
</div>
<div
slot="modal-footer"
class="footer-wave"
v-html="icons.purpleWaves"
></div>
</div><achievement-footer />
</b-modal>
</template>
<style lang="scss">
@import '@/assets/scss/colors.scss';
#rebirth {
.modal-dialog {
width: 330px;
}
.modal-content {
border: none;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 14px 28px 0 rgba($black, 0.24), 0 10px 10px 0 rgba($black, 0.28);
}
.modal-body {
padding: 0;
}
.modal-footer {
padding: 0;
border-top: none;
border-radius: 0;
margin: 0;
line-height: 0;
}
}
</style>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
.content {
padding: 24px 24px 0;
}
.header {
font-size: 1.25rem;
line-height: 1.4;
color: $purple-200;
margin-top: 8px;
margin-bottom: 16px;
}
.icon-area {
margin-bottom: 16px;
}
.sparkles {
width: 40px;
height: 64px;
&.mirror {
transform: scaleX(-1);
}
}
.close-x {
position: absolute;
right: 16px;
top: 16px;
cursor: pointer;
z-index: 2;
&:hover .svg-close {
opacity: 0.75;
}
.svg-close {
width: 16px;
height: 16px;
opacity: 0.5;
transition: opacity 0.2s ease;
pointer-events: none;
}
}
.achievement-icon {
margin: 0 24px;
}
.subtitle {
font-family: 'Roboto', sans-serif;
font-weight: 700;
font-style: normal;
font-size: 14px;
line-height: 24px;
letter-spacing: 0;
margin-bottom: 12px;
color: $gray-10;
}
.description {
font-size: 0.875rem;
line-height: 1.71;
margin-bottom: 12px;
color: $gray-50;
}
.stack-info {
font-size: 0.875rem;
line-height: 1.71;
color: $gray-50;
margin-bottom: 24px;
}
.btn-primary {
margin-bottom: 8px;
}
.footer-wave {
width: 100%;
::v-deep svg {
display: block;
width: calc(100% + 8px);
height: auto;
margin: 0 -4px -4px;
}
<style scoped>
.avatar {
width: 140px;
margin: 0 auto;
margin-bottom: 1.5em;
margin-top: 1.5em;
}
</style>
<script>
import closeIcon from '@/assets/svg/close.svg?raw';
import Sprite from '@/components/ui/sprite';
import starGroup from '@/assets/svg/star-group.svg?raw';
import purpleWaves from '@/assets/svg/purple-waves.svg?raw';
import achievementFooter from './achievementFooter';
import achievementAvatar from './achievementAvatar';
import { mapState } from '@/libs/store';
export default {
components: {
Sprite,
},
data () {
return {
icons: Object.freeze({
starGroup,
purpleWaves,
close: closeIcon,
}),
};
achievementFooter,
achievementAvatar,
},
computed: {
...mapState({ user: 'user.data' }),
achievementText () {
const rebirths = this.user.achievements.rebirths || 0;
const level = this.user.achievements.rebirthLevel || 0;
if (level >= 100) {
return this.$t('rebirthAchievement100', { number: rebirths, level });
}
if (rebirths === 1) {
return this.$t('rebirthAchievement', { number: rebirths, level });
}
return this.$t('rebirthAchievementPlural', { number: rebirths, level });
},
},
methods: {
close () {
@@ -1,186 +1,41 @@
<template>
<b-modal
id="rebirth-enabled"
size="sm"
:hide-header="true"
:title="$t('rebirthNew')"
size="md"
:hide-footer="true"
>
<div
class="close-x"
@click.stop="close()"
>
<div
class="svg-icon svg-close"
v-html="icons.close"
></div>
</div>
<div class="content text-center">
<h2
v-once
class="header"
>
{{ $t('rebirthUnlockedNewItem') }}
</h2>
<div class="d-flex align-items-center justify-content-center icon-area">
<div
v-once
class="svg-icon sparkles mirror"
v-html="icons.starGroup"
></div>
<img
class="orb-icon"
src="@/assets/images/rebirth-orb.png"
alt="Orb of Rebirth"
>
<div
v-once
class="svg-icon sparkles"
v-html="icons.starGroup"
></div>
<div class="modal-body">
<div class="col-12">
<div class="rebirth_orb"></div>
<p>
<span>{{ $t('rebirthUnlock') }}</span>
</p>
</div>
</div>
<div class="modal-footer">
<div class="col-12 text-center">
<button
class="btn btn-primary"
@click="close()"
>
{{ $t('close') }}
</button>
</div>
<p
v-once
class="subtitle"
>
{{ $t('rebirthUnlockedOrb') }}
</p>
<p
v-once
class="description"
>
{{ $t('rebirthUnlockedDesc') }}
</p>
<button
v-once
class="btn btn-primary"
@click="close()"
>
{{ $t('onwards') }}
</button>
</div>
<div
slot="modal-footer"
class="clearfix"
></div>
</b-modal>
</template>
<style lang="scss">
@import '@/assets/scss/colors.scss';
#rebirth-enabled {
.modal-dialog {
width: 330px;
}
.modal-content {
border-radius: 8px;
overflow: hidden;
box-shadow: 0 14px 28px 0 rgba($black, 0.24), 0 10px 10px 0 rgba($black, 0.28);
}
.modal-body {
padding: 0;
}
.modal-footer {
padding: 0;
border-top: none;
}
}
</style>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
.content {
padding: 24px 24px 0;
}
.header {
font-size: 1.25rem;
line-height: 1.4;
color: $purple-200;
margin-top: 8px;
margin-bottom: 12px;
}
.icon-area {
margin-bottom: 12px;
}
.sparkles {
width: 40px;
height: 64px;
&.mirror {
transform: scaleX(-1);
}
}
.close-x {
position: absolute;
right: 16px;
top: 16px;
cursor: pointer;
z-index: 2;
&:hover .svg-close {
opacity: 0.75;
}
.svg-close {
width: 16px;
height: 16px;
opacity: 0.5;
transition: opacity 0.2s ease;
pointer-events: none;
}
}
.orb-icon {
width: 62px;
height: 62px;
margin: 0 24px;
image-rendering: pixelated;
}
.subtitle {
font-family: 'Roboto', sans-serif;
font-weight: 700;
font-style: normal;
font-size: 14px;
line-height: 24px;
letter-spacing: 0;
margin-bottom: 12px;
color: $gray-50;
}
.description {
font-size: 0.875rem;
line-height: 1.71;
margin-bottom: 24px;
color: $gray-100;
}
.btn-primary {
margin-bottom: 24px;
<style scoped>
.rebirth_orb {
margin: 0 auto;
}
</style>
<script>
import closeIcon from '@/assets/svg/close.svg?raw';
import starGroup from '@/assets/svg/star-group.svg?raw';
import { mapState } from '@/libs/store';
export default {
data () {
return {
icons: Object.freeze({
starGroup,
close: closeIcon,
}),
};
},
computed: {
...mapState({ user: 'user.data' }),
},
@@ -81,7 +81,7 @@ export default {
watch: {
userIdentifier () {
this.isSearching = true;
this.$store.dispatch('admin:searchUsers', { userIdentifier: this.userIdentifier }).then(users => {
this.$store.dispatch('adminPanel:searchUsers', { userIdentifier: this.userIdentifier }).then(users => {
this.isSearching = false;
if (users.length === 1) {
this.loadUser(users[0]._id);
@@ -5,12 +5,6 @@
class="row"
>
<div class="form col-12">
<button
class="btn btn-danger mt-3 float-right"
@click="confirmDeleteHero"
>
Begin Member deletion
</button>
<basic-details
:user-id="hero._id"
:auth="hero.auth"
@@ -102,53 +96,6 @@
:reset-counter="resetCounter"
@clear-data="clearData"
/>
<b-modal
id="delete-member-modal"
title="Delete Member"
ok-title="Delete"
ok-variant="danger"
cancel-title="Cancel"
@ok="deleteHero"
>
<b-modal-body>
<p>
Are you sure you want to delete this member?
</p>
<p class="errorMessage">
Please note: This action cannot be undone!
</p>
<div class="ml-4">
<div class="form-check">
<input
id="deleteAccountCheck"
v-model="deleteHabiticaAccount"
class="form-check-input"
type="checkbox"
>
<label
class="form-check-label"
for="deleteAccountCheck"
>
Delete Habitica account
</label>
</div>
<div class="form-check">
<input
id="deleteAmplitudeCheck"
v-model="deleteAmplitudeData"
class="form-check-input"
type="checkbox"
>
<label
class="form-check-label"
for="deleteAmplitudeCheck"
>
Delete Amplitude data
</label>
</div>
</div>
</b-modal-body>
</b-modal>
</div>
</div>
</div>
@@ -237,8 +184,6 @@ export default {
hasParty: false,
partyNotExistError: false,
adminHasPrivForParty: true,
deleteHabiticaAccount: true,
deleteAmplitudeData: true,
};
},
watch: {
@@ -304,25 +249,6 @@ export default {
this.resetCounter += 1; // tell child components to reinstantiate from scratch
},
confirmDeleteHero () {
if (this.hero._id === this.user._id) {
window.alert('You cannot delete your own account.');
return;
}
this.$root.$emit('bv::show::modal', 'delete-member-modal');
},
deleteHero () {
this.$store.dispatch('hall:deleteHero', {
uuid: this.hero._id,
deleteHabiticaAccount: this.deleteHabiticaAccount,
deleteAmplitudeData: this.deleteAmplitudeData,
}).then(() => {
this.$root.$emit('bv::hide::modal', 'delete-member-modal');
this.$router.push({ name: 'adminPanel' });
}).catch(err => {
window.alert(err);
});
},
hasUnsavedChanges (...comparisons) {
for (const index in comparisons) {
if (index && comparisons[index]) {
@@ -37,11 +37,7 @@
Party ID
</label>
<strong class="col-sm-9 col-form-label">
<router-link
:to="{'name': 'groupAdminGroup', 'params': {'groupId': groupPartyData._id}}"
>
{{ groupPartyData._id }}
</router-link>
{{ groupPartyData._id }}
</strong>
</div>
<div class="form-group row">
@@ -15,25 +15,6 @@
:class="{ 'open': expand }"
>
Subscription, Monthly Perks
<span
v-if="isSubscribed() && !isCancelled()"
class="text-success float-right ml-3"
>
Active
</span>
<span
v-else-if="isSubscribed() && isCancelled()"
class="text-success float-right ml-3"
>
Active until {{ dateFormat(hero.purchased.plan.dateTerminated) }}
</span>
<span
v-else-if="hero.purchased.plan.customerId && hero.purchased.plan.dateTerminated"
class="text-warning float-right ml-3"
>
Inactive
</span>
<b
v-if="hasUnsavedChanges && !expand"
class="text-warning float-right"
@@ -65,7 +46,7 @@
class="form-control"
type="text"
>
<option value="Group Plan">
<option value="groupPlan">
Group Plan
</option>
<option value="Stripe">
@@ -173,11 +154,7 @@
>
<div class="card-body">
<h6 class="card-title">
<router-link
:to="{ name: 'groupAdminGroup', params: { groupId: group._id } }"
>
{{ group.name }}
</router-link>
{{ group.name }}
<small class="float-right">{{ group._id }}</small>
</h6>
<p class="card-text">
@@ -268,7 +245,8 @@
</div>
</div>
<small
v-if="isSubscribed() && !isCancelled()"
v-if="!hero.purchased.plan.dateTerminated
&& hero.purchased.plan.planId"
class="text-success"
>
The subscription does not have a termination date and is active.
@@ -441,79 +419,6 @@
>
</div>
</div>
<div class="form-group row">
<h2>Payment Details</h2>
</div>
<div class="form-group row">
<div class="offset-sm-3 col-sm-9 mb-3">
<button
type="button"
class="btn btn-secondary btn-sm"
@click="getSubscriptionPaymentDetails"
>
Get Subscription Payment Details
</button>
</div>
</div>
<div
v-if="paymentDetails"
>
<div
v-for="(value, key) in paymentDetails"
:key="key"
class="form-group row"
>
<label class="col-sm-3 col-form-label">
{{ getHumanReadablePaymentDetails(key).label }}:
<span
:id="`${key}_tooltip`"
v-b-tooltip.hover.right="getHumanReadablePaymentDetails(key).help"
class="info-icon"
>?</span>
</label>
<strong class="col-sm-9 col-form-label">
<span v-if="value === true">Yes</span>
<span v-else-if="value === false">No</span>
<span
v-else-if="value instanceof String && isDate(value)"
v-b-tooltip.hover="value"
>
{{ formatDate(value) }}
</span>
<span v-else-if="value === null">---</span>
<span v-else>{{ value }}</span>
</strong>
</div>
<div class="form-group row">
<div class="offset-sm-3 col-sm-9">
<a
v-if="hero.purchased.plan.paymentMethod === 'Google'"
class="btn btn-primary btn-sm"
target="_blank"
:href="playOrdersUrl"
>
Play Console
</a>
<a
v-else-if="hero.purchased.plan.paymentMethod === 'Paypal'"
class="btn btn-primary btn-sm"
target="_blank"
:href="'https://www.paypal.com/billing/subscriptions/' + paymentDetails.customerId"
>
PayPal Dashboard
</a>
<a
v-else-if="hero.purchased.plan.paymentMethod === 'Stripe'"
class="btn btn-primary btn-sm"
target="_blank"
:href="'https://dashboard.stripe.com/customers/' + paymentDetails.customerId"
>
Stripe Dashboard
</a>
</div>
</div>
</div>
</div>
<div
v-if="expand"
@@ -569,36 +474,17 @@
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
.form-group {
margin-bottom: 0.4rem;
}
.input-group-append {
width: auto;
.input-group-append {
width: auto;
.input-group-text {
border-bottom-right-radius: 2px;
border-top-right-radius: 2px;
font-weight: 600;
font-size: 0.8rem;
color: $gray-200;
}
}
.info-icon {
.input-group-text {
border-bottom-right-radius: 2px;
border-top-right-radius: 2px;
font-weight: 600;
font-size: 0.8rem;
color: $purple-400;
cursor: pointer;
margin-left: 0.2rem;
background-color: $gray-500;
padding: 0.1rem 0.3rem;
border-radius: 0.2rem;
}
.info-icon:hover {
background-color: $purple-400;
color: white;
color: $gray-200;
}
}
</style>
<script>
@@ -609,55 +495,6 @@ import subscriptionBlocks from '@/../../common/script/content/subscriptionBlocks
import saveHero from '../mixins/saveHero';
import LoadingSpinner from '@/components/ui/loadingSpinner';
const PLAY_CONSOLE_ORDERS_BASE_URL = import.meta.env.PLAY_CONSOLE_ORDERS_BASE_URL;
const humanReadablePaymentDetails = {
customerId: {
label: 'Customer ID',
help: 'The unique identifier for the customer in the payment system.',
},
purchaseDate: {
label: 'Purchase Date',
help: 'The date when the subscription was purchased or renewed.',
},
originalPurchaseDate: {
label: 'Original Purchase Date',
help: 'The date when the subscription was first purchased.',
},
productId: {
label: 'Product ID',
help: 'The identifier for the product associated with the subscription.',
},
transactionId: {
label: 'Transaction ID',
help: 'The unique identifier for the last transaction in the payment system.',
},
isCanceled: {
label: 'Is Canceled',
help: 'Indicates whether the subscription has been canceled by the user or the system.',
},
isExpired: {
label: 'Is Expired',
help: 'Indicates whether the subscription has expired. A cancelled subscription may still be active until the end of the billing cycle.',
},
expirationDate: {
label: 'Termination Date',
help: 'The date when the subscription will expire or has expired.',
},
nextPaymentDate: {
label: 'Next Payment Date',
help: 'The date when the next payment is due. If the subscription is canceled or expired, this may be null.',
},
lastPaymentDate: {
label: 'Last Payment Date',
help: 'The date when the lastpayment was made for the subscription.',
},
failedPayments: {
label: 'Failed Payments',
help: 'Number of times the payment failed for this subscription.',
},
};
export default {
components: {
LoadingSpinner,
@@ -683,7 +520,6 @@ export default {
isConvertingToGroupPlan: false,
groupPlanID: '',
subscriptionBlocks,
paymentDetails: null,
};
},
computed: {
@@ -717,9 +553,6 @@ export default {
}
return terminationDate;
},
playOrdersUrl () {
return `${PLAY_CONSOLE_ORDERS_BASE_URL}${this.paymentDetails?.transactionId || ''}`;
},
},
methods: {
dateFormat (date) {
@@ -750,20 +583,6 @@ export default {
this.isConvertingToGroupPlan = true;
this.hero.purchased.plan.owner = '';
},
getSubscriptionPaymentDetails () {
this.$store.dispatch('admin:getSubscriptionPaymentDetails', { userIdentifier: this.hero._id })
.then(details => {
if (details) {
this.paymentDetails = details;
} else {
alert('No payment details found.');
}
})
.catch(error => {
console.error('Error fetching subscription payment details:', error);
alert(`Failed to fetch payment details: ${error.message || 'Unknown error'}`);
});
},
saveClicked (e) {
e.preventDefault();
if (this.isConvertingToGroupPlan) {
@@ -782,31 +601,6 @@ export default {
this.$emit('changeUserIdentifier', id);
}
},
getHumanReadablePaymentDetails (key) {
return humanReadablePaymentDetails[key] || { label: key, help: '' };
},
isDate (date) {
return moment(date).isValid();
},
formatDate (date) {
return date ? moment(date).format('MM/DD/YYYY') : '---';
},
isSubscribed () {
console.log(this.hero.purchased.plan.customerId, this.hero.purchased.plan.dateTerminated);
return this.hero.purchased.plan
&& this.hero.purchased.plan.customerId
&& this.hero.purchased.plan.planId
&& this.hero.purchased.plan.paymentMethod
&& (
!this.hero.purchased.plan.dateTerminated
|| moment(this.hero.purchased.plan.dateTerminated).isAfter(moment())
);
},
isCancelled () {
return this.hero.purchased.plan
&& this.hero.purchased.plan.dateTerminated
&& this.hero.purchased.plan.dateTerminated !== '';
},
},
};
</script>
@@ -226,7 +226,7 @@ export default {
}
},
async retrieveUserHistory () {
const history = await this.$store.dispatch('admin:getUserHistory', { userIdentifier: this.hero._id });
const history = await this.$store.dispatch('adminPanel:getUserHistory', { userIdentifier: this.hero._id });
this.armoire = history.armoire;
this.questInviteResponses = history.questInviteResponses;
this.cron = history.cron;
@@ -8,13 +8,6 @@
>
{{ $t('adminPanel') }}
</router-link>
<router-link
v-if="hasPermission(user, 'groupSupport')"
class="nav-link"
:to="{name: 'groupAdmin'}"
>
{{ $t('groupAdmin') }}
</router-link>
<router-link
v-if="hasPermission(user, 'accessControl')"
class="nav-link"
@@ -1,47 +0,0 @@
<template>
<div class="form-group row">
<label class="col-sm-3 col-form-label"><slot name="label">{{ label }}</slot></label>
<div class="col-sm-9">
<slot>
<textarea
v-if="inputType === 'textarea'"
:value="value"
class="form-control"
:rows="rows"
@input="$emit('input', $event.target.value)"
></textarea>
<input
v-else
:value="value"
class="form-control"
:type="inputType"
@input="$emit('input', $event.target.value)"
>
</slot>
</div>
</div>
</template>
<script>
export default {
model: {
prop: 'value',
event: 'input',
},
props: {
label: {
type: String,
},
value: {
type: [String, Boolean],
},
inputType: {
type: String,
default: 'text',
},
rows: {
default: 3,
},
},
};
</script>
@@ -1,45 +0,0 @@
<template>
<form>
<form-row
v-model="group.name"
:label="$t('groupName')"
/>
<form-row
v-model="group.summary"
:label="$t('guildSummary')"
input-type="textarea"
/>
<form-row
v-model="group.description"
:label="$t('groupDescription')"
input-type="textarea"
rows="6"
/>
<form-row
v-model="group.bannedWordsAllowed"
:label="$t('bannedWordsAllowed')"
input-type="checkbox"
/>
<form-row
v-model="group.leaderOnly.challenges"
:label="$t('leaderOnlyChallenges')"
input-type="checkbox"
/>
</form>
</template>
<script>
import formRow from '@/components/admin/formRow.vue';
export default {
components: {
formRow,
},
props: {
group: {
type: Object,
required: true,
},
},
};
</script>
@@ -1,69 +0,0 @@
<template>
<div v-if="hasPermission(user, 'groupSupport')">
<h2>{{ group.name }}</h2>
<supportContainer
:title="$t('groupData')"
>
<groupData
:group="group"
/>
</supportContainer>
<supportContainer
:title="$t('groupPlanSubscription')"
/>
<supportContainer
v-if="group.type === 'party'"
:title="$t('questDetails')"
/>
<supportContainer
:title="$t('members')"
>
<members
:group="group"
/>
</supportContainer>
</div>
</template>
<script>
import { userStateMixin } from '../../../../mixins/userState';
import supportContainer from '../../supportContainer.vue';
import groupData from './groupData.vue';
import members from './members.vue';
export default {
components: {
supportContainer,
groupData,
members,
},
mixins: [userStateMixin],
data () {
return {
groupId: '',
group: {},
};
},
watch: {
groupId () {
this.loadGroup(this.groupId);
},
},
mounted () {
this.groupId = this.$route.params.groupId;
},
methods: {
clearData () {
this.group = {};
},
async loadGroup (groupId) {
this.$emit('changeGroupId', groupId);
this.group = await this.$store.dispatch('admin:getGroup', { groupId });
},
async updateGroup () {
await this.$store.dispatch('admin:updateGroup', { group: this.group });
this.$emit('groupSaved', this.group);
},
},
};
</script>
@@ -1,29 +0,0 @@
<template>
<form-row
:label="$t('groupLeader')"
>
<strong class="col-form-label">
<router-link
:to="{'name': 'adminPanelUser', 'params': {'userIdentifier': group.leader }}"
>
{{ group.leader }}
</router-link>
</strong>
</form-row>
</template>
<script>
import formRow from '@/components/admin/formRow.vue';
export default {
components: {
formRow,
},
props: {
group: {
type: Object,
required: true,
},
},
};
</script>
@@ -1,93 +0,0 @@
<template>
<div class="row standard-page col-12 d-flex justify-content-center">
<div class="group-admin-content">
<h1>{{ $t("groupAdmin") }}</h1>
<form
class="form-inline"
@submit.prevent="loadGroup(groupID)"
>
<div class="input-group col pl-0 pr-0">
<input
v-model="groupID"
class="form-control"
type="text"
placeholder="Group ID"
>
<div class="input-group-append">
<button
class="btn btn-primary"
type="button"
:disabled="!groupID"
@click="loadGroup(groupID)"
>
Load
</button>
</div>
</div>
</form>
<router-view
class="mt-3"
@changeGroupId="changeGroupId"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.uidField {
min-width: 45ch;
}
.input-group-append {
width:auto;
}
.group-admin-content {
flex: 0 0 800px;
max-width: 800px;
}
</style>
<script>
import VueRouter from 'vue-router';
import { mapState } from '@/libs/store';
const { isNavigationFailure, NavigationFailureType } = VueRouter;
export default {
data () {
return {
groupID: '',
};
},
computed: {
...mapState({ user: 'user.data' }),
},
mounted () {
this.$store.dispatch('common:setTitle', {
section: this.$t('groupAdmin'),
});
},
methods: {
changeGroupId (id) {
this.groupID = id;
},
async loadGroup (groupId) {
if (this.$router.currentRoute.name === 'groupAdminGroup') {
await this.$router.push({
name: 'groupAdmin',
});
}
await this.$router.push({
name: 'groupAdminGroup',
params: { groupId },
}).catch(failure => {
if (isNavigationFailure(failure, NavigationFailureType.duplicated)) {
this.$router.go();
}
});
},
},
};
</script>
@@ -1,53 +0,0 @@
<template>
<div class="card mt-2">
<div class="card-header">
<h3
class="mb-0 mt-0"
:class="{'open': expand}"
@click="expand = !expand"
>
<slot name="title">
{{ title }}
</slot>
</h3>
</div>
<div
v-if="expand"
class="card-body"
>
<slot></slot>
</div>
<div
v-if="expand && onSave"
class="card-footer"
>
<button
class="btn btn-primary mt-1"
@click="onSave"
>
{{ $t('save') }}
</button>
</div>
</div>
</template>
<script>
export default {
props: {
title: {
type: String,
required: false,
},
onSave: {
type: Function,
required: false,
},
},
data () {
return {
expand: false,
};
},
};
</script>
File diff suppressed because it is too large Load Diff
@@ -311,7 +311,7 @@
<input
id="passwordInput"
v-model="password"
class="form-control dark input-with-error"
class="form-control input-with-error"
type="password"
:placeholder="$t('password')"
:class="{'input-invalid': passwordInvalid, 'input-valid': passwordValid}"
@@ -323,7 +323,7 @@
{{ $t('minPasswordLength') }}
</div>
</div>
<div class="form-group mb-4">
<div class="form-group">
<label
v-once
for="confirmPasswordInput"
@@ -331,7 +331,7 @@
<input
id="confirmPasswordInput"
v-model="passwordConfirm"
class="form-control dark input-with-error"
class="form-control input-with-error"
type="password"
:placeholder="$t('confirmPasswordPlaceholder')"
:class="{'input-invalid': passwordConfirmInvalid, 'input-valid': passwordConfirmValid}"
@@ -344,14 +344,13 @@
</div>
</div>
<div class="text-center">
<button
class="btn btn-info w-100"
:disabled="!password || !passwordConfirm
|| password !== passwordConfirm || resetPasswordSetNewOneData.hasError"
<div
class="btn btn-info"
:enabled="!resetPasswordSetNewOneData.hasError"
@click="resetPasswordSetNewOneLink()"
>
{{ $t('setNewPass') }}
</button>
</div>
</div>
</form>
</div>
@@ -673,7 +672,7 @@ export default {
this.login();
},
forgotPasswordLink: debounce(async function forgotPassLink () {
async forgotPasswordLink () {
if (!this.username) {
window.alert(this.$t('missingEmail')); // eslint-disable-line no-alert
return;
@@ -684,7 +683,7 @@ export default {
});
window.alert(this.$t('newPassSent')); // eslint-disable-line no-alert
}, 500),
},
async resetPasswordSetNewOneLink () {
if (!this.password) {
window.alert(this.$t('missingNewPassword')); // eslint-disable-line no-alert
+4 -5
View File
@@ -321,11 +321,10 @@ export default {
return null;
},
petClass () {
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);
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);
}
if (this.member?.items.currentPet) return `Pet-${this.member.items.currentPet}`;
return '';
@@ -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,
});
},
@@ -7,6 +7,7 @@
@update-challenge="updateChallenge"
/>
<close-challenge-modal
:members="members"
:challenge-id="challenge._id"
:prize="challenge.prize"
:flag-count="challenge.flagCount"
@@ -71,40 +72,32 @@
</div>
<div class="col-12 col-md-6 text-right">
<div
class="box member-count p-2"
class="box member-count"
@click="showMemberModal()"
>
<div class="box-content">
<div class="icon-number-row">
<div
class="svg-icon member-icon"
v-html="icons.memberIcon"
></div>
<span class="number">{{ challenge.memberCount }}</span>
</div>
<div
v-once
class="details"
>
{{ $t('participantsTitle') }}
</div>
<div
class="svg-icon member-icon"
v-html="icons.memberIcon"
></div>
{{ challenge.memberCount }}
<div
v-once
class="details"
>
{{ $t('participantsTitle') }}
</div>
</div>
<div class="box prize-count p-2">
<div class="box-content">
<div class="icon-number-row">
<div
class="svg-icon gem-icon"
v-html="icons.gemIcon"
></div>
<span class="number">{{ challenge.prize || 0 }}</span>
</div>
<div
v-once
class="details"
>
{{ $t('prize') }}
</div>
<div class="box">
<div
class="svg-icon gem-icon"
v-html="icons.gemIcon"
></div>
{{ challenge.prize || 0 }}
<div
v-once
class="details"
>
{{ $t('prize') }}
</div>
</div>
</div>
@@ -311,6 +304,7 @@
.box {
display: inline-block;
padding: 1em;
border-radius: 2px;
background-color: $white;
box-shadow: 0 2px 2px 0 rgba(26, 24, 29, 0.16), 0 1px 4px 0 rgba(26, 24, 29, 0.12);
@@ -320,88 +314,22 @@
text-align: center;
font-size: 20px;
vertical-align: bottom;
overflow: hidden;
position: relative;
&.member-count:hover {
cursor: pointer;
}
.box-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
}
.icon-number-row {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.1em;
.number {
font-size: 20px;
font-weight: normal;
margin-left: 0.2em;
}
}
.svg-icon {
width: 30px;
display: inline-block;
margin-right: .2em;
vertical-align: bottom;
}
.details {
font-size: 12px;
margin-top: 0.4em;
color: $gray-200;
width: 100%;
padding: 0 4px;
line-height: 1.15;
word-break: break-word;
max-height: 2.3em;
overflow: visible;
}
&.member-count {
.icon-number-row {
.svg-icon {
width: 24px;
height: 24px;
}
.number {
font-size: 18px;
}
}
.details {
font-size: 11px;
line-height: 1.1;
max-height: 2.2em;
}
}
&.prize-count {
.icon-number-row {
.svg-icon {
width: 24px;
height: 24px;
}
.number {
font-size: 18px;
}
}
.details {
font-size: 11px;
line-height: 1.1;
max-height: 2.2em;
}
}
}
@@ -696,6 +624,7 @@ export default {
this.members = [];
},
closeChallenge () {
this.initialMembersLoad();
this.$root.$emit('bv::show::modal', 'close-challenge-modal');
},
edit () {
@@ -12,39 +12,23 @@
<label>
<strong v-once>{{ $t('name') }} *</strong>
</label>
<input
ref="nameInput"
<b-form-input
v-model="workingChallenge.name"
class="form-control"
type="text"
:placeholder="$t('challengeNamePlaceholder')"
@focus="setActiveField('name')"
@keydown="onFieldKeydown($event)"
@keydown.tab="autoCompleteMixinHandleTab($event)"
@keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)"
@keydown.down="autoCompleteMixinSelectNextAutocomplete($event)"
@keypress.enter="autoCompleteMixinSelectAutocomplete($event)"
@keydown.esc="autoCompleteMixinHandleEscape($event)"
>
@keydown="enableSubmit"
/>
</div>
<div class="form-group">
<label>
<strong v-once>{{ $t('shortName') }} *</strong>
</label>
<input
ref="shortNameInput"
<b-form-input
v-model="workingChallenge.shortName"
class="form-control"
type="text"
:placeholder="$t('shortNamePlaceholder')"
@focus="setActiveField('shortName')"
@keydown="onFieldKeydown($event)"
@keydown.tab="autoCompleteMixinHandleTab($event)"
@keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)"
@keydown.down="autoCompleteMixinSelectNextAutocomplete($event)"
@keypress.enter="autoCompleteMixinSelectAutocomplete($event)"
@keydown.esc="autoCompleteMixinHandleEscape($event)"
>
@keydown="enableSubmit"
/>
</div>
<div class="form-group">
<label>
@@ -56,17 +40,10 @@
{{ $t('charactersRemaining', {characters: charactersRemaining}) }}
</div>
<textarea
ref="summaryTextarea"
v-model="workingChallenge.summary"
class="summary-textarea form-control"
:placeholder="$t('challengeSummaryPlaceholder')"
@focus="setActiveField('summary')"
@keydown="onFieldKeydown($event)"
@keydown.tab="autoCompleteMixinHandleTab($event)"
@keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)"
@keydown.down="autoCompleteMixinSelectNextAutocomplete($event)"
@keypress.enter="autoCompleteMixinSelectAutocomplete($event)"
@keydown.esc="autoCompleteMixinHandleEscape($event)"
@keydown="enableSubmit"
></textarea>
</div>
<div class="form-group">
@@ -78,26 +55,11 @@
class="float-right"
></a>
<textarea
ref="descriptionTextarea"
v-model="workingChallenge.description"
class="description-textarea form-control"
:placeholder="$t('challengeDescriptionPlaceholder')"
@focus="setActiveField('description')"
@keydown="onFieldKeydown($event)"
@keydown.tab="autoCompleteMixinHandleTab($event)"
@keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)"
@keydown.down="autoCompleteMixinSelectNextAutocomplete($event)"
@keypress.enter="autoCompleteMixinSelectAutocomplete($event)"
@keydown.esc="autoCompleteMixinHandleEscape($event)"
@keydown="enableSubmit"
></textarea>
<emoji-auto-complete
ref="emojiAutocomplete"
:text="activeFieldText"
:textbox="textbox"
:coords="mixinData.autoComplete.coords"
:caret-position="mixinData.autoComplete.caretPosition"
@select="selectedAutocomplete"
/>
</div>
<div
v-if="creating"
@@ -318,17 +280,12 @@ import { TAVERN_ID, MIN_SHORTNAME_SIZE_FOR_CHALLENGES, MAX_SUMMARY_SIZE_FOR_CHAL
import CategoryOptions from '@/../../common/script/content/categoryOptions';
import markdownDirective from '@/directives/markdown';
import { userStateMixin } from '../../mixins/userState';
import emojiAutoComplete from '@/components/chat/emojiAutoComplete';
import { autoCompleteHelperMixin } from '@/mixins/autoCompleteHelper';
export default {
components: {
emojiAutoComplete,
},
directives: {
markdown: markdownDirective,
},
mixins: [userStateMixin, autoCompleteHelperMixin],
mixins: [userStateMixin],
props: ['groupId'],
data () {
const categoryOptions = CategoryOptions;
@@ -362,14 +319,9 @@ export default {
categoriesHashByKey,
loading: false,
groups: [],
textbox: null,
activeField: 'name',
};
},
computed: {
activeFieldText () {
return this.workingChallenge[this.activeField] || '';
},
creating () {
return !this.workingChallenge.id;
},
@@ -637,29 +589,6 @@ export default {
toggleCategorySelect () {
this.showCategorySelect = !this.showCategorySelect;
},
setActiveField (field) {
this.activeField = field;
const refMap = {
name: 'nameInput',
shortName: 'shortNameInput',
summary: 'summaryTextarea',
description: 'descriptionTextarea',
};
this.textbox = this.$refs[refMap[field]] || null;
},
onFieldKeydown (e) {
this.enableSubmit();
this.autoCompleteMixinUpdateCarretPosition(e);
},
selectedAutocomplete (newText, newCaret) {
this.workingChallenge[this.activeField] = newText;
this.$nextTick(() => {
if (this.textbox) {
this.textbox.setSelectionRange(newCaret, newCaret);
this.textbox.focus();
}
});
},
enableSubmit: throttle(function enableSubmit () {
/* Enables the submit button if it was disabled */
if (this.loading) {
@@ -4,7 +4,6 @@
id="close-challenge-modal"
:title="$t('endChallenge')"
size="md"
:hide-header="false"
>
<div
slot="modal-header"
@@ -16,9 +15,6 @@
>
{{ $t('endChallenge') }}
</h2>
<close-x
@close="$root.$emit('bv::hide::modal', 'close-challenge-modal')"
/>
</div>
<div class="row text-center">
<span
@@ -32,67 +28,28 @@
class="col-12"
>
<div class="col-12">
<div class="badge-section">
<div
class="gems-left"
v-html="icons.gemsOrange"
></div>
<div
class="challenge-badge"
v-html="icons.endChallengeBadge"
></div>
<div
class="gems-right"
v-html="icons.gemsPurple"
></div>
<div class="support-habitica">
<!-- @TODO: Add challenge achievement badge here-->
</div>
</div>
<div class="col-12">
<strong v-once>{{ $t('selectChallengeWinnersDescription') }}</strong>
</div>
<div class="col-12 search-input-container">
<div class="search-input-wrapper">
<div
class="search-icon"
v-html="icons.search"
></div>
<input
v-model="searchTerm"
class="search-input"
type="text"
placeholder="@Username"
@input="searchMembers"
@focus="showResults = true"
@blur="handleBlur"
>
<div
v-if="showResults && filteredMembers.length > 0"
class="search-results"
>
<div
v-for="member in filteredMembers"
:key="member._id"
class="search-result-item"
@mousedown="selectMember(member)"
>
{{ getMemberDisplayName(member) }}
</div>
</div>
</div>
<div class="col-12">
<member-search-dropdown
:text="winnerText"
:members="members"
:challenge-id="challengeId"
@member-selected="selectMember"
/>
</div>
<div class="col-12">
<button
class="btn award-winner-btn"
:class="{'has-winner': winner._id}"
:disabled="!winner._id"
v-once
class="btn btn-primary"
@click="closeChallenge"
>
<span>{{ $t('awardWinners') }}</span>
<div
class="gem-icon"
v-html="icons.gem"
></div>
<span>{{ prize }} {{ prize === 1 ? $t('gem') : $t('gems') }}</span>
{{ $t('awardWinners') }}
</button>
</div>
</span>
@@ -103,27 +60,14 @@
</div>
</div>
<div class="col-12">
<strong
v-once
class="delete-challenge-text"
>{{ $t('doYouWantedToDeleteChallenge') }}</strong>
</div>
<div
v-once
class="col-12 refund-text"
>
{{ $t('deleteChallengeRefundDescription') }}
<strong v-once>{{ $t('doYouWantedToDeleteChallenge') }}</strong>
</div>
<div class="col-12">
<button
v-once
class="btn btn-danger delete-challenge-btn"
class="btn btn-danger"
@click="deleteChallenge()"
>
<div
class="svg-icon color delete-icon"
v-html="icons.deleteIcon"
></div>
{{ $t('deleteChallenge') }}
</button>
</div>
@@ -138,7 +82,6 @@
<style lang='scss'>
@import '@/assets/scss/colors.scss';
@import '@/assets/scss/button.scss';
#close-challenge-modal {
h2 {
@@ -151,190 +94,26 @@
.header-wrap {
width: 100%;
padding-top: 32px;
position: relative;
padding-top: 2em;
}
.modal-close {
position: absolute;
right: 16px;
top: 16px;
padding: 0;
margin: 0;
}
.search-input-container {
margin-top: 1em !important;
}
.search-input-wrapper {
position: relative;
width: 384px;
.support-habitica {
background-image: url('@/assets/svg/for-css/support-habitica-gems.svg?raw');
width: 325px;
height: 89px;
margin: 0 auto;
.search-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-55%);
width: 16px;
height: 16px;
color: $gray-200;
pointer-events: none;
display: flex;
align-items: center;
}
.search-input {
width: 100%;
height: 32px;
padding-left: 36px;
padding-right: 12px;
border: 1px solid $gray-400;
border-radius: 4px;
font-size: 14px;
transition: border-color 0.2s ease, border-width 0.2s ease;
&:focus {
outline: none;
border: 2px solid $purple-400;
}
&::placeholder {
color: $gray-300;
}
}
.search-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: $white;
border: 1px solid $gray-400;
border-top: none;
border-radius: 0 0 4px 4px;
max-height: 200px;
overflow-y: auto;
z-index: 1000;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
.search-result-item {
padding: 8px 16px;
cursor: pointer;
transition: all 0.2s ease;
text-align: left;
&:hover {
background-color: $purple-600;
color: $purple-300;
}
}
}
}
.delete-challenge-text {
color: $maroon-50;
}
.refund-text {
font-family: 'Roboto', sans-serif;
font-size: 14px;
line-height: 24px;
font-weight: 400;
color: $gray-50;
margin-top: 0.5em !important;
}
.delete-challenge-btn {
font-family: 'Roboto', sans-serif;
font-size: 14px;
font-weight: 700;
line-height: 24px;
display: inline-flex;
align-items: center;
gap: 8px;
.delete-icon {
width: 16px;
height: 16px;
display: inline-flex;
}
}
.award-winner-btn {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 32px;
padding: 4px 12px;
transition: all 0.2s ease;
&:not(:disabled) {
background-color: $white;
color: $gray-200;
border: 1px solid $gray-400;
box-shadow: 0 2px 2px 0 rgba(26, 24, 29, 0.16), 0 1px 4px 0 rgba(26, 24, 29, 0.12);
&.has-winner {
background-color: $purple-200;
color: $white;
border-color: $purple-200;
}
&:hover:not(.has-winner) {
background-color: $gray-700;
}
}
.gem-icon {
width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
color: $gems-color;
}
}
.badge-section {
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
margin: -24px auto 0;
padding: 0.5rem 0;
.gems-left, .gems-right {
width: 64px;
height: 64px;
flex-shrink: 0;
}
.challenge-badge {
width: 48px;
height: 52px;
flex-shrink: 0;
}
}
.modal-footer, .modal-header {
border: none !important;
}
.modal-header {
padding: 0 !important;
}
.footer-wrap {
display: none;
}
.col-12 {
margin-top: 1.5em;
}
.col-12:first-child {
margin-top: 0;
margin-top: 2em;
}
.or {
@@ -344,41 +123,21 @@
margin-right: auto;
margin-left: auto;
font-weight: bold;
color: $gray-100;
}
}
</style>
<script>
import debounce from 'lodash/debounce';
import searchIcon from '@/assets/svg/for-css/search.svg?raw';
import deleteIcon from '@/assets/svg/delete.svg?raw';
import gemIcon from '@/assets/svg/gem.svg?raw';
import endChallengeBadge from '@/assets/svg/for-css/end_challenge_badge.svg?raw';
import gemsOrange from '@/assets/svg/for-css/orange100_red100_yellow100_gems.svg?raw';
import gemsPurple from '@/assets/svg/for-css/purple200_green10_blue100_gems.svg?raw';
import closeX from '@/components/ui/closeX';
import memberSearchDropdown from '@/components/members/memberSearchDropdown';
export default {
components: {
closeX,
memberSearchDropdown,
},
props: ['challengeId', 'prize', 'flagCount'],
props: ['challengeId', 'members', 'prize', 'flagCount'],
data () {
return {
winner: {},
searchTerm: '',
showResults: false,
filteredMembers: [],
isSearching: false,
icons: Object.freeze({
search: searchIcon,
deleteIcon,
gem: gemIcon,
endChallengeBadge,
gemsOrange,
gemsPurple,
}),
};
},
computed: {
@@ -390,58 +149,9 @@ export default {
return this.flagCount > 0;
},
},
created () {
this.searchMembersDebounced = debounce(this.performSearch, 500);
},
methods: {
searchMembers () {
if (!this.searchTerm) {
this.filteredMembers = [];
this.isSearching = false;
return;
}
this.isSearching = true;
this.searchMembersDebounced();
},
async performSearch () {
if (!this.searchTerm) {
this.filteredMembers = [];
this.isSearching = false;
return;
}
const searchTerm = this.searchTerm.replace('@', '');
try {
const members = await this.$store.dispatch('members:getChallengeMembers', {
challengeId: this.challengeId,
searchTerm,
includeAllPublicFields: true,
});
this.filteredMembers = members.slice(0, 10);
} catch (err) {
this.filteredMembers = [];
} finally {
this.isSearching = false;
}
},
getMemberDisplayName (member) {
if (member.auth?.local?.username) {
return `@${member.auth.local.username}`;
}
return member.profile?.name || '';
},
selectMember (member) {
this.winner = member;
this.searchTerm = this.getMemberDisplayName(member);
this.showResults = false;
},
handleBlur () {
setTimeout(() => {
this.showResults = false;
}, 200);
},
async closeChallenge () {
this.challenge = await this.$store.dispatch('challenges:selectChallengeWinner', {
@@ -1,282 +0,0 @@
<template>
<div
v-if="searchResults.length > 0"
class="autocomplete-selection"
:style="autocompleteStyle"
>
<div
v-for="result in searchResults"
:key="result.shortcode"
class="autocomplete-results d-flex align-items-center"
:class="{'hover-background': result.hover}"
@click="select(result)"
@mouseenter="setHover(result)"
@mouseleave="resetSelection()"
>
<img
v-if="result.imageUrl"
class="emoji-img"
:src="result.imageUrl"
:alt="result.shortcode"
>
<span
v-else
class="emoji-char"
>{{ result.emoji }}</span>
<span
class="shortcode ml-2"
:class="{'hover-foreground': result.hover}"
>:{{ result.shortcode }}:</span>
</div>
</div>
</template>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
.autocomplete-results {
padding: .5em;
}
.autocomplete-selection {
box-shadow: 1px 1px 1px #efefef;
}
.hover-background {
background-color: rgba(213, 200, 255, 0.32);
cursor: pointer;
}
.hover-foreground {
color: $purple-300 !important;
}
.emoji-char {
font-size: 20px;
line-height: 1;
}
.emoji-img {
height: 20px;
width: 20px;
}
.shortcode {
color: $gray-200;
font-size: 14px;
}
</style>
<script>
import habiticaMarkdown from 'habitica-markdown';
export default {
props: ['text', 'caretPosition', 'coords', 'textbox'],
data () {
return {
colonRegex: /:([a-zA-Z0-9_+]*)$/,
currentSearch: '',
searchActive: false,
searchResults: [],
selected: null,
emojiList: [],
renderTick: 0,
internalCoords: { TOP: 0, LEFT: 0 },
};
},
computed: {
autocompleteStyle () {
// eslint-disable-next-line no-unused-vars
const _tick = this.renderTick;
const isTextarea = this.textbox.tagName === 'TEXTAREA';
const dropdownPA = (this.$el && this.$el.nodeType === 1) ? this.$el.offsetParent : null;
const textboxOP = this.textbox.offsetParent;
const needsRectCalc = dropdownPA && textboxOP && dropdownPA !== textboxOP;
let top;
let left;
const caretLeft = this.internalCoords.LEFT - (this.textbox.scrollLeft || 0);
if (needsRectCalc) {
const textboxRect = this.textbox.getBoundingClientRect();
const parentRect = dropdownPA.getBoundingClientRect();
const parentScrollTop = dropdownPA.scrollTop || 0;
if (isTextarea) {
const computedStyle = window.getComputedStyle(this.textbox);
const lineHeight = parseFloat(computedStyle.lineHeight)
|| (parseFloat(computedStyle.fontSize) * 1.4);
const caretTopInTextbox = this.internalCoords.TOP
- (this.textbox.scrollTop || 0) + lineHeight;
const clamped = Math.min(Math.max(caretTopInTextbox, 0), this.textbox.offsetHeight);
top = (textboxRect.top - parentRect.top) + parentScrollTop + clamped + 2;
} else {
top = (textboxRect.bottom - parentRect.top) + parentScrollTop + 2;
}
left = (textboxRect.left - parentRect.left) + caretLeft;
} else {
if (isTextarea) {
const computedStyle = window.getComputedStyle(this.textbox);
const lineHeight = parseFloat(computedStyle.lineHeight)
|| (parseFloat(computedStyle.fontSize) * 1.4);
const caretTopInTextbox = this.internalCoords.TOP
- (this.textbox.scrollTop || 0) + lineHeight;
const clamped = Math.min(Math.max(caretTopInTextbox, 0), this.textbox.offsetHeight);
top = this.textbox.offsetTop + clamped + 2;
} else {
top = this.textbox.offsetTop + this.textbox.offsetHeight + 2;
}
left = this.textbox.offsetLeft + caretLeft;
}
return {
top: `${top}px`,
left: `${left}px`,
position: 'absolute',
minWidth: '150px',
zIndex: 100,
backgroundColor: 'white',
};
},
},
watch: {
searchResults (results, oldResults) {
if (results.length > 0 && (!oldResults || oldResults.length === 0)) {
this.$nextTick(() => {
this.renderTick += 1;
});
}
},
text (newText, prevText) {
if (!this.textbox) return;
this._measureCaretCoords();
const delCharsBool = prevText.length > newText.length;
const caretPosition = this.textbox.selectionEnd;
const lastFocusChar = delCharsBool ? prevText[caretPosition] : newText[caretPosition - 1];
if (
newText.length === 0
|| (lastFocusChar === ':' && delCharsBool)
) {
this.cancel();
} else {
if (lastFocusChar === ':') this.searchActive = true;
if (this.searchActive) {
this.searchResults = this.solveSearchResults(newText.substring(0, caretPosition));
}
}
},
},
created () {
const defs = habiticaMarkdown.emojiDefs;
if (!defs) return;
const customEmojis = habiticaMarkdown.customEmojis || {};
const list = [];
const keys = Object.keys(defs);
keys.sort();
for (const key of keys) {
const entry = { shortcode: key, emoji: defs[key], hover: false };
if (customEmojis[key]) {
entry.imageUrl = customEmojis[key];
}
list.push(entry);
}
this.emojiList = list;
},
methods: {
solveSearchResults (textFocus) {
const regexRes = this.colonRegex.exec(textFocus);
if (!regexRes) {
this.cancel();
return [];
}
this.currentSearch = regexRes[1];
if (this.currentSearch.length === 0) return [];
const lowerSearch = this.currentSearch.toLowerCase();
return this.emojiList
.filter(entry => entry.shortcode.startsWith(lowerSearch))
.slice(0, 6)
.map(entry => ({ ...entry, hover: false }));
},
select (result) {
const { text } = this;
const targetName = `${result.shortcode}: `;
const oldCaret = this.caretPosition;
const escapedSearch = this.currentSearch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
let newText = text.substring(0, this.caretPosition)
.replace(new RegExp(`${escapedSearch}$`), targetName);
const newCaret = newText.length;
newText += text.substring(oldCaret, text.length);
this.$emit('select', newText, newCaret);
this.cancel();
},
setHover (result) {
this.resetSelection();
result.hover = true;
},
clearHover () {
for (const selection of this.searchResults) {
selection.hover = false;
}
},
resetSelection () {
this.clearHover();
this.selected = null;
},
selectNext () {
if (this.searchResults.length > 0) {
this.clearHover();
this.selected = this.selected === null
? 0
: (this.selected + 1) % this.searchResults.length;
this.searchResults[this.selected].hover = true;
}
},
selectPrevious () {
if (this.searchResults.length > 0) {
this.clearHover();
this.selected = this.selected === null
? this.searchResults.length - 1
: (this.selected - 1 + this.searchResults.length) % this.searchResults.length;
this.searchResults[this.selected].hover = true;
}
},
makeSelection () {
if (this.searchResults.length > 0 && this.selected !== null) {
const result = this.searchResults[this.selected];
this.select(result);
}
},
_measureCaretCoords () {
const el = this.textbox;
const caretPosition = el.selectionEnd;
const div = document.createElement('div');
const span = document.createElement('span');
const copyStyle = getComputedStyle(el);
[].forEach.call(copyStyle, prop => {
div.style[prop] = copyStyle[prop];
});
div.style.position = 'absolute';
div.style.visibility = 'hidden';
document.body.appendChild(div);
div.textContent = el.value.substr(0, caretPosition);
span.textContent = el.value.substr(caretPosition) || '.';
div.appendChild(span);
this.internalCoords = {
TOP: span.offsetTop,
LEFT: span.offsetLeft,
};
document.body.removeChild(div);
},
cancel () {
this.searchActive = false;
this.searchResults = [];
this.resetSelection();
},
},
};
</script>
@@ -187,8 +187,7 @@
</div>
</div>
<div
v-if="user.purchased.background.birthday_bash
|| user.purchased.background.on_a_strange_planet"
v-if="user.purchased.background.birthday_bash"
>
<div
class="row justify-content-center title-row mb-3"
@@ -52,21 +52,17 @@
<div
v-if="!group.purchased.plan.dateTerminated
&& group.purchased.plan.paymentMethod === 'Stripe'"
class="btn btn-primary mb-3"
class="btn btn-primary"
@click="redirectToStripeEdit({groupId: group.id})"
>
{{ $t('subUpdateCard') }}
</div>
<div v-if="!group.purchased.plan.dateTerminated">
<div class="small gray-50 mb-3" v-once>
{{ $t('groupPlanBillingFYIShort') }}
</div>
<div
class="btn btn-sm btn-danger"
@click="cancelSubscriptionConfirm({group: group})"
>
{{ $t('cancelGroupSub') }}
</div>
<div
v-if="!group.purchased.plan.dateTerminated"
class="btn btn-sm btn-danger"
@click="cancelSubscriptionConfirm({group: group})"
>
{{ $t('cancelGroupSub') }}
</div>
</div>
</div>
@@ -82,7 +82,9 @@
<select-translated-array
:items="[
'groupParentChildren',
'groupCouple',
'groupFriends',
'groupCoworkers',
'groupManager',
'groupTeacher'
]"
@@ -1,577 +0,0 @@
<template>
<b-modal
id="group-plan-selection"
:hide-footer="true"
:hide-header="true"
size="md"
@show="loadData"
@hide="onHide"
>
<div class="selection-modal">
<div class="modal-header-row">
<h2 class="title">
{{ $t('chooseAnOption') }}
</h2>
<div class="header-actions">
<span
class="cancel-text"
@click="close"
>
{{ $t('cancel') }}
</span>
<button
class="btn btn-primary next-button"
:class="{ disabled: !selectedOption }"
:disabled="!selectedOption"
@click="continueFlow"
>
{{ $t('next') }}
</button>
</div>
</div>
<div
v-if="loading"
class="loading-container"
>
<div class="spinner-border text-secondary"></div>
</div>
<template v-else>
<div
v-if="hasUpgradeableGroups"
class="section-header"
>
{{ $t('upgradeExistingGroup') }}
</div>
<selectable-card
v-for="group in upgradeableGuilds"
:key="group._id"
class="option-card"
:selected="isSelected(group)"
@click="selectOption(group)"
>
<div class="option-content">
<div class="option-info">
<div class="option-name">
{{ group.name }}
</div>
<div class="option-members">
{{ formatMemberCount(group.memberCount) }}
</div>
<div class="option-label previously-upgraded">
<div
class="svg-icon sparkle-icon"
v-html="icons.sparkles"
></div>
{{ $t('previouslyUpgradedGroup') }}
</div>
</div>
<div class="option-price">
${{ calculatePrice(group.memberCount) }}.00/mo
</div>
</div>
</selectable-card>
<selectable-card
v-if="upgradeableParty"
class="option-card"
:class="{ 'has-pending-warning': partyPendingInviteCount > 0 }"
:selected="isSelected(upgradeableParty)"
@click="selectOption(upgradeableParty)"
>
<div class="option-content">
<div class="option-info">
<div class="option-name">
{{ upgradeableParty.name }}
</div>
<div class="option-members">
{{ formatMemberCount(upgradeableParty.memberCount) }}
<span
v-if="partyPendingInviteCount > 0"
class="pending-count"
>
{{ $t('pendingCount', { count: partyPendingInviteCount }) }}
</span>
</div>
<div
v-if="isPartyPreviouslyUpgraded"
class="option-label previously-upgraded"
>
<div
class="svg-icon sparkle-icon"
v-html="icons.sparkles"
></div>
{{ $t('previouslyUpgradedGroup') }}
</div>
<div
v-else
class="option-label your-party"
>
<div
class="svg-icon member-icon"
v-html="icons.member"
></div>
{{ $t('yourParty') }}
</div>
</div>
<div class="option-price">
${{ calculatePrice(upgradeableParty.memberCount) }}.00/mo
</div>
</div>
<div
v-if="partyPendingInviteCount > 0"
class="pending-warning-banner"
>
<div
class="svg-icon alert-icon"
v-html="icons.alert"
></div>
<span class="warning-text">{{ $t('upgradeCancelsPendingInvites') }}</span>
</div>
</selectable-card>
<div
v-if="hasUpgradeableGroups"
class="or-divider"
>
<div class="divider-line"></div>
<span class="or-text">{{ $t('or') }}</span>
<div class="divider-line"></div>
</div>
<selectable-card
class="option-card create-new"
:selected="selectedOption === 'new'"
@click="selectOption('new')"
>
<div class="option-content">
<div class="option-info">
<div class="option-name">
{{ $t('createNewGroup') }}
</div>
<div class="option-description">
{{ $t('inviteOthersForAdditional') }}
<span class="price-highlight">${{ perMemberPrice }}.00</span>
{{ $t('perMember') }}.
</div>
</div>
<div class="option-price">
${{ basePrice }}.00/mo
</div>
</div>
</selectable-card>
<div class="footer-note">
{{ $t('additionalMembersProrated') }}
</div>
</template>
</div>
</b-modal>
</template>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
.selection-modal {
padding: 24px;
}
.modal-header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.title {
font-family: 'Roboto Condensed', sans-serif;
font-weight: 700;
font-size: 20px;
line-height: 28px;
color: $purple-200;
margin: 0;
}
.header-actions {
display: flex;
align-items: center;
}
.cancel-text {
color: $blue-10;
font-size: 0.875rem;
margin-right: 16px;
cursor: pointer;
}
.next-button {
min-width: 64px;
&.disabled {
background-color: $gray-300;
border-color: $gray-300;
cursor: not-allowed;
}
}
.loading-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
}
.section-header {
font-family: 'Roboto', sans-serif;
font-weight: 700;
font-size: 14px;
line-height: 24px;
color: $gray-10;
margin-bottom: 12px;
}
.option-card {
margin-bottom: 12px;
::v-deep .option-name {
color: $gray-50;
}
&.selected ::v-deep .option-name {
color: $purple-200;
}
}
.pending-warning-banner {
display: flex;
align-items: center;
justify-content: center;
height: 32px;
background-color: $yellow-50;
border-radius: 0 0 6px 6px;
margin: 16px -16px 0 -16px;
gap: 4px;
.selected & {
margin: 15px -15px 0 -15px;
}
.alert-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
::v-deep path {
fill: $gray-10;
}
}
.warning-text {
font-family: 'Roboto', sans-serif;
font-weight: 400;
font-size: 12px;
line-height: 16px;
color: $gray-10;
}
}
.option-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding-left: 32px;
padding-right: 8px;
}
.option-info {
flex: 1;
}
.option-name {
font-family: 'Roboto', sans-serif;
font-size: 14px;
font-weight: 700;
line-height: 24px;
margin-bottom: 4px;
}
.option-members {
font-family: 'Roboto', sans-serif;
font-weight: 400;
font-size: 12px;
line-height: 16px;
color: $gray-100;
margin-bottom: 8px;
.pending-count {
font-weight: 700;
color: $yellow-5;
}
}
.option-label {
display: flex;
align-items: center;
font-family: 'Roboto', sans-serif;
font-size: 12px;
line-height: 16px;
gap: 4px;
&.previously-upgraded {
font-weight: 700;
color: $blue-10;
}
&.your-party {
font-weight: 700;
color: $gray-100;
}
.svg-icon {
width: 14px;
height: 14px;
}
.sparkle-icon {
color: $blue-10;
}
.member-icon {
color: $gray-100;
::v-deep path {
fill: $gray-100;
stroke: $gray-100;
stroke-width: 0.5px;
}
}
}
.option-description {
font-family: 'Roboto', sans-serif;
font-weight: 400;
font-size: 12px;
line-height: 16px;
color: $gray-100;
.price-highlight {
font-weight: 700;
}
}
.option-price {
font-family: 'Roboto', sans-serif;
font-weight: 700;
font-size: 20px;
line-height: 24px;
color: $purple-200;
white-space: nowrap;
}
.or-divider {
display: flex;
align-items: center;
margin: 20px 0;
.divider-line {
flex: 1;
height: 1px;
background-color: $gray-500;
}
.or-text {
padding: 0 16px;
font-family: 'Roboto', sans-serif;
font-weight: 700;
font-size: 12px;
line-height: 16px;
letter-spacing: 0.2em;
text-transform: uppercase;
color: $gray-100;
}
}
.create-new {
.option-name {
margin-bottom: 8px;
}
}
.footer-note {
font-family: 'Roboto', sans-serif;
font-weight: 400;
font-size: 12px;
line-height: 16px;
color: $gray-100;
text-align: center;
margin-top: 16px;
margin-left: 24px;
margin-right: 24px;
}
</style>
<style lang="scss">
#group-plan-selection {
.modal-dialog {
max-width: 504px;
}
.modal-content {
border-radius: 8px;
box-shadow: 0 14px 28px 0 rgba(26, 24, 29, 0.24), 0 10px 10px 0 rgba(26, 24, 29, 0.28);
}
.modal-body {
padding: 0;
}
.option-card.has-pending-warning.selectable-card {
padding-bottom: 0;
}
}
</style>
<script>
import axios from 'axios';
import paymentsMixin from '@/mixins/payments';
import { mapState } from '@/libs/store';
import SelectableCard from '@/components/ui/selectableCard.vue';
import svgSparkles from '@/assets/svg/sparkles.svg?raw';
import svgMember from '@/assets/svg/member-icon.svg?raw';
import svgAlert from '@/assets/svg/for-css/alert.svg?raw';
export default {
components: {
SelectableCard,
},
mixins: [paymentsMixin],
data () {
return {
selectedOption: null,
userGuilds: [],
userParty: null,
activeGroupPlanIds: [],
loading: true,
basePrice: 9,
perMemberPrice: 3,
icons: Object.freeze({
sparkles: svgSparkles,
member: svgMember,
alert: svgAlert,
}),
partyPendingInviteCount: 0,
};
},
computed: {
...mapState({ user: 'user.data' }),
upgradeableGuilds () {
return this.userGuilds.filter(group => {
const leaderId = group.leader?._id || group.leader;
if (leaderId !== this.user._id) return false;
const purchased = group.purchased;
if (!purchased?.wasUpgraded) return false;
if (this.activeGroupPlanIds.includes(group._id)) return false;
if (!purchased.dateTerminated) return false;
return new Date(purchased.dateTerminated) < new Date();
});
},
upgradeableParty () {
if (!this.userParty) return null;
const leaderId = this.userParty.leader?._id || this.userParty.leader;
if (leaderId !== this.user._id) return null;
if (this.activeGroupPlanIds.includes(this.userParty._id)) return null;
return this.userParty;
},
hasUpgradeableGroups () {
return this.upgradeableGuilds.length > 0 || this.upgradeableParty !== null;
},
isPartyPreviouslyUpgraded () {
if (!this.userParty) return false;
const purchased = this.userParty.purchased;
if (!purchased?.wasUpgraded) return false;
if (!purchased.dateTerminated) return false;
return new Date(purchased.dateTerminated) < new Date();
},
},
methods: {
async loadData () {
this.loading = true;
this.selectedOption = null;
this.partyPendingInviteCount = 0;
try {
const [guildsResponse, partyResponse] = await Promise.all([
axios.get('/api/v4/groups', { params: { type: 'guilds', includeExpiredPlans: 'true' } }),
axios.get('/api/v4/groups/party').catch(() => ({ data: { data: null } })),
]);
this.userGuilds = guildsResponse.data.data || [];
this.userParty = partyResponse.data.data;
if (this.userParty) {
try {
const invitesResponse = await axios.get(`/api/v4/groups/${this.userParty._id}/invites`);
this.partyPendingInviteCount = invitesResponse.data.data?.length || 0;
} catch (e) {
this.partyPendingInviteCount = 0;
}
}
await this.$store.dispatch('guilds:getGroupPlans', true);
const groupPlans = this.$store.state.groupPlans?.data || [];
this.activeGroupPlanIds = groupPlans.map(g => g._id);
} catch (e) {
console.error('Error loading group data:', e);
}
this.loading = false;
this.$nextTick(() => {
if (this.upgradeableGuilds.length > 0) {
this.selectedOption = this.upgradeableGuilds[0];
} else if (this.upgradeableParty) {
this.selectedOption = this.upgradeableParty;
} else {
this.selectedOption = 'new';
}
});
},
selectOption (option) {
this.selectedOption = option;
},
isSelected (group) {
if (!this.selectedOption || this.selectedOption === 'new') return false;
return this.selectedOption._id === group._id;
},
calculatePrice (memberCount) {
return this.basePrice + (this.perMemberPrice * (memberCount - 1));
},
formatMemberCount (count) {
return count === 1 ? this.$t('oneMember') : this.$t('membersCount', { count });
},
continueFlow () {
if (!this.selectedOption) return;
const selection = this.selectedOption;
this.close();
if (selection === 'new') {
this.$root.$emit('bv::show::modal', 'create-group');
} else {
this.stripeGroup({ group: selection, upgrade: true });
}
},
close () {
this.$root.$emit('bv::hide::modal', 'group-plan-selection');
},
onHide () {
this.selectedOption = null;
},
},
};
</script>
@@ -41,14 +41,6 @@
:chat="group.chat"
@select="selectedAutocomplete"
/>
<emoji-auto-complete
ref="emojiAutocomplete"
:text="newMessage"
:textbox="textbox"
:coords="mixinData.autoComplete.coords"
:caret-position="mixinData.autoComplete.caretPosition"
@select="selectedAutocomplete"
/>
</div>
<community-guidelines />
<div class="row chat-actions">
@@ -98,7 +90,6 @@ import { MAX_MESSAGE_LENGTH } from '@/../../common/script/constants';
import externalLinks from '../../mixins/externalLinks';
import autocomplete from '../chat/autoComplete';
import emojiAutoComplete from '../chat/emojiAutoComplete';
import communityGuidelines from './communityGuidelines';
import chatMessages from '../chat/chatMessages';
import { mapState } from '@/libs/store';
@@ -111,7 +102,6 @@ export default {
},
components: {
autocomplete,
emojiAutoComplete,
communityGuidelines,
chatMessages,
},
+57 -82
View File
@@ -25,61 +25,53 @@
<div class="col-12 col-md-6">
<div class="row icon-row">
<div
class="item-with-icon p-2"
class="item-with-icon"
tabindex="0"
role="button"
@keyup.enter="showMemberModal()"
@click="showMemberModal()"
>
<div class="box-content">
<div class="icon-number-row">
<div
v-if="group.memberCount > 1000"
class="svg-icon shield"
v-html="icons.goldGuildBadgeIcon"
></div>
<div
v-if="group.memberCount > 100 && group.memberCount < 999"
class="svg-icon shield"
v-html="icons.silverGuildBadgeIcon"
></div>
<div
v-if="group.memberCount < 100"
class="svg-icon shield"
v-html="icons.bronzeGuildBadgeIcon"
></div>
<span class="number">{{ group.memberCount | abbrNum }}</span>
</div>
<div
v-once
class="details"
>
{{ $t('memberList') }}
</div>
<div
v-if="group.memberCount > 1000"
class="svg-icon shield"
v-html="icons.goldGuildBadgeIcon"
></div>
<div
v-if="group.memberCount > 100 && group.memberCount < 999"
class="svg-icon shield"
v-html="icons.silverGuildBadgeIcon"
></div>
<div
v-if="group.memberCount < 100"
class="svg-icon shield"
v-html="icons.bronzeGuildBadgeIcon"
></div>
<span class="number">{{ group.memberCount | abbrNum }}</span>
<div
v-once
class="member-list label"
>
{{ $t('memberList') }}
</div>
</div>
<div v-if="!isParty">
<div
class="item-with-icon p-2"
class="item-with-icon"
tabindex="0"
role="button"
@keyup.enter="showGroupGems()"
@click="showGroupGems()"
>
<div class="box-content">
<div class="icon-number-row">
<div
class="svg-icon gem"
v-html="icons.gem"
></div>
<span class="number">{{ group.balance * 4 }}</span>
</div>
<div
v-once
class="details"
>
{{ $t('guildBank') }}
</div>
<div
class="svg-icon gem"
v-html="icons.gem"
></div>
<span class="number">{{ group.balance * 4 }}</span>
<div
v-once
class="label"
>
{{ $t('guildBank') }}
</div>
</div>
</div>
@@ -136,57 +128,35 @@
}
.item-with-icon {
display: inline-block;
border-radius: 2px;
background-color: $white;
background-color: #ffffff;
box-shadow: 0 2px 2px 0 rgba(26, 24, 29, 0.16), 0 1px 4px 0 rgba(26, 24, 29, 0.12);
margin-left: 1em;
width: 120px;
height: 76px;
padding: 1em;
text-align: center;
font-size: 20px;
vertical-align: bottom;
overflow: hidden;
position: relative;
min-width: 120px;
height: 76px;
margin-right: 1rem;
.box-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
&:last-of-type {
margin-left: 0.5rem;
}
.icon-number-row {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.1em;
.number {
font-size: 18px;
font-weight: normal;
margin-left: 0.2em;
}
}
.svg-icon {
width: 24px;
height: 24px;
.svg-icon.shield, .svg-icon.gem {
width: 28px;
height: auto;
margin: 0 auto;
display: inline-block;
vertical-align: bottom;
margin-right: 0.5em;
}
.details {
font-size: 11px;
color: $gray-200;
width: 100%;
padding: 0 4px;
line-height: 1.1;
word-break: break-word;
max-height: 2.2em;
overflow: visible;
.number {
font-size: 22px;
font-weight: bold;
}
.label {
margin-top: .5em;
}
}
@@ -245,6 +215,11 @@
.icon-row {
margin-top: 1em;
justify-content: flex-end;
.number {
font-size: 22px;
font-weight: bold;
}
}
.chat-row {
@@ -218,19 +218,13 @@
flex-direction: row;
flex-wrap: wrap;
gap: 0.5rem;
max-width: 400px;
width: 100%;
// somehow the browser felt like setting this 398px instead
// now its fixed to 400 :)
width: 400px;
margin-bottom: 1.5rem;
@media (max-width: 589px) {
max-width: 100%;
justify-content: center;
}
@media (max-width: 353px) {
gap: 0.25rem;
}
.quest-col {
::v-deep {
.item-wrapper {
@@ -257,28 +251,6 @@
::v-deep & {
.modal-dialog {
width: 448px !important;
max-width: calc(100vw - 20px);
margin: 0.5rem auto;
display: flex;
@media (max-width: 468px) {
width: 100% !important;
}
@media (max-width: 353px) {
width: 100% !important;
margin: 0.25rem auto;
}
}
.modal-content {
display: flex;
flex-direction: column;
width: 100%;
@media (max-width: 300px) {
border-radius: 0;
}
}
}
@@ -1,8 +1,8 @@
<template>
<div
v-if="!hidden"
id="privacy-banner"
class="banner d-flex align-items-center justify-content-between py-3 px-4"
id="privacy-banner"
v-if="!hidden"
>
<p
class="mr-3 mb-0"
@@ -1,43 +1,37 @@
<template>
<div
class="notification d-flex justify-content-center align-items-center"
class="notification d-flex flex-column justify-content-center text-center"
>
<img
src="@/assets/images/gifts_start.svg"
class="gift-start"
alt=""
<strong
v-once
class="mx-auto mb-2"
>
<div class="content-wrapper d-flex flex-column justify-content-center text-center">
<strong
{{ $t('g1g1') }}
</strong>
<small
v-once
class="mx-4 mb-3"
>
{{ $t('g1g1Details') }}
</small>
<div
class="btn-secondary mx-auto d-flex"
@click="showSelectUser()"
>
<div
v-once
class="mx-auto mb-2"
>
{{ $t('g1g1') }}
</strong>
<small
v-once
class="mx-4 mb-3"
>
{{ $t('g1g1Details') }}
</small>
<button
class="btn btn-secondary mx-auto"
@click="showSelectUser()"
class="m-auto"
>
{{ $t('sendGift') }}
</button>
</div>
</div>
<img
src="@/assets/images/gifts_start.svg"
class="gift-end"
alt=""
>
<div
class="close-x"
class="notification-remove"
@click.stop="remove()"
>
<div
class="svg-icon svg-close"
v-once
class="svg-icon"
v-html="icons.close"
></div>
</div>
@@ -47,89 +41,51 @@
<style lang='scss' scoped>
@import '@/assets/scss/colors.scss';
small {
small, strong {
color: $white;
font-family: 'Roboto', sans-serif;
font-weight: 400;
font-style: normal;
font-size: 14px;
line-height: 1.714;
letter-spacing: 0;
}
strong {
color: $white;
font-family: 'Roboto', sans-serif;
font-weight: 700;
font-style: normal;
font-size: 14px;
line-height: 1.714;
}
.notification {
background-image: url('@/assets/images/gifts_bg.svg');
background-size: cover;
background-position: center;
background-image: url('@/assets/images/g1g1-notif.png');
height: 10rem;
padding: 0;
padding: 3rem;
position: relative;
overflow: hidden;
white-space: normal;
cursor: pointer;
}
.content-wrapper {
flex: 1;
padding: 2rem;
z-index: 1;
}
.gift-start {
height: 96px;
width: auto;
.notification-remove {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
z-index: 0;
}
width: 18px;
height: 18px;
padding: 4px;
right: 24px;
top: 24px;
.gift-end {
height: 96px;
width: auto;
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%) scaleX(-1);
z-index: 0;
}
.close-x {
position: absolute;
right: 16px;
top: 16px;
cursor: pointer;
z-index: 2;
&:hover .svg-close {
opacity: 0.75;
.svg-icon {
width: 10px;
height: 10px;
}
}
.svg-close {
width: 18px;
height: 18px;
opacity: 0.5;
transition: opacity 0.2s ease;
pointer-events: none;
}
.btn-secondary {
width: 5.75rem;
min-height: 1.5rem;
border-radius: 2px;
border-color: $white;
box-shadow: 0 2px 2px 0 rgba(26, 24, 29, 0.16), 0 1px 4px 0 rgba(26, 24, 29, 0.12);
font-size: 12px;
font-weight: bold;
}
</style>
<script>
import closeIcon from '@/assets/svg/close-white.svg?raw';
import closeIcon from '@/assets/svg/close-teal.svg?raw';
import { mapActions } from '@/libs/store';
export default {
props: ['notification', 'eventKey'],
props: ['notification'],
data () {
return {
icons: Object.freeze({
@@ -138,11 +94,11 @@ export default {
};
},
methods: {
...mapActions({
readNotification: 'notifications:readNotification',
}),
remove () {
if (this.eventKey) {
window.localStorage.setItem(`hide-g1g1-${this.eventKey}`, 'true');
}
this.$emit('notification-removed');
this.readNotification({ notificationId: this.notification.id });
},
showSelectUser () {
this.$root.$emit('bv::show::modal', 'select-user-modal');
@@ -71,7 +71,7 @@ export default {
props: ['notification', 'canRemove'],
methods: {
action () {
this.$router.push(`/profile/${this.$store.state.user.data._id}#achievements`);
this.$router.push({ name: 'achievements' });
},
},
};
@@ -43,7 +43,7 @@ export default {
},
methods: {
action () {
this.$router.push(`/profile/${this.$store.state.user.data._id}#stats`);
this.$router.push({ name: 'stats' });
},
},
};
@@ -49,12 +49,6 @@
v-if="showOnboardingGuide"
:never-seen="hasSpecialBadge"
/>
<gift-one-get-one-notification
v-if="shouldShowG1g1"
:notification="g1g1Notification"
:event-key="g1g1EventKey"
@notification-removed="handleG1g1Removed"
/>
<component
:is="notification.type"
v-for="notification in notifications"
@@ -120,7 +114,6 @@
<script>
import * as quests from '@/../../common/script/content/quests';
import { hasCompletedOnboarding } from '@/../../common/script/libs/onboarding';
import find from 'lodash/find';
import { mapState, mapActions } from '@/libs/store';
import notificationsIcon from '@/assets/svg/notifications.svg?raw';
import MenuDropdown from '../ui/customMenuDropdown';
@@ -158,7 +151,6 @@ export default {
CARD_RECEIVED,
CHALLENGE_INVITATION,
GIFT_ONE_GET_ONE,
GiftOneGetOneNotification: GIFT_ONE_GET_ONE,
GROUP_TASK_ASSIGNED,
GROUP_TASK_CLAIMED,
GROUP_TASK_NEEDS_WORK,
@@ -186,14 +178,17 @@ export default {
hasSpecialBadge: false,
quests,
openStatus: undefined,
g1g1Hidden: false,
actionableNotifications: [
'GUILD_INVITATION', 'PARTY_INVITATION', 'CHALLENGE_INVITATION',
'QUEST_INVITATION',
],
// A list of notifications handled by this component,
// listed in the order they should appear in the notifications panel.
// NOTE: Those not listed here won't be shown in the notification panel!
handledNotifications: [
'NEW_STUFF',
'ITEM_RECEIVED',
'GIFT_ONE_GET_ONE',
'GROUP_TASK_NEEDS_WORK',
'GUILD_INVITATION',
'PARTY_INVITATION',
@@ -212,10 +207,7 @@ export default {
};
},
computed: {
...mapState({
user: 'user.data',
currentEventList: 'worldState.data.currentEventList',
}),
...mapState({ user: 'user.data' }),
notificationsOrder () {
// Returns a map of NOTIFICATION_TYPE -> POSITION
const orderMap = {};
@@ -294,9 +286,9 @@ export default {
return notifications;
},
// The total number of notification, shown inside the dropdown
notificationsCount () {
const g1g1Count = this.shouldShowG1g1 ? 1 : 0;
return this.notifications.length + g1g1Count;
return this.notifications.length;
},
hasUnseenNotifications () {
return this.notifications.some(notification => (notification.seen === false));
@@ -307,30 +299,6 @@ export default {
showOnboardingGuide () {
return !hasCompletedOnboarding(this.user);
},
currentG1g1Event () {
return find(this.currentEventList, event => event.promo === 'g1g1');
},
g1g1EventKey () {
if (!this.currentG1g1Event || !this.currentG1g1Event.start) return null;
const startDate = new Date(this.currentG1g1Event.start);
return `${startDate.getFullYear()}-${startDate.getMonth()}`;
},
shouldShowG1g1 () {
if (!this.currentG1g1Event) return false;
const eventKey = this.g1g1EventKey;
if (eventKey && window.localStorage.getItem(`hide-g1g1-${eventKey}`) === 'true') {
return false;
}
return !this.g1g1Hidden;
},
g1g1Notification () {
return {
type: 'GIFT_ONE_GET_ONE',
id: `g1g1-event-${this.currentG1g1Event?.start || 'default'}`,
data: {},
seen: false,
};
},
},
mounted () {
const onboardingPanelState = getLocalSetting(CONSTANTS.keyConstants.ONBOARDING_PANEL_STATE);
@@ -396,9 +364,6 @@ export default {
isActionable (notification) {
return this.actionableNotifications.indexOf(notification.type) !== -1;
},
handleG1g1Removed () {
this.g1g1Hidden = true;
},
},
};
@@ -176,12 +176,7 @@ export default {
}
},
showProfile (startingPage) {
const userId = this.$store.state.user.data._id;
let path = `/profile/${userId}`;
if (startingPage !== 'profile') {
path += `#${startingPage}`;
}
this.$router.push(path);
this.$router.push({ name: startingPage });
},
toLearnMore () {
this.$router.push({ name: 'subscription' });

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