UNPKG

react-trophies

Version:

Comprehensive achievement and trophy system for React apps with sound effects, notifications, theming, and visual components. Uses React, React-DOM, Sonner (toast notifications), Howler (sound effects), Zustand (state management), React-Confetti (celebrat

867 lines (854 loc) 39.7 kB
import React, { useState, useMemo, useCallback, useEffect } from 'react'; import { toast as toast$1 } from 'sonner'; import { Howl } from 'howler'; import { create } from 'zustand'; import Confetti from 'react-confetti'; import { useWindowSize } from 'react-use'; const useAchievementStore = create((set, get) => ({ isInitialized: false, config: {}, metrics: {}, unlockedAchievements: [], previouslyAwardedAchievements: [], storageKey: null, notifications: [], initialize: ({ config, initialState, storageKey }) => { var _a, _b, _c; const state = get(); if (state.isInitialized) return; const storedState = storageKey ? localStorage.getItem(storageKey) : null; const initialMetrics = initialState ? Object.keys(initialState) .filter(key => key !== 'previouslyAwardedAchievements') .reduce((acc, key) => (Object.assign(Object.assign({}, acc), { [key]: Array.isArray(initialState[key]) ? initialState[key] : [initialState[key]] })), {}) : {}; const initialAwarded = (initialState === null || initialState === void 0 ? void 0 : initialState.previouslyAwardedAchievements) || []; if (storedState) { try { const parsedState = JSON.parse(storedState); set({ isInitialized: true, config, storageKey, metrics: ((_a = parsedState.achievements) === null || _a === void 0 ? void 0 : _a.metrics) || initialMetrics, unlockedAchievements: ((_b = parsedState.achievements) === null || _b === void 0 ? void 0 : _b.unlockedAchievements) || [], previouslyAwardedAchievements: ((_c = parsedState.achievements) === null || _c === void 0 ? void 0 : _c.previouslyAwardedAchievements) || initialAwarded, }); } catch (error) { console.error('Error parsing stored achievement state:', error); set({ isInitialized: true, config, storageKey, metrics: initialMetrics, unlockedAchievements: [], previouslyAwardedAchievements: initialAwarded, }); } } else { set({ isInitialized: true, config, storageKey, metrics: initialMetrics, unlockedAchievements: [], previouslyAwardedAchievements: initialAwarded, }); } }, setMetrics: (metrics) => { const state = get(); set({ metrics }); if (state.storageKey) { localStorage.setItem(state.storageKey, JSON.stringify({ achievements: { metrics, unlockedAchievements: state.unlockedAchievements, previouslyAwardedAchievements: state.previouslyAwardedAchievements } })); } }, unlockAchievement: (achievementId) => { const state = get(); if (!state.unlockedAchievements.includes(achievementId)) { const newUnlockedAchievements = [...state.unlockedAchievements, achievementId]; set({ unlockedAchievements: newUnlockedAchievements }); if (state.storageKey) { localStorage.setItem(state.storageKey, JSON.stringify({ achievements: { metrics: state.metrics, unlockedAchievements: newUnlockedAchievements, previouslyAwardedAchievements: state.previouslyAwardedAchievements } })); } } }, markAchievementAsAwarded: (achievementId) => { const state = get(); if (!state.previouslyAwardedAchievements.includes(achievementId)) { const newAwardedAchievements = [...state.previouslyAwardedAchievements, achievementId]; set({ previouslyAwardedAchievements: newAwardedAchievements }); if (state.storageKey) { localStorage.setItem(state.storageKey, JSON.stringify({ achievements: { metrics: state.metrics, unlockedAchievements: state.unlockedAchievements, previouslyAwardedAchievements: newAwardedAchievements } })); } } }, resetAchievements: () => { const state = get(); set({ metrics: {}, unlockedAchievements: [], previouslyAwardedAchievements: [], }); if (state.storageKey) { localStorage.removeItem(state.storageKey); } }, addNotification: (notification) => { const state = get(); set({ notifications: [...state.notifications, notification] }); }, clearNotifications: () => { set({ notifications: [] }); }, })); const BadgesButton = ({ onClick, position, styles, unlockedAchievements, icon, drawer = false, customStyles, }) => { const positionStyle = position ? { [position.split('-')[0]]: '20px', [position.split('-')[1]]: '20px', } : {}; const handleButtonClick = () => { onClick(); }; const achievementsText = 'View Achievements'; const buttonContent = icon ? icon : achievementsText; return (React.createElement("button", { onClick: handleButtonClick, style: Object.assign(Object.assign(Object.assign({}, styles), positionStyle), customStyles) }, buttonContent)); }; var BadgesButton$1 = React.memo(BadgesButton); // src/defaultIcons.ts const defaultAchievementIcons = { // General Progress & Milestones levelUp: '🏆', questComplete: '📜', monsterDefeated: '⚔️', itemCollected: '📦', challengeCompleted: '🏁', milestoneReached: '🏅', firstStep: '👣', newBeginnings: '🌱', breakthrough: '💡', growth: '📈', // Social & Engagement shared: '🔗', liked: '❤️', commented: '💬', followed: '👥', invited: '🤝', communityMember: '🏘️', supporter: '🌟', connected: '🌐', participant: '🙋', influencer: '📣', // Time & Activity activeDay: '☀️', activeWeek: '📅', activeMonth: '🗓️', earlyBird: '⏰', nightOwl: '🌙', streak: '🔥', dedicated: '⏳', punctual: '⏱️', consistent: '🔄', marathon: '🏃', // Creativity & Skill artist: '🎨', writer: '✍️', innovator: '🔬', creator: '🛠️', expert: '🎓', master: '👑', pioneer: '🚀', performer: '🎭', thinker: '🧠', explorer: '🗺️', // Achievement Types bronze: '🥉', silver: '🥈', gold: '🥇', diamond: '💎', legendary: '✨', epic: '💥', rare: '🔮', common: '🔘', special: '🎁', hidden: '❓', // Numbers & Counters one: '1️⃣', ten: '🔟', hundred: '💯', thousand: '🔢', // Actions & Interactions clicked: '🖱️', used: '🔑', found: '🔍', built: '🧱', solved: '🧩', discovered: '🔭', unlocked: '🔓', upgraded: '⬆️', repaired: '🔧', defended: '🛡️', // Placeholders default: '⭐', // A fallback icon loading: '⏳', error: '⚠️', success: '✅', failure: '❌', // Miscellaneous trophy: '🏆', star: '⭐', flag: '🚩', puzzle: '🧩', gem: '💎', crown: '👑', medal: '🏅', ribbon: '🎗️', badge: '🎖️', shield: '🛡️', }; const BadgesModal = ({ isOpen, achievements, onClose, styles, icons = {} }) => { if (!isOpen) return null; return (React.createElement("div", { style: styles.overlay }, React.createElement("div", { style: styles.content }, React.createElement("h2", { style: styles.title }, "Your Achievements"), React.createElement("div", { style: styles.badgeContainer }, achievements.map((achievement) => { const mergedIcons = Object.assign(Object.assign({}, defaultAchievementIcons), icons); let iconToDisplay = mergedIcons.default; if (achievement.achievementIconKey && mergedIcons[achievement.achievementIconKey]) { iconToDisplay = mergedIcons[achievement.achievementIconKey]; } return (React.createElement("div", { key: achievement.achievementId, style: styles.badge }, iconToDisplay.startsWith('http') || iconToDisplay.startsWith('data:image') ? (React.createElement("img", { src: iconToDisplay, alt: achievement.achievementTitle, style: styles.badgeIcon })) : (React.createElement("p", { style: { fontSize: '2em' } }, iconToDisplay) // Render Unicode as large text ), React.createElement("span", { style: styles.badgeTitle }, achievement.achievementTitle))); })), React.createElement("button", { onClick: onClose, style: styles.button }, "Close")))); }; var BadgesModal$1 = React.memo(BadgesModal); const ConfettiWrapper = ({ show }) => { const { width, height } = useWindowSize(); if (!show) return null; return React.createElement(Confetti, { width: width, height: height, recycle: false }); }; const defaultStyles = { badgesModal: { overlay: { position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0, 0, 0, 0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', }, content: { backgroundColor: '#ffffff', borderRadius: '8px', padding: '20px', maxWidth: '600px', width: '100%', maxHeight: '80vh', overflowY: 'auto', }, title: { fontSize: '24px', fontWeight: 'bold', marginBottom: '20px', }, badgeContainer: { display: 'flex', flexWrap: 'wrap', justifyContent: 'center', }, badge: { display: 'flex', flexDirection: 'column', alignItems: 'center', margin: '10px', }, badgeIcon: { width: '50px', height: '50px', marginBottom: '5px', }, badgeTitle: { fontSize: '14px', textAlign: 'center', }, button: { backgroundColor: '#007bff', color: '#ffffff', padding: '10px 20px', borderRadius: '4px', border: 'none', cursor: 'pointer', marginTop: '20px', }, }, badgesButton: { position: 'fixed', padding: '10px 20px', backgroundColor: '#007bff', color: '#ffffff', border: 'none', borderRadius: '4px', cursor: 'pointer', zIndex: 1000, }, }; /** * AchievementToastContent - Defines the look of achievement toast notifications * This component is used internally by the toast() function but doesn't call toast itself * * @example * ```tsx * <AchievementToastContent * achievement={achievementObject} * icons={mergedIcons} * toastTitle="Achievement Unlocked!" * /> * ``` */ const AchievementToastContent = ({ achievement, icons = {}, title = "Achievement Unlocked!", customStyles = {}, className = "" }) => { // Find the appropriate icon to display const iconKey = achievement.achievementIconKey || 'default'; const iconToDisplay = icons[iconKey] || defaultAchievementIcons[iconKey] || defaultAchievementIcons.default; return (React.createElement("div", { style: Object.assign({ display: 'flex', alignItems: 'center', gap: '12px' }, customStyles), className: `rt-toast-content ${className}`, "data-testid": "achievement-toast-content" }, React.createElement("div", { style: { flexShrink: 0 }, className: "rt-toast-icon" }, (iconToDisplay === null || iconToDisplay === void 0 ? void 0 : iconToDisplay.startsWith('http')) || (iconToDisplay === null || iconToDisplay === void 0 ? void 0 : iconToDisplay.startsWith('data:image')) ? (React.createElement("img", { src: iconToDisplay, alt: achievement.achievementTitle, style: { width: '40px', height: '40px', objectFit: 'contain' } })) : (React.createElement("div", { style: { fontSize: '2rem', width: '40px', height: '40px', display: 'flex', alignItems: 'center', justifyContent: 'center' } }, iconToDisplay))), React.createElement("div", { className: "rt-toast-body" }, React.createElement("h3", { style: { margin: '0 0 4px 0', fontSize: '1rem' }, className: "rt-toast-title" }, title), React.createElement("p", { style: { margin: 0, fontSize: '0.9rem' }, className: "rt-toast-subtitle" }, achievement.achievementTitle), achievement.achievementDescription && (React.createElement("p", { style: { margin: '4px 0 0 0', fontSize: '0.8rem', opacity: 0.7 }, className: "rt-toast-description" }, achievement.achievementDescription))))); }; const AchievementContext = React.createContext(undefined); const useAchievementContext = () => { const context = React.useContext(AchievementContext); if (!context) { throw new Error('useAchievementContext must be used within an AchievementProvider'); } return context; }; /** * Provider component that manages achievement state and notifications * * @param props - The achievement provider props * @param props.children - Child components that will have access to achievement context * @param props.config - Configuration object defining all achievements * @param props.initialState - Initial metrics and previously awarded achievements * @param props.storageKey - Key for localStorage persistence * @param props.badgesButtonPosition - Position of the badges button * @param props.styles - Custom styles for components * @param props.icons - Custom icons for achievements * @param props.achievementSoundUrl - URL to sound effect MP3 file * @param props.enableSound - Controls whether achievement sound effects play * @param props.enableConfetti - Controls whether confetti celebration displays * @param props.enableToasts - Controls whether toast notifications display * @param props.toastTitle - Title text displayed at the top of achievement toast notifications * @param props.toastStyles - Custom styles for achievement toast notifications * @param props.useDefaultToastStyles - When true, uses Sonner's default toast styling instead of custom styling */ const AchievementProvider = ({ children, config, initialState = {}, storageKey = 'react-achievements', badgesButtonPosition = 'top-right', styles = {}, icons = {}, achievementSoundUrl, enableSound = true, enableConfetti = true, enableToasts = true, toastTitle = "Achievement Unlocked!", toastStyles = {}, useDefaultToastStyles = false }) => { const { metrics, unlockedAchievements: unlockedAchievementIds, previouslyAwardedAchievements, notifications, isInitialized, initialize, setMetrics, unlockAchievement, markAchievementAsAwarded, addNotification, clearNotifications, resetAchievements } = useAchievementStore(); const [showBadges, setShowBadges] = useState(false); const [showConfetti, setShowConfetti] = useState(false); const serializeConfig = (config) => { const serializedConfig = {}; Object.entries(config).forEach(([metricName, conditions]) => { if (!Array.isArray(conditions)) { console.error(`Invalid conditions for metric ${metricName}: expected array, got ${typeof conditions}`); return; } serializedConfig[metricName] = conditions.map((condition) => { if (!condition || typeof condition.isConditionMet !== 'function') { console.error(`Invalid condition for metric ${metricName}: missing isConditionMet function`); return { achievementDetails: { achievementId: 'invalid', achievementTitle: 'Invalid Achievement', achievementDescription: 'Invalid condition', achievementIconKey: 'error' }, conditionType: 'number', conditionValue: 0 }; } // Analyze the isConditionMet function to determine type and value const funcString = condition.isConditionMet.toString(); let conditionType; let conditionValue; if (funcString.includes('typeof value === "number"') || funcString.includes('typeof value === \'number\'')) { conditionType = 'number'; const matches = funcString.match(/value\s*>=?\s*(\d+)/); conditionValue = matches ? parseInt(matches[1]) : 0; } else if (funcString.includes('typeof value === "string"') || funcString.includes('typeof value === \'string\'')) { conditionType = 'string'; const matches = funcString.match(/value\s*===?\s*['"](.+)['"]/); conditionValue = matches ? matches[1] : ''; } else if (funcString.includes('typeof value === "boolean"') || funcString.includes('typeof value === \'boolean\'')) { conditionType = 'boolean'; conditionValue = funcString.includes('=== true'); } else if (funcString.includes('instanceof Date')) { conditionType = 'date'; const matches = funcString.match(/new Date\(['"](.+)['"]\)/); conditionValue = matches ? matches[1] : new Date().toISOString(); } else { // Default to number type if we can't determine the type conditionType = 'number'; conditionValue = 1; } return { achievementDetails: condition.achievementDetails, conditionType, conditionValue, }; }); }); return serializedConfig; }; const serializedConfig = useMemo(() => serializeConfig(config), [config]); const checkAchievements = useCallback(() => { if (!isInitialized) return; const newAchievements = []; Object.entries(serializedConfig).forEach(([metricName, conditions]) => { const metricValues = metrics[metricName]; if (!Array.isArray(metricValues)) { return; } conditions.forEach((condition) => { const isConditionMet = (value) => { switch (condition.conditionType) { case 'number': return typeof value === 'number' && value >= condition.conditionValue; case 'string': return typeof value === 'string' && value === condition.conditionValue; case 'boolean': return typeof value === 'boolean' && value === condition.conditionValue; case 'date': return value instanceof Date && value.getTime() >= new Date(condition.conditionValue).getTime(); default: return false; } }; const latestValue = metricValues[metricValues.length - 1]; if (isConditionMet(latestValue) && !unlockedAchievementIds.includes(condition.achievementDetails.achievementId) && !previouslyAwardedAchievements.includes(condition.achievementDetails.achievementId)) { newAchievements.push(condition.achievementDetails); } }); }); if (newAchievements.length > 0) { newAchievements.forEach((achievement) => { unlockAchievement(achievement.achievementId); markAchievementAsAwarded(achievement.achievementId); addNotification(achievement); }); setShowConfetti(true); } }, [serializedConfig, metrics, unlockedAchievementIds, previouslyAwardedAchievements, unlockAchievement, markAchievementAsAwarded, addNotification, isInitialized]); // Initialize only once useEffect(() => { if (!isInitialized) { initialize({ config: serializedConfig, initialState, storageKey, }); } }, [initialize, serializedConfig, initialState, storageKey, isInitialized]); // Check achievements when metrics change useEffect(() => { if (isInitialized) { checkAchievements(); } }, [metrics, checkAchievements, isInitialized]); // No longer need to check for Toaster component as we're using a different approach // Merge custom icons with default icons for consistent access const mergedIcons = useMemo(() => (Object.assign(Object.assign({}, defaultAchievementIcons), icons)), [icons]); useEffect(() => { if (notifications.length > 0) { // Play sound if enabled if (enableSound && achievementSoundUrl) { try { const sound = new Howl({ src: [achievementSoundUrl], volume: 0.5, onload: () => { }, onloaderror: (soundId, error) => { console.error('Error loading achievement sound:', error); } }); sound.play(); } catch (error) { console.error('Error playing achievement sound:', error); } } // Display each achievement notification if (enableToasts) { notifications.forEach(achievement => { if (useDefaultToastStyles) { // Use default Sonner toast styling toast$1(toastTitle, { description: achievement.achievementDescription, icon: achievement.achievementIconKey && achievement.achievementIconKey in mergedIcons ? mergedIcons[achievement.achievementIconKey] : achievement.achievementIconKey && achievement.achievementIconKey in defaultAchievementIcons ? defaultAchievementIcons[achievement.achievementIconKey] : defaultAchievementIcons.default, duration: 4000, id: `achievement-${achievement.achievementId}`, }); } else { // Use custom AchievementToastContent component toast$1.custom((t) => (React.createElement(AchievementToastContent, { achievement: achievement, icons: mergedIcons, title: toastTitle, customStyles: toastStyles })), { duration: 4000, id: `achievement-${achievement.achievementId}`, }); } }); } clearNotifications(); if (enableConfetti) { setShowConfetti(true); setTimeout(() => setShowConfetti(false), 4000); } } }, [notifications, enableSound, achievementSoundUrl, clearNotifications, enableConfetti, enableToasts, icons, toastTitle, toastStyles, useDefaultToastStyles]); const handleUpdateMetrics = useCallback((newMetrics) => { if (!isInitialized) return; const currentState = useAchievementStore.getState(); let updatedMetrics; if (typeof newMetrics === 'function') { updatedMetrics = newMetrics(currentState.metrics); } else { updatedMetrics = Object.entries(newMetrics).reduce((acc, [key, value]) => (Object.assign(Object.assign({}, acc), { [key]: Array.isArray(currentState.metrics[key]) ? [...currentState.metrics[key], ...(Array.isArray(value) ? value : [value])] : (Array.isArray(value) ? value : [value]) })), Object.assign({}, currentState.metrics)); } setMetrics(updatedMetrics); }, [isInitialized, setMetrics]); const handleResetStorage = useCallback(() => { if (storageKey) { localStorage.removeItem(storageKey); } resetAchievements(); }, [storageKey, resetAchievements]); const contextValue = useMemo(() => ({ updateMetrics: handleUpdateMetrics, unlockedAchievements: unlockedAchievementIds, resetStorage: handleResetStorage, notifications: notifications, clearNotifications: clearNotifications, }), [ handleUpdateMetrics, unlockedAchievementIds, handleResetStorage, notifications, clearNotifications ]); const showBadgesModal = useCallback(() => { setShowBadges(true); }, []); return (React.createElement(AchievementContext.Provider, { value: contextValue }, children, enableConfetti && React.createElement(ConfettiWrapper, { show: showConfetti }), React.createElement(BadgesButton$1, { onClick: showBadgesModal, position: badgesButtonPosition, styles: styles.badgesButton || defaultStyles.badgesButton, unlockedAchievements: [...unlockedAchievementIds, ...previouslyAwardedAchievements] .filter((id, index, self) => self.indexOf(id) === index) .map(id => { const achievement = Object.values(serializedConfig) .flat() .find(condition => condition.achievementDetails.achievementId === id); return achievement === null || achievement === void 0 ? void 0 : achievement.achievementDetails; }).filter((a) => !!a) }), React.createElement(BadgesModal$1, { isOpen: showBadges, achievements: [...unlockedAchievementIds, ...previouslyAwardedAchievements] .filter((id, index, self) => self.indexOf(id) === index) .map(id => { const achievement = Object.values(serializedConfig) .flat() .find(condition => condition.achievementDetails.achievementId === id); return achievement === null || achievement === void 0 ? void 0 : achievement.achievementDetails; }).filter((a) => !!a), onClose: () => setShowBadges(false), styles: styles.badgesModal || defaultStyles.badgesModal, icons: icons }))); }; // Map of icon keys to their emoji representations for fallback const ICON_MAP = { trophy: '🏆', star: '⭐', fire: '🔥', medal: '🥇', sparkles: '✨', moon: '🌙', default: '🎯' }; /** * A modal dialog to display all achievements, their status, and progress. * Integrates with Shadcn UI for styling and requires peer dependencies. * * @requires Shadcn UI components (Dialog, Button, ScrollArea, Progress) * @example * // Make sure you've installed the required components: * // npx shadcn-ui@latest add dialog button scroll-area progress * * // import { TrophyModal } from 'react-trophies'; * * // function App() { * // return ( * // <> * // <TrophyModal * // modalTitle="Your Achievements" * // config={achievementConfig} * // metrics={currentMetrics} * // /> * // </> * // ); * // } */ const TrophyModal = ({ trigger, modalTitle = 'Your Trophies', className, buttonPosition = 'bottom-right', config, metrics = {}, modalClassName = '', cardClassName: customCardClassName = '', unlockedCardClassName: customUnlockedClassName = '', iconClassName: customIconClassName = '' }) => { const [isOpen, setIsOpen] = useState(false); // Get unlockedAchievements from the context const { unlockedAchievements } = useAchievementContext(); // We use useMemo to calculate the achievement list only when data changes const allAchievements = useMemo(() => { if (!config || Object.keys(config).length === 0) { return []; } // 1. Flatten all achievements from the configuration const flattened = []; Object.entries(config).forEach(([_, conditions]) => { conditions.forEach(condition => { flattened.push(condition.achievementDetails); }); }); // 2. Add `isUnlocked` and `progress` to each one const processed = flattened.map(ach => { const isUnlocked = unlockedAchievements.includes(ach.achievementId); let progress = 0; // Calculate progress for locked, numeric achievements if (!isUnlocked && typeof ach.targetValue === 'number' && ach.targetValue > 0) { const metricName = Object.keys(config).find(key => config[key].some(c => c.achievementDetails.achievementId === ach.achievementId)); if (metricName && metrics[metricName]) { const metricValue = metrics[metricName]; const currentValue = Array.isArray(metricValue) ? Math.max(...metricValue.map(v => Number(v) || 0), 0) : Number(metricValue) || 0; progress = Math.min(100, Math.round((currentValue / ach.targetValue) * 100)); } } else if (isUnlocked) { progress = 100; } return Object.assign(Object.assign({}, ach), { isUnlocked, progress }); }); // 3. Sort the list to show unlocked achievements first return processed.sort((a, b) => Number(b.isUnlocked) - Number(a.isUnlocked)); }, [config, metrics, unlockedAchievements]); const unlockedCount = allAchievements.filter(a => a.isUnlocked).length; // Create position style for the fixed button const positionStyle = { position: 'fixed', [buttonPosition.split('-')[0]]: '1rem', [buttonPosition.split('-')[1]]: '1rem', zIndex: 50, }; // For SSR safety, check if we're in a browser environment if (typeof window === 'undefined') { return null; } // Use typed placeholders for UI components let DialogComponents = { Dialog: 'div', DialogTrigger: 'button', DialogContent: 'div', DialogHeader: 'div', DialogTitle: 'h2' }; let ButtonComponent = 'button'; let ScrollAreaComponent = 'div'; let ProgressComponent = 'div'; try { // We use require with a dynamic string to avoid bundling dependencies // This allows graceful fallbacks when dependencies aren't installed const dialog = require('@/components/ui/dialog'); const button = require('@/components/ui/button'); const scrollArea = require('@/components/ui/scroll-area'); const progress = require('@/components/ui/progress'); // Update components with Shadcn UI equivalents if found DialogComponents = { Dialog: dialog.Dialog, DialogTrigger: dialog.DialogTrigger, DialogContent: dialog.DialogContent, DialogHeader: dialog.DialogHeader, DialogTitle: dialog.DialogTitle }; ButtonComponent = button.Button; ScrollAreaComponent = scrollArea.ScrollArea; ProgressComponent = progress.Progress; } catch (error) { console.error('Failed to import Shadcn UI components. Make sure you have installed them:', error); return (React.createElement("div", { style: { border: '1px solid red', padding: '16px', borderRadius: '4px', color: 'red', marginBottom: '16px' } }, React.createElement("h3", null, "TrophyModal Error"), React.createElement("p", null, "Could not find Shadcn UI components. Please install them by running:"), React.createElement("pre", null, "npx shadcn-ui@latest add dialog button scroll-area progress"))); } // Defines the AchievementCard component to display individual achievements const AchievementCard = ({ achievement }) => { const { isUnlocked, progress, achievementIconKey } = achievement; // Get the appropriate icon emoji based on achievementIconKey or default const iconEmoji = achievementIconKey && ICON_MAP[achievementIconKey] ? ICON_MAP[achievementIconKey] : ICON_MAP.default; // Instead of using template literals which cause TypeScript errors, // use conditional classNames approach let cardClassName = `p-4 rounded-lg border transition-all duration-300 ${customCardClassName}`; if (isUnlocked) { // Green theme for unlocked achievements, can be overridden with custom classes cardClassName += ` border-green-500/50 bg-green-500/10 ${customUnlockedClassName}`; } else { cardClassName += " bg-secondary/20"; } let badgeClassName = "px-2 py-0.5 text-xs font-semibold rounded-full"; if (isUnlocked) { badgeClassName += " bg-green-600 text-white"; } else { badgeClassName += " bg-gray-200 text-gray-700"; } // Add CSS class for achievement icon styling let iconClassName = `mr-2 inline-flex items-center justify-center ${customIconClassName}`; if (achievementIconKey) { iconClassName += ` icon-${achievementIconKey}`; } return (React.createElement("div", { className: cardClassName, "data-icon-key": achievementIconKey || 'default' }, React.createElement("div", { className: "flex items-center justify-between" }, React.createElement("h3", { className: "font-bold text-lg flex items-center" }, React.createElement("span", { className: iconClassName, role: "img", "aria-label": achievementIconKey || 'achievement icon' }, iconEmoji), achievement.achievementTitle), React.createElement("span", { className: badgeClassName }, isUnlocked ? 'Unlocked! 🎉' : 'Locked')), React.createElement("p", { className: "text-sm text-muted-foreground mt-1" }, achievement.achievementDescription), !isUnlocked && typeof achievement.targetValue === 'number' && (React.createElement("div", { className: "mt-3" }, React.createElement("div", { className: "flex justify-between text-xs text-muted-foreground mb-1" }, React.createElement("span", null, "Progress"), React.createElement("span", null, progress, "%")), React.createElement(ProgressComponent, { value: progress, className: "h-2" }))))); }; // Construct button className safely to avoid template literals in JSX props let buttonClassName = "gap-2 bg-background/80 backdrop-blur-sm shadow-md hover:bg-primary/20"; if (className) { buttonClassName += " " + className; } // Check if we have all required Dialog components if (!DialogComponents.Dialog || !DialogComponents.DialogTrigger || !DialogComponents.DialogContent) { return (React.createElement("div", { style: { border: '1px solid red', padding: '16px', borderRadius: '4px', color: 'red', marginBottom: '16px' } }, React.createElement("h3", null, "TrophyModal Error"), React.createElement("p", null, "Could not find required UI components. Please install them by running:"), React.createElement("pre", null, "npx shadcn-ui@latest add dialog button scroll-area progress"))); } // Destructure components for easier use const { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle } = DialogComponents; return (React.createElement(Dialog, { open: isOpen, onOpenChange: setIsOpen }, React.createElement(DialogTrigger, { asChild: true }, trigger ? (React.cloneElement(trigger, { onClick: (e) => { e.preventDefault(); setIsOpen(true); } })) : (React.createElement(ButtonComponent, { variant: "outline", size: "sm", className: buttonClassName, style: positionStyle }, React.createElement("span", { className: "h-4 w-4" }, "\uD83C\uDFC6"), "Achievements (", unlockedCount, ")"))), React.createElement(DialogContent, { className: `sm:max-w-lg ${modalClassName}` }, React.createElement(DialogHeader, null, React.createElement(DialogTitle, { className: "text-2xl flex items-center gap-2" }, React.createElement("span", { className: "h-6 w-6 text-yellow-500", role: "img", "aria-label": "trophy" }, "\uD83C\uDFC6"), modalTitle)), React.createElement(ScrollAreaComponent, { className: "h-[60vh] pr-4" }, React.createElement("div", { className: "flex flex-col gap-4" }, allAchievements.map(ach => (React.createElement(AchievementCard, { key: ach.achievementId, achievement: ach })))))))); }; const useAchievementState = () => { const { metrics, previouslyAwardedAchievements } = useAchievementStore(); return { metrics, previouslyAwardedAchievements, }; }; /** * Toast utilities for react-trophies * * This module provides wrapper functions around sonner's toast capabilities * to allow for consistent styling of achievement toasts across an application. */ /** * Creates and displays a toast notification using sonner * * This is a simple wrapper around sonner's toast function that can be used for * custom styling across your application. Use undefined to maintain consistent styling * for trophy notifications. * * @param {string} title - The title of the toast * @param {ExternalToast} options - Options for the toast (same as sonner's options) * @returns The toast ID */ const toast = (title, options) => { return toast$1(title, options); }; /** * Checks if a Toaster component is mounted in the DOM. * Useful for debugging or adding conditional toast behavior. * * @returns {boolean} True if a Toaster component is found in the DOM */ const isToasterMounted = () => { return typeof document !== 'undefined' && document.querySelector('[data-sonner-toaster]') !== null; }; export { AchievementProvider, ConfettiWrapper, TrophyModal, toast as TrophyToast, isToasterMounted, useAchievementContext as useAchievement, useAchievementState, useAchievementStore };