ソースを参照

feat: :sparkles: feat (loja associado) adicionado titulo, filtro de busca e selecao de categorias

foram adicionadas informacoes faltantes, como o titulo, um input para pesquisar entre os itens listados, e um seletor de categorias para filtrar os itens da loja

fase:dev | origin:escopo
Gustavo Zanatta 3 週間 前
コミット
555da065ad

+ 3 - 1
src/i18n/locales/en.json

@@ -584,7 +584,9 @@
     "add_variation": "Add",
     "filter_all": "All",
     "filter_recent": "Recent",
-    "filter_others": "Others"
+    "filter_others": "Others",
+    "btn_want": "I WANT IT",
+    "btn_interested": "INTERESTED"
   },
   "parceiro": {
     "category": "Category",

+ 3 - 1
src/i18n/locales/es.json

@@ -583,7 +583,9 @@
     "add_variation": "Agregar",
     "filter_all": "Todos",
     "filter_recent": "Recientes",
-    "filter_others": "Otros"
+    "filter_others": "Otros",
+    "btn_want": "LO QUIERO",
+    "btn_interested": "INTERESADO"
   },
   "parceiro": {
     "category": "Categoría",

+ 3 - 1
src/i18n/locales/pt.json

@@ -584,7 +584,9 @@
     "add_variation": "Adicionar",
     "filter_all": "Todos",
     "filter_recent": "Recentes",
-    "filter_others": "Outros"
+    "filter_others": "Outros",
+    "btn_want": "EU QUERO",
+    "btn_interested": "INTERESSADO"
   },
   "parceiro": {
     "category": "Categoria",

+ 280 - 211
src/pages/associado/loja/AssociadoLojaPage.vue

@@ -1,172 +1,200 @@
 <template>
-  <div class="q-pa-md">
-
-    <div v-if="loading" class="flex flex-center q-py-xl">
-      <q-spinner color="primary" size="48px" />
-    </div>
-
-    <div
-      v-else-if="filteredItems.length === 0"
-      class="text-center text-grey q-py-xl"
-    >
-      Nenhum produto encontrado
-    </div>
-
-    <div v-else class="row q-col-gutter-md items-stretch">
-
-      <div
-        v-for="item in filteredItems"
-        :key="item.id"
-        class="col-12 col-sm-6 col-md-4"
-      >
-
-        <q-card flat bordered class="store-card">
-
-          <q-card-section class="q-pa-sm">
-
-            <div class="row no-wrap store-card-inner">
-
-              <!-- LEFT -->
-              <div class="col column justify-between q-pr-sm">
-
-                <div>
+  <div>
+    <DefaultHeaderPage />
+
+    <div class="q-pa-md">
+
+      <!-- Search -->
+      <div class="row q-col-gutter-sm q-mb-md">
+        <div class="col-12 col-md-5">
+          <q-input
+            v-model="search"
+            :placeholder="$t('associado.filter_by_category')"
+            outlined
+            dense
+            clearable
+          >
+            <template #prepend>
+              <q-icon name="mdi-magnify" />
+            </template>
+          </q-input>
+        </div>
+      </div>
 
-                  <div
-                    class="text-subtitle1 text-weight-bold text-violet-medium q-mb-xs"
-                  >
-                    {{ item.name }}
-                  </div>
+      <!-- Category chips -->
+      <div class="row q-gutter-xs q-mb-md flex-wrap">
+        <div
+          :class="['cat-chip', activeTab === 'all' ? 'cat-chip--selected' : 'cat-chip--default']"
+          @click="activeTab = 'all'"
+        >
+          {{ $t('loja.filter_all') }}
+        </div>
+        <div
+          v-for="cat in storeCategories"
+          :key="cat.value"
+          :class="['cat-chip', activeTab === cat.value ? 'cat-chip--selected' : 'cat-chip--default']"
+          @click="activeTab = cat.value"
+        >
+          {{ cat.label }}
+        </div>
+        <div
+          :class="['cat-chip', activeTab === 'recent' ? 'cat-chip--selected' : 'cat-chip--default']"
+          @click="activeTab = 'recent'"
+        >
+          {{ $t('loja.filter_recent') }}
+        </div>
+        <div class="cat-chip cat-chip--disabled">
+          {{ $t('loja.filter_others') }}
+        </div>
+      </div>
 
-                  <div
-                    v-if="item.description"
-                    class="text-caption text-grey-7 q-mb-sm ellipsis-2-lines"
-                  >
-                    {{ item.description }}
-                  </div>
+      <!-- Loading -->
+      <div v-if="loading" class="flex flex-center q-py-xl">
+        <q-spinner color="primary" size="48px" />
+      </div>
 
-                  <!-- VARIACOES -->
-                  <template v-if="item.variations?.length">
+      <!-- Empty -->
+      <div v-else-if="pagedItems.length === 0" class="text-center text-grey q-py-xl">
+        {{ $t('http.errors.no_records_found') }}
+      </div>
 
-                    <div class="text-caption text-grey-6 q-mb-xs">
-                      {{ variationTypeLabel(item) }}
+      <!-- Grid -->
+      <div v-else class="row q-col-gutter-md items-stretch">
+        <div
+          v-for="item in pagedItems"
+          :key="item.id"
+          class="col-12 col-sm-6 col-md-4"
+        >
+          <q-card flat bordered class="store-card" style="height: 100%">
+            <q-card-section class="q-pa-sm col column" style="height: 100%">
+              <div class="row no-wrap store-card-inner" style="flex: 1">
+
+                <!-- LEFT -->
+                <div class="col column justify-between q-pr-sm">
+                  <div>
+                    <!-- Category badge -->
+                    <div class="q-mb-xs">
+                      <span v-if="item.category?.name" class="badge-category">
+                        {{ item.category.name }}
+                      </span>
                     </div>
 
-                    <div class="row q-gutter-xs q-mb-sm">
-
-                      <div
-                        v-for="v in item.variations"
-                        :key="v.id"
-                        :class="[
-                          'variation-tag',
-                          activeVariation(item)?.id === v.id
-                            ? 'variation-tag--selected'
-                            : 'variation-tag--default'
-                        ]"
-                        @click="selectVariation(item, v)"
-                      >
-                        {{ v.variation_label }}
-                      </div>
-
+                    <div class="text-subtitle1 text-weight-bold text-violet-medium q-mb-xs leading-tight">
+                      {{ item.name }}
                     </div>
 
