Ver código fonte

feat: ✨ feat (eventos) criado eventos na landingpage e web

foi criado o cadastro de eventos no layout adm, e a exibicao dos eventos na landingpage, de forma dinamica

fase:dev | origin:escopo
Gustavo Zanatta 2 semanas atrás
pai
commit
ec061b6364

+ 8 - 1
app/components/AppFooter.vue

@@ -20,11 +20,17 @@
           </h4>
           <ul class="space-y-3">
             <li>
-              <button class="footer-link" @click="scrollTo('quem-somos')" >Quem Somos</button>
+              <button class="footer-link" @click="scrollTo('quem-somos')" >Início</button>
             </li>
             <li>
               <button class="footer-link" @click="scrollTo('beneficios')">Benefícios</button>
             </li>
+            <li>
+              <button class="footer-link" @click="scrollTo('sobre-nos')">Quem somos</button>
+            </li>
+            <li v-if="events.length">
+              <button class="footer-link" @click="scrollTo('eventos')">Eventos</button>
+            </li>
             <li>
               <span class="footer-link opacity-50 cursor-default select-none">Parceiros</span>
             </li>
@@ -69,6 +75,7 @@
 
 <script setup lang="ts">
 const { settings } = useCompanyData()
+const { events } = useEvents()
 
 const scrollTo = (id: string) => {
   document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' })

+ 61 - 12
app/components/AppHeader.vue

@@ -19,16 +19,10 @@
       <!-- Todos os itens à direita -->
       <div class="flex items-center gap-6 lg:gap-8">
         <nav class="hidden lg:flex items-center gap-8">
-          <button class="nav-link" @click="scrollTo('quem-somos')">Quem Somos</button>
-          <button class="nav-link" @click="scrollTo('beneficios')">Benefícios</button>
-          <button class="nav-link" @click="scrollTo('sobre-nos')">Sobre Nós</button>
-          <span class="nav-link opacity-50 cursor-default select-none">Parceiros</span>
-          <button class="nav-link" @click="scrollTo('associe-se')">Associe-se</button>
-        </nav>
-
-        <nav class="flex lg:hidden items-center gap-3">
-          <button class="nav-link-mobile" @click="scrollTo('beneficios')">Benefícios</button>
-          <button class="nav-link-mobile" @click="scrollTo('associe-se')">Associe-se</button>
+          <template v-for="item in navItems" :key="item.id">
+            <span v-if="item.disabled" class="nav-link opacity-50 cursor-default select-none">{{ item.label }}</span>
+            <button v-else class="nav-link" @click="scrollTo(item.id)">{{ item.label }}</button>
+          </template>
         </nav>
 
         <button
@@ -37,17 +31,53 @@
         >
           ENTRAR
         </button>
+
+        <button
+          class="flex lg:hidden items-center text-violet-normal cursor-pointer"
+          :aria-expanded="isMenuOpen"
+          aria-label="Abrir menu"
+          @click="isMenuOpen = !isMenuOpen"
+        >
+          <span class="material-icons text-2xl">{{ isMenuOpen ? 'close' : 'menu' }}</span>
+        </button>
       </div>
 
     </div>
+
+    <Transition name="mobile-menu">
+      <nav v-if="isMenuOpen" class="lg:hidden bg-white shadow-md border-t border-neutral-normal">
+        <template v-for="item in navItems" :key="item.id">
+          <span v-if="item.disabled" class="nav-link-mobile opacity-50 cursor-default select-none">{{ item.label }}</span>
+          <button v-else class="nav-link-mobile" @click="onMobileNavClick(item.id)">{{ item.label }}</button>
+        </template>
+      </nav>
+    </Transition>
   </header>
 </template>
 
 <script setup lang="ts">
+const { events } = useEvents()
+
+const navItems = computed(() => [
+  { id: 'quem-somos', label: 'Início' },
+  { id: 'beneficios', label: 'Benefícios' },
+  { id: 'sobre-nos', label: 'Quem somos' },
+  ...(events.value.length ? [{ id: 'eventos', label: 'Eventos' }] : []),
+  { id: 'parceiros', label: 'Parceiros', disabled: true },
+  { id: 'associe-se', label: 'Associe-se' },
+])
+
+const isMenuOpen = ref(false)
+
 const scrollTo = (id: string) => {
   document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' })
 }
 
+const onMobileNavClick = (id: string) => {
+  isMenuOpen.value = false
+  scrollTo(id)
+}
+
 const handleEntrar = () => {
   window.open('https://app.serprati.com.br/', '_blank')
 }
@@ -65,13 +95,32 @@ const handleEntrar = () => {
   color: #661d75;
 }
 .nav-link-mobile {
-  font-size: 0.75rem;
+  display: block;
+  width: 100%;
+  text-align: left;
+  font-size: 0.9375rem;
   font-weight: 500;
   color: #505050;
-  transition: color 0.2s;
+  padding: 0.875rem 1.5rem;
+  border-bottom: 1px solid #f2f2f2;
+  transition: color 0.2s, background-color 0.2s;
   cursor: pointer;
 }
+.nav-link-mobile:last-child {
+  border-bottom: none;
+}
 .nav-link-mobile:hover {
   color: #661d75;
+  background-color: #f0e8f1;
+}
+
+.mobile-menu-enter-active,
+.mobile-menu-leave-active {
+  transition: opacity 0.25s ease, transform 0.25s ease;
+}
+.mobile-menu-enter-from,
+.mobile-menu-leave-to {
+  opacity: 0;
+  transform: translateY(-8px);
 }
 </style>

+ 170 - 0
app/components/EventCard.vue

@@ -0,0 +1,170 @@
+<template>
+  <article class="event-card">
+    <button
+      type="button"
+      class="event-header"
+      :aria-expanded="isOpen"
+      @click="isOpen = !isOpen"
+    >
+      <img
+        v-if="event.cover_url"
+        :src="event.cover_url"
+        :alt="event.title"
+        class="event-thumb"
+        loading="lazy"
+      >
+      <div v-else class="event-thumb event-thumb--empty">
+        <i class="mdi mdi-calendar-star text-violet-normal" style="font-size: 1.5rem;"/>
+      </div>
+
+      <div class="min-w-0 flex-1 text-left">
+        <span class="event-badge">
+          <i :class="`mdi ${typeIcon}`" style="font-size: 0.875rem;"/>
+          {{ eventTypeLabel(event.type) }}
+        </span>
+        <h3 class="text-subtitle-1 font-semibold text-text mt-2 mb-1 truncate">
+          {{ event.title }}
+        </h3>
+        <p v-if="dateTimeLabel" class="text-body-2 text-text-2">
+          {{ dateTimeLabel }}
+        </p>
+      </div>
+
+      <span
+        class="material-icons text-violet-normal shrink-0 transition-transform duration-300"
+        :class="{ 'rotate-180': isOpen }"
+      >
+        expand_more
+      </span>
+    </button>
+
+    <div class="event-body" :class="isOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'">
+      <div class="overflow-hidden">
+        <div class="px-6 pb-6 pt-1">
+          <p
+            v-for="(paragraph, index) in paragraphs"
+            :key="index"
+            lang="pt-BR"
+            class="text-body-2 text-text-2 text-justify hyphens-auto event-text"
+            :class="{ 'mt-3': index > 0 }"
+          >
+            {{ paragraph }}
+          </p>
+
+          <img
+            v-if="event.cover_url"
+            :src="event.cover_url"
+            :alt="event.title"
+            class="event-cover"
+            :class="{ 'mt-5': paragraphs.length }"
+            loading="lazy"
+          >
+
+          <EventCarousel
+            v-if="photos.length"
+            :photos="photos"
+            class="mt-4"
+          />
+        </div>
+      </div>
+    </div>
+  </article>
+</template>
+
+<script setup lang="ts">
+import type { CompanyEvent } from '~/composables/useEvents'
+
+const { event } = defineProps<{ event: CompanyEvent }>()
+
+const isOpen = ref(true)
+
+const typeIcon = computed(() => (event.type === 'esporte' ? 'mdi-soccer' : 'mdi-palette-outline'))
+
+const dateTimeLabel = computed(() => formatEventDateTime(event))
+
+const photos = computed(() => (event.media ?? []).filter((photo) => !!photo.url))
+
+const paragraphs = computed(() =>
+  (event.description ?? '')
+    .split(/\r?\n/)
+    .map((line) => line.trim())
+    .filter(Boolean)
+)
+</script>
+
+<style scoped>
+.event-card {
+  background-color: #ffffff;
+  border-radius: 1rem;
+  box-shadow: 0 2px 12px rgba(102, 29, 117, 0.07);
+  transition: box-shadow 0.2s;
+  overflow: hidden;
+  height: 100%;
+}
+.event-card:hover {
+  box-shadow: 0 6px 24px rgba(102, 29, 117, 0.13);
+}
+
+.event-header {
+  display: flex;
+  align-items: center;
+  gap: 1rem;
+  width: 100%;
+  padding: 1.25rem 1.5rem;
+  cursor: pointer;
+  text-align: left;
+}
+
+.event-thumb {
+  width: 72px;
+  height: 72px;
+  border-radius: 0.75rem;
+  object-fit: cover;
+  flex-shrink: 0;
+}
+
+.event-thumb--empty {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  background-color: #f0e8f1;
+}
+
+.event-badge {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.25rem;
+  background-color: #f0e8f1;
+  color: #661d75;
+  font-family: "Nunito", sans-serif;
+  font-size: 0.6875rem;
+  font-weight: 600;
+  letter-spacing: 0.5px;
+  text-transform: uppercase;
+  padding: 0.125rem 0.5rem;
+  border-radius: 9999px;
+}
+
+.event-body {
+  display: grid;
+  transition: grid-template-rows 0.35s ease;
+}
+
+.event-text {
+  line-height: 1.7;
+}
+
+.event-cover {
+  width: 100%;
+  height: 240px;
+  object-fit: cover;
+  border-radius: 0.75rem;
+  display: block;
+}
+
+@media (min-width: 1024px) {
+  .event-cover {
+    height: 280px;
+  }
+}
+</style>

+ 144 - 0
app/components/EventCarousel.vue

@@ -0,0 +1,144 @@
+<template>
+  <div class="relative">
+    <div
+      ref="trackRef"
+      class="carousel-track"
+      @scroll.passive="onScroll"
+    >
+      <div
+        v-for="photo in photos"
+        :key="photo.id"
+        class="carousel-slide"
+      >
+        <img
+          :src="photo.url ?? ''"
+          :alt="photo.name ?? 'Foto do evento'"
+          class="carousel-img"
+          loading="lazy"
+        >
+      </div>
+    </div>
+
+    <template v-if="photos.length > 1">
+      <button
+        type="button"
+        class="carousel-nav left-2"
+        aria-label="Foto anterior"
+        @click="go(-1)"
+      >
+        <span class="material-icons text-xl">chevron_left</span>
+      </button>
+      <button
+        type="button"
+        class="carousel-nav right-2"
+        aria-label="Próxima foto"
+        @click="go(1)"
+      >
+        <span class="material-icons text-xl">chevron_right</span>
+      </button>
+
+      <div class="flex justify-center gap-2 mt-3">
+        <button
+          v-for="(photo, index) in photos"
+          :key="photo.id"
+          type="button"
+          class="carousel-dot"
+          :class="{ 'carousel-dot--active': index === activeIndex }"
+          :aria-label="`Ir para a foto ${index + 1}`"
+          @click="goTo(index)"
+        />
+      </div>
+    </template>
+  </div>
+</template>
+
+<script setup lang="ts">
+import type { EventMedia } from '~/composables/useEvents'
+
+const { photos } = defineProps<{ photos: EventMedia[] }>()
+
+const trackRef = ref<HTMLElement | null>(null)
+const activeIndex = ref(0)
+
+const onScroll = () => {
+  const track = trackRef.value
+  if (!track) return
+  activeIndex.value = Math.round(track.scrollLeft / track.clientWidth)
+}
+
+const goTo = (index: number) => {
+  const track = trackRef.value
+  if (!track) return
+  track.scrollTo({ left: index * track.clientWidth, behavior: 'smooth' })
+}
+
+const go = (step: number) => {
+  const total = photos.length
+  goTo((activeIndex.value + step + total) % total)
+}
+</script>
+
+<style scoped>
+.carousel-track {
+  display: flex;
+  overflow-x: auto;
+  scroll-snap-type: x mandatory;
+  scrollbar-width: none;
+  border-radius: 0.75rem;
+}
+.carousel-track::-webkit-scrollbar {
+  display: none;
+}
+
+.carousel-slide {
+  flex: 0 0 100%;
+  scroll-snap-align: start;
+}
+
+.carousel-img {
+  width: 100%;
+  height: 240px;
+  object-fit: cover;
+  display: block;
+}
+
+@media (min-width: 1024px) {
+  .carousel-img {
+    height: 280px;
+  }
+}
+
+.carousel-nav {
+  position: absolute;
+  top: 50%;
+  transform: translateY(-50%);
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 36px;
+  height: 36px;
+  border-radius: 9999px;
+  background-color: rgba(255, 255, 255, 0.9);
+  color: #661d75;
+  box-shadow: 0 2px 10px rgba(102, 29, 117, 0.18);
+  transition: background-color 0.2s;
+  cursor: pointer;
+}
+.carousel-nav:hover {
+  background-color: #ffffff;
+}
+
+.carousel-dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 9999px;
+  background-color: #d0b9d4;
+  transition: background-color 0.2s, width 0.2s;
+  cursor: pointer;
+}
+
+.carousel-dot--active {
+  width: 20px;
+  background-color: #661d75;
+}
+</style>

