Files
Portfolio-Game/frontend/app/pages/contact.vue
skycel 7e87a341a2 feat(epic-4): chemins narratifs, easter eggs, challenge et contact
Epic 4: Chemins Narratifs, Challenge & Contact

Stories implementees:
- 4.1: Composant ChoiceCards pour choix narratifs binaires
- 4.2: Sequence d'intro narrative avec Le Bug
- 4.3: Chemins narratifs differencies avec useNarrativePath
- 4.4: Table easter_eggs et systeme de detection (API + composable)
- 4.5: Easter eggs UI (popup, notification, collection)
- 4.6: Page challenge avec puzzle de code
- 4.7: Page revelation "Monde de Code"
- 4.8: Page contact avec formulaire et stats

Fichiers crees:
- Frontend: ChoiceCards, IntroSequence, ZoneEndChoice, EasterEggPopup,
  CodePuzzle, ChallengeSuccess, CodeWorld, et pages intro/challenge/revelation
- API: EasterEggController, Model, Migration, Seeder

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 13:35:12 +01:00

222 lines
6.2 KiB
Vue

<template>
<div class="max-w-2xl mx-auto px-4 py-8 md:py-12">
<!-- Stats du parcours -->
<div class="bg-sky-dark-50 rounded-xl p-6 mb-8">
<h2 class="text-xl font-ui font-bold text-sky-text mb-4">
{{ $t('contact.statsTitle') }}
</h2>
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
<div class="text-center">
<p class="text-3xl font-ui font-bold text-sky-accent">{{ stats.zonesVisited }}/{{ stats.zonesTotal }}</p>
<p class="text-sm text-sky-text/60">{{ $t('contact.zones') }}</p>
</div>
<div class="text-center">
<p class="text-3xl font-ui font-bold text-sky-accent">{{ stats.easterEggsFound }}/{{ stats.easterEggsTotal }}</p>
<p class="text-sm text-sky-text/60">{{ $t('contact.easterEggs') }}</p>
</div>
<div class="text-center">
<p class="text-3xl font-ui font-bold" :class="stats.challengeCompleted ? 'text-green-400' : 'text-sky-text/40'">
{{ stats.challengeCompleted ? 'OK' : '-' }}
</p>
<p class="text-sm text-sky-text/60">{{ $t('contact.challenge') }}</p>
</div>
</div>
</div>
<!-- Message de congratulations -->
<div class="text-center mb-8">
<h1 class="text-3xl md:text-4xl font-ui font-bold text-sky-text mb-4">
{{ $t('contact.title') }}
</h1>
<p class="font-narrative text-sky-text/60 text-lg">
{{ $t('contact.subtitle') }}
</p>
</div>
<!-- Success message -->
<Transition name="fade">
<div
v-if="isSubmitted"
class="bg-green-500/10 border border-green-500/50 rounded-xl p-8 text-center"
>
<div class="text-4xl mb-4" aria-hidden="true">!</div>
<h2 class="text-2xl font-ui font-bold text-green-400 mb-2">
{{ $t('contact.success') }}
</h2>
<p class="text-sky-text/60 font-narrative">
{{ $t('contact.successMessage') }}
</p>
</div>
</Transition>
<!-- Formulaire -->
<form
v-if="!isSubmitted"
class="space-y-6"
@submit.prevent="handleSubmit"
>
<!-- Honeypot (anti-spam) -->
<input
v-model="form.website"
type="text"
name="website"
class="hidden"
tabindex="-1"
autocomplete="off"
/>
<!-- Nom -->
<div>
<label for="name" class="block font-ui font-semibold text-sky-text mb-2">
{{ $t('contact.name') }} *
</label>
<input
id="name"
v-model="form.name"
type="text"
required
class="w-full px-4 py-3 bg-sky-dark border border-sky-dark-100 rounded-lg text-sky-text focus:border-sky-accent focus:outline-none transition-colors"
:placeholder="$t('contact.namePlaceholder')"
/>
</div>
<!-- Email -->
<div>
<label for="email" class="block font-ui font-semibold text-sky-text mb-2">
{{ $t('contact.email') }} *
</label>
<input
id="email"
v-model="form.email"
type="email"
required
class="w-full px-4 py-3 bg-sky-dark border border-sky-dark-100 rounded-lg text-sky-text focus:border-sky-accent focus:outline-none transition-colors"
:placeholder="$t('contact.emailPlaceholder')"
/>
</div>
<!-- Message -->
<div>
<label for="message" class="block font-ui font-semibold text-sky-text mb-2">
{{ $t('contact.message') }} *
</label>
<textarea
id="message"
v-model="form.message"
required
rows="5"
class="w-full px-4 py-3 bg-sky-dark border border-sky-dark-100 rounded-lg text-sky-text focus:border-sky-accent focus:outline-none transition-colors resize-none"
:placeholder="$t('contact.messagePlaceholder')"
/>
</div>
<!-- Error message -->
<p v-if="errorMessage" class="text-red-400 font-ui text-sm">
{{ errorMessage }}
</p>
<!-- Submit -->
<button
type="submit"
class="w-full py-4 bg-sky-accent text-white font-ui font-semibold rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50"
:disabled="isSubmitting"
>
{{ isSubmitting ? $t('contact.sending') : $t('contact.send') }}
</button>
</form>
<!-- Choice for next zone -->
<FeatureZoneEndChoice />
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'adventure',
})
const { t } = useI18n()
const { setPageMeta } = useSeo()
const config = useRuntimeConfig()
const progressionStore = useProgressionStore()
const { availableEasterEggs } = useFetchEasterEggs()
setPageMeta({
title: t('pages.contact.title'),
description: t('pages.contact.description'),
})
onMounted(() => {
progressionStore.visitSection('contact')
})
// Stats
const stats = computed(() => ({
zonesVisited: progressionStore.visitedSections.length,
zonesTotal: 4,
easterEggsFound: progressionStore.easterEggsFoundCount,
easterEggsTotal: availableEasterEggs.value.length || 8,
challengeCompleted: progressionStore.challengeCompleted,
}))
// Form state
const form = reactive({
name: '',
email: '',
message: '',
website: '', // Honeypot
})
const isSubmitting = ref(false)
const isSubmitted = ref(false)
const errorMessage = ref('')
async function handleSubmit() {
// Check honeypot
if (form.website) {
return
}
isSubmitting.value = true
errorMessage.value = ''
try {
await $fetch('/contact', {
method: 'POST',
baseURL: config.public.apiUrl as string,
headers: {
'X-API-Key': config.public.apiKey as string,
},
body: {
name: form.name,
email: form.email,
message: form.message,
},
})
isSubmitted.value = true
} catch (error: unknown) {
const err = error as { statusCode?: number }
if (err.statusCode === 429) {
errorMessage.value = t('contact.rateLimitError')
} else {
errorMessage.value = t('contact.error')
}
} finally {
isSubmitting.value = false
}
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>