ソースを参照

add the events index page

Olivier Massot 3 年 前
コミット
f26c089f08

+ 3 - 3
README.md

@@ -4,7 +4,7 @@
 
 Frames est une application Nuxt proposant des iframes à inclure sur des sites tierces.
 
-Iframes implémentées:
+Iframes implémentées :
 
 * [Recherche des structures des fédérations](https://ressources.opentalent.fr/display/SPEC/Les+societes+adherentes)
 
@@ -22,7 +22,7 @@ A voir:
 
 ## Déploiement
 
-Si le fichier .env n'existe pas, on créé un symlink vers le .env.xxx voulu sous le nom de .env (selon l'environnement)
+Si le fichier .env n'existe pas, créer un symlink vers le '.env.xxx' voulu sous le nom de '.env' (selon l'environnement)
 
     ln -s .env.xxx .env
 
@@ -49,7 +49,7 @@ Pour revenir à une version précédente:
     git reset --hard <id du commit>
     yarn build
 
-> **/!\ Non testé: ** Si la version précédente implique des version différentes des node_modules, il faudra sans doute supprimer le 
+> **/!\ Non testé : ** Si la version précédente implique des versions différentes des node_modules, il faudra sans doute supprimer le 
 > répertoire node_modules et le fichier yarn.lock, et relancer un `yarn install`
 
 ## Run tests

+ 421 - 0
components/Ui/Search/DateRangePicker.vue

