diff --git a/website/client/src/components/header/menu.vue b/website/client/src/components/header/menu.vue index 900cf0ec93..ae75b91be1 100644 --- a/website/client/src/components/header/menu.vue +++ b/website/client/src/components/header/menu.vue @@ -146,6 +146,12 @@ > {{ $t('titleTimeTravelers') }} + + {{ $t('titleCustomizations') }} + +
+
+ +
+ +
+ + + +
+

+ {{ $t('hidePinned') }} +

+ +
+
+
+
+
+
+
+ +
+
+
+
+
+ {{ $t('sortBy') }} + +
+
+ +
+ +

+ {{ category.text }} +

+ + + +
+

{{ items[0].set.text() }}

+ + + +
+
+ {{ $t('purchaseAll') }} +
+ {{ items[0].set.setPrice }} +
+
+
+
+
+
+ + + + + + + diff --git a/website/client/src/components/shops/index.vue b/website/client/src/components/shops/index.vue index d12a19c46e..953ebc5afb 100644 --- a/website/client/src/components/shops/index.vue +++ b/website/client/src/components/shops/index.vue @@ -26,6 +26,12 @@ > {{ $t('titleTimeTravelers') }} + + {{ $t('titleCustomizations') }} +
diff --git a/website/client/src/router/index.js b/website/client/src/router/index.js index f3e87d4584..7794721ee0 100644 --- a/website/client/src/router/index.js +++ b/website/client/src/router/index.js @@ -62,6 +62,7 @@ const MarketPage = () => import(/* webpackChunkName: "shops-market" */'@/compone const QuestsPage = () => import(/* webpackChunkName: "shops-quest" */'@/components/shops/quests/index'); const SeasonalPage = () => import(/* webpackChunkName: "shops-seasonal" */'@/components/shops/seasonal/index'); const TimeTravelersPage = () => import(/* webpackChunkName: "shops-timetravelers" */'@/components/shops/timeTravelers/index'); +const CustomizationsShopPage = () => import(/* webpackChunkName: "shops-customizations" */'@/components/shops/customizations/index'); Vue.use(VueRouter); @@ -113,6 +114,7 @@ const router = new VueRouter({ { name: 'quests', path: 'quests', component: QuestsPage }, { name: 'seasonal', path: 'seasonal', component: SeasonalPage }, { name: 'time', path: 'time', component: TimeTravelersPage }, + { name: 'customizations', path: 'customizations', component: CustomizationsShopPage }, ], }, { name: 'party', path: '/party', component: GroupPage }, diff --git a/website/common/locales/en/character.json b/website/common/locales/en/character.json index 4b6bdd7ecd..5a699b970e 100644 --- a/website/common/locales/en/character.json +++ b/website/common/locales/en/character.json @@ -34,6 +34,9 @@ "bodyFacialHair": "Facial Hair", "beard": "Beard", "mustache": "Mustache", + "titleFacialHair": "Facial Hair", + "titleHaircolor": "Hair Colors", + "titleHairbase": "Hair styles", "flower": "Flower", "accent": "Accent", "headband": "Headband", diff --git a/website/common/locales/en/generic.json b/website/common/locales/en/generic.json index bfaa238727..3b156d53b0 100644 --- a/website/common/locales/en/generic.json +++ b/website/common/locales/en/generic.json @@ -8,6 +8,7 @@ "gotIt": "Got it!", "titleTimeTravelers": "Time Travelers", "titleSeasonalShop": "Seasonal Shop", + "titleCustomizations": "Customizations", "saveEdits": "Save Edits", "showMore": "Show More", "showLess": "Show Less", diff --git a/website/common/script/content/constants/index.js b/website/common/script/content/constants/index.js index 358edb82b1..624285ec06 100644 --- a/website/common/script/content/constants/index.js +++ b/website/common/script/content/constants/index.js @@ -40,3 +40,4 @@ export { default as QUEST_PETS } from '../quests/pets'; export { default as QUEST_POTIONS } from '../quests/potions'; export { default as QUEST_TIME_TRAVEL } from '../quests/timeTravel'; export { default as QUEST_WORLD } from '../quests/world'; +export { getScheduleMatchingGroup, getCurrentGalaKey } from './schedule'; diff --git a/website/common/script/content/constants/schedule.js b/website/common/script/content/constants/schedule.js index eed010bfc6..c5a0bbbafa 100644 --- a/website/common/script/content/constants/schedule.js +++ b/website/common/script/content/constants/schedule.js @@ -3,7 +3,6 @@ import SEASONAL_SETS from './seasonalSets'; function backgroundMatcher (month1, month2, oddYear) { return function call (key) { - if (!key.startsWith('backgrounds')) return true; const keyLength = key.length; const month = parseInt(key.substring(keyLength - 6, keyLength - 4), 10); return (month === month1 || month === month2) @@ -24,6 +23,26 @@ function inListMatcher (list) { }; } +const ALWAYS_AVAILABLE_CUSTOMIZATIONS = [ + 'animalSkins', + 'rainbowSkins', + 'rainbowHairColors', + 'specialShirts', + 'facialHair', + 'baseHair1', + 'baseHair2', + 'baseHair3', +]; + +function customizationMatcher (list) { + return function call (item) { + if (ALWAYS_AVAILABLE_CUSTOMIZATIONS.indexOf(item) !== -1) { + return true; + } + return list.indexOf(item) !== -1; + }; +} + export const FIRST_RELEASE_DAY = 1; export const SECOND_RELEASE_DAY = 7; export const THIRD_RELEASE_DAY = 14; @@ -601,6 +620,12 @@ export const MONTHLY_SCHEDULE = { }; export const GALA_SWITCHOVER_DAY = 21; +export const GALA_KEYS = [ + 'winter', + 'spring', + 'summer', + 'fall', +]; export const GALA_SCHEDULE = { 0: [ { @@ -620,6 +645,13 @@ export const GALA_SCHEDULE = { 'evilsanta2', ], }, + { + type: 'customizations', + matcher: customizationMatcher([ + 'winteryHairColors', + 'winterySkins', + ]), + }, ], 1: [ { @@ -632,20 +664,15 @@ export const GALA_SCHEDULE = { 'shinySeed', ], }, + { + type: 'customizations', + matcher: customizationMatcher([ + 'shimmerHairColors', + 'pastelSkins', + ]), + }, ], 2: [ - { - type: 'seasonalGear', - items: SEASONAL_SETS.fall, - }, - { - type: 'seasonalSpells', - items: [ - 'spookySparkles', - ], - }, - ], - 3: [ { type: 'seasonalGear', items: SEASONAL_SETS.summer, @@ -656,9 +683,45 @@ export const GALA_SCHEDULE = { 'seafoam', ], }, + { + type: 'customizations', + matcher: customizationMatcher([ + 'splashySkins', + ]), + }, + ], + 3: [ + { + type: 'seasonalGear', + items: SEASONAL_SETS.fall, + }, + { + type: 'seasonalSpells', + items: [ + 'spookySparkles', + ], + }, + { + type: 'customizations', + matcher: customizationMatcher([ + 'hauntedHairColors', + 'supernaturalSkins', + ]), + }, ], }; +function getGalaIndex (date) { + const month = date instanceof moment ? date.month() : date.getMonth(); + const todayDay = date instanceof moment ? date.date() : date.getDate(); + let galaMonth = month; + const galaCount = Object.keys(GALA_SCHEDULE).length; + if (todayDay >= GALA_SWITCHOVER_DAY) { + galaMonth += 1; + } + return parseInt((galaCount / 12) * galaMonth, 10); +} + export function assembleScheduledMatchers (date) { const items = []; const month = date instanceof moment ? date.month() : date.getMonth(); @@ -674,12 +737,8 @@ export function assembleScheduledMatchers (date) { items.push(...value); } } - let galaMonth = month; - const galaCount = Object.keys(GALA_SCHEDULE).length; - if (todayDay >= GALA_SWITCHOVER_DAY) { - galaMonth += 1; - } - items.push(...GALA_SCHEDULE[parseInt((galaCount / 12) * galaMonth, 10)]); + + items.push(...GALA_SCHEDULE[getGalaIndex(date)]); return items; } @@ -688,7 +747,7 @@ let cachedScheduleMatchers = null; export function getScheduleMatchingGroup (type, date) { if (!cachedScheduleMatchers) { cachedScheduleMatchers = {}; - assembleScheduledMatchers(date !== undefined ? date : new Date()).forEach(matcher => { + assembleScheduledMatchers(date || new Date()).forEach(matcher => { if (!cachedScheduleMatchers[matcher.type]) { cachedScheduleMatchers[matcher.type] = { matchers: [], @@ -718,3 +777,7 @@ export function getScheduleMatchingGroup (type, date) { } return cachedScheduleMatchers[type]; } + +export function getCurrentGalaKey (date) { + return GALA_KEYS[getGalaIndex(date || new Date())]; +} diff --git a/website/common/script/libs/getItemInfo.js b/website/common/script/libs/getItemInfo.js index 59df678a01..ff9477b30c 100644 --- a/website/common/script/libs/getItemInfo.js +++ b/website/common/script/libs/getItemInfo.js @@ -379,6 +379,96 @@ export default function getItemInfo (user, type, item, officialPinnedItems, lang }; break; } + case 'haircolor': { + itemInfo = { + key: item.key, + class: `hair_bangs_${user.preferences.hair.bangs}_${item.key}`, + text: item.key, + notes: '', + value: item.price, + set: item.set, + locked: false, + currency: 'gems', + path: `customizations.hair.color.${item.key}`, + pinType: 'timeTravelersStable', + }; + break; + } + case 'hairbase': { + itemInfo = { + key: item.key, + class: `hair_base_${item.key}_${user.preferences.hair.color}`, + text: item.key, + notes: '', + value: item.price, + set: item.set, + locked: false, + currency: 'gems', + path: `customizations.hair.base.${item.key}`, + pinType: 'timeTravelersStable', + }; + break; + } + case 'hairmustache': { + itemInfo = { + key: item.key, + class: `hair_mustache_${item.key}_${user.preferences.hair.color}`, + text: item.key, + notes: '', + value: item.price, + set: item.set, + locked: false, + currency: 'gems', + path: `customizations.hair.mustache.${item.key}`, + pinType: 'timeTravelersStable', + }; + break; + } + case 'hairbeard': { + itemInfo = { + key: item.key, + class: `hair_beard_${item.key}_${user.preferences.hair.color}`, + text: item.key, + notes: '', + value: item.price, + set: item.set, + locked: false, + currency: 'gems', + path: `customizations.hair.beard.${item.key}`, + pinType: 'timeTravelersStable', + }; + break; + } + case 'shirt': { + itemInfo = { + key: item.key, + class: `${user.preferences.size}_shirt_${item.key}`, + text: item.key, + notes: '', + value: item.price, + set: item.set, + locked: false, + currency: 'gems', + path: `customizations.shirt.${item.key}`, + pinType: 'timeTravelersStable', + }; + break; + } + case 'skin': { + itemInfo = { + key: item.key, + class: `skin_${item.key}`, + text: item.key, + notes: '', + value: item.price, + set: item.set, + locked: false, + currency: 'gems', + path: `customizations.skin.${item.key}`, + pinType: 'timeTravelersStable', + }; + break; + } } if (itemInfo) { diff --git a/website/common/script/libs/shops-seasonal.config.js b/website/common/script/libs/shops-seasonal.config.js index 62ebde0958..d625dc601f 100644 --- a/website/common/script/libs/shops-seasonal.config.js +++ b/website/common/script/libs/shops-seasonal.config.js @@ -1,50 +1,25 @@ -import find from 'lodash/find'; import upperFirst from 'lodash/upperFirst'; -import moment from 'moment'; import { - EVENTS, - SEASONAL_SETS, + getCurrentGalaKey, } from '../content/constants'; +import { + armor, +} from '../content/gear/sets/special'; -const CURRENT_EVENT = find(EVENTS, event => moment().isBetween(event.start, event.end) - && ['winter', 'spring', 'summer', 'fall'].includes(event.season)); +const CURRENT_EVENT_KEY = getCurrentGalaKey(); + +function getCurrentSeasonalSets () { + const year = new Date().getFullYear(); + return { + rogue: armor[`${CURRENT_EVENT_KEY}${year}Rogue`].set, + warrior: armor[`${CURRENT_EVENT_KEY}${year}Warrior`].set, + wizard: armor[`${CURRENT_EVENT_KEY}${year}Mage`].set, + healer: armor[`${CURRENT_EVENT_KEY}${year}Healer`].set, + }; +} export default { - opened: CURRENT_EVENT, - - currentSeason: CURRENT_EVENT ? upperFirst(CURRENT_EVENT.season) : 'Closed', - - dateRange: { - start: CURRENT_EVENT ? moment(CURRENT_EVENT.start) : moment().subtract(1, 'days').toDate(), - end: CURRENT_EVENT ? moment(CURRENT_EVENT.end) : moment().subtract(1, 'seconds').toDate(), - }, - - availableSets: CURRENT_EVENT - ? [ - ...SEASONAL_SETS[CURRENT_EVENT.season], - ] - : [], - - pinnedSets: CURRENT_EVENT - ? { - rogue: 'spring2024MeltingSnowRogueSet', - warrior: 'spring2024FluoriteWarriorSet', - wizard: 'spring2024HibiscusMageSet', - healer: 'spring2024BluebirdHealerSet', - } - : {}, - - availableSpells: CURRENT_EVENT && moment().isBetween('2024-04-18T08:00-04:00', CURRENT_EVENT.end) - ? [ - 'shinySeed', - ] - : [], - - availableQuests: CURRENT_EVENT && moment().isBetween('2024-03-26T08:00-04:00', CURRENT_EVENT.end) - ? [ - 'egg', - ] - : [], - - featuredSet: 'spring2020LapisLazuliRogueSet', + currentSeason: CURRENT_EVENT_KEY ? upperFirst(CURRENT_EVENT_KEY) : 'Closed', + pinnedSets: getCurrentSeasonalSets(), + featuredSet: 'winter2019PoinsettiaSet', }; diff --git a/website/common/script/libs/shops.js b/website/common/script/libs/shops.js index d486d8e015..944ebf489d 100644 --- a/website/common/script/libs/shops.js +++ b/website/common/script/libs/shops.js @@ -449,8 +449,8 @@ shops.getSeasonalShop = function getSeasonalShop (user, language) { identifier: 'seasonalShop', text: i18n.t('seasonalShop'), notes: i18n.t(`seasonalShop${seasonalShopConfig.currentSeason}Text`), - imageName: seasonalShopConfig.opened ? 'seasonalshop_open' : 'seasonalshop_closed', - opened: seasonalShopConfig.opened, + imageName: 'seasonalshop_open', + opened: true, categories: this.getSeasonalShopCategories(user, language), featured: { text: i18n.t(seasonalShopConfig.featuredSet), @@ -467,10 +467,6 @@ shops.getSeasonalShop = function getSeasonalShop (user, language) { return resObject; }; -// To switch seasons/available inventory, edit the AVAILABLE_SETS object to whatever should be sold. -// let AVAILABLE_SETS = { -// setKey: i18n.t('setTranslationString', language), -// }; shops.getSeasonalShopCategories = function getSeasonalShopCategories (user, language) { const officialPinnedItems = getOfficialPinnedItems(user); @@ -553,4 +549,107 @@ shops.getBackgroundShopSets = function getBackgroundShopSets (language) { return sets; }; +/* Customization Shop */ + +shops.getCustomizationShop = function getCustomizationShop (user, language) { + return { + identifier: 'customizationShop', + text: i18n.t('titleCustomizations'), + notes: i18n.t('timeTravelersPopover'), + imageName: 'npc_timetravelers_active', + categories: shops.getCustomizationShopCategories(user, language), + }; +}; + +shops.getCustomizationShopCategories = function getCustomizationShopCategories (user, language) { + const categories = []; + const officialPinnedItems = getOfficialPinnedItems(user); + + const backgroundCategory = { + identifier: 'backgrounds', + text: i18n.t('backgrounds', language), + items: [], + }; + + const matchers = getScheduleMatchingGroup('backgrounds'); + eachRight(content.backgrounds, (group, key) => { + if (matchers.match(key)) { + each(group, bg => { + if (!user.purchased.background[bg.key]) { + const item = getItemInfo( + user, + 'background', + bg, + officialPinnedItems, + language, + ); + backgroundCategory.items.push(item); + } + }); + } + }); + categories.push(backgroundCategory); + + const facialHairCategory = { + identifier: 'facialHair', + text: i18n.t('titleFacialHair', language), + items: [], + }; + const customizationMatcher = getScheduleMatchingGroup('customizations'); + each(['color', 'base', 'mustache', 'beard'], hairType => { + let category; + if (hairType === 'beard' || hairType === 'mustache') { + category = facialHairCategory; + } else { + category = { + identifier: hairType, + text: i18n.t(`titleHair${hairType}`, language), + items: [], + }; + } + eachRight(content.appearances.hair[hairType], (hairStyle, key) => { + if (hairStyle.price > 0 && (!user.purchased.hair || !user.purchased.hair[hairType] + || !user.purchased.hair[hairType][key]) + && customizationMatcher.match(hairStyle.set.key)) { + const item = getItemInfo( + user, + `hair${hairType}`, + hairStyle, + officialPinnedItems, + language, + ); + category.items.push(item); + } + }); + // only add the facial hair category once + if (hairType !== 'beard') { + categories.push(category); + } + }); + + each(['shirt', 'skin'], type => { + const category = { + identifier: type, + text: i18n.t(type, language), + items: [], + }; + eachRight(content.appearances[type], (appearance, key) => { + if (appearance.price > 0 && (!user.purchased[type] || !user.purchased[type][key]) + && customizationMatcher.match(appearance.set.key)) { + const item = getItemInfo( + user, + type, + appearance, + officialPinnedItems, + language, + ); + category.items.push(item); + } + }); + categories.push(category); + }); + + return categories; +}; + export default shops; diff --git a/website/server/controllers/api-v3/shops.js b/website/server/controllers/api-v3/shops.js index efaaff2395..349abd83df 100644 --- a/website/server/controllers/api-v3/shops.js +++ b/website/server/controllers/api-v3/shops.js @@ -144,4 +144,26 @@ api.getBackgroundShopItems = { }, }; +/** + * @apiIgnore + * @api {get} /api/v3/shops/customizations get the available items for the backgrounds shop + * @apiName GetCustomizationShopItems + * @apiGroup Shops + * + * @apiSuccess {Object} data List of available backgrounds + * @apiSuccess {string} message Success message + */ +api.getBackgroundShopItems = { + method: 'GET', + url: '/shops/customizations', + middlewares: [authWithHeaders()], + async handler (req, res) { + const { user } = res.locals; + + const resObject = shops.getCustomizationShop(user, req.language); + + res.respond(200, resObject); + }, +}; + export default api;