react-achievements 3.2.0 → 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 +720 -7
- package/dist/index.d.ts +158 -1
- package/dist/index.js +385 -11
- package/dist/index.js.map +1 -1
- package/dist/types/core/errors/AchievementErrors.d.ts +51 -0
- 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 +18 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/providers/AchievementProvider.d.ts +5 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -30,16 +30,28 @@ import {
|
|
|
30
30
|
// Define achievements with the new three-tier Builder API - 95% less code!
|
|
31
31
|
import { AchievementBuilder } from 'react-achievements';
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
// Simple achievements with the new Simple API
|
|
34
|
+
const gameAchievements = {
|
|
35
|
+
score: {
|
|
36
|
+
100: { title: 'Century!', description: 'Score 100 points', icon: '🏆' },
|
|
37
|
+
500: { title: 'High Scorer!', description: 'Score 500 points', icon: '⭐' }
|
|
38
|
+
},
|
|
39
|
+
level: {
|
|
40
|
+
5: { title: 'Leveling Up', description: 'Reach level 5', icon: '📈' }
|
|
41
|
+
},
|
|
42
|
+
completedTutorial: {
|
|
43
|
+
true: { title: 'Tutorial Master', description: 'Complete the tutorial', icon: '📚' }
|
|
44
|
+
},
|
|
45
|
+
buttonClicks: {
|
|
46
|
+
10: { title: 'Clicker', description: 'Click 10 times', icon: '👆' },
|
|
47
|
+
100: { title: 'Super Clicker', description: 'Click 100 times', icon: '🖱️' }
|
|
48
|
+
}
|
|
49
|
+
};
|
|
38
50
|
|
|
39
51
|
// Demo component with all essential features
|
|
40
52
|
const DemoComponent = () => {
|
|
41
53
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
42
|
-
const { track, unlocked, unlockedCount, reset } = useSimpleAchievements();
|
|
54
|
+
const { track, increment, unlocked, unlockedCount, reset } = useSimpleAchievements();
|
|
43
55
|
|
|
44
56
|
return (
|
|
45
57
|
<div>
|
|
@@ -59,6 +71,14 @@ const DemoComponent = () => {
|
|
|
59
71
|
Complete tutorial
|
|
60
72
|
</button>
|
|
61
73
|
|
|
74
|
+
{/* Increment tracking - perfect for button clicks */}
|
|
75
|
+
<button onClick={() => increment('buttonClicks')}>
|
|
76
|
+
Click Me! (increments by 1)
|
|
77
|
+
</button>
|
|
78
|
+
<button onClick={() => increment('score', 10)}>
|
|
79
|
+
Bonus Points! (+10)
|
|
80
|
+
</button>
|
|
81
|
+
|
|
62
82
|
{/* Reset button */}
|
|
63
83
|
<button onClick={reset}>
|
|
64
84
|
Reset Achievements
|
|
@@ -141,13 +161,18 @@ const achievements = {
|
|
|
141
161
|
}
|
|
142
162
|
};
|
|
143
163
|
|
|
144
|
-
const { track, unlocked, unlockedCount, reset } = useSimpleAchievements();
|
|
164
|
+
const { track, increment, unlocked, unlockedCount, reset } = useSimpleAchievements();
|
|
145
165
|
|
|
146
166
|
// Track achievements easily
|
|
147
167
|
track('score', 100); // Unlocks "Century!" achievement
|
|
148
168
|
track('completedTutorial', true); // Unlocks "Tutorial Master"
|
|
149
169
|
track('characterClass', 'wizard'); // Unlocks "Arcane Scholar"
|
|
150
170
|
|
|
171
|
+
// Increment values - perfect for button clicks, actions, etc.
|
|
172
|
+
increment('buttonClicks'); // Adds 1 each time (great for button clicks)
|
|
173
|
+
increment('score', 50); // Adds 50 each time (custom amount)
|
|
174
|
+
increment('lives', -1); // Subtract 1 (negative increment)
|
|
175
|
+
|
|
151
176
|
// Track multiple metrics for custom conditions
|
|
152
177
|
track('score', 1000);
|
|
153
178
|
track('accuracy', 100); // Unlocks "Perfect Combo" if both conditions met
|
|
@@ -297,6 +322,9 @@ See the [examples directory](./stories/examples) for detailed implementations an
|
|
|
297
322
|
- Toast notifications
|
|
298
323
|
- Confetti animations
|
|
299
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
|
|
300
328
|
|
|
301
329
|
## Achievement Notifications & History
|
|
302
330
|
|
|
@@ -439,6 +467,608 @@ const App = () => {
|
|
|
439
467
|
export default App;
|
|
440
468
|
```
|
|
441
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
|
+
|
|
442
1072
|
## Styling
|
|
443
1073
|
|
|
444
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:
|
|
@@ -470,6 +1100,7 @@ The achievement components use default styling that works well out of the box. F
|
|
|
470
1100
|
| storage | 'local' \| 'memory' \| AchievementStorage | Storage implementation |
|
|
471
1101
|
| theme | ThemeConfig | Custom theme configuration |
|
|
472
1102
|
| onUnlock | (achievement: Achievement) => void | Callback when achievement is unlocked |
|
|
1103
|
+
| onError | (error: AchievementError) => void | **NEW in v3.3.0**: Callback when errors occur |
|
|
473
1104
|
|
|
474
1105
|
### useAchievements Hook
|
|
475
1106
|
|
|
@@ -549,6 +1180,88 @@ const Game = () => {
|
|
|
549
1180
|
|
|
550
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.
|
|
551
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
|
+
|
|
552
1265
|
## License
|
|
553
1266
|
|
|
554
1267
|
MIT
|