+ 59 - 0
app/composables/useEvents.ts

@@ -0,0 +1,59 @@
+export interface EventMedia {
+  id: number
+  name: string | null
+  url: string | null
+}
+
+export interface CompanyEvent {
+  id: number
+  title: string
+  description: string | null
+  date: string | null
+  time: string | null
+  type: 'cultura' | 'esporte'
+  cover_url: string | null
+  order: number
+  media: EventMedia[]
+}
+
+interface ApiResponse<T> {
+  payload: T
+}
+
+export const useEvents = () => {
+  const { apiUrl } = useRuntimeConfig().public
+
+  const { data: eventsData } = useAsyncData('company-events', () =>
+    $fetch<ApiResponse<CompanyEvent[]>>(`${apiUrl}/api/company-events`).catch(() => null),
+  )
+
+  const events = computed<CompanyEvent[]>(() => {
+    const payload = eventsData.value?.payload
+    if (!Array.isArray(payload)) return []
+    return payload
+  })
+
+  return { events }
+}
+
+const MONTHS = [
+  'janeiro', 'fevereiro', 'março', 'abril', 'maio', 'junho',
+  'julho', 'agosto', 'setembro', 'outubro', 'novembro', 'dezembro',
+]
+
+export const formatEventDateTime = (event: Pick<CompanyEvent, 'date' | 'time'>): string => {
+  const parts: string[] = []
+
+  if (event.date) {
+    const [year, month, day] = event.date.split('-')
+    const monthName = MONTHS[Number(month) - 1]
+    if (monthName && day && year) parts.push(`${Number(day)} de ${monthName} de ${year}`)
+  }
+
+  if (event.time) parts.push(`${event.time}h`)
+
+  return parts.join(' · ')
+}
+
+export const eventTypeLabel = (type: CompanyEvent['type']): string =>
+  type === 'esporte' ? 'Esporte' : 'Cultura'

