feat(03-01): create 9 shared components for landing sections and project display
- HeroSection: title + subtitle + 3 CTA UButtons - FeaturedProjectsSection: 3 featured projects via useProjects() - ServicesSection: 4 service cards with UCard + UIcon - TestimonialsSection: UCard per testimonial with ratings and stats - FAQSection: UAccordion with i18n-resolved items - CTASection: final CTA with 2 UButtons - ProjectCard: NuxtLink + NuxtImg + UBadge + schema.org microdata - TechBadge: Technology lookup with NuxtImg + UBadge level - ProjectGallery: UModal fullscreen + UCarousel + thumbnails + keyboard nav
This commit is contained in:
@@ -0,0 +1,92 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Project } from '~~/shared/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: Project
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const translatedCategory = computed(() => {
|
||||||
|
if (!props.project.category) return ''
|
||||||
|
const categoryKey = props.project.category.replace(/\s+/g, '').toLowerCase()
|
||||||
|
return t(`projects.categories.${categoryKey}`, props.project.category)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article class="group" itemscope itemtype="https://schema.org/CreativeWork">
|
||||||
|
<UCard>
|
||||||
|
<!-- Image -->
|
||||||
|
<template #header>
|
||||||
|
<NuxtLink :to="`/project/${project.id}`">
|
||||||
|
<NuxtImg
|
||||||
|
:src="project.image"
|
||||||
|
:alt="`${project.title} - ${project.description.slice(0, 60)}...`"
|
||||||
|
loading="lazy"
|
||||||
|
format="webp"
|
||||||
|
width="400"
|
||||||
|
height="300"
|
||||||
|
class="w-full h-48 object-cover"
|
||||||
|
itemprop="image"
|
||||||
|
/>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<!-- Category & Date -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<UBadge v-if="project.category" color="primary" variant="subtle" itemprop="genre">
|
||||||
|
{{ translatedCategory }}
|
||||||
|
</UBadge>
|
||||||
|
<time v-if="project.date" class="text-sm text-muted" :datetime="project.date" itemprop="dateCreated">
|
||||||
|
{{ project.date }}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<h3 class="text-lg font-bold" itemprop="name">
|
||||||
|
{{ project.title }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<p class="text-sm text-muted line-clamp-2" itemprop="description">
|
||||||
|
{{ project.description }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Technologies -->
|
||||||
|
<div v-if="project.technologies?.length" class="flex flex-wrap gap-1" itemprop="keywords">
|
||||||
|
<UBadge
|
||||||
|
v-for="tech in project.technologies.slice(0, 3)"
|
||||||
|
:key="tech"
|
||||||
|
variant="subtle"
|
||||||
|
color="neutral"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{{ tech }}
|
||||||
|
</UBadge>
|
||||||
|
<UBadge v-if="project.technologies.length > 3" variant="subtle" color="neutral" size="sm">
|
||||||
|
+{{ project.technologies.length - 3 }}
|
||||||
|
</UBadge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action -->
|
||||||
|
<template #footer>
|
||||||
|
<UButton
|
||||||
|
:to="`/project/${project.id}`"
|
||||||
|
variant="ghost"
|
||||||
|
icon="i-lucide-chevron-right"
|
||||||
|
trailing
|
||||||
|
block
|
||||||
|
:aria-label="`${t('projects.buttons.viewProject')} - ${project.title}`"
|
||||||
|
itemprop="url"
|
||||||
|
>
|
||||||
|
{{ t('projects.buttons.viewProject') }}
|
||||||
|
</UButton>
|
||||||
|
</template>
|
||||||
|
</UCard>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
gallery: string[]
|
||||||
|
projectTitle: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const isOpen = ref(false)
|
||||||
|
const currentIndex = ref(0)
|
||||||
|
const carouselRef = useTemplateRef('carousel')
|
||||||
|
|
||||||
|
function openGallery(index: number) {
|
||||||
|
currentIndex.value = index
|
||||||
|
isOpen.value = true
|
||||||
|
nextTick(() => {
|
||||||
|
carouselRef.value?.emblaApi?.scrollTo(index, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function goTo(index: number) {
|
||||||
|
currentIndex.value = index
|
||||||
|
carouselRef.value?.emblaApi?.scrollTo(index, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (!isOpen.value) return
|
||||||
|
if (e.key === 'ArrowRight') carouselRef.value?.emblaApi?.scrollNext()
|
||||||
|
if (e.key === 'ArrowLeft') carouselRef.value?.emblaApi?.scrollPrev()
|
||||||
|
if (e.key === 'Escape') isOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => document.addEventListener('keydown', onKeydown))
|
||||||
|
onUnmounted(() => document.removeEventListener('keydown', onKeydown))
|
||||||
|
|
||||||
|
defineExpose({ openGallery })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UModal v-model:open="isOpen" fullscreen>
|
||||||
|
<template #content>
|
||||||
|
<div class="flex flex-col items-center justify-center h-full p-4 gap-4">
|
||||||
|
<div class="flex items-center justify-between w-full max-w-4xl">
|
||||||
|
<h3 class="text-lg font-semibold">{{ projectTitle }}</h3>
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-x"
|
||||||
|
variant="ghost"
|
||||||
|
size="lg"
|
||||||
|
@click="isOpen = false"
|
||||||
|
:aria-label="'Close gallery'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UCarousel
|
||||||
|
ref="carousel"
|
||||||
|
v-slot="{ item }"
|
||||||
|
:items="props.gallery"
|
||||||
|
arrows
|
||||||
|
loop
|
||||||
|
class="w-full max-w-4xl"
|
||||||
|
@select="(i: number) => (currentIndex = i)"
|
||||||
|
>
|
||||||
|
<NuxtImg
|
||||||
|
:src="item"
|
||||||
|
:alt="`${projectTitle} - Image ${currentIndex + 1}`"
|
||||||
|
loading="lazy"
|
||||||
|
format="webp"
|
||||||
|
class="w-full h-auto max-h-[70vh] object-contain"
|
||||||
|
/>
|
||||||
|
</UCarousel>
|
||||||
|
|
||||||
|
<!-- Thumbnails -->
|
||||||
|
<div class="flex gap-2 justify-center flex-wrap">
|
||||||
|
<button
|
||||||
|
v-for="(img, i) in props.gallery"
|
||||||
|
:key="i"
|
||||||
|
:class="[
|
||||||
|
'rounded overflow-hidden border-2 transition-all',
|
||||||
|
i === currentIndex ? 'border-primary ring-2 ring-primary' : 'border-transparent opacity-60 hover:opacity-100',
|
||||||
|
]"
|
||||||
|
@click="goTo(i)"
|
||||||
|
>
|
||||||
|
<NuxtImg :src="img" width="80" height="60" class="object-cover" loading="lazy" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-sm text-muted">{{ currentIndex + 1 }} / {{ props.gallery.length }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Technology } from '~~/shared/types'
|
||||||
|
import { techStack } from '~/data/techstack'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tech: Technology | string
|
||||||
|
showLevel?: boolean
|
||||||
|
showImage?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
showLevel: true,
|
||||||
|
showImage: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const techMapping: Record<string, string> = {
|
||||||
|
'Three.js': 'JavaScript',
|
||||||
|
'WebGL': 'JavaScript',
|
||||||
|
'Discord.js': 'JavaScript',
|
||||||
|
'Express': 'Node.js',
|
||||||
|
'Canvas': 'JavaScript',
|
||||||
|
'Insta.js': 'JavaScript',
|
||||||
|
'Instagram API': 'JavaScript',
|
||||||
|
'Crowdin API': 'JavaScript',
|
||||||
|
'Cron': 'Node.js',
|
||||||
|
}
|
||||||
|
|
||||||
|
const techData = computed((): Technology => {
|
||||||
|
if (typeof props.tech !== 'string') {
|
||||||
|
return props.tech
|
||||||
|
}
|
||||||
|
|
||||||
|
const techName = props.tech
|
||||||
|
const allTechs = Object.values(techStack).flat()
|
||||||
|
|
||||||
|
let found = allTechs.find((t) => t.name.toLowerCase() === techName.toLowerCase())
|
||||||
|
|
||||||
|
if (!found && techMapping[techName]) {
|
||||||
|
found = allTechs.find((t) => t.name.toLowerCase() === techMapping[techName].toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
return found ?? { name: techName, image: '', level: 'Intermediate' as const }
|
||||||
|
})
|
||||||
|
|
||||||
|
const levelColor = computed(() => {
|
||||||
|
switch (techData.value.level) {
|
||||||
|
case 'Advanced':
|
||||||
|
return 'success' as const
|
||||||
|
case 'Intermediate':
|
||||||
|
return 'primary' as const
|
||||||
|
case 'Beginner':
|
||||||
|
return 'neutral' as const
|
||||||
|
default:
|
||||||
|
return 'neutral' as const
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center gap-2" itemscope itemtype="https://schema.org/ComputerLanguage">
|
||||||
|
<NuxtImg
|
||||||
|
v-if="showImage && techData.image"
|
||||||
|
:src="techData.image"
|
||||||
|
:alt="`${techData.name} logo`"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
loading="lazy"
|
||||||
|
class="shrink-0"
|
||||||
|
itemprop="image"
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-medium" itemprop="name">{{ techData.name }}</span>
|
||||||
|
<UBadge v-if="showLevel" :color="levelColor" variant="subtle" size="xs">
|
||||||
|
{{ techData.level }}
|
||||||
|
</UBadge>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const { t } = useI18n()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="py-16 px-4">
|
||||||
|
<div class="max-w-3xl mx-auto text-center">
|
||||||
|
<h2 class="text-3xl font-bold mb-4">{{ t('home.cta2.title') }}</h2>
|
||||||
|
<p class="text-lg text-muted mb-8">{{ t('home.cta2.subtitle') }}</p>
|
||||||
|
<div class="flex gap-4 justify-center">
|
||||||
|
<UButton to="/contact" size="lg">
|
||||||
|
{{ t('home.cta2.startProject') }}
|
||||||
|
</UButton>
|
||||||
|
<UButton to="/about" size="lg" variant="outline">
|
||||||
|
{{ t('home.cta2.learnMore') }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { FAQ } from '~~/shared/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
faqs: FAQ[]
|
||||||
|
title: string
|
||||||
|
subtitle: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const items = computed(() =>
|
||||||
|
props.faqs.map((faq) => ({
|
||||||
|
label: t(faq.questionKey),
|
||||||
|
content: t(faq.answerKey),
|
||||||
|
value: faq.questionKey,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="py-16 px-4">
|
||||||
|
<div class="max-w-3xl mx-auto">
|
||||||
|
<div class="text-center mb-12">
|
||||||
|
<h2 class="text-3xl font-bold mb-4">{{ title }}</h2>
|
||||||
|
<p class="text-lg text-muted">{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<UAccordion :items="items" type="single" collapsible />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { featuredProjects } = useProjects()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="py-16 px-4">
|
||||||
|
<div class="max-w-6xl mx-auto">
|
||||||
|
<div class="text-center mb-12">
|
||||||
|
<h2 class="text-3xl font-bold mb-4">{{ t('home.projects.title') }}</h2>
|
||||||
|
<p class="text-lg text-muted max-w-2xl mx-auto">{{ t('home.projects.subtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
<ProjectCard
|
||||||
|
v-for="project in featuredProjects"
|
||||||
|
:key="project.id"
|
||||||
|
:project="project"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const { t } = useI18n()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="py-20 md:py-32">
|
||||||
|
<div class="max-w-4xl mx-auto text-center px-4">
|
||||||
|
<h1 class="text-4xl md:text-6xl font-bold mb-6">
|
||||||
|
{{ t('home.title') }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-xl md:text-2xl text-muted mb-10">
|
||||||
|
{{ t('home.subtitle') }}
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
|
<UButton to="/projects" size="xl" icon="i-lucide-arrow-right" trailing>
|
||||||
|
{{ t('home.cta.viewProjects') }}
|
||||||
|
</UButton>
|
||||||
|
<UButton to="/fiverr" size="xl" variant="outline" icon="i-lucide-dollar-sign" trailing>
|
||||||
|
{{ t('nav.fiverr') }}
|
||||||
|
</UButton>
|
||||||
|
<UButton to="/contact" size="xl" variant="outline" icon="i-lucide-message-circle" trailing>
|
||||||
|
{{ t('home.cta.contactMe') }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const services = computed(() => [
|
||||||
|
{
|
||||||
|
icon: 'i-lucide-monitor',
|
||||||
|
title: t('home.services.webDev.title'),
|
||||||
|
description: t('home.services.webDev.description'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'i-lucide-smartphone',
|
||||||
|
title: t('home.services.mobileApps.title'),
|
||||||
|
description: t('home.services.mobileApps.description'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'i-lucide-zap',
|
||||||
|
title: t('home.services.optimization.title'),
|
||||||
|
description: t('home.services.optimization.description'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'i-lucide-settings',
|
||||||
|
title: t('home.services.maintenance.title'),
|
||||||
|
description: t('home.services.maintenance.description'),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="py-16 px-4">
|
||||||
|
<div class="max-w-6xl mx-auto">
|
||||||
|
<div class="text-center mb-12">
|
||||||
|
<h2 class="text-3xl font-bold mb-4">{{ t('home.services.title') }}</h2>
|
||||||
|
<p class="text-lg text-muted max-w-2xl mx-auto">{{ t('home.services.subtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<UCard v-for="(service, index) in services" :key="index">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<UIcon :name="service.icon" class="text-primary text-2xl shrink-0 mt-1" />
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-bold mb-2">{{ service.title }}</h3>
|
||||||
|
<p class="text-muted">{{ service.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { testimonials, testimonialsStats } from '~/data/testimonials'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="py-16 px-4">
|
||||||
|
<div class="max-w-6xl mx-auto">
|
||||||
|
<div class="text-center mb-12">
|
||||||
|
<h2 class="text-3xl font-bold mb-4">{{ t('home.testimonials.title') }}</h2>
|
||||||
|
<p class="text-lg text-muted max-w-2xl mx-auto">{{ t('home.testimonials.subtitle') }}</p>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="flex justify-center gap-8 mt-8">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-3xl font-bold text-primary">{{ testimonialsStats.totalReviews }}</p>
|
||||||
|
<p class="text-sm text-muted">{{ t('home.testimonials.stats.clients') }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-3xl font-bold text-primary">{{ testimonialsStats.averageRating }}/5</p>
|
||||||
|
<p class="text-sm text-muted">{{ t('home.testimonials.stats.rating') }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-3xl font-bold text-primary">{{ testimonialsStats.projectsCompleted }}</p>
|
||||||
|
<p class="text-sm text-muted">{{ t('home.testimonials.stats.projects') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<UCard v-for="(testimonial, index) in testimonials" :key="index">
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<!-- Rating -->
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<UIcon
|
||||||
|
v-for="star in 5"
|
||||||
|
:key="star"
|
||||||
|
name="i-lucide-star"
|
||||||
|
:class="star <= testimonial.rating ? 'text-yellow-400' : 'text-gray-300'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<p class="text-sm italic">"{{ testimonial.content }}"</p>
|
||||||
|
|
||||||
|
<!-- Author -->
|
||||||
|
<div class="flex items-center gap-3 mt-2">
|
||||||
|
<NuxtImg
|
||||||
|
:src="testimonial.avatar"
|
||||||
|
:alt="testimonial.name"
|
||||||
|
width="40"
|
||||||
|
height="40"
|
||||||
|
class="rounded-full"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-sm">{{ testimonial.name }}</p>
|
||||||
|
<p class="text-xs text-muted">{{ testimonial.project_type }} - {{ testimonial.platform }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user