@@ -0,0 +1,421 @@
+<!--
+Date range picker, taken from the following non-maintained project https://github.com/praveenpuglia/vuetify-daterange-picker
+-->
+<template>
+  <div class="v-date-range">
+    <v-menu
+      v-model="menu"
+      :close-on-content-click="false"
+      offset-y
+      v-bind="menuProps"
+    >
+      <template v-slot:activator="{ on }">
+        <v-text-field
+          v-on="on"
+          class="v-date-range__input-field"
+          :value="inputValue"
+          readonly
+          :disabled="disabled"
+          @click:clear="reset"
+          :label="placeholder"
+          append-icon="mdi-calendar"
+          v-bind="inputProps"
+        ></v-text-field>
+      </template>
+      <v-card class="v-date-range__menu-content">
+        <v-card-text>
+          <div
+            :data-days="highlightDates.length"
+            :class="{
+              'v-date-range__pickers': true,
+              'v-date-range--highlighted': highlightDates.length
+            }"
+          >
+            <v-card-title v-if="$slots.title">
+              <slot name="title" v-if="$slots.title"></slot>
+            </v-card-title>
+            <v-card-text>
+              <div class="v-date-range__content">
+                <v-list v-if="!noPresets" class="mr-4">
+                  <v-subheader>{{ presetLabel }}</v-subheader>
+                  <v-list-item
+                    v-for="(preset, index) in presets"
+                    v-model="isPresetActive[index]"
+                    :key="index"
+                    @click="selectPreset(index)"
+                  >
+                    <v-list-item-content>
+                      {{ preset.label }}
+                    </v-list-item-content>
+                  </v-list-item>
+                </v-list>
+                <v-date-picker
+                  class="mr-4 v-date-range__picker--start v-date-range__picker"
+                  v-model="pickerStart"
+                  :locale="locale"
+                  :first-day-of-week="firstDayOfWeek"
+                  :min="min"
+                  :max="pickerEnd || max"
+                  :no-title="noTitle"
+                  :next-icon="nextIcon"
+                  :prev-icon="prevIcon"
+                  :events="highlightDates"
+                  :event-color="highlightClasses"
+                ></v-date-picker>
+                <v-date-picker
+                  class="v-date-range__picker--end v-date-range__picker"
+                  v-model="pickerEnd"
+                  :locale="locale"
+                  :first-day-of-week="firstDayOfWeek"
+                  :min="pickerStart || min"
+                  :max="max"
+                  :no-title="noTitle"
+                  :next-icon="nextIcon"
+                  :prev-icon="prevIcon"
+                  :events="highlightDates"
+                  :event-color="highlightClasses"
+                ></v-date-picker>
+              </div>
+            </v-card-text>
+          </div>
+        </v-card-text>
+        <v-card-actions>
+          <v-spacer></v-spacer>
+          <v-btn text @click="reset">{{ mergedActionLabels.reset }}</v-btn>
+          <v-btn text @click="menu = false">{{
+              mergedActionLabels.cancel
+            }}</v-btn>
+          <v-btn
+            @click="applyRange"
+            color="primary"
+            :disabled="!bothSelected"
+          >{{ mergedActionLabels.apply }}</v-btn
+          >
+        </v-card-actions>
+      </v-card>
+    </v-menu>
+  </div>
+</template>
+<script lang="ts">
+import { format, parse, differenceInCalendarDays, addDays } from 'date-fns';
+import { formatIsoDate } from '@/services/utils/date'
+import Vue from "vue";
+
+interface ActionLabels { apply: string, cancel: string, reset: string }
+
+const ISO_FORMAT: string = 'yyyy-MM-dd';
+const DEFAULT_DATE: string = format(new Date(), ISO_FORMAT);
+const DEFAULT_ACTION_LABELS: ActionLabels = {
+  apply: 'Apply',
+  cancel: 'Cancel',
+  reset: 'Reset'
+};
+
+export default Vue.extend({
+  props: {
+    // Take start and end as the input. Passable via v-model.
+    value: {
+      type: Object as () => DateRange,
+      default: () => {
+        return { start: '', end: '' };
+      }
+    },
+    disabled: {
+      type: Boolean as () => boolean,
+      default: false
+    },
+    presets: {
+      type: Array as () => Array<DateRangePreset>,
+      default: () => {
+        return [];
+      }
+    },
+    noPresets: {
+      type: Boolean as () => boolean,
+      default: false
+    },
+    // Denotes the Placeholder string for start date.
+    startLabel: {
+      type: String as () => string,
+      default: 'Start Date'
+    },
+    // Denotes the Placeholder string for start date.
+    endLabel: {
+      type: String as () => string,
+      default: 'End Date'
+    },
+    // The string that gets placed between `startLabel` and `endLabel`
+    separatorLabel: {
+      type: String as () => string,
+      default: 'To'
+    },
+    presetLabel: {
+      type: String as () => string,
+      default: 'Presets'
+    },
+    actionLabels: {
+      type: Object as () => ActionLabels,
+      default: () => {
+        return DEFAULT_ACTION_LABELS;
+      }
+    },
+    /**
+     * Following values are all passable to vuetify's own datepicker
+     * component.
+     */
+    // Min selectable date.
+    min: {
+      type: String as () => string,
+      default: undefined
+    },
+    // Max selectable date
+    max: {
+      type: String as () => string,
+      default: undefined
+    },
+    // Locale
+    locale: {
+      type: String as () => string,
+      default: 'en-us'
+    },
+    firstDayOfWeek: {
+      type: [String, Number] as [() => string, () => number],
+      default: 0
+    },
+    noTitle: {
+      type: Boolean as () => boolean,
+      default: false
+    },
+    displayFormat: {
+      type: String as () => string
+    },
+    highlightColor: {
+      type: String as () => string,
+      default: 'blue lighten-5'
+    },
+    showReset: {
+      type: Boolean as () => boolean,
+      default: true
+    },
+    /**
+     * Icons
+     */
+    nextIcon: {
+      type: String,
+      default: '$vuetify.icons.next'
+    },
+    prevIcon: {
+      type: String,
+      default: '$vuetify.icons.prev'
+    },
+    inputProps: {
+      type: Object,
+      default: () => {
+        return {};
+      }
+    },
+    menuProps: {
+      type: Object,
+      default: () => {
+        return {};
+      }
+    }
+  },
+  data() {
+    return {
+      menu: false,
+      pickerStart: this.value.start as string,
+      pickerEnd: this.value.end as string,
+      highlightDates: [] as Array<string>,
+      highlightClasses: {}
+    };
+  },
+  computed: {
+    inputValue(): string {
+      if (this.isValueEmpty) {
+        return '';
+      }
+      const start = this.displayFormat
+        ? formatIsoDate(this.value.start, this.displayFormat)
+        : this.value.start;
+      const end = this.displayFormat
+        ? formatIsoDate(this.value.end, this.displayFormat)
+        : this.value.end;
+      return `${start}    ${this.separatorLabel}     ${end}`;
+    },
+    placeholder(): string {
+      return `${this.startLabel}    ${this.separatorLabel}    ${this.endLabel}`;
+    },
+    /**
+     * If the value prop doesn't have any start value,
+     * its most likely that an empty object was passed.
+     */
+    isValueEmpty(): boolean {
+      return !this.value.start;
+    },
+    /**
+     * If the user has selected both the dates or not
+     */
+    bothSelected(): boolean {
+      return !!this.pickerStart && !!this.pickerEnd;
+    },
+    isPresetActive(): Array<boolean> {
+      return this.presets.map(
+        preset =>
+          preset.range.start === this.pickerStart &&
+          preset.range.end === this.pickerEnd
+      );
+    },
+    mergedActionLabels(): ActionLabels {
+      return { ...DEFAULT_ACTION_LABELS, ...this.actionLabels };
+    }
+  },
+  methods: {
+    /**
+     * Emit the input event with the updated range data.
+     * This makes v-model work.
+     */
+    applyRange() {
+      this.menu = false;
+      this.emitRange();
+    },
+    /**
+     * Called every time the menu is closed.
+     * It also emits an event to tell the parent
+     * that the menu has closed.
+     *
+     * Upon closing the datepicker values are set
+     * to the current selected value.
+     */
+    closeMenu() {
+      // Reset the changed values for datepicker models.
+      this.pickerStart = this.value.start;
+      this.pickerEnd = this.value.end;
+      this.highlight();
+      this.$emit('menu-closed');
+    },
+    highlight() {
+      if (!this.bothSelected) {
+        return;
+      }
+      const dates = [];
+      const classes = {} as Array<string>;
+
+      const start = parse(this.pickerStart, ISO_FORMAT, new Date());
+      const end = parse(this.pickerEnd, ISO_FORMAT, new Date());
+
+      const diff = Math.abs(differenceInCalendarDays(start, end));
+
+      // Loop though all the days in range.
+      for (let i = 0; i <= diff; i++) {
+        const date: string = format(addDays(start, i), ISO_FORMAT);
+        dates.push(date);
+        const classesArr = [];
+        classesArr.push(`v-date-range__in-range`);
+        classesArr.push(this.highlightColor);
+
+        i === 0 && classesArr.push(`v-date-range__range-start`);
+        i === diff && classesArr.push(`v-date-range__range-end`);
+
+        classes[date as any] = classesArr.join(' ');
+      }
+      this.highlightDates = dates;
+      this.highlightClasses = classes;
+    },
+    selectPreset(presetIndex: number) {
+      this.pickerStart = this.presets[presetIndex].range.start;
+      this.pickerEnd = this.presets[presetIndex].range.end;
+    },
+    reset() {
+      // Reset Picker Values
+      this.pickerStart = '';
+      this.pickerEnd = '';
+      this.highlightDates = [];
+      this.highlightClasses = {};
+      this.emitRange();
+    },
+    emitRange() {
+      this.$emit('input', {
+        start: this.pickerStart,
+        end: this.pickerEnd
+      });
+    }
+  },
+  watch: {
+    // Watching to see if the menu is closed.
+    menu(isOpen: boolean) {
+      if (!isOpen) {
+        this.closeMenu();
+      } else {
+        this.highlight();
+      }
+    },
+    pickerStart: 'highlight',
+    pickerEnd: 'highlight'
+  }
+})
+</script>
+
+<style>
+
+  .v-date-range__input-field ::placeholder {
+    color: #666666;
+    opacity: 1; /* Firefox */
+  }
+
+  .v-menu__content {
+    top: 144px !important;
+  }
+
+  .v-date-range__content > .v-date-picker-table .v-btn {
+    border-radius: 0;
+  }
+
+  .v-date-range__pickers .v-date-picker-table table {
+    width: auto;
+    margin: auto;
+    border-collapse: collapse;
+  }
+
+  .v-date-range__pickers .v-date-picker-table .v-btn {
+    position: initial;
+  }
+
+  .v-date-range__pickers .v-date-range__content {
+    display: flex;
+  }
+
+  .v-date-range__pickers .v-date-picker-table__events {
+    height: 100%;
+    width: 100%;
+    top: 0;
+    z-index: -1;
+  }
+
+  .v-date-range__pickers .v-date-picker-table__events .v-date-range__in-range {
+    position: absolute;
+    z-index: 0;
+    width: 100%;
+    height: 100%;
+    left: 0;
+    top: 0;
+    bottom: 0;
+    right: 0;
+    border-radius: 0;
+  }
+
+  .v-date-range__pickers .v-date-picker-table__events .v-date-range__in-range.v-date-range__range-start {
+    border-top-left-radius: 50%;
+    border-bottom-left-radius: 50%;
+  }
+
+  .v-date-range__pickers .v-date-picker-table__events .v-date-range__in-range.v-date-range__range-end {
+    border-top-right-radius: 50%;
+    border-bottom-right-radius: 50%;
+    width: calc(100% - 5px);
+  }
+
+</style>
+
+
+

