651 lines
20 KiB
TypeScript
651 lines
20 KiB
TypeScript
/**
|
|
* CelebrationProgressionBanner
|
|
*
|
|
* A special version of ProgressionBanner that displays a celebration when
|
|
* a student unlocks a new skill, then imperceptibly morphs into the normal
|
|
* banner over ~60 seconds.
|
|
*
|
|
* This component interpolates 35+ CSS properties individually for a smooth,
|
|
* magical transition that the student won't notice happening.
|
|
*/
|
|
|
|
'use client'
|
|
|
|
import type { Shape } from 'canvas-confetti'
|
|
import confetti from 'canvas-confetti'
|
|
import { useEffect, useRef } from 'react'
|
|
import type { ProgressionMode } from '@/lib/curriculum/session-mode'
|
|
import {
|
|
boxShadow,
|
|
boxShadowsToCss,
|
|
gradientStop,
|
|
gradientToCss,
|
|
lerp,
|
|
lerpBoxShadows,
|
|
lerpColor,
|
|
lerpGradientStops,
|
|
lerpRgbaString,
|
|
type BoxShadow,
|
|
type GradientStop,
|
|
type RGBA,
|
|
} from '@/utils/interpolate'
|
|
import { useCelebrationWindDown } from '@/hooks/useCelebrationWindDown'
|
|
|
|
// =============================================================================
|
|
// Types
|
|
// =============================================================================
|
|
|
|
interface CelebrationProgressionBannerProps {
|
|
mode: ProgressionMode
|
|
onAction: () => void
|
|
isLoading: boolean
|
|
variant: 'dashboard' | 'modal'
|
|
isDark: boolean
|
|
/** Speed multiplier for testing (1 = normal, 10 = 10x faster) */
|
|
speedMultiplier?: number
|
|
/** Force a specific progress value (0-1) for Storybook */
|
|
forceProgress?: number
|
|
/** Disable confetti for Storybook (to avoid spam) */
|
|
disableConfetti?: boolean
|
|
}
|
|
|
|
// =============================================================================
|
|
// Confetti Celebration
|
|
// =============================================================================
|
|
|
|
function fireConfettiCelebration(): void {
|
|
const duration = 4000
|
|
const animationEnd = Date.now() + duration
|
|
const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 10000 }
|
|
|
|
function randomInRange(min: number, max: number) {
|
|
return Math.random() * (max - min) + min
|
|
}
|
|
|
|
// Multiple bursts of confetti
|
|
const interval = setInterval(() => {
|
|
const timeLeft = animationEnd - Date.now()
|
|
|
|
if (timeLeft <= 0) {
|
|
clearInterval(interval)
|
|
return
|
|
}
|
|
|
|
const particleCount = 50 * (timeLeft / duration)
|
|
|
|
// Confetti from left side
|
|
confetti({
|
|
...defaults,
|
|
particleCount,
|
|
origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 },
|
|
colors: ['#FFD700', '#FFA500', '#FF6347', '#FF1493', '#00CED1', '#32CD32'],
|
|
})
|
|
|
|
// Confetti from right side
|
|
confetti({
|
|
...defaults,
|
|
particleCount,
|
|
origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 },
|
|
colors: ['#FFD700', '#FFA500', '#FF6347', '#FF1493', '#00CED1', '#32CD32'],
|
|
})
|
|
}, 250)
|
|
|
|
// Initial big burst from center
|
|
confetti({
|
|
particleCount: 100,
|
|
spread: 70,
|
|
origin: { x: 0.5, y: 0.5 },
|
|
colors: ['#FFD700', '#FFA500', '#FF6347'],
|
|
zIndex: 10000,
|
|
})
|
|
|
|
// Fireworks effect - shooting stars
|
|
setTimeout(() => {
|
|
confetti({
|
|
particleCount: 50,
|
|
angle: 60,
|
|
spread: 55,
|
|
origin: { x: 0, y: 0.8 },
|
|
colors: ['#FFD700', '#FFFF00', '#FFA500'],
|
|
zIndex: 10000,
|
|
})
|
|
confetti({
|
|
particleCount: 50,
|
|
angle: 120,
|
|
spread: 55,
|
|
origin: { x: 1, y: 0.8 },
|
|
colors: ['#FFD700', '#FFFF00', '#FFA500'],
|
|
zIndex: 10000,
|
|
})
|
|
}, 500)
|
|
|
|
// More fireworks
|
|
setTimeout(() => {
|
|
confetti({
|
|
particleCount: 80,
|
|
angle: 90,
|
|
spread: 100,
|
|
origin: { x: 0.5, y: 0.9 },
|
|
colors: ['#FF1493', '#FF69B4', '#FFB6C1', '#FF6347'],
|
|
zIndex: 10000,
|
|
})
|
|
}, 1000)
|
|
|
|
// Star burst finale
|
|
setTimeout(() => {
|
|
const shapes: Shape[] = ['star', 'circle']
|
|
confetti({
|
|
particleCount: 150,
|
|
spread: 180,
|
|
origin: { x: 0.5, y: 0.4 },
|
|
colors: ['#FFD700', '#FFA500', '#FF6347', '#FF1493', '#00CED1', '#9370DB'],
|
|
shapes,
|
|
scalar: 1.2,
|
|
zIndex: 10000,
|
|
})
|
|
}, 1500)
|
|
}
|
|
|
|
// =============================================================================
|
|
// Style Definitions - Start (Celebration) and End (Normal) States
|
|
// =============================================================================
|
|
|
|
// Helper to create RGBA
|
|
const rgba = (r: number, g: number, b: number, a: number): RGBA => ({ r, g, b, a })
|
|
|
|
// --- Container Background Gradients ---
|
|
const CELEBRATION_BG_LIGHT: GradientStop[] = [
|
|
gradientStop(234, 179, 8, 0.2, 0), // yellow.500 @ 20%
|
|
gradientStop(251, 191, 36, 0.1, 50), // yellow.400 @ 10%
|
|
gradientStop(234, 179, 8, 0.2, 100),
|
|
]
|
|
const CELEBRATION_BG_DARK: GradientStop[] = [
|
|
gradientStop(234, 179, 8, 0.25, 0),
|
|
gradientStop(251, 191, 36, 0.15, 50),
|
|
gradientStop(234, 179, 8, 0.25, 100),
|
|
]
|
|
|
|
const NORMAL_BG_LIGHT: GradientStop[] = [
|
|
gradientStop(34, 197, 94, 0.06, 0), // green.500 @ 6%
|
|
gradientStop(59, 130, 246, 0.04, 100), // blue.500 @ 4%
|
|
]
|
|
const NORMAL_BG_DARK: GradientStop[] = [
|
|
gradientStop(34, 197, 94, 0.12, 0),
|
|
gradientStop(59, 130, 246, 0.08, 100),
|
|
]
|
|
|
|
// Pad to same length (3 stops each)
|
|
const NORMAL_BG_LIGHT_PADDED: GradientStop[] = [
|
|
...NORMAL_BG_LIGHT.slice(0, 1),
|
|
gradientStop(46, 163, 170, 0.05, 50), // midpoint interpolation
|
|
...NORMAL_BG_LIGHT.slice(1),
|
|
]
|
|
const NORMAL_BG_DARK_PADDED: GradientStop[] = [
|
|
...NORMAL_BG_DARK.slice(0, 1),
|
|
gradientStop(46, 163, 170, 0.1, 50),
|
|
...NORMAL_BG_DARK.slice(1),
|
|
]
|
|
|
|
// --- Container Box Shadows ---
|
|
const CELEBRATION_SHADOWS: BoxShadow[] = [
|
|
boxShadow(0, 0, 20, 0, 234, 179, 8, 0.4), // glow 1
|
|
boxShadow(0, 0, 40, 0, 234, 179, 8, 0.2), // glow 2
|
|
]
|
|
const NORMAL_SHADOWS: BoxShadow[] = [
|
|
boxShadow(0, 2, 8, 0, 0, 0, 0, 0.1), // subtle shadow
|
|
boxShadow(0, 0, 0, 0, 0, 0, 0, 0), // transparent (padding)
|
|
]
|
|
|
|
// --- Button Background Gradients ---
|
|
const CELEBRATION_BTN_LIGHT: GradientStop[] = [
|
|
gradientStop(252, 211, 77, 1, 0), // yellow.300
|
|
gradientStop(245, 158, 11, 1, 100), // yellow.500
|
|
]
|
|
const CELEBRATION_BTN_DARK: GradientStop[] = [
|
|
gradientStop(252, 211, 77, 1, 0),
|
|
gradientStop(245, 158, 11, 1, 100),
|
|
]
|
|
|
|
const NORMAL_BTN: GradientStop[] = [
|
|
gradientStop(34, 197, 94, 1, 0), // green.500
|
|
gradientStop(22, 163, 74, 1, 100), // green.600
|
|
]
|
|
|
|
// --- Button Box Shadows ---
|
|
const CELEBRATION_BTN_SHADOW: BoxShadow[] = [boxShadow(0, 4, 15, 0, 245, 158, 11, 0.4)]
|
|
const NORMAL_BTN_SHADOW: BoxShadow[] = [boxShadow(0, 2, 4, 0, 0, 0, 0, 0.1)]
|
|
|
|
// --- Colors ---
|
|
const COLORS = {
|
|
// Border
|
|
celebrationBorder: '#eab308', // yellow.500
|
|
normalBorderLight: '#4ade80', // green.400
|
|
normalBorderDark: '#22c55e', // green.500
|
|
|
|
// Title
|
|
celebrationTitleLight: '#a16207', // yellow.700
|
|
celebrationTitleDark: '#fef08a', // yellow.200
|
|
normalTitleLight: '#166534', // green.800
|
|
normalTitleDark: '#86efac', // green.300
|
|
|
|
// Subtitle
|
|
celebrationSubtitleLight: '#374151', // gray.700
|
|
celebrationSubtitleDark: '#e5e7eb', // gray.200
|
|
normalSubtitleLight: '#6b7280', // gray.500
|
|
normalSubtitleDark: '#a1a1aa', // gray.400
|
|
|
|
// Button text
|
|
celebrationBtnTextLight: '#111827', // gray.900 (dark text on yellow)
|
|
celebrationBtnTextDark: '#111827',
|
|
normalBtnText: '#ffffff', // white
|
|
}
|
|
|
|
// =============================================================================
|
|
// Style Calculation
|
|
// =============================================================================
|
|
|
|
interface InterpolatedStyles {
|
|
// Container
|
|
containerBackground: string
|
|
containerBorderWidth: number
|
|
containerBorderColor: string
|
|
containerBorderRadius: number
|
|
containerPadding: number
|
|
containerBoxShadow: string
|
|
|
|
// Emoji
|
|
emojiSize: number
|
|
emojiRotation: number
|
|
|
|
// Title
|
|
titleFontSize: number
|
|
titleColor: string
|
|
titleTextShadow: string
|
|
|
|
// Subtitle
|
|
subtitleFontSize: number
|
|
subtitleColor: string
|
|
subtitleMarginTop: number
|
|
|
|
// Button
|
|
buttonPaddingY: number
|
|
buttonPaddingX: number
|
|
buttonFontSize: number
|
|
buttonBackground: string
|
|
buttonBorderRadius: number
|
|
buttonBoxShadow: string
|
|
buttonColor: string
|
|
|
|
// Shimmer
|
|
shimmerOpacity: number
|
|
|
|
// Layout - all interpolated, no discrete jumps
|
|
contentGap: number
|
|
contentJustify: number // 0 = center, 1 = flex-start (use transform)
|
|
textMarginLeft: number
|
|
buttonWrapperPaddingX: number
|
|
buttonWrapperPaddingBottom: number
|
|
}
|
|
|
|
function calculateStyles(
|
|
progress: number,
|
|
oscillation: number,
|
|
isDark: boolean,
|
|
variant: 'dashboard' | 'modal'
|
|
): InterpolatedStyles {
|
|
const t = progress // 0 = celebration, 1 = normal
|
|
|
|
// Get theme-appropriate values
|
|
const celebrationBg = isDark ? CELEBRATION_BG_DARK : CELEBRATION_BG_LIGHT
|
|
const normalBg = isDark ? NORMAL_BG_DARK_PADDED : NORMAL_BG_LIGHT_PADDED
|
|
const celebrationBtn = isDark ? CELEBRATION_BTN_DARK : CELEBRATION_BTN_LIGHT
|
|
const normalBorder = isDark ? COLORS.normalBorderDark : COLORS.normalBorderLight
|
|
const celebrationTitle = isDark ? COLORS.celebrationTitleDark : COLORS.celebrationTitleLight
|
|
const normalTitle = isDark ? COLORS.normalTitleDark : COLORS.normalTitleLight
|
|
const celebrationSubtitle = isDark
|
|
? COLORS.celebrationSubtitleDark
|
|
: COLORS.celebrationSubtitleLight
|
|
const normalSubtitle = isDark ? COLORS.normalSubtitleDark : COLORS.normalSubtitleLight
|
|
const celebrationBtnText = isDark ? COLORS.celebrationBtnTextDark : COLORS.celebrationBtnTextLight
|
|
|
|
// Variant-specific sizes
|
|
const isModal = variant === 'modal'
|
|
const celebrationPadding = isModal ? 20 : 24
|
|
const normalPadding = isModal ? 14 : 16
|
|
const celebrationTitleSize = isModal ? 24 : 28
|
|
const normalTitleSize = isModal ? 15 : 16
|
|
const celebrationSubtitleSize = isModal ? 16 : 18
|
|
const normalSubtitleSize = isModal ? 12 : 13
|
|
const celebrationBtnPaddingY = isModal ? 14 : 16
|
|
const normalBtnPaddingY = isModal ? 14 : 16
|
|
const celebrationBtnPaddingX = isModal ? 28 : 32
|
|
const normalBtnPaddingX = 0 // full width in normal mode
|
|
const celebrationBtnFontSize = isModal ? 16 : 18
|
|
const normalBtnFontSize = isModal ? 16 : 17
|
|
|
|
// Wiggle amplitude decreases as we transition
|
|
const wiggleAmplitude = 3 * (1 - t)
|
|
const rotation = oscillation * wiggleAmplitude
|
|
|
|
return {
|
|
// Container
|
|
containerBackground: gradientToCss(135, lerpGradientStops(celebrationBg, normalBg, t)),
|
|
containerBorderWidth: lerp(3, 2, t),
|
|
containerBorderColor: lerpColor(COLORS.celebrationBorder, normalBorder, t),
|
|
containerBorderRadius: lerp(16, isModal ? 12 : 16, t),
|
|
containerPadding: lerp(celebrationPadding, normalPadding, t),
|
|
containerBoxShadow: boxShadowsToCss(lerpBoxShadows(CELEBRATION_SHADOWS, NORMAL_SHADOWS, t)),
|
|
|
|
// Emoji - single emoji, just size and wiggle
|
|
emojiSize: lerp(isModal ? 48 : 64, isModal ? 24 : 32, t),
|
|
emojiRotation: rotation,
|
|
|
|
// Title
|
|
titleFontSize: lerp(celebrationTitleSize, normalTitleSize, t),
|
|
titleColor: lerpColor(celebrationTitle, normalTitle, t),
|
|
titleTextShadow: `0 0 ${lerp(20, 0, t)}px ${lerpRgbaString(rgba(234, 179, 8, 0.5), rgba(0, 0, 0, 0), t)}`,
|
|
|
|
// Subtitle
|
|
subtitleFontSize: lerp(celebrationSubtitleSize, normalSubtitleSize, t),
|
|
subtitleColor: lerpColor(celebrationSubtitle, normalSubtitle, t),
|
|
subtitleMarginTop: lerp(8, 2, t),
|
|
|
|
// Button - always full width, wrapper controls visual width
|
|
buttonPaddingY: lerp(celebrationBtnPaddingY, normalBtnPaddingY, t),
|
|
buttonPaddingX: 0, // No horizontal padding on button itself
|
|
buttonFontSize: lerp(celebrationBtnFontSize, normalBtnFontSize, t),
|
|
buttonBackground: gradientToCss(135, lerpGradientStops(celebrationBtn, NORMAL_BTN, t)),
|
|
buttonBorderRadius: lerp(12, 0, t),
|
|
buttonBoxShadow: boxShadowsToCss(lerpBoxShadows(CELEBRATION_BTN_SHADOW, NORMAL_BTN_SHADOW, t)),
|
|
buttonColor: lerpColor(celebrationBtnText, COLORS.normalBtnText, t),
|
|
|
|
// Shimmer fades out
|
|
shimmerOpacity: 1 - t,
|
|
|
|
// Layout - all smoothly interpolated
|
|
contentGap: lerp(4, 12, t), // Gap between emoji and text
|
|
contentJustify: t, // 0 = centered layout, 1 = left-aligned
|
|
textMarginLeft: lerp(0, 0, t), // Could add margin if needed
|
|
buttonWrapperPaddingX: lerp(isModal ? 60 : 80, 0, t), // Shrinks button visually in celebration
|
|
buttonWrapperPaddingBottom: lerp(celebrationPadding, 0, t),
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Component
|
|
// =============================================================================
|
|
|
|
export function CelebrationProgressionBanner({
|
|
mode,
|
|
onAction,
|
|
isLoading,
|
|
variant,
|
|
isDark,
|
|
speedMultiplier = 1,
|
|
forceProgress,
|
|
disableConfetti = false,
|
|
}: CelebrationProgressionBannerProps) {
|
|
const confettiFiredRef = useRef(false)
|
|
|
|
const { progress, shouldFireConfetti, oscillation, onConfettiFired, isCelebrating } =
|
|
useCelebrationWindDown({
|
|
skillId: mode.nextSkill.skillId,
|
|
tutorialRequired: mode.tutorialRequired,
|
|
enabled: true,
|
|
speedMultiplier,
|
|
forceProgress,
|
|
})
|
|
|
|
// Fire confetti once (unless disabled for Storybook)
|
|
useEffect(() => {
|
|
if (shouldFireConfetti && !confettiFiredRef.current && !disableConfetti) {
|
|
confettiFiredRef.current = true
|
|
fireConfettiCelebration()
|
|
onConfettiFired()
|
|
}
|
|
}, [shouldFireConfetti, onConfettiFired, disableConfetti])
|
|
|
|
// If not celebrating at all, render the normal banner
|
|
if (!isCelebrating && progress >= 1) {
|
|
return (
|
|
<NormalProgressionBanner
|
|
mode={mode}
|
|
onAction={onAction}
|
|
isLoading={isLoading}
|
|
variant={variant}
|
|
isDark={isDark}
|
|
/>
|
|
)
|
|
}
|
|
|
|
// Calculate all interpolated styles
|
|
const styles = calculateStyles(progress, oscillation, isDark, variant)
|
|
|
|
return (
|
|
<div
|
|
data-element="session-mode-banner"
|
|
data-mode="progression"
|
|
data-variant={variant}
|
|
data-celebrating={isCelebrating}
|
|
data-progress={progress.toFixed(3)}
|
|
style={{
|
|
position: 'relative',
|
|
background: styles.containerBackground,
|
|
borderWidth: `${styles.containerBorderWidth}px`,
|
|
borderStyle: 'solid',
|
|
borderColor: styles.containerBorderColor,
|
|
borderRadius: `${styles.containerBorderRadius}px`,
|
|
boxShadow: styles.containerBoxShadow,
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
{/* Shimmer overlay - fades out */}
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
background:
|
|
'linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.3) 50%, transparent 100%)',
|
|
backgroundSize: '200% 100%',
|
|
animation: 'celebrationShimmer 2s linear infinite',
|
|
opacity: styles.shimmerOpacity,
|
|
pointerEvents: 'none',
|
|
}}
|
|
/>
|
|
|
|
{/* Content area - always row layout, centering achieved via flexbox */}
|
|
<div
|
|
style={{
|
|
padding: `${styles.containerPadding}px`,
|
|
display: 'flex',
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'flex-start',
|
|
gap: `${styles.contentGap}px`,
|
|
}}
|
|
>
|
|
{/* Emoji - single emoji with size and wiggle animation */}
|
|
<span
|
|
style={{
|
|
fontSize: `${styles.emojiSize}px`,
|
|
lineHeight: 1,
|
|
flexShrink: 0,
|
|
display: 'inline-block',
|
|
transform: `rotate(${styles.emojiRotation}deg)`,
|
|
}}
|
|
>
|
|
🌟
|
|
</span>
|
|
|
|
{/* Text content */}
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
{/* Title - same text throughout, only styling changes */}
|
|
<div
|
|
style={{
|
|
fontSize: `${styles.titleFontSize}px`,
|
|
fontWeight: 'bold',
|
|
color: styles.titleColor,
|
|
textShadow: styles.titleTextShadow,
|
|
}}
|
|
>
|
|
New Skill Unlocked: <strong>{mode.nextSkill.displayName}</strong>
|
|
</div>
|
|
|
|
{/* Subtitle - same text throughout */}
|
|
<div
|
|
style={{
|
|
fontSize: `${styles.subtitleFontSize}px`,
|
|
color: styles.subtitleColor,
|
|
marginTop: `${styles.subtitleMarginTop}px`,
|
|
}}
|
|
>
|
|
Ready to start the tutorial
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Button wrapper - padding interpolates to control visual button width */}
|
|
<div
|
|
style={{
|
|
padding: `0 ${styles.buttonWrapperPaddingX}px ${styles.buttonWrapperPaddingBottom}px`,
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
data-action="start-progression"
|
|
onClick={onAction}
|
|
disabled={isLoading}
|
|
style={{
|
|
width: '100%',
|
|
padding: `${styles.buttonPaddingY}px 16px`,
|
|
fontSize: `${styles.buttonFontSize}px`,
|
|
fontWeight: 'bold',
|
|
background: styles.buttonBackground,
|
|
color: styles.buttonColor,
|
|
borderRadius: `${styles.buttonBorderRadius}px`,
|
|
border: 'none',
|
|
boxShadow: styles.buttonBoxShadow,
|
|
cursor: isLoading ? 'not-allowed' : 'pointer',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: '8px',
|
|
}}
|
|
>
|
|
{isLoading ? 'Starting...' : 'Begin Tutorial →'}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Inject keyframes for shimmer animation */}
|
|
<style>
|
|
{`
|
|
@keyframes celebrationShimmer {
|
|
0% { background-position: -200% 0; }
|
|
100% { background-position: 200% 0; }
|
|
}
|
|
`}
|
|
</style>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// Normal Progression Banner (fallback when celebration is complete)
|
|
// =============================================================================
|
|
|
|
function NormalProgressionBanner({
|
|
mode,
|
|
onAction,
|
|
isLoading,
|
|
variant,
|
|
isDark,
|
|
}: Omit<
|
|
CelebrationProgressionBannerProps,
|
|
'speedMultiplier' | 'forceProgress' | 'disableConfetti'
|
|
>) {
|
|
return (
|
|
<div
|
|
data-element="session-mode-banner"
|
|
data-mode="progression"
|
|
data-variant={variant}
|
|
style={{
|
|
borderRadius: variant === 'modal' ? '12px' : '16px',
|
|
overflow: 'hidden',
|
|
border: '2px solid',
|
|
borderColor: isDark ? '#22c55e' : '#4ade80',
|
|
background: isDark
|
|
? 'linear-gradient(135deg, rgba(34, 197, 94, 0.12) 0%, rgba(59, 130, 246, 0.08) 100%)'
|
|
: 'linear-gradient(135deg, rgba(34, 197, 94, 0.06) 0%, rgba(59, 130, 246, 0.04) 100%)',
|
|
}}
|
|
>
|
|
{/* Info section */}
|
|
<div
|
|
style={{
|
|
padding: variant === 'modal' ? '14px 16px' : '16px 20px',
|
|
display: 'flex',
|
|
gap: '12px',
|
|
alignItems: 'center',
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
fontSize: variant === 'modal' ? '24px' : '32px',
|
|
lineHeight: 1,
|
|
}}
|
|
>
|
|
🌟
|
|
</span>
|
|
<div style={{ flex: 1 }}>
|
|
<p
|
|
style={{
|
|
fontSize: variant === 'modal' ? '15px' : '16px',
|
|
fontWeight: 600,
|
|
color: isDark ? '#86efac' : '#166534',
|
|
margin: 0,
|
|
}}
|
|
>
|
|
{mode.tutorialRequired ? 'New Skill Unlocked: ' : 'Ready to practice: '}
|
|
<strong>{mode.nextSkill.displayName}</strong>
|
|
</p>
|
|
<p
|
|
style={{
|
|
fontSize: variant === 'modal' ? '12px' : '13px',
|
|
marginTop: '2px',
|
|
color: isDark ? '#a1a1aa' : '#6b7280',
|
|
margin: 0,
|
|
}}
|
|
>
|
|
{mode.tutorialRequired ? 'Ready to start the tutorial' : 'Continue building mastery'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action button */}
|
|
<button
|
|
type="button"
|
|
data-action="start-progression"
|
|
onClick={onAction}
|
|
disabled={isLoading}
|
|
style={{
|
|
width: '100%',
|
|
padding: variant === 'modal' ? '14px' : '16px',
|
|
fontSize: variant === 'modal' ? '16px' : '17px',
|
|
fontWeight: 'bold',
|
|
color: 'white',
|
|
border: 'none',
|
|
borderRadius: 0,
|
|
cursor: isLoading ? 'not-allowed' : 'pointer',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: '8px',
|
|
background: isLoading ? '#9ca3af' : 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
|
|
boxShadow: isLoading ? 'none' : 'inset 0 1px 0 rgba(255,255,255,0.15)',
|
|
}}
|
|
>
|
|
{isLoading ? 'Starting...' : mode.tutorialRequired ? 'Begin Tutorial →' : "Let's Go! →"}
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|