feat: add exercise modal premium teaser with unlock insights and volume features to localization files and exercise video modal, and update related UI components for premium content promotion

This commit is contained in:
Mathias
2026-03-23 16:46:36 +01:00
parent b8f090411e
commit c7eb934462
15 changed files with 164 additions and 60 deletions
+8
View File
@@ -1483,6 +1483,14 @@ export default {
or: "or",
},
// Exercise modal premium teaser
exercise_modal: {
pr_title: "Personal Record Trend",
unlock_insights: "Unlock Advanced Insights",
feature_pr: "Personal records",
feature_volume: "Volume tracking",
},
// 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",
+7
View File
@@ -616,6 +616,13 @@ export default {
},
// Contact Support
exercise_modal: {
pr_title: "Tendencia de récords personales",
unlock_insights: "Desbloquear análisis avanzados",
feature_pr: "Récords personales",
feature_volume: "Seguimiento de volumen",
},
contact_support: "Contactar soporte",
contact_support_subtitle: "Describe tu problema y te ayudaremos lo antes posible. También puedes escribirnos directamente a",
+7
View File
@@ -1509,6 +1509,13 @@ export default {
or: "ou",
},
exercise_modal: {
pr_title: "Tendance des records personnels",
unlock_insights: "Débloquer les analyses avancées",
feature_pr: "Records personnels",
feature_volume: "Suivi du volume",
},
// 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 à",
+7
View File
@@ -1490,6 +1490,13 @@ export default {
},
// Contact Support
exercise_modal: {
pr_title: "Tendência de recordes pessoais",
unlock_insights: "Desbloquear análises avançadas",
feature_pr: "Recordes pessoais",
feature_volume: "Rastreamento de volume",
},
contact_support: "Contactar Suporte",
contact_support_subtitle: "Descreva o seu problema e iremos ajudá-lo o mais rápido possível. Também pode escrever-nos diretamente para",
+7
View File
@@ -1476,6 +1476,13 @@ export default {
},
// Contact Support
exercise_modal: {
pr_title: "Тренд личных рекордов",
unlock_insights: "Разблокировать продвинутую аналитику",
feature_pr: "Личные рекорды",
feature_volume: "Отслеживание объёма",
},
contact_support: "Связаться с поддержкой",
contact_support_subtitle: "Опишите вашу проблему, и мы поможем вам как можно скорее. Вы также можете написать нам напрямую на",
+7
View File
@@ -612,6 +612,13 @@ export default {
},
// Contact Support
exercise_modal: {
pr_title: "个人记录趋势",
unlock_insights: "解锁高级分析",
feature_pr: "个人记录",
feature_volume: "训练量追踪",
},
contact_support: "联系支持",
contact_support_subtitle: "描述您的问题,我们将尽快帮助您。您也可以直接写信给我们:",
+1 -1
View File
@@ -36,7 +36,7 @@ const sponsors: Record<string, Sponsor> = {
name: "Nutri&Co",
descriptionKey: "ads.sponsor_nutri_and_co",
logoUrl: "/images/sponsorship/nutri-and-co.png",
url: "https://www.nutri-and-co.com",
url: "https://nutriandco.com/",
brandColor: "#1A2F3B",
},
};
+1 -1
View File
@@ -3,8 +3,8 @@
import React from "react";
import Link from "next/link";
import { Crown, Sparkles } from "lucide-react";
import { useI18n } from "locales/client";
import { cn } from "@/shared/lib/utils";
import { useUserSubscription } from "@/features/ads/hooks/useUserSubscription";
import { Skeleton } from "@/components/ui/skeleton";
@@ -1,7 +1,6 @@
"use client";
import { Check, X, Star, Target } from "lucide-react";
import { useI18n } from "locales/client";
interface Feature {
@@ -172,7 +171,7 @@ export function FeatureComparisonTable() {
<div className="font-bold text-gray-900 dark:text-white">{t("premium.plans.free.name")}</div>
<div className="text-sm text-gray-500">{t("premium.plans.free.price_label")}</div>
</div>
<div className="text-center">
<div className="text-center bg-[#00D4AA]/5 dark:bg-[#00D4AA]/10 rounded-lg p-2 -m-2">
<div className="font-bold text-[#00D4AA]">{t("premium.plans.premium.name")}</div>
<div className="text-sm text-gray-500">{t("premium.plans.premium.price_label")}</div>
</div>
@@ -190,7 +189,9 @@ export function FeatureComparisonTable() {
<div className="grid grid-cols-3 gap-4 items-center py-2" key={featureIndex}>
<div className="text-xs sm:text-sm text-gray-700 dark:text-gray-300">{feature.name}</div>
<div className="text-center">{renderFeatureValue(feature.free)}</div>
<div className="text-center">{renderFeatureValue(feature.premium)}</div>
<div className="text-center bg-[#00D4AA]/5 dark:bg-[#00D4AA]/10 rounded-lg py-1">
{renderFeatureValue(feature.premium)}
</div>
</div>
))}
</div>
@@ -4,15 +4,8 @@ import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import { Crown, Zap, Heart, Check, ArrowRight, LogIn, Github, Users, RefreshCw, Lock, ShieldCheck, GiftIcon } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { useI18n, useCurrentLocale } from "locales/client";
import { usePremiumRedirect } from "@/shared/lib/premium/use-premium-redirect";
import { useIsPremium } from "@/shared/lib/premium/use-premium";
import { usePendingCheckout } from "@/shared/lib/premium/use-pending-checkout";
import { usePremiumPlans } from "@/shared/hooks/use-premium-plans";
import { useSession } from "@/features/auth/lib/auth-client";
import { Button } from "@/components/ui/button";
import { useMutation } from "@tanstack/react-query";
import { PricingHeroSection } from "./pricing-hero-section";
import { PricingFAQ } from "./pricing-faq";
@@ -21,6 +14,13 @@ import { ConversionFlowNotification } from "./conversion-flow-notification";
import type { CheckoutResult } from "@/shared/types/premium.types";
import { usePremiumRedirect } from "@/shared/lib/premium/use-premium-redirect";
import { useIsPremium } from "@/shared/lib/premium/use-premium";
import { usePendingCheckout } from "@/shared/lib/premium/use-pending-checkout";
import { usePremiumPlans } from "@/shared/hooks/use-premium-plans";
import { useSession } from "@/features/auth/lib/auth-client";
import { Button } from "@/components/ui/button";
export function PremiumUpgradeCard() {
const t = useI18n();
const locale = useCurrentLocale();
@@ -35,7 +35,6 @@ export function PremiumUpgradeCard() {
// Fetch dynamic pricing
const { data: plansData, isLoading: plansLoading } = usePremiumPlans();
console.log("plansData:", plansData);
// Handle premium redirects after successful upgrade
usePremiumRedirect();
@@ -80,8 +79,6 @@ export function PremiumUpgradeCard() {
};
const handleUpgrade = (planId: string) => {
console.log("planId:", planId);
// Check if user is authenticated
if (!isAuthenticated) {
// Store the selected plan for after authentication
@@ -102,11 +99,8 @@ export function PremiumUpgradeCard() {
const yearlyPlan = plansData?.plans.find((p) => p.internalId.startsWith("premium-yearly"));
const monthlyPrice = monthlyPlan?.priceMonthly || 7.9;
console.log("monthlyPrice:", monthlyPrice);
const yearlyPrice = yearlyPlan?.priceYearly || 49.0;
console.log("yearlyPrice:", yearlyPrice);
const currency = monthlyPlan?.currency || "EUR";
console.log("currency:", currency);
const currentPrice = isYearly ? yearlyPrice : monthlyPrice;
const currentPeriod = isYearly ? t("premium.pricing.year") : t("premium.pricing.month");
@@ -122,17 +116,6 @@ export function PremiumUpgradeCard() {
}).format(price);
};
// Log debug info in development
useEffect(() => {
if (plansData && process.env.NODE_ENV === "development") {
console.log("📊 Plans data:", plansData);
console.log("🌍 Detected region:", plansData.detectedRegion);
if (plansData.debug) {
console.log("🔍 Debug headers:", plansData.debug.headers);
}
}
}, [plansData]);
if (isPremium) {
return (
<div className="m-3 relative overflow-hidden bg-gradient-to-b from-[#FF6B35]/5 to-[#00D4AA]/5 dark:from-[#FF6B35]/10 dark:to-[#00D4AA]/10 rounded-3xl p-8 border border-[#FF6B35]/20 dark:border-[#FF6B35]/30">
+1 -1
View File
@@ -72,7 +72,7 @@ export function PricingFAQ() {
key={index}
>
<button
className="w-full px-6 py-4 text-left flex items-center justify-between hover:bg-gray-400 dark:hover:bg-gray-800 transition-colors duration-200"
className="w-full px-6 py-4 text-left flex items-center justify-between hover:bg-slate-200 dark:hover:bg-gray-800 transition-colors duration-200"
onClick={() => toggleFAQ(index)}
>
<span className="text-lg sm:text-xl leading-tight font-semibold text-gray-900 dark:text-white pr-4">{item.question}</span>
@@ -1,9 +1,13 @@
"use client";
import React from "react";
import { AlertCircle } from "lucide-react";
import { useI18n, useCurrentLocale } from "locales/client";
import { useWeightProgression, useOneRepMax, useVolumeData } from "../hooks/use-exercise-statistics";
import { WeightProgressionChart } from "./WeightProgressionChart";
import { VolumeChart } from "./VolumeChart";
import { OneRepMaxChart } from "./OneRepMaxChart";
import { cn } from "@/shared/lib/utils";
import { formatDate } from "@/shared/lib/date";
import { StatisticsTimeframe } from "@/shared/constants/statistics";
@@ -11,11 +15,6 @@ import { PremiumGate } from "@/components/ui/premium-gate";
import { Loader } from "@/components/ui/loader";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { useWeightProgression, useOneRepMax, useVolumeData } from "../hooks/use-exercise-statistics";
import { WeightProgressionChart } from "./WeightProgressionChart";
import { VolumeChart } from "./VolumeChart";
import { OneRepMaxChart } from "./OneRepMaxChart";
interface ExerciseStatisticsTabProps {
exerciseId: string;
unit?: "kg" | "lbs";
@@ -1,4 +1,4 @@
import React, { useCallback } from "react";
import React, { useCallback, useState } from "react";
import Image from "next/image";
import { Play, Shuffle, Trash2, GripVertical, Loader2, BarChart3 } from "lucide-react";
import { useCurrentLocale, useI18n } from "locales/client";
@@ -83,6 +83,7 @@ export const ExerciseListItem = React.memo(function ExerciseListItem({
const t = useI18n();
const locale = useCurrentLocale();
const playVideo = useBoolean();
const [modalTab, setModalTab] = useState<"video" | "statistics">("video");
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: exercise.id,
@@ -143,7 +144,7 @@ export const ExerciseListItem = React.memo(function ExerciseListItem({
{exercise.fullVideoImageUrl && (
<div
className="relative h-8 w-8 sm:h-10 sm:w-10 rounded overflow-hidden shrink-0 bg-slate-200 dark:bg-slate-800 cursor-pointer border border-slate-200 dark:border-slate-700/50"
onClick={playVideo.setTrue}
onClick={() => { setModalTab("video"); playVideo.setTrue(); }}
>
<Image
alt={exerciseName ?? ""}
@@ -183,7 +184,7 @@ export const ExerciseListItem = React.memo(function ExerciseListItem({
<button
className="p-1 sm:p-2 text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
onClick={playVideo.setTrue}
onClick={() => { setModalTab("statistics"); playVideo.setTrue(); }}
>
<BarChart3 className="h-4 w-4" />
</button>
@@ -192,7 +193,7 @@ export const ExerciseListItem = React.memo(function ExerciseListItem({
<Trash2 className="h-4 w-4" />
</button>
{exercise.fullVideoUrl && <ExerciseVideoModal exercise={exercise} onOpenChange={playVideo.toggle} open={playVideo.value} />}
{exercise.fullVideoUrl && <ExerciseVideoModal defaultTab={modalTab} exercise={exercise} onOpenChange={playVideo.toggle} open={playVideo.value} />}
</>
)}
</div>
@@ -1,11 +1,13 @@
import { useState } from "react";
import { BarChart3, Play } from "lucide-react";
import { useEffect, useState } from "react";
import Link from "next/link";
import { BarChart3, Crown, Lock, Play, TrendingUp, Zap } from "lucide-react";
import { useCurrentLocale, useI18n } from "locales/client";
import { ExerciseAttributeNameEnum, ExerciseAttributeValueEnum } from "@prisma/client";
import type { ExerciseWithAttributes } from "@/entities/exercise/types/exercise.types";
import { getYouTubeEmbedUrl } from "@/shared/lib/youtube";
import { useIsPremium } from "@/shared/lib/premium/use-premium";
import { getAttributeValueLabel } from "@/shared/lib/attribute-value-translation";
import { StatisticsTimeframe } from "@/shared/constants/statistics";
import { ExerciseCharts } from "@/features/statistics/components/ExerciseStatisticsTab";
@@ -18,13 +20,19 @@ interface ExerciseVideoModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
exercise: ExerciseWithAttributes;
defaultTab?: "video" | "statistics";
}
export function ExerciseVideoModal({ open, onOpenChange, exercise }: ExerciseVideoModalProps) {
export function ExerciseVideoModal({ open, onOpenChange, exercise, defaultTab = "video" }: ExerciseVideoModalProps) {
const t = useI18n();
const locale = useCurrentLocale();
const [activeTab, setActiveTab] = useState("video");
const [activeTab, setActiveTab] = useState(defaultTab);
const [selectedTimeframe, setSelectedTimeframe] = useState<StatisticsTimeframe>("8weeks");
const isPremium = useIsPremium();
useEffect(() => {
if (open) setActiveTab(defaultTab);
}, [open, defaultTab]);
const title = locale === "fr" ? exercise.name : exercise.nameEn || exercise.name;
const introduction = locale === "fr" ? exercise.introduction : exercise.introductionEn || exercise.introduction;
@@ -70,7 +78,7 @@ export function ExerciseVideoModal({ open, onOpenChange, exercise }: ExerciseVid
</div>
</DialogTitle>
</DialogHeader>
{/* @ts-expect-error Tabs shadcn */}
<Tabs className="flex-1" onValueChange={setActiveTab} value={activeTab}>
<TabsList className="grid w-full grid-cols-2 mx-4" style={{ width: "calc(100% - 2rem)" }}>
<TabsTrigger className="flex items-center gap-2" value="video">
@@ -121,16 +129,83 @@ export function ExerciseVideoModal({ open, onOpenChange, exercise }: ExerciseVid
)}
</TabsContent>
<TabsContent className="mt-0 px-2 md:px-6 pt-4 pb-6" value="statistics">
<div className="space-y-4">
{/* Timeframe selector */}
<div className="flex items-center justify-between flex-col sm:flex-row">
<h3 className="text-lg font-semibold text-slate-700 dark:text-slate-200">{t("statistics.performance_over_time")}</h3>
<TimeframeSelector onSelect={setSelectedTimeframe} selected={selectedTimeframe} />
<TabsContent className="mt-0" value="statistics">
<div className="space-y-5 px-3 md:px-6 pt-4 pb-6">
{/* Header — title + timeframe aligned */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<div className="w-8 h-8 rounded-lg bg-blue-100 dark:bg-blue-900/40 flex items-center justify-center shrink-0">
<BarChart3 className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<h3 className="text-base md:text-lg font-semibold text-slate-800 dark:text-slate-200 truncate">
{t("statistics.performance_over_time")}
</h3>
</div>
<TimeframeSelector className="shrink-0" onSelect={setSelectedTimeframe} selected={selectedTimeframe} />
</div>
{/* Charts */}
<ExerciseCharts exerciseId={exercise.id} timeframe={selectedTimeframe} />
{/* Charts in card */}
<div className="rounded-xl border border-slate-200 dark:border-slate-700/50 bg-white dark:bg-slate-800/30 p-3 md:p-4">
<ExerciseCharts exerciseId={exercise.id} timeframe={selectedTimeframe} />
</div>
{/* Premium teaser — Blurred advanced insights (loss aversion + Zeigarnik effect) */}
{!isPremium && (
<div className="relative rounded-xl border border-dashed border-amber-300 dark:border-amber-700/50 overflow-hidden">
{/* Blurred fake insights */}
<div className="select-none pointer-events-none blur-[6px] opacity-50 p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-emerald-500" />
<span className="text-sm font-semibold text-slate-700 dark:text-slate-300">{t("exercise_modal.pr_title")}</span>
</div>
<span className="text-lg font-bold text-emerald-500">+12%</span>
</div>
<div className="h-2 rounded-full bg-slate-200 dark:bg-slate-700">
<div className="h-2 rounded-full bg-gradient-to-r from-emerald-400 to-emerald-600 w-3/4" />
</div>
<div className="grid grid-cols-3 gap-2">
<div className="rounded-lg bg-slate-100 dark:bg-slate-800 p-2.5 text-center">
<p className="text-[10px] text-slate-400 uppercase tracking-wide">1RM Est.</p>
<p className="font-bold text-slate-700 dark:text-slate-200">85kg</p>
</div>
<div className="rounded-lg bg-slate-100 dark:bg-slate-800 p-2.5 text-center">
<p className="text-[10px] text-slate-400 uppercase tracking-wide">Volume</p>
<p className="font-bold text-slate-700 dark:text-slate-200">2,400kg</p>
</div>
<div className="rounded-lg bg-slate-100 dark:bg-slate-800 p-2.5 text-center">
<p className="text-[10px] text-slate-400 uppercase tracking-wide">Sessions</p>
<p className="font-bold text-slate-700 dark:text-slate-200">24</p>
</div>
</div>
</div>
{/* Overlay CTA */}
<div className="absolute inset-0 flex items-center justify-center bg-white/30 dark:bg-slate-900/30">
<Link
className=" flex flex-col items-center gap-2.5 px-6 py-4 rounded-2xl bg-white/95 dark:bg-slate-900/95 backdrop-blur-sm border border-amber-200 dark:border-amber-800 shadow-xl shadow-amber-500/10 hover:shadow-amber-500/25 transition-all duration-200 hover:scale-[1.02]"
href="/premium"
>
<div className="flex items-center gap-2.5">
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center shadow-lg shadow-amber-500/30">
<Crown className="w-4.5 h-4.5 text-white" />
</div>
<span className="font-bold text-sm text-slate-800 dark:text-slate-100">{t("exercise_modal.unlock_insights")}</span>
</div>
<div className="flex items-center gap-3 text-xs text-slate-500 dark:text-slate-400">
<span className="flex items-center gap-1">
<Zap className="w-3 h-3 text-amber-500" />
{t("exercise_modal.feature_pr")}
</span>
<span className="flex items-center gap-1">
<Lock className="w-3 h-3 text-amber-500" />
{t("exercise_modal.feature_volume")}
</span>
</div>
</Link>
</div>
</div>
)}
</div>
</TabsContent>
</Tabs>
@@ -1,7 +1,8 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { Loader2, Plus } from "lucide-react";
import { useI18n } from "locales/client";
import { arrayMove, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { arrayMove, SortableContext } from "@dnd-kit/sortable";
import { restrictToVerticalAxis, restrictToParentElement } from "@dnd-kit/modifiers";
import {
DndContext,
DragOverlay,
@@ -14,7 +15,6 @@ import {
DragOverEvent,
MouseSensor,
} from "@dnd-kit/core";
import { restrictToVerticalAxis, restrictToParentElement } from "@dnd-kit/modifiers";
import { useWorkoutStepper } from "../hooks/use-workout-stepper";
import { ExerciseListItem, ExerciseListItemOverlay } from "./exercise-list-item";
@@ -51,6 +51,8 @@ export const ExercisesSelection = ({
const { setExercisesOrder, exercisesOrder } = useWorkoutStepper();
const feedback = useDragFeedback();
const [activeId, setActiveId] = useState<string | null>(null);
const isMobile = useRef(typeof window !== "undefined" && window.innerWidth < 640);
const modifiers = isMobile.current ? [restrictToVerticalAxis] : [restrictToVerticalAxis, restrictToParentElement];
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -149,14 +151,14 @@ export const ExercisesSelection = ({
{/* Liste des exercices drag and drop */}
<DndContext
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
modifiers={modifiers}
onDragCancel={handleDragCancel}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragStart={handleDragStart}
sensors={sensors}
>
<SortableContext items={sortableItems} strategy={verticalListSortingStrategy}>
<SortableContext items={sortableItems}>
<div className="bg-white dark:bg-slate-900 rounded-t-lg border border-b-0 border-slate-200 dark:border-slate-800 overflow-hidden">
{flatExercises.map((item) => (
<ExerciseListItem