浏览代码

feat/add donation modal (#68)

Mat B. 1 月之前
父节点
当前提交
628c9725f0

+ 20 - 0
locales/en.ts

@@ -81,6 +81,26 @@ export default {
     },
   },
 
+  // Donation Modal
+  donation_modal: {
+    title: "Support the project",
+    congrats: "Congratulations on your workout! 🎉",
+    subtitle: "This app helps you for free, but it has a real cost for me...",
+    costs_title: "The reality of costs",
+    costs_description: "Currently, donations don't even cover basic costs: servers, authentication, infrastructure, database, etc.",
+    open_source_title: "100% Open Source",
+    open_source_description:
+      "This app is completely free and open source. No profit is generated - it's a passion project to help the community and help people exercise.",
+    no_ads: "No ads",
+    no_tracking: "No tracking",
+    impact_title: "Your impact",
+    impact_3_euros: "• Even €3 covers 1 week of server",
+    impact_support: "• Your support keeps the app free for everyone",
+    impact_footer: "Every donation, even small, makes a real difference! 🙏",
+    later_button: "Later",
+    support_button: "Support the project",
+  },
+
   // Contact Support
   contact_support: "Contact Support",
   contact_support_subtitle: "Describe your issue and we'll help you as soon as possible. You can also write to us directly at",

+ 21 - 0
locales/es.ts

@@ -81,6 +81,27 @@ export default {
     },
   },
 
+  // Donation Modal
+  donation_modal: {
+    title: "Apoya el proyecto",
+    congrats: "¡Felicidades por tu entrenamiento! 🎉",
+    subtitle: "Esta app te ayuda gratis, pero tiene un costo real para mí...",
+    costs_title: "La realidad de los costos",
+    costs_description:
+      "Actualmente, las donaciones ni siquiera cubren los costos básicos: servidores, autenticación, infraestructura, base de datos, etc.",
+    open_source_title: "100% Open Source",
+    open_source_description:
+      "Esta app es completamente gratuita y de código abierto. No se genera ganancia - es un proyecto de pasión para ayudar a la comunidad y ayudar a las personas a hacer ejercicio.",
+    no_ads: "Sin publicidad",
+    no_tracking: "Sin rastreo",
+    impact_title: "Tu impacto",
+    impact_3_euros: "• Incluso €3 cubren 1 semana de servidor",
+    impact_support: "• Tu apoyo mantiene la app gratuita para todos",
+    impact_footer: "¡Cada donación, incluso pequeña, hace una diferencia real! 🙏",
+    later_button: "Más tarde",
+    support_button: "Apoyar el proyecto",
+  },
+
   // Contact Support
   contact_support: "Contactar soporte",
   contact_support_subtitle: "Describe tu problema y te ayudaremos lo antes posible. También puedes escribirnos directamente a",

+ 21 - 0
locales/fr.ts

@@ -81,6 +81,27 @@ export default {
     },
   },
 
+  // Donation Modal
+  donation_modal: {
+    title: "Soutenez le projet",
+    congrats: "Félicitations pour la séance ! 🎉",
+    subtitle: "Cette app vous aide gratuitement, mais elle a un coût réel pour moi...",
+    costs_title: "La réalité des coûts",
+    costs_description:
+      "Actuellement, les donations ne couvrent même pas les coûts de base : serveurs, authentification, infrastructure, base de données, etc.",
+    open_source_title: "100% Open Source",
+    open_source_description:
+      "Cette app est entièrement gratuite et open source. Aucun profit n'est généré - c'est un projet de passion pour aider la communauté et aider les gens à faire du sport.",
+    no_ads: "Pas de pub",
+    no_tracking: "Pas de tracking",
+    impact_title: "Votre impact",
+    impact_3_euros: "• Même 3€ couvrent 1 semaine de serveur",
+    impact_support: "• Votre soutien garde l'app gratuite pour tous",
+    impact_footer: "Chaque don, même petit, fait une vraie différence ! 🙏",
+    later_button: "Plus tard",
+    support_button: "Soutenir le projet",
+  },
+
   // Contact Support
   contact_support: "Contacter le support",
   contact_support_subtitle: "Décrivez votre problème et nous vous aiderons dès que possible. Vous pouvez aussi nous écrire directement à",

+ 19 - 0
locales/zh-CN.ts

@@ -79,6 +79,25 @@ export default {
     },
   },
 
