ソースを参照

feat: :sparkles: 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 日 前
コミット
c73bfc72ae

+ 216 - 0
src/components/NotificationCard.vue

@@ -0,0 +1,216 @@
+<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 text-weight-bold"
+        :class="unread === false ? 'text-grey-7' : 'text-violet-normal'"
+      >
+        {{ notification.title }}
+      </div>
+      <div class="notification-card__message text-grey-7">
+        {{ 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="text-caption text-grey-6">
+        {{ formattedDate }}
+      </div>
+
+      <q-btn
+        v-if="showStatsButton"
+        unelevated
+        dense
+        color="violet-normal"
+        text-color="white"
+        :label="$t('notification.details')"
+        size="sm"
+        padding="4px 10px"
+        class="notification-card__details-btn"
+        @click.stop
+      >
+        <q-menu anchor="bottom right" self="top right" class="notification-card__menu">
+          <q-list dense style="min-width: 200px">
+            <q-item>
+              <q-item-section avatar>
+                <q-icon name="mdi-send-outline" color="violet-normal" size="18px" />
+              </q-item-section>
+              <q-item-section>
+                <q-item-label class="text-body2">
+                  {{ $t('notification.sent_to', { count: notification.sent_count ?? 0 }) }}
+                </q-item-label>
+              </q-item-section>
+            </q-item>
+            <q-item>
+              <q-item-section avatar>
+                <q-icon name="mdi-eye-outline" color="violet-normal" size="18px" />
+              </q-item-section>
+              <q-item-section>
+                <q-item-label class="text-body2">
+                  {{ $t('notification.seen_by', { count: notification.seen_count ?? 0 }) }}
+                </q-item-label>
+              </q-item-section>
+            </q-item>
+          </q-list>
+        </q-menu>
+      </q-btn>
+
+      <q-icon
+        v-else-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 },
+  showStatsButton: { type: Boolean, default: false },
+  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">
+.notification-card {
+  position: relative;
+  border-radius: 8px;
+  overflow: hidden;
+  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 16px rgba(102, 29, 117, 0.15);
+      transform: translateY(-2px);
+    }
+  }
+
+  &--unread {
+    border-top: 3px solid #7b2d97;
+  }
+
+  &__image {
+    width: 100%;
+    height: 90px;
+    overflow: hidden;
+    background: #f0e8f1;
+    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: 16px;
+    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;
+  }
+
+  &__message {
+    max-width: 100%;
+    font-size: 13px;
+    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;
+  }
+
+  &__menu {
+    border-radius: 8px;
+  }
+}
+</style>

+ 7 - 20
src/components/NotificationDetailDialog.vue

@@ -44,6 +44,7 @@ import { ref, computed, onMounted } from "vue";
 import { useI18n } from "vue-i18n";
 import { useDialogPluginComponent } from "quasar";
 import { userStore } from "src/stores/user";
