react-achievements 3.2.1 → 3.4.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,13 @@ 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.4.0**: Async storage support (IndexedDB, REST API, Offline Queue)
326
+ - **NEW in v3.4.0**: 50MB+ storage capacity with IndexedDB
327
+ - **NEW in v3.4.0**: Server-side sync with REST API storage
328
+ - **NEW in v3.4.0**: Offline-first capabilities with automatic queue sync
329
+ - **NEW in v3.3.0**: Comprehensive error handling system
330
+ - **NEW in v3.3.0**: Data export/import for achievement portability
331
+ - **NEW in v3.3.0**: Type-safe error classes with recovery guidance
325
332
 
326
333
  ## Achievement Notifications & History
327
334
 
@@ -413,9 +420,185 @@ const achievements = {
413
420
 
414
421
  The library provides a small set of essential fallback icons for system use (error states, loading, etc.). These are automatically used when needed and don't require any configuration.
415
422
 
423
+ ## Async Storage (NEW in v3.4.0)
424
+
425
+ React Achievements now supports async storage backends for modern applications that need large data capacity, server sync, or offline-first capabilities.
426
+
427
+ ### IndexedDB Storage
428
+
429
+ Browser-native storage with 50MB+ capacity (vs localStorage's 5-10MB limit):
430
+
431
+ ```tsx
432
+ import { AchievementProvider, StorageType } from 'react-achievements';
433
+
434
+ const App = () => {
435
+ return (
436
+ <AchievementProvider
437
+ achievements={gameAchievements}
438
+ storage={StorageType.IndexedDB} // Use IndexedDB for large data
439
+ >
440
+ <Game />
441
+ </AchievementProvider>
442
+ );
443
+ };
444
+ ```
445
+
446
+ **Benefits:**
447
+ - ✅ 10x larger capacity than localStorage
448
+ - ✅ Structured data storage
449
+ - ✅ Better performance for large datasets
450
+ - ✅ Non-blocking async operations
451
+
452
+ ### REST API Storage
453
+
454
+ Sync achievements with your backend server:
455
+
456
+ ```tsx
457
+ import { AchievementProvider, StorageType } from 'react-achievements';
458
+
459
+ const App = () => {
460
+ return (
461
+ <AchievementProvider
462
+ achievements={gameAchievements}
463
+ storage={StorageType.RestAPI}
464
+ restApiConfig={{
465
+ baseUrl: 'https://api.example.com',
466
+ userId: getCurrentUserId(),
467
+ headers: {
468
+ 'Authorization': `Bearer ${getAuthToken()}`
469
+ },
470
+ timeout: 10000 // Optional, default 10s
471
+ }}
472
+ >
473
+ <Game />
474
+ </AchievementProvider>
475
+ );
476
+ };
477
+ ```
478
+
479
+ **API Endpoints Expected:**
480
+ ```
481
+ GET /users/:userId/achievements/metrics
482
+ PUT /users/:userId/achievements/metrics
483
+ GET /users/:userId/achievements/unlocked
484
+ PUT /users/:userId/achievements/unlocked
485
+ DELETE /users/:userId/achievements
486
+ ```
487
+
488
+ **Benefits:**
489
+ - ✅ Cross-device synchronization
490
+ - ✅ Server-side backup
491
+ - ✅ User authentication support
492
+ - ✅ Centralized data management
493
+
494
+ ### Offline Queue Storage
495
+
496
+ Offline-first storage with automatic sync when back online:
497
+
498
+ ```tsx
499
+ import {
500
+ AchievementProvider,
501
+ OfflineQueueStorage,
502
+ RestApiStorage
503
+ } from 'react-achievements';
504
+
505
+ // Wrap REST API storage with offline queue
506
+ const restApi = new RestApiStorage({
507
+ baseUrl: 'https://api.example.com',
508
+ userId: 'user123',
509
+ headers: { 'Authorization': 'Bearer token' }
510
+ });
511
+
512
+ const offlineStorage = new OfflineQueueStorage(restApi);
513
+
514
+ const App = () => {
515
+ return (
516
+ <AchievementProvider
517
+ achievements={gameAchievements}
518
+ storage={offlineStorage}
519
+ >
520
+ <Game />
521
+ </AchievementProvider>
522
+ );
523
+ };
524
+ ```
525
+
526
+ **Benefits:**
527
+ - ✅ Works offline - queues operations locally
528
+ - ✅ Automatic sync when connection restored
529
+ - ✅ Persistent queue survives page refreshes
530
+ - ✅ Graceful degradation for poor connectivity
531
+
532
+ ### Custom Async Storage
533
+
534
+ You can create custom async storage by implementing the `AsyncAchievementStorage` interface:
535
+
536
+ ```tsx
537
+ import {
538
+ AsyncAchievementStorage,
539
+ AchievementMetrics,
540
+ AsyncStorageAdapter,
541
+ AchievementProvider
542
+ } from 'react-achievements';
543
+
544
+ class MyCustomAsyncStorage implements AsyncAchievementStorage {
545
+ async getMetrics(): Promise<AchievementMetrics> {
546
+ // Your async implementation (e.g., fetch from database)
547
+ const response = await fetch('/my-api/metrics');
548
+ return response.json();
549
+ }
550
+
551
+ async setMetrics(metrics: AchievementMetrics): Promise<void> {
552
+ await fetch('/my-api/metrics', {
553
+ method: 'PUT',
554
+ body: JSON.stringify(metrics)
555
+ });
556
+ }
557
+
558
+ async getUnlockedAchievements(): Promise<string[]> {
559
+ const response = await fetch('/my-api/unlocked');
560
+ return response.json();
561
+ }
562
+
563
+ async setUnlockedAchievements(achievements: string[]): Promise<void> {
564
+ await fetch('/my-api/unlocked', {
565
+ method: 'PUT',
566
+ body: JSON.stringify(achievements)
567
+ });
568
+ }
569
+
570
+ async clear(): Promise<void> {
571
+ await fetch('/my-api/clear', { method: 'DELETE' });
572
+ }
573
+ }
574
+
575
+ // Wrap with adapter for optimistic updates
576
+ const customStorage = new MyCustomAsyncStorage();
577
+ const adapter = new AsyncStorageAdapter(customStorage, {
578
+ onError: (error) => console.error('Storage error:', error)
579
+ });
580
+
581
+ const App = () => {
582
+ return (
583
+ <AchievementProvider
584
+ achievements={gameAchievements}
585
+ storage={adapter}
586
+ >
587
+ <Game />
588
+ </AchievementProvider>
589
+ );
590
+ };
591
+ ```
592
+
593
+ **How AsyncStorageAdapter Works:**
594
+ - **Optimistic Updates**: Returns cached data immediately (no waiting)
595
+ - **Eager Loading**: Preloads data during initialization
596
+ - **Background Writes**: All writes happen async without blocking UI
597
+ - **Error Handling**: Optional error callback for failed operations
598
+
416
599
  ## Custom Storage
417
600
 
418
- You can implement your own storage solution by implementing the `AchievementStorage` interface:
601
+ You can implement your own synchronous storage solution by implementing the `AchievementStorage` interface:
419
602
 
420
603
  ```tsx
421
604
  import { AchievementStorage, AchievementMetrics, AchievementProvider } from 'react-achievements';
@@ -464,6 +647,613 @@ const App = () => {
464
647
  export default App;
465
648
  ```
466
649
 
650
+ ## Error Handling
651
+
652
+ React Achievements v3.3.0 introduces a comprehensive error handling system with specialized error types, recovery guidance, and graceful degradation.
653
+
654
+ ### Error Types
655
+
656
+ The library provides 6 specialized error classes for different failure scenarios:
657
+
658
+ ```tsx
659
+ import {
660
+ StorageQuotaError,
661
+ ImportValidationError,
662
+ StorageError,
663
+ ConfigurationError,
664
+ SyncError,
665
+ isAchievementError,
666
+ isRecoverableError
667
+ } from 'react-achievements';
668
+ ```
669
+
670
+ | Error Type | When It Occurs | Recoverable | Use Case |
671
+ |-----------|----------------|-------------|----------|
672
+ | `StorageQuotaError` | Browser storage quota exceeded | Yes | Prompt user to clear storage or export data |
673
+ | `ImportValidationError` | Invalid data during import | Yes | Show validation errors to user |
674
+ | `StorageError` | Storage read/write failures | Maybe | Retry operation or fallback to memory storage |
675
+ | `ConfigurationError` | Invalid achievement config | No | Fix configuration during development |
676
+ | `SyncError` | Multi-device sync failures | Yes | Retry sync or use local data |
677
+
678
+ ### Using the onError Callback
679
+
680
+ Handle errors gracefully by providing an `onError` callback to the `AchievementProvider`:
681
+
682
+ ```tsx
683
+ import { AchievementProvider, AchievementError, StorageQuotaError } from 'react-achievements';
684
+
685
+ const App = () => {
686
+ const handleAchievementError = (error: AchievementError) => {
687
+ // Check error type
688
+ if (error instanceof StorageQuotaError) {
689
+ console.error(`Storage quota exceeded! Need ${error.bytesNeeded} bytes`);
690
+ console.log('Remedy:', error.remedy);
691
+
692
+ // Offer user the option to export and clear data
693
+ if (confirm('Storage full. Export your achievements?')) {
694
+ // Export data before clearing (see Data Export/Import section)
695
+ exportAndClearData();
696
+ }
697
+ }
698
+
699
+ // Use type guards
700
+ if (isRecoverableError(error)) {
701
+ // Show user-friendly error message with remedy
702
+ showNotification({
703
+ type: 'error',
704
+ message: error.message,
705
+ remedy: error.remedy
706
+ });
707
+ } else {
708
+ // Log non-recoverable errors
709
+ console.error('Non-recoverable error:', error);
710
+ }
711
+ };
712
+
713
+ return (
714
+ <AchievementProvider
715
+ achievements={gameAchievements}
716
+ storage="local"
717
+ onError={handleAchievementError}
718
+ >
719
+ <Game />
720
+ </AchievementProvider>
721
+ );
722
+ };
723
+ ```
724
+
725
+ ### Error Properties
726
+
727
+ All achievement errors include helpful properties:
728
+
729
+ ```tsx
730
+ try {
731
+ // Some operation that might fail
732
+ storage.setMetrics(metrics);
733
+ } catch (error) {
734
+ if (isAchievementError(error)) {
735
+ console.log(error.code); // Machine-readable: "STORAGE_QUOTA_EXCEEDED"
736
+ console.log(error.message); // Human-readable: "Browser storage quota exceeded"
737
+ console.log(error.recoverable); // true/false - can this be recovered?
738
+ console.log(error.remedy); // Guidance: "Clear browser storage or..."
739
+
740
+ // Error-specific properties
741
+ if (error instanceof StorageQuotaError) {
742
+ console.log(error.bytesNeeded); // How much space is needed
743
+ }
744
+ }
745
+ }
746
+ ```
747
+
748
+ ### Graceful Degradation
749
+
750
+ If no `onError` callback is provided, errors are automatically logged to the console with full details:
751
+
752
+ ```tsx
753
+ // Without onError callback
754
+ <AchievementProvider achievements={gameAchievements} storage="local">
755
+ <Game />
756
+ </AchievementProvider>
757
+
758
+ // Errors are automatically logged:
759
+ // "Achievement storage error: Browser storage quota exceeded.
760
+ // Remedy: Clear browser storage, reduce the number of achievements..."
761
+ ```
762
+
763
+ ### Type Guards
764
+
765
+ Use type guards for type-safe error handling:
766
+
767
+ ```tsx
768
+ import { isAchievementError, isRecoverableError } from 'react-achievements';
769
+
770
+ try {
771
+ await syncAchievements();
772
+ } catch (error) {
773
+ if (isAchievementError(error)) {
774
+ // TypeScript knows this is an AchievementError
775
+ console.log(error.code, error.remedy);
776
+
777
+ if (isRecoverableError(error)) {
778
+ // Attempt recovery
779
+ retryOperation();
780
+ }
781
+ } else {
782
+ // Handle non-achievement errors
783
+ console.error('Unexpected error:', error);
784
+ }
785
+ }
786
+ ```
787
+
788
+ ## Data Export/Import
789
+
790
+ 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.
791
+
792
+ ### Exporting Achievement Data
793
+
794
+ Export all achievement data including metrics, unlocked achievements, and configuration:
795
+
796
+ ```tsx
797
+ import { useAchievements, exportAchievementData } from 'react-achievements';
798
+
799
+ const MyComponent = () => {
800
+ const { getState } = useAchievements();
801
+
802
+ const exportData = () => {
803
+ const state = getState();
804
+ return exportAchievementData(
805
+ state.metrics,
806
+ state.unlockedAchievements,
807
+ achievements // Your achievement configuration
808
+ );
809
+ };
810
+
811
+ return (
812
+ <>
813
+ <button onClick={handleExportToFile}>Export to File</button>
814
+ <button onClick={handleExportToAWS}>Export to AWS S3</button>
815
+ <button onClick={handleExportToAzure}>Export to Azure</button>
816
+ </>
817
+ );
818
+ };
819
+ ```
820
+
821
+ ### Export to Local File
822
+
823
+ Download achievement data as a JSON file:
824
+
825
+ ```tsx
826
+ const handleExportToFile = () => {
827
+ const exportedData = exportData();
828
+
829
+ const blob = new Blob([JSON.stringify(exportedData)], { type: 'application/json' });
830
+ const url = URL.createObjectURL(blob);
831
+ const link = document.createElement('a');
832
+ link.href = url;
833
+ link.download = `achievements-${Date.now()}.json`;
834
+ link.click();
835
+ URL.revokeObjectURL(url);
836
+ };
837
+ ```
838
+
839
+ ### Export to AWS S3
840
+
841
+ Upload achievement data to Amazon S3 for cloud backup and cross-device sync:
842
+
843
+ ```tsx
844
+ import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
845
+
846
+ const handleExportToAWS = async () => {
847
+ const exportedData = exportData();
848
+
849
+ const s3Client = new S3Client({
850
+ region: 'us-east-1',
851
+ credentials: {
852
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID,
853
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
854
+ },
855
+ });
856
+
857
+ const userId = getCurrentUserId(); // Your user identification logic
858
+ const key = `achievements/${userId}/data.json`;
859
+
860
+ try {
861
+ await s3Client.send(new PutObjectCommand({
862
+ Bucket: 'my-app-achievements',
863
+ Key: key,
864
+ Body: JSON.stringify(exportedData),
865
+ ContentType: 'application/json',
866
+ Metadata: {
867
+ version: exportedData.version,
868
+ timestamp: exportedData.timestamp,
869
+ },
870
+ }));
871
+
872
+ console.log('Achievements backed up to S3 successfully!');
873
+ } catch (error) {
874
+ console.error('Failed to upload to S3:', error);
875
+ }
876
+ };
877
+ ```
878
+
879
+ ### Import from AWS S3
880
+
881
+ ```tsx
882
+ const MyComponent = () => {
883
+ const { update } = useAchievements(); // Get update from hook
884
+
885
+ const handleImportFromAWS = async () => {
886
+ const s3Client = new S3Client({ /* config */ });
887
+ const userId = getCurrentUserId();
888
+
889
+ try {
890
+ const response = await s3Client.send(new GetObjectCommand({
891
+ Bucket: 'my-app-achievements',
892
+ Key: `achievements/${userId}/data.json`,
893
+ }));
894
+
895
+ const data = JSON.parse(await response.Body.transformToString());
896
+
897
+ const result = importAchievementData(data, {
898
+ strategy: 'merge',
899
+ achievements: gameAchievements
900
+ });
901
+
902
+ if (result.success) {
903
+ update(result.mergedMetrics);
904
+ console.log('Achievements restored from S3!');
905
+ }
906
+ } catch (error) {
907
+ console.error('Failed to import from S3:', error);
908
+ }
909
+ };
910
+
911
+ return <button onClick={handleImportFromAWS}>Restore from AWS</button>;
912
+ };
913
+ ```
914
+
915
+ ### Export to Microsoft Azure Blob Storage
916
+
917
+ Upload achievement data to Azure for enterprise cloud backup:
918
+
919
+ ```tsx
920
+ import { BlobServiceClient } from '@azure/storage-blob';
921
+
922
+ const handleExportToAzure = async () => {
923
+ const exportedData = exportData();
924
+
925
+ const blobServiceClient = BlobServiceClient.fromConnectionString(
926
+ process.env.AZURE_STORAGE_CONNECTION_STRING
927
+ );
928
+
929
+ const containerClient = blobServiceClient.getContainerClient('achievements');
930
+ const userId = getCurrentUserId();
931
+ const blobName = `${userId}/achievements-${Date.now()}.json`;
932
+ const blockBlobClient = containerClient.getBlockBlobClient(blobName);
933
+
934
+ try {
935
+ await blockBlobClient.upload(
936
+ JSON.stringify(exportedData),
937
+ JSON.stringify(exportedData).length,
938
+ {
939
+ blobHTTPHeaders: {
940
+ blobContentType: 'application/json',
941
+ },
942
+ metadata: {
943
+ version: exportedData.version,
944
+ timestamp: exportedData.timestamp,
945
+ configHash: exportedData.configHash,
946
+ },
947
+ }
948
+ );
949
+
950
+ console.log('Achievements backed up to Azure successfully!');
951
+ } catch (error) {
952
+ console.error('Failed to upload to Azure:', error);
953
+ }
954
+ };
955
+ ```
956
+
957
+
958
+
959
+ ### Import from Azure Blob Storage
960
+
961
+ ```tsx
962
+ const MyComponent = () => {
963
+ const { update } = useAchievements(); // Get update from hook
964
+
965
+ const handleImportFromAzure = async () => {
966
+ const blobServiceClient = BlobServiceClient.fromConnectionString(
967
+ process.env.AZURE_STORAGE_CONNECTION_STRING
968
+ );
969
+
970
+ const containerClient = blobServiceClient.getContainerClient('achievements');
971
+ const userId = getCurrentUserId();
972
+
973
+ try {
974
+ // List blobs to find the latest backup
975
+ const blobs = containerClient.listBlobsFlat({ prefix: `${userId}/` });
976
+ let latestBlob = null;
977
+
978
+ for await (const blob of blobs) {
979
+ if (!latestBlob || blob.properties.createdOn > latestBlob.properties.createdOn) {
980
+ latestBlob = blob;
981
+ }
982
+ }
983
+
984
+ if (latestBlob) {
985
+ const blockBlobClient = containerClient.getBlockBlobClient(latestBlob.name);
986
+ const downloadResponse = await blockBlobClient.download(0);
987
+ const data = JSON.parse(await streamToString(downloadResponse.readableStreamBody));
988
+
989
+ const result = importAchievementData(data, {
990
+ strategy: 'merge',
991
+ achievements: gameAchievements
992
+ });
993
+
994
+ if (result.success) {
995
+ update(result.mergedMetrics);
996
+ console.log('Achievements restored from Azure!');
997
+ }
998
+ }
999
+ } catch (error) {
1000
+ console.error('Failed to import from Azure:', error);
1001
+ }
1002
+ };
1003
+
1004
+ return <button onClick={handleImportFromAzure}>Restore from Azure</button>;
1005
+ };
1006
+
1007
+ // Helper function to convert stream to string
1008
+ async function streamToString(readableStream) {
1009
+ return new Promise((resolve, reject) => {
1010
+ const chunks = [];
1011
+ readableStream.on('data', (data) => chunks.push(data.toString()));
1012
+ readableStream.on('end', () => resolve(chunks.join('')));
1013
+ readableStream.on('error', reject);
1014
+ });
1015
+ }
1016
+ ```
1017
+
1018
+ ### Cloud Storage Best Practices
1019
+
1020
+ When using cloud storage for achievements:
1021
+
1022
+ **Security**:
1023
+ ```tsx
1024
+ // Never expose credentials in client-side code
1025
+ // Use environment variables or secure credential management
1026
+ const credentials = {
1027
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID, // Server-side only
1028
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, // Server-side only
1029
+ };
1030
+
1031
+ // For client-side apps, use temporary credentials via STS or Cognito
1032
+ import { CognitoIdentityClient } from '@aws-sdk/client-cognito-identity';
1033
+ import { fromCognitoIdentityPool } from '@aws-sdk/credential-provider-cognito-identity';
1034
+
1035
+ const s3Client = new S3Client({
1036
+ region: 'us-east-1',
1037
+ credentials: fromCognitoIdentityPool({
1038
+ client: new CognitoIdentityClient({ region: 'us-east-1' }),
1039
+ identityPoolId: 'us-east-1:xxxxx-xxxx-xxxx',
1040
+ }),
1041
+ });
1042
+ ```
1043
+
1044
+ **File Naming**:
1045
+ ```tsx
1046
+ // Use consistent naming for easy retrieval
1047
+ const generateKey = (userId: string) => {
1048
+ const timestamp = new Date().toISOString();
1049
+ return `achievements/${userId}/${timestamp}.json`;
1050
+ };
1051
+
1052
+ // Or use latest.json for current data + timestamped backups
1053
+ const keys = {
1054
+ current: `achievements/${userId}/latest.json`,
1055
+ backup: `achievements/${userId}/backups/${Date.now()}.json`
1056
+ };
1057
+ ```
1058
+
1059
+ **Error Handling**:
1060
+ ```tsx
1061
+ const uploadWithRetry = async (data: ExportedData, maxRetries = 3) => {
1062
+ for (let i = 0; i < maxRetries; i++) {
1063
+ try {
1064
+ await uploadToCloud(data);
1065
+ return { success: true };
1066
+ } catch (error) {
1067
+ if (i === maxRetries - 1) throw error;
1068
+ await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i)));
1069
+ }
1070
+ }
1071
+ };
1072
+ ```
1073
+
1074
+ ### Importing Achievement Data
1075
+
1076
+ Import previously exported data with validation and merge strategies:
1077
+
1078
+ ```tsx
1079
+ import { useAchievements, importAchievementData } from 'react-achievements';
1080
+
1081
+ const MyComponent = () => {
1082
+ const { update } = useAchievements();
1083
+
1084
+ const handleImport = async (file: File) => {
1085
+ try {
1086
+ const text = await file.text();
1087
+ const importedData = JSON.parse(text);
1088
+
1089
+ const result = importAchievementData(importedData, {
1090
+ strategy: 'merge', // 'replace', 'merge', or 'preserve'
1091
+ achievements: gameAchievements
1092
+ });
1093
+
1094
+ if (result.success) {
1095
+ // Apply merged data
1096
+ update(result.mergedMetrics);
1097
+ console.log(`Imported ${result.importedCount} achievements`);
1098
+ } else {
1099
+ // Handle validation errors
1100
+ console.error('Import failed:', result.errors);
1101
+ }
1102
+ } catch (error) {
1103
+ if (error instanceof ImportValidationError) {
1104
+ console.error('Invalid import file:', error.remedy);
1105
+ }
1106
+ }
1107
+ };
1108
+
1109
+ return (
1110
+ <input
1111
+ type="file"
1112
+ accept=".json"
1113
+ onChange={(e) => e.target.files?.[0] && handleImport(e.target.files[0])}
1114
+ />
1115
+ );
1116
+ };
1117
+ ```
1118
+
1119
+ ### Merge Strategies
1120
+
1121
+ Control how imported data is merged with existing data:
1122
+
1123
+ ```tsx
1124
+ // Replace: Completely replace all existing data
1125
+ const result = importAchievementData(data, {
1126
+ strategy: 'replace',
1127
+ achievements
1128
+ });
1129
+ ```
1130
+
1131
+ ```tsx
1132
+ // Merge: Combine imported and existing data
1133
+ // - Takes maximum values for metrics
1134
+ // - Combines unlocked achievements
1135
+ const result = importAchievementData(data, {
1136
+ strategy: 'merge',
1137
+ achievements
1138
+ });
1139
+ ```
1140
+
1141
+ ```tsx
1142
+
1143
+ // Preserve: Only import new achievements, keep existing data
1144
+ const result = importAchievementData(data, {
1145
+ strategy: 'preserve',
1146
+ achievements
1147
+ });
1148
+ ```
1149
+
1150
+ ### Export Data Structure
1151
+
1152
+ The exported data includes:
1153
+
1154
+ ```
1155
+ {
1156
+ version: "1.0", // Export format version
1157
+ timestamp: "2024-12-10T...", // When data was exported
1158
+ configHash: "abc123...", // Hash of achievement config
1159
+ metrics: { // All tracked metrics
1160
+ score: 1000,
1161
+ level: 5
1162
+ },
1163
+ unlockedAchievements: [ // All unlocked achievement IDs
1164
+ "score_100",
1165
+ "level_5"
1166
+ ]
1167
+ }
1168
+ ```
1169
+
1170
+ ### Configuration Validation
1171
+
1172
+ Import validation ensures data compatibility:
1173
+
1174
+ ```tsx
1175
+ try {
1176
+ const result = importAchievementData(importedData, {
1177
+ strategy: 'replace',
1178
+ achievements
1179
+ });
1180
+
1181
+ if (!result.success) {
1182
+ // Check for configuration mismatch
1183
+ if (result.configMismatch) {
1184
+ console.warn('Achievement configuration has changed since export');
1185
+ console.log('You can still import with strategy: merge or preserve');
1186
+ }
1187
+
1188
+ // Check for validation errors
1189
+ console.error('Validation errors:', result.errors);
1190
+ }
1191
+ } catch (error) {
1192
+ if (error instanceof ImportValidationError) {
1193
+ console.error('Import failed:', error.message, error.remedy);
1194
+ }
1195
+ }
1196
+ ```
1197
+
1198
+ ### Use Cases
1199
+
1200
+ **Backup Before Clearing Storage**:
1201
+ ```tsx
1202
+ const MyComponent = () => {
1203
+ const { getState, reset } = useAchievements();
1204
+
1205
+ // Storage quota exceeded - export before clearing
1206
+ const handleStorageQuotaError = (error: StorageQuotaError) => {
1207
+ const state = getState();
1208
+ const backup = exportAchievementData(state.metrics, state.unlockedAchievements, achievements);
1209
+
1210
+ // Save backup
1211
+ localStorage.setItem('achievement-backup', JSON.stringify(backup));
1212
+
1213
+ // Clear storage
1214
+ reset();
1215
+
1216
+ alert('Data backed up and storage cleared!');
1217
+ };
1218
+
1219
+ return <button onClick={() => handleStorageQuotaError(new StorageQuotaError(1000))}>Test Backup</button>;
1220
+ };
1221
+ ```
1222
+
1223
+ **Cross-Device Transfer**:
1224
+ ```tsx
1225
+ const MyComponent = () => {
1226
+ const { getState, update } = useAchievements();
1227
+
1228
+ // Device 1: Export data
1229
+ const exportData = () => {
1230
+ const state = getState();
1231
+ const data = exportAchievementData(state.metrics, state.unlockedAchievements, achievements);
1232
+ // Upload to cloud or save to file
1233
+ return data;
1234
+ };
1235
+
1236
+ // Device 2: Import data
1237
+ const importData = async (cloudData) => {
1238
+ const result = importAchievementData(cloudData, {
1239
+ strategy: 'merge', // Combine with any local progress
1240
+ achievements
1241
+ });
1242
+
1243
+ if (result.success) {
1244
+ update(result.mergedMetrics);
1245
+ }
1246
+ };
1247
+
1248
+ return (
1249
+ <>
1250
+ <button onClick={() => exportData()}>Export for Transfer</button>
1251
+ <button onClick={() => importData(/* cloudData */)}>Import from Other Device</button>
1252
+ </>
1253
+ );
1254
+ };
1255
+ ```
1256
+
467
1257
  ## Styling
468
1258
 
469
1259
  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 +1285,7 @@ The achievement components use default styling that works well out of the box. F
495
1285
  | storage | 'local' \| 'memory' \| AchievementStorage | Storage implementation |
496
1286
  | theme | ThemeConfig | Custom theme configuration |
497
1287
  | onUnlock | (achievement: Achievement) => void | Callback when achievement is unlocked |
1288
+ | onError | (error: AchievementError) => void | **NEW in v3.3.0**: Callback when errors occur |
498
1289
 
499
1290
  ### useAchievements Hook
500
1291
 
@@ -574,6 +1365,88 @@ const Game = () => {
574
1365
 
575
1366
  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
1367
 
1368
+ ## Contributing
1369
+
1370
+ We welcome contributions to React Achievements! This project includes quality controls to ensure code reliability.
1371
+
1372
+ ### Git Hooks
1373
+
1374
+ The project uses pre-commit hooks to maintain code quality. After cloning the repository, install the hooks:
1375
+
1376
+ ```bash
1377
+ npm run install-hooks
1378
+ ```
1379
+
1380
+ This will install a pre-commit hook that automatically:
1381
+ - Runs TypeScript type checking
1382
+ - Runs the full test suite (154 tests)
1383
+ - Blocks commits if checks fail
1384
+
1385
+ ### What the Hook Does
1386
+
1387
+ When you run `git commit`, the hook will:
1388
+ 1. Run type checking (~2-5 seconds)
1389
+ 2. Run all tests (~2-3 seconds)
1390
+ 3. Block the commit if either fails
1391
+ 4. Allow the commit if all checks pass
1392
+
1393
+ ### Bypassing the Hook
1394
+
1395
+ Not recommended, but if needed:
1396
+
1397
+ ```bash
1398
+ git commit --no-verify
1399
+ ```
1400
+
1401
+ Only use this when:
1402
+ - Committing work-in-progress intentionally
1403
+ - Reverting a commit that broke tests
1404
+ - You have a valid reason to skip checks
1405
+
1406
+ Never bypass for:
1407
+ - Failing tests (fix them first!)
1408
+ - TypeScript errors (fix them first!)
1409
+
1410
+ ### Running Tests Manually
1411
+
1412
+ Before committing, you can run tests manually:
1413
+
1414
+ ```bash
1415
+ # Run type checking
1416
+ npm run type-check
1417
+
1418
+ # Run tests
1419
+ npm run test:unit
1420
+
1421
+ # Run both (same as git hook)
1422
+ npm test
1423
+ ```
1424
+
1425
+ ### Test Coverage
1426
+
1427
+ The library has comprehensive test coverage:
1428
+ - 154 total tests
1429
+ - Unit tests for all core functionality
1430
+ - Integration tests for React components
1431
+ - Error handling tests (43 tests)
1432
+ - Data export/import tests
1433
+
1434
+ ### Troubleshooting
1435
+
1436
+ If the hook isn't running:
1437
+
1438
+ 1. Check if it's installed:
1439
+ ```bash
1440
+ ls -la .git/hooks/pre-commit
1441
+ ```
1442
+
1443
+ 2. Reinstall if needed:
1444
+ ```bash
1445
+ npm run install-hooks
1446
+ ```
1447
+
1448
+ For more details, see [`docs/git-hooks.md`](./docs/git-hooks.md).
1449
+
577
1450
  ## License
578
1451
 
579
1452
  MIT