Guida completa per developer (codice completo)

Il periodo di San Valentino è sinonimo di connessione e quale modo migliore per celebrarlo se non attraverso un progetto che unisce tecnologia e sentimento?
In questa guida completa, vi accompagneremo nella creazione di Love Sync, un'applicazione React che testa la compatibilità di coppia con domande divertenti, animazioni fluide e risultati personalizzati.
Se volete vedere subito il risultato finale, potete provare l'app qui.
Questo progetto non è solo un tutorial: è un esempio concreto di come un'idea divertente possa trasformarsi in una Progressive Web App (PWA) completa, moderna e installabile.
Prima di immergerci nel codice, è importante capire perché React, abbinato a Vite e TailwindCSS, rappresenti la scelta ideale per questo progetto.
Per un'applicazione come Love Sync, dove l'interattività e il feedback visivo immediato (animazioni, transizioni) giocano un ruolo cruciale nell'esperienza utente (UX), il modello a componenti di React ci permette di creare interfacce modulari e reattive con estrema facilità.
Per garantire chiarezza e facilità di sviluppo, l'applicazione è stata progettata seguendo un approccio Component-Driven. Non abbiamo bisogno della complessità di Redux per questo scope; la gestione dello stato è centralizzata ma pulita.
La struttura è divisa logicamente:
La struttura delle cartelle segue le convenzioni moderne di React, mantenendo una separazione chiara tra logica, vista e dati.
src/ ├── components/ │ ├── StartScreen.jsx # Schermata iniziale │ ├── SetupScreen.jsx # Configurazione partita │ ├── QuestionCard.jsx # Card delle domande │ ├── PassPhoneScreen.jsx # Passaggio telefono │ ├── ResultScreen.jsx # Risultati finali │ ├── AIResultCard.jsx # Messaggi personalizzati │ ├── HistoryScreen.jsx # Storico partite │ └── HeartAnimation.jsx # Animazione cuori ├── data/ │ ├── questions.js # Database domande │ └── themes.js # Temi colore ├── services/ │ └── aiService.js # Generatore messaggi ├── sounds/ # File audio (opzionale) ├── App.jsx # Componente principale ├── main.jsx # Entry point └── index.css # Stili globali
Iniziamo con la configurazione dell'ambiente. Vite ci offre uno scaffolding istantaneo. Una volta installato TailwindCSS, definiamo subito l'estetica dell'app.
L'effetto vetro (Glassmorphism) è un trend moderno che dona profondità all'interfaccia, perfetto per un'app "romantica" ed elegante.
npm create vite@latest love-sync -- --template react cd love-sync npm install framer-motion npm install -D tailwindcss postcss autoprefixer npx tailwindcss init -p
Modifica tailwind.config.cjs:
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {},
},
plugins: [],
};
Definiamo le utility CSS globali in src/index.css:
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Font Google */
@import url("https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800&display=swap");
body {
font-family: "Nunito", sans-serif;
-webkit-font-smoothing: antialiased;
}
/* Effetto vetro (glassmorphism) */
.glass {
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
/* Nascondi scrollbar ma mantieni scroll */
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
La prima impressione conta. Utilizziamo framer-motion per animare l'ingresso degli elementi e il logo, creando un'esperienza accogliente fin dal primo caricamento. Notate come la logica di navigazione viene passata tramite props (onStart, onHistory), mantenendo i componenti "puri" e riutilizzabili.
// src/components/StartScreen.jsx
import { motion } from "framer-motion";
export default function StartScreen({ onStart, onShowHistory, hasHistory }) {
return (
<motion.div
className="glass p-8 rounded-3xl text-center max-w-md w-full text-white"
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -30 }}
>
<motion.h1
className="text-4xl font-bold mb-4"
animate={{ scale: [1, 1.05, 1] }}
transition={{ repeat: Infinity, duration: 2 }}
>
❤️ Love Sync
</motion.h1>
<p className="text-white/80 mb-6">
Il test di affinità in tempo reale per coppie.
</p>
<button
onClick={onStart}
className="bg-white text-pink-600 px-6 py-3 rounded-xl font-semibold shadow-lg w-full hover:bg-pink-50 transition mb-3"
>
Inizia ✨
</button>
{hasHistory && (
<button
onClick={onShowHistory}
className="bg-white/20 text-white px-6 py-2 rounded-xl font-medium w-full hover:bg-white/30 transition"
>
📊 Storico Partite
</button>
)}
<p className="text-xs text-white/60 mt-4">
Modalità Pass & Play – niente account, solo divertimento.
</p>
</motion.div>
);
}
La schermata di configurazione gestisce input controllati per nomi e preferenze, dimostrando la semplicità di gestione dei form in React senza librerie esterne.
// src/components/SetupScreen.jsx
import { useState } from "react";
import { motion } from "framer-motion";
import { categories } from "../data/questions";
export default function SetupScreen({ onStart }) {
const [player1Name, setPlayer1Name] = useState("");
const [player2Name, setPlayer2Name] = useState("");
const [questionCount, setQuestionCount] = useState(20);
const [category, setCategory] = useState("all");
const [timerEnabled, setTimerEnabled] = useState(false);
const [timerSeconds, setTimerSeconds] = useState(10);
const handleStart = () => {
onStart({
player1Name: player1Name.trim() || "Giocatore 1",
player2Name: player2Name.trim() || "Giocatore 2",
questionCount,
category,
timerEnabled,
timerSeconds,
});
};
return (
<motion.div
className="glass p-6 rounded-3xl max-w-md w-full text-white"
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -30 }}
>
<h2 className="text-2xl font-bold text-center mb-6">⚙️ Impostazioni</h2>
<div className="space-y-3 mb-6">
<div>
<label className="text-sm text-white/70 block mb-1">Nome Giocatore 1</label>
<input
type="text"
placeholder="Es: Marco"
value={player1Name}
onChange={(e) => setPlayer1Name(e.target.value)}
className="w-full px-4 py-2 rounded-xl bg-white/20 border border-white/30 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-white/50"
/>
</div>
<div>
<label className="text-sm text-white/70 block mb-1">Nome Giocatore 2</label>
<input
type="text"
placeholder="Es: Laura"
value={player2Name}
onChange={(e) => setPlayer2Name(e.target.value)}
className="w-full px-4 py-2 rounded-xl bg-white/20 border border-white/30 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-white/50"
/>
</div>
</div>
<div className="mb-6">
<label className="text-sm text-white/70 block mb-2">Categoria</label>
<div className="grid grid-cols-2 gap-2">
{categories.map((cat) => (
<button
key={cat.id}
onClick={() => setCategory(cat.id)}
className={`py-2 px-3 rounded-xl text-sm font-medium transition ${
category === cat.id ? "bg-white text-pink-600" : "bg-white/20 text-white hover:bg-white/30"
}`}
>
{cat.icon} {cat.name.split(" ").slice(1).join(" ")}
</button>
))}
</div>
</div>
<div className="mb-6">
<label className="text-sm text-white/70 block mb-2">
Numero domande: <span className="font-bold">{questionCount}</span>
</label>
<input
type="range"
min="20"
max="40"
value={questionCount}
onChange={(e) => setQuestionCount(Number(e.target.value))}
className="w-full accent-white"
/>
</div>
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<label className="text-sm text-white/70">Timer per risposta</label>
<button
onClick={() => setTimerEnabled(!timerEnabled)}
className={`w-12 h-6 rounded-full transition ${timerEnabled ? "bg-green-400" : "bg-white/30"}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ${timerEnabled ? "translate-x-6" : "translate-x-0.5"}`} />
</button>
</div>
{timerEnabled && (
<div className="flex items-center gap-2">
<input
type="range"
min="5"
max="30"
value={timerSeconds}
onChange={(e) => setTimerSeconds(Number(e.target.value))}
className="flex-1 accent-white"
/>
<span className="text-sm font-bold w-12">{timerSeconds}s</span>
</div>
)}
</div>
<button
onClick={handleStart}
className="bg-white text-pink-600 px-6 py-3 rounded-xl font-semibold shadow-lg w-full hover:bg-pink-50 transition"
>
Inizia il Quiz! 💕
</button>
</motion.div>
);
}
La QuestionCard utilizza AnimatePresence per creare transizioni fluide tra una domanda e l'altra. Questo piccolo dettaglio migliora drasticamente la percezione di qualità dell'app. La barra di progresso fornisce un feedback visivo immediato sullo stato della partita.
// src/components/QuestionCard.jsx
import { useEffect, useState, useCallback, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
export default function QuestionCard({
question,
onSelect,
player,
currentIndex,
totalQuestions,
timerEnabled = false,
timerSeconds = 10,
onTimeout,
}) {
const [timeLeft, setTimeLeft] = useState(timerSeconds);
const questionRef = useRef(question);
const hasAnsweredRef = useRef(false);
useEffect(() => {
questionRef.current = question;
hasAnsweredRef.current = false;
}, [question]);
const handleSelect = useCallback((answer) => {
if (hasAnsweredRef.current) return;
hasAnsweredRef.current = true;
onSelect(answer);
}, [onSelect]);
const handleTimeout = useCallback(() => {
if (hasAnsweredRef.current) return;
const currentQuestion = questionRef.current;
if (currentQuestion?.options?.length) {
const randomAnswer = currentQuestion.options[Math.floor(Math.random() * currentQuestion.options.length)];
hasAnsweredRef.current = true;
onSelect(randomAnswer);
}
}, [onSelect]);
useEffect(() => {
if (!timerEnabled || !question) return;
setTimeLeft(timerSeconds);
const interval = setInterval(() => {
setTimeLeft((prev) => {
if (prev <= 1) {
clearInterval(interval);
handleTimeout();
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
}, [timerEnabled, timerSeconds, question?.id, handleTimeout]);
if (!question) return null;
const progress = ((currentIndex + 1) / totalQuestions) * 100;
const timerProgress = (timeLeft / timerSeconds) * 100;
return (
<motion.div
className="glass p-8 rounded-3xl max-w-md w-full text-center text-white"
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -40 }}
>
{/* Progress Bar */}
<div className="mb-4">
<div className="flex justify-between text-sm text-white/70 mb-1">
<span>{player}</span>
<span>{currentIndex + 1}/{totalQuestions}</span>
</div>
<div className="w-full h-2 bg-white/20 rounded-full overflow-hidden">
<motion.div
className="h-full bg-white"
animate={{ width: `${progress}%` }}
/>
</div>
</div>
{/* Timer UI */}
{timerEnabled && (
<div className="mb-4">
<div className="flex justify-center items-center gap-2 mb-1">
<span>⏱️</span>
<span className={`text-xl font-bold ${timeLeft <= 3 ? "text-red-300 animate-pulse" : ""}`}>
{timeLeft}s
</span>
</div>
<div className="w-full h-1 bg-white/20 rounded-full overflow-hidden">
<motion.div
className={`h-full ${timeLeft <= 3 ? "bg-red-400" : "bg-green-400"}`}
animate={{ width: `${timerProgress}%` }}
/>
</div>
</div>
)}
<AnimatePresence mode="wait">
<motion.div
key={question.id}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
>
<h2 className="text-2xl font-bold mb-6">{question.text}</h2>
<div className="flex flex-col gap-4">
{question.options.map((opt, index) => (
<motion.button
key={index}
onClick={() => handleSelect(opt)}
className="bg-white/95 text-pink-600 py-3 rounded-xl font-semibold shadow hover:bg-white transition"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{opt}
</motion.button>
))}
</div>
</motion.div>
</AnimatePresence>
</motion.div>
);
}
Dato che l'app è pensata per essere usata su un solo dispositivo passato di mano in mano, abbiamo bisogno di una schermata intermedia "anti-sbirciatina".
// src/components/PassPhoneScreen.jsx
import { motion } from "framer-motion";
export default function PassPhoneScreen({ fromPlayer, toPlayer, onReady }) {
return (
<motion.div
className="glass p-8 rounded-3xl max-w-md w-full text-center text-white"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
>
<motion.div
className="text-6xl mb-4"
animate={{ rotate: [0, -10, 10, -10, 0] }}
transition={{ repeat: Infinity, duration: 1.5 }}
>
📱
</motion.div>
<h2 className="text-2xl font-bold mb-2">Passa il telefono!</h2>
<p className="text-white/80 mb-6">
{fromPlayer} ha finito.<br />
Ora tocca a <span className="font-bold text-pink-200">{toPlayer}</span>!
</p>
<p className="text-sm text-white/60 mb-6">
⚠️ Non sbirciare le risposte dell'altro!
</p>
<button
onClick={onReady}
className="bg-white text-pink-600 px-6 py-3 rounded-xl font-semibold shadow-lg w-full hover:bg-pink-50 transition"
>
Sono pronto/a! 🚀
</button>
</motion.div>
);
}
Invece di soluzioni complesse, utilizziamo gli Hook di React (useState, useEffect, useMemo) per orchestrare il flusso del gioco.
App.jsx agisce come "controllore", gestendo la macchina a stati finiti dell'applicazione.
// src/App.jsx
import { useMemo, useState, useEffect } from "react";
import { AnimatePresence } from "framer-motion";
import StartScreen from "./components/StartScreen";
import SetupScreen from "./components/SetupScreen";
import QuestionCard from "./components/QuestionCard";
import ResultScreen from "./components/ResultScreen";
import PassPhoneScreen from "./components/PassPhoneScreen";
import HistoryScreen from "./components/HistoryScreen";
import { getRandomQuestions } from "./data/questions";
import { getThemeById } from "./data/themes";
export default function App() {
const [step, setStep] = useState("start");
const [currentIndex, setCurrentIndex] = useState(0);
const [answersP1, setAnswersP1] = useState([]);
const [answersP2, setAnswersP2] = useState([]);
const [score, setScore] = useState(0);
const [gameQuestions, setGameQuestions] = useState([]);
const [player1Name, setPlayer1Name] = useState("Giocatore 1");
const [player2Name, setPlayer2Name] = useState("Giocatore 2");
const [currentTheme, setCurrentTheme] = useState("romantic");
const theme = getThemeById(currentTheme);
const handleAnswer = (answer) => {
if (step === "p1") {
setAnswersP1((prev) => [...prev, answer]);
} else {
setAnswersP2((prev) => [...prev, answer]);
}
if (currentIndex < gameQuestions.length - 1) {
setCurrentIndex((i) => i + 1);
} else {
if (step === "p1") setStep("passPhone");
else calculateResult();
}
};
const calculateResult = () => {
let matches = 0;
answersP1.forEach((ans, i) => {
if (ans === answersP2[i]) matches++;
});
setScore(Math.round((matches / gameQuestions.length) * 100));
setStep("result");
};
const startGame = (settings) => {
setPlayer1Name(settings.player1Name);
setPlayer2Name(settings.player2Name);
setGameQuestions(getRandomQuestions(settings.questionCount, settings.category));
setStep("p1");
setCurrentIndex(0);
};
return (
<div className={`min-h-screen flex items-center justify-center p-4 bg-gradient-to-br ${theme.gradient}`}>
<AnimatePresence mode="wait">
{step === "start" && <StartScreen onStart={() => setStep("setup")} />}
{step === "setup" && <SetupScreen onStart={startGame} />}
{step === "p1" && (
<QuestionCard
question={gameQuestions[currentIndex]}
onSelect={handleAnswer}
player={player1Name}
currentIndex={currentIndex}
totalQuestions={gameQuestions.length}
/>
)}
{/* Altri step omessi per brevità... */}
</AnimatePresence>
</div>
);
}
5. Data Model e Temi
Un punto di forza di questa architettura è la separazione dei dati. questions.js agisce come repository e themes.js permette una personalizzazione immediata dell'UI.
// src/data/questions.js
export const questions = [
{
id: 1,
text: "Qual è il regalo perfetto?",
options: ["Esperienze insieme", "Qualcosa di materiale"],
category: "romanticismo",
},
{
id: 2,
text: "San Valentino ideale?",
options: ["Cena romantica", "Avventura spontanea"],
category: "romanticismo",
}
// ... altre domande
];
export function getRandomQuestions(count = 20, categoryFilter = "all") {
let filtered = categoryFilter === "all" ? questions : questions.filter(q => q.category === categoryFilter);
return [...filtered].sort(() => Math.random() - 0.5).slice(0, count);
}
// src/data/themes.js
export const themes = [
{ id: "romantic", name: "Romantico", gradient: "from-pink-800 via-rose-900 to-red-950" },
{ id: "ocean", name: "Oceano", gradient: "from-blue-800 via-cyan-900 to-teal-950" }
];
Per rendere l'app più coinvolgente, abbiamo creato un aiService che utilizza template intelligenti per generare messaggi basati sul punteggio.
// src/services/aiService.js
const loveLetterTemplates = {
perfect: ["{p1} e {p2}, siete anime gemelle! 💕", "{p1} e {p2}, sintonia magica! ✨"],
low: ["{p1} e {p2}, gli opposti si attraggono! 🔥"]
};
export const generateLoveContent = (p1, p2, score) => {
const category = score === 100 ? "perfect" : score >= 40 ? "high" : "low";
const template = loveLetterTemplates[category][0];
return template.replace("{p1}", p1).replace("{p2}", p2);
};
Una PWA di qualità si distingue per i dettagli. HeartAnimation genera particelle dinamiche utilizzando Framer Motion.
// src/components/HeartAnimation.jsx
import { useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
export default function HeartAnimation({ trigger }) {
const [hearts, setHearts] = useState([]);
useEffect(() => {
if (trigger > 0) {
const newHearts = Array.from({ length: 15 }, (_, i) => ({
id: Date.now() + i,
x: Math.random() * 100,
emoji: ["❤️", "💖", "💘"][Math.floor(Math.random() * 3)],
}));
setHearts(newHearts);
setTimeout(() => setHearts([]), 2500);
}
}, [trigger]);
return (
<div className="fixed inset-0 pointer-events-none z-50">
<AnimatePresence>
{hearts.map((heart) => (
<motion.div
key={heart.id}
className="absolute"
style={{ left: `${heart.x}%` }}
initial={{ y: "100vh", opacity: 1 }}
animate={{ y: "-20vh", opacity: 0 }}
transition={{ duration: 2 }}
>
{heart.emoji}
</motion.div>
))}
</AnimatePresence>
</div>
);
}
Il manifest.json trasforma il nostro sito in un'app installabile sulla home screen:
{
"name": "Love Sync",
"short_name": "Love Sync",
"start_url": "/",
"display": "standalone",
"background_color": "#ec4899",
"theme_color": "#ec4899",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
Vi invito a prendere questo codice, giocarci e migliorarlo. Lo trovate qui: https://github.com/Unitiva/valentine-app
Buon coding e buon San Valentino! 💕