+ 23 - 1
app/pages/index.vue

@@ -104,7 +104,7 @@
 
         <div v-if="aboutCards.length" class="reveal text-center mb-12 lg:mb-16">
           <h2 class="text-h4 text-text" style="font-size: clamp(1.5rem, 3vw, 2.125rem);">
-            Sobre Nós
+            Quem somos
           </h2>
         </div>
 
@@ -164,6 +164,27 @@
       </div>
     </section>
 
+    <section v-if="events.length" id="eventos" class="bg-neutral-light py-16 lg:py-24">
+      <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
+
+        <div class="reveal text-center mb-12 lg:mb-16">
+          <h2 class="text-h4 text-text" style="font-size: clamp(1.5rem, 3vw, 2.125rem);">
+            Eventos
+          </h2>
+        </div>
+
+        <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
+          <EventCard
+            v-for="(event, index) in events"
+            :key="event.id"
+            :class="`reveal reveal-delay-${(index % 2) + 1}`"
+            :event="event"
+          />
+        </div>
+
+      </div>
+    </section>
+
     <section id="associe-se" class="bg-violet-light py-16 lg:py-24">
       <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
 
@@ -344,6 +365,7 @@ useHead({ title: 'Serprati' })
 definePageMeta({ layout: 'default' })
 
 const { settings, benefits } = useCompanyData()
+const { events } = useEvents()
 
 const heroBackground = computed(() => settings.value.hero_background_url ?? heroBgFallback)