-                  </template>
-
-                </div>
-
-                <!-- PRECO -->
-                <div class="column items-start q-mt-sm">
-
-                  <span
-                    v-if="item.price"
-                    class="text-caption text-grey-5 text-strike"
-                  >
-                    R$ {{ formatPrice(item.price) }}
-                  </span>
+                    <div v-if="item.description" class="text-caption text-grey-7 q-mb-sm ellipsis-2-lines">
+                      {{ item.description }}
+                    </div>
 
-                  <span
-                    class="text-subtitle1 text-weight-bold text-violet-dark"
-                  >
-                    R$ {{ formatPrice(displayPrice(item)) }}
-                  </span>
+                    <!-- Variations -->
+                    <template v-if="item.variations?.length">
+                      <div class="text-caption text-grey-6 q-mb-xs">
+                        {{ variationTypeLabel(item) }}
+                      </div>
+                      <div class="row q-gutter-xs q-mb-sm">
+                        <div
+                          v-for="v in item.variations"
+                          :key="v.id"
+                          :class="[
+                            'variation-tag',
+                            activeVariation(item)?.id === v.id
+                              ? 'variation-tag--selected'
+                              : 'variation-tag--default'
+                          ]"
+                          @click="selectVariation(item, v)"
+                        >
+                          {{ v.variation_label }}
+                        </div>
+                      </div>
+                    </template>
+                  </div>
 
+                  <!-- Price -->
+                  <div class="column items-start q-mt-sm">
+                    <span v-if="item.price" class="text-caption text-grey-5 text-strike">
+                      R$ {{ formatPrice(item.price) }}
+                    </span>
+                    <span class="text-subtitle1 text-weight-bold text-violet-dark">
+                      R$ {{ formatPrice(displayPrice(item)) }}
+                    </span>
+                  </div>
                 </div>
 
-              </div>
-
-              <!-- RIGHT -->
-              <div class="store-card-right column items-stretch">
-
-                <div class="store-card-image">
-
-                  <img
-                    v-if="item.media?.length"
-                    :src="item.media[0].url"
-                    :alt="item.name"
-                    class="store-card-img"
-                  />
+                <!-- RIGHT -->
+                <div class="store-card-right column items-stretch no-wrap">
+                  <div class="store-card-image col">
+                    <img
+                      v-if="item.media?.length"
+                      :src="item.media[0].url"
+                      :alt="item.name"
+                      class="store-card-img"
+                    />
+                    <q-icon
+                      v-else
+                      name="mdi-image-off-outline"
+                      size="32px"
+                      color="grey-4"
+                      class="absolute-center"
+                    />
+                  </div>
 
-                  <q-icon
-                    v-else
-                    name="mdi-image-off-outline"
-                    size="32px"
-                    color="grey-4"
-                    class="absolute-center"
+                  <!-- Button -->
+                  <q-btn
+                    unelevated
+                    size="sm"
+                    class="q-mt-xs"
+                    :class="item.user_interested ? 'btn-interested' : 'btn-gradient'"
+                    :icon="item.user_interested ? 'mdi-check' : 'mdi-thumb-up'"
+                    :label="item.user_interested ? $t('loja.btn_interested') : $t('loja.btn_want')"
+                    style="width: 100%"
+                    padding="5px 8px"
+                    @click="toggleInterest(item)"
                   />