+import { resolveNotificationImageUrl, formatNotificationDate } from "src/helpers/notification";
 import {
   markNotificationAsReadAssociado,
   markNotificationAsReadParceiro,
@@ -62,25 +63,9 @@ const store = userStore();
 const markedReadId = ref(null);
 const imageError = ref(false);
 
-const imageUrl = computed(() => {
-  const direct = props.item.notification?.image_url;
-  if (!direct) return null;
-  if (direct.startsWith("http")) return direct;
-  const base = (process.env.API_URL ?? "").replace(/\/$/, "");
-  return `${base}/${direct.replace(/^\//, "")}`;
-});
+const imageUrl = computed(() => resolveNotificationImageUrl(props.item.notification));
 
-const formatDate = (dateStr) => {
-  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 t("common.terms.today");
-  return date.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);
@@ -104,7 +89,7 @@ onMounted(async () => {
 <style scoped lang="scss">
 .notif-detail-card {
   width: 90vw;
-  max-width: 600px;
+  max-width: 640px;
   min-width: 300px;
 
   &__scroll {
@@ -113,6 +98,7 @@ onMounted(async () => {
   }
 
   &__title {
+    min-width: 0;
     white-space: normal;
     word-break: break-word;
   }
@@ -129,7 +115,7 @@ onMounted(async () => {
 
   &__img {
     width: 100%;
-    max-height: 350px;
+    max-height: 420px;
     object-fit: contain;
     display: block;
   }
@@ -137,6 +123,7 @@ onMounted(async () => {
   &__message {
     white-space: pre-wrap;
     word-break: break-word;
+    overflow-wrap: anywhere;
     line-height: 1.6;
   }
 }

+ 9 - 140
src/components/UnreadNotificationsDialog.vue

@@ -20,71 +20,16 @@
           <q-spinner color="violet-normal" size="50px" />
         </div>
 
-        <div v-else class="row q-col-gutter-md q-pt-xs">
-          <div
+        <div v-else class="notifications-grid q-pt-xs">
+          <NotificationCard
             v-for="item in notifications"
             :key="item.id"
-            class="col-xl-4 col-lg-4 col-md-6 col-sm-6 col-12"
-          >
-            <q-card
-              flat
-              bordered
-              class="notif-card"
-              :class="{ 'notif-card--unread': !item.read }"
-              @click="openDetail(item)"
-            >
-              <div class="notif-card__image">
-                <img
-                  v-if="imageUrl(item) && !imageErrors.has(item.id)"
-                  :src="imageUrl(item)"
-                  alt=""
-                  class="notif-card__img"
-                  @error="onImageError(item)"
-                />
-                <div v-else class="notif-card__placeholder flex flex-center">
-                  <q-icon
-                    name="mdi-bell-outline"
-                    size="40px"
-                    :color="item.read ? 'grey-4' : 'violet-normal'"
-                  />
-                </div>
-              </div>
-
-              <q-card-section class="q-pt-sm q-pb-xs">
-                <div class="row items-start justify-between no-wrap q-mb-xs">
-                  <div
-                    class="notif-card__title text-weight-bold ellipsis"
-                    :class="item.read ? 'text-grey-7' : 'text-violet-normal'"
-                  >
-                    {{ item.notification?.title }}
-                  </div>
-                  <q-badge
-                    v-if="!item.read"
-                    color="violet-normal"
-                    rounded
-                    class="q-ml-xs"
-                    style="flex-shrink: 0"
-                  />
-                </div>
-                <div class="notif-card__message text-caption text-grey-7">
-                  {{ item.notification?.message }}
-                </div>
-              </q-card-section>
-
-              <q-card-actions class="q-pt-xs q-pb-sm q-px-md">
-                <div class="text-caption text-grey-6">
-                  {{ formatDate(item.created_at) }}
-                </div>
-                <q-space />
-                <q-icon
-                  v-if="item.read"
-                  name="mdi-check-circle"
-                  color="positive"
-                  size="18px"
-                />
-              </q-card-actions>
-            </q-card>
-          </div>
+            :notification="item.notification"
+            :unread="!item.read"
+            :date="item.created_at"
+            show-read-icon
+            @click="openDetail(item)"
+          />
         </div>
       </q-card-section>
 
@@ -108,7 +53,6 @@
 
 <script setup>
 import { ref, computed, watch } from "vue";
-import { useI18n } from "vue-i18n";
 import { useQuasar } from "quasar";
 import { userStore } from "src/stores/user";
 import {
@@ -116,6 +60,7 @@ import {
   getMyUnreadNotificationsParceiro,
 } from "src/api/notification";
 import NotificationDetailDialog from "src/components/NotificationDetailDialog.vue";
+import NotificationCard from "src/components/NotificationCard.vue";
 
 const props = defineProps({
   modelValue: { type: Boolean, required: true },
@@ -123,13 +68,11 @@ const props = defineProps({
 
 const emit = defineEmits(["update:modelValue"]);
 
-const { t } = useI18n();
 const $q = useQuasar();
 const store = userStore();
 
 const loading = ref(false);
 const notifications = ref([]);
-const imageErrors = ref(new Set());
 
 const localUnreadCount = computed(() => notifications.value.filter((n) => !n.read).length);
 
@@ -148,30 +91,6 @@ const fetchUnread = async () => {
   }
 };
 
-const imageUrl = (item) => {
-  const direct = item.notification?.image_url;
-  if (!direct) return null;
-  if (direct.startsWith("http")) return direct;
-  const base = (process.env.API_URL ?? "").replace(/\/$/, "");
-  return `${base}/${direct.replace(/^\//, "")}`;
-};
-
-const onImageError = (item) => {
-  imageErrors.value = new Set(imageErrors.value).add(item.id);
-};
-
-const formatDate = (dateStr) => {
-  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 t("common.terms.today");
-  return date.toLocaleDateString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric" });
-};
-
 const openDetail = (item) => {
   $q.dialog({
     component: NotificationDetailDialog,
@@ -208,54 +127,4 @@ watch(
     overflow-y: auto;
   }
 }
-
-.notif-card {
-  border-radius: 8px;
-  overflow: hidden;
-  cursor: pointer;
-  transition: box-shadow 0.2s, transform 0.15s;
-
-  &:hover {
-    box-shadow: 0 4px 16px rgba(102, 29, 117, 0.15);
-    transform: translateY(-2px);
-  }
-
-  &--unread {
-    border-top: 3px solid #7b2d97;
-  }
-
-  &__image {
-    width: 100%;
-    height: 120px;
-    overflow: hidden;
-  }
-
-  &__img {
-    width: 100%;
-    height: 100%;
-    object-fit: cover;
-    display: block;
-  }
-
-  &__placeholder {
-    width: 100%;
-    height: 100%;
-    background: #f0e8f1;
-  }
-
-  &__title {
-    font-size: 14px;
-    line-height: 1.3;
-    min-width: 0;
-  }
-
-  &__message {
-    display: -webkit-box;
-    -webkit-line-clamp: 2;
-    line-clamp: 2;
-    -webkit-box-orient: vertical;
-    overflow: hidden;
-    line-height: 1.4;
-  }
-}
 </style>

+ 19 - 0
src/css/app.scss

@@ -218,3 +218,22 @@ input[type="number"]::-webkit-outer-spin-button {
     border-color: $violet-normal !important;
   }
 }
+
+.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" });
+};

+ 21 - 136
src/pages/associado/notificacoes/NotificacoesAssociadoPage.vue

@@ -13,63 +13,15 @@
       </div>
 
       <div v-else>
-        <div class="row q-px-md q-col-gutter-md">
-          <div
+        <div class="notifications-grid q-px-md">
+          <NotificationCard
             v-for="item in pagedItems"
             :key="item.id"
-            class="col-xl-4 col-lg-4 col-md-4 col-sm-6 col-12"
-          >
-            <q-card
-              flat
-              bordered
-              class="notif-card"
-              :class="{ 'notif-card--unread': !item.read }"
-              @click="onRead(item)"
-            >
-              <div class="notif-card__image">
-                <img
-                  v-if="imageUrl(item)"
-                  :src="imageUrl(item)"
-                  alt=""
-                  class="notif-card__img"
-                />
-                <div v-else class="notif-card__placeholder flex flex-center">
-                  <q-icon
-                    name="mdi-bell-outline"
-                    size="40px"
-                    :color="item.read ? 'grey-4' : 'violet-normal'"
-                  />
-                </div>
-              </div>
-
-              <q-card-section class="q-pt-sm q-pb-xs">
-                <div class="row items-start justify-between no-wrap q-mb-xs">
-                  <div
-                    class="notif-card__title text-weight-bold ellipsis"
-                    :class="item.read ? 'text-grey-7' : 'text-violet-normal'"
-                  >
-                    {{ item.notification?.title }}
-                  </div>
-                  <q-badge
-                    v-if="!item.read"
-                    color="violet-normal"
-                    rounded
-                    class="q-ml-xs"
-                    style="flex-shrink: 0"
-                  />
-                </div>
-                <div class="notif-card__message text-caption text-grey-7">
-                  {{ item.notification?.message }}
-                </div>
-              </q-card-section>
-
-              <q-card-actions class="q-pt-xs q-pb-sm q-px-md">
-                <div class="text-caption text-grey-6">
-                  {{ formatDate(item.created_at) }}
-                </div>
-              </q-card-actions>
-            </q-card>
-          </div>
+            :notification="item.notification"
+            :unread="!item.read"
+            :date="item.created_at"
+            @click="openDetail(item)"
+          />
         </div>
 
         <div v-if="totalPages > 1" class="flex flex-center q-mt-lg">
@@ -91,11 +43,13 @@
 
 <script setup>
 import { ref, computed, onMounted } from "vue";
-import { useI18n } from "vue-i18n";
-import { getMyNotificationsAssociado, markNotificationAsReadAssociado } from "src/api/notification";
+import { useQuasar } from "quasar";
+import { getMyNotificationsAssociado } from "src/api/notification";
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import NotificationCard from "src/components/NotificationCard.vue";
+import NotificationDetailDialog from "src/components/NotificationDetailDialog.vue";
 
-const { t } = useI18n();
+const $q = useQuasar();
 
 const loading = ref(true);
 const notifications = ref([]);
@@ -109,34 +63,15 @@ 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 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 t("common.terms.today");
-  return date.toLocaleDateString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric" });
-};
-
-const onRead = async (item) => {
-  if (item.read) return;
-  try {
-    await markNotificationAsReadAssociado(item.id);
-    item.read = true;
-  } catch (e) {
-    console.error(e);
-  }
+const openDetail = (item) => {
+  $q.dialog({
+    component: NotificationDetailDialog,
+    componentProps: { item },
+  }).onOk((markedId) => {
+    if (!markedId) return;
+    const found = notifications.value.find((n) => n.id === markedId);
+    if (found) found.read = true;
+  });
 };
 
 onMounted(async () => {
@@ -150,53 +85,3 @@ onMounted(async () => {
 });
 </script>
 
-<style scoped lang="scss">
-.notif-card {
-  border-radius: 8px;
-  overflow: hidden;
-  cursor: pointer;
-  transition: box-shadow 0.2s, transform 0.15s;
-
-  &:hover {
-    box-shadow: 0 4px 16px rgba(102, 29, 117, 0.15);
-    transform: translateY(-2px);
-  }
-
-  &--unread {
-    border-top: 3px solid #7b2d97;
-  }
-
-  &__image {
-    width: 100%;
-    height: 120px;
-    overflow: hidden;
-  }
-
-  &__img {
-    width: 100%;
-    height: 100%;
-    object-fit: cover;
-    display: block;
-  }
-
-  &__placeholder {
-    width: 100%;
-    height: 100%;
-    background: #f0e8f1;
-  }
-
-  &__title {
-    font-size: 14px;
-    line-height: 1.3;
-    min-width: 0;
-  }
-
-  &__message {
-    display: -webkit-box;
-    -webkit-line-clamp: 2;
-    -webkit-box-orient: vertical;
-    overflow: hidden;
-    line-height: 1.4;
-  }
-}
-</style>

+ 0 - 140
src/pages/notificacoes/components/NotificationCard.vue

@@ -1,140 +0,0 @@
-<template>
-  <q-card flat bordered class="notification-card">
-    <div class="notification-card__image">
-      <img
-        v-if="imageUrl"
-        :src="imageUrl"
-        alt=""
-        class="notification-card__img"
-      />
-      <div v-if="!imageUrl" class="notification-card__img-placeholder flex flex-center">
-        <q-icon name="mdi-bell-outline" size="40px" color="violet-normal" />
-      </div>
-    </div>
-
-    <q-card-section class="notification-card__body q-pt-sm q-pb-xs">
-      <div class="notification-card__title text-violet-normal text-weight-bold ellipsis">
-        {{ notification.title }}
-      </div>
-      <div class="notification-card__message text-caption text-grey-7 q-mt-xs">
-        {{ 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="text-caption text-grey-6">
-        {{ formatDate(notification.created_at) }}
-      </div>
-
-      <q-btn
-        unelevated
-        dense
-        color="violet-normal"
-        text-color="white"
-        :label="$t('notification.details')"
-        size="sm"
-        padding="4px 10px"
-        class="notification-card__details-btn"
-      >
-        <q-menu anchor="bottom right" self="top right" class="notification-card__menu">
-          <q-list dense style="min-width: 200px">
-            <q-item>
-              <q-item-section avatar>
-                <q-icon name="mdi-send-outline" color="violet-normal" size="18px" />
-              </q-item-section>
-              <q-item-section>
-                <q-item-label class="text-body2">
-                  {{ $t('notification.sent_to', { count: notification.sent_count ?? 0 }) }}
-                </q-item-label>
-              </q-item-section>
-            </q-item>
-            <q-item>
-              <q-item-section avatar>
-                <q-icon name="mdi-eye-outline" color="violet-normal" size="18px" />
-              </q-item-section>
-              <q-item-section>
-                <q-item-label class="text-body2">
-                  {{ $t('notification.seen_by', { count: notification.seen_count ?? 0 }) }}
-                </q-item-label>
-              </q-item-section>
-            </q-item>
-          </q-list>
-        </q-menu>
-      </q-btn>
-    </q-card-actions>
-  </q-card>
-</template>
-
-<script setup>
-import { computed } from "vue";
-import { useI18n } from "vue-i18n";
-
-const { t } = useI18n();
-
-const { notification } = defineProps({
-  notification: {
-    type: Object,
-    required: true,
-  },
-});
-
-const imageUrl = computed(() => {
-  if (!notification.image_url) return null;
-  if (notification.image_url.startsWith("http")) return notification.image_url;
-  return process.env.API_URL + notification.image_url;
-});
-
-const formatDate = (dateStr) => {
-  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 t("common.terms.today");
-  return date.toLocaleDateString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric" });
-};
-</script>
-
-<style scoped lang="scss">
-.notification-card {
-  border-radius: 8px;
-  overflow: hidden;
-
-  &__image {
-    width: 100%;
-    height: 120px;
-    overflow: hidden;
-  }
-
-  &__img {
-    width: 100%;
-    height: 100%;
-    object-fit: cover;
-  }
-
-  &__img-placeholder {
-    width: 100%;
-    height: 100%;
-    background: #f0e8f1;
-  }
-
-  &__title {
-    font-size: 14px;
-    line-height: 1.3;
-  }
-
-  &__message {
-    display: -webkit-box;
-    -webkit-line-clamp: 2;
-    -webkit-box-orient: vertical;
-    overflow: hidden;
-    line-height: 1.4;
-  }
-
-  &__menu {
-    border-radius: 8px;
-  }
-}
-</style>

+ 25 - 7
src/pages/notificacoes/components/NotificationHistoryPanel.vue

@@ -9,14 +9,14 @@
     </div>
 
     <div v-else>
-      <div class="row q-px-md q-col-gutter-md">
-        <div
+      <div class="notifications-grid q-px-md">
+        <NotificationCard
           v-for="item in notifications"
           :key="item.id"
-          class="col-xl-4 col-lg-4 col-md-4 col-sm-6 col-12"
-        >
-          <NotificationCard :notification="item" />
-        </div>
+          :notification="item"
+          show-stats-button
+          @click="openDetail(item)"
+        />
       </div>
 
       <div class="flex flex-center q-mt-lg">
@@ -37,8 +37,12 @@
 
 <script setup>
 import { ref, computed, onMounted } from "vue";
+import { useQuasar } from "quasar";
 import { getNotificationsPaginated } from "src/api/notification";
-import NotificationCard from "./NotificationCard.vue";
+import NotificationCard from "src/components/NotificationCard.vue";
+import NotificationDetailDialog from "src/components/NotificationDetailDialog.vue";
+
+const $q = useQuasar();
 
 const PER_PAGE = 12;
 
@@ -65,6 +69,20 @@ const refresh = async () => {
   await fetchData();
 };
 
+const openDetail = (item) => {
+  $q.dialog({
+    component: NotificationDetailDialog,
+    componentProps: {
+      item: {
+        notification: item,
+        id: item.id,
+        created_at: item.created_at,
+        read: true,
+      },
+    },
+  });
+};
+
 defineExpose({ refresh });
 
 onMounted(fetchData);

+ 21 - 136
src/pages/parceiros-convenios/NotificacoesParceiroPage.vue

@@ -13,63 +13,15 @@
       </div>
 
       <div v-else>
-        <div class="row q-px-md q-col-gutter-md">
-          <div
+        <div class="notifications-grid q-px-md">
+          <NotificationCard
             v-for="item in pagedItems"
             :key="item.id"
-            class="col-xl-4 col-lg-4 col-md-4 col-sm-6 col-12"
-          >
-            <q-card
-              flat
-              bordered
-              class="notif-card"
-              :class="{ 'notif-card--unread': !item.read }"
-              @click="onRead(item)"
-            >
-              <div class="notif-card__image">
-                <img
-                  v-if="imageUrl(item)"
-                  :src="imageUrl(item)"
-                  alt=""
-                  class="notif-card__img"
-                />
-                <div v-else class="notif-card__placeholder flex flex-center">
-                  <q-icon
-                    name="mdi-bell-outline"
-                    size="40px"
-                    :color="item.read ? 'grey-4' : 'violet-normal'"
-                  />
-                </div>
-              </div>
-
-              <q-card-section class="q-pt-sm q-pb-xs">
-                <div class="row items-start justify-between no-wrap q-mb-xs">
-                  <div
-                    class="notif-card__title text-weight-bold ellipsis"
-                    :class="item.read ? 'text-grey-7' : 'text-violet-normal'"
-                  >
-                    {{ item.notification?.title }}
-                  </div>
-                  <q-badge
-                    v-if="!item.read"
-                    color="violet-normal"
-                    rounded
-                    class="q-ml-xs"
-                    style="flex-shrink: 0"
-                  />
-                </div>
-                <div class="notif-card__message text-caption text-grey-7">
-                  {{ item.notification?.message }}
-                </div>
-              </q-card-section>
-
-              <q-card-actions class="q-pt-xs q-pb-sm q-px-md">
-                <div class="text-caption text-grey-6">
-                  {{ formatDate(item.created_at) }}
-                </div>
-              </q-card-actions>
-            </q-card>
-          </div>
+            :notification="item.notification"
+            :unread="!item.read"
+            :date="item.created_at"
+            @click="openDetail(item)"
+          />
         </div>
 
         <div v-if="totalPages > 1" class="flex flex-center q-mt-lg">
@@ -91,11 +43,13 @@
 
 <script setup>
 import { ref, computed, onMounted } from "vue";
-import { useI18n } from "vue-i18n";
-import { getMyNotificationsParceiro, markNotificationAsReadParceiro } from "src/api/notification";
+import { useQuasar } from "quasar";
+import { getMyNotificationsParceiro } from "src/api/notification";
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import NotificationCard from "src/components/NotificationCard.vue";
+import NotificationDetailDialog from "src/components/NotificationDetailDialog.vue";
 
-const { t } = useI18n();
+const $q = useQuasar();
 
 const loading = ref(true);
 const notifications = ref([]);
@@ -109,34 +63,15 @@ 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 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 t("common.terms.today");
-  return date.toLocaleDateString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric" });
-};
-
-const onRead = async (item) => {
-  if (item.read) return;
-  try {
-    await markNotificationAsReadParceiro(item.id);
-    item.read = true;
-  } catch (e) {
-    console.error(e);
-  }
+const openDetail = (item) => {
+  $q.dialog({
+    component: NotificationDetailDialog,
+    componentProps: { item },
+  }).onOk((markedId) => {
+    if (!markedId) return;
+    const found = notifications.value.find((n) => n.id === markedId);
+    if (found) found.read = true;
+  });
 };
 
 onMounted(async () => {
@@ -150,53 +85,3 @@ onMounted(async () => {
 });
 </script>
 
-<style scoped lang="scss">
-.notif-card {
-  border-radius: 8px;
-  overflow: hidden;
-  cursor: pointer;
-  transition: box-shadow 0.2s, transform 0.15s;
-
-  &:hover {
-    box-shadow: 0 4px 16px rgba(102, 29, 117, 0.15);
-    transform: translateY(-2px);
-  }
-
-  &--unread {
-    border-top: 3px solid #7b2d97;
-  }
-
-  &__image {
-    width: 100%;
-    height: 120px;
-    overflow: hidden;
-  }
-
-  &__img {
-    width: 100%;
-    height: 100%;
-    object-fit: cover;
-    display: block;
-  }
-
-  &__placeholder {
-    width: 100%;
-    height: 100%;
-    background: #f0e8f1;
-  }
-
-  &__title {
-    font-size: 14px;
-    line-height: 1.3;
-    min-width: 0;
-  }
-
-  &__message {
-    display: -webkit-box;
-    -webkit-line-clamp: 2;
-    -webkit-box-orient: vertical;
-    overflow: hidden;
-    line-height: 1.4;
-  }
-}
-</style>