feat(portfolio): mise à jour du site avec de nouvelles sections et améliorations SEO

- Révision des métadonnées dans index.html pour un meilleur référencement.
- Ajout de nouvelles sections : FAQ, Témoignages, Services, et CTA.
- Intégration de données structurées pour les FAQ et les témoignages.
- Amélioration du fichier robots.txt pour un meilleur contrôle d'indexation.
- Mise à jour du sitemap.xml avec de nouvelles URLs.
- Ajout de nouveaux composants Vue.js pour les sections de témoignages et de services.
- Amélioration des styles CSS pour une meilleure présentation des sections.
- Ajout de la gestion des dates et des témoignages dans le composant testimonials.
This commit is contained in:
Mr¤KayJayDee
2025-06-25 23:25:51 +02:00
parent af1f47dbf3
commit 542c468eb3
35 changed files with 2712 additions and 472 deletions

View File

@@ -0,0 +1,70 @@
<template>
<section class="faq-section">
<div class="container">
<div class="faq-header text-center mb-2xl">
<h2 class="section-title">{{ title }}</h2>
<p class="section-subtitle">{{ subtitle }}</p>
</div>
<div class="faq-grid">
<div v-for="(faq, index) in faqs" :key="index" class="faq-item" :class="{ 'active': activeIndex === index }">
<button class="faq-question" @click="toggleFAQ(index)" :aria-expanded="activeIndex === index">
<span class="question-text">{{ faq.question }}</span>
<svg class="faq-icon" :class="{ 'rotated': activeIndex === index }" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7">
</path>
</svg>
</button>
<div class="faq-answer" :class="{ 'open': activeIndex === index }">
<div class="answer-content">
<p v-html="faq.answer"></p>
<div v-if="faq.features" class="faq-features">
<h4>{{ t('faq.keyPoints') }}</h4>
<ul>
<li v-for="feature in faq.features" :key="feature">{{ feature }}</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from '@/composables/useI18n'
const { t } = useI18n()
interface FAQ {
question: string
answer: string
features?: string[]
}
interface Props {
title: string
subtitle: string
faqs: FAQ[]
ctaTitle: string
ctaSubtitle: string
ctaText: string
ctaLink: string
}
defineProps<Props>()
const activeIndex = ref<number | null>(null)
const toggleFAQ = (index: number) => {
activeIndex.value = activeIndex.value === index ? null : index
}
</script>
<style scoped>
@import './styles/ServiceFAQ.css';
</style>

View File

@@ -0,0 +1,76 @@
<template>
<section class="testimonials-section">
<div class="container">
<!-- Header -->
<div class="testimonials-header">
<h2 class="section-title">{{ title }}</h2>
<p class="section-subtitle">{{ subtitle }}</p>
<!-- Stats -->
<TestimonialsStats :stats="stats" :labels="statsLabels" />
</div>
<!-- Testimonials Grid -->
<div class="testimonials-grid">
<TestimonialCard v-for="(testimonial, index) in testimonials" :key="index" :testimonial="testimonial"
:class="{ 'featured': testimonial.featured }" />
</div>
<!-- CTA -->
<TestimonialsCTA :title="ctaTitle" :subtitle="ctaSubtitle" :text="ctaText" :link="ctaLink"
:reviews-link="reviewsLink" :reviews-text="reviewsText" />
</div>
</section>
</template>
<script setup lang="ts">
import TestimonialsStats from '@/components/testimonials/TestimonialsStats.vue'
import TestimonialCard from '@/components/testimonials/TestimonialCard.vue'
import TestimonialsCTA from '@/components/testimonials/TestimonialsCTA.vue'
interface Testimonial {
name: string
role: string
company: string
avatar: string
rating: number
content: string
date: string
platform: string
featured?: boolean
project_type: string
results?: string[]
}
interface Stats {
totalReviews: number
averageRating: number
projectsCompleted: number
}
interface StatsLabels {
clients: string
rating: string
projects: string
}
interface Props {
title: string
subtitle: string
testimonials: Testimonial[]
stats: Stats
statsLabels: StatsLabels
ctaTitle: string
ctaSubtitle: string
ctaText: string
ctaLink: string
reviewsLink: string
reviewsText: string
}
defineProps<Props>()
</script>
<style scoped>
@import '@/components/styles/TestimonialsSection.css';
</style>