-
                 </div>
 
-                <!-- BOTAO -->
-                <q-btn
-                  unelevated
-                  size="sm"
-                  class="btn-gradient q-mt-xs"
-                  :color="item.user_interested ? 'negative' : 'primary'"
-                  :icon="
-                    item.user_interested
-                      ? 'mdi-heart-remove'
-                      : 'mdi-heart-outline'
-                  "
-                  :label="
-                    item.user_interested
-                      ? 'Remover Interesse'
-                      : 'Demonstrar Interesse'
-                  "
-                  @click="toggleInterest(item)"
-                />
-
               </div>
+            </q-card-section>
+          </q-card>
+        </div>
+      </div>
 
-            </div>
-
-          </q-card-section>
-
-        </q-card>
-
+      <!-- Pagination -->
+      <div v-if="totalPages > 1" class="flex flex-center q-mt-lg">
+        <q-pagination
+          v-model="currentPage"
+          :max="totalPages"
+          boundary-links
+          color="violet-normal"
+        />
       </div>
 
     </div>
-
   </div>
 </template>
 
 <script setup>
-import { ref, computed, onMounted } from "vue";
+import { ref, computed, onMounted, watch } from "vue";
 import { useQuasar } from "quasar";
-
-import {
-  getStoreItems,
-  toggleInterest as apiToggleInterest,
-} from "src/api/storeItem";
+import { useI18n } from "vue-i18n";
+import { normalizeString } from "src/helpers/utils";
+import { getStoreItems, toggleInterest as apiToggleInterest } from "src/api/storeItem";
+import { getCategories } from "src/api/category";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
 
 const $q = useQuasar();
+const { t } = useI18n();
 
 const items = ref([]);
 const loading = ref(true);
+const search = ref("");
+const activeTab = ref("all");
+const storeCategories = ref([]);
+const currentPage = ref(1);
+const perPage = 12;
 
 const selectedVariations = ref({});
 
-const filteredItems = computed(() => items.value);
-
 const selectVariation = (item, variation) => {
   selectedVariations.value[item.id] = variation;
 };
@@ -176,67 +204,81 @@ const activeVariation = (item) =>
 
 const displayPrice = (item) => {
   const v = activeVariation(item);
-
-  if (v?.variation_value != null) {
-    return v.variation_value;
-  }
-
+  if (v?.variation_value != null) return v.variation_value;
   return item.associate_price ?? item.price;
 };
 
 const variationTypeLabel = (item) => {
   const type = item.variations?.[0]?.variation_type;
-
-  if (type === "tamanho") return "Tamanho";
-  if (type === "cor") return "Cor";
-  if (type === "modelo") return "Modelo";
-
+  if (type === "tamanho") return t("loja.variation_tamanho");
+  if (type === "cor")     return t("loja.variation_cor");
+  if (type === "modelo")  return t("loja.variation_modelo");
   return "";
 };
 
 const formatPrice = (price) =>