+ 162 - 0
components/Ui/Search/DateRangePickerCustom.vue

@@ -0,0 +1,162 @@
+<template >
+  <div v-click-outside="onOutsideClick">
+    <v-text-field
+      v-model="dateIntervalText"
+      type="text"
+      class="text-field"
+      outlined
+      readonly
+      clearable
+      hide-details
+      :label="$t('when') + ' ?'"
+      append-icon="mdi-calendar"
+      @click="onClick"
+      @change="$emit('change', $event ? $event.value : '')"
+      @click:append="onClick"
+    />
+
+    <v-card v-show="show" class="date-picker pa-2">
+      <div class="d-flex flex-row">
+        <div class="presets">
+          <a
+            v-for="preset in presets"
+            class="preset-link"
+            @click="presetClicked(preset.range)"
+          >
+            {{ preset.label }}
+          </a>
+        </div>
+
+        <v-date-picker
+          ref="datePicker1"
+          :value="isoDateStart"
+          range
+          :min="today"
+          scrollable
+          no-title
+          locale="fr"
+          @change="dateRangeChanged"
+        />
+      </div>
+    </v-card>
+  </div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue'
+import { format, parse, addDays, addMonths, nextSunday } from 'date-fns'
+
+interface DateRangePreset {
+  label: string,
+  range: DateRange
+}
+
+export default Vue.extend({
+  props: {
+    value: {
+      type: Object
+    }
+  },
+  data () {
+    return {
+      model: null as DateRange | null,
+      dateStart: null as Date | null,
+      dateEnd: null as Date | null,
+      show: false
+    }
+  },
+  methods: {
+    onClick() {
+      this.show = !this.show
+    },
+    onOutsideClick() {
+      this.show = false
+    },
+    presetClicked(range: DateRange) {
+      this.dateStart = range.start
+      this.dateEnd = range.end
+    },
+    clear () {
+      this.dateStart = null
+      this.dateEnd = null
+    },
+    dateToString(date: Date | null) {
+      return date !== null ? format(date, 'yyyy-MM-dd') : ''
+    },
+    stringToDate(date: string) {
+      return new Date(date)
+    }
+  },
+  computed: {
+    today() {
+      return format(new Date(), 'yyyy-MM-dd')
+    },
+    presets (): Array<DateRangePreset> {
+      const today = new Date()
+
+      // Today
+      const today_preset: DateRangePreset = {
+        label: this.$t('today').toString(),
+        range: {start: today, end: today}
+      }
+
+      // Cette semaine
+      const week_preset: DateRangePreset = {
+        label: this.$t('next_week').toString(),
+        range: {start: today, end: addDays(today, 7)}
+      }
+
+      // Ce week-end
+      const sunday: Date = nextSunday(today)
+      const weekend_preset = {
+        label: this.$t('next_weekend').toString(),
+        range: {start: addDays(sunday, -1), end: sunday}
+      }
+
+      // Ce mois
+      const month_preset: DateRangePreset = {
+        label: this.$t('next_month').toString(),
+        range: {start: today, end: addMonths(today, 1)}
+      }
+
+      return [today_preset, week_preset, weekend_preset, month_preset]
+    },
+    dateIntervalText (): string {
+      if (this.dateStart !== null && this.dateEnd !== null) {
+        return format(this.dateStart, 'dd/MM/yyyy') + ' ~ ' + format(this.dateEnd, 'dd/MM/yyyy')
+      } else if (this.dateStart !== null) {
+        return format(this.dateStart, 'dd/MM/yyyy') + ' ~ ?'
+      } else if (this.dateEnd !== null) {
+        return '? ~ ' + format(this.dateEnd, 'dd/MM/yyyy')
+      } else {
+        return ''
+      }
+    }
+  }
+})
+</script>
+
+<style scoped>
+
+.date-picker {
+  z-index: 1;
+  position: absolute;
+}
+
+.presets {
+  display: flex;
+  flex-direction: column;
+  padding: 12px;
+  min-width: 120px;
+  font-size: 14px;
+}
+
+.preset-link {
+  padding: 12px 0;
+}
+
+.preset-link:hover {
+  text-decoration: underline;
+}
+
+</style>

