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