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