View File

@@ -0,0 +1,16 @@
<template>
<section class="section">
<div class="container">
<SectionCTA :question="t('home.cta2.title')" :description="t('home.cta2.subtitle')"
:primary-text="t('home.cta2.startProject')" primary-link="/contact" :secondary-text="t('home.cta2.learnMore')"
secondary-link="/about" />
</div>
</section>
</template>
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n'
import SectionCTA from '@/components/shared/SectionCTA.vue'
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,48 @@
<template>
<section class="section">
<div class="container">
<div class="text-center mb-2xl">
<h2 class="mb-lg">{{ t('home.featuredProjects.title') }}</h2>
<p class="text-xl text-secondary max-w-2xl mx-auto">
{{ t('home.featuredProjects.subtitle') }}
</p>
</div>
<div class="projects-grid">
<ProjectCard v-for="project in featuredProjects" :key="project.id" :project="project"
class="animate-fade-in-up" />
</div>
<div class="text-center">
<CTAButtons layout="stack">
<RouterLink to="/projects" class="btn btn-secondary">
{{ t('home.featuredProjects.viewAll') }}
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"></path>
</svg>
</RouterLink>
</CTAButtons>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from '@/composables/useI18n'
import { useProjects } from '@/composables/useProjects'
import ProjectCard from '@/components/ProjectCard.vue'
import CTAButtons from '@/components/shared/CTAButtons.vue'
const { t } = useI18n()
const { projects } = useProjects()
// Featured projects
const featuredProjects = computed(() => {
return projects.value.filter(project => project.featured).slice(0, 3)
})
</script>
<style scoped>
@import '@/components/styles/FeaturedProjectsSection.css';
</style>

View File

@@ -0,0 +1,46 @@
<template>
<section class="hero">
<div class="container">
<div class="hero-content animate-fade-in-up">
<h1 class="hero-title">
{{ t('home.title') }}
</h1>
<p class="hero-subtitle">
{{ t('home.subtitle') }}
</p>
<CTAButtons layout="columns">
<RouterLink to="/projects" class="btn btn-primary btn-lg">
{{ t('home.cta.viewProjects') }}
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6">
</path>
</svg>
</RouterLink>
<RouterLink to="/fiverr" class="btn btn-secondary btn-lg">
{{ t('nav.fiverr') }}
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z">
</path>
</svg>
</RouterLink>
<RouterLink to="/contact" class="btn btn-secondary btn-lg">
{{ t('home.cta.contactMe') }}
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z">
</path>
</svg>
</RouterLink>
</CTAButtons>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n'
import CTAButtons from '@/components/shared/CTAButtons.vue'
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,70 @@
<template>
<section class="services-section">
<div class="container">
<div class="text-center mb-2xl">
<h2 class="mb-lg">{{ t('home.services.title') }}</h2>
<p class="text-xl text-secondary max-w-2xl mx-auto">
{{ t('home.services.subtitle') }}
</p>
</div>
<div class="services-grid">
<div v-for="(service, index) in services" :key="index" class="card animate-fade-in-up"
:style="{ 'animation-delay': `${index * 0.1}s` }">
<div class="card-body">
<div class="flex items-start">
<div class="service-icon" :style="{ background: service.color, color: 'var(--text-inverse)' }">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="service.icon"></path>
</svg>
</div>
<div>
<h3 class="text-xl font-bold mb-md">{{ service.title }}</h3>
<p class="text-secondary">{{ service.description }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from '@/composables/useI18n'
const { t } = useI18n()
// Services data
const services = computed(() => [
{
icon: 'M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z',
color: 'var(--color-primary)',
title: t('home.services.webDev.title'),
description: t('home.services.webDev.description')
},
{
icon: 'M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z',
color: 'var(--color-secondary)',
title: t('home.services.mobileApps.title'),
description: t('home.services.mobileApps.description')
},
{
icon: 'M13 10V3L4 14h7v7l9-11h-7z',
color: 'var(--color-success)',
title: t('home.services.optimization.title'),
description: t('home.services.optimization.description')
},
{
icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z',
color: 'var(--color-warning)',
title: t('home.services.maintenance.title'),
description: t('home.services.maintenance.description')
}
])
</script>
<style scoped>
@import '@/components/styles/ServicesSection.css';
</style>

View File

@@ -0,0 +1,36 @@
<template>
<div class="cta-buttons-container">
<div class="cta-buttons" :class="layoutClass">
<slot></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
layout?: 'columns' | 'row' | 'stack'
maxButtons?: number
}
const props = withDefaults(defineProps<Props>(), {
layout: 'columns',
maxButtons: 3
})
const layoutClass = computed(() => {
switch (props.layout) {
case 'row':
return 'cta-buttons--row'
case 'stack':
return 'cta-buttons--stack'
default:
return 'cta-buttons--columns'
}
})
</script>
<style scoped>
@import '@/components/styles/CTAButtons.css';
</style>

