Initial commit

This commit is contained in:
zhongjin
2020-06-15 10:58:47 +08:00
commit 4f1dfe7564
8590 changed files with 1516878 additions and 0 deletions
@@ -0,0 +1,234 @@
<template>
<Page class="page--settings">
<div class="connectedapps grid">
<SettingsSidebar class="grid-cell--col4" :userModel="user" :baseUrl="baseUrl" />
<div class="conectedapps__container grid-cell--col8">
<div class="connectedapps__title">
<h2 class="text is-caption">{{ $t(`ConnectedAppsPage.title`) }}</h2>
</div>
<p v-if="hasConnectedApps" class="text is-small">{{ $t(`ConnectedAppsPage.description`) }}</p>
<p v-else class="text is-small u-mb--96">{{ $t(`ConnectedAppsPage.emptyDescription`) }}</p>
<div v-if="!hasConnectedApps" class="u-mt--16">
<div class="connectedapps__title">
<h2 class="text is-caption">{{ $t(`ConnectedAppsPage.emptyTipTitle`) }}</h2>
</div>
<p class="text is-small"
v-html="$t('ConnectedAppsPage.emptyTipDescription', { url: this.$router.resolve({ name: 'oauth_apps_list' }).href })">
</p>
</div>
<div v-if="hasConnectedApps" class="connectedapps__list">
<div class="connectedapps__list-title">
<h3 class="text is-small is-semibold">{{ $t(`ConnectedAppsPage.listTitle`) }}</h3>
</div>
<ul>
<AppElement v-for="connectedApp in connectedApps" :key="connectedApp.id" :oAuthApp="connectedApp">
<button class="connectedapps__button connectedapps__button--revoke button button--ghost" @click="openModal(connectedApp)">
{{ $t(`ConnectedAppsPage.removeAccessButton`) }}
</button>
</AppElement>
</ul>
</div>
</div>
</div>
<Modal :isOpen="isModalOpen" @closeModal="closeModal">
<div class="connectedapps__modal">
<div class="connectedapps__modal-inner">
<div class="connectedapps__icon u-mb--24">
<img svg-inline src="../../assets/icons/apps/default.svg">
<img class="connectedapps__badge" svg-inline src="../../assets/icons/apps/trash.svg">
</div>
<span class="text is-caption u-mb--8" v-html="$t(`ConnectedAppsPage.removeModal.title`, { name: selectedApp.name })"></span>
<span class="text is-small is-txtSoftGrey">{{ $t(`ConnectedAppsPage.removeModal.subtitle`) }}</span>
<div class="connectedapps__modal-actions">
<button class="connectedapps__button button button--ghost is-primary u-mr--12" @click="closeModal">{{ $t(`ConnectedAppsPage.removeModal.cancelButton`) }}</button>
<button class="connectedapps__button button button--alert text is-small" @click="revokeAccess(selectedApp)">{{ $t(`ConnectedAppsPage.removeModal.removeButton`) }}</button>
</div>
</div>
</div>
</Modal>
</Page>
</template>
<script>
import Page from 'new-dashboard/components/Page';
import AppElement from '../../components/Apps/AppElement';
import SettingsSidebar from 'new-dashboard/components/Apps/SettingsSidebar';
import Modal from 'new-dashboard/components/Modal';
import { mapState } from 'vuex';
export default {
name: 'ConnectedApps',
components: {
AppElement,
Page,
SettingsSidebar,
Modal
},
data () {
return {
isModalOpen: false,
selectedApp: {}
};
},
beforeMount: function () {
this.$store.dispatch('connectedApps/fetch', {
apiKey: this.$store.state.user.api_key
});
},
computed: {
...mapState({
isFetchingConnectedApps: state => state.connectedApps.isFetching,
user: state => state.user,
baseUrl: state => state.user.base_url,
connectedApps: state => state.connectedApps.list,
hasConnectedApps: state => !state.connectedApps.isFetching && !!Object.keys(state.connectedApps.list).length
})
},
methods: {
openModal (selectedApp) {
this.isModalOpen = true;
this.selectedApp = selectedApp;
},
closeModal () {
this.isModalOpen = false;
this.selectedApp = {};
},
revokeAccess (selectedApp) {
this.$store
.dispatch('connectedApps/revoke', selectedApp)
.then(() => {
this.closeModal();
this.$router.push({ name: 'connected_apps' });
});
}
}
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="scss">
@import 'new-dashboard/styles/variables';
.connectedapps {
display: flex;
width: 940px;
min-height: 585px;
margin: 48px auto 0;
padding: 0;
&__container {
display: flex;
width: 100;
padding-left: 82px;
}
&__title {
margin-bottom: 16px;
padding-bottom: 28px;
border-bottom: 1px solid $neutral--300;
}
&__list {
margin-top: 36px;
}
&__list-title {
padding-bottom: 24px;
border-bottom: 1px solid $neutral--300;
}
&__item {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 20px 0;
border-bottom: 1px solid $neutral--300;
}
&__item-info {
display: flex;
flex-grow: 1;
}
&__item-title {
max-width: 356px;
line-height: 22px;
}
&__item-description {
max-width: 356px;
}
&__icon {
display: flex;
position: relative;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border: 1px solid $neutral--300;
border-radius: 2px;
}
&__badge {
display: block;
position: absolute;
top: -9px;
right: -9px;
animation: fade-and-bounce-up 0.6s 0.35s ease-in-out backwards;
}
}
.connectedapps__button {
font-family: 'Open Sans', sans-serif;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
&.connectedapps__button--revoke {
align-self: center;
}
}
.button--ghost {
padding: 8px 12px;
border: 1px solid $blue--500;
background: none;
color: $blue--500;
}
.button--uppercase {
text-transform: uppercase;
}
.connectedapps__modal {
display: flex;
flex-direction: column;
justify-content: space-around;
width: 100%;
height: 100%;
}
.connectedapps__modal-inner {
display: flex;
flex-direction: column;
align-items: center;
width: 960px;
margin: 0 auto;
padding: 0;
}
.connectedapps__modal-actions {
display: flex;
justify-content: center;
width: 100%;
margin-top: 54px;
padding-top: 38px;
border-top: 1px solid $neutral--300;
}
</style>
@@ -0,0 +1,57 @@
<!-- Base Component for OAuth pages -->
<template>
<Page class="page--settings">
<div class="oauthapps grid">
<SettingsSidebar class="grid-cell--col4" :userModel="user" :baseUrl="baseUrl" />
<!-- Form -->
<div class="oauthapps__container grid-cell--col8">
<AppTabs></AppTabs>
<router-view></router-view>
</div>
</div>
</Page>
</template>
<script>
import Page from 'new-dashboard/components/Page';
import SettingsSidebar from 'new-dashboard/components/Apps/SettingsSidebar';
import AppTabs from 'new-dashboard/components/Apps/AppTabs';
import { mapState } from 'vuex';
export default {
name: 'OAuthApps',
components: {
Page,
SettingsSidebar,
AppTabs
},
computed: {
...mapState({
user: state => state.user,
baseUrl: state => state.user.base_url
})
}
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="scss">
@import 'new-dashboard/styles/variables';
.oauthapps {
display: flex;
width: 940px;
min-height: 585px;
margin: 48px auto 0;
padding: 0;
&__container {
display: flex;
flex-direction: column;
width: 100%;
padding-left: 14px;
}
}
</style>
@@ -0,0 +1,257 @@
<template>
<section class="catalog">
<div class="container grid">
<div class="full-width">
<SectionTitle class="grid-cell">
<template slot="icon">
<img src="../../assets/icons/section-title/catalog.svg" width="18" height="20" />
</template>
<template slot="title">
<span>{{ $t('CatalogPage.header') }}</span>
</template>
</SectionTitle>
</div>
</div>
<div class="catalog__filter container grid" action="">
<div class="grid-cell grid-cell--col6 catalog__filter--dropdown">
<CatalogDropdown ref="dropdownCategories"
:title="$t('CatalogDropdown.category.title')"
:placeholder="$t('CatalogDropdown.category.placeholder')"
:options="categories"
:disabled="true"
@selected="getCountries"
@reset="resetCategory">
<template slot="extra">
<span v-html="$t('CatalogDropdown.category.extra')" class="text is-small"></span>
</template>
</CatalogDropdown>
</div>
<div class="grid-cell grid-cell--col6 catalog__filter--dropdown">
<CatalogDropdown ref="dropdownCountries"
:title="$t('CatalogDropdown.country.title')"
:placeholder="$t('CatalogDropdown.country.placeholder')"
:options="countries"
:disabled="true"
:limitHeight="true"
@open="false"
@selected="getDatasets"
@reset="resetCountry">
<template slot="extra">
<span v-html="$t('CatalogDropdown.country.extra')" class="text is-small"></span>
</template>
</CatalogDropdown>
</div>
</div>
<!-- Datasets list -->
<div class="container grid" v-if="datasets.length > 0">
<div class="grid-cell grid-cell--col12">
<CatalogListHeader :order="appliedOrder" :orderDirection="appliedOrderDirection" @changeOrder="applyOrder"></CatalogListHeader>
<ul v-if="!isFetchingDatasets" >
<li v-for="(dataset) in currentPageDatasets" :key="dataset.id">
<CatalogCard :dataset="dataset"></CatalogCard>
</li>
</ul>
<ul v-else>
<li v-for="(n) in resultsPerPage" :key="n">
<CatalogFakeCard />
</li>
</ul>
</div>
</div>
<Pagination v-if="shouldShowPagination" :page=currentPage :numPages=numPages @pageChange="goToPage"></Pagination>
</section>
</template>
<script>
import { mapState } from 'vuex';
import sendCustomEvent from 'new-dashboard/utils/send-custom-event';
import SectionTitle from 'new-dashboard/components/SectionTitle';
import CatalogListHeader from 'new-dashboard/components/Catalog/CatalogListHeader';
import CatalogCard from 'new-dashboard/components/Catalog/CatalogCard';
import CatalogFakeCard from 'new-dashboard/components/Catalog/CatalogFakeCard';
import CatalogDropdown from 'new-dashboard/components/Catalog/CatalogDropdown';
import Pagination from 'new-dashboard/components/Pagination';
export default {
name: 'CatalogPage',
components: {
SectionTitle,
CatalogListHeader,
CatalogCard,
CatalogFakeCard,
CatalogDropdown,
Pagination
},
beforeRouteUpdate (to, from, next) {
sendCustomEvent('catalogSelectCategory', {
catalogSelectedCategory: to.query.category
});
sendCustomEvent('catalogSelectCountry', {
catalogSelectedCountry: to.query.country
});
next();
},
mounted () {
const category = this.$route.query.category;
const country = this.$route.query.country;
if (category && country) {
this.setUrlOptions();
} else {
this.getCategories();
}
},
computed: {
...mapState({
numPages: state => state.catalog.numPages,
currentPage: state => state.catalog.page,
datasets: state => state.catalog.list,
categories: state => state.catalog.categories,
countries: state => state.catalog.countries,
isFetchingDatasets: state => state.catalog.isFetching,
numResults: state => state.catalog.umResults,
resultsPerPage: state => state.catalog.resultsPerPage,
appliedOrder: state => state.catalog.order,
appliedOrderDirection: state => state.catalog.orderDirection
}),
shouldShowPagination () {
return !this.isFetchingDatasets && this.numPages > 1;
},
currentPageDatasets () {
return this.datasets.slice(((this.currentPage - 1) * this.resultsPerPage), (this.currentPage * this.resultsPerPage));
}
},
methods: {
goToPage (page) {
window.scroll({ top: 0, left: 0 });
this.$router.push({
name: 'catalog',
params: this.$route.params,
query: { ...this.$route.query, page }
});
},
applyOrder (orderParams) {
this.$router.push({
name: 'catalog',
params: this.$route.params,
query: {
...this.$route.query,
page: 1,
order: orderParams.order,
order_direction: orderParams.direction
}
});
this.$store.dispatch('catalog/order', orderParams);
},
getCategories () {
this.clearList();
this.$store.dispatch('catalog/fetchCategories')
.then(
() => {
this.$refs.dropdownCategories.enableDropdown();
this.$refs.dropdownCategories.openDropdown();
},
(err) => {
this.$refs.dropdownCategories.enableDropdown();
this.$refs.dropdownCategories.setError(err);
}
);
},
getCountries (category) {
this.clearList();
this.$store.dispatch('catalog/fetchCountries', category)
.then(
() => {
this.$refs.dropdownCountries.clearInput();
this.$refs.dropdownCountries.enableDropdown();
window.scroll({ top: 0, left: 0 });
},
(err) => {
this.$refs.dropdownCountries.enableDropdown();
this.$refs.dropdownCountries.setError(err);
}
);
},
getDatasets (country) {
this.$store.dispatch('catalog/fetchDatasets', {
category: this.$refs.dropdownCategories.selected,
country
}).then(
() => {
this.$router.push({
name: 'catalog',
query: {
category: this.$refs.dropdownCategories.selected,
country
}
});
window.scroll({ top: 0, left: 0 });
},
(err) => {
this.$refs.dropdownCategories.setError(err);
this.$refs.dropdownCountries.setError(err);
}
);
},
resetCategory () {
this.$refs.dropdownCountries.clearInput();
this.$refs.dropdownCountries.disableDropdown();
this.clearList();
},
resetCountry () {
this.clearList();
},
clearList () {
this.$store.dispatch('catalog/clearList');
},
setUrlOptions () {
const category = this.$route.query.category;
const country = this.$route.query.country;
const promiseDatasets = this.$store.dispatch('catalog/fetchDatasets', {
category: category,
country: country
});
const promiseCategories = this.$store.dispatch('catalog/fetchCategories');
const promiseCountires = this.$store.dispatch('catalog/fetchCountries', category);
Promise.all([promiseDatasets, promiseCategories, promiseCountires])
.then(
() => {
if (this.datasets.length > 0) {
this.$refs.dropdownCategories.setInput(category);
this.$refs.dropdownCountries.setInput(country);
} else {
this.getCategories();
this.$router.push({ name: 'catalog' });
}
},
() => {
this.$router.push({ name: 'catalog' });
}
);
}
}
};
</script>
<style scoped lang="scss">
@import 'new-dashboard/styles/variables';
.catalog {
min-height: 640px;
margin-top: 64px;
&__filter {
justify-content: space-between;
height: 168px;
&--dropdown {
position: relative;
}
}
}
.full-width {
width: 100%;
}
</style>
@@ -0,0 +1,176 @@
<template>
<Page class="page--data">
<SecondaryNavigation>
<a class="catalogDetail__back title is-small" href="javascript:history.back()">
<img class="catalogDetail__back--icon" svg-inline src="../../assets/icons/common/back.svg"/>
<span>{{ $t('CatalogDetailPage.back') }}</span>
</a>
</SecondaryNavigation>
<section v-if="!isFetchingDatasets" class="catalogDetail">
<div class="container grid">
<div class="full-width">
<SectionTitle class="grid-cell">
<template slot="icon">
<img src="../../assets/icons/section-title/data.svg" width="18" height="20" />
</template>
<template slot="title">
<span>{{ dataset.name }}</span>
</template>
<template slot="actionButton">
<CatalogRequestSuccess v-if="hasBeenSuccesfullyRequested"></CatalogRequestSuccess>
<button v-else class="button is-primary" @click="requestDataset">{{ $t('CatalogDetailPage.requestDataset') }}</button>
</template>
</SectionTitle>
<div class="catalogDetail__description grid-cell">
<span class="text is-caption">
{{ dataset.description }}
</span>
</div>
</div>
<div class="grid-cell grid-cell--col6">
<h4 class="catalogDetail__label title is-caption">{{ $t('CatalogDetailPage.variableGroups') }}</h4>
<ul v-for="variable in dataset.variable_name" :key="variable" class="catalogDetail__list">
<li class="text is-caption catalogDetail__list--item">{{variable}}</li>
</ul>
</div>
<div class="grid-cell grid-cell--col3">
<h4 class="catalogDetail__label title is-caption">{{ $t('CatalogDetailPage.category') }}</h4>
<div class="u-flex">
<div :class="`catalog__icon catalog__icon--${getCSSModifier(dataset.category)}`"></div>
<p class="text is-caption">{{ dataset.category }}</p>
</div>
</div>
<div class="grid-cell grid-cell--col3">
<h4 class="catalogDetail__label title is-caption">{{ $t('CatalogDetailPage.country') }}</h4>
<div class="u-flex">
<div :class="`catalog__icon catalog__icon--${getCSSModifier(dataset.country)}`"></div>
<p class="text is-caption">{{ dataset.country }}</p>
</div>
</div>
</div>
</section>
</Page>
</template>
<script>
import { mapState } from 'vuex';
import toObject from 'new-dashboard/utils/to-object';
import getCSSModifier from 'new-dashboard/utils/get-css-modifier';
import sendCustomEvent from 'new-dashboard/utils/send-custom-event';
import Page from 'new-dashboard/components/Page';
import SecondaryNavigation from 'new-dashboard/components/SecondaryNavigation';
import SectionTitle from 'new-dashboard/components/SectionTitle';
import CatalogRequestSuccess from 'new-dashboard/components/Catalog/CatalogRequestSuccess';
export default {
name: 'CatalogDetail',
components: {
Page,
SecondaryNavigation,
SectionTitle,
CatalogRequestSuccess
},
data: function () {
return {
hasBeenSuccesfullyRequested: false
};
},
computed: {
...mapState({
user: state => state.user,
datasets: state => state.catalog.list,
isFetchingDatasets: state => state.catalog.isFetching,
dataset (state) {
if (this.isFetchingDatasets) {
return {};
}
const datasetList = toObject(this.datasets, 'id');
const selectedDataset = datasetList[this.$route.params.id];
sendCustomEvent('catalogSelectDataset', {
catalogSelectedDataset: selectedDataset.name
});
return selectedDataset;
}
})
},
methods: {
getCSSModifier,
requestDataset () {
this.$store.dispatch('catalog/requestDataset', { user: this.user, dataset: this.dataset })
.then(
() => {
this.hasBeenSuccesfullyRequested = true;
this.sendCustomCatalogEvents(this.dataset);
}
);
},
sendCustomCatalogEvents (dataset) {
sendCustomEvent('catalogRequestDataset', {
catalogRequestedDataset: dataset.name
});
sendCustomEvent('catalogRequestCountry', {
catalogRequestedCountry: dataset.country
});
sendCustomEvent('catalogRequestCategory', {
catalogRequestedCategory: dataset.category
});
}
}
};
</script>
<style scoped lang="scss">
@import "new-dashboard/styles/variables";
.page--data {
padding-top: 64px;
}
.full-width {
width: 100%;
}
.catalogDetail {
margin-top: 64px;
&__back {
display: flex;
align-items: center;
padding: 24px 0;
&--icon {
width: 7px;
height: 12px;
margin-right: 8px;
}
}
&__description {
margin-bottom: 64px;
}
&__label {
margin-bottom: 24px;
}
&__list {
list-style-position: inside;
list-style-type: disc;
&--item {
margin-bottom: 16px;
}
}
}
.catalog__icon {
width: 24px;
height: 24px;
margin-right: 12px;
background-size: contain;
}
</style>
@@ -0,0 +1,57 @@
<template>
<Page class="page--data">
<SecondaryNavigation>
<div class="tabs">
<router-link :to="{ name: 'datasets' }" class="tabs__item title is-small" exact active-class="is-active" :class="{'is-active': isDatasetPage }">
<span>{{ $t('DataPage.tabs.yourDatasets') }}</span>
</router-link>
<router-link :to="{ name: 'catalog' }" class="tabs__item title is-small" active-class="is-active">
<span>{{ $t('DataPage.tabs.catalog') }}</span>
</router-link>
</div>
</SecondaryNavigation>
<router-view></router-view>
</Page>
</template>
<script>
import Page from 'new-dashboard/components/Page';
import SecondaryNavigation from 'new-dashboard/components/SecondaryNavigation';
import { isAllowed } from 'new-dashboard/core/configuration/filters';
export default {
name: 'DataPage',
components: {
Page,
SecondaryNavigation
},
computed: {
isDatasetPage () {
return isAllowed(this.$route.params.filter);
}
}
};
</script>
<style scoped lang="scss">
@import "new-dashboard/styles/variables";
.tabs {
display: flex;
&__item {
margin-right: 48px;
padding: 24px 0 20px;
border-bottom: 4px solid transparent;
background: none;
color: $color-primary;
text-transform: none;
&.is-active {
border-color: currentColor;
color: $black;
}
}
}
</style>
@@ -0,0 +1,142 @@
<template>
<section class="datasets-section">
<StickySubheader :is-visible="Boolean(selectedDatasets.length && isScrollPastHeader)">
<h2 class="title is-caption">
{{ $t('BulkActions.selected', {count: selectedDatasets.length}) }}
</h2>
<DatasetBulkActions
:selectedDatasets="selectedDatasets"
:areAllDatasetsSelected="areAllDatasetsSelected"
@selectAll="selectAll"
@deselectAll="deselectAll"></DatasetBulkActions>
</StickySubheader>
<DatasetsList
ref="datasetsList"
class="grid__content"
:hasBulkActions="true"
:canHoverCard="true"
:maxVisibleDatasets="maxVisibleDatasets"
@applyFilter="applyFilter"
@applyOrder="applyOrder"
@selectionChange="updateSelected" />
<Pagination v-if="shouldShowPagination" :page=currentPage :numPages=numPages @pageChange="goToPage"></Pagination>
</section>
</template>
<script>
import { mapState } from 'vuex';
import { checkFilters } from 'new-dashboard/router/hooks/check-navigation';
import Pagination from 'new-dashboard/components/Pagination';
import DatasetBulkActions from 'new-dashboard/components/BulkActions/DatasetBulkActions.vue';
import StickySubheader from '../../components/StickySubheader';
import DatasetsList from 'new-dashboard/components/DatasetsList.vue';
export default {
name: 'DatasetsPage',
components: {
DatasetBulkActions,
StickySubheader,
Pagination,
DatasetsList
},
data () {
return {
isScrollPastHeader: false,
maxVisibleDatasets: 12,
selectedDatasets: []
};
},
beforeMount () {
if (this.$store.getters['user/isViewer']) {
// Redirect to shared datasets page if user is viewer
return this.$router.replace({ name: 'datasets', params: { filter: 'shared' } });
}
},
mounted () {
this.stickyScrollPosition = this.getHeaderBottomPageOffset();
this.$onScrollChange = this.onScrollChange.bind(this);
document.addEventListener('scroll', this.$onScrollChange, { passive: true });
},
beforeDestroy () {
document.removeEventListener('scroll', this.$onScrollChange, { passive: true });
},
beforeRouteUpdate (to, from, next) {
checkFilters('datasets', 'datasets', to, from, next);
},
computed: {
...mapState({
numPages: state => state.datasets.numPages,
currentPage: state => state.datasets.page,
datasets: state => state.datasets.list,
isFetchingDatasets: state => state.datasets.isFetching,
numResults: state => state.datasets.metadata.total_entries
}),
isNotificationVisible () {
return this.$store.getters['user/isNotificationVisible'];
},
areAllDatasetsSelected () {
return Object.keys(this.datasets).length === this.selectedDatasets.length;
},
shouldShowPagination () {
return !this.isFetchingDatasets && this.numResults > 0 && this.numPages > 1;
}
},
methods: {
updateSelected (selected) {
this.selectedDatasets = selected;
},
goToPage (page) {
this.deselectAll();
window.scroll({ top: 0, left: 0 });
this.$router.push({
name: 'datasets',
params: this.$route.params,
query: {...this.$route.query, page}
});
},
applyFilter (filter) {
this.$router.push({ name: 'datasets', params: { filter } });
},
applyOrder (orderParams) {
this.deselectAll();
this.$router.push({
name: 'datasets',
params: this.$route.params,
query: {
...this.$route.query,
page: 1,
order: orderParams.order,
order_direction: orderParams.direction
}
});
},
selectAll () {
this.$refs.datasetsList.selectAll();
},
deselectAll () {
this.$refs.datasetsList.deselectAll();
},
onScrollChange () {
this.isScrollPastHeader = window.pageYOffset > this.stickyScrollPosition;
},
getHeaderBottomPageOffset () {
const headerContainer = this.$refs.datasetsList.getHeaderContainer();
const headerBoundingClientRect = headerContainer.$el.getBoundingClientRect();
const notificationHeight = this.isNotificationVisible ? 60 : 0;
return headerBoundingClientRect.top - notificationHeight;
}
}
};
</script>
<style scoped lang="scss">
@import 'new-dashboard/styles/variables';
.datasets-section {
margin-top: 64px;
}
</style>
@@ -0,0 +1,76 @@
<template>
<section>
<DatasetsList
:hasBulkActions="false"
:canHoverCard="false"
:maxVisibleDatasets="maxVisibleDatasets"
@applyFilter="applyFilter"
@applyOrder="applyOrder"
@contentChanged="onContentChanged"/>
<router-link :to="{ name: 'datasets' }" class="title is-small viewall-link" v-if="showViewAllLink">
{{ datasetsLinkText }}
</router-link>
</section>
</template>
<script>
import { mapState } from 'vuex';
import DatasetsList from 'new-dashboard/components/DatasetsList.vue';
export default {
name: 'DatasetsSection',
components: {
DatasetsList
},
data () {
return {
maxVisibleDatasets: 6
};
},
computed: {
...mapState({
isFetchingDatasets: state => state.datasets.isFetching,
currentEntriesCount: state => state.datasets.metadata.total_entries
}),
datasetsLinkText () {
return this.$t('HomePage.DatasetsSection.viewAll');
},
showViewAllLink () {
return !this.isFetchingDatasets && this.currentEntriesCount;
}
},
methods: {
applyOrder (orderOptions) {
this.$store.dispatch('datasets/order', orderOptions);
this.$store.dispatch('datasets/fetch');
},
applyFilter (filter) {
this.$store.dispatch('datasets/filter', filter);
this.$store.dispatch('datasets/fetch');
},
fetchDatasets () {
this.$store.dispatch('datasets/fetch');
},
onContentChanged (type) {
this.$emit('contentChanged', type);
}
}
};
</script>
<style scoped lang="scss">
@import "new-dashboard/styles/variables";
.full-width {
width: 100%;
}
.viewall-link {
display: block;
margin-top: 64px;
letter-spacing: 1px;
text-align: center;
text-transform: uppercase;
}
</style>
@@ -0,0 +1,100 @@
<template>
<Page class="page--welcome">
<Welcome />
<RecentSection class="section" v-if="isSectionActive('RecentSection') && hasRecentContent" @sectionChange="changeSection" @contentChanged="onContentChanged"/>
<TagsSection class="section tags-section" v-if="isSectionActive('TagsSection')" @sectionChange="changeSection"/>
<MapsSection class="section" @contentChanged="onContentChanged"/>
<DatasetsSection class="section section--noBorder" @contentChanged="onContentChanged"/>
<QuotaSection></QuotaSection>
<router-view name="onboarding-modal"/>
</Page>
</template>
<script>
import Welcome from './WelcomeSection/Welcome.vue';
import TagsSection from './TagsSection/TagsSection.vue';
import RecentSection from './RecentSection/RecentSection.vue';
import MapsSection from './MapsSection/MapsSection.vue';
import DatasetsSection from './DatasetsSection/DatasetsSection.vue';
import QuotaSection from './QuotaSection/QuotaSection.vue';
import Page from 'new-dashboard/components/Page';
export default {
name: 'Home',
components: {
Welcome,
TagsSection,
RecentSection,
MapsSection,
DatasetsSection,
QuotaSection,
Page
},
beforeMount () {
this.$store.dispatch('recentContent/fetch');
this.$store.dispatch('maps/resetFilters');
this.$store.dispatch('datasets/resetFilters');
this.$store.dispatch('maps/setResultsPerPage', 6);
this.$store.dispatch('datasets/setResultsPerPage', 6);
// If user is viewer, show shared maps and datasets
if (this.$store.getters['user/isViewer']) {
this.$store.dispatch('maps/filter', 'shared');
this.$store.dispatch('datasets/filter', 'shared');
}
this.$store.dispatch('maps/fetch');
this.$store.dispatch('datasets/fetch');
},
data () {
return {
activeSection: this.$route.query.section || 'RecentSection'
};
},
computed: {
hasRecentContent () {
return this.$store.getters['recentContent/hasRecentContent'] ||
this.$store.state.recentContent.isFetching;
},
isFirstTimeViewingDashboard () {
return this.$store.state.config.isFirstTimeViewingDashboard;
}
},
methods: {
isSectionActive (activeSection) {
return activeSection === this.activeSection;
},
changeSection (nextActiveSection) {
this.activeSection = nextActiveSection;
},
onContentChanged () {
this.$store.dispatch('recentContent/fetch');
this.$store.dispatch('maps/fetch');
this.$store.dispatch('datasets/fetch');
}
}
};
</script>
<style scoped lang="scss">
@import 'new-dashboard/styles/variables';
.section {
position: relative;
&--maps {
z-index: $z-index__stack-context--first;
}
&--datasets {
z-index: $z-index__stack-context--second;
}
}
.tags-section {
padding-bottom: 104px;
}
</style>
@@ -0,0 +1,84 @@
<template>
<section class="maps-section">
<MapsList
:hasBulkActions="false"
:isCondensedDefault="true"
:canChangeViewMode="false"
:canHoverCard="false"
:maxVisibleMaps="maxVisibleMaps"
:isInitialOrEmpty="showViewAllLink"
@applyFilter="applyFilter"
@applyOrder="applyOrder"
@contentChanged="onContentChanged"/>
<router-link :to="{ name: 'maps' }" class="title is-small viewall-link" v-if="showViewAllLink">{{ mapsLinkText }}</router-link>
</section>
</template>
<script>
import { mapState } from 'vuex';
import MapsList from 'new-dashboard/components/MapsList.vue';
export default {
name: 'MapsSection',
components: {
MapsList
},
data () {
return {
maxVisibleMaps: 6
};
},
computed: {
...mapState({
appliedFilter: state => state.maps.filterType,
appliedOrder: state => state.maps.order,
isFetchingMaps: state => state.maps.isFetching,
numResults: state => state.maps.metadata.total_entries,
totalUserEntries: state => state.maps.metadata.total_user_entries,
totalShared: state => state.maps.metadata.total_shared,
isFirst: state => state.config.isFirstTimeViewingDashboard
}),
mapsLinkText () {
return this.$t('HomePage.MapsSection.viewAll');
},
showViewAllLink () {
return !this.isFetchingMaps && this.totalUserEntries;
}
},
methods: {
applyFilter (filter) {
this.$store.dispatch('maps/filter', filter);
this.$store.dispatch('maps/fetch');
},
applyOrder (orderOptions) {
this.$store.dispatch('maps/order', orderOptions);
this.$store.dispatch('maps/fetch');
},
hasFilterApplied (filter) {
return this.appliedFilter === filter;
},
onContentChanged (type) {
this.$emit('contentChanged', type);
}
}
};
</script>
<style scoped lang="scss">
@import "new-dashboard/styles/variables";
.maps-section {
.full-width {
width: 100%;
}
}
.viewall-link {
display: block;
margin-top: 64px;
letter-spacing: 1px;
text-align: center;
text-transform: uppercase;
}
</style>
@@ -0,0 +1,137 @@
<template>
<QuotaContainer :title="$t(`QuotaSection.account`)" :perMonth="false">
<QuotaWidget
:name="$t(`QuotaSection.storage`)"
:usedQuota="getAmountInUnit(usedStorage, amountExponent)"
:availableQuota="getAmountInUnit(availableStorage, amountExponent)"
:unit="getUnit(amountExponent)"
:formatToLocale="false"
:helpLink="storageHelpLink"/>
<QuotaWidget
v-if="hasTableLimits"
:name="$t(`QuotaSection.publicMaps`)"
:usedQuota="usedPublicMaps"
:availableQuota="availablePublicMaps"/>
<QuotaWidget
v-if="hasPublicMapLimits"
:name="$t(`QuotaSection.datasets`)"
:usedQuota="usedDatasets"
:availableQuota="availableDatasets"/>
<QuotaWidget
v-if="hasApiKeysLimits"
:name="$t(`QuotaSection.apiKeys`)"
:usedQuota="usedApiKeys"
:availableQuota="availableApiKeys"/>
</QuotaContainer>
</template>
<script>
import { mapState } from 'vuex';
import QuotaWidget from './QuotaWidget';
import QuotaContainer from './QuotaContainer';
import { apiKeysTypes } from 'new-dashboard/core/constants/api-keys';
import { accountsWithLimits } from 'new-dashboard/core/constants/accounts';
export default {
name: 'AccountQuota',
components: {
QuotaWidget,
QuotaContainer
},
beforeMount () {
this.getApiKeysTotal();
},
data () {
return {
usedApiKeys: 0
};
},
computed: {
...mapState({
availableStorage: state => state.user.quota_in_bytes,
remainingStorage: state => state.user.remaining_byte_quota,
availablePublicMaps: state => state.user.public_map_quota,
linkMapsTotal: state => state.user.link_privacy_map_count,
passwordMapsTotal: state => state.user.password_privacy_map_count,
publicMapsTotal: state => state.user.public_privacy_map_count,
availableDatasets: state => state.user.table_quota,
usedDatasets: state => state.user.table_count,
availableApiKeys: state => state.user.regular_api_key_quota,
planAccountType: state => state.user.account_type
}),
usedStorage () {
return this.availableStorage - this.remainingStorage;
},
amountExponent () {
return this.getExpBaseTwo(this.availableStorage) - 10;
},
storageHelpLink () {
return 'https://carto.com/help/your-account/your-disk-storage/';
},
hasTableLimits () {
return accountsWithLimits.includes(this.planAccountType);
},
hasPublicMapLimits () {
return accountsWithLimits.includes(this.planAccountType);
},
hasApiKeysLimits () {
return accountsWithLimits.includes(this.planAccountType);
},
usedPublicMaps () {
return this.linkMapsTotal + this.passwordMapsTotal + this.publicMapsTotal;
}
},
methods: {
getExpBaseTwo (sizeInBytes) {
if (sizeInBytes === 0) {
return 0;
}
let exponent = 0;
if (Math.log2) {
exponent = Math.log2(sizeInBytes);
} else {
exponent = Math.log(sizeInBytes) * Math.LOG2E;
}
return Math.round(exponent / 10) * 10;
},
getUnit (exponent) {
if (exponent < 10) {
return 'B';
} else if (exponent < 20) {
return 'KB';
} else if (exponent < 30) {
return 'MB';
} else if (exponent < 40) {
return 'GB';
} else if (exponent < 50) {
return 'TB';
} else if (exponent < 60) {
return 'PB';
} else if (exponent < 70) {
return 'EB';
} else {
return '?';
}
},
getAmountInUnit (number, exponent) {
return number / Math.pow(2, exponent);
},
getApiKeysTotal () {
this.$store.state.client.getApiKeys(apiKeysTypes.REGULAR,
(err, _, data) => {
if (err) {
return;
}
this.usedApiKeys = data.count;
}
);
}
}
};
</script>
@@ -0,0 +1,40 @@
<template>
<QuotaContainer :title="$t(`QuotaSection.dataServices`)" :perMonth="true">
<QuotaWidget :name="$t(`QuotaSection.geocoding`)" :usedQuota="geocodingUsed" :availableQuota="geocodingAvailable" :helpLink="geocodingHelpLink"></QuotaWidget>
<QuotaWidget :name="$t(`QuotaSection.isolines`)" :usedQuota="isolinesUsed" :availableQuota="isolinesAvailable" :helpLink="isolinesHelpLink"></QuotaWidget>
<QuotaWidget :name="$t(`QuotaSection.routing`)" :usedQuota="routingUsed" :availableQuota="routingAvailable" :helpLink="routingHelpLink"></QuotaWidget>
</QuotaContainer>
</template>
<script>
import { mapState } from 'vuex';
import QuotaWidget from './QuotaWidget';
import QuotaContainer from './QuotaContainer';
export default {
name: 'DataServicesQuota',
components: {
QuotaWidget,
QuotaContainer
},
computed: {
...mapState({
geocodingUsed: state => state.user.geocoding.monthly_use,
geocodingAvailable: state => state.user.geocoding.quota,
routingUsed: state => state.user.mapzen_routing.monthly_use,
routingAvailable: state => state.user.mapzen_routing.quota ? state.user.mapzen_routing.quota : 0,
isolinesUsed: state => state.user.here_isolines.monthly_use,
isolinesAvailable: state => state.user.here_isolines.quota
}),
geocodingHelpLink () {
return 'https://carto.com/help/working-with-data/geocoding/';
},
isolinesHelpLink () {
return 'https://carto.com/help/working-with-data/isolines/';
},
routingHelpLink () {
return 'https://carto.com/help/working-with-data/routing/';
}
}
};
</script>
@@ -0,0 +1,120 @@
<template>
<div class="quota-container">
<div class="quota-headers">
<div class="quota-cell cell--main">
<h3 class="title is-caption quota-title">{{title}}</h3>
</div>
<div class="quota-labels">
<div class="quota-cell cell--medium">
<p class="text is-small is-txtSoftGrey">{{ $t('QuotaSection.header.remaining') }}</p>
</div>
<div class="quota-cell cell--medium cell--mobile">
<p class="text is-small is-txtSoftGrey">{{ $t('QuotaSection.header.used') }}</p>
</div>
<div class="quota-cell cell--medium">
<p class="text is-small is-txtSoftGrey quota-total">
{{$t(`QuotaSection.header.total`)}} <span v-if="perMonth" class="quota-month">{{ $t(`QuotaSection.header.perMonth`) }}</span>
</p>
</div>
<div class="quota-cell cell--small"></div>
</div>
</div>
<div class="quota-info">
<slot></slot>
</div>
</div>
</template>
<script>
export default {
name: 'QuotaContainer',
props: {
title: String,
perMonth: Boolean
}
};
</script>
<style scoped lang="scss">
@import 'new-dashboard/styles/variables';
.quota-headers {
display: flex;
justify-content: space-between;
}
.quota-title {
margin-bottom: 8px;
}
.quota-labels {
display: flex;
}
.quota-info {
display: flex;
flex-direction: column;
align-items: center;
background-color: $white;
}
.quota-cell {
display: flex;
align-items: center;
padding: 0 10px;
white-space: nowrap;
&:first-of-type {
padding-left: 0;
}
&:last-of-type {
padding-right: 0;
}
}
.cell--mobile {
@media (max-width: $layout-mobile) {
display: none;
}
}
.cell--main {
min-width: 300px;
@media (max-width: $layout-mobile) {
min-width: unset;
}
}
.cell--medium {
width: 120px;
}
.cell--small {
width: 58px;
@media (max-width: $layout-tablet) {
display: none;
}
}
.quota-month {
&::after {
content: '*';
}
}
@media (max-width: $layout-tablet) and (min-width: $layout-mobile) {
.quota-total {
&::after {
content: '*';
}
}
.quota-month {
display: none;
}
}
</style>
@@ -0,0 +1,82 @@
<template>
<div class="section is-bgSoftBlue section--noBorder">
<div class="container">
<div class="full-width">
<SectionTitle class="grid-cell">
<template slot="icon">
<img svg-inline src="../../../assets/icons/section-title/quota.svg">
</template>
<template slot="title">{{ $t('QuotaSection.title') }}</template>
</SectionTitle>
<ul class="grid quota-list">
<li class="grid-cell grid-cell--col12 quota-listitem">
<AccountQuota></AccountQuota>
</li>
<li class="grid-cell grid-cell--col12 quota-listitem">
<DataServicesQuota></DataServicesQuota>
</li>
</ul>
<div class="quota-billing">
<span class="quota-billingday text is-small is-txtSoftGrey">
{{ $tc(`QuotaSection.credits`, remainingDaysUntilLDSRenewal, { remainingDays: remainingDaysUntilLDSRenewal, day: billingDay })}}
</span>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
import SectionTitle from 'new-dashboard/components/SectionTitle';
import AccountQuota from './AccountQuota';
import DataServicesQuota from './DataServicesQuota';
import format from 'date-fns/format';
import differenceInDays from 'date-fns/difference_in_days';
import zhCN from 'date-fns/locale/zh_cn';
export default {
name: 'QuotasModule',
components: {
SectionTitle,
AccountQuota,
DataServicesQuota
},
computed: {
...mapState({
billingPeriod: state => state.user.next_billing_period
}),
billingDay () {
return format(new Date(this.billingPeriod), 'Do');
},
remainingDaysUntilLDSRenewal () {
return differenceInDays(new Date(this.billingPeriod), new Date(),{ locale: zhCN });
}
}
};
</script>
<style scoped lang="scss">
@import 'new-dashboard/styles/variables';
.quota-listitem {
&:not(:first-of-type) {
margin-top: 64px;
}
}
.quota-billing {
display: flex;
justify-content: flex-end;
padding: 0 10px;
}
.quota-billingday {
margin-top: 16px;
&::before {
content: '*';
}
}
</style>
@@ -0,0 +1,206 @@
<template>
<div class="quota-widget" @mouseover="onMouseOver" @mouseleave="onMouseLeave">
<div class="quota-main">
<div class="quota-cell cell--title">
<h4 class="text is-caption is-semibold is-txtGrey">{{name}}</h4>
</div>
<div class="quota-cell cell--large">
<div class="progressbar">
<div :class="`progressbar progressbar--${getStatusBar}`" :style="{width: `${getUsedPercent}%`}">
</div>
</div>
</div>
</div>
<div class="quota-data">
<div class="quota-cell cell--medium">
<span class="text is-small is-txtSoftGrey">
{{ formatToLocale ? getNumberInLocaleFormat(roundOneDecimal(remainingQuota)) : roundOneDecimal(remainingQuota) }} {{unit}}
</span>
</div>
<div class="quota-cell cell--medium cell--mobile">
<span class="text is-small is-txtSoftGrey">
{{ formatToLocale ? getNumberInLocaleFormat(roundOneDecimal(usedQuota)) : roundOneDecimal(usedQuota) }} {{unit}}
</span>
</div>
<div class="quota-cell cell--medium">
<span class="text is-small is-txtSoftGrey">
{{ formatToLocale ? getNumberInLocaleFormat(roundOneDecimal(availableQuota)) : roundOneDecimal(availableQuota) }} {{unit}}
</span>
</div>
<div class="quota-help cell--small">
<a :href="helpLink" v-if="helpLink" target= "_blank"><img svg-inline class="quota-image" :class="{'is-active': active}" src="../../../assets/icons/common/question-mark.svg"/></a>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'QuotaCard',
props: {
name: String,
availableQuota: Number,
usedQuota: Number,
unit: String,
billingPeriod: String,
formatToLocale: {
type: Boolean,
default: true
},
helpLink: String
},
data: function () {
return {
active: false
};
},
computed: {
remainingQuota () {
const remainingQuota = this.availableQuota - this.usedQuota;
return Math.max(0, remainingQuota);
},
getUsedPercent () {
if (this.availableQuota === 0) {
return 100;
}
const width = (this.usedQuota / this.availableQuota) * 100;
return width;
},
getStatusBar () {
const usedPercent = this.getUsedPercent;
if (usedPercent > 90) {
return 'problem';
} else if (usedPercent > 60) {
return 'warning';
} else {
return 'good';
}
}
},
methods: {
roundOneDecimal (number) {
return Math.round(number * 10) / 10;
},
getNumberInLocaleFormat (number) {
return number.toLocaleString();
},
onMouseOver () {
this.active = true;
},
onMouseLeave () {
this.active = false;
}
}
};
</script>
<style scoped lang="scss">
@import 'new-dashboard/styles/variables';
.quota-widget {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 80px;
&:not(:last-of-type) {
border-bottom: 1px solid $softblue;
}
}
.quota-main {
display: flex;
width: 100%;
}
.quota-data {
display: flex;
}
.quota-cell {
display: flex;
align-items: center;
padding: 0 10px;
white-space: nowrap;
&:first-of-type {
padding-left: 0;
}
&:last-of-type {
padding-right: 0;
}
}
.cell--mobile {
@media (max-width: $layout-mobile) {
display: none;
}
}
.quota-help {
display: flex;
justify-content: flex-end;
padding-right: 16px;
cursor: pointer;
}
.quota-image {
display: none;
&.is-active {
display: flex;
}
}
.cell--title {
width: 110px;
margin-left: 36px;
}
.cell--large {
flex-grow: 1;
flex-shrink: 1;
min-width: 120px;
margin-right: 40px;
@media (max-width: $layout-mobile) {
display: none;
}
}
.cell--medium {
width: 120px;
}
.cell--small {
width: 58px;
@media (max-width: $layout-tablet) {
display: none;
}
}
.progressbar {
width: 100%;
max-width: 240px;
height: 8px;
border-radius: 4px;
background-color: $progressbar__bg-color;
&.progressbar--good {
background-color: $success__bg-color;
}
&.progressbar--warning {
background-color: $warning__bg-color;
}
.progressbar--problem {
background-color: $danger__bg-color;
}
}
</style>
@@ -0,0 +1,88 @@
<template>
<section class="recent-section is-bgSoftBlue">
<div class="container">
<SectionTitle class="grid-cell">
<template slot="icon">
<img src="../../../assets/icons/section-title/recent.svg">
</template>
<template slot="title">{{ $t('HomePage.RecentSection.title') }}</template>
<template slot="actionButton">
<button class="button button--small is-primary button--ghost button--last" @click="goToTagsSection">
<img svg-inline src="../../../assets/icons/sections/recent-content/tags.svg" class="recent__action"/>
{{ $t('HomePage.RecentSection.viewTagsAction') }}
</button>
</template>
</SectionTitle>
<ul class="grid">
<template v-if="!isFetching">
<li class="card grid-cell grid-cell--col4 grid-cell--col6--tablet grid-cell--col12--mobile"
v-for="visualization in recentContent"
:key="visualization.id">
<SimpleMapCard
:visualization="visualization"
:visibleSections="visibleSections"
:canHover="false"
:singleLineTitle="true"
storeActionType="recentContent"
@contentChanged="onContentChanged" />
</li>
</template>
<template v-else>
<li class="card grid-cell grid-cell--col4 grid-cell--col6--tablet grid-cell--col12--mobile"
v-for="n in 3"
:key="n">
<SimpleMapCardFake :visibleSections="visibleSections"></SimpleMapCardFake>
</li>
</template>
</ul>
</div>
</section>
</template>
<script>
import { mapState } from 'vuex';
import SectionTitle from 'new-dashboard/components/SectionTitle';
import SimpleMapCard from 'new-dashboard/components/MapCard/SimpleMapCard';
import SimpleMapCardFake from 'new-dashboard/components/MapCard/fakes/SimpleMapCardFake';
export default {
name: 'RecentSection',
components: {
SectionTitle,
SimpleMapCard,
SimpleMapCardFake
},
data () {
return {
visibleSections: ['privacy', 'lastModification']
};
},
computed: {
...mapState({
isFetching: state => {
return state.recentContent.isFetching;
},
recentContent: state => state.recentContent.list
})
},
methods: {
goToTagsSection () {
const section = 'TagsSection';
this.$emit('sectionChange', section);
this.$router.push({ query: { section, sectionPage: 1 } });
},
onContentChanged (type) {
this.$emit('contentChanged', type);
}
}
};
</script>
<style lang="scss" scoped>
.recent__action {
margin-right: 12px;
}
</style>
@@ -0,0 +1,134 @@
<template>
<section class="tags-section is-bgSoftBlue">
<div class="container">
<SectionTitle class="grid-cell">
<template slot="icon">
<img src="../../../assets/icons/section-title/tags.svg">
</template>
<template slot="title">{{ $t('HomePage.TagsSection.title') }}</template>
<template slot="actionButton">
<button class="button button--small is-primary button--ghost button--last" @click="goToRecentSection">
<img svg-inline src="../../../assets/icons/sections/tags/recent_content.svg" class="tags__action"/>
{{ $t('HomePage.TagsSection.viewRecentContentAction') }}
</button>
</template>
</SectionTitle>
<LoadingState v-if="isFetching" :text="$t('HomePage.TagsSection.loading')" class="loading"></LoadingState>
<EmptyState
:text="$t('HomePage.TagsSection.emptyState.title')"
:subtitle="$t('HomePage.TagsSection.emptyState.subtitle')"
v-if="isEmptyState">
</EmptyState>
<ul class="grid tags" v-if="hasTags">
<li class="card grid-cell grid-cell--col4 grid-cell--col6--tablet grid-cell--col12--mobile tag" v-for="tag in tags" :key="tag.tag">
<TagCard :tag="tag" />
</li>
</ul>
<Pagination class="pagination-element" v-if="shouldShowPagination" :page=currentPage :numPages=numPages @pageChange="goToPage"></Pagination>
</div>
</section>
</template>
<script>
import SectionTitle from 'new-dashboard/components/SectionTitle';
import TagCard from 'new-dashboard/components/Tag/TagCard';
import Pagination from 'new-dashboard/components/Pagination';
import EmptyState from 'new-dashboard/components/States/EmptyState';
import LoadingState from 'new-dashboard/components/States/LoadingState';
export default {
name: 'TagsSection',
components: {
SectionTitle,
TagCard,
Pagination,
EmptyState,
LoadingState
},
mounted () {
const page = parseInt(this.$route.query.sectionPage);
this.goToPage(page);
},
data () {
return {
isFetching: false,
tags: [],
currentPage: 1,
perPage: 6,
totalTags: 0
};
},
computed: {
hasTags () {
return !this.isFetching && this.totalTags > 0;
},
isEmptyState () {
return !this.isFetching && this.totalTags <= 0;
},
numPages () {
return Math.ceil(this.totalTags / this.perPage);
},
shouldShowPagination () {
return this.numPages > 1;
}
},
methods: {
goToRecentSection () {
const section = 'RecentSection';
this.setURLParams({ section, sectionPage: 1 });
this.$emit('sectionChange', section);
},
getTags (options = {}) {
this.isFetching = true;
options.perPage = this.perPage;
this.$store.state.client.getTags(options,
(err, _, tags) => {
this.isFetching = false;
if (err) {
return;
}
this.tags = tags.result;
this.totalTags = tags.total;
this.currentPage = options.page;
}
);
},
goToPage (page) {
this.setURLParams({ sectionPage: page });
this.getTags({ page });
},
setURLParams ({ section = 'TagsSection', sectionPage = 1 }) {
this.$router.push({ query: { section, sectionPage } });
}
}
};
</script>
<style lang="scss" scoped>
.tags {
margin-bottom: 10px;
}
.tag {
margin-bottom: 24px;
}
.tags__action {
margin-right: 12px;
}
.loading {
margin: 154px 0 85px;
}
</style>
@@ -0,0 +1,92 @@
<template>
<section class="welcome-section">
<WelcomeFirst v-if="isFirst" :name="name" :userType="userType"></WelcomeFirst>
<WelcomeCompact v-if="!isFirst" :name="name" :userType="userType">
<template v-if="trialEndDate">
<span v-html="trialTimeLeft" class="title is-small"></span>
<a class="title is-small" :href="accountUpgradeURL" v-if="accountUpgradeURL">
{{ $t('HomePage.WelcomeSection.subscribeNow') }}
</a>
</template>
</WelcomeCompact>
</section>
</template>
<script>
import { mapState } from 'vuex';
import distanceInWordsStrict from 'date-fns/distance_in_words_strict';
import WelcomeCompact from './WelcomeCompact';
import WelcomeFirst from './WelcomeFirst';
import WelcomeBasic from './WelcomeBasic';
import { isOrganizationAdmin } from 'new-dashboard/core/models/organization';
export default {
name: 'Welcome',
components: {
WelcomeBasic,
WelcomeCompact,
WelcomeFirst
},
computed: {
...mapState({
isFirst: state => state.config.isFirstTimeViewingDashboard,
accountUpgradeURL: state => state.config.upgrade_url,
trialEndDate: state => state.user.trial_ends_at,
user: state => state.user,
name: state => state.user.name || state.user.username,
organization: state => state.user.organization,
notifications: state => state.user.organizationNotifications
}),
trialTimeLeft () {
return this.$t(`HomePage.WelcomeSection.trialMessage`, { date: distanceInWordsStrict(this.trialEndDate, new Date(), { partialMethod: 'round' }) });
},
userType () {
if (this.isOrganizationAdmin()) {
return 'organizationAdmin';
}
if (this.isOrganizationUser()) {
return 'organizationUser';
}
if (this.isIndividualUser()) {
return 'individual';
}
if (this.isPersonal30()) {
return '30day';
}
if (this.isFreeUser()) {
return 'free';
}
return 'unknown';
}
},
methods: {
isPersonal30 () {
const personal30User = ['PERSONAL30'];
return personal30User.includes(this.user.account_type);
},
isFreeUser () {
const freeUser = ['FREE'];
return freeUser.includes(this.user.account_type);
},
isIndividualUser () {
const individualUsers = ['Individual'];
return individualUsers.includes(this.user.account_type);
},
isOrganizationAdmin () {
if (!this.isOrganizationUser()) {
return false;
}
return isOrganizationAdmin(this.user.organization, this.user);
},
isOrganizationUser () {
return Boolean(this.organization);
}
}
};
</script>
@@ -0,0 +1,69 @@
<template>
<section class="welcome-basic">
<div class="container">
<div class="welcome-basic__greeting title is-title">{{ greeting }}</div>
<span v-if="hasNotifications" class="notification-led"></span>
<div class="welcome-basic__text text is-caption">{{ text }}</div>
<div class="welcome-basic__actions">
<button v-if="!hasNotifications" class="button button--small is-primary">Learn More</button>
<button v-if="hasNotifications" class="button button--small is-primary">Go to Notifications</button>
</div>
</div>
</section>
</template>
<script>
export default {
name: 'WelcomeBasic',
props: {
username: String,
notifications: Array
},
computed: {
greeting () {
return this.$t('HomePage.WelcomeSection.greeting', {username: this.$props.username});
},
text () {
return this.hasNotifications
? 'You have unread notifications, do you want to read them now?'
: 'We just released CARTO VL, our new vector rendering Javascript library to create amazing LI applications.';
},
hasNotifications () {
return this.$props.notifications.length > 0;
}
}
};
</script>
<style scoped lang="scss">
@import "new-dashboard/styles/variables";
.welcome-basic {
position: relative;
padding: 124px 0;
text-align: center;
.notification-led {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
background: $notification__bg-color;
vertical-align: top;
}
&__greeting {
display: inline-block;
}
&__text {
max-width: 724px;
margin: 16px auto 48px;
}
&__actions {
display: flex;
justify-content: center;
}
}
</style>
@@ -0,0 +1,88 @@
<template>
<section class="welcome-compact">
<div class="container welcome-compact__content">
<div class="welcome-compact__greeting title is-caption">{{ greeting }}</div>
<div class="welcome-compact__actions">
<OnboardingButton :isFirstTimeViewingDashboard="false"></OnboardingButton>
<CreateButton class="button--ghost" visualizationType="map" :disabled="isViewer">{{ $t(`HomePage.WelcomeSection.actions.createMap`) }}</CreateButton>
<CreateButton class="button--ghost" visualizationType="dataset" :disabled="!canCreateDatasets">{{ $t(`HomePage.WelcomeSection.actions.createDataset`) }}</CreateButton>
</div>
<div class="welcome-compact__extra">
<slot />
</div>
</div>
</section>
</template>
<script>
import CreateButton from 'new-dashboard/components/CreateButton.vue';
import OnboardingButton from 'new-dashboard/components/Onboarding/OnboardingButton.vue';
export default {
name: 'WelcomeCompact',
components: {
CreateButton,
OnboardingButton
},
props: {
name: String
},
computed: {
greeting () {
return this.$t('HomePage.WelcomeSection.greeting', {name: this.$props.name});
},
canCreateDatasets () {
return this.$store.getters['user/canCreateDatasets'];
},
isViewer () {
return this.$store.getters['user/isViewer'];
}
}
};
</script>
<style scoped lang="scss">
@import "new-dashboard/styles/variables";
.welcome-compact__content {
display: flex;
align-items: center;
max-width: 940px;
padding: 0;
}
.welcome-compact {
position: relative;
padding: 20px;
border-bottom: 1px solid #E6E8EB;
&__greeting {
display: inline-block;
margin-right: 64px;
}
&__actions {
display: inline-flex;
}
&__extra {
display: flex;
align-items: center;
margin-left: auto;
}
.button--ghost {
margin-right: 48px;
padding: 0;
background: none;
color: #047AE6;
text-transform: none;
}
.button--outline {
margin-left: 24px;
padding: 9px 16px;
}
}
</style>
@@ -0,0 +1,107 @@
<template>
<section class="welcome-first">
<div class="container">
<div class="welcome-first__greeting title is-title">{{ greeting }}</div>
<div class="welcome-first__text text is-caption" v-html="text"></div>
<div class="welcome-first__actions">
<OnboardingButton v-if="!isOrganizationAdmin" :isFirstTimeViewingDashboard="true"></OnboardingButton>
<a class="button button--border"
:href="`${ baseUrl }/organization`"
v-if="isOrganizationAdmin">
{{ $t('HomePage.WelcomeSection.firstTime.manageOrganization') }}
</a>
</div>
</div>
</section>
</template>
<script>
import differenceInDays from 'date-fns/difference_in_days';
import CreateButton from 'new-dashboard/components/CreateButton.vue';
import OnboardingButton from 'new-dashboard/components/Onboarding/OnboardingButton.vue';
export default {
name: 'WelcomeFirst',
components: {
CreateButton,
OnboardingButton
},
props: {
name: String,
userType: String
},
computed: {
greeting () {
return this.$t('HomePage.WelcomeSection.greeting', {name: this.$props.name});
},
text () {
const organizationName = this.$store.state.user.organization && this.$store.state.user.organization.name;
const firstTimeMessage = this.$t('HomePage.WelcomeSection.firstTime.message');
const planMessage = this.$t(`HomePage.WelcomeSection.firstTime.planMessage.${this.userType}`, {
organizationName,
trialLength: this.trialLength
});
return `${firstTimeMessage} ${planMessage}`;
},
baseUrl () {
return this.$store.state.user.base_url;
},
isOrganizationAdmin () {
return this.userType === 'organizationAdmin';
},
isOrganizationUser () {
return this.userType === 'organizationUser';
},
organizationMail () {
const organization = this.$store.state.user.organization;
return organization.admin_email;
},
canCreateDatasets () {
return this.$store.getters['user/canCreateDatasets'];
},
isViewer () {
return this.$store.getters['user/isViewer'];
},
trialLength () {
const trialEndDate = this.$store.state.user.trial_ends_at;
const createdAt = this.$store.state.user.created_at;
return trialEndDate ? differenceInDays(trialEndDate, createdAt) : null;
}
}
};
</script>
<style scoped lang="scss">
@import "new-dashboard/styles/variables";
.welcome-first {
position: relative;
padding: 124px 0;
background: $primary-color;
color: $white;
text-align: center;
&__text {
max-width: 724px;
margin: 16px auto 48px;
}
&__actions {
display: flex;
justify-content: center;
}
.button.button--border {
border: 1px solid $white;
background: none;
color: $white;
text-transform: uppercase;
&:not(:last-child) {
margin-right: 36px;
}
}
}
</style>
@@ -0,0 +1,37 @@
<template>
<Page>
<div class="container maintenance-container">
<div class="maintenance-icon">
<img src="../assets/icons/common/wrench.svg" />
</div>
<h2 class="maintenance-title title is-title is-txtDarkBlue is-bold">{{ $t('MaintenancePage.title') }}</h2>
<p class="text is-body is-txtDarkBlue">{{ $t('MaintenancePage.text') }}</p>
</div>
</Page>
</template>
<script>
import Page from 'new-dashboard/components/Page';
export default {
name: 'Maintenance',
components: {
Page
}
};
</script>
<style scoped lang="scss">
.maintenance-container {
text-align: center;
}
.maintenance-icon {
margin-bottom: 48px;
}
.maintenance-title {
margin-bottom: 16px;
}
</style>
@@ -0,0 +1,150 @@
<template>
<Page>
<StickySubheader :is-visible="Boolean(selectedMaps.length && isScrollPastHeader)">
<h2 class="title is-caption">
{{ $t('BulkActions.selected', {count: selectedMaps.length}) }}
</h2>
<MapBulkActions
:selectedMaps="selectedMaps"
:areAllMapsSelected="areAllMapsSelected"
@selectAll="selectAll"
@deselectAll="deselectAll"></MapBulkActions>
</StickySubheader>
<MapsList
ref="mapsList"
class="grid__content"
:hasBulkActions="true"
:isCondensedDefault="isCondensed"
:canChangeViewMode="true"
:canHoverCard="true"
:maxVisibleMaps="maxVisibleMaps"
@applyFilter="applyFilter"
@applyOrder="applyOrder"
@selectionChange="updateSelected" />
<Pagination v-if="shouldShowPagination" :page=currentPage :numPages=numPages @pageChange="goToPage"></Pagination>
</Page>
</template>
<script>
import { checkFilters } from 'new-dashboard/router/hooks/check-navigation';
import { mapState } from 'vuex';
import Page from 'new-dashboard/components/Page';
import MapBulkActions from 'new-dashboard/components/BulkActions/MapBulkActions.vue';
import Pagination from 'new-dashboard/components/Pagination';
import StickySubheader from 'new-dashboard/components/StickySubheader';
import MapsList from 'new-dashboard/components/MapsList.vue';
export default {
name: 'MapsPage',
components: {
Page,
MapBulkActions,
StickySubheader,
Pagination,
MapsList
},
data () {
return {
isScrollPastHeader: false,
maxVisibleMaps: 12,
selectedMaps: [],
isCondensed: false
};
},
created: function () {
this.loadUserConfiguration();
},
beforeMount () {
if (this.$store.getters['user/isViewer']) {
// Redirect to shared maps page if user is viewer
return this.$router.replace({ name: 'maps', params: { filter: 'shared' } });
}
},
mounted () {
this.stickyScrollPosition = this.getHeaderBottomPageOffset();
this.$onScrollChange = this.onScrollChange.bind(this);
document.addEventListener('scroll', this.$onScrollChange, { passive: true });
},
beforeDestroy () {
document.removeEventListener('scroll', this.$onScrollChange, { passive: true });
},
beforeRouteUpdate (to, from, next) {
checkFilters('maps', 'maps', to, from, next);
},
computed: {
...mapState({
numPages: state => state.maps.numPages,
currentPage: state => state.maps.page,
maps: state => state.maps.list,
isFetchingMaps: state => state.maps.isFetching,
currentEntriesCount: state => state.maps.metadata.total_entries
}),
isNotificationVisible () {
return this.$store.getters['user/isNotificationVisible'];
},
areAllMapsSelected () {
return Object.keys(this.maps).length === this.selectedMaps.length;
},
shouldShowPagination () {
return !this.isFetchingMaps && this.currentEntriesCount > 0 && this.numPages > 1;
}
},
methods: {
updateSelected (selected) {
this.selectedMaps = selected;
},
goToPage (page) {
window.scroll({ top: 0, left: 0 });
this.deselectAll();
this.$router.push({
name: 'maps',
params: this.$route.params,
query: { ...this.$route.query, page }
});
},
applyFilter (filter) {
this.$router.push({ name: 'maps', params: { filter } });
},
applyOrder (orderParams) {
this.deselectAll();
this.$router.push({
name: 'maps',
params: this.$route.params,
query: {
...this.$route.query,
order: orderParams.order,
order_direction: orderParams.direction
}
});
},
selectAll () {
this.$refs.mapsList.selectAll();
},
deselectAll () {
this.$refs.mapsList.deselectAll();
},
onScrollChange () {
this.isScrollPastHeader = window.pageYOffset > this.stickyScrollPosition;
},
getHeaderBottomPageOffset () {
const headerContainer = this.$refs.mapsList.getHeaderContainer();
const headerBoundingClientRect = headerContainer.$el.getBoundingClientRect();
const notificationHeight = this.isNotificationVisible ? 60 : 0;
return headerBoundingClientRect.top - notificationHeight;
},
loadUserConfiguration () {
if (localStorage.hasOwnProperty('mapViewMode')) {
if (localStorage.mapViewMode === 'compact') {
this.isCondensed = true;
} else if (localStorage.mapViewMode === 'standard') {
this.isCondensed = false;
}
}
}
}
};
</script>
@@ -0,0 +1,99 @@
<template>
<Page>
<div class="notifications-list-container container grid grid__content">
<div class="full-width">
<SectionTitle class="grid-cell" :showActionButton="false" ref="headerContainer">
<template slot="icon">
<img src="../assets/icons/section-title/envelope.svg">
</template>
<template slot="title">{{ pageTitle }}</template>
</SectionTitle>
<ul v-if="!emptyState" class="notifications-list grid-cell grid-cell--col9 grid-cell--col12--tablet">
<li class="notification-item" v-for="notification in notifications" :key="notification.id">
<NotificationCard
:receivedAt="notification.received_at"
:readAt="notification.read_at"
:htmlBody="notification.html_body">
</NotificationCard>
</li>
</ul>
<EmptyState v-if="!isFetching && emptyState" :text="emptyStateText">
<img svg-inline src="../assets/icons/common/check-bubble.svg">
</EmptyState>
<LoadingState v-if="isFetching" :text="loadingStateText" class="loading-state"></LoadingState>
</div>
</div>
</Page>
</template>
<script>
import Page from 'new-dashboard/components/Page';
import EmptyState from '../components/States/EmptyState';
import LoadingState from '../components/States/LoadingState';
import NotificationCard from '../components/NotificationCard';
import SectionTitle from '../components/SectionTitle';
export default {
name: 'NotificationsPage',
components: {
Page,
EmptyState,
LoadingState,
NotificationCard,
SectionTitle
},
computed: {
pageTitle () {
return this.$t(`NotificationsPage.header.title`);
},
notifications () {
return this.$store.state.notifications.notifications;
},
emptyState () {
return !this.notifications || !this.notifications.length;
},
emptyStateText () {
return this.$t(`NotificationsPage.emptyState`);
},
loadingStateText () {
return this.$t(`NotificationsPage.loadingState`);
},
isFetching () {
return this.$store.state.notifications.isFetching;
}
},
mounted: function () {
this.$store.dispatch('user/resetOrganizationNotifications');
}
};
</script>
<style scoped lang="scss">
@import "new-dashboard/styles/variables";
.notifications-list-container {
margin-bottom: 44px;
}
.full-width {
width: 100%;
}
.notification-item {
margin-bottom: 36px;
border-bottom: 1px solid $softblue;
&:last-child {
border-bottom: none;
}
.notification {
margin-bottom: 36px;
}
}
.empty-state,
.loading-state {
margin: 20vh 0 8vh;
}
</style>
@@ -0,0 +1,311 @@
<template>
<Page class="page__sticky-subheader">
<StickySubheader :is-visible="true" class="page-subheader">
<span class="title" v-if="isFirstFetch">
{{ $t('SearchPage.title.allFetching', { query: searchTerm || tag }) }}
<span class="loading">
<img svg-inline src="../assets/icons/common/loading.svg" class="loading__svg"/>
</span>
</span>
<span class="title" v-else-if="searchTerm">{{ $tc('SearchPage.title.searchTerm', totalResults, { query: searchTerm }) }}</span>
<span class="title" v-else-if="tag">{{ $tc('SearchPage.title.tag', totalResults, { query: tag }) }}</span>
</StickySubheader>
<div class="container grid grid__content">
<div class="full-width">
<section class="section section--maps" :class="{ 'has-pagination': hasMaps && mapsNumPages > 1 }" ref="maps">
<div class="section__title grid-cell title is-medium">{{ $t('SearchPage.sections.maps') }}</div>
<div class="js-grid__head--sticky">
<div v-if="hasMaps || isFetchingMaps"
class="grid-cell grid-cell--noMargin grid-cell--col12 grid__head--sticky"
:class="{ 'has-user-notification': isNotificationVisible }">
<CondensedMapHeader order="" orderDirection="" :isSortable="false"></CondensedMapHeader>
</div>
<ul class="grid-cell grid-cell--col12" v-if="isFetchingMaps">
<li v-for="n in 6" :key="n" class="search-item">
<MapCardFake :condensed="true" class="search-item"></MapCardFake>
</li>
</ul>
<ul class="grid-cell grid-cell--col12" v-if="!isFetchingMaps">
<li v-for="map in maps" :key="map.id" class="search-item">
<MapCard :visualization=map :canHover=false :condensed="true" storeActionType="search"></MapCard>
</li>
<div class="is-caption text maps--empty" v-if="!hasMaps">
{{ $t('SearchPage.emptyText.maps') }}
</div>
</ul>
</div>
<Pagination
v-if="hasMaps && mapsNumPages > 1"
:page=mapsPage
:numPages=mapsNumPages
@pageChange="page => onPageChange('maps', page)"></Pagination>
</section>
<section class="section section--datasets" ref="datasets">
<div class="section__title grid-cell title is-medium">{{ $t('SearchPage.sections.data') }}</div>
<div class="js-grid__head--sticky">
<div
v-if="hasDatasets || isFetchingDatasets"
class="grid-cell grid-cell--noMargin grid-cell--col12 grid__head--sticky"
:class="{ 'has-user-notification': isNotificationVisible }">
<DatasetListHeader order="" orderDirection="" :isSortable="false"></DatasetListHeader>
</div>
<ul class="grid-cell grid-cell--col12" v-if="!isFetchingDatasets">
<li v-for="dataset in datasets" :key="dataset.id" class="search-item">
<DatasetCard :dataset=dataset :canHover=false storeActionType="search"></DatasetCard>
</li>
<div class="is-caption text data--empty" v-if="!hasDatasets">
{{ $t('SearchPage.emptyText.datasets') }}
</div>
</ul>
<ul class="grid-cell grid-cell--col12" v-if="isFetchingDatasets">
<li v-for="n in 6" :key="n" class="search-item">
<DatasetCardFake></DatasetCardFake>
</li>
</ul>
</div>
<Pagination
class="pagination-element"
v-if="hasDatasets && datasetsNumPages > 1"
:page=datasetsPage
:numPages=datasetsNumPages
@pageChange="page => onPageChange('datasets', page)"></Pagination>
</section>
<section class="section section--tags" ref="tags" v-if="isTermSearch">
<div class="section__title grid-cell title is-medium">{{ $t('SearchPage.sections.tags') }}</div>
<ul class="grid" v-if="!isFetchingTags">
<li v-for="(tag, n) in tags" :key="n" class="search-item--tag grid-cell grid-cell--col4 grid-cell--col6--tablet grid-cell--col12--mobile">
<TagCard :tag=tag :condensed="true"></TagCard>
</li>
<li class="grid-cell">
<div class="is-caption text" v-if="!hasTags">
{{ $t('SearchPage.emptyText.tags') }}
</div>
</li>
</ul>
<ul class="grid" v-if="isFetchingTags">
<li v-for="n in 6" :key="n" class="search-item--tag grid-cell grid-cell--col4 grid-cell--col6--tablet grid-cell--col12--mobile">
<FakeTagCard></FakeTagCard>
</li>
</ul>
<Pagination
class="pagination-element"
v-if="hasTags && tagsNumPages > 1"
:page=tagsPage
:numPages=tagsNumPages
@pageChange="page => onPageChange('tags', page)"></Pagination>
</section>
</div>
</div>
</Page>
</template>
<script>
import Page from 'new-dashboard/components/Page';
import StickySubheader from 'new-dashboard/components/StickySubheader';
import CondensedMapHeader from 'new-dashboard/components/MapCard/CondensedMapHeader.vue';
import MapCard from 'new-dashboard/components/MapCard/MapCard.vue';
import MapCardFake from 'new-dashboard/components/MapCard/fakes/MapCardFake';
import DatasetListHeader from '../components/Dataset/DatasetListHeader';
import DatasetCard from 'new-dashboard/components/Dataset/DatasetCard';
import DatasetCardFake from 'new-dashboard/components/Dataset/DatasetCardFake';
import TagCard from 'new-dashboard/components/Tag/TagCard';
import FakeTagCard from 'new-dashboard/components/Tag/FakeTagCard';
import Pagination from 'new-dashboard/components/Pagination';
import updateSearchParams from 'new-dashboard/router/hooks/update-search-params';
import { mapState } from 'vuex';
const TWO_HEADERS_HEIGHT = 128;
export default {
name: 'SearchPage',
components: {
Page,
CondensedMapHeader,
DatasetCard,
DatasetCardFake,
DatasetListHeader,
MapCard,
MapCardFake,
Pagination,
StickySubheader,
TagCard,
FakeTagCard
},
beforeRouteUpdate (to, from, next) {
this.$store.dispatch('search/resetState');
this.isFirstFetch = true;
this.updateSearchParams(to, from, next);
},
beforeRouteLeave (to, from, next) {
this.$store.dispatch('search/resetState');
next();
},
data () {
return {
isFirstFetch: true
};
},
computed: {
...mapState({
searchTerm: state => state.search.searchTerm,
tag: state => state.search.tag,
maps: state => state.search.maps.results,
mapsPage: state => state.search.maps.page,
mapsNumPages: state => state.search.maps.numPages,
isFetchingMaps: state => state.search.maps.isFetching,
datasets: state => state.search.datasets.results,
datasetsPage: state => state.search.datasets.page,
datasetsNumPages: state => state.search.datasets.numPages,
isFetchingDatasets: state => state.search.datasets.isFetching,
tags: state => state.search.tags.results,
tagsPage: state => state.search.tags.page,
tagsNumPages: state => state.search.tags.numPages,
isFetchingTags: state => state.search.tags.isFetching,
totalResults: state => state.search.maps.numResults +
state.search.datasets.numResults +
state.search.tags.numResults
}),
hasMaps () {
return Object.keys(this.maps || {}).length;
},
hasDatasets () {
return Object.keys(this.datasets || {}).length;
},
hasTags () {
return (this.tags || []).length;
},
allSectionsFetching () {
return this.isFetchingMaps || this.isFetchingDatasets || this.isFetchingTags;
},
isTermSearch () {
return this.searchTerm;
},
isNotificationVisible () {
return this.$store.getters['user/isNotificationVisible'];
}
},
methods: {
onPageChange (section, page) {
this.$store.dispatch('search/changeSectionPage', { section, page });
this.scrollToSection(section);
},
isOutOfViewport (boundingClientRect) {
return boundingClientRect.top < TWO_HEADERS_HEIGHT;
},
scrollToSection (section) {
const sectionBoundingClientRect = this.$refs[section].getBoundingClientRect();
const offsetDistance = TWO_HEADERS_HEIGHT + 16; // Two headers + margin
if (!this.isOutOfViewport(sectionBoundingClientRect)) {
return;
}
window.scrollBy({ top: sectionBoundingClientRect.top - offsetDistance, behavior: 'smooth' });
},
updateSearchParams
},
watch: {
allSectionsFetching (newValue) {
if (newValue === false) {
this.isFirstFetch = false;
}
}
}
};
</script>
<style lang="scss" scoped>
@import 'new-dashboard/styles/variables';
.section {
position: relative;
margin-bottom: 48px;
padding: 0;
border-bottom: 0;
&--maps {
z-index: $z-index__stack-context--first;
}
&--datasets {
z-index: $z-index__stack-context--second;
}
&--tags {
z-index: $z-index__stack-context--third;
.search-item--tag {
margin-bottom: 24px;
}
}
&.has-pagination,
&:last-child {
margin-bottom: 48px;
}
&__title {
margin-bottom: 16px;
}
}
.page-subheader {
background-color: $softblue;
}
.loading {
margin-left: 24px;
&__svg {
width: 18px;
vertical-align: text-top;
g {
stroke-width: 2px;
}
}
}
.full-width {
width: 100%;
}
.grid__head--sticky {
top: 128px;
}
.grid__head--sticky.has-user-notification {
top: 128px + $notification-warning__height;
}
.map-element {
margin-bottom: 36px;
}
.pagination-element {
margin-top: 36px;
}
.search-item {
&:not(:last-child) {
border-bottom: 1px solid $border-color;
}
}
.maps--empty,
.data--empty {
margin-bottom: 64px;
}
</style>
@@ -0,0 +1,121 @@
<template>
<Page class="is-bgSoftBlue">
<div class="container grid grid__content">
<h3 class="title is-body solutions-title">{{ $t(`SolutionsPage.solutionsTitle`) }}</h3>
<p class="text is-caption solutions-description">{{ $t(`SolutionsPage.solutionsDescription`) }}</p>
<ul class="grid-cell--col12 grid">
<li class="grid-cell--col12 solution-element">
<SolutionCard
:title="$t(`SolutionsPage.sitePlanning.title`)"
:description="$t(`SolutionsPage.sitePlanning.description`)"
infoUrl="https://carto.com/solutions/site-planning/"
demoUrl="https://carto.com/demo/siteplanning-demo/">
<template slot="icon"><img svg-inline src="../assets/icons/solutions/site-planning.svg"></template>
<template slot="image"><img src="../assets/icons/solutions/screens/site-planning.png"></template>
<template slot="demo-logos">
<img class="demo-card-logo" src="../assets/icons/solutions/logos/dhl.svg"/>
<img class="demo-card-logo" src="../assets/icons/solutions/logos/securitas.svg"/>
</template>
</SolutionCard>
</li>
<li class="grid-cell--col12 solution-element">
<SolutionCard
:title="$t(`SolutionsPage.territoryManagement.title`)"
:description="$t(`SolutionsPage.territoryManagement.description`)"
infoUrl="https://carto.com/solutions/territory-management/"
demoUrl="https://carto.com/demo/salesquest-demo/">
<template slot="icon"><img svg-inline src="../assets/icons/solutions/territory.svg"></template>
<template slot="image"><img src="../assets/icons/solutions/screens/territory.png"></template>
<template slot="demo-logos">
<img class="demo-card-logo" src="../assets/icons/solutions/logos/fcc.svg"/>
<img class="demo-card-logo" src="../assets/icons/solutions/logos/dpd.svg"/>
<img class="demo-card-logo" src="../assets/icons/solutions/logos/la-poste.svg"/>
</template>
</SolutionCard>
</li>
<li class="grid-cell--col12 solution-element">
<SolutionCard
:title="$t(`SolutionsPage.logisticsOptimization.title`)"
:description="$t(`SolutionsPage.logisticsOptimization.description`)"
infoUrl="https://carto.com/solutions/logistics-optimization/"
demoUrl="https://carto.com/demo/logistic-demo/">
<template slot="icon"><img svg-inline src="../assets/icons/solutions/logistics.svg"></template>
<template slot="image"><img src="../assets/icons/solutions/screens/delivery.png"></template>
<template slot="demo-logos">
<img class="demo-card-logo" src="../assets/icons/solutions/logos/bonobos.svg"/>
<img class="demo-card-logo" src="../assets/icons/solutions/logos/ikea.svg"/>
<img class="demo-card-logo" src="../assets/icons/solutions/logos/sanitas.svg"/>
</template>
</SolutionCard>
</li>
</ul>
<div class="talk-us-card">
<h3 class="title is-medium">{{ $t(`SolutionsPage.talkUsTitle`) }}</h3>
<p class="text is-caption card-description">{{ $t(`SolutionsPage.talkUsDescription`) }}</p>
<div class="talk-us-button">
<a href="https://carto.com/request-live-demo/" target="_blank" class="button u-no-decoration">
<span>{{ $t(`SolutionsPage.talkUsButton`) }}</span>
</a>
</div>
</div>
</div>
</Page>
</template>
<script>
import Page from 'new-dashboard/components/Page';
import SolutionCard from 'new-dashboard/components/Solutions/SolutionCard';
export default {
name: 'SolutionsPage',
components: {
Page,
SolutionCard
}
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="scss">
@import 'new-dashboard/styles/variables';
.solutions-title {
width: 100%;
margin-bottom: 16px;
font-size: 24px;
line-height: 32px;
text-align: center;
}
.solutions-description {
max-width: 615px;
margin-right: auto;
margin-left: auto;
text-align: center;
}
.talk-us-card {
width: 100%;
margin-top: 64px;
padding: 36px 28px;
border-radius: 2px;
background-color: $white;
}
.card-description {
max-width: 561px;
margin-top: 8px;
margin-bottom: 24px;
}
.talk-us-button {
display: flex;
}
.solution-element {
margin-top: 48px;
}
</style>
@@ -0,0 +1,24 @@
<template>
<Page>
<ExpirationMessage :expirationDays="expirationDays" :addPaymentsURL="addPaymentsURL"></ExpirationMessage>
</Page>
</template>
<script>
import Page from 'new-dashboard/components/Page';
import ExpirationMessage from 'new-dashboard/components/TrialExpired/ExpirationMessage';
export default {
name: 'TrialExpired',
components: {
Page,
ExpirationMessage
},
data () {
return {
addPaymentsURL: window.payments_url,
expirationDays: window.expiration_days
};
}
};
</script>