+ 13 - 1
lang/fr-FR.js

@@ -7,6 +7,7 @@ export default (_context, _locale) => {
     click_on_land_to_go_there: 'Cliquez sur une des régions ci-dessous pour centrer la carte sur elle',
     what: 'Quoi',
     where: 'Où',
+    when: 'Quand',
     please_wait: 'Veuillez patienter',
     an_error_occured: 'Une erreur s\'est produite',
     results: 'Résultats',
@@ -36,6 +37,7 @@ export default (_context, _locale) => {
     show_tel: 'Montrer le numéro de téléphone',
     show_email: 'Montrer l\'adresse e-mail',
     see_national_network: 'Voir le réseau national',
+    events: 'Évènements',
     BIG_BAND: 'Big band',
     BRASS_BAND: 'Brass band',
     ORCHESTRA_CLASS: "Classe d'orchestre",
@@ -72,6 +74,16 @@ export default (_context, _locale) => {
     EDUCATION: 'Enseignement',
     CHEERLEADER: 'Majorettes',
     TROOP: 'Troupe',
-    OTHER: 'Autre'
+    OTHER: 'Autre',
+    today: 'Aujourd\'hui',
+    next_week: 'Prochaine semaine',
+    next_weekend: 'Prochain Week-End',
+    next_month: 'Prochain mois',
+    until: 'Jusqu\'au',
+    start_date: 'Date de début',
+    end_date: 'Date de fin',
+    apply: 'Appliquer',
+    cancel: 'Annuler',
+    reset: 'Réinitialiser',
   })
 }

