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 +874 -1
- package/dist/index.d.ts +308 -4
- package/dist/index.js +1048 -19
- package/dist/index.js.map +1 -1
- package/dist/types/core/errors/AchievementErrors.d.ts +55 -0
- package/dist/types/core/storage/AsyncStorageAdapter.d.ts +48 -0
- package/dist/types/core/storage/IndexedDBStorage.d.ts +29 -0
- package/dist/types/core/storage/OfflineQueueStorage.d.ts +42 -0
- package/dist/types/core/storage/RestApiStorage.d.ts +20 -0
- package/dist/types/core/types.d.ts +13 -2
- package/dist/types/core/utils/dataExport.d.ts +34 -0
- package/dist/types/core/utils/dataImport.d.ts +50 -0
- package/dist/types/hooks/useSimpleAchievements.d.ts +12 -0
- package/dist/types/index.d.ts +9 -1
- package/dist/types/providers/AchievementProvider.d.ts +9 -2
- package/package.json +3 -1
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
|