소스 검색

freemium architecture & organization

Vincent 5 달 전
부모
커밋
1f091711b7
63개의 변경된 파일1383개의 추가작업 그리고 854개의 파일을 삭제
  1. 2 2
      components/Layout/Common/Section.vue
  2. 2 2
      components/Layout/Header/HomeBtn.vue
  3. 275 0
      components/Ui/Input/Autocomplete/ApiResources.vue
  4. 0 0
      components/Ui/Input/Autocomplete/Enum.vue
  5. 0 148
      components/Ui/Input/AutocompleteWithAPI.vue
  6. 0 143
      components/Ui/Input/AutocompleteWithAp2i.vue
  7. 0 124
      components/Ui/Input/Enum.vue
  8. 11 1
      components/Ui/Input/Number.vue
  9. 37 86
      components/Ui/Input/Phone.vue
  10. 53 14
      components/Ui/Input/TextArea.vue
  11. 0 57
      components/Ui/ItemFromUri.vue
  12. 180 0
      components/Ui/MapLeaflet.client.vue
  13. 0 49
      components/Ui/Template/DataTable.vue
  14. 0 25
      components/Ui/Template/Date.vue
  15. 0 86
      components/Ui/Xeditable/Text.vue
  16. 3 7
      composables/data/useAp2iRequestService.ts
  17. 4 4
      composables/data/useEntityFetch.ts
  18. 1 1
      composables/data/useRefreshProfile.ts
  19. 12 3
      composables/utils/useHomeUrl.ts
  20. 15 0
      config/abilities/pages/myAccount.yaml
  21. 10 1
      i18n/lang/fr.json
  22. 47 0
      layouts/freemium.vue
  23. 18 0
      models/ApiResource.ts
  24. 80 0
      models/Freemium/Organization.ts
  25. 23 0
      models/decorators.ts
  26. 2 0
      nuxt.config.ts
  27. 3 1
      package.json
  28. 1 1
      pages/cmf_licence_structure.vue
  29. 26 0
      pages/freemium.vue
  30. 13 0
      pages/freemium/events.vue
  31. 128 0
      pages/freemium/organization.vue
  32. 13 0
      pages/freemium/subscription.vue
  33. 2 2
      pages/parameters/attendance_booking_reasons/[id].vue
  34. 2 2
      pages/parameters/attendance_booking_reasons/new.vue
  35. 4 4
      pages/parameters/attendances.vue
  36. 4 4
      pages/parameters/bulletin.vue
  37. 2 2
      pages/parameters/cycles/[id].vue
  38. 4 4
      pages/parameters/education_notation.vue
  39. 2 2
      pages/parameters/education_timings/[id].vue
  40. 2 2
      pages/parameters/education_timings/index.vue
  41. 2 2
      pages/parameters/education_timings/new.vue
  42. 3 3
      pages/parameters/general_parameters.vue
  43. 2 2
      pages/parameters/intranet.vue
  44. 2 2
      pages/parameters/residence_areas/[id].vue
  45. 2 2
      pages/parameters/residence_areas/index.vue
  46. 2 2
      pages/parameters/residence_areas/new.vue
  47. 2 2
      pages/parameters/sms.vue
  48. 2 2
      pages/parameters/subdomains/[id].vue
  49. 2 2
      pages/parameters/subdomains/new.vue
  50. 2 2
      pages/parameters/super_admin.vue
  51. 4 4
      pages/parameters/teaching.vue
  52. 4 4
      pages/parameters/website.vue
  53. 11 0
      plugins/vPhoneInput.ts
  54. 31 0
      services/asserts/AssertRuleRegistry.ts
  55. 12 0
      services/asserts/MaxAssert.ts
  56. 14 0
      services/asserts/NullableAssert.ts
  57. 21 0
      services/asserts/TypeAssert.ts
  58. 8 0
      services/asserts/getAssertUtils.ts
  59. 5 15
      services/data/entityManager.ts
  60. 4 0
      services/layout/menuBuilder/accountMenuBuilder.ts
  61. 7 0
      types/data.d.ts
  62. 5 0
      types/interfaces.d.ts
  63. 255 33
      yarn.lock

+ 2 - 2
components/Layout/Parameters/Section.vue → components/Layout/Common/Section.vue

@@ -1,11 +1,11 @@
 <template>
-  <v-card class="parameters-page-card">
+  <v-card class="page-section">
     <slot />
   </v-card>
 </template>
 
 <style scoped lang="scss">
