react-achievements 3.2.1 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -322,6 +322,9 @@ See the [examples directory](./stories/examples) for detailed implementations an
322
322
  - Toast notifications
323
323
  - Confetti animations
324
324
  - TypeScript support
325
+ - **NEW in v3.3.0**: Comprehensive error handling system
326
+ - **NEW in v3.3.0**: Data export/import for achievement portability
327
+ - **NEW in v3.3.0**: Type-safe error classes with recovery guidance
325
328
 
326
329
  ## Achievement Notifications & History
327
330
 
@@ -464,6 +467,608 @@ const App = () => {
464
467
  export default App;
465
468
  ```
466
469
 
470
+ ## Error Handling
471
+
472
+ React Achievements v3.3.0 introduces a comprehensive error handling system with specialized error types, recovery guidance, and graceful degradation.
473
+
474
+ ### Error Types
475
+
476
+ The library provides 6 specialized error classes for different failure scenarios:
477
+
478
+ ```tsx
479
+ import {
480
+ StorageQuotaError,
481
+ ImportValidationError,
482
+ StorageError,
483
+ ConfigurationError,
484
+ SyncError,
485
+ isAchievementError,
486
+ isRecoverableError
487
+ } from 'react-achievements';
488
+ ```
489
+
490
+ | Error Type | When It Occurs | Recoverable | Use Case |
491
+ |-----------|----------------|-------------|----------|
492
+ | `StorageQuotaError` | Browser storage quota exceeded | Yes | Prompt user to clear storage or export data |
493
+ | `ImportValidationError` | Invalid data during import | Yes | Show validation errors to user |
494
+ | `StorageError` | Storage read/write failures | Maybe | Retry operation or fallback to memory storage |
495
+ | `ConfigurationError` | Invalid achievement config | No | Fix configuration during development |
496
+ | `SyncError` | Multi-device sync failures | Yes | Retry sync or use local data |
497
+
498
+ ### Using the onError Callback
499
+
500
+ Handle errors gracefully by providing an `onError` callback to the `AchievementProvider`:
501
+
502
+ ```tsx
503
+ import { AchievementProvider, AchievementError, StorageQuotaError } from 'react-achievements';
504
+
505
+ const App = () => {
506
+ const handleAchievementError = (error: AchievementError) => {
507
+ // Check error type
508
+ if (error instanceof StorageQuotaError) {
509
+ console.error(`Storage quota exceeded! Need ${error.bytesNeeded} bytes`);
510
+ console.log('Remedy:', error.remedy);
511
+
512
+ // Offer user the option to export and clear data
513
+ if (confirm('Storage full. Export your achievements?')) {
514
+ // Export data before clearing (see Data Export/Import section)
515
+ exportAndClearData();
516
+ }
517
+ }
518
+
519
+ // Use type guards
520
+ if (isRecoverableError(error)) {
521
+ // Show user-friendly error message with remedy
522
+ showNotification({
523
+ type: 'error',
524
+ message: error.message,
525
+ remedy: error.remedy
526
+ });
527
+ } else {
528
+ // Log non-recoverable errors
529
+ console.error('Non-recoverable error:', error);
530
+ }
531
+ };
532
+
533
+ return (
534
+ <AchievementProvider
535
+ achievements={gameAchievements}
536
+ storage="local"
537
+ onError={handleAchievementError}
538
+ >
539
+ <Game />
540
+ </AchievementProvider>
541
+ );
542
+ };
543
+ ```
544
+
545
+ ### Error Properties
546
+
547
+ All achievement errors include helpful properties:
548
+
549
+ ```tsx
550
+ try {
551
+ // Some operation that might fail
552
+ storage.setMetrics(metrics);
553
+ } catch (error) {
554
+ if (isAchievementError(error)) {
555
+ console.log(error.code); // Machine-readable: "STORAGE_QUOTA_EXCEEDED"
556
+ console.log(error.message); // Human-readable: "Browser storage quota exceeded"
557
+ console.log(error.recoverable); // true/false - can this be recovered?
558
+ console.log(error.remedy); // Guidance: "Clear browser storage or..."
559
+
560
+ // Error-specific properties
561
+ if (error instanceof StorageQuotaError) {
562
+ console.log(error.bytesNeeded); // How much space is needed
563
+ }
564
+ }
565
+ }
566
+ ```
567
+
568
+ ### Graceful Degradation
569
+
570
+ If no `onError` callback is provided, errors are automatically logged to the console with full details:
571
+
572
+ ```tsx
573
+ // Without onError callback
574
+ <AchievementProvider achievements={gameAchievements} storage="local">
575
+ <Game />
576
+ </AchievementProvider>
577
+
578
+ // Errors are automatically logged:
579
+ // "Achievement storage error: Browser storage quota exceeded.
580
+ // Remedy: Clear browser storage, reduce the number of achievements..."
581
+ ```
582
+
583
+ ### Type Guards
584
+
585
+ Use type guards for type-safe error handling:
586
+
587
+ ```tsx
588
+ import { isAchievementError, isRecoverableError } from 'react-achievements';
589
+
590
+ try {
591
+ await syncAchievements();
592
+ } catch (error) {
593
+ if (isAchievementError(error)) {
594
+ // TypeScript knows this is an AchievementError
595
+ console.log(error.code, error.remedy);
596
+
597
+ if (isRecoverableError(error)) {
598
+ // Attempt recovery
599
+ retryOperation();
600
+ }
601
+ } else {
602
+ // Handle non-achievement errors
603
+ console.error('Unexpected error:', error);
604
+ }
605
+ }
606
+ ```
607
+
608
+ ## Data Export/Import
609
+
610
+ Transfer achievements between devices, create backups, or migrate data with the export/import system. Export to local files or cloud storage providers like AWS S3 and Azure Blob Storage.
611
+
612
+ ### Exporting Achievement Data
613
+
614
+ Export all achievement data including metrics, unlocked achievements, and configuration:
615
+
616
+ ```tsx
617
+ import { useAchievements, exportAchievementData } from 'react-achievements';
618
+
619
+ const MyComponent = () => {
620
+ const { getState } = useAchievements();
621
+
622
+ const exportData = () => {
623
+ const state = getState();
624
+ return exportAchievementData(
625
+ state.metrics,
626
+ state.unlockedAchievements,
627
+ achievements // Your achievement configuration
628
+ );
629
+ };
630
+
631
+ return (
632
+ <>
633
+ <button onClick={handleExportToFile}>Export to File</button>
634
+ <button onClick={handleExportToAWS}>Export to AWS S3</button>
635
+ <button onClick={handleExportToAzure}>Export to Azure</button>
636
+ </>
637
+ );
638
+ };
639
+ ```
640
+
641
+ ### Export to Local File
642
+
643
+ Download achievement data as a JSON file:
644
+
645
+ ```tsx
646
+ const handleExportToFile = () => {
647
+ const exportedData = exportData();
648
+
649
+ const blob = new Blob([JSON.stringify(exportedData)], { type: 'application/json' });
650
+ const url = URL.createObjectURL(blob);
651
+ const link = document.createElement('a');
652
+ link.href = url;
653
+ link.download = `achievements-${Date.now()}.json`;
654
+ link.click();
655
+ URL.revokeObjectURL(url);
656
+ };
657
+ ```
658
+
659
+ ### Export to AWS S3
660
+
661
+ Upload achievement data to Amazon S3 for cloud backup and cross-device sync:
662
+
663
+ ```tsx
664
+ import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
665
+
666
+ const handleExportToAWS = async () => {
667
+ const exportedData = exportData();
668
+
669
+ const s3Client = new S3Client({
670
+ region: 'us-east-1',
671
+ credentials: {
672
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID,
673
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
674
+ },
675
+ });
676
+
677
+ const userId = getCurrentUserId(); // Your user identification logic
678
+ const key = `achievements/${userId}/data.json`;
679
+
680
+ try {
681
+ await s3Client.send(new PutObjectCommand({
682
+ Bucket: 'my-app-achievements',
683
+ Key: key,
684
+ Body: JSON.stringify(exportedData),
685
+ ContentType: 'application/json',
686
+ Metadata: {
687
+ version: exportedData.version,
688
+ timestamp: exportedData.timestamp,
689
+ },
690
+ }));
691
+
692
+ console.log('Achievements backed up to S3 successfully!');
693
+ } catch (error) {
694
+ console.error('Failed to upload to S3:', error);
695
+ }
696
+ };
697
+ ```
698
+
699
+ ### Import from AWS S3
700
+
701
+ ```tsx
702
+ const MyComponent = () => {
703
+ const { update } = useAchievements(); // Get update from hook
704
+
705
+ const handleImportFromAWS = async () => {
706
+ const s3Client = new S3Client({ /* config */ });
707
+ const userId = getCurrentUserId();
708
+
709
+ try {
710
+ const response = await s3Client.send(new GetObjectCommand({
711
+ Bucket: 'my-app-achievements',
712
+ Key: `achievements/${userId}/data.json`,
713
+ }));
714
+
715
+ const data = JSON.parse(await response.Body.transformToString());
716
+
717
+ const result = importAchievementData(data, {
718
+ strategy: 'merge',
719
+ achievements: gameAchievements
720
+ });
721
+
722
+ if (result.success) {
723
+ update(result.mergedMetrics);
724
+ console.log('Achievements restored from S3!');
725
+ }
726
+ } catch (error) {
727
+ console.error('Failed to import from S3:', error);
728
+ }
729
+ };
730
+
731
+ return <button onClick={handleImportFromAWS}>Restore from AWS</button>;
732
+ };
733
+ ```
734
+
735
+ ### Export to Microsoft Azure Blob Storage
736
+
737
+ Upload achievement data to Azure for enterprise cloud backup:
738
+
739
+ ```tsx
740
+ import { BlobServiceClient } from '@azure/storage-blob';
741
+
742
+ const handleExportToAzure = async () => {
743
+ const exportedData = exportData();
744
+
745
+ const blobServiceClient = BlobServiceClient.fromConnectionString(
746
+ process.env.AZURE_STORAGE_CONNECTION_STRING
747
+ );
748
+
749
+ const containerClient = blobServiceClient.getContainerClient('achievements');
750
+ const userId = getCurrentUserId();
751
+ const blobName = `${userId}/achievements-${Date.now()}.json`;
752
+ const blockBlobClient = containerClient.getBlockBlobClient(blobName);
753
+
754
+ try {
755
+ await blockBlobClient.upload(
756
+ JSON.stringify(exportedData),
757
+ JSON.stringify(exportedData).length,
758
+ {
759
+ blobHTTPHeaders: {
760
+ blobContentType: 'application/json',
761
+ },
762
+ metadata: {
763
+ version: exportedData.version,
764
+ timestamp: exportedData.timestamp,
765
+ configHash: exportedData.configHash,
766
+ },
767
+ }
768
+ );
769
+
770
+ console.log('Achievements backed up to Azure successfully!');
771
+ } catch (error) {
772
+ console.error('Failed to upload to Azure:', error);
773
+ }
774
+ };
775
+ ```
776
+
777
+
778
+
779
+ ### Import from Azure Blob Storage
780
+
781
+ ```tsx
782
+ const MyComponent = () => {
783
+ const { update } = useAchievements(); // Get update from hook
784
+
785
+ const handleImportFromAzure = async () => {
786
+ const blobServiceClient = BlobServiceClient.fromConnectionString(
787
+ process.env.AZURE_STORAGE_CONNECTION_STRING
788
+ );
789
+
790
+ const containerClient = blobServiceClient.getContainerClient('achievements');
791
+ const userId = getCurrentUserId();
792
+
793
+ try {
794
+ // List blobs to find the latest backup
795
+ const blobs = containerClient.listBlobsFlat({ prefix: `${userId}/` });
796
+ let latestBlob = null;
797
+
798
+ for await (const blob of blobs) {
799
+ if (!latestBlob || blob.properties.createdOn > latestBlob.properties.createdOn) {
800
+ latestBlob = blob;
801
+ }
802
+ }
803
+
804
+ if (latestBlob) {
805
+ const blockBlobClient = containerClient.getBlockBlobClient(latestBlob.name);
806
+ const downloadResponse = await blockBlobClient.download(0);
807
+ const data = JSON.parse(await streamToString(downloadResponse.readableStreamBody));
808
+
809
+ const result = importAchievementData(data, {
810
+ strategy: 'merge',
811
+ achievements: gameAchievements
812
+ });
813
+
814
+ if (result.success) {
815
+ update(result.mergedMetrics);
816
+ console.log('Achievements restored from Azure!');
817
+ }
818
+ }
819
+ } catch (error) {
820
+ console.error('Failed to import from Azure:', error);
821
+ }
822
+ };
823
+
824
+ return <button onClick={handleImportFromAzure}>Restore from Azure</button>;
825
+ };
826
+
827
+ // Helper function to convert stream to string
828
+ async function streamToString(readableStream) {
829
+ return new Promise((resolve, reject) => {
830
+ const chunks = [];
831
+ readableStream.on('data', (data) => chunks.push(data.toString()));
832
+ readableStream.on('end', () => resolve(chunks.join('')));
833
+ readableStream.on('error', reject);
834
+ });
835
+ }
836
+ ```
837
+
838
+ ### Cloud Storage Best Practices
839
+
840
+ When using cloud storage for achievements:
841
+
842
+ **Security**:
843
+ ```tsx
844
+ // Never expose credentials in client-side code
845
+ // Use environment variables or secure credential management
846
+ const credentials = {
847
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID, // Server-side only
848
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, // Server-side only
849
+ };
850
+
851
+ // For client-side apps, use temporary credentials via STS or Cognito
852
+ import { CognitoIdentityClient } from '@aws-sdk/client-cognito-identity';
853
+ import { fromCognitoIdentityPool } from '@aws-sdk/credential-provider-cognito-identity';
854
+
855
+ const s3Client = new S3Client({
856
+ region: 'us-east-1',
857
+ credentials: fromCognitoIdentityPool({
858
+ client: new CognitoIdentityClient({ region: 'us-east-1' }),
859
+ identityPoolId: 'us-east-1:xxxxx-xxxx-xxxx',
860
+ }),
861
+ });
862
+ ```
863
+
864
+ **File Naming**:
865
+ ```tsx
866
+ // Use consistent naming for easy retrieval
867
+ const generateKey = (userId: string) => {
868
+ const timestamp = new Date().toISOString();
869
+ return `achievements/${userId}/${timestamp}.json`;
870
+ };
871
+
872
+ // Or use latest.json for current data + timestamped backups
873
+ const keys = {
874
+ current: `achievements/${userId}/latest.json`,
875
+ backup: `achievements/${userId}/backups/${Date.now()}.json`
876
+ };
877
+ ```
878
+
879
+ **Error Handling**:
880
+ ```tsx
881
+ const uploadWithRetry = async (data: ExportedData, maxRetries = 3) => {
882
+ for (let i = 0; i < maxRetries; i++) {
883
+ try {
884
+ await uploadToCloud(data);
885
+ return { success: true };
886
+ } catch (error) {
887
+ if (i === maxRetries - 1) throw error;
888
+ await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i)));
889
+ }
890
+ }
891
+ };
892
+ ```
893
+
894
+ ### Importing Achievement Data
895
+
896
+ Import previously exported data with validation and merge strategies:
897
+
898
+ ```tsx
899
+ import { useAchievements, importAchievementData } from 'react-achievements';
900
+
901
+ const MyComponent = () => {
902
+ const { update } = useAchievements();
903
+
904
+ const handleImport = async (file: File) => {
905
+ try {
906
+ const text = await file.text();
907
+ const importedData = JSON.parse(text);
908
+
909
+ const result = importAchievementData(importedData, {
910
+ strategy: 'merge', // 'replace', 'merge', or 'preserve'
911
+ achievements: gameAchievements
912
+ });
913
+
914
+ if (result.success) {
915
+ // Apply merged data
916
+ update(result.mergedMetrics);
917
+ console.log(`Imported ${result.importedCount} achievements`);
918
+ } else {
919
+ // Handle validation errors
920
+ console.error('Import failed:', result.errors);
921
+ }
922
+ } catch (error) {
923
+ if (error instanceof ImportValidationError) {
924
+ console.error('Invalid import file:', error.remedy);
925
+ }
926
+ }
927
+ };
928
+
929
+ return (
930
+ <input
931
+ type="file"
932
+ accept=".json"
933
+ onChange={(e) => e.target.files?.[0] && handleImport(e.target.files[0])}
934
+ />
935
+ );
936
+ };
937
+ ```
938
+
939
+ ### Merge Strategies
940
+
941
+ Control how imported data is merged with existing data:
942
+
943
+ ```tsx
944
+ // Replace: Completely replace all existing data
945
+ const result = importAchievementData(data, {
946
+ strategy: 'replace',
947
+ achievements
948
+ });
949
+
950
+ // Merge: Combine imported and existing data
951
+ // - Takes maximum values for metrics
952
+ // - Combines unlocked achievements
953
+ const result = importAchievementData(data, {
954
+ strategy: 'merge',
955
+ achievements
956
+ });
957
+
958
+ // Preserve: Only import new achievements, keep existing data
959
+ const result = importAchievementData(data, {
960
+ strategy: 'preserve',
961
+ achievements
962
+ });
963
+ ```
964
+
965
+ ### Export Data Structure
966
+
967
+ The exported data includes:
968
+
969
+ ```
970
+ {
971
+ version: "1.0", // Export format version
972
+ timestamp: "2024-12-10T...", // When data was exported
973
+ configHash: "abc123...", // Hash of achievement config
974
+ metrics: { // All tracked metrics
975
+ score: 1000,
976
+ level: 5
977
+ },
978
+ unlockedAchievements: [ // All unlocked achievement IDs
979
+ "score_100",
980
+ "level_5"
981
+ ]
982
+ }
983
+ ```
984
+
985
+ ### Configuration Validation
986
+
987
+ Import validation ensures data compatibility:
988
+
989
+ ```tsx
990
+ try {
991
+ const result = importAchievementData(importedData, {
992
+ strategy: 'replace',
993
+ achievements
994
+ });
995
+
996
+ if (!result.success) {
997
+ // Check for configuration mismatch
998
+ if (result.configMismatch) {
999
+ console.warn('Achievement configuration has changed since export');
1000
+ console.log('You can still import with strategy: merge or preserve');
1001
+ }
1002
+
1003
+ // Check for validation errors
1004
+ console.error('Validation errors:', result.errors);
1005
+ }
1006
+ } catch (error) {
1007
+ if (error instanceof ImportValidationError) {
1008
+ console.error('Import failed:', error.message, error.remedy);
1009
+ }
1010
+ }
1011
+ ```
1012
+
1013
+ ### Use Cases
1014
+
1015
+ **Backup Before Clearing Storage**:
1016
+ ```tsx
1017
+ const MyComponent = () => {
1018
+ const { getState, reset } = useAchievements();
1019
+
1020
+ // Storage quota exceeded - export before clearing
1021
+ const handleStorageQuotaError = (error: StorageQuotaError) => {
1022
+ const state = getState();
1023
+ const backup = exportAchievementData(state.metrics, state.unlockedAchievements, achievements);
1024
+
1025
+ // Save backup
1026
+ localStorage.setItem('achievement-backup', JSON.stringify(backup));
1027
+
1028
+ // Clear storage
1029
+ reset();
1030
+
1031
+ alert('Data backed up and storage cleared!');
1032
+ };
1033
+
1034
+ return <button onClick={() => handleStorageQuotaError(new StorageQuotaError(1000))}>Test Backup</button>;
1035
+ };
1036
+ ```
1037
+
1038
+ **Cross-Device Transfer**:
1039
+ ```tsx
1040
+ const MyComponent = () => {
1041
+ const { getState, update } = useAchievements();
1042
+
1043
+ // Device 1: Export data
1044
+ const exportData = () => {
1045
+ const state = getState();
1046
+ const data = exportAchievementData(state.metrics, state.unlockedAchievements, achievements);
1047
+ // Upload to cloud or save to file
1048
+ return data;
1049
+ };
1050
+
1051
+ // Device 2: Import data
1052
+ const importData = async (cloudData) => {
1053
+ const result = importAchievementData(cloudData, {
1054
+ strategy: 'merge', // Combine with any local progress
1055
+ achievements
1056
+ });
1057
+
1058
+ if (result.success) {
1059
+ update(result.mergedMetrics);
1060
+ }
1061
+ };
1062
+
1063
+ return (
1064
+ <>
1065
+ <button onClick={() => exportData()}>Export for Transfer</button>
1066
+ <button onClick={() => importData(/* cloudData */)}>Import from Other Device</button>
1067
+ </>
1068
+ );
1069
+ };
1070
+ ```
1071
+
467
1072
  ## Styling
468
1073
 
469
1074
  The achievement components use default styling that works well out of the box. For custom styling, you can override the CSS classes or customize individual component props:
@@ -495,6 +1100,7 @@ The achievement components use default styling that works well out of the box. F
495
1100
  | storage | 'local' \| 'memory' \| AchievementStorage | Storage implementation |
496
1101
  | theme | ThemeConfig | Custom theme configuration |
497
1102
  | onUnlock | (achievement: Achievement) => void | Callback when achievement is unlocked |
1103
+ | onError | (error: AchievementError) => void | **NEW in v3.3.0**: Callback when errors occur |
498
1104
 
499
1105
  ### useAchievements Hook
500
1106
 
@@ -574,6 +1180,88 @@ const Game = () => {
574
1180
 
575
1181
  This API provides maximum flexibility for complex achievement logic but requires more verbose configuration. Most users should use the Simple API or Builder API instead.
576
1182
 
1183
+ ## Contributing
1184
+
1185
+ We welcome contributions to React Achievements! This project includes quality controls to ensure code reliability.
1186
+
1187
+ ### Git Hooks
1188
+
1189
+ The project uses pre-commit hooks to maintain code quality. After cloning the repository, install the hooks:
1190
+
1191
+ ```bash
1192
+ npm run install-hooks
1193
+ ```
1194
+
1195
+ This will install a pre-commit hook that automatically:
1196
+ - Runs TypeScript type checking
1197
+ - Runs the full test suite (154 tests)
1198
+ - Blocks commits if checks fail
1199
+
1200
+ ### What the Hook Does
1201
+
1202
+ When you run `git commit`, the hook will:
1203
+ 1. Run type checking (~2-5 seconds)
1204
+ 2. Run all tests (~2-3 seconds)
1205
+ 3. Block the commit if either fails
1206
+ 4. Allow the commit if all checks pass
1207
+
1208
+ ### Bypassing the Hook
1209
+
1210
+ Not recommended, but if needed:
1211
+
1212
+ ```bash
1213
+ git commit --no-verify
1214
+ ```
1215
+
1216
+ Only use this when:
1217
+ - Committing work-in-progress intentionally
1218
+ - Reverting a commit that broke tests
1219
+ - You have a valid reason to skip checks
1220
+
1221
+ Never bypass for:
1222
+ - Failing tests (fix them first!)
1223
+ - TypeScript errors (fix them first!)
1224
+
1225
+ ### Running Tests Manually
1226
+
1227
+ Before committing, you can run tests manually:
1228
+
1229
+ ```bash
1230
+ # Run type checking
1231
+ npm run type-check
1232
+
1233
+ # Run tests
1234
+ npm run test:unit
1235
+
1236
+ # Run both (same as git hook)
1237
+ npm test
1238
+ ```
1239
+
1240
+ ### Test Coverage
1241
+
1242
+ The library has comprehensive test coverage:
1243
+ - 154 total tests
1244
+ - Unit tests for all core functionality
1245
+ - Integration tests for React components
1246
+ - Error handling tests (43 tests)
1247
+ - Data export/import tests
1248
+
1249
+ ### Troubleshooting
1250
+
1251
+ If the hook isn't running:
1252
+
1253
+ 1. Check if it's installed:
1254
+ ```bash
1255
+ ls -la .git/hooks/pre-commit
1256
+ ```
1257
+
1258
+ 2. Reinstall if needed:
1259
+ ```bash
1260
+ npm run install-hooks
1261
+ ```
1262
+
1263
+ For more details, see [`docs/git-hooks.md`](./docs/git-hooks.md).
1264
+
577
1265
  ## License
578
1266
 
579
1267
  MIT