Przeglądaj źródła

Merge branch 'feature/events-page' into develop

Olivier Massot 3 lat temu
rodzic
commit
226474284a

+ 2 - 2
.env.ci

@@ -2,5 +2,5 @@
 NODE_ENV=production
 DEBUG=1
 
-SSR_API_BASE_URL = https://api.preprod.opentalent.fr
-CLIENT_API_BASE_URL = https://api.preprod.opentalent.fr
+SSR_API_BASE_URL=https://ap2i.preprod.opentalent.fr
+CLIENT_API_BASE_URL=https://ap2i.preprod.opentalent.fr

+ 2 - 2
.env.local

@@ -2,5 +2,5 @@
 NODE_ENV=dev
 DEBUG=1
 
-SSR_API_BASE_URL = http://nginx
-CLIENT_API_BASE_URL = https://local.api.opentalent.fr
+SSR_API_BASE_URL=http://nginx_new
+CLIENT_API_BASE_URL=https://local.ap2i.opentalent.fr

+ 2 - 2
.env.preprod

@@ -2,5 +2,5 @@
 NODE_ENV=production
 DEBUG=1
 
-SSR_API_BASE_URL = https://api.preprod.opentalent.fr
-CLIENT_API_BASE_URL = https://api.preprod.opentalent.fr
+SSR_API_BASE_URL=https://ap2i.preprod.opentalent.fr
+CLIENT_API_BASE_URL=https://ap2i.preprod.opentalent.fr

+ 2 - 2
.env.prod

@@ -3,5 +3,5 @@
 NODE_ENV=production
 DEBUG=0
 
-SSR_API_BASE_URL = https://api.opentalent.fr
-CLIENT_API_BASE_URL = https://api.opentalent.fr
+SSR_API_BASE_URL=https://ap2i.opentalent.fr
+CLIENT_API_BASE_URL=https://ap2i.opentalent.fr

+ 3 - 0
.gitignore

@@ -93,3 +93,6 @@ sw.*
 # Cypress
 /cypress/screenshots
 /cypress/videos
+
+package-lock.json
+yarn.lock

+ 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

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

