瀏覽代碼

Merge branch 'feature/freemium-final' into develop

Vincent 4 月之前
父節點
當前提交
cdf6548f98
共有 34 個文件被更改,包括 480 次插入160 次删除
  1. 4 0
      assets/css/global.scss
  2. 30 20
      components/Form/Freemium/Event.vue
  3. 4 3
      components/Layout/Header.vue
  4. 4 1
      components/Layout/Subheader.vue
  5. 31 9
      components/Ui/EventList.vue
  6. 1 1
      components/Ui/Form.vue
  7. 10 1
      components/Ui/Input/Autocomplete/ApiResources.vue
  8. 20 1
      components/Ui/Input/DateTimePicker.vue
  9. 18 0
      components/Ui/Input/Phone.vue
  10. 18 1
      components/Ui/Input/TextArea.vue
  11. 52 7
      components/Ui/Input/TreeSelect.vue
  12. 5 3
      components/Ui/Input/TreeSelect/EventCategories.vue
  13. 1 1
      components/Ui/LoadingPanel.client.vue
  14. 4 3
      components/Ui/MapLeaflet.client.vue
  15. 0 76
      composables/useVuetifyValidation.ts
  16. 19 2
      i18n/lang/fr/general.json
  17. 93 9
      layouts/freemium.vue
  18. 125 19
      pages/freemium/dashboard.vue
  19. 9 0
      pages/freemium/events/[id].vue
  20. 9 0
      pages/freemium/events/new.vue
  21. 11 1
      pages/freemium/organization.vue
  22. 1 1
      pages/subscription.vue
  23. 二進制
      public/images/OT_Logo_Agenda.png
  24. 二進制
      public/images/Opentalent_Artist-Blanc.png
  25. 二進制
      public/images/Opentalent_Artist_Griffe.png
  26. 二進制
      public/images/Opentalent_Griffe.png
  27. 二進制
      public/images/Opentalent_Manager-Blanc.png
  28. 二進制
      public/images/Opentalent_Manager.png
  29. 二進制
      public/images/Opentalent_Manager_Griffe.png
  30. 二進制
      public/images/Opentalent_School-Blanc.png
  31. 二進制
      public/images/Opentalent_School_Griffe.png
  32. 1 1
      services/asserts/TypeAssert.ts
  33. 9 0
      stores/organizationProfile.ts
  34. 1 0
      types/enum/enums.ts

+ 4 - 0
assets/css/global.scss

@@ -75,6 +75,10 @@ header .v-toolbar__content {
   font-size: 0.9rem;
 }
 