-.parameters-page-card {
+.page-section {
   background-color: rgb(var(--v-theme-neutral-very-soft));
   color: rgb(var(--v-theme-on-neutral-very-soft));
   padding: 24px;

+ 2 - 2
components/Layout/Header/HomeBtn.vue

@@ -4,7 +4,8 @@
       ref="btn"
       icon="fas fa-home"
       size="small"
-      :href="homeUrl"
+      :href="!$can('display', 'freemium_events_page') ? homeUrl : undefined"
+      :to="$can('display', 'freemium_events_page') ? homeUrl : undefined"
       class="on-primary"
     />
     <v-tooltip :activator="btn" :text="$t('welcome')" location="bottom" />
@@ -13,7 +14,6 @@
 
 <script setup lang="ts">
 import { ref } from 'vue'
-import { useDisplay } from 'vuetify'
 import { useHomeUrl } from '~/composables/utils/useHomeUrl'
 
 const { homeUrl } = useHomeUrl()

+ 275 - 0
components/Ui/Input/Autocomplete/ApiResources.vue

@@ -0,0 +1,275 @@
+<!--
+Champs autocomplete dédié à la recherche des Accesses d'une structure
+
+@see https://vuetifyjs.com/en/components/autocompletes/#usage
+-->
+
+<template>
+  <main>
+    <UiInputAutocomplete
+      :model-value="modelValue"
+      :field="field"
+      :label="label"
+      :items="items"
+      :item-value="listValue"
+      :is-loading="pending"
+      :multiple="multiple"
+      hide-no-data
+      :chips="chips"
+      :closable-chips="true"
+      :auto-select-first="false"
+      prepend-inner-icon="fas fa-magnifying-glass"
+      :return-object="false"
+      :variant="variant"
+      :class="pending || pageStore.loading ? 'hide-selection' : ''"
+      @update:model-value="onUpdateModelValue"
+      @update:search="onUpdateSearch"
+    />
+  </main>
+</template>
+
+<script setup lang="ts">
+import type { PropType, ComputedRef, Ref } from 'vue'
+import { computed } from 'vue'
+import * as _ from 'lodash-es'
+import { useEntityFetch } from '~/composables/data/useEntityFetch'
+import Query from '~/services/data/Query'
+import OrderBy from '~/services/data/Filters/OrderBy'
+import { ORDER_BY_DIRECTION, SEARCH_STRATEGY } from '~/types/enum/data'
+import PageFilter from '~/services/data/Filters/PageFilter'
+import InArrayFilter from '~/services/data/Filters/InArrayFilter'
+import SearchFilter from '~/services/data/Filters/SearchFilter'
+import type ApiModel from "~/models/ApiModel";
+
+const props = defineProps({
+  modelValue: {
+    type: [Array, Number],
+    required: false,
+    default: null,
+  },
+  model: {
+    type: Function as PropType<() => typeof ApiModel>,
+    required: true,
+  },
+  /**
+   * Filtres à transmettre à la source de données
+   */
+  query: {
+    type: Object as PropType<typeof Query>,
+    required: false,
+  },
+  /**
+   * Nom de la propriété d'une entité lorsque l'input concerne cette propriété
+   * - Utilisé par la validation
+   * - Laisser null si le champ ne s'applique pas à une entité
+   */
+  field: {
+    type: String,
+    required: false,
+    default: null,
+  },
+  /**
+   * Label du champ
+   * Si non défini, c'est le nom de propriété qui est utilisé
+   */
+  label: {
+    type: String,
+    required: false,
+    default: null,
+  },
+  /**
+   * Définit si le champ est en lecture seule
+   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-readonly
+   */
+  readonly: {
+    type: Boolean,
+    required: false,
+  },
+  /**
+   * Autorise la sélection multiple
+   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-multiple
+   */
+  multiple: {
+    type: Boolean,
+    default: false,
+  },
+  /**
+   * Rends les résultats sous forme de puces
+   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-chips
+   */
+  chips: {
+    type: Boolean,
+    default: false,
+  },
+  /**
+   * Closes the menu and clear the current search after the selection has been updated
+   */
+  clearSearchAfterUpdate: {
+    type: Boolean,
+    default: false,
+  },
+  /**
+   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-variant
+   */
+  variant: {
+    type: String as PropType<
+      | 'filled'
+      | 'outlined'
+      | 'plain'
+      | 'underlined'
+      | 'solo'
+      | 'solo-inverted'
+      | 'solo-filled'
+      | undefined
+    >,
+    required: false,
+    default: 'outlined',
+  },
+  /**
+   * Nom de la propriété servant à générer les values dans la list
+   */
+  listValue: {
+    type: String,
+    required: false,
+    default: null,
+  },
+  /**
+   * Nom de la propriété servant à générer les labels dans la list
+   */
+  listLabel: {
+    type: String,
+    required: false,
+    default: null,
+  },
+})
+
+/**
+ * Element de la liste autocomplete
+ */
+interface ListItem {
+  id: number | string
+  title: string
+}
+
+const { fetchCollection } = useEntityFetch()
+const i18n = useI18n()
+const pageStore = usePageStore()
+
+/**
+ * Génère un AccessListItem à partir d'un Access
+ * @param searchItem
+ */
+const item = (searchItem: any): ListItem => {
+  return {
+    id: searchItem[props.listValue],
+    title: searchItem[props.listLabel]
+      ? searchItem[props.listLabel]
+      : `(${i18n.t('missing_value')})`,
+  }
+}
+
+const queryActive = new Query(
+  new OrderBy(props.listLabel, ORDER_BY_DIRECTION.ASC),
+  new PageFilter(ref(1), ref(20)),
+  new InArrayFilter(props.listValue, [props.modelValue]),
+)
+
+const {
+  data: collectionActive,
+  pending: pendingActive
+} = fetchCollection(props.model, null, queryActive)
+
+
+/**
+ * Saisie de l'utilisateur utilisée pour filtrer la recherche
+ */
+const searchFilter: Ref<string | null> = ref(null)
+
+/**
+ * Query transmise à l'API lors des changements de filtre de recherche
+ */
+const querySearch = new Query(
+  new OrderBy(props.listLabel, ORDER_BY_DIRECTION.ASC),
+  new PageFilter(ref(1), ref(20)),
+  new SearchFilter(props.listLabel, searchFilter, SEARCH_STRATEGY.IPARTIAL),
+)
+
+/**
+ * On commence par fetcher les accesses déjà actifs, pour affichage des noms
+ */
+const {
+  data: collectionSearch,
+  pending: pendingSearch,
+  refresh: refreshSearch,
+} = fetchCollection(props.model, null, querySearch)
+
+const pending = computed(() => pendingSearch.value || pendingActive.value)
+
+/**
+ * Contenu de la liste autocomplete
+ */
+const items: ComputedRef<Array<ListItem>> = computed(() => {
+  if (pending.value || !(collectionActive.value && collectionSearch.value)) {
+    return []
+  }
+
+  const activeItems: ListItem[] =
+    collectionActive.value.items.map(item)
+  const searchedItems: ListItem[] = collectionSearch.value.items
+    .map(item)
+    .filter(
+      (item) =>
+        !collectionActive.value!.items.find((other) => other[props.listValue] === item[props.listValue]),
+    )
+
+  return activeItems.concat(searchedItems)
+})
+
+/**
+ * Délai entre le dernier caractère saisi et la requête de vérification de la mise à jour des résultats (en ms)
+ */
+const inputDelay = 400
+
+/**
+ * Version debounced de la fonction refresh
+ * @see https://docs-lodash.com/v4/debounce/
+ */
+const refreshDebounced: _.DebouncedFunc<() => void> = _.debounce(async () => {
+  await refreshSearch()
+}, inputDelay)
+
+// ### Events
+const emit = defineEmits(['update:model-value'])
+
+/**
+ * La recherche textuelle a changé.
+ * @param event
+ */
+const onUpdateSearch = (event: string) => {
+  searchFilter.value = event
+  refreshDebounced()
+}
+
+const onUpdateModelValue = (event: Array<number>) => {
+  if (props.clearSearchAfterUpdate) {
+    searchFilter.value = ''
+  }
+  emit('update:model-value', event)
+}
+</script>
+
+<style scoped lang="scss">
+.v-autocomplete {
+  min-width: 350px;
+}
+
+.hide-selection {
+  /**
+      On cache le contenu au chargement en attendant de résoudre le bug qui fait
+      que ce sont les ids ou les IRIs qui s'affichent le temps du chargement
+   */
+  :deep(.v-chip__content) {
+    color: transparent !important;
+  }
+}
+</style>

+ 0 - 0
components/Ui/Input/AutocompleteWithEnum.vue → components/Ui/Input/Autocomplete/Enum.vue


+ 0 - 148
components/Ui/Input/AutocompleteWithAPI.vue

@@ -1,148 +0,0 @@
-<!--
-Liste déroulante avec autocompletion (les données sont issues
-d'une api)
-
-@see https://vuetifyjs.com/en/components/autocompletes/#usage
--->
-<template>
-  <main>
-    <UiInputAutocomplete
-      :field="field"
-      :label="label"
-      :data="remoteData ? remoteData : data"
-      :items="items"
-      :is-loading="isLoading"
-      :item-text="itemText"
-      :slot-text="slotText"
-      :item-value="itemValue"
-      :multiple="multiple"
-      :chips="chips"
-      prepend-icon="mdi-magnify"
-      :return-object="returnObject"
-      :no-filter="noFilter"
-      @research="search"
-      @update="$emit('update', $event, field)"
-    />
-  </main>
-</template>
-
-<script setup lang="ts">
-import { ref, toRefs, watch } from 'vue'
-import type { Ref } from 'vue'
-import { useFetch } from '#app'
-import UrlUtils from '~/services/utils/urlUtils'
-
-const props = defineProps({
-  label: {
-    type: String,
-    required: false,
-    default: null,
-  },
-  field: {
-    type: String,
-    required: false,
-    default: null,
-  },
-  searchFunction: {
-    type: Function as PropType<
-      (research: string, field?: string) => Promise<Array<unknown>>
-    >,
-    required: true,
-  },
-  data: {
-    type: [String, Number, Object, Array],
-    required: false,
-    default: null,
-  },
-  remoteUri: {
-    type: [Array],
-    required: false,
-    default: null,
-  },
-  remoteUrl: {
-    type: String,
-    required: false,
-    default: null,
-  },
-  readonly: {
-    type: Boolean,
-    required: false,
-  },
-  itemValue: {
-    type: String,
-    default: 'id',
-  },
-  itemTitle: {
-    type: Array,
-    required: true,
-  },
-  slotText: {
-    type: Array,
-    required: false,
-    default: () => [],
-  },
-  returnObject: {
-    type: Boolean,
-    default: false,
-  },
-  noFilter: {
-    type: Boolean,
-    default: false,
-  },
-  multiple: {
-    type: Boolean,
-    default: false,
-  },
-  chips: {
-    type: Boolean,
-    default: false,
-  },
-})
-
-const emit = defineEmits(['update'])
-
-const { data } = toRefs(props)
-const items = ref([])
-const remoteData: Ref<Array<string> | null> = ref(null)
-const isLoading = ref(false)
-
-if (props.data) {
-  items.value = props.multiple ? (data.value ?? []) : [data.value]
-} else if (props.remoteUri) {
-  const ids: Array<string | number> = []
-
-  for (const uri of props.remoteUri) {
-    ids.push(UrlUtils.extractIdFromUri(uri as string))
-  }
-
-  const options: FetchOptions = {
-    method: 'GET',
-    query: { key: 'id', value: ids.join(',') },
-  }
-
-  useFetch(async () => {
-    isLoading.value = true
-
-    const r: { data: Array<string> } = await $fetch(props.remoteUrl, options)
-
-    isLoading.value = false
-    remoteData.value = r.data
-    items.value = r.data
-  })
-}
-
-const search = async (research: string) => {
-  isLoading.value = true
-  const func = props.searchFunction
-  items.value = items.value.concat(await func(research, props.field))
-  isLoading.value = false
-}
-
-const unwatch = watch(data, (d) => {
-  items.value = props.multiple ? d : [d]
-})
-
-onUnmounted(() => {
-  unwatch()
-})
-</script>

+ 0 - 143
components/Ui/Input/AutocompleteWithAp2i.vue

@@ -1,143 +0,0 @@
-<!--
-Liste déroulante avec autocompletion issue de Ap2i
-
-@see https://vuetifyjs.com/en/components/autocompletes/#usage
--->
-<template>
-  <main>
-    <UiInputAutocomplete
-      :v-model="modelValue"
-      :field="field"
-      :label="label"
-      :items="items"
-      :is-loading="pending"
-      item-title="title"
-      item-value="id"
-      :multiple="multiple"
-      :chips="chips"
-      prepend-icon="fas fa-magnifying-glass"
-      :return-object="false"
-    />
-  </main>
-</template>
-
-<script setup lang="ts">
-import { computed } from 'vue'
-import type { ComputedRef, Ref, PropType } from 'vue'
-import { useEntityFetch } from '~/composables/data/useEntityFetch'
-import type ApiResource from '~/models/ApiResource'
-import type ApiModel from '~/models/ApiModel'
-import type { AnyJson, AssociativeArray } from '~/types/data'
-
-const props = defineProps({
-  /**
-   * v-model
-   */
-  modelValue: {
-    type: [String, Number, Object, Array],
-    required: false,
-    default: null,
-  },
-  /**
-   * Classe de l'ApiModel (ex: Organization, Notification, ...) qui sert de source à la liste
-   */
-  model: {
-    type: Function as PropType<typeof ApiModel>,
-    required: true,
-  },
-  /**
-   * Filtres à transmettre à la source de données
-   */
-  query: {
-    type: Object as PropType<Ref<AssociativeArray>>,
-    required: false,
-    default: ref(null),
-  },
-  /**
-   * Fonction qui sera exécutée sur chaque item, et qui doit renvoyer un objet contenant les
-   * propriétés 'id' et 'title'
-   *
-   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-item-title
-   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-item-value
-   */
-  transformation: {
-    type: Function as PropType<
-      (item: ApiResource) => { id: number | string; title: string }
-    >,
-    required: false,
-    default: (item: ApiResource) => item,
-  },
-  /**
-   * Nom de la propriété d'une entité lorsque l'input concerne cette propriété
-   * - Utilisé par la validation
-   * - Laisser null si le champ ne s'applique pas à une entité
-   */
-  field: {
-    type: String,
-    required: false,
-    default: null,
-  },
-  /**
-   * Label du champ
-   * Si non défini, c'est le nom de propriété qui est utilisé
-   */
-  label: {
-    type: String,
-    required: false,
-    default: null,
-  },
-  /**
-   * Définit si le champ est en lecture seule
-   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-readonly
-   */
-  readonly: {
-    type: Boolean,
-    required: false,
-  },
-  /**
-   * Autorise la sélection multiple
-   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-multiple
-   */
-  multiple: {
-    type: Boolean,
-    default: false,
-  },
-  /**
-   * Rends les résultats sous forme de puces
-   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-chips
-   */
-  chips: {
-    type: Boolean,
-    default: false,
-  },
-  // TODO: c'est quoi?
-  slotText: {
-    type: Array,
-    required: false,
-    default: null,
-  },
-})
-
-const { fetchCollection } = useEntityFetch()
-
-const query: ComputedRef<AnyJson> = computed(() => {
-  return {
-    ...(props.query.value ?? {}),
-    ...{ 'groups[]': 'access_people_ref' },
-  }
-})
-
-const { data: collection, pending } = await fetchCollection(
-  props.model,
-  null,
-  query,
-)
-
-const items: ComputedRef<Array<{ id: number | string; title: string }>> =
-  computed(() => {
-    if (!pending.value && collection.value && collection.value.items) {
-      return collection.value.items.map(props.transformation)
-    }
-    return []
-  })
-</script>

+ 0 - 124
components/Ui/Input/Enum.vue

@@ -1,124 +0,0 @@
-<!--
-Liste déroulante dédiée à l'affichage d'objets Enum
-
-@see https://vuetifyjs.com/en/components/selects/
--->
-
-<template>
-  <main>
-    <v-skeleton-loader v-if="pending" type="list-item" loading />
-
-    <v-select
-      v-else
-      :value="modelValue"
-      :label="$t(label ?? field)"
-      :items="items"
-      item-value="value"
-      item-title="label"
-      :no-data-text="$t('nothing_to_show') + '...'"
-      :rules="rules"
-      :disabled="readonly"
-      :error="error || !!fieldViolations"
-      :error-messages="
-        errorMessage || (fieldViolations ? $t(fieldViolations) : '')
-      "
-      density="compact"
-      @update:model-value="
-        updateViolationState($event)
-        $emit('update:modelValue', $event)
-      "
-    />
-  </main>
-</template>
-
-<script setup lang="ts">
-import { useFieldViolation } from '~/composables/form/useFieldViolation'
-import { useEnumFetch } from '~/composables/data/useEnumFetch'
-
-const props = defineProps({
-  /**
-   * v-model
-   */
-  modelValue: {
-    type: String,
-    required: false,
-    default: null,
-  },
-  /**
-   * Nom de l'Enum utilisée pour peupler la liste
-   */
-  enum: {
-    type: String,
-    required: true,
-  },
-  /**
-   * Nom de la propriété d'une entité lorsque l'input concerne cette propriété
-   * - Utilisé par la validation
-   * - Laisser null si le champ ne s'applique pas à une entité
-   */
-  field: {
-    type: String,
-    required: false,
-    default: null,
-  },
-  /**
-   * Label du champ
-   * Si non défini, c'est le nom de propriété qui est utilisé
-   */
-  label: {
-    type: String,
-    required: false,
-    default: null,
-  },
-  /**
-   * Définit si le champ est en lecture seule
-   */
-  readonly: {
-    type: Boolean,
-    required: false,
-  },
-  /**
-   * Règles de validation
-   * @see https://vuetify.cn/en/components/forms/#validation-with-submit-clear
-   */
-  rules: {
-    type: Array,
-    required: false,
-    default: () => [],
-  },
-  /**
-   * Le champ est-il actuellement en état d'erreur
-   */
-  error: {
-    type: Boolean,
-    required: false,
-  },
-  /**
-   * Si le champ est en état d'erreur, quel est le message d'erreur ?
-   */
-  errorMessage: {
-    type: String,
-    required: false,
-    default: null,
-  },
-})
-
-if (typeof props.enum === 'undefined') {
-  throw new TypeError("missing 'enum' property for input")
-}
-
-const { fieldViolations, updateViolationState } = useFieldViolation(props.field)
-
-const { fetch } = useEnumFetch()
-const { data: items, pending } = fetch(props.enum)
-
-const emit = defineEmits(['update:modelValue', 'change'])
-
-const onModelUpdate = (event: string | null) => {
-  updateViolationState(event)
-  emit('change', event)
-  emit('update:modelValue', event)
-}
-</script>
-
-<style scoped></style>

+ 11 - 1
components/Ui/Input/Number.vue

@@ -8,6 +8,7 @@ An input for numeric values
     ref="input"
     :model-value.number="modelValue"
     :label="label || field ? $t(label ?? field) : undefined"
+    :rules="rules"
     hide-details
     type="number"
     :variant="variant"
@@ -19,7 +20,7 @@ An input for numeric values
 <script setup lang="ts">
 import type { PropType, Ref } from 'vue'
 
-type Density = null | 'default' | 'comfortable' | 'compact'
+type ValidationRule = (value: string | number | null) => boolean | string
 
 const props = defineProps({
   modelValue: {
@@ -77,6 +78,15 @@ const props = defineProps({
     required: false,
     default: 'outlined',
   },
+  /**
+   * Règles de validation
+   * @see https://vuetify.cn/en/components/forms/#validation-with-submit-clear
+   */
+  rules: {
+    type: Array as PropType<ValidationRule[]>,
+    required: false,
+    default: () => [],
+  },
 })
 
 /**

+ 37 - 86
components/Ui/Input/Phone.vue

@@ -1,120 +1,71 @@
 <!--
 Champs de saisie d'un numéro de téléphone
 
-@see https://github.com/yogakurniawan/vue-tel-input-vuetify
+@see https://github.com/paul-thebaud/v-phone-inpu
 
-// TODO: tester compatibilité avec Vue 3
 -->
 
 <template>
-  <client-only>
-    <vue-tel-input-vuetify
-      v-model="myPhone"
-      :error="error || !!violation"
-      :error-messages="errorMessage || violation ? $t(violation) : ''"
-      :field="field"
-      :label="label"
-      :readonly="readonly"
-      clearable
-      valid-characters-only
-      validate-on-blur
-      :rules="rules"
-      @input="onInput"
-      @change="onChangeValue"
-    />
-  </client-only>
+  <v-phone-input
+    :model-value.number="modelValue"
+    :rules="rules"
+    :label="label || field ? $t(label ?? field) : undefined"
+    defaultCountry="FR"
+    @update:model-value="onUpdate($event)"
+    :invalidMessage="(n) => $t('invalid_phone_number', { example: n.example})"
+  />
 </template>
 
 <script setup lang="ts">
-import { useNuxtApp } from '#app'
-import type { Ref } from 'vue'
-import { useFieldViolation } from '~/composables/form/useFieldViolation'
+
+type ValidationRule = (value: string | number | null) => boolean | string
 
 const props = defineProps({
-  label: {
+  modelValue: {
     type: String,
-    required: false,
-    default: '',
+    required: true,
   },
+  /**
+   * Nom de la propriété d'une entité lorsque l'input concerne cette propriété
+   * - Utilisé par la validation
+   * - Laisser null si le champ ne s'applique pas à une entité
+   */
   field: {
     type: String,
     required: false,
     default: null,
   },
-  data: {
-    type: [String, Number],
+  /**
+   * Label du champ
+   * Si non défini, c'est le nom de propriété qui est utilisé
+   */
+  label: {
+    type: String,
     required: false,
     default: null,
   },
-  readonly: {
-    type: Boolean,
-    required: false,
-  },
-  error: {
-    type: Boolean,
-    required: false,
-  },
-  errorMessage: {
-    type: String,
+  /**
+   * Règles de validation
+   * @see https://vuetify.cn/en/components/forms/#validation-with-submit-clear
+   */
+  rules: {
+    type: Array as PropType<ValidationRule[]>,
     required: false,
-    default: null,
+    default: () => [],
   },
 })
 
-const { emit } = useNuxtApp()
-
-const i18n = useI18n()
+const emit = defineEmits(['update:modelValue'])
 
-const { violation, onChange } = useFieldViolation(props.field, emit)
-
-const nationalNumber: Ref<string | number> = ref('')
-const internationalNumber: Ref<string | number> = ref('')
-const isValid: Ref<boolean> = ref(false)
-const onInit: Ref<boolean> = ref(true)
-
-const onInput = (
-  _: unknown,
-  {
-    number,
-    valid,
-    countryChoice,
-  }: {
-    number: {
-      national: string | number
-      international: string | number
-    }
-    valid: boolean
-    countryChoice: Record<string, unknown>
-  },
-) => {
-  isValid.value = valid
-  nationalNumber.value = number.national
-  internationalNumber.value = number.international
-  onInit.value = false
-}
-
-const onChangeValue = () => {
-  if (isValid.value) {
-    onChange(internationalNumber.value)
+const onUpdate = (event: string|null) => {
+  if(event === ''){
+    event = null
   }
+  emit('update:model-value', event)
 }
 
-const myPhone = computed({
-  get: () => {
-    return onInit.value ? props.data : nationalNumber.value
-  },
-  set: (value) => {
-    return props.data
-  },
-})
-
-const rules = [
-  (phone: string) => !phone || isValid.value || i18n.t('phone_error'),
-]
 </script>
 
 <style lang="scss">
-input:read-only {
-  color: rgb(var(--v-theme-on-neutral));
-}
+
 </style>

+ 53 - 14
components/Ui/Input/TextArea.vue

@@ -7,49 +7,81 @@ Champs de saisie de bloc texte
 <template>
   <v-textarea
     outlined
-    :value="data"
-    :label="$t(fieldLabel)"
+    :model-value="modelValue"
+    :label="label || field ? $t(label ?? field) : undefined"
     :rules="rules"
     :disabled="readonly"
-    :error="error || !!violation"
-    :error-messages="errorMessage || violation ? $t(violation) : ''"
+    :error="error || !!fieldViolations"
+    :error-messages="
+      errorMessage || (fieldViolations ? $t(fieldViolations) : '')
+    "
+    @update:model-value="onUpdate($event)"
     @change="onChange($event)"
   />
 </template>
 
 <script setup lang="ts">
-import { useNuxtApp } from '#app'
 import { useFieldViolation } from '~/composables/form/useFieldViolation'
+import type {PropType} from "vue";
+
+type ValidationRule = (value: string | number | null) => boolean | string
 
 const props = defineProps({
-  label: {
-    type: String,
+  /**
+   * v-model
+   */
+  modelValue: {
+    type: [String, Number] as PropType<string | number | null>,
     required: false,
     default: null,
   },
+  /**
+   * Nom de la propriété d'une entité lorsque l'input concerne cette propriété
+   * - Utilisé par la validation
+   * - Laisser null si le champ ne s'applique pas à une entité
+   */
   field: {
     type: String,
     required: false,
     default: null,
   },
-  data: {
-    type: [String, Number],
+  /**
+   * Label du champ
+   * Si non défini, c'est le nom de propriété qui est utilisé
+   */
+  label: {
+    type: String,
     required: false,
     default: null,
   },
+  /**
+   * Définit si le champ est en lecture seule
+   */
   readonly: {
     type: Boolean,
     required: false,
+    default: false,
   },
+  /**
+   * Règles de validation
+   * @see https://vuetify.cn/en/components/forms/#validation-with-submit-clear
+   */
   rules: {
-    type: Array,
+    type: Array as PropType<ValidationRule[]>,
     required: false,
     default: () => [],
   },
+  /**
+   * Le champ est-il actuellement en état d'erreur
+   */
   error: {
     type: Boolean,
     required: false,
+    default: false,
   },
+  /**
+   * Si le champ est en état d'erreur, quel est le message d'erreur?
+   */
   errorMessage: {
     type: String,
     required: false,
@@ -57,14 +89,21 @@ const props = defineProps({
   },
 })
 
-const { emit } = useNuxtApp()
+const { fieldViolations, updateViolationState } = useFieldViolation(props.field)
+
+const emit = defineEmits(['update:model-value', 'change'])
 
-const fieldLabel = props.label ?? props.field
+const onUpdate = (event: string) => {
+  emit('update:model-value', event)
+}
 
-const { violation, onChange } = useFieldViolation(props.field, emit)
+const onChange = (event: Event | undefined) => {
+  updateViolationState(event)
+  emit('change', event)
+}
 </script>
 
-<style lang="scss">
+<style scoped lang="scss">
 input:read-only {
   color: rgb(var(--v-theme-on-neutral));
 }

+ 0 - 57
components/Ui/ItemFromUri.vue

@@ -1,57 +0,0 @@
-<!--
-Espace permettant de récupérer un item via une uri et de gérer son affichage via un slot
--->
-<template>
-  <main>
-    <v-skeleton-loader v-if="pending" :type="loaderType" />
-    <div v-else>
-      <slot name="item.text" v-bind="{ item }" />
-    </div>
-    <slot />
-  </main>
-</template>
-
-<script setup lang="ts">
-// TODO: renommer en EntityFromUri? voir si ce component est encore nécessaire, ou si ça ne peut pas être une méthode de l'entity manager
-
-import type { Query } from 'pinia-orm'
-import { computed } from 'vue'
-import type { ComputedRef } from 'vue'
-import UrlUtils from '~/services/utils/urlUtils'
-import { useEntityFetch } from '~/composables/data/useEntityFetch'
-import type ApiResource from '~/models/ApiResource'
-
-const props = defineProps({
-  uri: {
-    type: String,
-    required: false,
-    default: null,
-  },
-  model: {
-    type: Object,
-    required: true,
-  },
-  query: {
-    type: Object as () => Query,
-    required: true,
-  },
-  loaderType: {
-    type: String,
-    required: false,
-    default: 'text',
-  },
-})
-
-const id = UrlUtils.extractIdFromUri(props.uri)
-if (id === null) {
-  throw new Error('Uri parsing error : no id found')
-}
-
-const { fetch } = useEntityFetch()
-
-const { data, pending } = fetch(props.model, id)
-
-const item: ComputedRef<ApiResource | null> = computed(() => {
-  return data.value
-})
-</script>

+ 180 - 0
components/Ui/MapLeaflet.client.vue

@@ -0,0 +1,180 @@
+<template>
+  <div class="map-container">
+    <v-skeleton-loader type="image" v-if="pending"></v-skeleton-loader>
+
+    <LMap
+      v-show="!pending"
+      style="height: 350px"
+      :zoom="zoom"
+      :center="position"
+      :use-global-leaflet="false"
+    >
+
+      <LTileLayer
+        url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
+        attribution="&amp;copy; <a href=&quot;https://www.openstreetmap.org/&quot;>OpenStreetMap</a> contributors"
+        layer-type="base"
+        name="OpenStreetMap"
+      />
+
+      <LMarker
+        :lat-lng="position"
+        @update:latLng="onPositionUpdate($event)"
+        draggable />
+    </LMap>
+
+    <v-btn
+      prepend-icon="fas fa-location-dot"
+      class="mt-3"
+      v-if="searchButton"
+      @click="search()"
+    >
+      {{$t('search_gps_button')}}
+    </v-btn>
+    <div v-if="!pending && gpsResponses.length > 0">
+      <div v-for="(gpsResponse, key) in gpsResponses" class="address_choices" @click="addressChoice(key)">
+        {{gpsResponse['displayName']}}
+        <v-btn
+          prepend-icon="fas fa-map-location"
+          @click="addressChoice(key)"
+        >Choisir</v-btn>
+      </div>
+    </div>
+
+  </div>
+</template>
+
+<script setup lang="ts">
+
+import 'leaflet/dist/leaflet.css'
+import { LMap, LTileLayer, LMarker } from '@vue-leaflet/vue-leaflet'
+import {type ComputedRef, defineProps, type PropType} from 'vue'
+import {LatLng, type PointTuple} from 'leaflet'
+import {useAp2iRequestService} from "~/composables/data/useAp2iRequestService";
+import UrlUtils from "~/services/utils/urlUtils";
+import type {AnyJson, CollectionResponsePromise} from "~/types/data";
+import Country from "~/models/Core/Country";
+import {useEntityManager} from "~/composables/data/useEntityManager";
+
+const props = defineProps({
+  latitude: {
+    type: Number as PropType<number | null>,
+    required: true,
+  },
+  longitude: {
+    type: Number as PropType<number | null>,
+    required: true,
+  },
+  streetAddress:{
+    type: String as PropType<string | null>,
+    required: false
+  },
+  streetAddressSecond:{
+    type: String as PropType<string | null>,
+    required: false
+  },
+  streetAddressThird:{
+    type: String as PropType<string | null>,
+    required: false
+  },
+  postalCode:{
+    type: String as PropType<string | null>,
+    required: false
+  },
+  addressCity:{
+    type: String as PropType<string | null>,
+    required: false
+  },
+  addressCountryId:{
+    type: Number as PropType<number | null>,
+    required: false
+  },
+  searchButton:{
+    type: Boolean,
+    required: false,
+    default: false
+  }
+})
+
+const {apiRequestService, pending} = useAp2iRequestService()
+const { em } = useEntityManager()
+
+const position:ComputedRef<PointTuple> = computed(()=>{
+  return [props.latitude || 12, props.longitude || 12]
+})
+const zoom = ref(12)
+
+const emit = defineEmits(['update:latitude', 'update:longitude'])
+
+const onPositionUpdate = (event: LatLng):void => {
+  emit('update:latitude', event.lat)
+  emit('update:longitude', event.lng)
+}
+
+const gpsResponses:Ref<Array<AnyJson>> = ref([])
+const search = async () => {
+  gpsResponses.value = []
+  const baseUrl = UrlUtils.join('api', 'gps-coordinate-searching')
+  const query:AnyJson = {
+    'streetAddress': props.streetAddress,
+    'streetAddressSecond': props.streetAddressSecond,
+    'streetAddressThird': props.streetAddressThird,
+    'cp': props.postalCode,
+    'city': props.addressCity
+  }
+
+  if(props.addressCountryId){
+    const country:Country = em.find(Country, props.addressCountryId)
+    query['country'] = country?.name
+  }
+
+  const url = UrlUtils.addQuery(baseUrl, query)
+  const responses:CollectionResponsePromise = await apiRequestService.get(url)
+
+  if(responses['member'].length > 0){
+    onPositionUpdate(new LatLng(responses['member'][0]['latitude'], responses['member'][0]['longitude']))
+    if(responses['member'].length > 1){
+      zoom.value = 6
+      gpsResponses.value = responses['member']
+    }else{
+      zoom.value = 12
+    }
+  }
+}
+
+const addressChoice = (key:number):void => {
+  zoom.value = 12
+  onPositionUpdate(new LatLng(gpsResponses.value[key]['latitude'] as number, gpsResponses.value[key]['longitude']  as number))
+}
+
+</script>
+
+<style scoped lang="scss">
+.address_choices {
+  cursor: pointer;
+  width: 60%;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 0.75rem 1rem;
+  border-radius: 0.5rem;
+  margin-top: 0.5rem;
+  background-color: #f9f9f9;
+  transition: background-color 0.2s ease;
+
+  &:hover {
+    background-color: #eef3ff;
+  }
+
+  .v-btn {
+    flex-shrink: 0;
+  }
+}
+
+:deep(.v-skeleton-loader__image) {
+  height: 350px;
+}
+:deep(.map_wrap) {
+  height: 350px;
+}
+</style>

+ 0 - 49
components/Ui/Template/DataTable.vue

@@ -1,49 +0,0 @@
-<!--
-Template de base d'un tableau interactif
-
-@see https://vuetifyjs.com/en/components/data-tables/
--->
-
-<template>
-  <v-col cols="12" sm="12">
-    <v-data-table :headers="headersWithItem" :items="items" class="elevation-1">
-      <template
-        v-for="(header, index) in headersWithItem"
-        :key="index"
-        #[header.item]="slotProps"
-      >
-        <slot :name="header.item" v-bind="slotProps">
-          {{ slotProps.item[header.value] }}
-        </slot>
-      </template>
-    </v-data-table>
-  </v-col>
-</template>
-
-<script setup lang="ts">
-interface TableHeader {
-  value: string
-  item?: string
-  [key: string]: unknown
-}
-
-const props = defineProps({
-  items: {
-    type: Array,
-    required: true,
-  },
-  headers: {
-    type: Array as PropType<TableHeader[]>,
-    required: true,
-  },
-})
-
-const { headers } = toRefs(props)
-
-const headersWithItem = computed(() => {
-  return headers.value.map((header: TableHeader) => {
-    header.item = 'item.' + header.value
-    return header
-  })
-})
-</script>

+ 0 - 25
components/Ui/Template/Date.vue

@@ -1,25 +0,0 @@
-<!--
-Date formatée
--->
-
-<template>
-  <span>{{ datesFormatted }}</span>
-</template>
-
-<script setup lang="ts">
-import { computed } from 'vue'
-import type { ComputedRef } from 'vue'
-import DateUtils from '~/services/utils/dateUtils'
-
-const props = defineProps({
-  data: {
-    type: [String, Array],
-    required: false,
-    default: null,
-  },
-})
-
-const datesFormatted: ComputedRef<string> = computed(() => {
-  return DateUtils.format(props.data, 'DD/MM/YYYY')
-})
-</script>

+ 0 - 86
components/Ui/Xeditable/Text.vue

@@ -1,86 +0,0 @@
-<!--
-Affichage texte qui passe en mode édition lorsqu'on clique dessus
-
-Utilisé par exemple pour le choix de l'année active
--->
-
-<template>
-  <main>
-    <!-- Mode édition activé -->
-    <div v-if="edit" class="d-flex align-center x-editable-input">
-      <UiInputText v-model="inputValue" class="ma-0 pa-0" :type="type" />
-
-      <v-icon
-        icon="fas fa-check"
-        aria-hidden="false"
-        class="valid icons text-primary"
-        size="small"
-        @click="update"
-      />
-      <v-icon
-        icon="fas fa-times"
-        aria-hidden="false"
-        class="cancel icons text-neutral-strong"
-        size="small"
-        @click="close"
-      />
-    </div>
-
-    <!-- Mode édition désactivé -->
-    <div v-else class="edit-link d-flex align-center" @click="edit = true">
-      <slot name="xeditable.read" v-bind="{ inputValue }" />
-    </div>
-  </main>
-</template>
-
-<script setup lang="ts">
-import { ref } from 'vue'
-import type { Ref } from 'vue'
-
-const props = defineProps({
-  type: {
-    type: String,
-    required: false,
-    default: null,
-  },
-  data: {
-    type: [String, Number],
-    required: false,
-    default: null,
-  },
-})
-
-const emit = defineEmits(['update'])
-
-const edit: Ref<boolean> = ref(false)
-const inputValue: Ref<string | number | null> = ref(props.data)
-
-const update = () => {
-  edit.value = false
-  if (inputValue.value !== props.data) {
-    emit('update', inputValue.value)
-  }
-}
-
-const close = () => {
-  edit.value = false
-  inputValue.value = props.data
-}
-</script>
-
-<style scoped lang="scss">
-.v-input {
-  height: 24px;
-}
-
-.v-icon {
-  padding: 2px;
-  height: 24px;
-  width: 24px;
-  margin: 0 2px;
-}
-
-.edit-link {
-  cursor: pointer;
-}
-</style>

+ 3 - 7
composables/data/useAp2iRequestService.ts

@@ -101,13 +101,9 @@ export const useAp2iRequestService = () => {
     onResponseError,
   }
 
-  // Avoid memory leak
-  if (apiRequestServiceClass === null) {
-    // Utilise la fonction `create` d'ohmyfetch pour générer un fetcher dédié à l'interrogation de Ap2i
-    const fetcher = $fetch.create(config)
-
-    apiRequestServiceClass = new ApiRequestService(fetcher)
-  }
+  // Utilise la fonction `create` d'ohmyfetch pour générer un fetcher dédié à l'interrogation de Ap2i
+  const fetcher = $fetch.create(config)
+  apiRequestServiceClass = new ApiRequestService(fetcher)
 
   return { apiRequestService: apiRequestServiceClass, pending }
 }

+ 4 - 4
composables/data/useEntityFetch.ts

@@ -13,13 +13,13 @@ import type Query from '~/services/data/Query'
 interface useEntityFetchReturnType {
   fetch: (
     model: typeof ApiResource,
-    id: number,
+    id?: number | null,
   ) => AsyncData<ApiResource | null, Error | null>
 
   fetchCollection: (
     model: typeof ApiResource,
     parent?: ApiResource | null,
-    query?: Query | null,
+    query?: typeof Query | Query| null,
   ) => {
     data: ComputedRef<Collection | null>
     pending: Ref<boolean>
@@ -42,10 +42,10 @@ export const useEntityFetch = (
 ): useEntityFetchReturnType => {
   const { em } = useEntityManager()
 
-  const fetch = (model: typeof ApiResource, id: number) =>
+  const fetch = (model: typeof ApiResource, id?: number|null) =>
     useAsyncData(
       model.entity + '_' + id + '_' + uuid4(),
-      () => em.fetch(model, id, true),
+      () => em.fetch(model, id),
       { lazy },
     )
 

+ 1 - 1
composables/data/useRefreshProfile.ts

@@ -15,7 +15,7 @@ export const useRefreshProfile = () => {
       accessId = accessProfileStore.currentAccessId
     }
 
-    return (await em.fetch(MyProfile, accessId, true)) as MyProfile
+    return (await em.fetch(MyProfile, accessId)) as MyProfile
   }
 
   /**

+ 12 - 3
composables/utils/useHomeUrl.ts

@@ -1,9 +1,18 @@
 import { useAdminUrl } from '~/composables/utils/useAdminUrl'
-
+import {useAbility} from "@casl/vue";
 export const useHomeUrl = () => {
-  const { makeAdminUrl } = useAdminUrl()
+  const ability = useAbility()
+
+  let homeUrl = null
 
-  const homeUrl = makeAdminUrl('dashboard')
+  if(ability.can('display', 'freemium_events_page')){
+    const router = useRouter()
+    const to = router.resolve({ name: 'freemium_events_page' })
+    homeUrl = to.href
+  }else{
+    const { makeAdminUrl } = useAdminUrl()
+    homeUrl = makeAdminUrl('dashboard')
+  }
 
   return { homeUrl }
 }

+ 15 - 0
config/abilities/pages/myAccount.yaml

@@ -104,3 +104,18 @@ my_settings_page:
   action: 'display'
   conditions:
     - { function: organizationHasAnyModule, parameters: ['GeneralConfig'] }
+
+freemium_organization_page:
+  action: 'display'
+  conditions:
+    - { function: organizationHasAnyModule, parameters: ['Freemium'] }
+
+freemium_subscription_page:
+  action: 'display'
+  conditions:
+    - { function: organizationHasAnyModule, parameters: ['Freemium'] }
+
+freemium_events_page:
+  action: 'display'
+  conditions:
+    - { function: organizationHasAnyModule, parameters: ['Freemium'] }

+ 10 - 1
i18n/lang/fr.json

@@ -1,4 +1,14 @@
 {
+  "search_gps_button": "Mettre à jour les coordonnées géographique.",
+  "invalid_phone_number": "Numéro de téléphone non valide (exemple {example})",
+  "tel": "Téléphone",
+  "postal_address": "Coordonnées postales",
+  "coordinate": "Coordonnées",
+  "general_informations": "Informations générales",
+  "freemium_organization_page": "Fiche de ma structure",
+  "freemium_profile_page": "Mon profile",
+  "freemium_events_page": "Mes événements",
+  "freemium_page": "Freemium",
   "i_subscribe": "Je m'abonne",
   "price_include_cmf": "Inclus avec votre adhésion CMF",
   "artist": "Artist Standard",
@@ -50,7 +60,6 @@
   "convert_price_to_sms": "soit {nb_sms} SMS",
   "yearly_paid_giving_x_eur_ttc_per_year": "Payable annuellement, soit {price} TTC / an",
   "only_for_cmf_members": "Offre réservée aux adhérents CMF",
-  "public_price_x_ttc_a_year": "Prix public: {price} TTC/an",
   "product_sheet": "Fiche produit",
   "download_order_form": "Télécharger le bon de commande",
   "download_cmf_order_form": "Télécharger le bon de commande CMF",

+ 47 - 0
layouts/freemium.vue

@@ -0,0 +1,47 @@
+<template>
+  <div>
+    <!-- Show the loading page -->
+    <client-only placeholder-tag="client-only-placeholder" placeholder=" " />
+
+    <v-app>
+      <LayoutLoadingScreen />
+
+      <LayoutHeader />
+
+      <v-main class="main">
+
+        <!-- Page will be rendered here-->
+        <div class="inner-container">
+          <h3>{{ pageTitle }}</h3>
+
+          <slot />
+        </div>
+      </v-main>
+
+      <LazyLayoutAlertContainer />
+    </v-app>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useLayoutStore } from '~/stores/layout'
+
+const layoutStore = useLayoutStore()
+layoutStore.name = 'freemium'
+
+const route = useRoute()
+const i18n = useI18n()
+
+const pageTitle = computed(() => i18n.t(route.name || 'freemium_page'))
+</script>
+
+<style scoped lang="scss">
+.inner-container {
+  max-width: 1200px;
+  margin: 0 auto;
+
+  h3 {
+    margin: 36px 0 18px 2%;
+  }
+}
+</style>

+ 18 - 0
models/ApiResource.ts

@@ -6,6 +6,8 @@ import { Model } from 'pinia-orm'
 class ApiResource extends Model {
   protected static _iriEncodedFields: Record<string, ApiResource>
   protected static _idField: string
+  protected static _idLess: boolean = false
+  protected static _assert: object | null = {}
 
   public static addIriEncodedField(name: string, target: ApiResource) {
     if (!this._iriEncodedFields) {
@@ -26,6 +28,22 @@ class ApiResource extends Model {
     return this._idField
   }
 
+  public static setAsserts(assert: object) {
+    this._assert = assert
+  }
+
+  public static getAsserts() {
+    return this._assert
+  }
+
+  public static setIdLess() {
+    this._idLess = true
+  }
+
+  public static isIdLess(): boolean {
+    return this._idLess
+  }
+
   /**
    * Fix the 'Cannot stringify arbitrary non-POJOs' warning, meaning server can not parse the store
    *

+ 80 - 0
models/Freemium/Organization.ts

@@ -0,0 +1,80 @@
+import { Str, Uid } from 'pinia-orm/dist/decorators'
+import ApiModel from '~/models/ApiModel'
+import {Assert, IdLess, IriEncoded} from '~/models/decorators'
+import {Attr, Bool, Num} from "pinia-orm/decorators";
+import Country from "~/models/Core/Country";
+import File from "~/models/Core/File";
+
+/**
+ * AP2i Model : Freemium / Organization
+ *
+ * */
+@IdLess()
+export default class Organization extends ApiModel {
+  static entity = 'freemium/organization'
+
+  @Uid()
+  declare id: number | string | null
+
+  @Str(null)
+  @Assert({'max':128, 'nullable': false})
+  declare name: string | null
+
+  @Str(null)
+  declare description: string | null
+
+  @Str(null)
+  @Assert({'type' : 'email', 'nullable': false})
+  declare email: string | null
+
+  @Str(null)
+  declare tel: string | null
+
+  @Str(null)
+  declare streetAddress: string | null
+
+  @Str(null)
+  declare streetAddressSecond: string | null
+
+  @Str(null)
+  declare streetAddressThird: string | null
+
+  @Str(null)
+  declare postalCode: string | null
+
+  @Str(null)
+  declare addressCity: string | null
+
+  @IriEncoded(Country)
+  @Num(0)
+  declare addressCountry: number
+
+  @Num(null)
+  declare latitude: number | null
+
+  @Num(null)
+  declare longitude: number | null
+
+  @Str(null)
+  @Assert({'max':255})
+  declare facebook: string
+
+  @Str(null)
+  @Assert({'max':255})
+  declare twitter: string
+
+  @Str(null)
+  @Assert({'max':255})
+  declare youtube: string
+
+  @Str(null)
+  @Assert({'max':255})
+  declare instagram: string
+
+  @Bool(true)
+  declare portailVisibility: boolean
+
+  @Attr(null)
+  @IriEncoded(File)
+  declare logo: number | null
+}

+ 23 - 0
models/decorators.ts

@@ -33,3 +33,26 @@ export function IdField(): PropertyDecorator {
     self.setIdField(propertyKey as string)
   }
 }
+
+export function Assert(assert: object): PropertyDecorator {
+  // We have to comply with the PropertyDecorator return type
+  return (target: object, propertyKey: string | symbol) => {
+    // @ts-expect-error The object is an ApiResource
+    const self = target.$self()
+    const rules = self.getAsserts()
+    rules[propertyKey] = assert
+    self.setAsserts(rules)
+  }
+}
+
+/**
+ *
+ * @constructor
+ */
+export function IdLess(): ClassDecorator {
+  // We have to comply with the PropertyDecorator return type
+  return (target) => {
+    // @ts-expect-error The object is an ApiResource Class
+    target.setIdLess()
+  }
+}

+ 2 - 0
nuxt.config.ts

@@ -9,6 +9,7 @@ const transpile = [
   'pinia',
   'pinia-orm',
   'date-fns',
+  'v-phone-input',
 ]
 
 if (!process.env.NUXT_ENV) {
@@ -170,6 +171,7 @@ export default defineNuxtConfig({
     'nuxt-prepare',
     'nuxt-vitalizer',
     '@nuxt/eslint',
+    '@nuxtjs/leaflet',
   ],
   vite: {
     esbuild: {

+ 3 - 1
package.json

@@ -32,6 +32,7 @@
     "@nuxt/image": "1.9.0",
     "@nuxtjs/eslint-config-typescript": "^12.1.0",
     "@nuxtjs/i18n": "^9.1.3",
+    "@nuxtjs/leaflet": "1.2.6",
     "@pinia-orm/nuxt": "^1.10.1",
     "@pinia/nuxt": "^0.5.1",
     "@vuepic/vue-datepicker": "^11.0",
@@ -39,6 +40,7 @@
     "date-fns": "^4.1.0",
     "event-source-polyfill": "^1.0.31",
     "file-saver": "^2.0.5",
+    "flag-icons": "^7.5.0",
     "glob": "^11.0.1",
     "js-yaml": "^4.1.0",
     "libphonenumber-js": "1.11.18",
@@ -51,10 +53,10 @@
     "pinia-orm": "^1.10.1",
     "sass": "^1.69.5",
     "uuid": "^9.0.1",
+    "v-phone-input": "^5.0.0",
     "vite-plugin-vuetify": "^2.0.4",
     "vue-advanced-cropper": "^2.8.9",
     "vue-matomo": "^4.2.0",
-    "vue-tel-input-vuetify": "^1.5.3",
     "vue-the-mask": "^0.11.1",
     "vuetify": "3.6.14",
     "yaml-import": "^3.0.0"

+ 1 - 1
pages/cmf_licence_structure.vue

@@ -104,7 +104,7 @@ const submit = async () => {
           console.warn(
             "File's status has not been updated : force a status checkup",
           )
-          await em.fetch(File, receipt.fileId, true)
+          await em.fetch(File, receipt.fileId)
         }
       }, i * 4000)
     }

+ 26 - 0
pages/freemium.vue

@@ -0,0 +1,26 @@
+<!-- Page de détails des paramètres -->
+
+<template>
+  <NuxtLayout name="freemium">
+    <!-- Rend le contenu de la page -->
+    <NuxtPage />
+  </NuxtLayout>
+</template>
+
+<script setup lang="ts">
+/**
+ * Disable the default layout, the page will use the layout defined with <NuxtLayout />
+ * @see https://nuxt.com/docs/guide/directory-structure/layouts#overriding-a-layout-on-a-per-page-basis
+ */
+definePageMeta({
+  name: 'freemium_page',
+  layout: false,
+})
+</script>
+
+<style scoped lang="scss">
+:deep(.v-table thead td) {
+  color: rgb(var(--v-theme-on-primary-alt));
+  font-weight: bold;
+}
+</style>

+ 13 - 0
pages/freemium/events.vue

@@ -0,0 +1,13 @@
+<template>
+  <div class="d-flex flex-column align-center">
+    <h2 class="ma-4">Freemium</h2>
+  </div>
+</template>
+
+<script setup lang="ts">
+
+definePageMeta({
+  name: 'freemium_events_page',
+})
+
+</script>

+ 128 - 0
pages/freemium/organization.vue

@@ -0,0 +1,128 @@
+<template>
+      <UiForm v-if="organization" v-model="organization">
+
+        <LayoutCommonSection>
+          <v-row>
+            <v-col cols="12">
+              <h4 class="mb-8">{{ $t('general_informations') }}</h4>
+
+              <UiInputText v-model="organization.name" field="name" :rules="getAsserts('name')" />
+
+              <UiInputTextArea v-model="organization.description" field="description"  />
+
+              <UiInputImage
+                v-model="organization.logo"
+                field="logo"
+                :width="120"
+                :cropping-enabled="true"
+              />
+            </v-col>
+
+          </v-row>
+        </LayoutCommonSection>
+
+        <LayoutCommonSection>
+          <v-row>
+            <v-col cols="12">
+              <h4 class="mb-8">{{ $t('coordinate') }}</h4>
+
+              <UiInputText v-model="organization.email" field="email" :rules="getAsserts('email')" />
+
+              <UiInputPhone v-model="organization.tel" field="tel"/>
+
+            </v-col>
+          </v-row>
+        </LayoutCommonSection>
+
+        <LayoutCommonSection>
+          <v-row>
+            <v-col cols="12">
+              <h4 class="mb-8">{{ $t('postal_address') }}</h4>
+
+              <UiInputText v-model="organization.streetAddress" field="streetAddress" />
+
+              <UiInputText v-model="organization.streetAddressSecond" field="streetAddressSecond" />
+
+              <UiInputText v-model="organization.streetAddressThird" field="streetAddressThird" />
+
+              <UiInputText v-model="organization.postalCode" field="postalCode" />
+
+              <UiInputText v-model="organization.addressCity" field="addressCity" />
+
+              <UiInputAutocompleteApiResources
+                v-model="organization.addressCountry"
+                field="addressCountry"
+                :model="Country"
+                listValue="id"
+                listLabel="name"
+                />
+
+              <client-only>
+                <UiMapLeaflet
+                  v-model:latitude="organization.latitude"
+                  v-model:longitude="organization.longitude"
+                  :streetAddress="organization.streetAddress"
+                  :streetAddressSecond="organization.streetAddressSecond"
+                  :streetAddressThird="organization.streetAddressThird"
+                  :postalCode="organization.postalCode"
+                  :addressCity="organization.addressCity"
+                  :addressCountryId="organization.addressCountry"
+                  :searchButton="true"
+                ></UiMapLeaflet>
+              </client-only>
+
+            </v-col>
+          </v-row>
+        </LayoutCommonSection>
+
+        <LayoutCommonSection>
+          <v-row>
+            <v-col cols="12">
+
+              <h4 class="mb-8">{{ $t('communication_params') }}</h4>
+
+              <UiInputText v-model="organization.facebook" field="facebook" />
+
+              <UiInputText v-model="organization.twitter" field="twitter" />
+
+              <UiInputText v-model="organization.youtube" field="youtube" />
+
+              <UiInputText v-model="organization.instagram" field="instagram" />
+
+              <UiInputCheckbox
+                v-model="organization.portailVisibility"
+                field="portailVisibility"
+              />
+            </v-col>
+          </v-row>
+        </LayoutCommonSection>
+
+      </UiForm>
+
+</template>
+
+<script setup lang="ts">
+
+import {useEntityFetch} from "~/composables/data/useEntityFetch";
+import type {AsyncData} from "#app";
+import Organization from "~/models/Freemium/Organization";
+import {getAssertUtils} from "~/services/asserts/getAssertUtils";
+import Country from "~/models/Core/Country";
+import {useAp2iRequestService} from "~/composables/data/useAp2iRequestService";
+
+definePageMeta({
+  name: 'freemium_organization_page',
+})
+
+const {apiRequestService, pending} = useAp2iRequestService()
+
+
+const { fetch } = useEntityFetch()
+
+const { data: organization } = fetch(
+  Organization
+) as AsyncData<Organization | null, Error | null>
+
+const getAsserts = (key) => getAssertUtils(Organization.getAsserts(), key)
+
+</script>

+ 13 - 0
pages/freemium/subscription.vue

@@ -0,0 +1,13 @@
+<template>
+  <div class="d-flex flex-column align-center">
+    <h2 class="ma-4">Présentation produits</h2>
+  </div>
+</template>
+
+<script setup lang="ts">
+
+definePageMeta({
+  name: 'freemium_subscription_page',
+})
+
+</script>

+ 2 - 2
pages/parameters/attendance_booking_reasons/[id].vue

@@ -1,6 +1,6 @@
 <template>
   <div>
-    <LayoutParametersSection>
+    <LayoutCommonSection>
       <UiFormEdition
         :model="AttendanceBookingReason"
         go-back-route="/parameters/attendances"
@@ -13,7 +13,7 @@
           />
         </template>
       </UiFormEdition>
-    </LayoutParametersSection>
+    </LayoutCommonSection>
   </div>
 </template>
 <script setup lang="ts">

+ 2 - 2
pages/parameters/attendance_booking_reasons/new.vue

@@ -1,6 +1,6 @@
 <template>
   <div>
-    <LayoutParametersSection>
+    <LayoutCommonSection>
       <UiFormCreation
         :model="AttendanceBookingReason"
         go-back-route="/parameters/attendances"
@@ -22,7 +22,7 @@
           </v-container>
         </template>
       </UiFormCreation>
-    </LayoutParametersSection>
+    </LayoutCommonSection>
   </div>
 </template>
 

+ 4 - 4
pages/parameters/attendances.vue

@@ -1,6 +1,6 @@
 <template>
   <div>
-    <LayoutParametersSection v-if="organizationProfile.isSchool">
+    <LayoutCommonSection v-if="organizationProfile.isSchool">
       <h4>{{ $t('alert_configuration') }}</h4>
       <UiLoadingPanel v-if="pending" />
       <UiForm v-else-if="parameters !== null" v-model="parameters">
@@ -31,15 +31,15 @@
           </v-col>
         </v-row>
       </UiForm>
-    </LayoutParametersSection>
+    </LayoutCommonSection>
 
-    <LayoutParametersSection>
+    <LayoutCommonSection>
       <LayoutParametersEntityTable
         :model="AttendanceBookingReason"
         :title="$t('attendanceBookingReasons')"
         :columns-definitions="[{ property: 'reason' }]"
       />
-    </LayoutParametersSection>
+    </LayoutCommonSection>
   </div>
 </template>
 <script setup lang="ts">

+ 4 - 4
pages/parameters/bulletin.vue

@@ -1,6 +1,6 @@
 <template>
   <LayoutContainer>
-    <LayoutParametersSection>
+    <LayoutCommonSection>
       <UiLoadingPanel v-if="pending" />
       <UiForm v-else v-model="parameters">
         <v-row>
@@ -49,13 +49,13 @@
 
             <h4 class="my-8">{{ $t('bulletinSettings') }}</h4>
 
-            <UiInputAutocompleteWithEnum
+            <UiInputAutocompleteEnum
               v-model="parameters.bulletinCriteriaSort"
               field="bulletinCriteriaSort"
               enum-name="organization_bulletin_criteria_sort"
             />
 
-            <UiInputAutocompleteWithEnum
+            <UiInputAutocompleteEnum
               v-model="parameters.bulletinReceiver"
               field="bulletinReceiver"
               enum-name="organization_bulletin_send_to"
@@ -68,7 +68,7 @@
           </v-col>
         </v-row>
       </UiForm>
-    </LayoutParametersSection>
+    </LayoutCommonSection>
   </LayoutContainer>
 </template>
 

+ 2 - 2
pages/parameters/cycles/[id].vue

@@ -1,12 +1,12 @@
 <template>
   <div>
-    <LayoutParametersSection>
+    <LayoutCommonSection>
       <UiFormEdition :model="Cycle" go-back-route="/parameters/teaching">
         <template #default="{ entity }">
           <UiInputText v-model="entity.label" field="label" :rules="rules()" />
         </template>
       </UiFormEdition>
-    </LayoutParametersSection>
+    </LayoutCommonSection>
   </div>
 </template>
 <script setup lang="ts">

+ 4 - 4
pages/parameters/education_notation.vue

@@ -1,6 +1,6 @@
 <template>
   <LayoutContainer>
-    <LayoutParametersSection>
+    <LayoutCommonSection>
       <UiLoadingPanel v-if="pending" />
       <UiForm v-else v-model="parameters">
         <v-row>
@@ -23,14 +23,14 @@
               label="mandatory_validation_for_evaluations"
             />
 
-            <UiInputAutocompleteWithEnum
+            <UiInputAutocompleteEnum
               v-if="organizationProfile.hasModule('AdvancedEducationNotation')"
               v-model="parameters.advancedEducationNotationType"
               enum-name="advanced_education_notation"
               field="advancedEducationNotationType"
             />
 
-            <UiInputAutocompleteWithEnum
+            <UiInputAutocompleteEnum
               v-model="parameters.educationPeriodicity"
               enum-name="education_periodicity"
               field="educationPeriodicity"
@@ -48,7 +48,7 @@
           </v-col>
         </v-row>
       </UiForm>
-    </LayoutParametersSection>
+    </LayoutCommonSection>
   </LayoutContainer>
 </template>
 

+ 2 - 2
pages/parameters/education_timings/[id].vue

@@ -1,6 +1,6 @@
 <template>
   <div>
-    <LayoutParametersSection>
+    <LayoutCommonSection>
       <UiFormEdition
         :model="EducationTiming"
         go-back-route="/parameters/education_timings"
@@ -13,7 +13,7 @@
           />
         </template>
       </UiFormEdition>
-    </LayoutParametersSection>
+    </LayoutCommonSection>
   </div>
 </template>
 <script setup lang="ts">

+ 2 - 2
pages/parameters/education_timings/index.vue

@@ -1,11 +1,11 @@
 <template>
   <LayoutContainer>
-    <LayoutParametersSection>
+    <LayoutCommonSection>
       <LayoutParametersEntityTable
         :model="EducationTiming"
         :columns-definitions="[{ property: 'timing' }]"
       />
-    </LayoutParametersSection>
+    </LayoutCommonSection>
   </LayoutContainer>
 </template>
 

+ 2 - 2
pages/parameters/education_timings/new.vue

@@ -1,6 +1,6 @@
 <template>
   <div>
-    <LayoutParametersSection>
+    <LayoutCommonSection>
       <UiFormCreation
         :model="EducationTiming"
         go-back-route="/parameters/education_timings"
@@ -22,7 +22,7 @@
           </v-container>
         </template>
       </UiFormCreation>
-    </LayoutParametersSection>
+    </LayoutCommonSection>
   </div>
 </template>
 

+ 3 - 3
pages/parameters/general_parameters.vue

@@ -1,6 +1,6 @@
 <template>
   <LayoutContainer>
-    <LayoutParametersSection>
+    <LayoutCommonSection>
       <UiLoadingPanel v-if="pending" />
       <UiForm v-else-if="parameters !== null" v-model="parameters">
         <v-row>
@@ -44,7 +44,7 @@
               class="my-2"
             />
 
-            <UiInputAutocompleteWithEnum
+            <UiInputAutocompleteEnum
               v-model="parameters.timezone"
               enum-name="timezone"
               field="timezone"
@@ -83,7 +83,7 @@
           </v-col>
         </v-row>
       </UiForm>
-    </LayoutParametersSection>
+    </LayoutCommonSection>
   </LayoutContainer>
 </template>
 

+ 2 - 2
pages/parameters/intranet.vue

@@ -1,6 +1,6 @@
 <template>
   <LayoutContainer>
-    <LayoutParametersSection>
+    <LayoutCommonSection>
       <UiLoadingPanel v-if="pending" />
       <UiForm v-else v-model="parameters">
         <v-row>
@@ -46,7 +46,7 @@
           </v-col>
         </v-row>
       </UiForm>
-    </LayoutParametersSection>
+    </LayoutCommonSection>
   </LayoutContainer>
 </template>
 

+ 2 - 2
pages/parameters/residence_areas/[id].vue

@@ -1,6 +1,6 @@
 <template>
   <div>
-    <LayoutParametersSection>
+    <LayoutCommonSection>
       <UiFormEdition
         :model="ResidenceArea"
         go-back-route="/parameters/residence_areas"
@@ -14,7 +14,7 @@
           />
         </template>
       </UiFormEdition>
-    </LayoutParametersSection>
+    </LayoutCommonSection>
   </div>
 </template>
 

+ 2 - 2
pages/parameters/residence_areas/index.vue

@@ -1,12 +1,12 @@
 <template>
-  <LayoutParametersSection>
+  <LayoutCommonSection>
     <LayoutContainer>
       <LayoutParametersEntityTable
         :model="ResidenceArea"
         :columns-definitions="[{ property: 'label' }]"
       />
     </LayoutContainer>
-  </LayoutParametersSection>
+  </LayoutCommonSection>
 </template>
 
 <script setup lang="ts">

+ 2 - 2
pages/parameters/residence_areas/new.vue

@@ -1,6 +1,6 @@
 <template>
   <div>
-    <LayoutParametersSection>
+    <LayoutCommonSection>
       <UiFormCreation
         :model="ResidenceArea"
         go-back-route="/parameters/residence_areas"
@@ -20,7 +20,7 @@
           </v-container>
         </template>
       </UiFormCreation>
-    </LayoutParametersSection>
+    </LayoutCommonSection>
   </div>
 </template>
 

+ 2 - 2
pages/parameters/sms.vue

@@ -1,6 +1,6 @@
 <template>
   <div>
-    <LayoutParametersSection>
+    <LayoutCommonSection>
       <UiForm v-if="parameters" v-model="parameters">
         <v-row>
           <v-col cols="12">
@@ -20,7 +20,7 @@
           </v-col>
         </v-row>
       </UiForm>
-    </LayoutParametersSection>
+    </LayoutCommonSection>
   </div>
 </template>
 <script setup lang="ts">

+ 2 - 2
pages/parameters/subdomains/[id].vue

@@ -1,7 +1,7 @@
 <!-- Page de détails d'un sous-domaine -->
 <template>
   <div>
-    <LayoutParametersSection>
+    <LayoutCommonSection>
       <UiLoadingPanel v-if="pending" />
       <div v-else-if="subdomain !== null">
         <div>{{ $t('youRegisteredTheFollowingSubdomain') }} :</div>
@@ -32,7 +32,7 @@
           </div>
         </div>
       </div>
-    </LayoutParametersSection>
+    </LayoutCommonSection>
   </div>
 </template>
 

+ 2 - 2
pages/parameters/subdomains/new.vue

@@ -1,6 +1,6 @@
 <template>
   <div>
-    <LayoutParametersSection>
+    <LayoutCommonSection>
       <UiForm
         ref="form"
         v-model="subdomain"
@@ -45,7 +45,7 @@
           </NuxtLink>
         </template>
       </UiForm>
-    </LayoutParametersSection>
+    </LayoutCommonSection>
   </div>
 </template>
 

+ 2 - 2
pages/parameters/super_admin.vue

@@ -1,6 +1,6 @@
 <template>
   <div>
-    <LayoutParametersSection>
+    <LayoutCommonSection>
       <div class="explanation">
         <div class="px-4 d-flex flex-row align-center justify-center py-2">
           <v-icon class="theme-info">fa fa-info</v-icon>
@@ -36,7 +36,7 @@
         />
       </UiForm>
       <span v-else>{{ $t('no_admin_access_recorded') }}</span>
-    </LayoutParametersSection>
+    </LayoutCommonSection>
   </div>
 </template>
 

+ 4 - 4
pages/parameters/teaching.vue

@@ -1,6 +1,6 @@
 <template>
   <LayoutContainer>
-    <LayoutParametersSection>
+    <LayoutCommonSection>
       <h4>{{ $t('configuration') }}</h4>
 
       <UiLoadingPanel v-if="pending" />
@@ -11,9 +11,9 @@
           label="allow_to_configure_teachings_with_played_instrument_choice"
         />
       </UiForm>
-    </LayoutParametersSection>
+    </LayoutCommonSection>
 
-    <LayoutParametersSection>
+    <LayoutCommonSection>
       <LayoutParametersTable
         :items="tableItems"
         :title="$t('teaching_cycles')"
@@ -25,7 +25,7 @@
         :actions="[TABLE_ACTION.EDIT]"
         @edit-clicked="goToCycleEditPage"
       />
-    </LayoutParametersSection>
+    </LayoutCommonSection>
   </LayoutContainer>
 </template>
 

+ 4 - 4
pages/parameters/website.vue

@@ -1,6 +1,6 @@
 <template>
   <LayoutContainer>
-    <LayoutParametersSection>
+    <LayoutCommonSection>
       <h4 class="flex-grow-1 align-self-center">
         {{ $t('your_website') }}
       </h4>
@@ -49,9 +49,9 @@
           class="my-4"
         />
       </UiForm>
-    </LayoutParametersSection>
+    </LayoutCommonSection>
 
-    <LayoutParametersSection v-if="!parameters.desactivateOpentalentSiteWeb">
+    <LayoutCommonSection v-if="!parameters.desactivateOpentalentSiteWeb">
       <div class="section-header">
         <h4 class="flex-grow-1">
           {{ $t('your_subdomains') }}
@@ -115,7 +115,7 @@
           </div>
         </div>
       </div>
-    </LayoutParametersSection>
+    </LayoutCommonSection>
   </LayoutContainer>
 </template>
 

+ 11 - 0
plugins/vPhoneInput.ts

@@ -0,0 +1,11 @@
+import 'flag-icons/css/flag-icons.min.css';
+import 'v-phone-input/dist/v-phone-input.css';
+import { createVPhoneInput } from 'v-phone-input';
+
+export default defineNuxtPlugin((nuxtApp) => {
+  const vPhoneInput = createVPhoneInput({
+    countryIconMode: 'svg',
+  });
+
+  nuxtApp.vueApp.use(vPhoneInput);
+});

+ 31 - 0
services/asserts/AssertRuleRegistry.ts

@@ -0,0 +1,31 @@
+import type {AssertRule} from "~/types/interfaces";
+import { MaxAssert } from './MaxAssert';
+import { NullableAssert } from './NullableAssert';
+import { TypeAssert } from './TypeAssert';
+
+export class AssertRuleRegistry {
+  private rules: AssertRule[] = [];
+
+  constructor() {
+    this.rules = [
+      new MaxAssert(),
+      new NullableAssert(),
+      new TypeAssert(),
+    ];
+  }
+
+  getValidators(asserts: Record<string, any>): ((value: any) => true | string)[] {
+    const allRules: ((value: any) => true | string)[] = [];
+
+    for (const key in asserts) {
+      const criteria = asserts[key];
+
+      const rule = this.rules.find(r => r.supports(key));
+      if (rule) {
+        allRules.push(rule.createRule(criteria));
+      }
+    }
+
+    return allRules;
+  }
+}

+ 12 - 0
services/asserts/MaxAssert.ts

@@ -0,0 +1,12 @@
+import type {AssertRule} from "~/types/interfaces";
+
+export class MaxAssert implements AssertRule {
+  supports(key: string): boolean {
+    return key === 'max';
+  }
+
+  createRule(criteria: number): (value: string) => true | string {
+    return (value: string) =>
+      value.length <= criteria || `Maximum ${criteria} caractères`;
+  }
+}

+ 14 - 0
services/asserts/NullableAssert.ts

@@ -0,0 +1,14 @@
+import type {AssertRule} from "~/types/interfaces";
+import { useI18n } from 'vue-i18n';
+
+export class NullableAssert implements AssertRule {
+  supports(key: string): boolean {
+    return key === 'nullable';
+  }
+
+  createRule(criteria: boolean): (value: any) => true | string {
+    const { t } = useI18n();
+    return (value: any) =>
+      !criteria ? !!value || t('required') : true;
+  }
+}

+ 21 - 0
services/asserts/TypeAssert.ts

@@ -0,0 +1,21 @@
+import type {AssertRule} from "~/types/interfaces";
+import { useI18n } from 'vue-i18n';
+import ValidationUtils from "~/services/utils/validationUtils";
+
+export class TypeAssert implements AssertRule {
+  supports(key: string): boolean {
+    return key === 'type';
+  }
+
+  createRule(criteria: string): (value: any) => true | string {
+    const validationUtils = new ValidationUtils()
+    const { t } = useI18n();
+
+    if (criteria === 'email') {
+      return (email: string) =>
+        validationUtils.validEmail(email) || t('email_error');
+    }
+
+    return () => true;
+  }
+}

+ 8 - 0
services/asserts/getAssertUtils.ts

@@ -0,0 +1,8 @@
+import { AssertRuleRegistry } from './AssertRuleRegistry';
+
+export function getAssertUtils(asserts: Record<string, any>, key: string) {
+  if (!asserts || !(key in asserts)) return [];
+
+  const registry = new AssertRuleRegistry();
+  return registry.getValidators(asserts[key]);
+}

+ 5 - 15
services/data/entityManager.ts

@@ -172,25 +172,13 @@ class EntityManager {
    *
    * @param model  Model of the object to fetch
    * @param id   Id of the object to fetch
-   * @param forceRefresh  Force a new get request to the api ;
-   *                      current object in store will be overwritten if it exists
    */
   public async fetch(
     model: typeof ApiResource,
-    id: number,
-    forceRefresh: boolean = false,
+    id?: number | null,
   ): Promise<ApiResource> {
-    // If the model instance is already in the store and forceRefresh is false, return the object in store
-    if (!forceRefresh) {
-      // TODO: est-ce qu'il y a vraiment des situations où on appellera cette méthode sans le forceRefresh?
-      const item = this.find(model, id)
-      if (item && typeof item !== 'undefined') {
-        return item
-      }
-    }
-
     // Else, get the object from the API
-    const url = UrlUtils.join('api', model.entity, String(id))
+    const url = UrlUtils.join('api', model.entity, id ? String(id) : '')
     const response = await this.apiRequestService.get(url)
 
     // deserialize the response
@@ -279,7 +267,9 @@ class EntityManager {
     const headers = { profileHash: await this.makeProfileHash() }
 
     if (!instance.isNew()) {
-      url = UrlUtils.join(url, String(instance.id))
+      if(!model.isIdLess()){
+        url = UrlUtils.join(url, String(instance.id))
+      }
       response = await this.apiRequestService.patch(url, data, null, headers)
     } else {
       delete data.id

+ 4 - 0
services/layout/menuBuilder/accountMenuBuilder.ts

@@ -162,6 +162,10 @@ export default class AccountMenuBuilder extends AbstractMenuBuilder {
 
     children.push(...this.makeChildren([{ pageName: 'my_settings_page' }]))
 
+    children.push(...this.makeChildren([{ pageName: 'freemium_organization_page' }]))
+
+    children.push(...this.makeChildren([{ pageName: 'freemium_subscription_page' }]))
+
     actions.push(
       this.createItem('logout', undefined, `/logout`, MENU_LINK_TYPE.V1),
     )

+ 7 - 0
types/data.d.ts

@@ -65,4 +65,11 @@ interface EnumItem {
   label: string
 }
 
+interface CollectionResponse {
+  member: Array<AnyJson>,
+  totalItems: number
+}
+
+type CollectionResponsePromise = Response<CollectionResponse>
+
 type Enum = Array<EnumChoice>

+ 5 - 0
types/interfaces.d.ts

@@ -212,3 +212,8 @@ interface ColumnDefinition {
    */
   label?: string
 }
+
+interface AssertRule {
+  supports(key: string): boolean;
+  createRule(criteria: any): (value: any) => true | string;
+}

+ 255 - 33
yarn.lock

@@ -239,6 +239,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@babel/helper-string-parser@npm:^7.27.1":
+  version: 7.27.1
+  resolution: "@babel/helper-string-parser@npm:7.27.1"
+  checksum: 10c0/8bda3448e07b5583727c103560bcf9c4c24b3c1051a4c516d4050ef69df37bb9a4734a585fe12725b8c2763de0a265aa1e909b485a4e3270b7cfd3e4dbe4b602
+  languageName: node
+  linkType: hard
+
 "@babel/helper-validator-identifier@npm:^7.22.20, @babel/helper-validator-identifier@npm:^7.27.1":
   version: 7.27.1
   resolution: "@babel/helper-validator-identifier@npm:7.27.1"
@@ -281,6 +288,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@babel/parser@npm:^7.27.5":
+  version: 7.27.5
+  resolution: "@babel/parser@npm:7.27.5"
+  dependencies:
+    "@babel/types": "npm:^7.27.3"
+  bin:
+    parser: ./bin/babel-parser.js
+  checksum: 10c0/f7faaebf21cc1f25d9ca8ac02c447ed38ef3460ea95be7ea760916dcf529476340d72a5a6010c6641d9ed9d12ad827c8424840277ec2295c5b082ba0f291220a
+  languageName: node
+  linkType: hard
+
 "@babel/plugin-proposal-decorators@npm:^7.23.0":
   version: 7.25.9
   resolution: "@babel/plugin-proposal-decorators@npm:7.25.9"
@@ -407,6 +425,16 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@babel/types@npm:^7.27.3":
+  version: 7.27.6
+  resolution: "@babel/types@npm:7.27.6"
+  dependencies:
+    "@babel/helper-string-parser": "npm:^7.27.1"
+    "@babel/helper-validator-identifier": "npm:^7.27.1"
+  checksum: 10c0/39d556be114f2a6d874ea25ad39826a9e3a0e98de0233ae6d932f6d09a4b222923a90a7274c635ed61f1ba49bbd345329226678800900ad1c8d11afabd573aaf
+  languageName: node
+  linkType: hard
+
 "@bcoe/v8-coverage@npm:^1.0.2":
   version: 1.0.2
   resolution: "@bcoe/v8-coverage@npm:1.0.2"
@@ -2089,6 +2117,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@nuxtjs/leaflet@npm:1.2.6":
+  version: 1.2.6
+  resolution: "@nuxtjs/leaflet@npm:1.2.6"
+  dependencies:
+    "@types/leaflet": "npm:^1.9.15"
+    "@vue-leaflet/vue-leaflet": "npm:^0.10.1"
+    leaflet: "npm:^1.9.4"
+  checksum: 10c0/9f8cddc34244c3fe9f8f4180d1f84f6496bd07d4033d17a40cc730d598c63dae5387b1882a7473fe37935346d54b43adb8b1450822cafe10803c92b7441b4ef7
+  languageName: node
+  linkType: hard
+
 "@one-ini/wasm@npm:0.1.1":
   version: 0.1.1
   resolution: "@one-ini/wasm@npm:0.1.1"
@@ -2910,6 +2949,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@types/geojson@npm:*":
+  version: 7946.0.16
+  resolution: "@types/geojson@npm:7946.0.16"
+  checksum: 10c0/1ff24a288bd5860b766b073ead337d31d73bdc715e5b50a2cee5cb0af57a1ed02cc04ef295f5fa68dc40fe3e4f104dd31282b2b818a5ba3231bc1001ba084e3c
+  languageName: node
+  linkType: hard
+
 "@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.0":
   version: 2.0.6
   resolution: "@types/istanbul-lib-coverage@npm:2.0.6"
@@ -2966,6 +3012,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@types/leaflet@npm:^1.9.15":
+  version: 1.9.19
+  resolution: "@types/leaflet@npm:1.9.19"
+  dependencies:
+    "@types/geojson": "npm:*"
+  checksum: 10c0/09a32f19d05a28b4c6478c7830af049a14573e78e5652947723b6416b20b3080970da88b6c43991d81fed156b94a7da3f2261d5ffe430b61c4ec6467fc94a54f
+  languageName: node
+  linkType: hard
+
 "@types/lodash-es@npm:^4.17.12":
   version: 4.17.12
   resolution: "@types/lodash-es@npm:4.17.12"
@@ -3860,6 +3915,21 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@vue-leaflet/vue-leaflet@npm:^0.10.1":
+  version: 0.10.1
+  resolution: "@vue-leaflet/vue-leaflet@npm:0.10.1"
+  dependencies:
+    vue: "npm:^3.2.25"
+  peerDependencies:
+    "@types/leaflet": ^1.5.7
+    leaflet: ^1.6.0
+  peerDependenciesMeta:
+    "@types/leaflet":
+      optional: true
+  checksum: 10c0/0e44e340821f4007beef9132378f756b95daeb6d2d5434b9f20559fcc4a21424586e9200e21ae46ca3b5fbde4c1b6cf282145245a72b225ee464d11db8ac4cf4
+  languageName: node
+  linkType: hard
+
 "@vue-macros/common@npm:^1.15.0, @vue-macros/common@npm:^1.16.1":
   version: 1.16.1
   resolution: "@vue-macros/common@npm:1.16.1"
@@ -3936,6 +4006,19 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@vue/compiler-core@npm:3.5.17":
+  version: 3.5.17
+  resolution: "@vue/compiler-core@npm:3.5.17"
+  dependencies:
+    "@babel/parser": "npm:^7.27.5"
+    "@vue/shared": "npm:3.5.17"
+    entities: "npm:^4.5.0"
+    estree-walker: "npm:^2.0.2"
+    source-map-js: "npm:^1.2.1"
+  checksum: 10c0/d6b50f6f0a71a77a04452877c601cfd6ea13ec07aa68a061523166c1150e159f64230eee28e1042e6113e334a11c25c306bae5d463931a9e7f96261a29a0042d
+  languageName: node
+  linkType: hard
+
 "@vue/compiler-dom@npm:3.5.13, @vue/compiler-dom@npm:^3.2.45, @vue/compiler-dom@npm:^3.3.4":
   version: 3.5.13
   resolution: "@vue/compiler-dom@npm:3.5.13"
@@ -3946,6 +4029,16 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@vue/compiler-dom@npm:3.5.17":
+  version: 3.5.17
+  resolution: "@vue/compiler-dom@npm:3.5.17"
+  dependencies:
+    "@vue/compiler-core": "npm:3.5.17"
+    "@vue/shared": "npm:3.5.17"
+  checksum: 10c0/27e4c201522abcb2755318fc502a4cf8a752fb90441bbd954c018990e80bb30e4075dadefa7f36671028779d9c21d34d76330f6b441921e317cf1c102a5411b6
+  languageName: node
+  linkType: hard
+
 "@vue/compiler-sfc@npm:2.7.16":
   version: 2.7.16
   resolution: "@vue/compiler-sfc@npm:2.7.16"
@@ -3978,6 +4071,23 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@vue/compiler-sfc@npm:3.5.17":
+  version: 3.5.17
+  resolution: "@vue/compiler-sfc@npm:3.5.17"
+  dependencies:
+    "@babel/parser": "npm:^7.27.5"
+    "@vue/compiler-core": "npm:3.5.17"
+    "@vue/compiler-dom": "npm:3.5.17"
+    "@vue/compiler-ssr": "npm:3.5.17"
+    "@vue/shared": "npm:3.5.17"
+    estree-walker: "npm:^2.0.2"
+    magic-string: "npm:^0.30.17"
+    postcss: "npm:^8.5.6"
+    source-map-js: "npm:^1.2.1"
+  checksum: 10c0/63c9b4cac42291c5c7edaaa26a6b052fd47b7b7dda2c40ad7b344c4195b8add97e4a89e73e50bf94ee33b402cc075d69602c76cbd4627eedcf6061c9df91c8e7
+  languageName: node
+  linkType: hard
+
 "@vue/compiler-ssr@npm:3.5.13":
   version: 3.5.13
   resolution: "@vue/compiler-ssr@npm:3.5.13"
@@ -3988,6 +4098,16 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@vue/compiler-ssr@npm:3.5.17":
+  version: 3.5.17
+  resolution: "@vue/compiler-ssr@npm:3.5.17"
+  dependencies:
+    "@vue/compiler-dom": "npm:3.5.17"
+    "@vue/shared": "npm:3.5.17"
+  checksum: 10c0/80f0ccb05e8c6b3c72d4ea50ec87a1f89704483608053b1fcc88669886069edcd21cabc6608816c09d99fc6cab1985d676bf3725175f80482f2b3aaf51a15416
+  languageName: node
+  linkType: hard
+
 "@vue/devtools-api@npm:^6.5.0, @vue/devtools-api@npm:^6.6.3, @vue/devtools-api@npm:^6.6.4":
   version: 6.6.4
   resolution: "@vue/devtools-api@npm:6.6.4"
@@ -4075,6 +4195,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@vue/reactivity@npm:3.5.17":
+  version: 3.5.17
+  resolution: "@vue/reactivity@npm:3.5.17"
+  dependencies:
+    "@vue/shared": "npm:3.5.17"
+  checksum: 10c0/60ef16300b3819323df52eb243a9b6eabfd877ff0a97a2ee9e12537cbf855b52b78d57abb3cabf79c434e1afa4cbae738225bb2fdf8872d237ae6513f03f03e1
+  languageName: node
+  linkType: hard
+
 "@vue/runtime-core@npm:3.5.13":
   version: 3.5.13
   resolution: "@vue/runtime-core@npm:3.5.13"
@@ -4085,6 +4214,16 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@vue/runtime-core@npm:3.5.17":
+  version: 3.5.17
+  resolution: "@vue/runtime-core@npm:3.5.17"
+  dependencies:
+    "@vue/reactivity": "npm:3.5.17"
+    "@vue/shared": "npm:3.5.17"
+  checksum: 10c0/dd6cc5e451cce2d979ce53f0c9dc100d47f38fe70398f061eed111d51a904199f984429b6243e2044272018375da5fd9ce469d1e72b7fb316a1160f700fa9950
+  languageName: node
+  linkType: hard
+
 "@vue/runtime-dom@npm:3.5.13":
   version: 3.5.13
   resolution: "@vue/runtime-dom@npm:3.5.13"
@@ -4097,6 +4236,18 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@vue/runtime-dom@npm:3.5.17":
+  version: 3.5.17
+  resolution: "@vue/runtime-dom@npm:3.5.17"
+  dependencies:
+    "@vue/reactivity": "npm:3.5.17"
+    "@vue/runtime-core": "npm:3.5.17"
+    "@vue/shared": "npm:3.5.17"
+    csstype: "npm:^3.1.3"
+  checksum: 10c0/963d9b901e465621f24db745988f2f63c07fd7aa2a552e77d9dbd2a70f2c3f002f340866f085fa2fd791b62f7cd3e8a37f701351a49b839b4bbe5649fe9acc43
+  languageName: node
+  linkType: hard
+
 "@vue/server-renderer@npm:3.5.13":
   version: 3.5.13
   resolution: "@vue/server-renderer@npm:3.5.13"
@@ -4109,6 +4260,18 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@vue/server-renderer@npm:3.5.17":
+  version: 3.5.17
+  resolution: "@vue/server-renderer@npm:3.5.17"
+  dependencies:
+    "@vue/compiler-ssr": "npm:3.5.17"
+    "@vue/shared": "npm:3.5.17"
+  peerDependencies:
+    vue: 3.5.17
+  checksum: 10c0/34c6bcf909fe64820dc28d97fdcb4752346b923b5bd4a09521228988dc86e3b70c1edfd4f0daf8d6b5f4d74cc56c9bdac2ec9c17e7ef0e616e575d8f7b910d3a
+  languageName: node
+  linkType: hard
+
 "@vue/shared@npm:3.5.13, @vue/shared@npm:^3.5.13":
   version: 3.5.13
   resolution: "@vue/shared@npm:3.5.13"
@@ -4116,6 +4279,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@vue/shared@npm:3.5.17":
+  version: 3.5.17
+  resolution: "@vue/shared@npm:3.5.17"
+  checksum: 10c0/915d8f80d863826531cf6ddefeb52455cbffcbca4d14717472b7765b3142d2ad9900dfce351e90a22e1fe9e2f8fca588421de6e751e1c816ab9e1fdefa3e8a0d
+  languageName: node
+  linkType: hard
+
 "@vue/shared@npm:^3.5.14":
   version: 3.5.16
   resolution: "@vue/shared@npm:3.5.16"
@@ -4336,6 +4506,7 @@ __metadata:
     "@nuxt/test-utils-edge": "npm:3.8.0-28284309.b3d3d7f4"
     "@nuxtjs/eslint-config-typescript": "npm:^12.1.0"
     "@nuxtjs/i18n": "npm:^9.1.3"
+    "@nuxtjs/leaflet": "npm:1.2.6"
     "@pinia-orm/nuxt": "npm:^1.10.1"
     "@pinia/nuxt": "npm:^0.5.1"
     "@types/cleave.js": "npm:^1.4.12"
@@ -4365,6 +4536,7 @@ __metadata:
     eslint-plugin-you-dont-need-lodash-underscore: "npm:^6.14.0"
     event-source-polyfill: "npm:^1.0.31"
     file-saver: "npm:^2.0.5"
+    flag-icons: "npm:^7.5.0"
     glob: "npm:^11.0.1"
     js-yaml: "npm:^4.1.0"
     jsdom: "npm:^26.0.0"
@@ -4381,12 +4553,12 @@ __metadata:
     ts-jest: "npm:^29.2.5"
     typescript: "npm:^5.7.3"
     uuid: "npm:^9.0.1"
+    v-phone-input: "npm:^5.0.0"
     vite-plugin-vuetify: "npm:^2.0.4"
     vitest: "npm:3.0.4"
     vue-advanced-cropper: "npm:^2.8.9"
     vue-jest: "npm:^3.0.7"
     vue-matomo: "npm:^4.2.0"
-    vue-tel-input-vuetify: "npm:^1.5.3"
     vue-the-mask: "npm:^0.11.1"
     vuetify: "npm:3.6.14"
     yaml-import: "npm:^3.0.0"
@@ -4622,10 +4794,10 @@ __metadata:
   languageName: node
   linkType: hard
 
-"awesome-phonenumber@npm:^2.15.0":
-  version: 2.73.0
-  resolution: "awesome-phonenumber@npm:2.73.0"
-  checksum: 10c0/d9d7067adc64b0e1593fe9b421c36d1089942d1a72a1d70b966cb63e0c7063525828ac9a8f2a1e61e33d8f36d594ecfb2933fc12e8d2334b209c03965a4f01e4
+"awesome-phonenumber@npm:^7.5.0":
+  version: 7.5.0
+  resolution: "awesome-phonenumber@npm:7.5.0"
+  checksum: 10c0/ec11a86f06db88ccb7592f7d326f77678aab2dbc7e3b59419c8b8c77d18eb13ccc51f55b1c41fed0068f340004fc9f3ffbc0f2d5dcc339396cb8f4ad00a270b7
   languageName: node
   linkType: hard
 
@@ -5618,6 +5790,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"countries-list@npm:^3.1.1":
+  version: 3.1.1
+  resolution: "countries-list@npm:3.1.1"
+  checksum: 10c0/26734d5927ee9dafa30e4da214b6ab7ecf7cdd93516e5bb077d6fdb1f9f37b4fd8698987b0062c1f3b0c95d18fe947f941ef0ab8c3cc486ef33f1c3dca2678b7
+  languageName: node
+  linkType: hard
+
 "crc-32@npm:^1.2.0":
   version: 1.2.2
   resolution: "crc-32@npm:1.2.2"
@@ -7730,6 +7909,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"flag-icons@npm:^7.5.0":
+  version: 7.5.0
+  resolution: "flag-icons@npm:7.5.0"
+  checksum: 10c0/c81e20c1751833882c95633349ba7e76e74d7d86c317e4ac2bac86589325430a3181913453fa3824edabaa1a84469f7ae4fd50c388027079048a5c3be438874a
+  languageName: node
+  linkType: hard
+
 "flat-cache@npm:^4.0.0":
   version: 4.0.1
   resolution: "flat-cache@npm:4.0.1"
@@ -9602,6 +9788,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"leaflet@npm:^1.9.4":
+  version: 1.9.4
+  resolution: "leaflet@npm:1.9.4"
+  checksum: 10c0/f639441dbb7eb9ae3fcd29ffd7d3508f6c6106892441634b0232fafb9ffb1588b05a8244ec7085de2c98b5ed703894df246898477836cfd0ce5b96d4717b5ca1
+  languageName: node
+  linkType: hard
+
 "levn@npm:^0.4.1":
   version: 0.4.1
   resolution: "levn@npm:0.4.1"
@@ -10242,7 +10435,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"nanoid@npm:^3.3.8":
+"nanoid@npm:^3.3.11, nanoid@npm:^3.3.8":
   version: 3.3.11
   resolution: "nanoid@npm:3.3.11"
   bin:
@@ -11751,6 +11944,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"postcss@npm:^8.5.6":
+  version: 8.5.6
+  resolution: "postcss@npm:8.5.6"
+  dependencies:
+    nanoid: "npm:^3.3.11"
+    picocolors: "npm:^1.1.1"
+    source-map-js: "npm:^1.2.1"
+  checksum: 10c0/5127cc7c91ed7a133a1b7318012d8bfa112da9ef092dddf369ae699a1f10ebbd89b1b9f25f3228795b84585c72aabd5ced5fc11f2ba467eedf7b081a66fad024
+  languageName: node
+  linkType: hard
+
 "prebuild-install@npm:^7.1.1":
   version: 7.1.3
   resolution: "prebuild-install@npm:7.1.3"
@@ -14453,6 +14657,26 @@ __metadata:
   languageName: node
   linkType: hard
 
+"v-phone-input@npm:^5.0.0":
+  version: 5.0.0
+  resolution: "v-phone-input@npm:5.0.0"
+  dependencies:
+    awesome-phonenumber: "npm:^7.5.0"
+    countries-list: "npm:^3.1.1"
+    flag-icons: "npm:^7.5.0"
+    world-flags-sprite: "npm:^0.0.2"
+  peerDependencies:
+    vue: ^3.0.0
+    vuetify: ^3.0.0
+  dependenciesMeta:
+    flag-icons:
+      optional: true
+    world-flags-sprite:
+      optional: true
+  checksum: 10c0/3ab0bade0361daef5dbebd317a8649798a99ee56258817e96f7687b9a295a462e681a6373dc647769d4d30c84472ee149422998648106219fbc20495182b3942
+  languageName: node
+  linkType: hard
+
 "validate-npm-package-license@npm:^3.0.1, validate-npm-package-license@npm:^3.0.4":
   version: 3.0.4
   resolution: "validate-npm-package-license@npm:3.0.4"
@@ -14924,17 +15148,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"vue-tel-input-vuetify@npm:^1.5.3":
-  version: 1.5.3
-  resolution: "vue-tel-input-vuetify@npm:1.5.3"
-  dependencies:
-    awesome-phonenumber: "npm:^2.15.0"
-    vuetify: "npm:^2.2.11"
-    whatwg-fetch: "npm:^3.2.0"
-  checksum: 10c0/3a3f3ce8a0dea1065899979755e87fd0c22720f61b3d5c0968ec90a53bd06b1106f5133c547417be62b7d62d77fcdfa87b710451725741c40500c264c0116257
-  languageName: node
-  linkType: hard
-
 "vue-template-es2015-compiler@npm:^1.6.0":
   version: 1.9.1
   resolution: "vue-template-es2015-compiler@npm:1.9.1"
@@ -14959,6 +15172,24 @@ __metadata:
   languageName: node
   linkType: hard
 
+"vue@npm:^3.2.25":
+  version: 3.5.17
+  resolution: "vue@npm:3.5.17"
+  dependencies:
+    "@vue/compiler-dom": "npm:3.5.17"
+    "@vue/compiler-sfc": "npm:3.5.17"
+    "@vue/runtime-dom": "npm:3.5.17"
+    "@vue/server-renderer": "npm:3.5.17"
+    "@vue/shared": "npm:3.5.17"
+  peerDependencies:
+    typescript: "*"
+  peerDependenciesMeta:
+    typescript:
+      optional: true
+  checksum: 10c0/271e7aeca24495145e0e00e8553c681ae4feb08309207aa7bf6e13d0603ca8e1b6a8091d62542041aef2f4874940ee3f0a186a7753f0729fa5a12576861b34a8
+  languageName: node
+  linkType: hard
+
 "vue@npm:^3.4, vue@npm:^3.5.13":
   version: 3.5.13
   resolution: "vue@npm:3.5.13"
@@ -14999,15 +15230,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"vuetify@npm:^2.2.11":
-  version: 2.7.2
-  resolution: "vuetify@npm:2.7.2"
-  peerDependencies:
-    vue: ^2.6.4
-  checksum: 10c0/2c7df556c50d0ab74901d112b3944cf7022c97a58c423cc8572ece129bf81ac077652e0bd59aca29a0999d94d4aae1d06653cec6cc82612e81c48690d78fa60f
-  languageName: node
-  linkType: hard
-
 "w3c-xmlserializer@npm:^5.0.0":
   version: 5.0.0
   resolution: "w3c-xmlserializer@npm:5.0.0"
@@ -15047,13 +15269,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"whatwg-fetch@npm:^3.2.0":
-  version: 3.6.20
-  resolution: "whatwg-fetch@npm:3.6.20"
-  checksum: 10c0/fa972dd14091321d38f36a4d062298df58c2248393ef9e8b154493c347c62e2756e25be29c16277396046d6eaa4b11bd174f34e6403fff6aaca9fb30fa1ff46d
-  languageName: node
-  linkType: hard
-
 "whatwg-mimetype@npm:^4.0.0":
   version: 4.0.0
   resolution: "whatwg-mimetype@npm:4.0.0"
@@ -15194,6 +15409,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"world-flags-sprite@npm:^0.0.2":
+  version: 0.0.2
+  resolution: "world-flags-sprite@npm:0.0.2"
+  checksum: 10c0/10f59fbe6f4889fd85f24991fdb034a45f638881fe07436061e1bddb044b0551607b7734222c479ab835b183b331f691990a45a06b59b19b505ae9bbd5add17f
+  languageName: node
+  linkType: hard
+
 "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0, wrap-ansi@npm:^7.0.0":
   version: 7.0.0
   resolution: "wrap-ansi@npm:7.0.0"