View File

@@ -0,0 +1,58 @@
<template>
<div class="section-cta">
<div class="cta-content">
<h3 class="cta-question">{{ question }}</h3>
<p class="cta-description">{{ description }}</p>
<CTAButtons layout="columns">
<RouterLink :to="primaryLink" class="btn btn-primary btn-lg">
{{ primaryText }}
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z">
</path>
</svg>
</RouterLink>
<RouterLink v-if="secondaryText && secondaryLink" :to="secondaryLink" class="btn btn-secondary btn-lg">
{{ secondaryText }}
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6">
</path>
</svg>
</RouterLink>
<a v-if="externalText && externalLink" :href="externalLink" class="btn btn-secondary btn-lg" target="_blank"
rel="noopener">
{{ externalText }}
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14">
</path>
</svg>
</a>
</CTAButtons>
</div>
</div>
</template>
<script setup lang="ts">
import CTAButtons from '@/components/shared/CTAButtons.vue'
interface Props {
question: string
description: string
primaryText: string
primaryLink: string
secondaryText?: string
secondaryLink?: string
externalText?: string
externalLink?: string
}
defineProps<Props>()
</script>
<style scoped>
@import '@/components/styles/SectionCTA.css';
</style>

View File

@@ -0,0 +1,78 @@
/* CTAButtons Styles */
.cta-buttons-container {
display: flex;
justify-content: center;
width: 100%;
}
.cta-buttons {
display: flex;
gap: 1rem;
justify-content: center;
align-items: center;
max-width: 900px;
width: 100%;
}
/* Column Layout (Default) */
.cta-buttons--columns {
flex-direction: column;
}
@media (min-width: 640px) {
.cta-buttons--columns {
flex-direction: row;
flex-wrap: wrap;
}
}
@media (min-width: 768px) {
.cta-buttons--columns {
flex-direction: row;
flex-wrap: nowrap;
}
}
/* Row Layout */
.cta-buttons--row {
flex-direction: column;
}
@media (min-width: 640px) {
.cta-buttons--row {
flex-direction: row;
flex-wrap: wrap;
}
}
/* Stack Layout */
.cta-buttons--stack {
flex-direction: column;
}
/* Button Styling */
.cta-buttons :deep(.btn) {
white-space: nowrap;
min-width: fit-content;
flex: 0 0 auto;
text-align: center;
}
@media (min-width: 768px) {
.cta-buttons--columns :deep(.btn) {
flex: 1 1 auto;
max-width: 280px;
}
.cta-buttons--row :deep(.btn) {
flex: 0 1 auto;
max-width: 250px;
}
}
@media (max-width: 639px) {
.cta-buttons :deep(.btn) {
width: 100%;
max-width: 320px;
}
}

View File