+.text-neutral {
+  color: rgb(var(--v-theme-on-neutral));
+}
+
 h3,
 h4 {
   color: rgb(var(--v-theme-on-neutral-soft));

+ 30 - 20
components/Form/Freemium/Event.vue

@@ -69,26 +69,37 @@
           @update:model-value="getPlace(entity)"
         />
 
-        <div class="d-flex justify-center"
-             v-if="!newPlace"
-        >
-          <v-btn
-            prepend-icon="fa-solid fa-plus"
-            class="mb-6"
-            @click="onAddPlaceClick(entity)"
-          >
-            {{ $t('add_place') }}
-          </v-btn>
-
-          <v-btn
+        <v-row  v-if="!newPlace" class="mb-6 justify-center">
+          <v-col
             v-if="entity.place && !editPlace"
-            prepend-icon="fa-solid fa-plus"
-            class="mb-6 ml-6"
-            @click="onEditPlaceClick(entity)"
+            cols="12"
+            sm="6"
+            class="d-flex justify-center mb-2"
+          >
+              <v-btn
+                prepend-icon="fa-solid fa-pencil"
+                @click="onEditPlaceClick(entity)"
+              >
+                {{ $t('edit_place') }}
+              </v-btn>
+          </v-col>
+
+          <v-col
+            cols="12"
+            sm="6"
+            class="d-flex justify-center mb-2"
           >
-            {{ $t('edit_place') }}
-          </v-btn>
-        </div>
+            <v-btn
+              prepend-icon="fa-solid fa-plus"
+              @click="onAddPlaceClick(entity)"
+            >
+              {{ $t('add_place') }}
+            </v-btn>
+          </v-col>
+
+        </v-row>
+
+
 
         <UiInputText :readonly="!newPlace && !editPlace" v-model="entity.placeName" field="placeName" />
 
@@ -160,8 +171,7 @@
       <template #dialogText>
         <v-card-text class="text">
           <p>
-            Si vous modifiez les informations de ce lieu et que ce lieu est lié à d'autre événements,
-            alors les changements seront répercutés dans tous vos événements liés.
+            {{$t('warning_edit_place')}}
           </p>
         </v-card-text>
       </template>

+ 4 - 3
components/Layout/Header.vue

@@ -27,7 +27,7 @@ Contient entre autres le nom de l'organisation, l'accès à l'aide et aux préf
       :class="smAndUp ? 'mr-3' : ''"
     />
 
-    <LayoutHeaderHomeBtn v-if="smAndUp" />
+    <LayoutHeaderHomeBtn />
 
     <LayoutHeaderMenu
       v-if="isWebsitesMenuNotEmpty"
@@ -94,8 +94,9 @@ const { smAndUp } = useDisplay()
 
 const hasLateralMenu = computed(() => {
   return (
-    (layoutStore.name !== 'parameters' && hasMenu('Main')) ||
-    (layoutStore.name === 'parameters' && hasMenu('Parameters'))
+    layoutStore.name !== 'freemium' &&
+    ((layoutStore.name !== 'parameters' && hasMenu('Main')) ||
+    (layoutStore.name === 'parameters' && hasMenu('Parameters')))
   )
 })
 

+ 4 - 1
components/Layout/Subheader.vue

@@ -15,7 +15,9 @@ Contient entre autres le breadcrumb, les commandes de changement d'année et les
 
       <span class="flex-fill" />
 
-      <v-card class="d-flex flex-row align-center mr-6" :flat="true" tile>
+      <v-card
+        v-if="!organizationProfile.isFreemiumProduct"
+        class="d-flex flex-row align-center mr-6" :flat="true" tile>
         <LayoutSubHeaderActivityYear
           v-if="smAndUp && !showDateTimeRange"
           class="activity-year"
@@ -76,6 +78,7 @@ import { useAccessProfileStore } from '~/stores/accessProfile'
 
 const { smAndUp, lgAndUp } = useDisplay()
 const accessProfile = useAccessProfileStore()
+const organizationProfile = useOrganizationProfileStore()
 const { hasMenu } = useMenu()
 const btn: Ref = ref(null)
 

+ 31 - 9
components/Ui/EventList.vue

@@ -1,6 +1,6 @@
 <template>
   <div>
-    <v-list>
+    <v-list  height="380">
 
       <v-list-item
         class="event-item"
@@ -11,17 +11,17 @@
       >
         <template v-slot:prepend>
           <v-avatar color="grey-lighten-1">
-            <v-img :src="event.image" alt="Image de l'événement" />
+            <UiImage :imageId="event.image" :width="50" />
           </v-avatar>
         </template>
 
         <template v-slot:append>
-          <v-avatar color="primary" @click="$emit('edit', event.id)">
+          <v-avatar  @click="$emit('edit', event.id)">
             <v-icon>fas fa-pencil</v-icon>
           </v-avatar>
 
           <UiButtonDelete :entity="event">
-            <v-avatar color="error">
+            <v-avatar>
               <v-icon>fas fa-trash</v-icon>
             </v-avatar>
           </UiButtonDelete>
@@ -30,11 +30,27 @@
 
     </v-list>
 
-    <div class="d-flex justify-space-between px-4 py-2">
-      <v-btn variant="outlined" @click="$emit('load', pagination.first)" v-if="pagination.first && pagination.previous">Premier</v-btn>
-      <v-btn variant="outlined" @click="$emit('load', pagination.previous)" v-if="pagination.previous">Précédents</v-btn>
-      <v-btn variant="outlined" @click="$emit('load', pagination.next)" v-if="pagination.next">Suivants</v-btn>
-      <v-btn variant="outlined" @click="$emit('load', pagination.last)" v-if="pagination.last != 1">Dernier</v-btn>
+    <div class="d-flex justify-space-between px-4 py-2 pagination mb-5" v-if="events.length > 0">
+      <v-btn variant="flat" class="pagination-btn" @click="$emit('load', pagination.first)" :disabled="!pagination.first || !pagination.previous">
+        <v-avatar>
+        <v-icon>fas fa-angles-left</v-icon>
+      </v-avatar>
+      </v-btn>
+      <v-btn variant="flat" class="pagination-btn" @click="$emit('load', pagination.previous)" :disabled="!pagination.previous">
+        <v-avatar>
+          <v-icon>fas fa-angle-left</v-icon>
+        </v-avatar>
+      </v-btn>
+      <v-btn variant="flat" class="pagination-btn" @click="$emit('load', pagination.next)" :disabled="!pagination.next">
+        <v-avatar>
+          <v-icon>fas fa-angle-right</v-icon>
+        </v-avatar>
+      </v-btn>
+      <v-btn variant="flat" class="pagination-btn" @click="$emit('load', pagination.last)" :disabled="pagination.last == 1">
+        <v-avatar>
+          <v-icon>fas fa-angles-right</v-icon>
+        </v-avatar>
+      </v-btn>
     </div>
   </div>
 </template>
@@ -58,5 +74,11 @@ const date = useDate()
 <style scoped>
 .event-item {
   margin-top: 20px;
+  color: rgb(var(--v-theme-on-primary-alt));
 }
+
+.pagination-btn[disabled] {
+  opacity: 0.3;
+}
+
 </style>

+ 1 - 1
components/Ui/Form.vue

@@ -162,8 +162,8 @@ const props = defineProps({
   },
 })
 
-// ### Définitions
 
+// ### Définitions
 const i18n = useI18n()
 const router = useRouter()
 const { em } = useEntityManager()

+ 10 - 1
components/Ui/Input/Autocomplete/ApiResources.vue

@@ -1,5 +1,13 @@
 <!--
