diff --git a/test/client/unit/specs/components/inventory/drawer.js b/test/client/unit/specs/components/ui/drawer.js
similarity index 87%
rename from test/client/unit/specs/components/inventory/drawer.js
rename to test/client/unit/specs/components/ui/drawer.js
index b76b76718f..fa252b84d7 100644
--- a/test/client/unit/specs/components/inventory/drawer.js
+++ b/test/client/unit/specs/components/ui/drawer.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import DrawerComponent from 'client/components/inventory/drawer.vue';
+import DrawerComponent from 'client/components/ui/drawer.vue';
describe('DrawerComponent', () => {
it('sets the correct default data', () => {
diff --git a/test/common/ops/sell.js b/test/common/ops/sell.js
index da62c6c776..11c9676c03 100644
--- a/test/common/ops/sell.js
+++ b/test/common/ops/sell.js
@@ -65,6 +65,16 @@ describe('shared.ops.sell', () => {
}
});
+ it('returns an error when the requested amount is above the available amount', (done) => {
+ try {
+ sell(user, {params: { type, key }, query: {amount: 2} });
+ } catch (err) {
+ expect(err).to.be.an.instanceof(NotFound);
+ expect(err.message).to.equal(i18n.t('userItemsNotEnough', {type}));
+ done();
+ }
+ });
+
it('reduces item count from user', () => {
sell(user, {params: { type, key } });
diff --git a/website/client/assets/images/market/market_banner_web_alexnpc.png b/website/client/assets/images/market/market_banner_web_alexnpc.png
new file mode 100644
index 0000000000..38121f7a88
Binary files /dev/null and b/website/client/assets/images/market/market_banner_web_alexnpc.png differ
diff --git a/website/client/assets/images/market/shop_background.png b/website/client/assets/images/market/shop_background.png
new file mode 100644
index 0000000000..d88617d9dd
Binary files /dev/null and b/website/client/assets/images/market/shop_background.png differ
diff --git a/website/client/assets/scss/banner.scss b/website/client/assets/scss/banner.scss
new file mode 100644
index 0000000000..622c247062
--- /dev/null
+++ b/website/client/assets/scss/banner.scss
@@ -0,0 +1,36 @@
+
+@import '~client/assets/scss/colors.scss';
+
+.featured-label {
+ width: auto;
+ height: 28px;
+ border-radius: 2px;
+ background-color: #b36213;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+
+ &.with-border {
+ border: solid 2px $yellow-10;
+ height: 32px;
+ }
+
+ .rectangle {
+ margin: 9px;
+ display: inline-block;
+ width: 6px;
+ height: 6px;
+ -webkit-transform: rotate(-45deg);
+ transform: rotate(-45deg);
+ background-color: $yellow-100;
+ border: solid 2px $yellow-10;
+ }
+
+ .text {
+ font-size: 12px;
+ font-weight: bold;
+ line-height: 1.33;
+ color: $white;
+ flex: 1;
+ }
+}
diff --git a/website/client/assets/scss/dropdown.scss b/website/client/assets/scss/dropdown.scss
index 4fa36b5473..6923bddfd7 100644
--- a/website/client/assets/scss/dropdown.scss
+++ b/website/client/assets/scss/dropdown.scss
@@ -59,3 +59,14 @@
margin-right: 20px;
margin-left: 20px;
}
+
+.dropdown-icon-item {
+ .svg-icon {
+ margin: 0px 16px 0px 0px;
+ vertical-align: middle;
+ }
+
+ .text {
+ vertical-align: middle;
+ }
+}
diff --git a/website/client/assets/scss/icon.scss b/website/client/assets/scss/icon.scss
index 63c2d18f6f..933f032963 100644
--- a/website/client/assets/scss/icon.scss
+++ b/website/client/assets/scss/icon.scss
@@ -1,9 +1,9 @@
.svg-icon {
display: block;
stroke-width: 0;
+ transition: none !important;
stroke: currentColor;
fill: currentColor;
- transition: none !important;
svg {
display: block;
@@ -12,6 +12,13 @@
* {
transition: none !important;
}
+
+ &.color {
+ svg path {
+ stroke: currentColor;
+ fill: currentColor;
+ }
+ }
}
.icon-16 {
diff --git a/website/client/assets/scss/index.scss b/website/client/assets/scss/index.scss
index 4a60ede1b3..492048832a 100644
--- a/website/client/assets/scss/index.scss
+++ b/website/client/assets/scss/index.scss
@@ -25,3 +25,4 @@
@import './task';
@import './categories';
@import './dragdrop';
+@import './banner';
diff --git a/website/client/assets/scss/item.scss b/website/client/assets/scss/item.scss
index 63f4babe4e..f880f096a5 100644
--- a/website/client/assets/scss/item.scss
+++ b/website/client/assets/scss/item.scss
@@ -48,6 +48,10 @@
}
}
+.flat .item {
+ box-shadow: none;
+}
+
.drawer-content .item:hover {
border-color: transparent;
box-shadow: none;
diff --git a/website/client/assets/scss/modal.scss b/website/client/assets/scss/modal.scss
new file mode 100644
index 0000000000..acfd201f42
--- /dev/null
+++ b/website/client/assets/scss/modal.scss
@@ -0,0 +1,36 @@
+
+@import '~client/assets/scss/colors.scss';
+
+
+@mixin centeredModal() {
+ display: flex;
+ justify-content: center;
+ flex-direction: column;
+
+ header, footer {
+ border: 0;
+ }
+}
+
+.modal-dialog {
+ .title {
+ height: 24px;
+ margin-top: 24px;
+ font-family: 'Roboto Condensed';
+ font-size: 20px;
+ font-weight: bold;
+ line-height: 1.2;
+ text-align: center;
+ color: $gray-50;
+ }
+
+ .text {
+ height: 60px;
+ font-family: Roboto;
+ font-size: 14px;
+ line-height: 1.43;
+ text-align: center;
+ color: $gray-100;
+ }
+
+}
diff --git a/website/client/assets/svg/clock.svg b/website/client/assets/svg/clock.svg
new file mode 100644
index 0000000000..5f8c2a41b5
--- /dev/null
+++ b/website/client/assets/svg/clock.svg
@@ -0,0 +1,6 @@
+
diff --git a/website/client/assets/svg/lock.svg b/website/client/assets/svg/lock.svg
new file mode 100644
index 0000000000..0ac53170bd
--- /dev/null
+++ b/website/client/assets/svg/lock.svg
@@ -0,0 +1,3 @@
+
diff --git a/website/client/assets/svg/pin.svg b/website/client/assets/svg/pin.svg
new file mode 100644
index 0000000000..2e0bf00699
--- /dev/null
+++ b/website/client/assets/svg/pin.svg
@@ -0,0 +1,3 @@
+
diff --git a/website/client/components/appMenu.vue b/website/client/components/appMenu.vue
index e9a4b32059..400cba4e70 100644
--- a/website/client/components/appMenu.vue
+++ b/website/client/components/appMenu.vue
@@ -14,8 +14,13 @@ div
router-link.dropdown-item(:to="{name: 'items'}", exact) {{ $t('items') }}
router-link.dropdown-item(:to="{name: 'equipment'}") {{ $t('equipment') }}
router-link.dropdown-item(:to="{name: 'stable'}") {{ $t('stable') }}
- router-link.nav-item(tag="li", :to="{name: 'shops'}", exact)
+ router-link.nav-item.dropdown(tag="li", :to="{name: 'market'}", :class="{'active': $route.path.startsWith('/shop')}")
a.nav-link(v-once) {{ $t('shops') }}
+ .dropdown-menu
+ router-link.dropdown-item(:to="{name: 'market'}", exact) {{ $t('market') }}
+ router-link.dropdown-item(:to="{name: 'quests'}") {{ $t('quests') }}
+ router-link.dropdown-item(:to="{name: 'seasonal'}") {{ $t('titleSeasonalShop') }}
+ router-link.dropdown-item(:to="{name: 'time'}") {{ $t('titleTimeTravelers') }}
router-link.nav-item(tag="li", :to="{name: 'party'}")
a.nav-link(v-once) {{ $t('party') }}
router-link.nav-item.dropdown(tag="li", :to="{name: 'tavern'}", :class="{'active': $route.path.startsWith('/guilds')}")
diff --git a/website/client/components/avatar.vue b/website/client/components/avatar.vue
index 165323d739..cf32d38e9e 100644
--- a/website/client/components/avatar.vue
+++ b/website/client/components/avatar.vue
@@ -16,21 +16,21 @@
// Show avatar only if not currently affected by visual buff
template(v-if!="!member.stats.buffs.snowball && !member.stats.buffs.spookySparkles && !member.stats.buffs.shinySeed && !member.stats.buffs.seafoam")
span(:class="'chair_' + member.preferences.chair")
- span(:class="member.items.gear[costumeClass].back")
+ span(:class="getGearClass('back')")
span(:class="skinClass")
span.head_0
span(:class="member.preferences.size + '_shirt_' + member.preferences.shirt")
- span(:class="member.preferences.size + '_' + member.items.gear[costumeClass].armor")
- span(:class="member.items.gear[costumeClass].back_collar")
- span(:class="member.items.gear[costumeClass].body")
+ span(:class="member.preferences.size + '_' + getGearClass('armor')")
+ span(:class="getGearClass('back_collar')")
+ span(:class="getGearClass('body')")
template(v-for="type in ['base', 'bangs', 'mustache', 'beard']")
span(:class="'hair_' + type + '_' + member.preferences.hair[type] + '_' + member.preferences.hair.color")
- span(:class="member.items.gear[costumeClass].eyewear")
- span(:class="member.items.gear[costumeClass].head")
- span(:class="member.items.gear[costumeClass].headAccessory")
+ span(:class="getGearClass('eyewear')")
+ span(:class="getGearClass('head')")
+ span(:class="getGearClass('headAccessory')")
span(:class="'hair_flower_' + member.preferences.hair.flower")
- span(:class="member.items.gear[costumeClass].shield")
- span(:class="member.items.gear[costumeClass].weapon")
+ span(:class="getGearClass('shield')")
+ span(:class="getGearClass('weapon')")
// Resting
span.zzz(v-if="member.preferences.sleep")
@@ -105,6 +105,12 @@ export default {
type: Boolean,
default: false,
},
+ withBackground: {
+ type: Boolean,
+ },
+ overrideAvatarGear: {
+ type: Object,
+ },
width: {
type: Number,
default: 140,
@@ -144,7 +150,9 @@ export default {
backgroundClass () {
let background = this.member.preferences.background;
- if (background && !this.avatarOnly) {
+ let allowToShowBackground = !this.avatarOnly || this.withBackground;
+
+ if (background && allowToShowBackground) {
return `background_${this.member.preferences.background}`;
}
@@ -167,5 +175,16 @@ export default {
return this.member.preferences.costume ? 'costume' : 'equipped';
},
},
+ methods: {
+ getGearClass (gearType) {
+ let result = this.member.items.gear[this.costumeClass][gearType];
+
+ if (this.overrideAvatarGear && this.overrideAvatarGear[gearType]) {
+ result = this.overrideAvatarGear[gearType];
+ }
+
+ return result;
+ },
+ },
};
diff --git a/website/client/components/inventory/equipment/index.vue b/website/client/components/inventory/equipment/index.vue
index f5abed5302..5a7e2c42ec 100644
--- a/website/client/components/inventory/equipment/index.vue
+++ b/website/client/components/inventory/equipment/index.vue
@@ -134,8 +134,8 @@ import toggleSwitch from 'client/components/ui/toggleSwitch';
import Item from 'client/components/inventory/item';
import EquipmentAttributesPopover from 'client/components/inventory/equipment/attributesPopover';
-import StarBadge from 'client/components/inventory/starBadge';
-import Drawer from 'client/components/inventory/drawer';
+import StarBadge from 'client/components/ui/starBadge';
+import Drawer from 'client/components/ui/drawer';
import i18n from 'common/script/i18n';
diff --git a/website/client/components/inventory/item.vue b/website/client/components/inventory/item.vue
index 6a55ac07eb..ce7b8fd005 100644
--- a/website/client/components/inventory/item.vue
+++ b/website/client/components/inventory/item.vue
@@ -6,7 +6,7 @@ div(v-if="emptyItem")
span.item-label(v-if="label") {{ label }}
b-popover(
v-else,
- :triggers="['hover']",
+ :triggers="[showPopover?'hover':'']",
:placement="popoverPosition",
)
span(slot="content")
@@ -46,6 +46,10 @@ export default {
type: String,
default: 'bottom',
},
+ showPopover: {
+ type: Boolean,
+ default: true,
+ },
},
methods: {
click () {
diff --git a/website/client/components/inventory/stable/foodItem.vue b/website/client/components/inventory/stable/foodItem.vue
index 2a63bd37e9..7521065649 100644
--- a/website/client/components/inventory/stable/foodItem.vue
+++ b/website/client/components/inventory/stable/foodItem.vue
@@ -25,7 +25,7 @@ b-popover(
import bPopover from 'bootstrap-vue/lib/components/popover';
import DragDropDirective from 'client/directives/dragdrop.directive';
-import CountBadge from './countBadge';
+import CountBadge from 'client/components/ui/countBadge';
export default {
components: {
diff --git a/website/client/components/inventory/stable/index.vue b/website/client/components/inventory/stable/index.vue
index 3846d06d05..7e369b9407 100644
--- a/website/client/components/inventory/stable/index.vue
+++ b/website/client/components/inventory/stable/index.vue
@@ -248,6 +248,7 @@
+
+
diff --git a/website/client/components/shops/market/buyModal.vue b/website/client/components/shops/market/buyModal.vue
new file mode 100644
index 0000000000..6002385f72
--- /dev/null
+++ b/website/client/components/shops/market/buyModal.vue
@@ -0,0 +1,195 @@
+
+ b-modal#buy-modal(
+ :visible="true",
+ v-if="item != null",
+ :hide-header="true",
+ @change="onChange($event)"
+ )
+ span.badge.badge-pill.badge-dialog(
+ :class="{'item-selected-badge': true}",
+ v-if="withPin"
+ )
+ span.svg-icon.inline.color.icon-10(v-html="icons.pin")
+
+ div.close
+ span.svg-icon.inline.icon-10(aria-hidden="true", v-html="icons.close", @click="hideDialog()")
+
+ div.content(v-if="item != null")
+
+ div.inner-content
+ slot(name="item", :item="item")
+
+ h4.title {{ itemText }}
+ div.text {{ itemNotes }}
+
+ slot(name="additionalInfo", :item="item")
+
+ div
+ span.svg-icon.inline.icon-32(aria-hidden="true", v-html="(priceType === 'gems') ? icons.gem : icons.gold")
+ span.value(:class="priceType") {{ item.value }}
+
+ button.btn.btn-primary(@click="buyItem()") {{ $t('buyNow') }}
+
+ div.clearfix(slot="modal-footer")
+ span.balance.float-left {{ $t('yourBalance') }}
+ balanceInfo.float-right
+
+
+
+
+
+
diff --git a/website/client/components/shops/market/equipmentAttributesGrid.vue b/website/client/components/shops/market/equipmentAttributesGrid.vue
new file mode 100644
index 0000000000..3e645d7041
--- /dev/null
+++ b/website/client/components/shops/market/equipmentAttributesGrid.vue
@@ -0,0 +1,55 @@
+
+ div
+ .attribute-entry(v-for="attr in ATTRIBUTES", :key="attr")
+ span.key(:class="{'no-value': item[attr] == '0'}") {{ `${$t(attr)}: ` }}
+ span.val(:class="{'no-value': item[attr] == '0'}") {{ `+${item[attr]}` }}
+
+
+
+
+
diff --git a/website/client/components/shops/market/index.vue b/website/client/components/shops/market/index.vue
new file mode 100644
index 0000000000..e54d98b65e
--- /dev/null
+++ b/website/client/components/shops/market/index.vue
@@ -0,0 +1,667 @@
+
+ .row.market
+ .standard-sidebar
+ .form-group
+ input.form-control.input-search(type="text", v-model="searchText", :placeholder="$t('search')")
+
+ .form
+ h2(v-once) {{ $t('filter') }}
+ .form-group
+ .form-check(
+ v-for="category in categories",
+ :key="category.identifier",
+ )
+ label.custom-control.custom-checkbox
+ input.custom-control-input(type="checkbox", v-model="viewOptions[category.identifier].selected")
+ span.custom-control-indicator
+ span.custom-control-description(v-once) {{ category.text }}
+
+ div.form-group.clearfix
+ h3.float-left Hide locked
+ toggle-switch.float-right.hideMissing(
+ :label="''",
+ v-model="hideLocked",
+ )
+ div.form-group.clearfix
+ h3.float-left Hide pinned
+ toggle-switch.float-right.hideMissing(
+ :label="''",
+ v-model="hidePinned",
+ )
+ .standard-page
+ div.featuredItems
+ .background
+ div.npc
+ div.featured-label
+ span.rectangle
+ span.text Alex
+ span.rectangle
+ div.content
+ div.featured-label.with-border
+ span.rectangle
+ span.text(v-once) {{ $t('featuredItems') }}
+ span.rectangle
+
+ div.items.margin-center
+ shopItem(
+ v-for="item in featuredItems",
+ :key="item.key",
+ :item="item",
+ :price="item.value",
+ :priceType="item.currency",
+ :itemContentClass="'shop_'+item.key",
+ :emptyItem="false",
+ :popoverPosition="'top'",
+ @click="selectedGearToBuy = item"
+ )
+ template(slot="popoverContent", scope="ctx")
+ equipmentAttributesPopover(:item="ctx.item")
+
+ h1.mb-0.page-header(v-once) {{ $t('market') }}
+
+ .clearfix
+ h2.float-left
+ | {{ $t('classEquipment') }}
+
+ div.float-right
+ span.dropdown-label {{ $t('class') }}
+ b-dropdown(right=true)
+ span.dropdown-icon-item(slot="text")
+ span.svg-icon.inline.icon-16(v-html="icons[selectedGroupGearByClass]")
+ span.text {{ getClassName(selectedGroupGearByClass) }}
+
+ b-dropdown-item(
+ v-for="sort in content.classes",
+ @click="selectedGroupGearByClass = sort",
+ :active="selectedGroupGearByClass === sort",
+ :key="sort"
+ )
+ span.dropdown-icon-item
+ span.svg-icon.inline.icon-16(v-html="icons[sort]")
+ span.text {{ getClassName(sort) }}
+
+ span.dropdown-label {{ $t('sortBy') }}
+ b-dropdown(:text="$t(selectedSortGearBy)", right=true)
+ b-dropdown-item(
+ v-for="sort in sortGearBy",
+ @click="selectedSortGearBy = sort",
+ :active="selectedSortGearBy === sort",
+ :key="sort"
+ ) {{ $t(sort) }}
+
+ br
+
+ itemRows(
+ :items="filteredGear(selectedGroupGearByClass, searchTextThrottled, selectedSortGearBy, hideLocked, hidePinned)",
+ :itemWidth=94,
+ :itemMargin=24,
+ :showAllLabel="$t('showAllEquipment', { classType: getClassName(selectedGroupGearByClass) })",
+ :showLessLabel="$t('showLessEquipment', { classType: getClassName(selectedGroupGearByClass) })"
+ )
+ template(slot="item", scope="ctx")
+ shopItem(
+ :key="ctx.item.key",
+ :item="ctx.item",
+ :price="ctx.item.value",
+ :priceType="ctx.item.currency",
+ :itemContentClass="'shop_'+ctx.item.key",
+ :emptyItem="userItems.gear[ctx.item.key] === undefined",
+ :popoverPosition="'top'",
+ @click="selectedGearToBuy = ctx.item"
+ )
+ template(slot="popoverContent", scope="ctx")
+ equipmentAttributesPopover(:item="ctx.item")
+ div {{ ctx.item }}
+
+ template(slot="itemBadge", scope="ctx")
+ span.badge.badge-pill.badge-item.badge-svg(
+ :class="{'item-selected-badge': ctx.item.pinned, 'hide': !ctx.item.pinned}",
+ @click.prevent.stop="togglePinned(ctx.item)"
+ )
+ span.svg-icon.inline.icon-12.color(v-html="icons.pin")
+
+ .clearfix
+ h2.float-left
+ | {{ $t('items') }}
+
+ div.float-right
+ span.dropdown-label {{ $t('sortBy') }}
+ b-dropdown(:text="$t(selectedSortItemsBy)", right=true)
+ b-dropdown-item(
+ v-for="sort in sortItemsBy",
+ @click="selectedSortItemsBy = sort",
+ :active="selectedSortItemsBy === sort",
+ :key="sort"
+ ) {{ $t(sort) }}
+
+
+ div(
+ v-for="category in categories",
+ v-if="viewOptions[category.identifier].selected"
+ )
+ h4 {{ category.text }}
+
+ div.items
+ shopItem(
+ v-for="item in sortedMarketItems(category, selectedSortItemsBy, searchTextThrottled)",
+ :key="item.key",
+ :item="item",
+ :price="item.value",
+ :priceType="item.currency",
+ :itemContentClass="item.class",
+ :emptyItem="false",
+ :popoverPosition="'top'",
+ @click="selectedItemToBuy = item"
+ )
+ span(slot="popoverContent")
+ h4.popover-content-title {{ item.text }}
+ div {{ item }}
+ div {{ userItems[item.purchaseType][item.key] }}
+ template(slot="itemBadge", scope="ctx")
+ countBadge(
+ :show="true",
+ :count="userItems[item.purchaseType][item.key] || 0"
+ )
+
+
+ drawer(
+ :title="$t('quickInventory')"
+ )
+ div(slot="drawer-header")
+ drawer-header-tabs(
+ :tabs="drawerTabs",
+ @changedPosition="tabSelected($event)"
+ )
+ div(slot="right-item")
+ b-popover(
+ :triggers="['click']",
+ :placement="'top'",
+ )
+ span(slot="content")
+ .popover-content-text(v-html="$t('petLikeToEatText')", v-once)
+
+ div.hand-cursor(v-once)
+ | {{ $t('petLikeToEat') + ' ' }}
+ span.svg-icon.inline.icon-16(v-html="icons.information")
+
+ drawer-slider(
+ :items="ownedItems(selectedDrawerItemType) || []",
+ slot="drawer-slider",
+ :itemWidth=94,
+ :itemMargin=24,
+ )
+ template(slot="item", scope="ctx")
+ item(
+ :item="ctx.item",
+ :itemContentClass="getItemClass(selectedDrawerItemType, ctx.item.key)",
+ popoverPosition="top",
+ @click="selectedItemToSell = ctx.item"
+ )
+ template(slot="itemBadge", scope="ctx")
+ countBadge(
+ :show="true",
+ :count="userItems[drawerTabs[selectedDrawerTab].contentType][ctx.item.key] || 0"
+ )
+ span(slot="popoverContent")
+ h4.popover-content-title {{ ctx.item.text() }}
+
+ sellModal(
+ :item="selectedItemToSell",
+ :itemType="selectedDrawerItemType",
+ :itemCount="selectedItemToSell != null ? userItems[drawerTabs[selectedDrawerTab].contentType][selectedItemToSell.key] : 0",
+ @change="resetItemToSell($event)"
+ )
+ template(slot="item", scope="ctx")
+ item.flat(
+ :item="ctx.item",
+ :itemContentClass="getItemClass(selectedDrawerItemType, ctx.item.key)",
+ :showPopover="false"
+ )
+ template(slot="itemBadge", scope="ctx")
+ countBadge(
+ :show="true",
+ :count="userItems[drawerTabs[selectedDrawerTab].contentType][ctx.item.key] || 0"
+ )
+
+ buyModal(
+ :item="selectedGearToBuy",
+ priceType="gold",
+ :withPin="true",
+ @change="resetGearToBuy($event)"
+ )
+ template(slot="item", scope="ctx")
+ div
+ avatar(
+ :member="user",
+ :avatarOnly="true",
+ :withBackground="true",
+ :overrideAvatarGear="memberOverrideAvatarGear(selectedGearToBuy)"
+ )
+
+ template(slot="additionalInfo", scope="ctx")
+ equipmentAttributesGrid.bordered(:item="ctx.item")
+
+ buyModal(
+ :item="selectedItemToBuy",
+ :priceType="selectedItemToBuy ? selectedItemToBuy.currency : ''",
+ @change="resetItemToBuy($event)"
+ )
+ template(slot="item", scope="ctx")
+ item.flat(
+ :item="ctx.item",
+ :itemContentClass="ctx.item.class",
+ :showPopover="false"
+ )
+
+
+
+
+
+
diff --git a/website/client/components/shops/market/sellModal.vue b/website/client/components/shops/market/sellModal.vue
new file mode 100644
index 0000000000..88b2e4ee01
--- /dev/null
+++ b/website/client/components/shops/market/sellModal.vue
@@ -0,0 +1,184 @@
+
+ b-modal#sell-modal(
+ :visible="item != null",
+ :hide-header="true",
+ @change="onChange($event)"
+ )
+ div.close
+ span.svg-icon.inline.icon-10(aria-hidden="true", v-html="icons.close", @click="hideDialog()")
+
+ div.content(v-if="item != null")
+
+ div.inner-content
+ slot(name="item", :item="item")
+
+ h4.title {{ item.text() }}
+ div.text {{ item.notes() }}
+
+ div
+ b.how-many-to-sell {{ $t('howManyToSell') }}
+ div
+ b-dropdown(:text="selectedAmountToSell +''", right=true)
+ b-dropdown-item(
+ v-for="num of dropDownItems",
+ @click="selectedAmountToSell = num",
+ :active="selectedAmountToSell === num",
+ :key="num"
+ ) {{ num }}
+
+ span.svg-icon.inline.icon-32(aria-hidden="true", v-html="icons.gold")
+ span.value {{ item.value }}
+
+ button.btn.btn-primary(@click="sellItems()") {{ $t('sell') }}
+
+ div.clearfix(slot="modal-footer")
+ span.balance.float-left {{ $t('yourBalance') }}
+ balanceInfo.float-right
+
+
+
+
+
+
diff --git a/website/client/components/shops/shopItem.vue b/website/client/components/shops/shopItem.vue
new file mode 100644
index 0000000000..53619cc94b
--- /dev/null
+++ b/website/client/components/shops/shopItem.vue
@@ -0,0 +1,142 @@
+
+b-popover(
+ :triggers="[showPopover?'hover':'']",
+ :placement="popoverPosition",
+)
+ span(slot="content")
+ slot(name="popoverContent", :item="item")
+
+ .item-wrapper(@click="click()")
+ .item(
+ :class="{'item-empty': emptyItem, 'highlight': highlightBorder}",
+ )
+ slot(name="itemBadge", :item="item", :emptyItem="emptyItem")
+ div.shop-content
+ span.svg-icon.inline.lock(v-if="item.locked" v-html="icons.lock")
+
+
+ div.image(:class="itemContentClass")
+
+ div.price
+ span.svg-icon.inline.icon-16(v-html="(priceType === 'gems') ? icons.gem : icons.gold")
+
+ span.price-label(:class="priceType") {{ price }}
+
+
+
+
+
+
diff --git a/website/client/components/inventory/stable/countBadge.vue b/website/client/components/ui/countBadge.vue
similarity index 100%
rename from website/client/components/inventory/stable/countBadge.vue
rename to website/client/components/ui/countBadge.vue
diff --git a/website/client/components/inventory/drawer.vue b/website/client/components/ui/drawer.vue
similarity index 96%
rename from website/client/components/inventory/drawer.vue
rename to website/client/components/ui/drawer.vue
index d82b1be2dc..9602ab93e6 100644
--- a/website/client/components/inventory/drawer.vue
+++ b/website/client/components/ui/drawer.vue
@@ -2,7 +2,7 @@
.drawer-container
.drawer-title(@click="open = !open")
| {{title}}
- .drawer-toggle-icon.svg-icon(v-html="open ? icons.minimize : icons.expand", :class="{ closed: !open }")
+ .drawer-toggle-icon.svg-icon.icon-10(v-html="open ? icons.minimize : icons.expand", :class="{ closed: !open }")
transition(name="slide-up", @afterLeave="adjustPagePadding", @afterEnter="adjustPagePadding")
.drawer-content(v-show="open")
slot(name="drawer-header")
@@ -36,6 +36,7 @@
.drawer-toggle-icon {
float: right;
margin-right: 16px;
+ margin-top: 16px;
&.closed {
margin-top: 3px;
diff --git a/website/client/components/ui/drawerHeaderTabs.vue b/website/client/components/ui/drawerHeaderTabs.vue
new file mode 100644
index 0000000000..4952b112f6
--- /dev/null
+++ b/website/client/components/ui/drawerHeaderTabs.vue
@@ -0,0 +1,65 @@
+
+div.header-tabs
+ .drawer-tab-container
+ .drawer-tab(v-for="(tab, index) in tabs")
+ a.drawer-tab-text(
+ @click="changeTab(index)",
+ :class="{'drawer-tab-text-active': selectedTabPosition === index}",
+ :title="tab.label"
+ ) {{ tab.label }}
+
+ span.right-item
+ slot(name="right-item")
+
+
+
+
+
diff --git a/website/client/components/inventory/stable/drawerSlider.vue b/website/client/components/ui/drawerSlider.vue
similarity index 100%
rename from website/client/components/inventory/stable/drawerSlider.vue
rename to website/client/components/ui/drawerSlider.vue
diff --git a/website/client/components/ui/itemRows.vue b/website/client/components/ui/itemRows.vue
new file mode 100644
index 0000000000..b09adb0ef6
--- /dev/null
+++ b/website/client/components/ui/itemRows.vue
@@ -0,0 +1,71 @@
+
+ .item-rows
+ div.items(v-resize="500", @resized="currentWidth = $event.width")
+ template(v-for="item in itemsToShow(showAll)")
+ slot(
+ name="item",
+ :item="item",
+ )
+
+ .btn.btn-show-more(
+ @click="showAll = !showAll",
+ v-if="items.length > itemsPerRow()"
+ ) {{ showAll ? showLessLabel : showAllLabel }}
+
+
+
+
diff --git a/website/client/components/inventory/starBadge.vue b/website/client/components/ui/starBadge.vue
similarity index 100%
rename from website/client/components/inventory/starBadge.vue
rename to website/client/components/ui/starBadge.vue
diff --git a/website/client/router.js b/website/client/router.js
index b528e0d071..9d9e8737ea 100644
--- a/website/client/router.js
+++ b/website/client/router.js
@@ -84,6 +84,10 @@ const MyChallenges = () => import(/* webpackChunkName: "challenges" */ './compon
const FindChallenges = () => import(/* webpackChunkName: "challenges" */ './components/challenges/findChallenges');
const ChallengeDetail = () => import(/* webpackChunkName: "challenges" */ './components/challenges/challengeDetail');
+// Shops
+const ShopsContainer = () => import(/* webpackChunkName: "shops" */'./components/shops/index');
+const MarketPage = () => import(/* webpackChunkName: "shops-market" */'./components/shops/market/index');
+
Vue.use(VueRouter);
const router = new VueRouter({
@@ -111,7 +115,16 @@ const router = new VueRouter({
{ name: 'stable', path: 'stable', component: StablePage },
],
},
- { name: 'shops', path: '/shops', component: Page },
+ {
+ path: '/shops',
+ component: ShopsContainer,
+ children: [
+ { name: 'market', path: 'market', component: MarketPage },
+ { name: 'quests', path: 'quests', component: Page },
+ { name: 'seasonal', path: 'seasonal', component: Page },
+ { name: 'time', path: 'time', component: Page },
+ ],
+ },
{ name: 'party', path: '/party', component: GuildPage },
{ name: 'groupPlan', path: '/group-plans', component: GroupPlansAppPage },
{
diff --git a/website/client/store/actions/index.js b/website/client/store/actions/index.js
index 4d43b4330f..e6e515a128 100644
--- a/website/client/store/actions/index.js
+++ b/website/client/store/actions/index.js
@@ -13,6 +13,7 @@ import * as chat from './chat';
import * as notifications from './notifications';
import * as tags from './tags';
import * as hall from './hall';
+import * as shops from './shops';
// Actions should be named as 'actionName' and can be accessed as 'namespace:actionName'
// Example: fetch in user.js -> 'user:fetch'
@@ -31,6 +32,7 @@ const actions = flattenAndNamespace({
notifications,
tags,
hall,
+ shops,
});
export default actions;
diff --git a/website/client/store/actions/shops.js b/website/client/store/actions/shops.js
new file mode 100644
index 0000000000..88ed46efe6
--- /dev/null
+++ b/website/client/store/actions/shops.js
@@ -0,0 +1,53 @@
+import axios from 'axios';
+import { loadAsyncResource } from 'client/libs/asyncResource';
+import buyOp from 'common/script/ops/buy';
+import sellOp from 'common/script/ops/sell';
+
+export function fetch (store, forceLoad = false) { // eslint-disable-line no-shadow
+ return loadAsyncResource({
+ store,
+ path: 'shops.market',
+ url: '/api/v3/shops/market',
+ deserialize (response) {
+ return response.data.data;
+ },
+ forceLoad,
+ });
+}
+
+export function buyItem (store, params) {
+ const user = store.state.user.data;
+ buyOp(user, {params});
+ axios
+ .post(`/api/v3/user/buy/${params.key}`);
+ // TODO
+ // .then((res) => console.log('equip', res))
+ // .catch((err) => console.error('equip', err));
+}
+
+export function sellItems (store, params) {
+ const user = store.state.user.data;
+ sellOp(user, {params, query: {amount: params.amount}});
+ axios
+ .post(`/api/v3/user/sell/${params.type}/${params.key}?amount=${params.amount}`);
+ // TODO
+ // .then((res) => console.log('equip', res))
+ // .catch((err) => console.error('equip', err));
+}
+
+
+export function pinGear (store, params) {
+ //axios
+ // .post(`/api/v3/user/pin/${params.key}`);
+ // TODO
+ // .then((res) => console.log('equip', res))
+ // .catch((err) => console.error('equip', err));
+}
+
+export function unpinGear (store, params) {
+ //axios
+ // .post(`/api/v3/user/unpin/${params.key}`);
+ // TODO
+ // .then((res) => console.log('equip', res))
+ // .catch((err) => console.error('equip', err));
+}
diff --git a/website/client/store/getters/index.js b/website/client/store/getters/index.js
index 1a8eeec476..af2400ae94 100644
--- a/website/client/store/getters/index.js
+++ b/website/client/store/getters/index.js
@@ -1,5 +1,6 @@
import { flattenAndNamespace } from 'client/libs/store/helpers/internals';
import * as user from './user';
+import * as shops from './shops';
import * as tasks from './tasks';
import * as party from './party';
import * as members from './members';
@@ -12,6 +13,7 @@ const getters = flattenAndNamespace({
tasks,
party,
members,
+ shops,
});
export default getters;
diff --git a/website/client/store/getters/shops.js b/website/client/store/getters/shops.js
new file mode 100644
index 0000000000..8564ee3b50
--- /dev/null
+++ b/website/client/store/getters/shops.js
@@ -0,0 +1,3 @@
+export function market (store) {
+ return store.state.shops.market;
+}
diff --git a/website/client/store/index.js b/website/client/store/index.js
index 4643f22600..71f9c760cc 100644
--- a/website/client/store/index.js
+++ b/website/client/store/index.js
@@ -43,6 +43,9 @@ export default function () {
quest: {},
members: asyncResourceFactory(),
},
+ shops: {
+ market: asyncResourceFactory(),
+ },
myGuilds: [],
editingGroup: {}, // TODO move to local state
// content data, frozen to prevent Vue from modifying it since it's static and never changes
diff --git a/website/common/locales/en/newClient.json b/website/common/locales/en/newClient.json
index 27673de57e..3700ede724 100644
--- a/website/common/locales/en/newClient.json
+++ b/website/common/locales/en/newClient.json
@@ -43,6 +43,7 @@
"sortByColor": "Color",
"sortByHatchable": "Hatchable",
"hatch": "Hatch!",
+ "foodTitle": "Food",
"dragThisFood": "Drag this <%= foodName %> to a Pet and watch it grow!",
"clickOnPetToFeed": "Click on a Pet to feed <%= foodName %> and watch it grow!",
"dragThisPotion": "Drag this <%= potionName %> to an Egg and hatch a new pet!",
@@ -110,7 +111,6 @@
"wantToJoinPartyTitle": "Want to join a Party?",
"wantToJoinPartyDescription": "Aenean non mattis eros, quis semper ipsum. Phasellus vulputate in nibh et suscipit. In hac habitasse platea dictumst.",
"copy": "Copy",
- "lookingForGroup": "Looking for Group",
"inviteToPartyOrQuest": "Invite Party to Quest",
"inviteInformation": "Clicking “Invite” will send an invitation to your party members. When all members have accepted or denied, the Quest begins.",
"questOwnerRewards": "Quest Owner Rewards",
@@ -167,7 +167,6 @@
"helpfulLinks": "Helpful Links",
"communityGuidelinesLink": "Community Guidelines",
"lookingForGroup": "Looking for Group (Party Wanted) Posts",
- "faq": "FAQ",
"dataDisplayTool": "Data Display Tool",
"reportProblem": "Report a Problem",
"requestFeature": "Request a Feature",
@@ -213,5 +212,21 @@
"challengeInformationPlaceHolder": "Write a short description advertising your Challenge to other Habiticans. What is the main purpose of your Challenge and why should people join it? Try to include useful keywords in the description so that Habiticans can easily find it when they search!",
"where": "Where*",
"challengeMinimum": "Minimum 1 Gem for public Challenges (helps prevent spam, it really does).",
- "group": "Group"
+ "group": "Group",
+
+ "sortByType": "Type",
+ "sortByPrice": "Price",
+ "sortByCon": "Con",
+ "sortByPer": "Per",
+ "sortByStr": "Str",
+ "sortByInt": "Int",
+ "classEquipment": "Class Equipment",
+ "showAllEquipment": "Show All <%= classType %> Equipment",
+ "showLessEquipment": "Show Less <%= classType %> Equipment",
+ "howManyToSell": "How many would you like to sell?",
+ "yourBalance": "Your balance",
+ "sell": "Sell",
+ "buyNow": "Buy Now",
+ "sortByNumber": "Number",
+ "featuredItems": "Featured Items!"
}
diff --git a/website/common/locales/en/npc.json b/website/common/locales/en/npc.json
index 0fdd68e323..595b3d47e2 100644
--- a/website/common/locales/en/npc.json
+++ b/website/common/locales/en/npc.json
@@ -41,6 +41,7 @@
"plusOneGem": "+1 Gem",
"typeNotSellable": "Type is not sellable. Must be one of the following <%= acceptedTypes %>",
"userItemsKeyNotFound": "Key not found for user.items <%= type %>",
+ "userItemsNotEnough": "Not enough items found for user.items <%= type %>",
"pathRequired": "Path string is required",
"unlocked": "Items have been unlocked",
"alreadyUnlocked": "Full set already unlocked.",
diff --git a/website/common/script/content/shop-featuredItems.js b/website/common/script/content/shop-featuredItems.js
new file mode 100644
index 0000000000..0b05de4bc5
--- /dev/null
+++ b/website/common/script/content/shop-featuredItems.js
@@ -0,0 +1,10 @@
+const featuredItems = {
+ market: [
+ 'head_armoire_vikingHelm',
+ 'weapon_special_1',
+ 'shield_special_0',
+ 'armor_warrior_5',
+ ],
+};
+
+export default featuredItems;
diff --git a/website/common/script/ops/sell.js b/website/common/script/ops/sell.js
index 9a2f3db403..5c754cd521 100644
--- a/website/common/script/ops/sell.js
+++ b/website/common/script/ops/sell.js
@@ -14,6 +14,7 @@ const ACCEPTEDTYPES = ['eggs', 'hatchingPotions', 'food'];
module.exports = function sell (user, req = {}) {
let key = get(req.params, 'key');
let type = get(req.params, 'type');
+ let amount = get(req.query, 'amount', 1);
if (!type) {
throw new BadRequest(i18n.t('typeRequired', req.language));
@@ -31,8 +32,14 @@ module.exports = function sell (user, req = {}) {
throw new NotFound(i18n.t('userItemsKeyNotFound', {type}, req.language));
}
- user.items[type][key]--;
- user.stats.gp += content[type][key].value;
+ let currentAmount = user.items[type][key];
+
+ if (amount > currentAmount) {
+ throw new NotFound(i18n.t('userItemsNotEnough', {type}, req.language));
+ }
+
+ user.items[type][key] -= amount;
+ user.stats.gp += content[type][key].value * amount;
return [
pick(user, splitWhitespace('stats items')),
diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js
index ee61b8a08b..cf66e8c1fd 100644
--- a/website/server/controllers/api-v3/user.js
+++ b/website/server/controllers/api-v3/user.js
@@ -1490,12 +1490,13 @@ api.userReleaseMounts = {
};
/**
- * @api {post} /api/v3/user/sell/:type/:key Sell a gold-sellable item owned by the user
+ * @api {post} /api/v3/user/sell/:type/:key?amount=1 Sell a gold-sellable item owned by the user
* @apiName UserSell
* @apiGroup User
*
- * @apiParam {String="eggs","hatchingPotions","food"} type The type of item to sell.
- * @apiParam {String} key The key of the item
+ * @apiParam (Path) {String="eggs","hatchingPotions","food"} type The type of item to sell.
+ * @apiParam (Path) {String} key The key of the item
+ * @apiParam (Query) {Number} (optional) amount The amount to sell
*
* @apiSuccess {Object} data.stats
* @apiSuccess {Object} data.items