@@ -0,0 +1,21 @@
/* FeaturedProjectsSection Styles */
.projects-grid {
display: grid;
grid-template-columns: 1fr;
gap: 2rem;
margin-bottom: 3rem;
}
@media (min-width: 768px) {
.projects-grid {
grid-template-columns: repeat(2, 1fr);
gap: 2rem;
}
}
@media (min-width: 1024px) {
.projects-grid {
grid-template-columns: repeat(3, 1fr);
gap: 2rem;
}
}

View File

@@ -0,0 +1,65 @@
/* SectionCTA Styles */
.section-cta {
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
border-radius: 20px;
padding: 48px 32px;
text-align: center;
margin: 64px 0;
border: 1px solid #e2e8f0;
transition: all 0.3s ease;
}
.section-cta:hover {
transform: translateY(-2px);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
}
.cta-content {
max-width: 600px;
margin: 0 auto;
}
.cta-question {
font-size: 1.75rem;
font-weight: 700;
color: #1e293b;
margin-bottom: 16px;
line-height: 1.3;
}
.cta-description {
font-size: 1.125rem;
color: #64748b;
margin-bottom: 32px;
line-height: 1.6;
}
/* Mobile Responsive */
@media (max-width: 768px) {
.section-cta {
padding: 32px 24px;
margin: 48px 0;
}
.cta-question {
font-size: 1.5rem;
}
.cta-description {
font-size: 1rem;
}
}
/* Dark Theme */
.dark .section-cta {
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
border-color: #475569;
}
.dark .cta-question {
color: #f1f5f9;
}
.dark .cta-description {
color: #94a3b8;
}

View File