+  // Donation Modal
+  donation_modal: {
+    title: "支持项目",
+    congrats: "恭喜完成锻炼!🎉",
+    subtitle: "这个应用免费帮助您,但对我来说有真正的成本...",
+    costs_title: "成本现实",
+    costs_description: "目前,捐赠甚至无法覆盖基本成本:服务器、身份验证、基础设施、数据库等。",
+    open_source_title: "100% 开源",
+    open_source_description: "这个应用完全免费且开源。不产生任何利润 - 这是一个激情项目,帮助社区和帮助人们锻炼。",
+    no_ads: "无广告",
+    no_tracking: "无追踪",
+    impact_title: "您的影响",
+    impact_3_euros: "• 即使 €3 也能覆盖 1 周的服务器费用",
+    impact_support: "• 您的支持让应用对所有人保持免费",
+    impact_footer: "每一笔捐赠,即使很小,都会产生真正的影响!🙏",
+    later_button: "稍后",
+    support_button: "支持项目",
+  },
+
   // Contact Support
   contact_support: "联系支持",
   contact_support_subtitle: "描述您的问题,我们将尽快帮助您。您也可以直接写信给我们:",

+ 22 - 7
src/features/workout-builder/ui/workout-stepper.tsx

@@ -10,6 +10,8 @@ import { useI18n } from "locales/client";
 import Trophy from "@public/images/trophy.png";
 import { WorkoutSessionSets } from "@/features/workout-session/ui/workout-session-sets";
 import { WorkoutSessionHeader } from "@/features/workout-session/ui/workout-session-header";
+import { DonationModal } from "@/features/workout-session/ui/donation-modal";
+import { useDonationModal } from "@/features/workout-session/hooks/use-donation-modal";
 import { WorkoutBuilderFooter } from "@/features/workout-builder/ui/workout-stepper-footer";
 import { Button } from "@/components/ui/button";
 
@@ -124,11 +126,20 @@ export function WorkoutStepper() {
   };
 
   const [showCongrats, setShowCongrats] = useState(false);
+  const { showModal, openModal, closeModal } = useDonationModal();
 
   const goToProfile = () => {
     router.push("/profile");
   };
 
