Files
habitica/website/client/src/components/shops/timeTravelers/index.vue
Fiz ae4130b108 Add backend support for Hydra mount (#15482)
* chore: update time travelers shop to display seasonal backgrounds

* chore: update time travelers banner (note CSS borken rn)

* chore: fix borken CSS and update logic in shop

* chore: added isSubscribed function, not working

* chore: isSubscribed working but no bg for subscribers

* chore: logic and css updates

* chore: update habitica-images

* chore: add check for trinket

* chore: more time traveler shop logicking

* Add backend support for Hydra mount

- Add Dragon-Hydra to special mounts in stable.js
  - Configure as contributor level 7 reward with canFind: true
  - Add GIF format support for mount sprites
  - Enable admin panel granting capability

* Fix Vue template errors in timeTravelers component

* Fix duplicate template block in timeTravelers component

* add CSS for Hydra mount GIF sprites

Added CSS rules for Mount_Head_Dragon-Hydra and Mount_Body_Dragon-Hydra GIF sprites

* Remove the separate Hydra mount dimension declaration

---------

Co-authored-by: CuriousMagpie <eilatan@gmail.com>
2025-08-05 15:12:44 -05:00

398 lines
11 KiB
Vue

<template>
<div class="row timeTravelers">
<div
class="standard-sidebar d-none d-sm-block"
>
<filter-sidebar>
<div
slot="search"
class="form-group"
>
<input
v-model="searchText"
class="form-control input-search"
type="text"
:placeholder="$t('search')"
>
</div>
<filter-group>
<checkbox
v-for="category in categories"
:id="`category-${category.identifier}`"
:key="category.identifier"
:checked.sync="viewOptions[category.identifier].selected"
:text="category.text"
/>
</filter-group>
<div class="form-group clearfix">
<h3
v-once
class="float-left"
>
{{ $t('hidePinned') }}
</h3>
<toggle-switch
v-model="hidePinned"
class="float-right"
/>
</div>
</filter-sidebar>
</div>
<div class="standard-page">
<div class="featuredItems">
<div
v-if="isSubscribed || (hasTrinket && !isSubscribed)"
class="background"
:style="{'background-image': imageURLs.background}"
>
<div
class="npc"
:style="{'background-image': imageURLs.npc}"
>
<div class="featured-label">
<span class="rectangle"></span>
<span
v-once
class="text"
>
{{ $t('timeTravelers') }}
</span>
<span class="rectangle"></span>
</div>
</div>
</div>
<div class="content">
<div
class="background"
:style="{'background-image': imageURLs.background}"
>
<div
class="npc"
:style="{'background-image': imageURLs.npc}"
>
<div class="featured-label">
<span class="rectangle"></span>
<span
v-once
class="text"
>
{{ $t('timeTravelers') }}
</span>
<span class="rectangle"></span>
</div>
</div>
<div
v-if="!isSubscribed && !hasTrinket"
class="shop-message featured-label with-border closed"
>
<span class="rectangle"></span>
<span
v-once
class="text"
>
{{ $t('timeTravelersPopoverNoSubMobile') }}
</span>
<span class="rectangle"></span>
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-between w-items">
<h1
v-once
class="page-header mt-4 mb-4"
>
{{ $t('timeTravelers') }}
</h1>
<div
class="clearfix mt-4"
>
<div class="float-right">
<span class="dropdown-label">{{ $t('sortBy') }}</span>
<select-translated-array
:right="true"
:value="selectedSortItemsBy"
:items="sortItemsBy"
:inline-dropdown="false"
class="inline"
@select="selectedSortItemsBy = $event"
/>
</div>
</div>
</div>
<!-- eslint-disable vue/no-use-v-if-with-v-for -->
<div
v-for="category in categories"
v-if="!anyFilterSelected || viewOptions[category.identifier].selected"
:key="category.identifier"
:class="category.identifier"
>
<!-- eslint-enable vue/no-use-v-if-with-v-for -->
<h2 class="mb-3">
{{ category.text }}
</h2><itemRows
:items="travelersItems(category, selectedSortItemsBy, searchTextThrottled, hidePinned)"
:item-width="94"
:item-margin="24"
:type="category.identifier"
:fold-button="false"
:no-items-label="$t('allEquipmentOwned')"
:click-handler="false"
>
<template
slot="item"
slot-scope="ctx"
>
<shopItem
:key="ctx.item.key"
:item="ctx.item"
:price="ctx.item.value"
:price-type="ctx.item.currency"
:empty-item="false"
@click="selectItemToBuy(ctx.item)"
/>
</template>
</itemRows>
</div>
</div>
<buyQuestModal
:item="selectedItemToBuy || {}"
:price-type="selectedItemToBuy ? selectedItemToBuy.currency : ''"
:with-pin="true"
@change="resetItemToBuy($event)"
>
<template
slot="item"
slot-scope="ctx"
>
<item
class="flat"
:item="ctx.item"
:item-content-class="ctx.item.class"
:show-popover="false"
/>
</template>
</buyQuestModal>
</div>
</template>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
@import '@/assets/scss/shops.scss';
.w-items {
max-width: 920px;
}
</style>
<script>
import _filter from 'lodash/filter';
import _sortBy from 'lodash/sortBy';
import _throttle from 'lodash/throttle';
import _groupBy from 'lodash/groupBy';
import _map from 'lodash/map';
import _find from 'lodash/find';
import moment from 'moment';
import isPinned from '@/../../common/script/libs/isPinned';
import shops from '@/../../common/script/libs/shops';
import { mapState } from '@/libs/store';
import ShopItem from '../shopItem';
import Item from '@/components/inventory/item';
import ItemRows from '@/components/ui/itemRows';
import toggleSwitch from '@/components/ui/toggleSwitch';
import BuyQuestModal from '../quests/buyQuestModal.vue';
import svgHourglass from '@/assets/svg/hourglass.svg?raw';
import pinUtils from '@/mixins/pinUtils';
import FilterSidebar from '@/components/ui/filterSidebar';
import FilterGroup from '@/components/ui/filterGroup';
import Checkbox from '@/components/ui/checkbox';
import SelectTranslatedArray from '@/components/tasks/modal-controls/selectTranslatedArray';
export default {
components: {
SelectTranslatedArray,
Checkbox,
FilterGroup,
FilterSidebar,
ShopItem,
Item,
ItemRows,
toggleSwitch,
BuyQuestModal,
},
mixins: [pinUtils],
data () {
return {
viewOptions: {},
searchText: null,
searchTextThrottled: null,
icons: Object.freeze({
hourglass: svgHourglass,
}),
sortItemsBy: ['AZ', 'sortByNumber'],
selectedSortItemsBy: 'AZ',
selectedItemToBuy: null,
hidePinned: false,
backgroundUpdate: new Date(),
currentEvent: null,
imageURLs: {
background: '',
npc: '',
},
};
},
computed: {
...mapState({
content: 'content',
quests: 'shops.quests.data',
user: 'user.data',
userStats: 'user.data.stats',
userItems: 'user.data.items',
currentEventList: 'worldState.data.currentEventList',
}),
isSubscribed () {
const now = new Date();
const { plan } = this.user.purchased;
return plan && plan.customerId
&& (!plan.dateTerminated || moment(plan.dateTerminated).isAfter(now));
},
hasTrinket () {
return this.user.purchased.plan.consecutive.trinkets > 0;
},
shop () {
return shops.getTimeTravelersShop(this.user);
},
categories () {
const apiCategories = this.shop.categories;
// FIX ME Refactor the apiCategories Hack to
// force update for now until we restructure the data
let backgroundUpdate = this.backgroundUpdate; // eslint-disable-line
const normalGroups = _filter(apiCategories, c => c.identifier === 'mounts' || c.identifier === 'pets' || c.identifier === 'quests' || c.identifier === 'backgrounds');
const setGroups = _filter(apiCategories, c => c.identifier !== 'mounts' && c.identifier !== 'pets' && c.identifier !== 'quests' && c.identifier !== 'backgrounds');
const setCategory = {
identifier: 'sets',
text: this.$t('mysterySets'),
items: setGroups.map(c => ({
...c,
value: 1,
currency: 'hourglasses',
key: c.identifier,
class: `shop_set_mystery_${c.identifier}`,
purchaseType: 'mystery_set',
})),
};
normalGroups.push(setCategory);
normalGroups.forEach(category => {
// do not reset the viewOptions if already set once
if (typeof this.viewOptions[category.identifier] === 'undefined') {
this.$set(this.viewOptions, category.identifier, {
selected: false,
});
}
});
return normalGroups;
},
anyFilterSelected () {
return Object.values(this.viewOptions).some(g => g.selected);
},
},
watch: {
searchText: _throttle(function throttleSearch () {
this.searchTextThrottled = this.searchText.toLowerCase();
}, 250),
},
mounted () {
this.$store.dispatch('common:setTitle', {
subSection: this.$t('timeTravelers'),
section: this.$t('shops'),
});
this.$root.$on('buyModal::boughtItem', () => {
this.backgroundUpdate = new Date();
});
this.$root.$on('bv::modal::hidden', event => {
if (event.componentId === 'buy-quest-modal') {
this.$root.$emit('buyModal::hidden', this.selectedItemToBuy.key);
}
});
this.currentEvent = _find(this.currentEventList, event => Boolean(['winter', 'spring', 'summer', 'fall'].includes(event.season)));
if (!this.currentEvent || !this.currentEvent.season || this.currentEvent.season === 'thanksgiving') {
this.imageURLs.background = 'url(/static/npc/normal/time_travelers_background.png)';
} else {
this.imageURLs.background = `url(/static/npc/${this.currentEvent.season}/time_travelers_background.png)`;
this.imageURLs.npc = `url(/static/npc/${this.currentEvent.season}/time_travelers_open_banner.png)`;
}
},
beforeDestroy () {
this.$root.$off('buyModal::boughtItem');
},
methods: {
travelersItems (category, sortBy, searchBy, hidePinned) {
let result = _map(category.items, e => ({
...e,
pinned: isPinned(this.user, e),
}));
result = _filter(result, i => {
if (hidePinned && i.pinned) {
return false;
}
return !searchBy || i.text.toLowerCase().indexOf(searchBy) !== -1;
});
switch (sortBy) { // eslint-disable-line default-case
case 'AZ': {
result = _sortBy(result, ['text']);
break;
}
case 'sortByNumber': {
result = _sortBy(result, ['value']);
break;
}
}
return result;
},
getGrouped (entries) {
return _groupBy(entries, 'group');
},
selectItemToBuy (item) {
if (item.purchaseType === 'quests') {
this.selectedItemToBuy = item;
this.$root.$emit('bv::show::modal', 'buy-quest-modal');
} else {
this.$root.$emit('buyModal::showItem', item);
}
},
resetItemToBuy ($event) {
if (!$event) {
this.selectedItemToBuy = null;
}
},
},
};
</script>