Creare un'App React per l’affinità di coppia

Guida completa per developer (codice completo)

react valentine app

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.

Perché React e questo Stack?

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à.

  • Vite: garantisce una build ultra-rapida e una Developer Experience (DX) eccellente.
  • TailwindCSS: permette di implementare un design system complesso (come l'effetto Glassmorphism) senza uscire dal markup.
  • Framer Motion: è la chiave per dare "vita" all'app, gestendo transizioni complesse tra le schermate che sarebbero difficili da realizzare con il solo CSS.

Architettura del progetto: Component-Driven e modulare

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:

  • Components: i blocchi visivi dell'UI (schermate di gioco, card).
  • Data: il "database" statico delle domande e dei temi.
  • Services: logica separata per la generazione dei risultati (simulazione AI).
  • Assets: gestione di suoni e media.

Struttura delle cartelle

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

Guida passo passo all'implementazione

1. Setup Iniziale e Styling "Glassmorphism"

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;
}

2. Il cuore dell'interazione: Start e Setup

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.

StartScreen.jsx

// 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.

SetupScreen.jsx

// 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>
  );
}

3. UX Dinamica: QuestionCard e Transizioni

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>
  );
}

4. Gestione dello Stato: App.jsx

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" }
];

6. Risultati e Simulazione AI

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);
};

7. Il tocco magico: HeartAnimation

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>
  );
}

Persistenza e PWA

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" }
  ]
}

Deploy su Netlify/Vercel

  • Crea un account su netlify.com o vercel.com
  • Connetti il repository GitHub
  • Deploy automatico ad ogni push!

Il valore dell’Open Source

Vi invito a prendere questo codice, giocarci e migliorarlo. Lo trovate qui: https://github.com/Unitiva/valentine-app

Buon coding e buon San Valentino! 💕

Autoreadmin
Potrebbero interessarti...
back to top icon