@@ -0,0 +1,366 @@
.faq-section {
padding: 4rem 0;
background: #f8fafc;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}
.faq-header {
margin-bottom: 3rem;
}
.section-title {
font-size: 2.5rem;
font-weight: 700;
color: #1e293b;
margin-bottom: 1rem;
}
.section-subtitle {
font-size: 1.25rem;
color: #64748b;
max-width: 600px;
margin: 0 auto;
}
.faq-grid {
max-width: 800px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1rem;
}
.faq-item {
background: white;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
border: 1px solid #e2e8f0;
}
.faq-item:hover {
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
.faq-item.active {
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
border-color: #3b82f6;
}
.faq-question {
width: 100%;
padding: 1.5rem;
background: none;
border: none;
text-align: left;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 1.1rem;
font-weight: 600;
color: #1e293b;
transition: color 0.3s ease;
}
.faq-question:hover {
color: #3b82f6;
}
.faq-item.active .faq-question {
color: #3b82f6;
border-bottom: 1px solid #e2e8f0;
}
.question-text {
flex: 1;
margin-right: 1rem;
}
.faq-icon {
width: 24px;
height: 24px;
transition: transform 0.3s ease;
color: #3b82f6;
flex-shrink: 0;
}
.faq-icon.rotated {
transform: rotate(180deg);
}
.faq-answer {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.faq-answer.open {
max-height: 500px;
}
.answer-content {
padding: 0 1.5rem 1.5rem;
color: #64748b;
line-height: 1.6;
}
.answer-content p {
margin-bottom: 1rem;
}
.faq-features {
margin-top: 1rem;
padding: 1rem;
background: #f1f5f9;
border-radius: 8px;
border-left: 4px solid #3b82f6;
}
.faq-features h4 {
margin-bottom: 0.75rem;
color: #3b82f6;
font-size: 0.9rem;
font-weight: 600;
}
.faq-features ul {
list-style: none;
padding: 0;
margin: 0;
}
.faq-features li {
padding: 0.25rem 0;
position: relative;
padding-left: 1.5rem;
color: #475569;
}
.faq-features li::before {
content: '✅';
position: absolute;
left: 0;
top: 0.25rem;
}
.faq-cta {
background: white;
padding: 3rem;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
margin-top: 3rem;
border: 1px solid #e2e8f0;
}
.cta-title {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 1rem;
color: #1e293b;
}
.cta-subtitle {
color: #64748b;
margin-bottom: 2rem;
font-size: 1.1rem;
}
/* Dark theme support using CSS classes */
:root.dark .faq-section {
background: #0f172a;
}
:root.dark .section-title {
color: #f1f5f9;
}
:root.dark .section-subtitle {
color: #94a3b8;
}
:root.dark .faq-item {
background: #1e293b;
border-color: #334155;
}
:root.dark .faq-item.active {
border-color: #60a5fa;
}
:root.dark .faq-question {
color: #f1f5f9;
}
:root.dark .faq-question:hover {
color: #60a5fa;
}
:root.dark .faq-item.active .faq-question {
color: #60a5fa;
border-bottom-color: #334155;
}
:root.dark .faq-icon {
color: #60a5fa;
}
:root.dark .answer-content {
color: #94a3b8;
}
:root.dark .faq-features {
background: #0f172a;
border-left-color: #60a5fa;
}
:root.dark .faq-features h4 {
color: #60a5fa;
}
:root.dark .faq-features li {
color: #cbd5e1;
}
:root.dark .faq-cta {
background: #1e293b;
border-color: #334155;
}
:root.dark .cta-title {
color: #f1f5f9;
}
:root.dark .cta-subtitle {
color: #94a3b8;
}
/* Light theme explicit styles (optional but good for clarity) */
:root:not(.dark) .faq-section {
background: #f8fafc;
}
:root:not(.dark) .section-title {
color: #1e293b;
}
:root:not(.dark) .section-subtitle {
color: #64748b;
}
:root:not(.dark) .faq-item {
background: white;
border-color: #e2e8f0;
}
:root:not(.dark) .faq-item.active {
border-color: #3b82f6;
}
:root:not(.dark) .faq-question {
color: #1e293b;
}
:root:not(.dark) .faq-question:hover {
color: #3b82f6;
}
:root:not(.dark) .faq-item.active .faq-question {
color: #3b82f6;
border-bottom-color: #e2e8f0;
}
:root:not(.dark) .faq-icon {
color: #3b82f6;
}
:root:not(.dark) .answer-content {
color: #64748b;
}
:root:not(.dark) .faq-features {
background: #f1f5f9;
border-left-color: #3b82f6;
}
:root:not(.dark) .faq-features h4 {
color: #3b82f6;
}
:root:not(.dark) .faq-features li {
color: #475569;
}
:root:not(.dark) .faq-cta {
background: white;
border-color: #e2e8f0;
}
:root:not(.dark) .cta-title {
color: #1e293b;
}
:root:not(.dark) .cta-subtitle {
color: #64748b;
}
@media (max-width: 768px) {
.faq-section {
padding: 3rem 0;
}
.section-title {
font-size: 2rem;
}
.section-subtitle {
font-size: 1.1rem;
}
.faq-question {
padding: 1rem;
font-size: 1rem;
}
.answer-content {
padding: 0 1rem 1rem;
}
.faq-cta {
padding: 2rem 1rem;
margin-top: 2rem;
}
.cta-title {
font-size: 1.3rem;
}
.cta-subtitle {
font-size: 1rem;
}
}
@media (max-width: 480px) {
.container {
padding: 0 0.5rem;
}
.faq-question {
padding: 0.75rem;
}
.answer-content {
padding: 0 0.75rem 0.75rem;
}
.question-text {
margin-right: 0.5rem;
}
}

View File

@@ -0,0 +1,30 @@
/* ServicesSection Styles */
.services-grid {
display: grid;
grid-template-columns: 1fr;
gap: 2rem;
margin-bottom: 3rem;
}
@media (min-width: 768px) {
.services-grid {
grid-template-columns: repeat(2, 1fr);
gap: 2rem;
}
}
.services-section {
background: var(--bg-secondary);
padding: 5rem 0;
}
.service-icon {
width: 3rem;
height: 3rem;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
margin-right: 1rem;
flex-shrink: 0;
}

View File

@@ -0,0 +1,330 @@
/* Testimonial Card - Clean Modern Design */
.testimonial-card {
background: white;
border-radius: 16px;
padding: 32px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border: 1px solid #e2e8f0;
height: fit-content;
transition: all 0.2s ease;
}
.testimonial-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
.testimonial-card.featured {
border: 2px solid #3b82f6;
position: relative;
}
.testimonial-card.featured::after {
content: '⭐ Featured';
position: absolute;
top: -1px;
right: -1px;
background: #3b82f6;
color: white;
padding: 4px 12px;
border-radius: 0 14px 0 12px;
font-size: 0.75rem;
font-weight: 600;
}
/* Header */
.testimonial-header {
display: flex;
align-items: flex-start;
gap: 16px;
margin-bottom: 24px;
}
.client-info {
flex: 1;
display: flex;
align-items: center;
gap: 12px;
}
.client-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
}
.client-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.client-details {
flex: 1;
}
.client-name {
font-size: 1rem;
font-weight: 700;
color: #1e293b;
margin-bottom: 4px;
transition: color 0.3s ease;
}
.client-role {
font-size: 0.875rem;
color: #3b82f6;
font-weight: 500;
margin-bottom: 2px;
transition: color 0.3s ease;
}
.client-company {
font-size: 0.75rem;
color: #64748b;
transition: color 0.3s ease;
}
/* Rating */
.rating {
flex-shrink: 0;
text-align: right;
}
.stars {
margin-bottom: 4px;
}
.star {
font-size: 1rem;
color: #fbbf24;
margin-left: 1px;
}
.star:not(.filled) {
color: #e5e7eb;
}
.rating-text {
font-size: 0.75rem;
color: #64748b;
font-weight: 600;
transition: color 0.3s ease;
}
/* Content */
.testimonial-content {
margin-bottom: 24px;
}
.testimonial-content blockquote {
font-size: 1rem;
line-height: 1.6;
color: #374151;
margin-bottom: 20px;
font-style: normal;
position: relative;
transition: color 0.3s ease;
}
/* Project Info */
.project-info {
margin-bottom: 16px;
}
.project-tag {
display: inline-flex;
align-items: center;
gap: 8px;
background: #eff6ff;
color: #1e40af;
padding: 6px 12px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
transition: all 0.3s ease;
}
.project-type {
opacity: 0.8;
}
/* Results */
.results {
margin-bottom: 16px;
}
.results h5 {
font-size: 0.875rem;
color: #059669;
margin-bottom: 8px;
font-weight: 600;
transition: color 0.3s ease;
}
.results ul {
list-style: none;
padding: 0;
margin: 0;
}
.results li {
font-size: 0.875rem;
color: #64748b;
padding: 2px 0;
position: relative;
padding-left: 20px;
transition: color 0.3s ease;
}
.results li::before {
content: '✓';
position: absolute;
left: 0;
color: #059669;
font-weight: bold;
transition: color 0.3s ease;
}
/* Footer */
.testimonial-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 20px;
border-top: 1px solid #f1f5f9;
gap: 12px;
transition: border-color 0.3s ease;
}
.badges {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.badge {
padding: 4px 8px;
border-radius: 12px;
font-size: 0.6875rem;
font-weight: 600;
}
.badge.featured {
background: #fef3c7;
color: #92400e;
}
.badge.platform {
background: #f1f5f9;
color: #64748b;
}
.testimonial-date {
font-size: 0.75rem;
color: #94a3b8;
transition: color 0.3s ease;
}
/* Mobile Responsive */
@media (max-width: 768px) {
.testimonial-card {
padding: 24px;
}
.testimonial-header {
flex-direction: column;
gap: 12px;
}
.client-info {
width: 100%;
}
.rating {
align-self: flex-start;
text-align: left;
}
.testimonial-footer {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
}
/* Dark Theme */
.dark .testimonial-card {
background: #1e293b;
border-color: #334155;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.dark .testimonial-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.dark .testimonial-card.featured {
border-color: #60a5fa;
}
.dark .testimonial-card.featured::after {
background: #60a5fa;
}
.dark .client-name {
color: #f1f5f9;
}
.dark .client-role {
color: #60a5fa;
}
.dark .client-company {
color: #94a3b8;
}
.dark .rating-text {
color: #94a3b8;
}
.dark .testimonial-content blockquote {
color: #cbd5e1;
}
.dark .project-tag {
background: #1e40af;
color: #bfdbfe;
}
.dark .results h5 {
color: #34d399;
}
.dark .results li {
color: #94a3b8;
}
.dark .results li::before {
color: #34d399;
}
.dark .testimonial-footer {
border-top-color: #334155;
}
.dark .badge.featured {
background: #78350f;
color: #fcd34d;
}
.dark .badge.platform {
background: #334155;
color: #94a3b8;
}
.dark .testimonial-date {
color: #64748b;
}

View File

@@ -0,0 +1,58 @@
/* Testimonials CTA - Clean Modern Design */
.testimonials-cta {
text-align: center;
background: white;
border-radius: 20px;
padding: 48px 32px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border: 1px solid #e2e8f0;
margin-top: 40px;
transition: all 0.3s ease;
}
.cta-title {
font-size: 1.5rem;
font-weight: 700;
color: #1e293b;
margin-bottom: 12px;
transition: color 0.3s ease;
}
.cta-subtitle {
font-size: 1rem;
color: #64748b;
margin-bottom: 32px;
line-height: 1.6;
transition: color 0.3s ease;
}
/* Mobile Responsive */
@media (max-width: 768px) {
.testimonials-cta {
padding: 32px 24px;
margin-top: 32px;
}
.cta-title {
font-size: 1.25rem;
}
.cta-subtitle {
font-size: 0.875rem;
}
}
/* Dark Theme */
.dark .testimonials-cta {
background: #1e293b;
border-color: #334155;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.dark .cta-title {
color: #f1f5f9;
}
.dark .cta-subtitle {
color: #94a3b8;
}

View File

@@ -0,0 +1,80 @@
/* Testimonials Section - Clean Modern Design */
.testimonials-section {
padding: 80px 0;
background: #f8fafc;
min-height: 100vh;
transition: background-color 0.3s ease;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
.testimonials-header {
text-align: center;
margin-bottom: 60px;
}
.section-title {
font-size: 2.5rem;
font-weight: 800;
color: #1e293b;
margin-bottom: 16px;
letter-spacing: -0.025em;
transition: color 0.3s ease;
}
.section-subtitle {
font-size: 1.125rem;
color: #64748b;
max-width: 600px;
margin: 0 auto 40px;
line-height: 1.7;
transition: color 0.3s ease;
}
/* Dark Theme */
.dark .testimonials-section {
background: #0f172a;
}
.dark .section-title {
color: #f1f5f9;
}
.dark .section-subtitle {
color: #94a3b8;
}
.testimonials-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 30px;
margin-bottom: 60px;
}
/* Mobile Responsive */
@media (max-width: 768px) {
.testimonials-section {
padding: 60px 0;
}
.container {
padding: 0 16px;
}
.section-title {
font-size: 2rem;
}
.section-subtitle {
font-size: 1rem;
}
.testimonials-grid {
grid-template-columns: 1fr;
gap: 20px;
}
}

View File

@@ -0,0 +1,72 @@
/* Testimonials Stats - Clean Simple Design */
.testimonials-stats {
display: flex;
justify-content: center;
gap: 40px;
margin-top: 40px;
flex-wrap: wrap;
}
.stat-item {
text-align: center;
background: white;
padding: 30px 20px;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
min-width: 140px;
border: 1px solid #e2e8f0;
transition: all 0.3s ease;
}
.stat-number {
font-size: 2.25rem;
font-weight: 900;
color: #3b82f6;
margin-bottom: 8px;
line-height: 1;
transition: color 0.3s ease;
}
.stat-label {
font-size: 0.875rem;
color: #64748b;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
transition: color 0.3s ease;
}
/* Dark Theme */
.dark .stat-item {
background: #1e293b;
border-color: #334155;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.dark .stat-number {
color: #60a5fa;
}
.dark .stat-label {
color: #94a3b8;
}
/* Mobile Responsive */
@media (max-width: 768px) {
.testimonials-stats {
gap: 20px;
}
.stat-item {
padding: 20px 16px;
min-width: 120px;
}
.stat-number {
font-size: 1.875rem;
}
.stat-label {
font-size: 0.75rem;
}
}

View File

@@ -0,0 +1,98 @@
<template>
<div class="testimonial-card">
<!-- Header -->
<div class="testimonial-header">
<div class="client-info">
<div class="client-avatar">
<img :src="testimonial.avatar" :alt="testimonial.name" loading="lazy" />
</div>
<div class="client-details">
<h4 class="client-name">{{ testimonial.name }}</h4>
<p class="client-role">{{ testimonial.role }}</p>
<p class="client-company">{{ testimonial.company }}</p>
</div>
</div>
<!-- Rating -->
<div class="rating">
<div class="stars">
<span v-for="star in 5" :key="star" class="star" :class="{ 'filled': star <= testimonial.rating }">
</span>
</div>
<span class="rating-text">{{ testimonial.rating }}/5</span>
</div>
</div>
<!-- Content -->
<div class="testimonial-content">
<blockquote>
{{ testimonial.content }}
</blockquote>
<!-- Project Info -->
<div v-if="testimonial.project_type" class="project-info">
<div class="project-tag">
<span class="project-type">{{ testimonial.project_type }}</span>
</div>
</div>
<!-- Results -->
<div v-if="testimonial.results" class="results">
<h5>{{ t('testimonials.card.results') }}</h5>
<ul>
<li v-for="result in testimonial.results" :key="result">
{{ result }}
</li>
</ul>
</div>
</div>
<!-- Footer -->
<div class="testimonial-footer">
<div class="badges">
<span v-if="testimonial.featured" class="badge featured">
🏆 {{ t('testimonials.card.featured') }}
</span>
<span class="badge platform">
{{ testimonial.platform }}
</span>
</div>
<div class="testimonial-date">
{{ formatRelativeTime(testimonial.date) }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n'
import { useDateFormat } from '@/composables/useDateFormat'
interface Testimonial {
name: string
role: string
company: string
avatar: string
rating: number
content: string
date: string
platform: string
featured?: boolean
project_type: string
results?: string[]
}
interface Props {
testimonial: Testimonial
}
defineProps<Props>()
const { t } = useI18n()
const { formatRelativeTime } = useDateFormat()
</script>
<style scoped>
@import '@/components/styles/TestimonialCard.css';
</style>

View File

@@ -0,0 +1,41 @@
<template>
<div class="testimonials-cta">
<h3 class="cta-title">{{ title }}</h3>
<p class="cta-subtitle">{{ subtitle }}</p>
<CTAButtons layout="row">
<RouterLink :to="link" class="btn btn-primary btn-lg">
{{ text }}
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
</RouterLink>
<a :href="reviewsLink" class="btn btn-secondary btn-lg" target="_blank" rel="noopener">
{{ reviewsText }}
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</CTAButtons>
</div>
</template>
<script setup lang="ts">
import CTAButtons from '@/components/shared/CTAButtons.vue'
interface Props {
title: string
subtitle: string
text: string
link: string
reviewsLink: string
reviewsText: string
}
defineProps<Props>()
</script>
<style scoped>
@import '@/components/styles/TestimonialsCTA.css';
</style>

View File

@@ -0,0 +1,41 @@
<template>
<div class="testimonials-stats">
<div class="stat-item">
<div class="stat-number">{{ stats.totalReviews }}+</div>
<div class="stat-label">{{ labels.clients }}</div>
</div>
<div class="stat-item">
<div class="stat-number">{{ stats.averageRating }}/5</div>
<div class="stat-label">{{ labels.rating }}</div>
</div>
<div class="stat-item">
<div class="stat-number">{{ stats.projectsCompleted }}+</div>
<div class="stat-label">{{ labels.projects }}</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Stats {
totalReviews: number
averageRating: number
projectsCompleted: number
}
interface Labels {
clients: string
rating: string
projects: string
}
interface Props {
stats: Stats
labels: Labels
}
defineProps<Props>()
</script>
<style scoped>
@import '@/components/styles/TestimonialsStats.css';
</style>