Initial commit

This commit is contained in:
jeremy bayse
2026-03-20 08:25:58 +01:00
commit a55a33ae2a
143 changed files with 19599 additions and 0 deletions

View File

@@ -0,0 +1,262 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { Head, router } from '@inertiajs/vue3';
import axios from 'axios';
const props = defineProps({
quiz: Object,
attempt: Object
});
const currentQuestionIndex = ref(0);
const answers = ref({});
const timeLeft = ref(props.quiz.duration_minutes * 60);
let timer = null;
// Initialize answers from existing attempt answers if any
onMounted(() => {
// Initialize all questions with empty answers to avoid v-model errors
props.quiz.questions.forEach(q => {
answers.value[q.id] = { option_id: null, text_content: '' };
});
// Populate with existing answers
props.attempt.answers.forEach(ans => {
answers.value[ans.question_id] = {
option_id: ans.option_id,
text_content: ans.text_content || ''
};
});
startTimer();
});
const startTimer = () => {
// Calculate elapsed time since start
const startTime = new Date(props.attempt.started_at).getTime();
const now = new Date().getTime();
const elapsedSeconds = Math.floor((now - startTime) / 1000);
timeLeft.value = Math.max(0, (props.quiz.duration_minutes * 60) - elapsedSeconds);
timer = setInterval(() => {
if (timeLeft.value > 0) {
timeLeft.value--;
} else {
finishQuiz();
}
}, 1000);
};
onUnmounted(() => {
if (timer) clearInterval(timer);
});
const formatTime = (seconds) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const currentQuestion = computed(() => props.quiz.questions[currentQuestionIndex.value]);
const progress = computed(() => ((currentQuestionIndex.value + 1) / props.quiz.questions.length) * 100);
const saveAnswer = async () => {
const qid = currentQuestion.value.id;
const ans = answers.value[qid] || {};
try {
await axios.post(route('attempts.save', props.attempt.id), {
question_id: qid,
...ans
});
} catch (e) {
console.error('Save failed', e);
}
};
const selectOption = (optionId) => {
answers.value[currentQuestion.value.id] = {
option_id: optionId,
text_content: null
};
saveAnswer();
};
const updateOpenAnswer = (text) => {
answers.value[currentQuestion.value.id] = {
option_id: null,
text_content: text
};
// Debounce save for text content in a real app, here we just save on "Next" or small timeout
};
const nextQuestion = () => {
if (currentQuestionIndex.value < props.quiz.questions.length - 1) {
currentQuestionIndex.value++;
} else {
finishQuiz();
}
};
const prevQuestion = () => {
if (currentQuestionIndex.value > 0) {
currentQuestionIndex.value--;
}
};
const finishQuiz = () => {
if (timer) clearInterval(timer);
router.post(route('attempts.finish', props.attempt.id));
};
</script>
<template>
<Head :title="quiz.title" />
<div class="min-h-screen bg-slate-900 text-slate-100 flex flex-col font-sans selection:bg-indigo-500/30">
<!-- Header -->
<header class="h-20 flex items-center justify-between px-8 border-b border-slate-800 bg-slate-900/80 backdrop-blur sticky top-0 z-10">
<div class="flex items-center gap-4">
<div class="w-10 h-10 bg-indigo-600 rounded-lg flex items-center justify-center font-bold text-xl shadow-[0_0_15px_rgba(79,70,229,0.4)]">
Q
</div>
<div>
<h1 class="font-bold text-lg leading-none">{{ quiz.title }}</h1>
<p class="text-slate-500 text-xs mt-1 uppercase tracking-widest font-semibold">{{ currentQuestionIndex + 1 }} sur {{ quiz.questions.length }}</p>
</div>
</div>
<div class="flex items-center gap-6">
<!-- Progress Circular (CSS only simplification) -->
<div class="hidden sm:flex items-center gap-2">
<span class="text-xs text-slate-500 font-bold uppercase tracking-wider">Progression</span>
<div class="w-32 h-2 bg-slate-800 rounded-full overflow-hidden">
<div class="h-full bg-indigo-500 transition-all duration-500" :style="{ width: progress + '%' }"></div>
</div>
</div>
<div class="flex items-center gap-2 bg-slate-800/50 px-4 py-2 rounded-xl border border-slate-700 shadow-inner">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-indigo-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="font-mono text-xl font-bold" :class="{ 'text-red-400 animate-pulse': timeLeft < 300 }">
{{ formatTime(timeLeft) }}
</span>
</div>
</div>
</header>
<!-- Main Content -->
<main class="flex-1 flex flex-col items-center justify-center p-8">
<div class="w-full max-w-3xl">
<!-- Question Card -->
<div class="bg-slate-800/50 backdrop-blur-xl rounded-3xl p-8 sm:p-12 border border-slate-700 shadow-2xl relative overflow-hidden group">
<!-- Subtle background glow -->
<div class="absolute -top-24 -right-24 w-64 h-64 bg-indigo-600/10 blur-[100px] rounded-full group-hover:bg-indigo-600/20 transition-all duration-700"></div>
<div class="relative z-10">
<div class="mb-8">
<div v-if="currentQuestion.title" class="px-3 py-1 bg-indigo-500/20 text-indigo-400 border border-indigo-500/30 rounded-lg text-xs font-black uppercase tracking-widest inline-block mb-4">
{{ currentQuestion.title }}
</div>
<h2 class="text-2xl sm:text-3xl font-bold leading-tight">
{{ currentQuestion.label }}
</h2>
<div v-if="currentQuestion.context" class="mt-6 p-6 bg-slate-700/20 border-l-4 border-indigo-500 rounded-r-2xl text-slate-300 leading-relaxed italic">
{{ currentQuestion.context }}
</div>
</div>
<!-- QCM Options -->
<div v-if="currentQuestion.type === 'qcm'" class="grid gap-4">
<button
v-for="option in currentQuestion.options"
:key="option.id"
@click="selectOption(option.id)"
class="w-full text-left p-6 rounded-2xl border transition-all duration-200 flex items-center justify-between group/opt"
:class="[
answers[currentQuestion.id]?.option_id === option.id
? 'bg-indigo-600 border-indigo-400 shadow-lg shadow-indigo-600/20 translate-x-1'
: 'bg-slate-700/30 border-slate-600 hover:border-slate-400 hover:bg-slate-700/50'
]"
>
<span class="font-medium text-lg">{{ option.option_text }}</span>
<div
class="w-6 h-6 rounded-full border-2 flex items-center justify-center transition-colors"
:class="[
answers[currentQuestion.id]?.option_id === option.id
? 'border-white bg-white'
: 'border-slate-500 group-hover/opt:border-slate-300'
]"
>
<div v-if="answers[currentQuestion.id]?.option_id === option.id" class="w-2.5 h-2.5 bg-indigo-600 rounded-full"></div>
</div>
</button>
</div>
<!-- Open Question -->
<div v-else>
<textarea
class="w-full h-48 bg-slate-700/30 border border-slate-600 rounded-2xl p-6 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-all outline-none resize-none text-lg"
placeholder="Saisissez votre réponse ici..."
v-model="answers[currentQuestion.id].text_content"
@blur="saveAnswer"
></textarea>
</div>
</div>
</div>
<!-- Navigation Controls -->
<div class="mt-12 flex justify-between items-center px-4">
<button
@click="prevQuestion"
:disabled="currentQuestionIndex === 0"
class="flex items-center gap-2 font-bold uppercase tracking-widest text-sm text-slate-400 hover:text-white transition-colors disabled:opacity-30 disabled:pointer-events-none"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
Précédent
</button>
<div class="flex items-center gap-1">
<div
v-for="(_, i) in quiz.questions"
:key="i"
class="w-1.5 h-1.5 rounded-full transition-all duration-300"
:class="[i === currentQuestionIndex ? 'bg-indigo-500 w-6' : (answers[quiz.questions[i].id] ? 'bg-slate-500' : 'bg-slate-700')]"
></div>
</div>
<button
@click="nextQuestion"
class="group flex items-center gap-3 bg-white text-slate-900 px-8 py-4 rounded-2xl font-bold uppercase tracking-widest text-sm hover:bg-indigo-500 hover:text-white transition-all duration-300 active:scale-95 shadow-xl shadow-white/5"
>
{{ currentQuestionIndex === quiz.questions.length - 1 ? 'Terminer' : 'Suivant' }}
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 transition-transform group-hover:translate-x-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
</main>
<!-- Footer / Feedback -->
<footer class="h-12 flex items-center px-8 text-[10px] text-slate-600 uppercase tracking-[0.2em] font-bold">
RecruitQuizz Pro v1.0 Vos réponses sont sauvegardées automatiquement.
</footer>
</div>
</template>
<style scoped>
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
</style>