-  Number(price).toLocaleString("pt-BR", {
-    minimumFractionDigits: 2,
-  });
+  Number(price).toLocaleString("pt-BR", { minimumFractionDigits: 2 });
+
+const filteredItems = computed(() => {
+  let list = items.value;
+
+  if (activeTab.value === "recent") {
+    const cutoff = new Date().setDate(new Date().getDate() - 30);
+    list = list.filter((i) => new Date(i.created_at).getTime() >= cutoff);
+  } else if (activeTab.value !== "all") {
+    list = list.filter((i) => i.category_id === activeTab.value);
+  }
+
+  if (search.value) {
+    const needle = normalizeString(search.value);
+    list = list.filter((i) => normalizeString(i.name || "").includes(needle));
+  }
+
+  return list;
+});
+
+const totalPages = computed(() => Math.ceil(filteredItems.value.length / perPage));
+
+const pagedItems = computed(() => {
+  const start = (currentPage.value - 1) * perPage;
+  return filteredItems.value.slice(start, start + perPage);
+});
+
+watch([search, activeTab], () => {
+  currentPage.value = 1;
+});
 
 const toggleInterest = async (item) => {
   try {
     const result = await apiToggleInterest(item.id);
-
     item.user_interested = result.interested;
-
-    item.interests_count += result.interested ? 1 : -1;
-
+    item.interests_count = (item.interests_count ?? 0) + (result.interested ? 1 : -1);
     $q.notify({
       type: "positive",
       message: result.interested
-        ? "Interesse demonstrado!"
-        : "Interesse removido!",
+        ? t("loja.btn_interested")
+        : t("http.success"),
     });
-
   } catch {
-    $q.notify({
-      type: "negative",
-      message: "Erro ao processar interesse",
-    });
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
   }
 };
 
 onMounted(async () => {
   try {
-    const response = await getStoreItems();
-
-    items.value = response;
-
-    response.forEach((item) => {
+    const [itemsData, catsData] = await Promise.all([
+      getStoreItems(),
+      getCategories("store"),
+    ]);
+    items.value = itemsData;
+    storeCategories.value = catsData.map((c) => ({ label: c.name, value: c.id }));
+    itemsData.forEach((item) => {
       if (item.variations?.length) {
         selectedVariations.value[item.id] = item.variations[0];
       }
     });
-
   } catch (e) {
     console.error(e);
-
   } finally {
     loading.value = false;
   }
@@ -245,17 +287,15 @@ onMounted(async () => {
 
 <style scoped lang="scss">
 .store-card {
-  transition: 0.2s;
-  border-radius: 12px;
-  height: 100%;
-
-  &:hover {
-    box-shadow: 0 4px 16px rgba(102, 29, 117, 0.12);
-  }
+  transition: box-shadow 0.2s;
+  display: flex;
+  flex-direction: column;
+  &:hover { box-shadow: 0 4px 16px rgba(102, 29, 117, 0.12); }
 }
 
 .store-card-inner {
-  min-height: 140px;
+  min-height: 0;
+  flex: 1;
 }
 
 .store-card-right {
@@ -266,74 +306,103 @@ onMounted(async () => {
 
 .store-card-image {
   position: relative;
-  height: 110px;
   border-radius: 8px;
   overflow: hidden;
-  background: #f5f0f7;
   border: 1px solid rgba(102, 29, 117, 0.15);
+  background: #f5f0f7;
+  min-height: 110px;
 }
 
 .store-card-img {
   width: 100%;
   height: 100%;
   object-fit: cover;
+  display: block;
 }
 
-.variation-tag {
+.ellipsis-2-lines {
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  line-clamp: 2;
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+}
+
+// --- category chips ---
+.cat-chip {
   display: inline-flex;
   align-items: center;
   justify-content: center;
+  height: 28px;
+  padding: 0 12px;
+  border-radius: 5px;
+  font-size: 12px;
+  font-weight: 500;
+  cursor: pointer;
+  user-select: none;
+  transition: background 0.15s, color 0.15s;
+
+  &--default  { background: #c9a3dc; color: #fff; }
+  &--selected { background: #4d1658; color: #fff; }
+  &--disabled {
+    background: #c9a3dc;
+    color: rgba(255, 255, 255, 0.5);
+    cursor: default;
+    pointer-events: none;
+  }
+}
+
+// --- category badge on card ---
+.badge-category {
+  display: inline-flex;
+  align-items: center;
+  background: #ede0f5;
+  color: #4d1658;
+  font-size: 10px;
+  font-weight: 500;
+  padding: 2px 8px;
+  border-radius: 20px;
+  line-height: 1.4;
+}
 
+// --- variation tags ---
+.variation-tag {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
   min-width: 28px;
   height: 26px;
-
   padding: 0 7px;
-
   border-radius: 4px;
-
   font-size: 11px;
   font-weight: 500;
-
   cursor: pointer;
+  user-select: none;
+  transition: background 0.15s, color 0.15s;
 
-  transition: 0.15s;
-
-  &--default {
-    background: #ede0f5;
-    color: #4d1658;
-  }
-
-  &--selected {
-    background: #4d1658;
-    color: #fff;
-  }
+  &--default  { background: #ede0f5; color: #4d1658; }
+  &--selected { background: #4d1658; color: #ede0f5; }
 }
 
+// --- buttons ---
 .btn-gradient {
-  background: linear-gradient(
-    90deg,
-    #4d1658 0%,
-    #8b30a5 100%
-  ) !important;
-
+  background: linear-gradient(90deg, #4d1658 0%, #8b30a5 100%) !important;
   color: white !important;
-
   border-radius: 8px !important;
 }
 
-.text-violet-dark {
-  color: #4d1658;
-}
+.btn-gradient :deep(.q-icon) { color: white !important; }
 
-.text-violet-medium {
-  color: #7b2d97;
+.btn-interested {
+  background: #22c55e !important;
+  color: white !important;
+  border-radius: 8px !important;
 }
 
-.ellipsis-2-lines {
-  display: -webkit-box;
-  -webkit-line-clamp: 2;
-  line-clamp: 2;
-  -webkit-box-orient: vertical;
-  overflow: hidden;
-}
-</style>
+.btn-interested :deep(.q-icon) { color: white !important; }
+
+// --- text colors ---
+.text-violet-dark   { color: #4d1658; }
+.text-violet-medium { color: #7b2d97; }
+.leading-tight      { line-height: 1.25; }
+</style>