Przeglądaj źródła

feat: ✨ feat (notificacoes) refatorada exibicao das notificacoes

foi refatorada a exibicao das notificacoes para ser responsivo, indo de 1 a 4 por linha conforme largura da tela. Adicionado excerpts nos textos para nao quebrar layout

fase:dev | origin:escopo
Gustavo Zanatta 1 dzień temu
rodzic
commit
cdc3970cc3

+ 192 - 0
src/components/NotificationCard.vue

@@ -0,0 +1,192 @@
+<template>
+  <q-card
+    flat
+    bordered
+    class="notification-card"
+    :class="{ 'notification-card--unread': unread === true, 'notification-card--clickable': clickable }"
+    @click="handleClick"
+  >
+    <q-badge
+      v-if="unread === true"
+      color="violet-normal"
+      rounded
+      class="notification-card__badge"
+    />
+
+    <div class="notification-card__image">
+      <img
+        v-if="imgSrc && !imgError"
+        :src="imgSrc"
+        alt=""
+        class="notification-card__img"
+        @error="imgError = true"
+      />
+      <div v-else class="notification-card__img-placeholder flex flex-center">
+        <q-icon name="mdi-bell-outline" size="32px" :color="unread === false ? 'grey-4' : 'violet-normal'" />
+      </div>
+    </div>
+
+    <q-card-section class="notification-card__body q-pt-sm q-pb-xs">
+      <div
+        class="notification-card__title"
+        :class="{ 'notification-card__title--read': unread === false }"
+      >
+        {{ notification.title }}
+      </div>
+      <div class="notification-card__message">
+        {{ notification.message }}
+      </div>
+    </q-card-section>
+
+    <q-card-actions class="notification-card__actions q-pt-xs q-pb-sm q-px-md flex justify-between items-center">
+      <div class="notification-card__date">
+        {{ formattedDate }}
+      </div>
+      <q-icon
+        v-if="showReadIcon && unread === false"
+        name="mdi-check-circle"
+        color="positive"
+        size="18px"
+      />
+    </q-card-actions>
+  </q-card>
+</template>
+
+<script setup>
+import { ref, computed } from "vue";
+import { useI18n } from "vue-i18n";
+import { resolveNotificationImageUrl, formatNotificationDate } from "src/helpers/notification";
+
+const { t } = useI18n();
+
+const props = defineProps({
+  notification: { type: Object, required: true },
+  unread: { type: Boolean, default: null },
+  clickable: { type: Boolean, default: true },
+  showReadIcon: { type: Boolean, default: false },
+  date: { type: String, default: null },
+});
+
+const emit = defineEmits(["click"]);
+
+const imgError = ref(false);
+const imgSrc = computed(() => resolveNotificationImageUrl(props.notification));
+const formattedDate = computed(() =>
+  formatNotificationDate(props.date ?? props.notification.created_at, t("common.terms.today"))
+);
+
+const handleClick = () => {
+  if (props.clickable) emit("click");
+};
+</script>
+
+<style scoped lang="scss">
+@use "src/css/quasar.variables.scss" as vars;
+
+.notification-card {
+  position: relative;
+  border-radius: 10px;
+  overflow: hidden;
+  background: white;
+  min-width: 0;
+  max-width: 100%;
+
+  &__badge {
+    position: absolute;
+    top: 10px;
+    right: 10px;
+    z-index: 1;
+  }
+
+  &--clickable {
+    cursor: pointer;
+    transition: box-shadow 0.2s, transform 0.15s;
+
+    &:hover {
+      box-shadow: 0 4px 14px rgba(102, 29, 117, 0.14);
+      transform: translateY(-2px);
+    }
+
+    &:active {
+      opacity: 0.9;
+    }
+  }
+
+  &--unread {
+    border-top: 3px solid vars.$violet-normal;
+  }
+
+  &__image {
+    width: 100%;
+    height: 84px;
+    overflow: hidden;
+    background: vars.$violet-light;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+
+  &__img {
+    width: 100%;
+    height: 100%;
+    object-fit: contain;
+    display: block;
+  }
+
+  &__img-placeholder {
+    width: 100%;
+    height: 100%;
+  }
+
+  &__body {
+    min-width: 0;
+    overflow: hidden;
+  }
+
+  &__actions {
+    gap: 8px;
+    min-width: 0;
+  }
+
+  // `nowrap` faria o título ter largura mínima igual ao texto inteiro e empurrar
+  // o card para fora da tela; o clamp trunca em 2 linhas sem pressionar a largura.
+  &__title {
+    max-width: 100%;
+    font-size: 15px;
+    font-weight: 600;
+    color: vars.$violet-normal;
+    line-height: 1.3;
+    overflow-wrap: anywhere;
+    word-break: break-word;
+    display: -webkit-box;
+    -webkit-line-clamp: 2;
+    line-clamp: 2;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
+
+    &--read {
+      color: vars.$violet-dark;
+    }
+  }
+
+  &__message {
+    max-width: 100%;
+    font-size: 13px;
+    color: vars.$color-text-2;
+    line-height: 1.45;
+    overflow-wrap: anywhere;
+    word-break: break-word;
+    display: -webkit-box;
+    -webkit-line-clamp: 2;
+    line-clamp: 2;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
+  }
+
+  &__date {
+    font-size: 11px;
+    color: vars.$violet-normal;
+    opacity: 0.75;
+  }
+}
+</style>

+ 16 - 23
src/components/NotificationDetailDialog.vue

@@ -3,7 +3,7 @@
     <q-card class="notif-detail-card">
 
       <q-card-section class="row items-center q-pb-none">
-        <div class="text-h6 text-violet-normal ellipsis" style="max-width: calc(100% - 48px)">
+        <div class="text-h6 text-violet-normal notif-detail-card__title" style="max-width: calc(100% - 48px)">
           {{ item.notification?.title }}
         </div>
         <q-space />
@@ -13,8 +13,8 @@
       <q-separator class="q-mt-sm" />
 
       <q-card-section class="q-pt-md notif-detail-card__scroll">
-        <div v-if="imageUrl" class="notif-detail-card__image q-mb-md">
-          <img :src="imageUrl" alt="" class="notif-detail-card__img" />
+        <div v-if="imageUrl && !imageError" class="notif-detail-card__image q-mb-md">
+          <img :src="imageUrl" alt="" class="notif-detail-card__img" @error="imageError = true" />
         </div>
 
         <div class="text-body2 text-grey-8 notif-detail-card__message">
@@ -46,6 +46,7 @@ import { ref, computed, onMounted } from "vue";
 import { useI18n } from "vue-i18n";
 import { useDialogPluginComponent } from "quasar";
 import { markNotificationAsReadAssociado } from "src/api/notification";
+import { resolveNotificationImageUrl, formatNotificationDate } from "src/helpers/notification";
 
 const props = defineProps({
   item: { type: Object, required: true },
@@ -57,26 +58,11 @@ const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
 const { t } = useI18n();
 
 const markedReadId = ref(null);
+const imageError = ref(false);
 
-const imageUrl = computed(() => {
-  const media = props.item.notification?.media?.[0]?.url;
-  if (media) return media;
-  const direct = props.item.notification?.image_url;
-  if (!direct) return null;
-  return direct.startsWith("http") ? direct : (process.env.API_URL ?? "") + direct;
-});
+const imageUrl = computed(() => resolveNotificationImageUrl(props.item.notification));
 
-const formatDate = (dateStr) => {
-  if (!dateStr) return "";
-  const d = new Date(dateStr);
-  const today = new Date();
-  const isToday =
-    d.getDate() === today.getDate() &&
-    d.getMonth() === today.getMonth() &&
-    d.getFullYear() === today.getFullYear();
-  if (isToday) return t("common.terms.today");
-  return d.toLocaleDateString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric" });
-};
+const formatDate = (dateStr) => formatNotificationDate(dateStr, t("common.terms.today"));
 
 const handleClose = () => {
   onDialogOK(markedReadId.value);
@@ -96,7 +82,7 @@ onMounted(async () => {
 <style scoped lang="scss">
 .notif-detail-card {
   width: 90vw;
-  max-width: 520px;
+  max-width: 600px;
   min-width: 300px;
 
   &__scroll {
@@ -104,6 +90,12 @@ onMounted(async () => {
     overflow-y: auto;
   }
 
+  &__title {
+    min-width: 0;
+    white-space: normal;
+    word-break: break-word;
+  }
+
   &__image {
     width: 100%;
     border-radius: 8px;
@@ -116,7 +108,7 @@ onMounted(async () => {
 
   &__img {
     width: 100%;
-    max-height: 300px;
+    max-height: 400px;
     object-fit: contain;
     display: block;
   }
@@ -124,6 +116,7 @@ onMounted(async () => {
   &__message {
     white-space: pre-wrap;
     word-break: break-word;
+    overflow-wrap: anywhere;
     line-height: 1.6;
   }
 }

+ 10 - 121
src/components/UnreadNotificationsDialog.vue

@@ -14,46 +14,16 @@
           <q-spinner color="violet-normal" size="48px" />
         </div>
 
-        <div v-else class="notif-list">
-          <div
+        <div v-else class="notifications-grid">
+          <NotificationCard
             v-for="item in notifications"
             :key="item.id"
-            class="notif-item"
-            :class="{ 'notif-item--unread': !item.read }"
+            :notification="item.notification"
+            :unread="!item.read"
+            :date="item.created_at"
+            show-read-icon
             @click="openDetail(item)"
-          >
-            <div class="notif-item__icon">
-              <q-icon
-                name="mdi-bell-outline"
-                size="24px"
-                :color="item.read ? 'grey-4' : 'violet-normal'"
-              />
-            </div>
-
-            <div class="notif-item__content">
-              <div class="notif-item__title" :class="{ 'notif-item__title--read': item.read }">
-                {{ item.notification?.title }}
-              </div>
-              <div class="notif-item__message">{{ item.notification?.message }}</div>
-              <div class="notif-item__date">{{ formatDate(item.created_at) }}</div>
-            </div>
-
-            <q-icon
-              v-if="item.read"
-              name="mdi-check-circle"
-              color="positive"
-              size="20px"
-              class="q-ml-xs"
-              style="flex-shrink: 0"
-            />
-            <q-badge
-              v-else
-              color="violet-normal"
-              rounded
-              class="q-ml-xs"
-              style="flex-shrink: 0; width: 8px; height: 8px; min-height: unset; padding: 0"
-            />
-          </div>
+          />
         </div>
       </q-card-section>
 
@@ -79,14 +49,13 @@
 <script setup>
 import { ref, computed } from "vue";
 import { useDialogPluginComponent, useQuasar } from "quasar";
-import { useI18n } from "vue-i18n";
 import { getMyUnreadNotificationsAssociado } from "src/api/notification";
 import NotificationDetailDialog from "src/components/NotificationDetailDialog.vue";
+import NotificationCard from "src/components/NotificationCard.vue";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
 const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
-const { t } = useI18n();
 const $q = useQuasar();
 
 const loading = ref(false);
@@ -94,18 +63,6 @@ const notifications = ref([]);
 
 const localUnreadCount = computed(() => notifications.value.filter((n) => !n.read).length);
 
-const formatDate = (dateStr) => {
-  if (!dateStr) return "";
-  const d = new Date(dateStr);
-  const today = new Date();
-  const isToday =
-    d.getDate() === today.getDate() &&
-    d.getMonth() === today.getMonth() &&
-    d.getFullYear() === today.getFullYear();
-  if (isToday) return t("common.terms.today");
-  return d.toLocaleDateString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric" });
-};
-
 const openDetail = (item) => {
   $q.dialog({ component: NotificationDetailDialog, componentProps: { item } }).onOk((markedId) => {
     if (markedId) {
@@ -130,82 +87,14 @@ fetchUnread();
 </script>
 
 <style scoped lang="scss">
-@use "src/css/quasar.variables.scss" as vars;
-
 .unread-dialog-card {
   width: 90vw;
-  max-width: 480px;
+  max-width: 900px;
   min-width: 300px;
 
   &__scroll {
-    max-height: 55vh;
+    max-height: 60vh;
     overflow-y: auto;
   }
 }
-
-.notif-list {
-  display: flex;
-  flex-direction: column;
-  gap: 8px;
-}
-
-.notif-item {
-  background: vars.$violet-light;
-  border-radius: 10px;
-  padding: 12px;
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  gap: 10px;
-  cursor: pointer;
-  transition: opacity 0.15s;
-
-  &:active {
-    opacity: 0.75;
-  }
-
-  &--unread {
-    border-left: 3px solid vars.$violet-normal;
-  }
-
-  &__icon {
-    flex-shrink: 0;
-  }
-
-  &__content {
-    flex: 1;
-    min-width: 0;
-  }
-
-  &__title {
-    font-size: 14px;
-    font-weight: 600;
-    color: vars.$violet-normal;
-    margin-bottom: 3px;
-    line-height: 1.3;
-
-    &--read {
-      color: vars.$color-text-2;
-      font-weight: 400;
-    }
-  }
-
-  &__message {
-    font-size: 12px;
-    color: vars.$color-text-2;
-    line-height: 1.4;
-    display: -webkit-box;
-    -webkit-line-clamp: 2;
-    line-clamp: 2;
-    -webkit-box-orient: vertical;
-    overflow: hidden;
-  }
-
-  &__date {
-    font-size: 11px;
-    color: vars.$violet-normal;
-    margin-top: 4px;
-    opacity: 0.7;
-  }
-}
 </style>

+ 19 - 0
src/css/app.scss

@@ -183,3 +183,22 @@ input[type="number"]::-webkit-outer-spin-button {
   max-width: 100vw;
   box-sizing: border-box;
 }
+
+.notifications-grid {
+  display: grid;
+  width: 100%;
+  gap: 16px;
+  grid-template-columns: minmax(0, 1fr);
+
+  @media (min-width: 600px) {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  @media (min-width: 1024px) {
+    grid-template-columns: repeat(3, minmax(0, 1fr));
+  }
+
+  @media (min-width: 1440px) {
+    grid-template-columns: repeat(4, minmax(0, 1fr));
+  }
+}

+ 22 - 0
src/helpers/notification.js

@@ -0,0 +1,22 @@
+export const resolveNotificationImageUrl = (content) => {
+  if (!content) return null;
+  const media = content.media?.[0]?.url;
+  if (media) return media;
+  const direct = content.image_url;
+  if (!direct) return null;
+  if (direct.startsWith("http")) return direct;
+  const base = (process.env.API_URL ?? "").replace(/\/$/, "");
+  return `${base}/${direct.replace(/^\//, "")}`;
+};
+
+export const formatNotificationDate = (dateStr, todayLabel) => {
+  if (!dateStr) return "";
+  const date = new Date(dateStr);
+  const today = new Date();
+  const isToday =
+    date.getDate() === today.getDate() &&
+    date.getMonth() === today.getMonth() &&
+    date.getFullYear() === today.getFullYear();
+  if (isToday) return todayLabel;
+  return date.toLocaleDateString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric" });
+};

+ 7 - 169
src/pages/associado/notificacoes/NotificacoesAssociadoPage.vue

@@ -10,50 +10,15 @@
       {{ $t('notification.empty') }}
     </div>
 
-    <div v-else class="notif-list q-px-md">
-      <div
+    <div v-else class="notifications-grid q-px-md">
+      <NotificationCard
         v-for="item in pagedItems"
         :key="item.id"
-        class="notif-item"
-        :class="{ 'notif-item--unread': !item.read }"
+        :notification="item.notification"
+        :unread="!item.read"
+        :date="item.created_at"
         @click="onRead(item)"
-      >
-        <!-- Left: small image + date below -->
-        <div class="notif-item__left">
-          <div class="notif-item__img-wrap">
-            <img
-              v-if="imageUrl(item)"
-              :src="imageUrl(item)"
-              alt=""
-              class="notif-item__img"
-            />
-            <div v-else class="notif-item__placeholder flex flex-center">
-              <q-icon
-                name="mdi-bell-outline"
-                size="24px"
-                :color="item.read ? 'grey-4' : 'violet-normal'"
-              />
-            </div>
-          </div>
-          <span class="notif-item__date text-violet-normal">{{ formatDate(item.created_at) }}</span>
-        </div>
-
-        <!-- Right: title + message -->
-        <div class="notif-item__content ">
-          <div class="notif-item__title" :class="{ 'notif-item__title--read': item.read }">
-            {{ item.notification?.title }}
-          </div>
-          <div class="notif-item__message">{{ item.notification?.message }}</div>
-        </div>
-
-        <!-- Unread dot top-right -->
-        <q-badge
-          v-if="!item.read"
-          color="violet-normal"
-          rounded
-          class="notif-item__dot"
-        />
-      </div>
+      />
     </div>
 
     <div v-if="totalPages > 1" class="flex flex-center q-mt-lg">
@@ -73,13 +38,12 @@
 <script setup>
 import { ref, computed, onMounted } from "vue";
 import { useQuasar } from "quasar";
-import { useI18n } from "vue-i18n";
 import { getMyNotificationsAssociado } from "src/api/notification";
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
 import NotificationDetailDialog from "src/components/NotificationDetailDialog.vue";
+import NotificationCard from "src/components/NotificationCard.vue";
 
 const $q = useQuasar();
-const { t } = useI18n();
 
 const loading = ref(true);
 const notifications = ref([]);
@@ -93,26 +57,6 @@ const pagedItems = computed(() => {
   return notifications.value.slice(start, start + PER_PAGE);
 });
 
-const imageUrl = (item) => {
-  const media = item.notification?.media?.[0]?.url;
-  if (media) return media;
-  const direct = item.notification?.image_url;
-  if (!direct) return null;
-  return direct.startsWith("http") ? direct : (process.env.API_URL + direct);
-};
-
-const formatDate = (dateStr) => {
-  if (!dateStr) return "";
-  const d = new Date(dateStr);
-  const today = new Date();
-  const isToday =
-    d.getDate() === today.getDate() &&
-    d.getMonth() === today.getMonth() &&
-    d.getFullYear() === today.getFullYear();
-  if (isToday) return t("common.terms.today");
-  return d.toLocaleDateString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric" });
-};
-
 const onRead = (item) => {
   $q.dialog({ component: NotificationDetailDialog, componentProps: { item } }).onOk((markedId) => {
     if (markedId) {
@@ -143,110 +87,4 @@ onMounted(async () => {
   overflow-x: hidden;
   padding-bottom: 16px;
 }
-
-.notif-list {
-  display: flex;
-  flex-direction: column;
-  gap: 10px;
-  padding-top: 8px;
-}
-
-.notif-item {
-  background: white;
-  border-radius: 12px;
-  padding: 14px;
-  display: flex;
-  flex-direction: row;
-  align-items: flex-start;
-  gap: 14px;
-  cursor: pointer;
-  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.07);
-  position: relative;
-  overflow: hidden;
-  transition: box-shadow 0.2s;
-
-  &:hover {
-    box-shadow: 0 4px 14px rgba(102, 29, 117, 0.14);
-  }
-
-  &--unread {
-    border-left: 3px solid vars.$violet-normal;
-  }
-
-  &__left {
-    display: flex;
-    flex-direction: column;
-    align-items: center;
-    gap: 6px;
-    flex-shrink: 0;
-    width: 68px;
-  }
-
-  &__img-wrap {
-    width: 68px;
-    height: 68px;
-    border-radius: 10px;
-    overflow: hidden;
-    background: vars.$violet-light;
-  }
-
-  &__img {
-    width: 100%;
-    height: 100%;
-    object-fit: cover;
-    display: block;
-  }
-
-  &__placeholder {
-    width: 100%;
-    height: 100%;
-    background: vars.$violet-light;
-  }
-
-  &__date {
-    font-size: 10px;
-    color: vars.$violet-normal;
-    text-align: center;
-    line-height: 1.3;
-  }
-
-  &__content {
-    flex: 1;
-    min-width: 0;
-    padding-right: 10px;
-  }
-
-  &__title {
-    font-size: 14px;
-    font-weight: 600;
-    color: vars.$violet-normal;
-    line-height: 1.3;
-    margin-bottom: 5px;
-
-    &--read {
-    color: vars.$violet-dark;
-    }
-  }
-
-  &__message {
-    font-size: 12px;
-    color: vars.$color-text-2;
-    line-height: 1.5;
-    display: -webkit-box;
-    -webkit-line-clamp: 3;
-    line-clamp: 3;
-    -webkit-box-orient: vertical;
-    overflow: hidden;
-  }
-
-  &__dot {
-    position: absolute;
-    top: 14px;
-    right: 14px;
-    width: 8px;
-    height: 8px;
-    min-height: unset;
-    padding: 0;
-  }
-}
 </style>