View File

@@ -0,0 +1,33 @@
<script setup>
import { Head, Link } from '@inertiajs/vue3';
</script>
<template>
<Head title="Merci !" />
<div class="min-h-screen bg-slate-900 text-slate-100 flex flex-col items-center justify-center p-8 selection:bg-indigo-500/30">
<div class="max-w-xl text-center">
<div class="w-24 h-24 bg-emerald-500/10 border border-emerald-500/20 text-emerald-500 rounded-full flex items-center justify-center mx-auto mb-8 shadow-[0_0_50px_rgba(16,185,129,0.2)] scale-110">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<h1 class="text-4xl font-black mb-4 tracking-tight">Merci pour votre participation !</h1>
<p class="text-slate-400 text-lg mb-12">
Votre test technique a été validé avec succès. Notre équipe va maintenant analyser vos résultats et reviendra vers vous dès que possible.
</p>
<Link
:href="route('dashboard')"
class="inline-block bg-white text-slate-900 px-10 py-4 rounded-2xl font-black uppercase tracking-[0.2em] text-sm hover:bg-emerald-500 hover:text-white transition-all duration-500 shadow-xl shadow-white/5 active:scale-95"
>
Retour à l'accueil
</Link>
</div>
<footer class="mt-20 text-[10px] text-slate-600 uppercase tracking-[0.4em] font-bold">
RecruitQuizz Pro Système de recrutement automatisé
</footer>
</div>
</template>