UNPKG

react-achievements-zustand

Version:

This package allows users to transpose a React achievements engine over their React apps using Zustand for state management

592 lines (576 loc) 39.7 kB
import React, { useState, useMemo, useCallback, useEffect } from 'react'; import { toast, ToastContainer } from 'react-toastify'; import { create } from 'zustand'; import Confetti from 'react-confetti'; import { useWindowSize } from 'react-use'; function styleInject(css, ref) { if ( ref === void 0 ) ref = {}; var insertAt = ref.insertAt; if (typeof document === 'undefined') { return; } var head = document.head || document.getElementsByTagName('head')[0]; var style = document.createElement('style'); style.type = 'text/css'; if (insertAt === 'top') { if (head.firstChild) { head.insertBefore(style, head.firstChild); } else { head.appendChild(style); } } else { head.appendChild(style); } if (style.styleSheet) { style.styleSheet.cssText = css; } else { style.appendChild(document.createTextNode(css)); } } var css_248z = ":root {\n --toastify-color-light: #fff;\n --toastify-color-dark: #121212;\n --toastify-color-info: #3498db;\n --toastify-color-success: #07bc0c;\n --toastify-color-warning: #f1c40f;\n --toastify-color-error: #e74c3c;\n --toastify-color-transparent: rgba(255, 255, 255, 0.7);\n --toastify-icon-color-info: var(--toastify-color-info);\n --toastify-icon-color-success: var(--toastify-color-success);\n --toastify-icon-color-warning: var(--toastify-color-warning);\n --toastify-icon-color-error: var(--toastify-color-error);\n --toastify-toast-width: 320px;\n --toastify-toast-background: #fff;\n --toastify-toast-min-height: 64px;\n --toastify-toast-max-height: 800px;\n --toastify-font-family: sans-serif;\n --toastify-z-index: 9999;\n --toastify-text-color-light: #757575;\n --toastify-text-color-dark: #fff;\n --toastify-text-color-info: #fff;\n --toastify-text-color-success: #fff;\n --toastify-text-color-warning: #fff;\n --toastify-text-color-error: #fff;\n --toastify-spinner-color: #616161;\n --toastify-spinner-color-empty-area: #e0e0e0;\n --toastify-color-progress-light: linear-gradient(\n to right,\n #4cd964,\n #5ac8fa,\n #007aff,\n #34aadc,\n #5856d6,\n #ff2d55\n );\n --toastify-color-progress-dark: #bb86fc;\n --toastify-color-progress-info: var(--toastify-color-info);\n --toastify-color-progress-success: var(--toastify-color-success);\n --toastify-color-progress-warning: var(--toastify-color-warning);\n --toastify-color-progress-error: var(--toastify-color-error);\n}\n\n.Toastify__toast-container {\n z-index: var(--toastify-z-index);\n -webkit-transform: translate3d(0, 0, var(--toastify-z-index));\n position: fixed;\n padding: 4px;\n width: var(--toastify-toast-width);\n box-sizing: border-box;\n color: #fff;\n}\n.Toastify__toast-container--top-left {\n top: 1em;\n left: 1em;\n}\n.Toastify__toast-container--top-center {\n top: 1em;\n left: 50%;\n transform: translateX(-50%);\n}\n.Toastify__toast-container--top-right {\n top: 1em;\n right: 1em;\n}\n.Toastify__toast-container--bottom-left {\n bottom: 1em;\n left: 1em;\n}\n.Toastify__toast-container--bottom-center {\n bottom: 1em;\n left: 50%;\n transform: translateX(-50%);\n}\n.Toastify__toast-container--bottom-right {\n bottom: 1em;\n right: 1em;\n}\n\n@media only screen and (max-width : 480px) {\n .Toastify__toast-container {\n width: 100vw;\n padding: 0;\n left: 0;\n margin: 0;\n }\n .Toastify__toast-container--top-left, .Toastify__toast-container--top-center, .Toastify__toast-container--top-right {\n top: 0;\n transform: translateX(0);\n }\n .Toastify__toast-container--bottom-left, .Toastify__toast-container--bottom-center, .Toastify__toast-container--bottom-right {\n bottom: 0;\n transform: translateX(0);\n }\n .Toastify__toast-container--rtl {\n right: 0;\n left: initial;\n }\n}\n.Toastify__toast {\n position: relative;\n min-height: var(--toastify-toast-min-height);\n box-sizing: border-box;\n margin-bottom: 1rem;\n padding: 8px;\n border-radius: 4px;\n box-shadow: 0 1px 10px 0 rgba(0, 0, 0, 0.1), 0 2px 15px 0 rgba(0, 0, 0, 0.05);\n display: -ms-flexbox;\n display: flex;\n -ms-flex-pack: justify;\n justify-content: space-between;\n max-height: var(--toastify-toast-max-height);\n overflow: hidden;\n font-family: var(--toastify-font-family);\n cursor: default;\n direction: ltr;\n /* webkit only issue #791 */\n z-index: 0;\n}\n.Toastify__toast--rtl {\n direction: rtl;\n}\n.Toastify__toast--close-on-click {\n cursor: pointer;\n}\n.Toastify__toast-body {\n margin: auto 0;\n -ms-flex: 1 1 auto;\n flex: 1 1 auto;\n padding: 6px;\n display: -ms-flexbox;\n display: flex;\n -ms-flex-align: center;\n align-items: center;\n}\n.Toastify__toast-body > div:last-child {\n word-break: break-word;\n -ms-flex: 1;\n flex: 1;\n}\n.Toastify__toast-icon {\n -webkit-margin-end: 10px;\n margin-inline-end: 10px;\n width: 20px;\n -ms-flex-negative: 0;\n flex-shrink: 0;\n display: -ms-flexbox;\n display: flex;\n}\n\n.Toastify--animate {\n animation-fill-mode: both;\n animation-duration: 0.7s;\n}\n\n.Toastify--animate-icon {\n animation-fill-mode: both;\n animation-duration: 0.3s;\n}\n\n@media only screen and (max-width : 480px) {\n .Toastify__toast {\n margin-bottom: 0;\n border-radius: 0;\n }\n}\n.Toastify__toast-theme--dark {\n background: var(--toastify-color-dark);\n color: var(--toastify-text-color-dark);\n}\n.Toastify__toast-theme--light {\n background: var(--toastify-color-light);\n color: var(--toastify-text-color-light);\n}\n.Toastify__toast-theme--colored.Toastify__toast--default {\n background: var(--toastify-color-light);\n color: var(--toastify-text-color-light);\n}\n.Toastify__toast-theme--colored.Toastify__toast--info {\n color: var(--toastify-text-color-info);\n background: var(--toastify-color-info);\n}\n.Toastify__toast-theme--colored.Toastify__toast--success {\n color: var(--toastify-text-color-success);\n background: var(--toastify-color-success);\n}\n.Toastify__toast-theme--colored.Toastify__toast--warning {\n color: var(--toastify-text-color-warning);\n background: var(--toastify-color-warning);\n}\n.Toastify__toast-theme--colored.Toastify__toast--error {\n color: var(--toastify-text-color-error);\n background: var(--toastify-color-error);\n}\n\n.Toastify__progress-bar-theme--light {\n background: var(--toastify-color-progress-light);\n}\n.Toastify__progress-bar-theme--dark {\n background: var(--toastify-color-progress-dark);\n}\n.Toastify__progress-bar--info {\n background: var(--toastify-color-progress-info);\n}\n.Toastify__progress-bar--success {\n background: var(--toastify-color-progress-success);\n}\n.Toastify__progress-bar--warning {\n background: var(--toastify-color-progress-warning);\n}\n.Toastify__progress-bar--error {\n background: var(--toastify-color-progress-error);\n}\n.Toastify__progress-bar-theme--colored.Toastify__progress-bar--info, .Toastify__progress-bar-theme--colored.Toastify__progress-bar--success, .Toastify__progress-bar-theme--colored.Toastify__progress-bar--warning, .Toastify__progress-bar-theme--colored.Toastify__progress-bar--error {\n background: var(--toastify-color-transparent);\n}\n\n.Toastify__close-button {\n color: #fff;\n background: transparent;\n outline: none;\n border: none;\n padding: 0;\n cursor: pointer;\n opacity: 0.7;\n transition: 0.3s ease;\n -ms-flex-item-align: start;\n align-self: flex-start;\n}\n.Toastify__close-button--light {\n color: #000;\n opacity: 0.3;\n}\n.Toastify__close-button > svg {\n fill: currentColor;\n height: 16px;\n width: 14px;\n}\n.Toastify__close-button:hover, .Toastify__close-button:focus {\n opacity: 1;\n}\n\n@keyframes Toastify__trackProgress {\n 0% {\n transform: scaleX(1);\n }\n 100% {\n transform: scaleX(0);\n }\n}\n.Toastify__progress-bar {\n position: absolute;\n bottom: 0;\n left: 0;\n width: 100%;\n height: 5px;\n z-index: var(--toastify-z-index);\n opacity: 0.7;\n transform-origin: left;\n}\n.Toastify__progress-bar--animated {\n animation: Toastify__trackProgress linear 1 forwards;\n}\n.Toastify__progress-bar--controlled {\n transition: transform 0.2s;\n}\n.Toastify__progress-bar--rtl {\n right: 0;\n left: initial;\n transform-origin: right;\n}\n\n.Toastify__spinner {\n width: 20px;\n height: 20px;\n box-sizing: border-box;\n border: 2px solid;\n border-radius: 100%;\n border-color: var(--toastify-spinner-color-empty-area);\n border-right-color: var(--toastify-spinner-color);\n animation: Toastify__spin 0.65s linear infinite;\n}\n\n@keyframes Toastify__bounceInRight {\n from, 60%, 75%, 90%, to {\n animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);\n }\n from {\n opacity: 0;\n transform: translate3d(3000px, 0, 0);\n }\n 60% {\n opacity: 1;\n transform: translate3d(-25px, 0, 0);\n }\n 75% {\n transform: translate3d(10px, 0, 0);\n }\n 90% {\n transform: translate3d(-5px, 0, 0);\n }\n to {\n transform: none;\n }\n}\n@keyframes Toastify__bounceOutRight {\n 20% {\n opacity: 1;\n transform: translate3d(-20px, 0, 0);\n }\n to {\n opacity: 0;\n transform: translate3d(2000px, 0, 0);\n }\n}\n@keyframes Toastify__bounceInLeft {\n from, 60%, 75%, 90%, to {\n animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);\n }\n 0% {\n opacity: 0;\n transform: translate3d(-3000px, 0, 0);\n }\n 60% {\n opacity: 1;\n transform: translate3d(25px, 0, 0);\n }\n 75% {\n transform: translate3d(-10px, 0, 0);\n }\n 90% {\n transform: translate3d(5px, 0, 0);\n }\n to {\n transform: none;\n }\n}\n@keyframes Toastify__bounceOutLeft {\n 20% {\n opacity: 1;\n transform: translate3d(20px, 0, 0);\n }\n to {\n opacity: 0;\n transform: translate3d(-2000px, 0, 0);\n }\n}\n@keyframes Toastify__bounceInUp {\n from, 60%, 75%, 90%, to {\n animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);\n }\n from {\n opacity: 0;\n transform: translate3d(0, 3000px, 0);\n }\n 60% {\n opacity: 1;\n transform: translate3d(0, -20px, 0);\n }\n 75% {\n transform: translate3d(0, 10px, 0);\n }\n 90% {\n transform: translate3d(0, -5px, 0);\n }\n to {\n transform: translate3d(0, 0, 0);\n }\n}\n@keyframes Toastify__bounceOutUp {\n 20% {\n transform: translate3d(0, -10px, 0);\n }\n 40%, 45% {\n opacity: 1;\n transform: translate3d(0, 20px, 0);\n }\n to {\n opacity: 0;\n transform: translate3d(0, -2000px, 0);\n }\n}\n@keyframes Toastify__bounceInDown {\n from, 60%, 75%, 90%, to {\n animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);\n }\n 0% {\n opacity: 0;\n transform: translate3d(0, -3000px, 0);\n }\n 60% {\n opacity: 1;\n transform: translate3d(0, 25px, 0);\n }\n 75% {\n transform: translate3d(0, -10px, 0);\n }\n 90% {\n transform: translate3d(0, 5px, 0);\n }\n to {\n transform: none;\n }\n}\n@keyframes Toastify__bounceOutDown {\n 20% {\n transform: translate3d(0, 10px, 0);\n }\n 40%, 45% {\n opacity: 1;\n transform: translate3d(0, -20px, 0);\n }\n to {\n opacity: 0;\n transform: translate3d(0, 2000px, 0);\n }\n}\n.Toastify__bounce-enter--top-left, .Toastify__bounce-enter--bottom-left {\n animation-name: Toastify__bounceInLeft;\n}\n.Toastify__bounce-enter--top-right, .Toastify__bounce-enter--bottom-right {\n animation-name: Toastify__bounceInRight;\n}\n.Toastify__bounce-enter--top-center {\n animation-name: Toastify__bounceInDown;\n}\n.Toastify__bounce-enter--bottom-center {\n animation-name: Toastify__bounceInUp;\n}\n\n.Toastify__bounce-exit--top-left, .Toastify__bounce-exit--bottom-left {\n animation-name: Toastify__bounceOutLeft;\n}\n.Toastify__bounce-exit--top-right, .Toastify__bounce-exit--bottom-right {\n animation-name: Toastify__bounceOutRight;\n}\n.Toastify__bounce-exit--top-center {\n animation-name: Toastify__bounceOutUp;\n}\n.Toastify__bounce-exit--bottom-center {\n animation-name: Toastify__bounceOutDown;\n}\n\n@keyframes Toastify__zoomIn {\n from {\n opacity: 0;\n transform: scale3d(0.3, 0.3, 0.3);\n }\n 50% {\n opacity: 1;\n }\n}\n@keyframes Toastify__zoomOut {\n from {\n opacity: 1;\n }\n 50% {\n opacity: 0;\n transform: scale3d(0.3, 0.3, 0.3);\n }\n to {\n opacity: 0;\n }\n}\n.Toastify__zoom-enter {\n animation-name: Toastify__zoomIn;\n}\n\n.Toastify__zoom-exit {\n animation-name: Toastify__zoomOut;\n}\n\n@keyframes Toastify__flipIn {\n from {\n transform: perspective(400px) rotate3d(1, 0, 0, 90deg);\n animation-timing-function: ease-in;\n opacity: 0;\n }\n 40% {\n transform: perspective(400px) rotate3d(1, 0, 0, -20deg);\n animation-timing-function: ease-in;\n }\n 60% {\n transform: perspective(400px) rotate3d(1, 0, 0, 10deg);\n opacity: 1;\n }\n 80% {\n transform: perspective(400px) rotate3d(1, 0, 0, -5deg);\n }\n to {\n transform: perspective(400px);\n }\n}\n@keyframes Toastify__flipOut {\n from {\n transform: perspective(400px);\n }\n 30% {\n transform: perspective(400px) rotate3d(1, 0, 0, -20deg);\n opacity: 1;\n }\n to {\n transform: perspective(400px) rotate3d(1, 0, 0, 90deg);\n opacity: 0;\n }\n}\n.Toastify__flip-enter {\n animation-name: Toastify__flipIn;\n}\n\n.Toastify__flip-exit {\n animation-name: Toastify__flipOut;\n}\n\n@keyframes Toastify__slideInRight {\n from {\n transform: translate3d(110%, 0, 0);\n visibility: visible;\n }\n to {\n transform: translate3d(0, 0, 0);\n }\n}\n@keyframes Toastify__slideInLeft {\n from {\n transform: translate3d(-110%, 0, 0);\n visibility: visible;\n }\n to {\n transform: translate3d(0, 0, 0);\n }\n}\n@keyframes Toastify__slideInUp {\n from {\n transform: translate3d(0, 110%, 0);\n visibility: visible;\n }\n to {\n transform: translate3d(0, 0, 0);\n }\n}\n@keyframes Toastify__slideInDown {\n from {\n transform: translate3d(0, -110%, 0);\n visibility: visible;\n }\n to {\n transform: translate3d(0, 0, 0);\n }\n}\n@keyframes Toastify__slideOutRight {\n from {\n transform: translate3d(0, 0, 0);\n }\n to {\n visibility: hidden;\n transform: translate3d(110%, 0, 0);\n }\n}\n@keyframes Toastify__slideOutLeft {\n from {\n transform: translate3d(0, 0, 0);\n }\n to {\n visibility: hidden;\n transform: translate3d(-110%, 0, 0);\n }\n}\n@keyframes Toastify__slideOutDown {\n from {\n transform: translate3d(0, 0, 0);\n }\n to {\n visibility: hidden;\n transform: translate3d(0, 500px, 0);\n }\n}\n@keyframes Toastify__slideOutUp {\n from {\n transform: translate3d(0, 0, 0);\n }\n to {\n visibility: hidden;\n transform: translate3d(0, -500px, 0);\n }\n}\n.Toastify__slide-enter--top-left, .Toastify__slide-enter--bottom-left {\n animation-name: Toastify__slideInLeft;\n}\n.Toastify__slide-enter--top-right, .Toastify__slide-enter--bottom-right {\n animation-name: Toastify__slideInRight;\n}\n.Toastify__slide-enter--top-center {\n animation-name: Toastify__slideInDown;\n}\n.Toastify__slide-enter--bottom-center {\n animation-name: Toastify__slideInUp;\n}\n\n.Toastify__slide-exit--top-left, .Toastify__slide-exit--bottom-left {\n animation-name: Toastify__slideOutLeft;\n}\n.Toastify__slide-exit--top-right, .Toastify__slide-exit--bottom-right {\n animation-name: Toastify__slideOutRight;\n}\n.Toastify__slide-exit--top-center {\n animation-name: Toastify__slideOutUp;\n}\n.Toastify__slide-exit--bottom-center {\n animation-name: Toastify__slideOutDown;\n}\n\n@keyframes Toastify__spin {\n from {\n transform: rotate(0deg);\n }\n to {\n transform: rotate(360deg);\n }\n}"; styleInject(css_248z); 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: (displayedNotifications) => { const state = get(); // Filter out only the notifications that were just displayed const remainingNotifications = state.notifications.filter(notification => !displayedNotifications.some(displayed => displayed.achievementId === notification.achievementId)); set({ notifications: remainingNotifications }); }, })); 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, }, }; 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; }; const AchievementProvider = ({ children, config, initialState = {}, storageKey = 'react-achievements', badgesButtonPosition = 'top-right', styles = {}, icons = {}, }) => { 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]; // Don't skip if no values - some achievements might depend on empty arrays if (!Array.isArray(metricValues)) { console.debug(`Invalid metric type for ${metricName}: expected array`); 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: console.debug(`Unknown condition type for achievement: ${condition.achievementDetails.achievementId}`); return false; } }; // Add debug logging console.debug(`Checking achievement ${condition.achievementDetails.achievementId}:`, { metricName, metricValues, conditionValue: condition.conditionValue, conditionType: condition.conditionType, alreadyUnlocked: unlockedAchievementIds.includes(condition.achievementDetails.achievementId), alreadyAwarded: previouslyAwardedAchievements.includes(condition.achievementDetails.achievementId) }); // Check the latest value in the array const latestValue = metricValues[metricValues.length - 1]; if (isConditionMet(latestValue) && !unlockedAchievementIds.includes(condition.achievementDetails.achievementId) && !previouslyAwardedAchievements.includes(condition.achievementDetails.achievementId)) { console.debug(`Achievement unlocked: ${condition.achievementDetails.achievementId}`); newAchievements.push(condition.achievementDetails); } }); }); if (newAchievements.length > 0) { console.debug('New achievements:', newAchievements); 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]); // Handle notifications useEffect(() => { if (notifications.length > 0) { // Keep track of which notifications we're displaying const displayingNotifications = [...notifications]; // Show all notifications at once displayingNotifications.forEach(achievement => { const mergedIcons = Object.assign(Object.assign({}, defaultAchievementIcons), icons); const iconToDisplay = (achievement === null || achievement === void 0 ? void 0 : achievement.achievementIconKey) && achievement.achievementIconKey in mergedIcons ? mergedIcons[achievement.achievementIconKey] : mergedIcons.default; toast(React.createElement("div", { style: { display: 'flex', alignItems: 'center' } }, React.createElement("span", { style: { fontSize: '2em', marginRight: '10px' } }, iconToDisplay), React.createElement("div", null, React.createElement("div", { style: { fontWeight: 'bold' } }, achievement.achievementTitle), React.createElement("div", null, achievement.achievementDescription))), { position: "top-right", autoClose: 3000, hideProgressBar: false, closeOnClick: true, pauseOnHover: true, draggable: true, progress: undefined }); }); // Clear only the notifications we just displayed clearNotifications(displayingNotifications); // Show confetti for the achievements setShowConfetti(true); setTimeout(() => setShowConfetti(false), 3000); } }, [notifications, icons, clearNotifications]); const showBadgesModal = () => setShowBadges(true); return (React.createElement(AchievementContext.Provider, { value: { updateMetrics: (newMetrics) => { if (!isInitialized) return; if (typeof newMetrics === 'function') { const updatedMetrics = newMetrics(metrics); setMetrics(updatedMetrics); } else { // Properly merge arrays for each metric key const mergedMetrics = Object.entries(newMetrics).reduce((acc, [key, value]) => (Object.assign(Object.assign({}, acc), { [key]: Array.isArray(metrics[key]) ? [...metrics[key], ...value] : value })), Object.assign({}, metrics)); setMetrics(mergedMetrics); } }, unlockedAchievements: unlockedAchievementIds, resetStorage: () => { localStorage.removeItem(storageKey); resetAchievements(); }, } }, children, React.createElement(ToastContainer, null), 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 }))); }; const useAchievementState = () => { const { metrics, previouslyAwardedAchievements } = useAchievementStore(); return { metrics, previouslyAwardedAchievements, }; }; export { AchievementProvider, ConfettiWrapper, useAchievementContext as useAchievement, useAchievementState, useAchievementStore };