@@ -0,0 +1,428 @@
+<!--
+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
+          clearable
+          hide-details
+          :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 Vue from "vue";
+import DatesUtils from "~/services/utils/dateUtils";
+
+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
+    },
+    label: {
+      type: String as () => string | null,
+      default: null
+    },
+    // 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: {},
+      dateUtils: new DatesUtils(this.$dateFns, this.$t, this.$i18n)
+    };
+  },
+  computed: {
+    inputValue(): string {
+      if (this.isValueEmpty) {
+        return '';
+      }
+      const start = this.displayFormat
+        ? this.dateUtils.formatIsoDate(this.value.start, this.displayFormat)
+        : this.value.start;
+      const end = this.displayFormat
+        ? this.dateUtils.formatIsoDate(this.value.end, this.displayFormat)
+        : this.value.end;
+      return `${start}  ${this.separatorLabel}  ${end}`;
+    },
+    placeholder(): string {
+      return this.label !== null ? this.label : `${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>
+
+
+

+ 25 - 3
doc/include_iframe_structures.md → doc/iframes.md

@@ -1,5 +1,15 @@
 # Inclure une iframe à un site web
 
+Ci dessous, un guide pour inclure les pages dans un site externe sous forme d'iframe.
+
+#### Pré-requis :
+
+Vous devez inclure à votre page le fichier suivant:
+[iframeResizer.min.js](https://raw.githubusercontent.com/davidjbradshaw/iframe-resizer/master/js/iframeResizer.min.js)
+
+    <script type="text/javascript" src="https://raw.githubusercontent.com/davidjbradshaw/iframe-resizer/master/js/iframeResizer.min.js"></script>
+
+
 ## Iframe 'Recherche des structures'
 
 Pour inclure cette iframe, ajoutez le code suivant à votre page:
@@ -18,7 +28,19 @@ Remplacez `[color]` par le thème adapté à votre page (options: *blue*, *green
 
 (optionnel) Vous pouvez changer l'affichage par défaut en modifiant l'option 'view' (options: *map*, *list*)
 
-Enfin, incluez à votre page le fichier suivant: 
-[iframeResizer.min.js](https://raw.githubusercontent.com/davidjbradshaw/iframe-resizer/master/js/iframeResizer.min.js)
 
-    <script type="text/javascript" src="https://raw.githubusercontent.com/davidjbradshaw/iframe-resizer/master/js/iframeResizer.min.js"></script>
+## Iframe 'Agenda des événements'
+
+Pour inclure cette iframe, ajoutez le code suivant à votre page:
+
+    <iframe
+            src="https://frames.opentalent.fr/events/?organization=[id]&theme=[color]"
+            referrerpolicy="strict-origin"
+            style="border: none;width: 100%;max-width: 100%;height: 3600px;"
+            onload="iFrameResize()"
+    ></iframe>
+
+Remplacez `[id]` par l'id de votre organisation
+
+Remplacez `[color]` par le thème adapté à votre page (options: *blue*, *green*, *grey*, *light-blue*,
+*light-red*, *orange*, *purple*, *red*)

+ 25 - 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,28 @@ 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',
+    from_hour: 'de',
+    to_hour: 'à',
+    on_hour: 'à',
+    on_day: 'Le',
+    from_day: 'Du',
+    to_day: 'au',
+    more_to_know: 'En savoir plus',
+    description: 'Description',
+    keywords: 'Mots-clés',
+    share: 'Partager',
+    share_on: 'Partager sur',
+    share_by_email: 'Partager par email',
   })
 }

+ 10 - 3
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
@@ -52,7 +52,8 @@ export default {
     // https://go.nuxtjs.dev/typescript
     '@nuxt/typescript-build',
     // https://go.nuxtjs.dev/vuetify
-    '@nuxtjs/vuetify'
+    '@nuxtjs/vuetify',
+    '@nuxtjs/fontawesome'
   ],
 
   // Modules: https://go.nuxtjs.dev/config-modules
@@ -73,8 +74,14 @@ export default {
           }
         ]
       }
-    ]
+    ],
+    '@nuxtjs/date-fns',
+    'vue-social-sharing/nuxt'
   ],
+  dateFns: {
+    defaultLocale: 'fr-FR',
+    fallbackLocale: 'en-US'
+  },
 
   // Vuetify module configuration
   // @see https://vuetifyjs.com/en/features/theme/

+ 4 - 0
package.json

@@ -25,14 +25,18 @@
     "@fortawesome/free-solid-svg-icons": "^5.15.4",
     "@fortawesome/vue-fontawesome": "^2.0.2",
     "@nuxtjs/axios": "^5.13.6",
+    "@nuxtjs/date-fns": "^1.5.0",
+    "@nuxtjs/fontawesome": "^1.1.2",
     "@nuxtjs/i18n": "^7.0.3",
     "core-js": "^3.15.1",
+    "date-fns": "^2.29.1",
     "iframe-resizer": "^4.3.2",
     "leaflet": "^1.7.1",
     "libphonenumber-js": "^1.9.38",
     "nuxt": "^2.15.7",
     "nuxt-fontawesome": "^0.4.0",
     "nuxt-leaflet": "^0.0.25",
+    "vue-social-sharing": "^3.0.9",
     "vue2-leaflet": "^2.7.1",
     "vue2-leaflet-markercluster": "^3.1.0",
     "vuetify": "^2.5.5"

+ 285 - 0
pages/events/_id.vue

@@ -0,0 +1,285 @@
+<template>
+  <LayoutContainer>
+    <header class="mb-4">
+      <v-layout>
+        <v-btn
+          :to="{path: '/events', query: query}"
+          nuxt
+          plain
+        >
+          <font-awesome-icon class="icon mr-1" :icon="['fas', 'chevron-left']" />
+          {{ $t('go_back') }}
+        </v-btn>
+      </v-layout>
+    </header>
+
+    <v-container class="content">
+      <v-row>
+        <v-col cols="12" sm="6" class="pr-6">
+          <v-img
+            :src="publicEvent.imageUrl || '/images/event-default.jpg'"
+            alt="banner"
+            max-width="100%"
+            max-height="100%"
+            lazy-src="/images/event-default.jpg"
+          />
+        </v-col>
+        <v-col cols="12" sm="6" class="pl-6">
+          <div class="d-flex flex-column" style="min-height: 100%">
+            <v-row class="py-2">
+              <h2>{{ publicEvent.name }}</h2>
+            </v-row>
+            <v-row class="py-2">
+              <table class="infos">
+                <tr v-if="publicEvent.datetimeStart" class="pa-1">
+                  <td class="pt-1">
+                    <font-awesome-icon :icon="['fas', 'calendar']" class="icon mr-2" />
+                  </td>
+                  <td class="pa-1">
+                    <span>
+                      {{ dateUtils.formatDateIntervalFor(new Date(publicEvent.datetimeStart), new Date(publicEvent.datetimeEnd)) }}
+                    </span>
+                  </td>
+                </tr>
+
+                <tr v-if="publicEvent.address.addressCity">
+                  <td class="pt-1">
+                    <font-awesome-icon class="icon" :icon="['fas', 'map-marker-alt']" />
+                  </td>
+                  <td class="pa-1">
+                    <span v-if="publicEvent.roomName" style="white-space: pre-line;">{{ publicEvent.roomName }}<br></span>
+                    <span v-if="publicEvent.address.streetAddress" style="white-space: pre-line;">{{ publicEvent.address.streetAddress }}<br></span>
+                    <span>{{ publicEvent.address.addressCity }}</span>
+                  </td>
+                </tr>
+              </table>
+            </v-row>
+            <v-row v-if="publicEvent.description" class="py-2 flex d-flex flex-column">
+              <h4 class="mt-2 mb-3" style="height: fit-content;">
+                {{ $t('description') }}
+              </h4>
+              <p class="flex">
+                {{ publicEvent.description }}
+              </p>
+            </v-row>
+            <div class="flex"></div>
+            <v-row v-if="publicEvent.url" justify="end" align-content="end" class="py-2">
+              <v-btn :href="publicEvent.url" target="_blank">{{ $t("more_to_know") }}</v-btn>
+            </v-row>
+          </div>
+        </v-col>
+      </v-row>
+      <v-row>
+        <v-col
+          cols="12"
+          v-if="publicEvent.address.latitude && publicEvent.address.longitude"
+        >
+          <v-responsive width="100%" height="300px">
+            <no-ssr>
+              <l-map
+                id="map"
+                :zoom="16"
+                :center="[publicEvent.address.latitude, publicEvent.address.longitude]"
+                :options="{ scrollWheelZoom: false, zoomSnap: 0.25 }"
+              >
+                <l-tile-layer
+                  url="http://{s}.tile.osm.org/{z}/{x}/{y}.png"
+                  attribution="&copy; <a href='http://osm.org/copyright'>OpenStreetMap</a> contributors"
+                />
+                <l-marker
+                  :key="publicEvent.uuid"
+                  :lat-lng="[publicEvent.address.latitude, publicEvent.address.longitude]"
+                >
+                  <l-popup>
+                    <b>{{ publicEvent.name }}</b><br>
+                    <span>
+                      {{ publicEvent.address.postalCode }} {{ publicEvent.address.addressCity }}
+                    </span><br>
+                  </l-popup>
+                </l-marker>
+              </l-map>
+            </no-ssr>
+          </v-responsive>
+        </v-col>
+      </v-row>
+      <v-row>
+        <v-spacer />
+        <v-col cols="12" md="6" class="d-flex flex-column align-end">
+          <table>
+            <tr>
+              <td>
+                <h5 class="ma-2">
+                  {{ $t('share') }}
+                </h5>
+              </td>
+            </tr>
+            <tr>
+              <td>
+                <div class="d-flex flex-row align-items-start flex-wrap">
+                  <ShareNetwork
+                    v-for="socialNetwork in socialNetworks"
+                    :key="socialNetwork.network"
+                    :network="socialNetwork.network"
+                    :url="localUrl"
+                    :title="publicEvent.name"
+                    :description="publicEvent.description"
+                    hashtags="opentalent,event"
+                    :media="publicEvent.imageUrl || '/images/event-default.jpg'"
+                    class="social-link"
+                  >
+                    <a
+                      :title="$t('share_on') + ' ' + socialNetwork.name"
+                      :style="{color: socialNetwork.color}"
+                    >
+                      <font-awesome-icon
+                        class="icon social-icon"
+                        :icon="socialNetwork.icon"
+                      />
+                    </a>
+                  </ShareNetwork>
+                </div>
+              </td>
+            </tr>
+          </table>
+        </v-col>
+      </v-row>
+    </v-container>
+
+  </LayoutContainer>
+</template>
+
+
+<script lang="ts">
+import Vue from 'vue'
+import EventsProvider from '~/services/data/EventsProvider'
+import DatesUtils from "~/services/utils/dateUtils";
+
+export default Vue.extend({
+  data () {
+    return {
+      theme: this.$route.query.theme ?? 'orange',
+      organization: this.$route.query.organization ?? null,
+      publicEvent: null as PublicEvent | null,
+      dateUtils: new DatesUtils(this.$dateFns, this.$t, this.$i18n),
+      localUrl: location.href,
+      socialNetworks: [
+        { network: 'facebook', name: 'Facebook', icon: ['fab', 'facebook-f'], color: '#1877f2' },
+        { network: 'messenger', name: 'Messenger', icon: ['fab', 'facebook-messenger'], color: '#0084ff' },
+        { network: 'twitter', name: 'Twitter', icon: ['fab', 'twitter'], color: '#1da1f2' },
+        { network: 'reddit', name: 'Reddit', icon: ['fab', 'reddit-alien'], color: '#ff4500' },
+        { network: 'telegram', name: 'Telegram', icon: ['fab', 'telegram-plane'], color: '#0088cc' },
+        { network: 'whatsapp', name: 'Whatsapp', icon: ['fab', 'whatsapp'], color: '#25d366' },
+        { network: 'email', name: 'Email', icon: ['fa', 'envelope'], color: '#333333' },
+      ] // @see https://nicolasbeauvais.github.io/vue-social-sharing/?path=/story/vuesocialsharing--multiple-share-networks
+    }
+  },
+  computed: {
+    query () {
+      const q: any = { theme: this.theme }
+      if (this.organization !== null) {
+        q.organization = this.organization
+      }
+      return q
+    }
+  },
+  async asyncData ({params, $axios}): Promise<{ publicEvent: PublicEvent }> {
+    return await new EventsProvider($axios).getById(params.id).then((res) => {
+      return { publicEvent: res }
+    })
+  }
+})
+</script>
+
+<style scoped lang="scss">
+@import 'assets/style/variables.scss';
+
+.content {
+  margin: 18px 10%;
+  max-width: 80%;
+}
+
+h2 {
+  color: var(--v-primary-base);
+  font-size: 22px;
+  text-transform: uppercase;
+}
+
+h4 {
+  color: #666;
+  border-bottom: solid 1px var(--v-primary-base);
+  font-size: 22px;
+}
+
+.content {
+  color: #4d4d4d;
+}
+
+.infos {
+  text-transform: uppercase;
+  font-weight: 700;
+}
+
+@media screen and (min-width: 600px) {
+  .description {
+    border-right: solid 2px var(--v-primary-base);
+    width: 45%;
+    padding-right: 5%;
+  }
+
+  .contact {
+    width: 45%;
+    padding-left: 5%;
+  }
+}
+
+@media screen and (max-width: 600px) {
+  .content {
+    max-width: 100%;
+    margin: 10px 0;
+  }
+}
+
+.contact td {
+  padding: 6px 12px;
+}
+
+.contact .icon {
+  color: var(--v-primary-base);
+}
+
+.social-link {
+  display: flex;
+  flex-direction: row;
+  justify-content: center;
+  padding: 6px;
+  text-decoration: none;
+  position:relative;
+}
+
+.social-link:hover {
+  text-decoration: underline;
+}
+
+.social-icon {
+  font-size: 22px;
+  margin: auto 6px;
+}
+
+.social-link:before {
+  display:block;
+  content:" ";
+  position:absolute;
+  z-index:100;
+  background:rgba(255, 255, 255, 0.3);
+  top:0;
+  left:0;
+  right:0;
+  bottom:0;
+  opacity:0;
+}
+.social-link:hover:before {
+  opacity:1;
+}
+
+</style>
+

+ 385 - 0
pages/events/index.vue

@@ -0,0 +1,385 @@
+<!-- Search for events -->
+
+<template>
+  <LayoutContainer>
+    <!-- Search form -->
+    <v-row class="my-3" id="searchBar">
+      <v-col cols="1"></v-col>
+      <v-col cols="10">
+        <v-card class="px-4" rounded="lg" :elevation="6">
+          <v-form method="get" class="mt-8 w100">
+            <v-container>
+              <v-row>
+                <v-col cols="12" md="4" class="py-2 px-1">
+                  <UiSearchDateRangePicker
+                    ref="dateSearch"
+                    v-model="dateRangeFilter"
+                    :label="$t('when') + ' ?'"
+                    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')}"
+                    @input="dateRangeFilterChanged"
+                  >
+                  </UiSearchDateRangePicker>
+                </v-col>
+
+                <v-col cols="12" md="4" class="py-2 px-1">
+                  <UiSearchAddress
+                    ref="addressSearch"
+                    type="municipality"
+                    @change="locationFilterChanged"
+                  />
+                </v-col>
+
+                <v-col cols="12" md="4" 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 class="search-actions" justify="space-around">
+                <v-col cols="12" sm="5" md="3">
+                  <v-btn @click="reinitializeFilters">
+                    {{ $t('reinitialize') }}
+                  </v-btn>
+                </v-col>
+                <v-col cols="12" sm="5" md="3">
+                  <v-btn class="h100" @click="search()" style="width: 100%;">
+                    {{ $t('search') }}
+                  </v-btn>
+                </v-col>
+              </v-row>
+            </v-container>
+          </v-form>
+        </v-card>
+      </v-col>
+      <v-col cols="1"></v-col>
+    </v-row>
+
+    <v-row>
+      <!-- loading skeleton -->
+      <v-container v-if="$fetchState.pending" style="width: 100%">
+        <v-row v-for="i in 4" :key="i" justify="center" justify-sm="start">
+          <v-col
+            v-for="j in 4"
+            :key="j"
+            :cols="12"
+            :sm="6"
+            :md="4"
+            :lg="3"
+            class="py-2 px-2 my-2"
+          >
+            <v-skeleton-loader type="card-avatar, article, button" :loading="true" height="400px" />
+          </v-col>
+        </v-row>
+      </v-container>
+
+      <!-- Results -->
+      <v-data-iterator
+        v-else
+        :items="events"
+        :page.sync="page"
+        :items-per-page="itemsPerPage"
+        sort-by="name"
+        hide-default-footer
+        :no-data-text="$t('no_results')"
+        style="width: 100%"
+      >
+        <template #header>
+          <i class="results-count">{{ totalRecords }} {{ $t('results') }}</i>
+        </template>
+        <template #default="props">
+          <v-container>
+            <v-row
+              justify="center"
+              justify-sm="start"
+            >
+              <v-col
+                v-for="publicEvent in events"
+                :key="publicEvent.uuid"
+                :cols="12"
+                :sm="6"
+                :md="4"
+                :lg="3"
+                class="py-2 px-2 my-2"
+              >
+                <v-card
+                  elevation="4"
+                  class="event-card pa-0 d-flex flex-column"
+                >
+                  <div class="d-flex justify-center max-w100">
+                    <v-img
+                      :src="publicEvent.thumbnailUrl || '/images/event-default.jpg'"
+                      alt="poster"
+                      max-width="100%"
+                      max-height="100%"
+                      :contain="true"
+                      tile
+                    />
+                  </div>
+
+                  <div class="d-flex flex-column flex-grow-1 px-3">
+                    <v-card-title class="title">
+                      <nuxt-link :to="{path: '/events/' + publicEvent.uuid, query: query}">
+                        {{ publicEvent.name }}
+                      </nuxt-link>
+                    </v-card-title>
+
+                    <v-card-text class="infos pb-0">
+                      <table>
+                        <tr v-if="publicEvent.datetimeStart" class="pa-1">
+                          <td class="pt-1">
+                            <font-awesome-icon :icon="['fas', 'calendar']" class="icon mr-2" />
+                          </td>
+                          <td class="pa-1">
+                            <span>
+                              {{ dateUtils.formatDateIntervalFor(new Date(publicEvent.datetimeStart), new Date(publicEvent.datetimeEnd)) }}
+                            </span>
+                          </td>
+                        </tr>
+
+                        <tr v-if="publicEvent.address.addressCity">
+                          <td class="pt-1">
+                            <font-awesome-icon class="icon" :icon="['fas', 'map-marker-alt']" />
+                          </td>
+                          <td class="pa-1">
+                            <span v-if="publicEvent.roomName" style="white-space: pre-line;">{{ publicEvent.roomName }}<br></span>
+                            <span v-if="publicEvent.address.streetAddress" style="white-space: pre-line;">{{ publicEvent.address.streetAddress }}<br></span>
+                            <span>{{ publicEvent.address.addressCity }}</span>
+                          </td>
+                        </tr>
+                      </table>
+                    </v-card-text>
+                  </div>
+
+                  <v-card-actions class="align-self-end pa-3">
+                    <v-btn
+                      class="see"
+                      :to="{path: '/events/' + publicEvent.uuid, query: query}"
+                      nuxt
+                    >
+                      <span style="margin-right: 6px;">{{ $t("more_to_know") }}</span>
+                      <font-awesome-icon :icon="['fa', 'caret-right']" />
+                    </v-btn>
+                  </v-card-actions>
+                </v-card>
+              </v-col>
+            </v-row>
+          </v-container>
+        </template>
+        <template #footer>
+          <v-pagination
+            v-model="page"
+            :length="pagesCount"
+            total-visible="9"
+            color="primary"
+            class="my-5"
+            @input="pageUpdated"
+          />
+        </template>
+      </v-data-iterator>
+    </v-row>
+  </LayoutContainer>
+</template>
+
+<script lang="ts">
+import Vue from 'vue'
+import EventsProvider from "~/services/data/EventsProvider"
+import DatesUtils from "~/services/utils/dateUtils";
+
+const defaultDateRange: DateRange = { start: '', end: '' }
+
+export default Vue.extend({
+  scrollToTop: true,
+  data () {
+    return {
+      theme: this.$route.query.theme as string ?? 'orange',
+      organization: parseInt(this.$route.query.organization as string) ?? null,
+      page: 1,
+      events: [] as Array<PublicEvent>,
+      itemsPerPage: 16,
+      textFilter: null as string | null,
+      locationFilter: null as Coordinates | null,
+      dateRangeFilter: defaultDateRange,
+      totalRecords: 0 as number,
+      pagesCount: 1 as number | null,
+      dateUtils: new DatesUtils(this.$dateFns, this.$t, this.$i18n)
+    }
+  },
+  async fetch () {
+    await new EventsProvider(this.$axios).getBy(
+      this.textFilter,
+      this.organization,
+      this.dateRangeFilter.start,
+      this.dateRangeFilter.end,
+      this.locationFilter,
+      this.page,
+      this.itemsPerPage
+    ).then(
+      (collection: HydraCollection<PublicEvent>) => {
+        this.events = collection.items
+        this.totalRecords = collection.totalItems
+        this.page = collection.page ?? 1
+        this.pagesCount = collection.lastPage ?? 1
+      })
+  },
+  computed: {
+    query (): object {
+      const q: any = { theme: this.theme }
+      if (this.organization > 0) {
+        q.organization = this.organization
+      }
+      return q
+    },
+    dateRangeMin(): string {
+      return this.dateUtils.todayIso()
+    },
+    dateRangePresets(): Array<DateRangePreset> {
+      // Today
+      const today_preset: DateRangePreset = {
+        label: this.$t('today').toString(),
+        range: {start: this.dateUtils.todayIso(), end: this.dateUtils.todayIso()}
+      }
+
+      // Cette semaine
+      const week_preset: DateRangePreset = {
+        label: this.$t('next_week').toString(),
+        range: {start: this.dateUtils.todayIso(), end: this.dateUtils.formatIso(this.$dateFns.addDays(this.dateUtils.today(), 7))}
+      }
+
+      // Ce week-end
+      const sunday: Date = this.$dateFns.nextSunday(this.dateUtils.today())
+      const weekend_preset: DateRangePreset = {
+        label: this.$t('next_weekend').toString(),
+        range: {start: this.dateUtils.formatIso(this.$dateFns.addDays(sunday, -2)), end: this.dateUtils.formatIso(sunday)}
+      }
+
+      // Ce mois
+      const month_preset: DateRangePreset = {
+        label: this.$t('next_month').toString(),
+        range: {start: this.dateUtils.todayIso(), end: this.dateUtils.formatIso(this.$dateFns.addMonths(this.dateUtils.today(), 1))}
+      }
+
+      return [today_preset, week_preset, weekend_preset, month_preset]
+    }
+  },
+  methods: {
+    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 dateSearch = this.$refs.dateSearch as any
+      dateSearch.reset()
+      const addressSearch = this.$refs.addressSearch as any
+      addressSearch.clear()
+      this.page = 1
+      this.search()
+    },
+    async pageUpdated (page: number): Promise<void> {
+      await this.$vuetify.goTo(0, { duration: 400, offset: 0, easing: 'easeInOutCubic' })
+      this.page = page
+      this.search()
+    },
+    /**
+     * Update the filteredEvents array
+     */
+    search (): void {
+      this.$fetch()
+    },
+    /**
+     * 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%;
+  width: 280px;
+  min-width: 280px;
+  color: #666666;
+}
+
+.event-card .title {
+  text-transform: uppercase;
+}
+
+.event-card .title a {
+  width: 100%;
+  text-align: center;
+}
+
+.infos td {
+  vertical-align: top;
+  text-transform: capitalize;
+}
+
+.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;
+}
+
+.search-actions button {
+  width: 100%;
+  height: 36px !important;
+}
+
+
+</style>

+ 10 - 10
pages/structures/_id.vue

@@ -152,9 +152,9 @@
                 <font-awesome-icon class="icon" :icon="['fas', 'phone-alt']" />
               </td>
               <td class="phone">
-                <div v-if="structure.telphone || structure.mobilPhone">
-                  <a v-if="showTel" :href="'tel:' + (structure.telphone || structure.mobilPhone)" class="neutral">
-                    {{ formatPhoneNumber(structure.telphone || structure.mobilPhone) }}
+                <div v-if="structure.phone || structure.mobilePhone">
+                  <a v-if="showTel" :href="'tel:' + (structure.phone || structure.mobilePhone)" class="neutral">
+                    {{ formatPhoneNumber(structure.phone || structure.mobilePhone) }}
                   </a>
                   <v-btn v-else small @click="showTel = 1">
                     {{ $t('show_tel') }}
@@ -193,15 +193,15 @@
               </td>
               <td class="network">
                 <NuxtLink
-                  v-if="parent && (structure.n1Id !== parent)"
+                  v-if="parent && (structure.parentId !== parent)"
                   class="neutral"
-                  :to="{path: '/structures/' + structure.n1Id, query: { parent: parent, view: view, theme: theme }}"
+                  :to="{path: '/structures/' + structure.parentId, query: { parent: parent, view: view, theme: theme }}"
                   nuxt
                 >
-                  {{ structure.n1Name }}
+                  {{ structure.parentName }}
                 </NuxtLink>
                 <div v-else>
-                  {{ structure.n1Name }}
+                  {{ structure.partName }}
                 </div>
               </td>
             </tr>
@@ -209,14 +209,14 @@
         </v-col>
       </v-row>
 
-      <v-row v-if="structure.latitude && structure.longitude">
+      <v-row v-if="structure.mapAddress.latitude && structure.mapAddress.longitude">
         <v-col cols="12">
           <v-responsive width="100%" height="450px">
             <no-ssr>
               <l-map
                 id="map"
                 :zoom="13"
-                :center="[structure.latitude, structure.longitude]"
+                :center="[structure.mapAddress.latitude, structure.mapAddress.longitude]"
                 :options="{ scrollWheelZoom: false, zoomSnap: 0.25 }"
               >
                 <l-tile-layer
@@ -225,7 +225,7 @@
                 />
                 <l-marker
                   :key="structure.id"
-                  :lat-lng="[structure.latitude, structure.longitude]"
+                  :lat-lng="[structure.mapAddress.latitude, structure.mapAddress.longitude]"
                 >
                   <l-popup>
                     <b>{{ structure.name }}</b><br>

+ 10 - 22
pages/structures/index.vue

@@ -237,15 +237,15 @@
                             </td>
                             <td>
                               <NuxtLink
-                                v-if="structure.n1Id !== parent"
+                                v-if="structure.parentId !== parent"
                                 class="neutral"
-                                :to="{path: '/structures/' + structure.n1Id, query: { parent: parent, view: view, theme: theme }}"
+                                :to="{path: '/structures/' + structure.parentId, query: { parent: parent, view: view, theme: theme }}"
                                 nuxt
                               >
-                                {{ structure.n1Name }}
+                                {{ structure.parentName }}
                               </NuxtLink>
                               <div v-else>
-                                {{ structure.n1Name }}
+                                {{ structure.parentName }}
                               </div>
                             </td>
                           </tr>
@@ -317,7 +317,7 @@ import Vue from 'vue'
 import { LatLngBounds } from 'leaflet'
 import departments from '@/enums/departments'
 import practices from '@/enums/practices'
-import sphericDistance from '@/services/utils/geo'
+import sphericalDistance from '@/services/utils/geo'
 import StructuresProvider from '~/services/data/StructuresProvider'
 
 const CMF_ID = 12097
@@ -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
@@ -489,7 +489,7 @@ export default Vue.extend({
 
       const radius = Number(this.distanceFilter) ?? 5
 
-      return sphericDistance(
+      return sphericalDistance(
         this.locationFilter.latitude,
         this.locationFilter.longitude,
         structure.mapAddress.latitude,
@@ -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))
     }
   }
 })

+ 85 - 0
services/data/EventsProvider.ts

@@ -0,0 +1,85 @@
+import BaseProvider from '~/services/data/BaseProvider'
+import Address from "~/components/Ui/Search/Address.vue";
+import HydraParser from "~/services/data/HydraParser";
+
+const LOCATION_RADIUS = 20
+
+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.city,
+      country: ''
+    } as Address
+
+    delete e['@id']
+    delete e['@type']
+    delete e.latitude
+    delete e.longitude
+    delete e.streetAddress
+    delete e.postalCode
+    delete e.city
+
+    return e
+  }
+
+  async getBy (
+    name: string | null = null,
+    organizationId: number | null = null,
+    dateMin: string | null = null,
+    dateMax: string | null = null,
+    location: Coordinates | null = null,
+    page: number = 1,
+    itemsPerPage = 20
+  ): Promise<HydraCollection<PublicEvent>> {
+
+    const query = new URLSearchParams();
+    if (name !== null) {
+      query.append('name', name)
+    }
+    if (organizationId !== null) {
+      query.append('organizationId', String(organizationId))
+    }
+    if (dateMin !== null && dateMin !== '') {
+      query.append('datetimeEnd[after]', dateMin)
+    }
+    if (dateMax !== null && dateMax !== '') {
+      query.append('datetimeEnd[before]', dateMax)
+    }
+    if (location !== null) {
+      query.append('withinDistance', [location.latitude, location.longitude, LOCATION_RADIUS].join(','))
+    }
+    if (page !== null) {
+      query.append('page', `${page}`)
+    }
+    if (itemsPerPage !== null) {
+      query.append('itemsPerPage', `${itemsPerPage}`)
+    }
+
+    query.append('order[datetimeEnd]', 'asc')
+
+    let url = `/api/public/events`
+    if (query) {
+      url += `?${query}`
+    }
+
+    return await this.get(url).then((res) => {
+      return HydraParser.parseCollection(res, this.normalize)
+    })
+  }
+
+  async getById (eventUuid: string): Promise<PublicEvent> {
+    return await this.get(
+      `/api/public/events/${eventUuid}`
+    ).then((s) => {
+      return this.normalize(s)
+    })
+  }
+}
+
+export default PublicEventsProvider

+ 28 - 0
services/data/HydraParser.ts

@@ -0,0 +1,28 @@
+
+
+
+
+class HydraParser {
+  static parseCollection<T>(data: any, normalize: ((s: object) => T)): HydraCollection<T> {
+
+    const view = data['hydra:view'] as any
+
+    return {
+      iri: data['@id'] as string,
+      totalItems: parseInt(data['hydra:totalItems']) ?? 0,
+      page: this.getPageFromIri(view['@id'] ?? ''),
+      previousPage: this.getPageFromIri(view['hydra:previous'] ?? ''),
+      nextPage: this.getPageFromIri(view['hydra:next'] ?? ''),
+      firstPage: this.getPageFromIri(view['hydra:first'] ?? ''),
+      lastPage: this.getPageFromIri(view['hydra:last'] ?? ''),
+      items: data['hydra:member'].map((s: any) => { return normalize(s) }),
+    }
+  }
+
+  private static getPageFromIri(iri: string): number | null {
+    const match = iri.match(/page=(\d+)/)
+    return match ? parseInt(match[1]) : null;
+  }
+}
+
+export default HydraParser

+ 6 - 16
services/data/StructuresProvider.ts

@@ -2,14 +2,6 @@ import BaseProvider from '~/services/data/BaseProvider'
 
 class StructuresProvider extends BaseProvider {
   protected normalize (s: any) : Structure {
-    s.n1Id = s.n1Id ? parseInt(s.n1Id) : null
-    s.n2Id = s.n2Id ? parseInt(s.n2Id) : null
-    s.n3Id = s.n3Id ? parseInt(s.n3Id) : null
-    s.n4Id = s.n4Id ? parseInt(s.n4Id) : null
-    s.n5Id = s.n5Id ? parseInt(s.n5Id) : null
-    s.practices = s.practices ? s.practices.split(',') : []
-    s.addresses = ((s.addresses && s.addresses !== '{}') ? JSON.parse('[' + s.addresses + ']') : []) as Array<Address>
-
     // Define the on-map address according to the chosen priorities
     s.mapAddress = s.addresses.find((a: Address) => { return a.type === 'ADDRESS_PRACTICE' }) ||
                    s.addresses.find((a: Address) => { return a.type === 'ADDRESS_HEAD_OFFICE' }) ||
@@ -20,25 +12,23 @@ class StructuresProvider extends BaseProvider {
                       s.addresses.find((a: Address) => { return a.type === 'ADDRESS_HEAD_OFFICE' }) ||
                       null
 
-    s.latitude = s.latitude ? parseFloat(s.latitude) : null
-    s.longitude = s.longitude ? parseFloat(s.longitude) : null
-    s.parents = s.parents ? s.parents.split(',').map((i: string) => Number(i)) : []
-    s.articles = (s.articles && s.articles !== '{}') ? JSON.parse('[' + s.articles + ']').reverse() : []
-    s.articles.sort((a: Article, b: Article) => { return a.date > b.date ? -1 : 1 })
+    if (s.hasOwnProperty('articles') && s.articles) {
+      s.articles.sort((a: Article, b: Article) => { return a.date > b.date ? -1 : 1 })
+    }
     return s
   }
 
   async getAll (parentId: number): Promise<Array<Structure>> {
     return await this.get(
-      `/api/public/federation_structures/all?parent-id=${parentId}`
+      `/api/public/federation_structures?parents=${parentId}`
     ).then((res) => {
-      return res.map((s: any) => { return this.normalize(s) })
+      return res["hydra:member"].map((s: any) => { return this.normalize(s) })
     })
   }
 
   async getById (organizationId: number): Promise<Structure> {
     return await this.get(
-      `/api/public/federation_structures/get?organization-id=${organizationId}`
+      `/api/public/federation_structures/${organizationId}`
     ).then((s) => {
       return this.normalize(s)
     })

+ 66 - 0
services/utils/dateUtils.ts

@@ -0,0 +1,66 @@
+import { format, parse } from 'date-fns';
+import locale from "date-fns/locale/fr";
+
+export default class DatesUtils {
+  private $dateFns: dateFns;
+  private $t: any
+  private $i18n: any
+
+  private ISO_FORMAT = 'yyyy-MM-dd';
+
+  constructor(dateFns: dateFns, t: any, i18n: any) {
+    this.$dateFns = dateFns
+    this.$t = t
+    this.$i18n = i18n
+  }
+
+  formatIso(date: Date): string {
+    return format(date, this.ISO_FORMAT)
+  }
+
+  today(): Date {
+    return new Date()
+  }
+
+  todayIso(): string {
+    return this.formatIso(this.today())
+  }
+
+  formatIsoDate(date: string, fmt: string): string {
+    return format(parse(date, this.ISO_FORMAT, new Date()), fmt);
+  }
+
+  reformatDate(date: string, fromFormat: string, toFormat: string): string {
+    return format(parse(date, fromFormat, new Date()), toFormat);
+  }
+
+  formatDate(date: Date, short = true): string {
+    return short ? this.$dateFns.format(date, 'dd/MM/yyyy') : this.$dateFns.format(date, 'dd MMM yyyy', {locale: locale})
+  }
+
+  formatTime(date: Date): string {
+    return this.$dateFns.format(date, "HH'h'mm")
+  }
+
+  formatDateTime(date: Date): string {
+    return this.formatDate(date) + ' ' + this.$t('on_hour') + ' ' + this.formatTime(date)
+  }
+
+  formatDateIntervalFor(dateStart: Date | null = null, dateEnd: Date | null = null): string {
+    if (dateStart === null && dateEnd !== null) {
+      return this.formatDateTime(dateEnd)
+    } else if (dateEnd === null && dateStart !== null) {
+      return this.formatDateTime(dateStart)
+    } else if (dateStart !== null && dateEnd !== null) {
+      if (this.$dateFns.isEqual(dateStart, dateEnd)) {
+        return this.formatDateTime(dateStart)
+      } else if (this.$dateFns.isSameDay(dateStart, dateEnd)) {
+        return this.formatDate(dateStart, false) + ', ' +
+          this.formatTime(dateStart) + ' - ' + this.formatTime(dateEnd)
+      } else {
+        return this.$t('from_day') + ' ' + this.formatDateTime(dateStart) + ' ' + this.$t('to_day') + ' ' + this.formatDateTime(dateEnd)
+      }
+    }
+    return ""
+  }
+}

+ 4 - 2
services/utils/geo.ts

@@ -9,13 +9,15 @@ function toRad (val: number): number {
 
 /**
  * This function takes in latitude and longitude of two location and returns the distance between them as the crow flies (in km)
+ * Implementation of the Haversine formula
+ *
  * @param lat1
  * @param lon1
  * @param lat2
  * @param lon2
  * @returns {number}
  */
-function sphericDistance (lat1: number, lon1: number, lat2: number, lon2: number): number {
+function sphericalDistance (lat1: number, lon1: number, lat2: number, lon2: number): number {
   const R = 6371 // km
   const dLat = toRad(lat2 - lat1)
   const dLon = toRad(lon2 - lon1)
@@ -28,4 +30,4 @@ function sphericDistance (lat1: number, lon1: number, lat2: number, lon2: number
   return R * c
 }
 
-export default sphericDistance
+export default sphericalDistance

+ 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()
+}
+

BIN
static/images/event-default.jpg


+ 0 - 18
todo.md

@@ -1,18 +0,0 @@
-## fixes
-
-## en plus
-
-? (PO nécesaire) ajouter un bouton sur la page détails des fédés: 'voir tous les adhérents'
-* remplacer la liste déroulante 'Distance' par un slider
-* documenter
-* faire un script pour identifier les geoloc foireuses, en utilisant l'api adresse, et en listant les structures dont la loc enregistrée est à plus de 5km de l'adresse
-
-## cypress
-
-* adapter aux nouveaux paramètres de requête attendus
-* améliorer les perfs, peut-être en faisant un yarn build lors de la phase d'install
-* tester la carte leaflet en:
-  * vérifiant que des coordonnées sont visibles
-  * permettant de zoomer sur un emplacement
-* fournir une base de fixtures permettant de tester plus précisément les choses
-* intercepter la requête à api.gouv.fr

+ 3 - 1
tsconfig.json

@@ -9,6 +9,7 @@
       "DOM"
     ],
     "esModuleInterop": true,
+    "allowSyntheticDefaultImports": true,
     "allowJs": true,
     "sourceMap": true,
     "strict": true,
@@ -29,7 +30,8 @@
       "@nuxtjs/i18n",
       "nuxt-leaflet",
       "@nuxtjs/axios",
-      "@types/node"
+      "@types/node",
+      "@nuxtjs/date-fns"
     ]
   },
   "exclude": [

+ 46 - 9
types/interfaces.d.ts

@@ -24,25 +24,21 @@ interface Structure {
   readonly id: number,
   name: string,
   logoId: string | null,
-  principalType: string | null,
+  type: string | null,
   website: string | null,
   mapAddress: Address | null,
   postalAddress: Address | null,
   addresses: Array<Address>,
-  telphone: string | null,
-  mobilPhone: string | null,
+  phone: string | null,
+  mobilePhone: string | null,
   email: string | null,
   facebook: string | null,
   twitter: string | null,
   instagram: string | null,
   youtube: string | null,
   practices: Array<string>,
-  n1Id: number | null,
-  n1Name: string | null,
-  n2Id: number | null,
-  n3Id: number | null,
-  n4Id: number | null,
-  n5Id: number | null,
+  parentId: number | null,
+  parentName: string | null,
   parents: Array<number>,
   description: string | null,
   imageId: string | null,
@@ -62,3 +58,44 @@ interface UiSearchAddressItem {
   value: Coordinates,
   disabled?: boolean
 }
+
+interface PublicEvent {
+  uuid: string,
+  organizationId: number | null,
+  name: string,
+  description: string | null,
+  url: string | null,
+  datetimeStart: Date,
+  datetimeEnd: Date,
+  address: Address | null,
+  roomName: string | null,
+  roomDescription: string | null,
+  roomCapacity: string | null,
+  roomFloorSize: string | null,
+  imageUrl: string | null,
+  thumbnailUrl: string | null,
+  categories: Array<string>,
+  origin: string,
+  entityId: number
+}
+
+interface DateRange {
+  start: string,
+  end: string
+}
+
+interface DateRangePreset {
+  label: string,
+  range: DateRange
+}
+
+interface HydraCollection<T> {
+  iri: string,
+  totalItems: number,
+  page: number | null,
+  previousPage: number | null,
+  nextPage: number | null,
+  firstPage: number | null,
+  lastPage: number | null
+  items: Array<T>,
+}