+ 1 - 1
nuxt.config.js

@@ -44,7 +44,7 @@ export default {
   plugins: [
     { src: '~/plugins/vue2-leaflet-markercluster.js', mode: 'client' },
     { src: '~/plugins/theming.js', mode: 'client' },
-    { src: '~/plugins/iframeResizer.js', mode: 'client' }
+    { src: '~/plugins/iframeResizer.js', mode: 'client' },
   ],
 
   // Modules for dev and build (recommended): https://go.nuxtjs.dev/config-modules

+ 1 - 0
package.json

@@ -27,6 +27,7 @@
     "@nuxtjs/axios": "^5.13.6",
     "@nuxtjs/i18n": "^7.0.3",
     "core-js": "^3.15.1",
+    "date-fns": "^2.29.0",
     "iframe-resizer": "^4.3.2",
     "leaflet": "^1.7.1",
     "libphonenumber-js": "^1.9.38",

+ 363 - 0
pages/events/index.vue

@@ -0,0 +1,363 @@
+<!-- Search for events -->
+
+<template>
+  <LayoutContainer>
+    <!-- Header -->
+    <v-row>
+      <v-layout>
+        <h2 v-if="!hideTitle">
+          {{ $t("events") }}
+        </h2>
+      </v-layout>
+    </v-row>
+
+    <!-- Search form -->
+    <v-row>
+      <v-form method="get" class="mt-8 w100">
+        <v-container>
+          <v-row>
+            <v-col cols="12" md="6" class="py-2 px-1">
+              <UiSearchDateRangePicker
+                ref="dateSearch"
+                v-model="dateRangeFilter"
+                :start-label="$t('start_date')"
+                :end-label="$t('end_date')"
+                preset-label=""
+                separator-label="~"
+                :presets="dateRangePresets"
+                :min="dateRangeMin"
+                locale="fr-FR"
+                no-title
+                display-format="dd/MM/yyyy"
+                :inputProps="{outlined: true}"
+                :menuProps="{offsetY: true, closeOnContentClick: false}"
+                :actionLabels="{apply: $t('apply'), cancel: $t('cancel'), reset: $t('reset')}"
+              >
+              </UiSearchDateRangePicker>
+            </v-col>
+
+            <v-col cols="12" md="6" class="py-2 px-1">
+              <UiSearchAddress
+                ref="addressSearch"
+                type="municipality"
+                @change="locationFilterChanged"
+              />
+            </v-col>
+
+            <v-col cols="12" md="6" class="py-2 px-1">
+              <v-text-field
+                v-model="textFilter"
+                type="text"
+                outlined
+                clearable
+                hide-details
+                append-icon="mdi-magnify"
+                :label="$t('what') + ' ?'"
+                @click:append="search"
+                @keydown.enter="search"
+              />
+            </v-col>
+          </v-row>
+
+          <v-row>
+            <v-col cols="2" class="py-2 px-1">
+              <v-btn class="h100" @click="reinitializeFilters">
+                {{ $t('reinitialize') }}
+              </v-btn>
+            </v-col>
+            <v-col cols="2" class="py-2 px-1 d-flex justify-end">
+              <v-btn class="h100" @click="test()">
+                {{ $t('search') }}
+              </v-btn>
+            </v-col>
+          </v-row>
+        </v-container>
+      </v-form>
+    </v-row>
+
+    <v-row>
+      <!-- loading skeleton -->
+      <v-container v-if="$fetchState.pending">
+        <v-row v-for="i in 3" :key="i" justify="space-between" class="mt-1 mb-3">
+          <v-col v-for="j in 2" :key="j" cols="12" :md="12" class="py-2 px-1">
+            <v-skeleton-loader type="card" :loading="true" />
+          </v-col>
+        </v-row>
+      </v-container>
+
+      <!-- Results -->
+      <v-data-iterator
+        v-else
+        :items="filteredEvents"
+        :page.sync="page"
+        :items-per-page="itemsPerPage"
+        sort-by="name"
+        hide-default-footer
+        no-data-text=""
+      >
+        <template #header>
+          <i class="results-count">{{ totalRecords }} {{ $t('results') }}</i>
+        </template>
+        <template #default="props">
+          <v-row justify="space-between" class="mt-1 mb-3">
+            <v-col
+              v-for="event in props.items"
+              :key="event.uuid"
+              cols="12"
+              sm="12"
+              :lg="12"
+              class="py-2 px-1"
+            >
+              <v-card
+                elevation="1"
+                outlined
+                :class="'event-card pa-3 d-flex ' + ($vuetify.breakpoint.smAndDown ? 'flex-column' : 'flex-row align-items-center')"
+              >
+                <div class="d-flex justify-center max-w100">
+                  <v-img
+                    v-if="event.imageId"
+                    :src="'https://api.opentalent.fr/app.php/_internal/secure/files/' + event.imageId"
+                    alt="poster"
+                    height="80"
+                    width="240"
+                    max-height="100%"
+                    :contain="true"
+                    style="margin: 12px;"
+                  />
+                  <div v-else style="height: 104px; width: 264px" />
+                </div>
+
+                <div class="d-flex flex-column flex-grow-1">
+                  <v-card-title class="title">
+                    <nuxt-link :to="{path: '/events/' + event.uuid, query: { theme: theme }}">
+                      {{ event.name }}
+                    </nuxt-link>
+                  </v-card-title>
+
+                  <v-card-text class="infos">
+                  </v-card-text>
+                </div>
+
+                <v-card-actions class="align-self-end">
+                  <v-btn
+                    class="see"
+                    :to="{path: '/event/' + event.uuid, query: { theme: theme, hideTitle: hideTitle }}"
+                    nuxt
+                  >
+                    <span style="margin-right: 6px;">{{ $t("see_more") }}</span>
+                    <font-awesome-icon :icon="['fa', 'caret-right']" />
+                  </v-btn>
+                </v-card-actions>
+              </v-card>
+            </v-col>
+          </v-row>
+        </template>
+        <template #footer>
+          <v-pagination
+            v-model="page"
+            :length="pageCount"
+            total-visible="9"
+            color="primary"
+          />
+        </template>
+      </v-data-iterator>
+    </v-row>
+  </LayoutContainer>
+</template>
+
+<script lang="ts">
+import Vue from 'vue'
+import sphericDistance from '@/services/utils/geo'
+import EventsProvider from "~/services/data/EventsProvider"
+import { today, todayIso, formatIso } from '@/services/utils/date'
+import { addDays, nextSunday, addMonths } from "date-fns";
+
+const defaultDateRange: DateRange = { start: '', end: '' }
+
+export default Vue.extend({
+  data () {
+    return {
+      theme: this.$route.query.theme ?? 'orange',
+      hideTitle: this.$route.query.hideTitle === 'true',
+      events: [] as Array<PublicEvent>,
+      filteredEvents: [] as Array<PublicEvent>,
+      loading: true,
+      page: 1,
+      itemsPerPage: 8,
+      textFilter: null as string | null,
+      locationFilter: null as Coordinates | null,
+      dateRangeFilter: defaultDateRange
+    }
+  },
+  async fetch () {
+    await new EventsProvider(this.$axios).getBy().then(
+      (res) => {
+        this.events = res
+        this.filteredEvents = res
+      })
+  },
+  computed: {
+    totalRecords (): number {
+      return this.filteredEvents.length
+    },
+    pageCount (): number {
+      return Math.floor(this.totalRecords / this.itemsPerPage) + 1
+    },
+    dateRangeMin(): string {
+      return todayIso()
+    },
+    dateRangePresets(): Array<DateRangePreset> {
+      // Today
+      const today_preset: DateRangePreset = {
+        label: this.$t('today').toString(),
+        range: {start: todayIso(), end: todayIso()}
+      }
+
+      // Cette semaine
+      const week_preset: DateRangePreset = {
+        label: this.$t('next_week').toString(),
+        range: {start: todayIso(), end: formatIso(addDays(today(), 7))}
+      }
+
+      // Ce week-end
+      const sunday: Date = nextSunday(today())
+      const weekend_preset: DateRangePreset = {
+        label: this.$t('next_weekend').toString(),
+        range: {start: formatIso(addDays(sunday, -2)), end: formatIso(sunday)}
+      }
+
+      // Ce mois
+      const month_preset: DateRangePreset = {
+        label: this.$t('next_month').toString(),
+        range: {start: todayIso(), end: formatIso(addMonths(today(), 1))}
+      }
+
+      return [today_preset, week_preset, weekend_preset, month_preset]
+    }
+  },
+  methods: {
+    test () {
+      console.log(this.dateRangeFilter)
+    },
+    textFilterChanged (newVal: string) {
+      this.textFilter = newVal
+    },
+    locationFilterChanged (newVal: Coordinates) {
+      this.locationFilter = newVal
+      this.search()
+    },
+    dateRangeFilterChanged (newVal: DateRange) {
+      this.dateRangeFilter = newVal
+      this.search()
+    },
+    reinitializeFilters (): void {
+      this.textFilter = null
+      this.locationFilter = null
+      this.dateRangeFilter = defaultDateRange
+      const addressSearch = this.$refs.addressSearch as any
+      addressSearch.clear()
+      this.filteredEvents = this.events
+    },
+    /**
+     * Does the event match the current textFilter
+     * @param event
+     * @returns {boolean}
+     */
+    matchTextFilter (event: PublicEvent): boolean {
+      if (!this.textFilter) { return true }
+
+      return normalize(event.name).includes(normalize(this.textFilter))
+    },
+    /**
+     * Does the event match the current locationFilter
+     * @param event
+     * @returns {boolean}
+     */
+    matchLocationFilter (event: PublicEvent): boolean {
+      if (!this.locationFilter) { return true }
+      if (event.address === null || !event.address.latitude || !event.address.longitude) { return false }
+
+      const radius = 30
+
+      return sphericDistance(
+        this.locationFilter.latitude,
+        this.locationFilter.longitude,
+        event.address.latitude,
+        event.address.longitude
+      ) <= radius
+    },
+    /**
+     * Does the event match each of the page filters
+     * @param event
+     * @returns {boolean}
+     */
+    matchFilters (event: PublicEvent): boolean {
+      return this.matchTextFilter(event) &&
+        this.matchLocationFilter(event)
+    },
+    /**
+     * Update the filteredEvents array
+     */
+    search (): void {
+      this.filteredEvents = this.events.filter((e) => { return this.matchFilters(e) })
+    },
+    /**
+     * Enhanced filter for v-autocomplete components
+     *
+     * @param _
+     * @param queryText
+     * @param itemText
+     */
+    enhancedAutocompleteFilter (_: any, queryText: string, itemText: string): boolean {
+      return normalize(itemText).includes(normalize(queryText))
+    }
+  }
+})
+</script>
+
+<style scoped lang="scss">
+@import 'assets/style/variables.scss';
+
+h2 {
+  color: var(--v-primary-base);
+}
+
+.event-card {
+  height: 100%;
+  color: #666666;
+}
+
+.infos .col {
+  padding: 6px 12px;
+}
+
+.infos td {
+  padding: 4px;
+  vertical-align: top;
+}
+.infos td:first-child {
+  padding-top: 6px;
+  text-align: center;
+}
+
+.title {
+  word-break: normal;
+  color: var(--v-primary-base);
+  font-size: 18px;
+  font-weight: 500;
+  line-height: 1.6rem;
+}
+
+.title a {
+  text-decoration: none;
+}
+
+.icon {
+  color: var(--v-primary-base);
+}
+
+.results-count {
+  font-size: .8em;
+  color: #666;
+}
+</style>

+ 4 - 16
pages/structures/index.vue

@@ -371,8 +371,8 @@ export default Vue.extend({
         // populate federations filter
         for (const s of res) {
           const f = {
-            id: s.n1Id,
-            name: s.n1Name
+            id: s.parentId,
+            name: s.parentName
           }
           if (!this.federations.includes(f)) {
             this.federations.push(f)
@@ -476,7 +476,7 @@ export default Vue.extend({
     matchTextFilter (structure: Structure): boolean {
       if (!this.textFilter) { return true }
 
-      return this.searchTextNormalize(structure.name).includes(this.searchTextNormalize(this.textFilter))
+      return normalize(structure.name).includes(normalize(this.textFilter))
     },
     /**
      * Does the structure match the current locationFilter
@@ -563,18 +563,6 @@ export default Vue.extend({
         this.fitMapToResults()
       }
     },
-    searchTextNormalize (s: string): string {
-      return s
-        .toLowerCase()
-        .replace(/[éèẽëê]/g, 'e')
-        .replace(/[ç]/g, 'c')
-        .replace(/[îïĩ]/g, 'i')
-        .replace(/[àã]/g, 'a')
-        .replace(/[öôõ]/g, 'o')
-        .replace(/[ûüũ]/g, 'u')
-        .replace(/[-]/g, ' ')
-        .trim()
-    },
     /**
      * Enhanced filter for v-autocomplete components
      *
@@ -583,7 +571,7 @@ export default Vue.extend({
      * @param itemText
      */
     enhancedAutocompleteFilter (_: any, queryText: string, itemText: string): boolean {
-      return this.searchTextNormalize(itemText).includes(this.searchTextNormalize(queryText))
+      return normalize(itemText).includes(normalize(queryText))
     }
   }
 })

+ 66 - 0
services/data/EventsProvider.ts

@@ -0,0 +1,66 @@
+import BaseProvider from '~/services/data/BaseProvider'
+import Address from "~/components/Ui/Search/Address.vue";
+
+class PublicEventsProvider extends BaseProvider {
+  protected normalize (e: any) : PublicEvent {
+
+    e.address = {
+      type: '',
+      latitude: e.latitude,
+      longitude: e.longitude,
+      streetAddress: e.streetAddress,
+      postalCode: e.postalCode,
+      addressCity: e.addressCity,
+      country: ''
+    } as Address
+    // s.categories = s.categories.split()
+
+    return e
+  }
+
+  async getBy (
+    name: string | null = null,
+    organizationId: number | null = null,
+    dateMin: string | null = null,
+    dateMax: string | null = null,
+    city: string | null = null
+  ): Promise<Array<PublicEvent>> {
+
+    const query = new URLSearchParams();
+    if (name !== null) {
+      query.append('name', name)
+    }
+    if (organizationId !== null) {
+      query.append('organizationId', String(organizationId))
+    }
+    if (dateMin !== null) {
+      query.append('dateStart[after]', dateMin)
+    }
+    if (dateMax !== null) {
+      query.append('dateEnd[before]', dateMax)
+    }
+    if (city !== null) {
+      query.append('city', city)
+    }
+
+    let url = `/api/public/events`
+    if (query) {
+      url += `?{query}`
+    }
+
+    return await this.get(url).then((res) => {
+      console.log(res)
+      return res['hydra:member'].map((s: any) => { return this.normalize(s) })
+    })
+  }
+
+  async getById (eventUuid: number): Promise<PublicEvent> {
+    return await this.get(
+      `/api/public/events/${eventUuid}`
+    ).then((s) => {
+      return this.normalize(s)
+    })
+  }
+}
+
+export default PublicEventsProvider

+ 21 - 0
services/utils/date.ts

@@ -0,0 +1,21 @@
+import { format, parse, differenceInCalendarDays, addDays } from 'date-fns';
+
+const ISO_FORMAT = 'yyyy-MM-dd';
+
+function formatIso(date: Date): string {
+  return format(date, ISO_FORMAT)
+}
+
+function today(): Date {
+  return new Date()
+}
+
+function todayIso(): string {
+  return formatIso(today())
+}
+
+function formatIsoDate(date: string, fmt: string): string {
+  return format(parse(date, ISO_FORMAT, new Date()), fmt);
+}
+
+export { today, formatIso, todayIso, formatIsoDate }

+ 18 - 0
services/utils/string.ts

@@ -0,0 +1,18 @@
+/**
+ * Remove any special characters from the string
+ *
+ * @param s
+ */
+function normalize(s: string): string {
+  return s
+    .toLowerCase()
+    .replace(/[éèẽëê]/g, 'e')
+    .replace(/[ç]/g, 'c')
+    .replace(/[îïĩ]/g, 'i')
+    .replace(/[àã]/g, 'a')
+    .replace(/[öôõ]/g, 'o')
+    .replace(/[ûüũ]/g, 'u')
+    .replace(/[-]/g, ' ')
+    .trim()
+}
+

+ 1 - 0
tsconfig.json

@@ -9,6 +9,7 @@
       "DOM"
     ],
     "esModuleInterop": true,
+    "allowSyntheticDefaultImports": true,
     "allowJs": true,
     "sourceMap": true,
     "strict": true,

+ 10 - 0
types/interfaces.d.ts

@@ -77,3 +77,13 @@ interface PublicEvent {
   origin: string,
   entityId: number
 }
+
+interface DateRange {
+  start: string,
+  end: string
+}
+
+interface DateRangePreset {
+  label: string,
+  range: DateRange
+}