+  const handleCongrats = () => {
+    setShowCongrats(true);
+    // Show donation modal after congrats screen appears
+    setTimeout(() => {
+      openModal();
+    }, 400);
+  };
+
   const handleToggleEquipment = (equipment: ExerciseAttributeValueEnum) => {
     toggleEquipment(equipment);
     if (fromSession) setFromSession(null);
@@ -152,12 +163,16 @@ export function WorkoutStepper() {
 
   if (showCongrats && !isWorkoutActive) {
     return (
-      <div className="flex flex-col items-center justify-center py-16 h-full">
-        <Image alt="Trophée" className="w-56 h-56" src={Trophy} />
-        <h2 className="text-2xl font-bold mb-2">{t("workout_builder.session.congrats")}</h2>
-        <p className="text-lg text-slate-600 mb-6">{t("workout_builder.session.congrats_subtitle")}</p>
-        <Button onClick={goToProfile}>{t("commons.go_to_profile")}</Button>
-      </div>
+      <>
+        <div className="flex flex-col items-center justify-center py-16 h-full">
+          <Image alt="Trophée" className="w-56 h-56" src={Trophy} />
+          <h2 className="text-2xl font-bold mb-2 text-center">{t("workout_builder.session.congrats")}</h2>
+          <p className="text-lg text-slate-600 mb-6">{t("workout_builder.session.congrats_subtitle")}</p>
+          <Button onClick={goToProfile}>{t("commons.go_to_profile")}</Button>
+        </div>
+        {/* Donation Modal */}
+        <DonationModal isOpen={showModal} onClose={closeModal} />
+      </>
     );
   }
 
@@ -165,7 +180,7 @@ export function WorkoutStepper() {
     return (
       <div className="w-full max-w-6xl mx-auto">
         {!showCongrats && <WorkoutSessionHeader onQuitWorkout={quitWorkout} />}
-        <WorkoutSessionSets isWorkoutActive={isWorkoutActive} onCongrats={() => setShowCongrats(true)} showCongrats={showCongrats} />
+        <WorkoutSessionSets isWorkoutActive={isWorkoutActive} onCongrats={handleCongrats} showCongrats={showCongrats} />
       </div>
     );
   }

+ 21 - 0
src/features/workout-session/hooks/use-donation-modal.ts

@@ -0,0 +1,21 @@
+"use client";
+
+import { useState } from "react";
+
+export function useDonationModal() {
+  const [showModal, setShowModal] = useState(false);
+
+  const openModal = () => {
+    setShowModal(true);
+  };
+
+  const closeModal = () => {
+    setShowModal(false);
+  };
+
+  return {
+    showModal,
+    openModal,
+    closeModal,
+  };
+}

+ 131 - 0
src/features/workout-session/ui/donation-modal.tsx

@@ -0,0 +1,131 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { Heart, X, Code, Server } from "lucide-react";
+
+import { useI18n } from "locales/client";
+import { Button } from "@/components/ui/button";
+
+interface DonationModalProps {
+  isOpen: boolean;
+  onClose: () => void;
+}
+
+export function DonationModal({ isOpen, onClose }: DonationModalProps) {
+  const t = useI18n();
+  const modalRef = useRef<HTMLDialogElement>(null);
+
+  useEffect(() => {
+    const modal = modalRef.current;
+    if (!modal) return;
+
+    if (isOpen) {
+      modal.showModal();
+    } else {
+      modal.close();
+    }
+  }, [isOpen]);
+
+  useEffect(() => {
+    const modal = modalRef.current;
+    if (!modal) return;
+
+    const handleClose = () => {
+      onClose();
+    };
+
+    modal.addEventListener("close", handleClose);
+    return () => modal.removeEventListener("close", handleClose);
+  }, [onClose]);
+
+  const handleDonate = () => {
+    window.open("https://ko-fi.com/workoutcool", "_blank");
+    onClose();
+  };
+
+  return (
+    <dialog className="modal modal-bottom sm:modal-middle" ref={modalRef} style={{ padding: 0 }}>
+      <div className="modal-box max-w-lg">
+        {/* Header */}
+        <div className="flex items-center justify-between mb-4">
+          <div className="flex items-center gap-2">
+            <Heart className="h-6 w-6 text-red-500" />
+            <h3 className="font-bold text-lg text-slate-900 dark:text-slate-100">{t("donation_modal.title")}</h3>
+          </div>
+          <form method="dialog">
+            <Button className="p-1" size="small" variant="ghost">
+              <X className="h-4 w-4" />
+            </Button>
+          </form>
+        </div>
+
+        {/* Content */}
+        <div className="space-y-4 mb-6">
+          <div className="text-center">
+            <p className="text-slate-600 dark:text-slate-400 leading-relaxed">{t("donation_modal.congrats")}</p>
+            <p className="text-slate-600 dark:text-slate-400 leading-relaxed mt-2">{t("donation_modal.subtitle")}</p>
+          </div>
+
+          {/* Transparency section */}
+          <div className="bg-gradient-to-r from-orange-50 to-red-50 dark:from-orange-900/20 dark:to-red-900/20 p-4 rounded-lg border border-orange-200 dark:border-orange-800">
+            <div className="flex items-center gap-2 mb-3">
+              <Server className="h-4 w-4 text-orange-600" />
+              <span className="font-semibold text-sm text-slate-900 dark:text-slate-100">{t("donation_modal.costs_title")}</span>
+            </div>
+            <p className="text-sm text-slate-600 dark:text-slate-400 leading-relaxed mb-2">{t("donation_modal.costs_description")}</p>
+          </div>
+
+          {/* Open source value */}
+          <div className="bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-900/20 dark:to-purple-900/20 p-4 rounded-lg border border-blue-200 dark:border-blue-800">
+            <div className="flex items-center gap-2 mb-2">
+              <Code className="h-4 w-4 text-blue-600" />
+              <span className="font-semibold text-sm text-slate-900 dark:text-slate-100">{t("donation_modal.open_source_title")}</span>
+            </div>
+            <p className="text-sm text-slate-600 dark:text-slate-400 leading-relaxed mb-2">{t("donation_modal.open_source_description")}</p>
+            <div className="grid grid-cols-2 gap-2 text-xs ">
+              <div className="flex items-center justify-center gap-1 text-blue-700 dark:text-blue-400">
+                <Heart className="h-3 w-3" />
+                <span>{t("donation_modal.no_ads")}</span>
+              </div>
+              <div className="flex items-center justify-center gap-1 text-blue-700 dark:text-blue-400">
+                <Heart className="h-3 w-3" />
+                <span>{t("donation_modal.no_tracking")}</span>
+              </div>
+            </div>
+          </div>
+
+          {/* Impact section */}
+          <div className="bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 p-4 rounded-lg border border-green-200 dark:border-green-800">
+            <div className="flex items-center gap-2 mb-2">
+              <Heart className="h-4 w-4 text-green-600" />
+              <span className="font-semibold text-sm text-slate-900 dark:text-slate-100">{t("donation_modal.impact_title")}</span>
+            </div>
+            <ul className="text-sm text-slate-600 dark:text-slate-400 space-y-1">
+              <li>{t("donation_modal.impact_3_euros")}</li>
+
+              <li>{t("donation_modal.impact_support")}</li>
+            </ul>
+            <p className="text-xs text-center text-green-700 dark:text-green-400 mt-2 font-medium">{t("donation_modal.impact_footer")}</p>
+          </div>
+        </div>
+
+        {/* Actions */}
+        <div className="modal-action">
+          <form className="flex gap-2 w-full flex-col sm:flex-row" method="dialog">
+            <Button className="flex-1" onClick={onClose} size="small" variant="outline">
+              {t("donation_modal.later_button")}
+            </Button>
+            <Button
+              className="flex-1 bg-gradient-to-r from-red-500 to-pink-500 hover:from-red-600 hover:to-pink-600 text-white border-0"
+              onClick={handleDonate}
+              size="large"
+            >
+              <Heart className="h-4 w-4 mr-2" />
+              {t("donation_modal.support_button")}
+            </Button>
+          </form>
+        </div>
+      </div>
+    </dialog>
+  );
+}