-Champs autocomplete dédié à la recherche des Accesses d'une structure
+Champs autocomplete dédié à la recherche d'une Resource dans l'api
+Exemple : on souhaite lister les lieux d'une structure
+<UiInputAutocompleteApiResources
+          v-model="entity.place"
+          field="place"
+          :model="PlaceSearchItem"
+          listValue="id"
+          listLabel="name"
+        />
 
 @see https://vuetifyjs.com/en/components/autocompletes/#usage
 -->
@@ -41,6 +49,7 @@ 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";
+import PlaceSearchItem from "~/models/Custom/Search/PlaceSearchItem";
 
 const props = defineProps({
   /**

+ 20 - 1
components/Ui/Input/DateTimePicker.vue

@@ -6,6 +6,7 @@
         :label="$t('choose_day')"
         prepend-icon="fas fa-calendar"
         :rules="rules"
+        :variant="variant"
         :error="error || !!fieldViolations"
         :error-messages="
           errorMessage || (fieldViolations ? $t(fieldViolations) : '')
@@ -29,6 +30,7 @@
     <v-col cols="12" md="6">
       <v-text-field
         :model-value="time"
+        :variant="variant"
         :label="$t('choose_hour')"
         prepend-icon="fas fa-clock"
         :rules="rules"
@@ -122,7 +124,24 @@ const props = defineProps({
     type: String,
     required: false,
     default: null,
-  }
+  },
+  /**
+   * @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',
+  },
 })
 
 const showMenuTime = ref(false)

+ 18 - 0
components/Ui/Input/Phone.vue

@@ -9,6 +9,7 @@ Champs de saisie d'un numéro de téléphone
   <v-phone-input
     :model-value.number="modelValue"
     :rules="rules"
+    :variant="variant"
     :label="label || field ? $t(label ?? field) : undefined"
     defaultCountry="FR"
     @update:model-value="onUpdate($event)"
@@ -53,6 +54,23 @@ const props = defineProps({
     required: false,
     default: () => [],
   },
+  /**
+   * @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',
+  },
 })
 
 const emit = defineEmits(['update:modelValue'])

+ 18 - 1
components/Ui/Input/TextArea.vue

@@ -6,7 +6,6 @@ Champs de saisie de bloc texte
 
 <template>
   <v-textarea
-    outlined
     :model-value="modelValue"
     :label="label || field ? $t(label ?? field) : undefined"
     :rules="rules"
@@ -15,6 +14,7 @@ Champs de saisie de bloc texte
     :error-messages="
       errorMessage || (fieldViolations ? $t(fieldViolations) : '')
     "
+    :variant="variant"
     @update:model-value="onUpdate($event)"
     @change="onChange($event)"
   />
@@ -87,6 +87,23 @@ const props = defineProps({
     required: false,
     default: null,
   },
+  /**
+   * @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',
+  },
 })
 
 const { fieldViolations, updateViolationState } = useFieldViolation(props.field)

+ 52 - 7
components/Ui/Input/TreeSelect.vue

@@ -20,9 +20,8 @@ et sélectionner des éléments organisés en catégories et sous-catégories.
     :items="flattenedItems"
     item-title="label"
     item-value="value"
+    :variant="variant"
     multiple
-    chips
-    closable-chips
     :menu-props="{ maxHeight: 400 }"
     @update:menu="onMenuUpdate"
     @update:model-value="$emit('update:modelValue', $event)"
@@ -55,10 +54,8 @@ et sélectionner des éléments organisés en catégories et sous-catégories.
         closable
         @click:close="removeItem(item.raw.value!)"
       >
-        <!-- Always prioritize the mapping for consistent labels, fall back to item label if available -->
         {{
-          selectedItemsMap[item.raw.value] ||
-          (item.raw.label && item.raw.label !== item.raw.value ? item.raw.label : item.raw.value)
+          selectedItemsMap[item.raw] || selectedItemsMap[item.raw.value]
         }}
       </v-chip>
       <span
@@ -155,12 +152,13 @@ et sélectionner des éléments organisés en catégories et sous-catégories.
 <script setup lang="ts">
 import StringUtils from '~/services/utils/stringUtils'
 import _ from 'lodash'
+import type {PropType} from "vue";
 
 interface SelectItem {
   id: string
   label: string
   normalizedLabel?: string
-  value?: string
+  value?: number
   type: 'category' | 'subcategory' | 'item'
   parentId?: string
   level: number
@@ -189,6 +187,23 @@ const props = defineProps({
     required: false,
     default: null,
   },
+  /**
+   * @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',
+  }
 })
 
 const searchInput = ref()
@@ -247,12 +262,42 @@ const expandedCategories: Ref<Set<string>> = ref(new Set())
 const expandedSubcategories: Ref<Set<string>> = ref(new Set())
 const searchText: Ref<string> = ref('')
 
+/**
+ * Expands all parent categories and subcategories of selected items.
+ */
+const expandParentsOfSelectedItems = () => {
+  expandedCategories.value.clear()
+  expandedSubcategories.value.clear()
+
+  for (const selectedId of props.modelValue) {
+    const item = normalizedItems.value.find(i => i.value === selectedId)
+    if (!item) continue
+
+    // Trouver la sous-catégorie
+    const subcategory = normalizedItems.value.find(i => i.id === item.parentId)
+    if (subcategory) {
+      expandedSubcategories.value.add(subcategory.id)
+
+      // Trouver la catégorie
+      if (subcategory.parentId) {
+        const category = normalizedItems.value.find(i => i.id === subcategory.parentId)
+        if (category) {
+          expandedCategories.value.add(category.id)
+        }
+      }
+    }
+  }
+}
+
 /**
  * A callback function that is triggered when the menu's open state is updated.
  */
 const onMenuUpdate = (isOpen: boolean) => {
+  if (isOpen) {
+    expandParentsOfSelectedItems()
+  }
   // Réinitialiser la recherche quand le menu se ferme
-  if (!isOpen && searchText.value) {
+  else if (searchText.value) {
     searchText.value = ''
     onSearchInput()
   }

+ 5 - 3
components/Ui/Input/TreeSelect/EventCategories.vue

@@ -6,6 +6,7 @@
     v-bind="$attrs"
     :loading="status === FETCHING_STATUS.PENDING"
     @update:model-value="$emit('update:modelValue', $event)"
+    :max-visible-chips="6"
   />
 </template>
 
@@ -14,7 +15,7 @@ import { useEntityFetch } from '~/composables/data/useEntityFetch'
 import EventCategory from '~/models/Core/EventCategory'
 import {FETCHING_STATUS} from "~/types/enum/data";
 
-defineProps({
+const props = defineProps({
   modelValue: {
     type: Array as PropType<string[]>,
     required: true,
@@ -30,6 +31,7 @@ defineProps({
   },
 })
 
+
 const i18n = useI18n()
 
 const emit = defineEmits(['update:modelValue'])
@@ -113,13 +115,12 @@ const hierarchicalItems = computed(() => {
     )
       return
 
-    const familyKey = category.famillyLabel
     const subfamilyKey = `${category.famillyLabel}-${category.subfamillyLabel}`
 
     genders.push({
       id: `gender-${category.id}`,
       label: i18n.t(category.genderLabel),
-      value: category.id.toString(),
+      value: category.id,
       type: 'item',
       parentId: `subfamily-${subfamilyKey}`,
       level: 2,
@@ -136,6 +137,7 @@ const hierarchicalItems = computed(() => {
   return result
 })
 
+
 // Nettoyer les données lors du démontage du composant
 onBeforeUnmount(() => {
   // Nettoyer les références du store si nécessaire

+ 1 - 1
components/Ui/LoadingPanel.client.vue

@@ -1,6 +1,6 @@
 <template>
   <v-row class="fill-height ma-0" align="center" justify="center">
-    <v-progress-circular :indeterminate="true" color="neutral" />
+    <v-progress-circular :indeterminate="true" color="on-neutral" />
   </v-row>
 </template>
 

+ 4 - 3
components/Ui/MapLeaflet.client.vue

@@ -32,6 +32,7 @@
     >
       {{$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']}}
@@ -102,18 +103,18 @@ const props = defineProps({
   },
 })
 
-const FRANCE_LATTITUDE = 46.603354
+const FRANCE_LATITUDE = 46.603354
 const FRANCE_LONGITUDE = 1.888334
 const {apiRequestService, pending} = useAp2iRequestService()
 const { em } = useEntityManager()
 
 const position:ComputedRef<PointTuple> = computed(()=>{
-  return [props.latitude || FRANCE_LATTITUDE, props.longitude || FRANCE_LONGITUDE]
+  return [props.latitude || FRANCE_LATITUDE, props.longitude || FRANCE_LONGITUDE]
 })
 
 const zoom = computed({
   get() {
-    return props.latitude && props.latitude != FRANCE_LATTITUDE ? 12 : 5
+    return props.latitude && props.latitude != FRANCE_LATITUDE ? 12 : 5
   },
   set(newValue: string) {
     zoom.value = newValue

+ 0 - 76
composables/useVuetifyValidation.ts

@@ -1,76 +0,0 @@
-// composables/useVuetifyValidation.ts
-import { computed, ref, inject, onMounted, onUnmounted } from 'vue'
-
-export function useVuetifyValidation(
-  value: Ref<any>,
-  rules: Ref<Array<(value: any) => boolean | string>>
-) {
-  const hasBeenTouched = ref(false)
-  const forceValidation = ref(false)
-  const form = inject<any>('form', null)
-
-  const errorMessages = computed(() => {
-    if (!rules.value.length) return []
-
-    const errors: string[] = []
-    for (const rule of rules.value) {
-      const result = rule(value.value)
-      if (result !== true) {
-        errors.push(typeof result === 'string' ? result : 'Erreur de validation')
-      }
-    }
-    return errors
-  })
-
-  const hasError = computed(() => {
-    if (!hasBeenTouched.value && !forceValidation.value) return false
-    return errorMessages.value.length > 0
-  })
-
-  const isValid = computed(() => errorMessages.value.length === 0)
-
-  const validationState = {
-    id: Math.random().toString(36).substr(2, 9),
-    isValid,
-    errorMessages,
-    validate: () => {
-      forceValidation.value = true
-      return isValid.value
-    },
-    reset: () => {
-      hasBeenTouched.value = false
-      forceValidation.value = false
-    },
-    resetValidation: () => {
-      forceValidation.value = false
-    }
-  }
-
-  const touch = () => {
-    hasBeenTouched.value = true
-  }
-
-  const validate = () => {
-    forceValidation.value = true
-  }
-
-  onMounted(() => {
-    if (form?.register) {
-      form.register(validationState)
-    }
-  })
-
-  onUnmounted(() => {
-    if (form?.unregister) {
-      form.unregister(validationState.id)
-    }
-  })
-
-  return {
-    hasError,
-    errorMessages,
-    isValid,
-    touch,
-    validate
-  }
-}

+ 19 - 2
i18n/lang/fr/general.json

@@ -1,4 +1,19 @@
 {
+  "warning_edit_place": "Si vous modifiez les informations de ce lieu et que ce lieu est lié à d'autre événements,alors les changements seront répercutés dans tous vos événements liés.",
+  "agenda_def": "Pour la promotion de votre structure et de vos événements",
+  "manager_def": "Pour les fédérations, confédérations et institutions publiques",
+  "school_def": "Pour tous les établissements d’enseignement artistique",
+  "artist_def": "Pour les orchestres, les chorales, compagnies et troupes artistiques",
+  "our_solution": "DÉCOUVREZ NOS SOLUTIONS",
+  "no_future_event": "Vous n'avez aucun événement à venir",
+  "no_past_event": "Vous n'avez aucun événement passé",
+  "futur_event": "Mes événements à venir",
+  "past_event": "Mes événements passés",
+  "add_event": "Ajouter un événement",
+  "my_organization": "Ma structure",
+  "edit_organization": "Modifier la structure",
+  "dashboard_breadcrumbs": "Tableau de bord",
+  "freemium_breadcrumbs": "Freemium",
   "i_understand": "Je comprends",
   "place_change_everywhere": "Les changements apportés seront appliqués aux autres événements",
   "event_categories_choices": "Choisissez à quelles catégories appartient votre événement",
@@ -24,14 +39,14 @@
   "datetimeEnd": "Date et heure de fin",
   "need_to_be_integer": "Doit être un nombre entier",
   "freemium_event_create_page": "Créer un nouvel événement",
-  "search_gps_button": "Mettre à jour les coordonnées géographique.",
+  "search_gps_button": "Situer sur la carte",
   "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_profile_page": "Mon profil",
   "freemium_dashboard_page": "Bienvenue sur votre compte Opentalent",
   "freemium_page": "Freemium",
   "showing": "Affichage",
@@ -45,6 +60,8 @@
   "trial_all_ready_did": "Vous avez déjà bénéficié d’un essai gratuit !",
   "opentalent_contact": "Contacter Opentalent",
   "discover_offer": "Découvrir toutes les offres",
+  "try_premium_light": "Essayez Opentalent Artist Premium",
+  "30_days_free": " gratuitement pendant 30J",
   "try_premium": "Essayez Opentalent Artist Premium gratuitement pendant 30J",
   "opentalent_options": "Les options Opentalent",
   "opentalent_offers": "Les offres Opentalent",

+ 93 - 9
layouts/freemium.vue

@@ -1,9 +1,9 @@
 <template>
-  <div>
+  <div class="layout-wrapper">
     <!-- Show the loading page -->
     <client-only placeholder-tag="client-only-placeholder" placeholder=" " />
 
-    <v-app>
+    <v-app class="app-wrapper">
       <LayoutLoadingScreen />
 
       <LayoutHeader />
@@ -11,21 +11,86 @@
       <v-main class="main">
 
         <!-- Page will be rendered here-->
-        <div class="inner-container">
-          <h3>{{ pageTitle }}</h3>
+        <div>
+          <LayoutSubheader />
+
+          <LayoutAlertBar />
 
           <slot />
         </div>
       </v-main>
 
+      <!-- Footer -->
+      <v-footer class="white--text footer theme-secondary">
+
+          <v-row v-if="smAndUp">
+            <v-col cols="12" sm="3">
+              <p class="text-h6 font-weight-bold text-center">{{$t('DÉCOUVREZ NOS SOLUTIONS')}}</p>
+            </v-col>
+            <v-col cols="12" sm="2" class="text-center text-product">
+              <a href="https://logiciels.opentalent.fr/opentalent-artist" target="_blank">
+                <img src="/images/Opentalent_Artist-Blanc.png" height="70" class="mb-2" />
+                <p>{{$t('artist_def')}}</p>
+              </a>
+            </v-col>
+            <v-col cols="12" sm="2" class="text-center text-product">
+              <a href="https://logiciels.opentalent.fr/opentalent-school" target="_blank">
+                <img src="/images/Opentalent_School-Blanc.png" height="70" class="mb-2" />
+                <p>{{$t('school_def')}}</p>
+              </a>
+            </v-col>
+            <v-col cols="12" sm="2" class="text-center text-product">
+              <a href="https://logiciels.opentalent.fr/opentalent-manager" target="_blank">
+                <img src="/images/Opentalent_Manager-Blanc.png" height="70" class="mb-2" />
+                <p>{{$t('manager_def')}}</p>
+              </a>
+            </v-col>
+            <v-col cols="12" sm="2" class="text-center text-product">
+              <a href="https://opentalent.fr/" target="_blank">
+                <img src="/images/OT_Logo_Agenda.png" height="70" class="mb-2" />
+                <p>{{$t('agenda_def')}}</p>
+              </a>
+            </v-col>
+          </v-row>
+
+          <v-row v-else>
+            <v-col cols="12">
+              <p class="text-h6 font-weight-bold text-center">{{$t('DÉCOUVREZ NOS SOLUTIONS')}}</p>
+            </v-col>
+            <v-col cols="3" class="text-center text-product">
+              <a href="https://logiciels.opentalent.fr/opentalent-artist" target="_blank">
+                <img src="/images/Opentalent_Artist_Griffe.png" height="70" class="mb-2" />
+              </a>
+            </v-col>
+            <v-col cols="3" class="text-center text-product">
+              <a href="https://logiciels.opentalent.fr/opentalent-school" target="_blank">
+                <img src="/images/Opentalent_School_Griffe.png" height="70" class="mb-2" />
+              </a>
+            </v-col>
+            <v-col cols="3" class="text-center text-product">
+              <a href="https://logiciels.opentalent.fr/opentalent-manager" target="_blank">
+                <img src="/images/Opentalent_Manager_Griffe.png" height="70" class="mb-2" />
+              </a>
+            </v-col>
+            <v-col cols="3" class="text-center text-product">
+              <a href="https://opentalent.fr" target="_blank">
+                <img src="/images/Opentalent_Griffe.png" height="70" class="mb-2" />
+              </a>
+            </v-col>
+          </v-row>
+      </v-footer>
+
       <LazyLayoutAlertContainer />
+
     </v-app>
   </div>
 </template>
 
 <script setup lang="ts">
 import { useLayoutStore } from '~/stores/layout'
+import {useDisplay} from "vuetify";
 
+const { smAndUp, sm } = useDisplay()
 const layoutStore = useLayoutStore()
 layoutStore.name = 'freemium'
 
@@ -36,12 +101,31 @@ const pageTitle = computed(() => i18n.t(route.name || 'freemium_page'))
 </script>
 
 <style scoped lang="scss">
-.inner-container {
-  max-width: 1200px;
-  margin: 0 auto;
+.layout-wrapper,
+.app-wrapper {
+  display: flex;
+  flex-direction: column;
+  min-height: 100vh;
+}
+
+.main {
+  flex: 1 0 auto;  /* Prend tout l’espace restant */
+}
+
+.v-footer {
+  padding-left: 50px;
+  padding-right: 50px;
+  padding-top: 20px;
+  padding-bottom: 20px;
+  flex: 0 0 auto !important;
+
+  .text-product{
+    font-size: 13px;
+  }
 
-  h3 {
-    margin: 36px 0 18px 2%;
+  a{
+    color: white;
+    text-decoration: none;
   }
 }
 </style>

+ 125 - 19
pages/freemium/dashboard.vue

@@ -1,16 +1,20 @@
 <template>
-  <v-container fluid>
+  <v-container fluid class="inner-container">
     <v-row>
       <!-- Bloc événements -->
-      <v-col cols="12" md="8">
+      <v-col cols="12" md="7">
         <v-card>
-          <v-tabs v-model="tab">
-            <v-tab value="future">Mes événements à venir</v-tab>
-            <v-tab value="past">Mes événements passés</v-tab>
+          <v-tabs v-model="tab" class="tabs-title">
+            <v-tab value="future">{{$t('futur_event')}}</v-tab>
+            <v-tab value="past">{{$t('past_event')}}</v-tab>
           </v-tabs>
 
+          <v-btn color="primary" to="events/new" class="ml-5 mt-5">{{$t('add_event')}}</v-btn>
+
           <v-tabs-window v-model="tab">
             <v-tabs-window-item value="future">
+              <UiLoadingPanel v-if="statusUpcomingEvents == FETCHING_STATUS.PENDING" />
+
               <UiEventList
                 v-if="statusUpcomingEvents == FETCHING_STATUS.SUCCESS && upcomingEvents?.items"
                 :events="upcomingEvents.items"
@@ -18,8 +22,13 @@
                 @load="loadUpcomingEvents"
                 @edit="editEvent"
               />
+              <span v-if="upcomingEvents.items.length == 0" class="no_event">
+                {{$t('no_future_event')}}
+              </span>
             </v-tabs-window-item>
             <v-tabs-window-item value="past">
+              <UiLoadingPanel v-if="statusPastEvents == FETCHING_STATUS.PENDING" />
+
               <UiEventList
                 v-if="statusPastEvents == FETCHING_STATUS.SUCCESS && pastEvents?.items"
                 :events="pastEvents.items"
@@ -27,30 +36,46 @@
                 @load="loadPastEvents"
                 @edit="editEvent"
               />
+              <span v-if="pastEvents.items.length == 0" class="no_event">
+                {{$t('no_past_event')}}
+              </span>
             </v-tabs-window-item>
           </v-tabs-window>
 
-          <v-card-actions>
-            <v-btn color="primary" to="events/new">Ajouter un événement</v-btn>
-          </v-card-actions>
         </v-card>
       </v-col>
 
       <!-- Bloc structure -->
-      <v-col cols="12" md="4">
-        <v-card v-if="statusOrganization == FETCHING_STATUS.SUCCESS">
-          <v-card-title>Structure</v-card-title>
+      <v-col cols="12" md="5">
+        <v-card v-if="statusOrganization == FETCHING_STATUS.SUCCESS" class="pa-5">
+          <v-card-title class="text-h6" >
+            <v-icon icon="fa fa-hotel" class="text-button icon-hotel"  />
+            <span class="organization_title">{{$t('my_organization')}}</span>
+          </v-card-title>
           <v-card-text>
-            <div><strong>Nom:</strong> {{ organization?.name }}</div>
-            <div><strong>Email:</strong> {{ organization?.email }}</div>
+            <div><strong>{{$t('name')}} :</strong> {{ organization?.name }}</div>
+            <div><strong>{{$t('email')}} :</strong> {{ organization?.email }}</div>
           </v-card-text>
-          <v-card-actions>
-            <v-btn color="secondary" to="organization">Modifier la structure</v-btn>
-          </v-card-actions>
         </v-card>
+
+        <v-btn block class="mb-2 btn btn_edit_orga" to="organization">
+          <i class="fa fa-pen mr-2" />{{$t('edit_organization')}}
+        </v-btn>
+
+        <v-btn block class="text-black btn btn_trial" @click="startTrial">
+          <span><v-icon icon="fa fa-ticket" /> {{$t('try_premium_light')}}<br /> {{$t('30_days_free')}}</span>
+        </v-btn>
+
       </v-col>
     </v-row>
+
   </v-container>
+
+  <LayoutDialogTrialAlreadyDid
+    :show="showDialogTrialAlReadyDid"
+    @close-dialog="showDialogTrialAlReadyDid = false"
+  />
+
 </template>
 
 <script setup lang="ts">
@@ -61,7 +86,7 @@ definePageMeta({
   name: 'freemium_dashboard_page',
 })
 
-import { ref } from 'vue'
+import {type Ref, ref} from 'vue'
 import {useEntityFetch} from "~/composables/data/useEntityFetch";
 import Organization from "~/models/Freemium/Organization";
 import Event from "~/models/Freemium/Event";
@@ -73,12 +98,18 @@ import TimeFilter from "~/services/data/Filters/TimeFilter";
 import Country from "~/models/Core/Country";
 import DateUtils from "~/services/utils/dateUtils";
 import UrlUtils from "~/services/utils/urlUtils";
+import {useApiLegacyRequestService} from "~/composables/data/useApiLegacyRequestService";
+import {useAdminUrl} from "~/composables/utils/useAdminUrl";
 
 //Ref Définition
-const tab = ref(null)
+const runtimeConfig = useRuntimeConfig()
 const { fetch, fetchCollection } = useEntityFetch()
+const { apiRequestService } = useApiLegacyRequestService()
+const {makeAdminUrl} = useAdminUrl()
+const tab = ref(null)
 const upcomingPage = ref(1)
 const pastPage = ref(1)
+const showDialogTrialAlReadyDid: Ref<boolean> = ref(false)
 
 //Fetch
 const { data: organization, status:statusOrganization } = fetch(Organization)
@@ -127,6 +158,20 @@ function fetchEvents(past:boolean = false){
   return fetchCollection(Event, null, query)
 }
 
+/**
+ * Action lorsque l'on souhaite démarrer l'essai
+ */
+async function startTrial() {
+  try {
+    await apiRequestService.get('/trial/is_available')
+    await navigateTo(makeAdminUrl('trial'), {
+      external: true,
+    })
+  } catch (error) {
+    showDialogTrialAlReadyDid.value = true
+  }
+}
+
 /**
  * Nettoyage du store
  */
@@ -138,8 +183,69 @@ onUnmounted(() => {
 
 </script>
 
-<style scoped>
+<style scoped lang="scss">
+
+.tabs-title{
+  margin-top: 20px;
+  padding-left: 20px;
+  background-color: rgb(var(--v-theme-neutral));
+  .v-tab--selected{
+    color: rgb(var(--v-theme-on-neutral--clickable));
+  }
+}
+
+
 .v-card {
   margin-bottom: 16px;
+  color: rgb(var(--v-theme-on-primary-alt));
+}
+
+.v-card-text{
+  div{
+    line-height: 2;
+  }
+}
+
+.organization_title{
+  font-weight: 500;
+}
+
+.icon-hotel{
+  margin: 0 5px 4px 0;
+}
+
+.btn {
+  border: 1px solid;
+  cursor: pointer;
+}
+
+.inner-container {
+  margin: 0 auto;
+  padding: 30px;
+}
+.btn_trial {
+  height: 55px;
+  background-color: rgb(var(--v-theme-x-create-btn));
+  color: #000;
+
+  span {
+    text-align: center;
+    line-height: 1.2; /* optionnel : pour resserrer ou espacer */
+  }
+  .v-icon {
+    transform: rotate(135deg); /* angle en degrés */
+    font-size: 16px;
+    padding-right: 5px;
+    margin: 0 5px 4px 0;
+  }
+}
+
+.btn_edit_orga{
+  color: rgb(var(--v-theme-on-primary-alt)) !important;
+}
+
+.no_event{
+  padding: 25px;
+  font-size: 16px;
 }
 </style>

+ 9 - 0
pages/freemium/events/[id].vue

@@ -1,5 +1,6 @@
 <template>
   <UiFormEdition
+    class="inner-container"
     :model="Event"
     go-back-route="/freemium/dashboard"
   >
@@ -17,3 +18,11 @@ definePageMeta({
 })
 
 </script>
+
+<style scoped lang="scss">
+
+.inner-container {
+  max-width: 1200px;
+}
+
+</style>

+ 9 - 0
pages/freemium/events/new.vue

@@ -1,5 +1,6 @@
 <template>
   <UiFormCreation
+    class="inner-container"
     :model="Event"
     go-back-route="/freemium/dashboard"
   >
@@ -17,3 +18,11 @@ definePageMeta({
 })
 
 </script>
+
+<style scoped lang="scss">
+
+.inner-container {
+  max-width: 1200px;
+}
+
+</style>

+ 11 - 1
pages/freemium/organization.vue

@@ -1,5 +1,7 @@
 <template>
-      <UiFormEdition :model="Organization">
+      <UiFormEdition :model="Organization"
+                     class="inner-container"
+      >
         <template #default="{ entity : organization }">
           <div v-if="organization">
             <LayoutCommonSection>
@@ -121,3 +123,11 @@ onUnmounted(() => {
 const getAsserts = (key) => getAssertUtils(Organization.getAsserts(), key)
 
 </script>
+
+<style scoped lang="scss">
+
+.inner-container {
+  max-width: 1200px;
+}
+
+</style>

+ 1 - 1
pages/subscription.vue

@@ -476,7 +476,6 @@ Page 'Mon abonnement'
 import { useAbility } from '@casl/vue'
 import type { Ref } from 'vue'
 import { useDisplay } from 'vuetify'
-import type { AsyncData } from '#app'
 import { useOrganizationProfileStore } from '~/stores/organizationProfile'
 import { useEntityFetch } from '~/composables/data/useEntityFetch'
 import DolibarrAccount from '~/models/Organization/DolibarrAccount'
@@ -486,6 +485,7 @@ import { useApiLegacyRequestService } from '~/composables/data/useApiLegacyReque
 import { usePageStore } from '~/stores/page'
 import { DOLIBARR_BILLING_DOC_TYPE } from '~/types/enum/enums'
 import LayoutMobytStatus from '~/components/Layout/MobytStatus.vue'
+import {FETCHING_STATUS} from "~/types/enum/data";
 
 // meta
 definePageMeta({

二進制
public/images/OT_Logo_Agenda.png


二進制
public/images/Opentalent_Artist-Blanc.png


二進制
public/images/Opentalent_Artist_Griffe.png


二進制
public/images/Opentalent_Griffe.png


二進制
public/images/Opentalent_Manager-Blanc.png


二進制
public/images/Opentalent_Manager.png


二進制
public/images/Opentalent_Manager_Griffe.png


二進制
public/images/Opentalent_School-Blanc.png


二進制
public/images/Opentalent_School_Griffe.png


+ 1 - 1
services/asserts/TypeAssert.ts

@@ -18,7 +18,7 @@ export class TypeAssert implements AssertRule {
 
     if (criteria === 'integer') {
       return (value: any) =>
-        parseInt(value) > 0 || t('need_to_be_integer');
+        Number.isInteger(value) || t('need_to_be_integer');
     }
 
     return () => true;

+ 9 - 0
stores/organizationProfile.ts

@@ -128,6 +128,14 @@ export const useOrganizationProfileStore = defineStore(
       return product.value === PRODUCT.MANAGER
     })
 
+    /**
+     * L'organization possède-t-elle un produit freemium
+     * @return {boolean}
+     */
+    const isFreemiumProduct = computed((): boolean => {
+      return product.value === PRODUCT.FREEMIUM
+    })
+
     /**
      * L'organization peut-elle afficher la lister des adhérents avec leurs coordonnées
      * @return {boolean|null}
@@ -230,6 +238,7 @@ export const useOrganizationProfileStore = defineStore(
       isSchoolProduct,
       isSchool,
       isManagerProduct,
+      isFreemiumProduct,
       isShowAdherentList,
       isAssociation,
       isTrialActive,

+ 1 - 0
types/enum/enums.ts

@@ -4,6 +4,7 @@ export const enum PRODUCT {
   ARTIST = 'artist',
   ARTIST_PREMIUM = 'artist-premium',
   MANAGER = 'manager',
+  FREEMIUM = 'freemium',
 }
 
 export const enum NETWORK {