@umituz/react-native-settings 4.23.32 → 4.23.34
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/package.json +3 -1
- package/src/domains/rating/application/services/RatingService.ts +185 -0
- package/src/domains/rating/domain/entities/RatingConfig.ts +158 -0
- package/src/domains/rating/index.ts +29 -3
- package/src/domains/rating/infrastructure/storage/RatingStorage.ts +182 -0
- package/src/domains/rating/presentation/components/RatingPromptModal.tsx +156 -0
- package/src/domains/rating/presentation/hooks/useAppRating.tsx +128 -0
- package/src/index.ts +16 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-settings",
|
|
3
|
-
"version": "4.23.
|
|
3
|
+
"version": "4.23.34",
|
|
4
4
|
"description": "Complete settings hub for React Native apps - consolidated package with settings, about, legal, appearance, feedback, FAQs, rating, and gamification",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"expo-notifications": ">=0.28.0",
|
|
47
47
|
"expo-device": ">=6.0.0",
|
|
48
48
|
"expo-haptics": ">=15.0.0",
|
|
49
|
+
"expo-store-review": ">=14.0.0",
|
|
49
50
|
"@react-native-community/datetimepicker": ">=8.0.0",
|
|
50
51
|
"react": ">=19.0.0",
|
|
51
52
|
"react-native": ">=0.81.0",
|
|
@@ -91,6 +92,7 @@
|
|
|
91
92
|
"expo-sharing": "^14.0.8",
|
|
92
93
|
"expo-notifications": "~0.27.6",
|
|
93
94
|
"expo-secure-store": "^15.0.8",
|
|
95
|
+
"expo-store-review": "~14.0.0",
|
|
94
96
|
"expo-video": "^3.0.15",
|
|
95
97
|
"expo-web-browser": "^12.0.0",
|
|
96
98
|
"firebase": "^12.7.0",
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rating Service
|
|
3
|
+
* Core business logic for app rating system
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { RatingConfig, RatingState } from "../../domain/entities/RatingConfig";
|
|
7
|
+
import {
|
|
8
|
+
getEventCount,
|
|
9
|
+
incrementEventCount,
|
|
10
|
+
getLastPromptDate,
|
|
11
|
+
setLastPromptDate,
|
|
12
|
+
getHasRated,
|
|
13
|
+
setHasRated,
|
|
14
|
+
getDismissed,
|
|
15
|
+
setDismissed,
|
|
16
|
+
getRatingState,
|
|
17
|
+
reset as resetStorage,
|
|
18
|
+
} from "../../infrastructure/storage/RatingStorage";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Calculate days between two dates
|
|
22
|
+
*/
|
|
23
|
+
function daysBetween(dateString: string, now: Date): number {
|
|
24
|
+
const date = new Date(dateString);
|
|
25
|
+
const diffMs = now.getTime() - date.getTime();
|
|
26
|
+
return Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Track an event occurrence
|
|
31
|
+
*/
|
|
32
|
+
export async function trackEvent(eventType: string): Promise<void> {
|
|
33
|
+
try {
|
|
34
|
+
await incrementEventCount(eventType);
|
|
35
|
+
|
|
36
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
37
|
+
const count = await getEventCount(eventType);
|
|
38
|
+
console.log(`[RatingService] Event tracked: ${eventType}, count: ${count}`);
|
|
39
|
+
}
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
42
|
+
console.error("[RatingService] Error tracking event:", error);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Check if prompt should be shown based on criteria
|
|
49
|
+
*/
|
|
50
|
+
export async function shouldShowPrompt(config: RatingConfig): Promise<boolean> {
|
|
51
|
+
try {
|
|
52
|
+
const hasRated = await getHasRated();
|
|
53
|
+
if (hasRated) {
|
|
54
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
55
|
+
console.log("[RatingService] User has already rated, skipping prompt");
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const dismissed = await getDismissed();
|
|
61
|
+
if (dismissed) {
|
|
62
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
63
|
+
console.log("[RatingService] User permanently dismissed prompt");
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const eventCount = await getEventCount(config.eventType);
|
|
69
|
+
const minCount = config.minEventCount ?? 3;
|
|
70
|
+
|
|
71
|
+
if (eventCount < minCount) {
|
|
72
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
73
|
+
console.log(
|
|
74
|
+
`[RatingService] Event count ${eventCount} < ${minCount}, not showing prompt`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const lastPromptDate = await getLastPromptDate(config.eventType);
|
|
81
|
+
|
|
82
|
+
if (lastPromptDate) {
|
|
83
|
+
const cooldownDays = config.cooldownDays ?? 90;
|
|
84
|
+
const daysSinceLastPrompt = daysBetween(lastPromptDate, new Date());
|
|
85
|
+
|
|
86
|
+
if (daysSinceLastPrompt < cooldownDays) {
|
|
87
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
88
|
+
console.log(
|
|
89
|
+
`[RatingService] Cooldown period active: ${daysSinceLastPrompt}/${cooldownDays} days`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
97
|
+
console.log("[RatingService] All criteria met, prompt should be shown");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return true;
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
103
|
+
console.error("[RatingService] Error checking criteria:", error);
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Mark that prompt was shown to user
|
|
111
|
+
*/
|
|
112
|
+
export async function markPromptShown(eventType: string): Promise<void> {
|
|
113
|
+
try {
|
|
114
|
+
const now = new Date().toISOString();
|
|
115
|
+
await setLastPromptDate(eventType, now);
|
|
116
|
+
|
|
117
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
118
|
+
console.log(`[RatingService] Prompt shown marked for: ${eventType}`);
|
|
119
|
+
}
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
122
|
+
console.error("[RatingService] Error marking prompt shown:", error);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Mark that user has rated the app
|
|
129
|
+
*/
|
|
130
|
+
export async function markRated(): Promise<void> {
|
|
131
|
+
try {
|
|
132
|
+
await setHasRated(true);
|
|
133
|
+
|
|
134
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
135
|
+
console.log("[RatingService] User marked as rated");
|
|
136
|
+
}
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
139
|
+
console.error("[RatingService] Error marking rated:", error);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Mark that user permanently dismissed the prompt
|
|
146
|
+
*/
|
|
147
|
+
export async function markDismissed(): Promise<void> {
|
|
148
|
+
try {
|
|
149
|
+
await setDismissed(true);
|
|
150
|
+
|
|
151
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
152
|
+
console.log("[RatingService] User marked as dismissed");
|
|
153
|
+
}
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
156
|
+
console.error("[RatingService] Error marking dismissed:", error);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Get current rating state for event type
|
|
163
|
+
*/
|
|
164
|
+
export async function getState(eventType: string): Promise<RatingState> {
|
|
165
|
+
return getRatingState(eventType);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Reset rating data (for testing or specific event type)
|
|
170
|
+
*/
|
|
171
|
+
export async function reset(eventType?: string): Promise<void> {
|
|
172
|
+
try {
|
|
173
|
+
await resetStorage(eventType);
|
|
174
|
+
|
|
175
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
176
|
+
console.log(
|
|
177
|
+
`[RatingService] Reset ${eventType ? `event: ${eventType}` : "all rating data"}`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
182
|
+
console.error("[RatingService] Error resetting:", error);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rating Configuration
|
|
3
|
+
* Types and interfaces for app rating system
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type React from "react";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Rating prompt configuration
|
|
10
|
+
*/
|
|
11
|
+
export interface RatingConfig {
|
|
12
|
+
/**
|
|
13
|
+
* Type of event to track (e.g., "ai_generation", "onboarding_completed")
|
|
14
|
+
*/
|
|
15
|
+
eventType: string;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Minimum number of events before showing prompt
|
|
19
|
+
* @default 3
|
|
20
|
+
*/
|
|
21
|
+
minEventCount?: number;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Cooldown period in days before showing prompt again
|
|
25
|
+
* @default 90
|
|
26
|
+
*/
|
|
27
|
+
cooldownDays?: number;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* App name to display in prompt
|
|
31
|
+
*/
|
|
32
|
+
appName?: string;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Custom translations for the prompt
|
|
36
|
+
*/
|
|
37
|
+
translations?: RatingTranslations;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Callback when user gives positive feedback
|
|
41
|
+
*/
|
|
42
|
+
onPositiveFeedback?: () => void | Promise<void>;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Callback when user gives negative feedback
|
|
46
|
+
*/
|
|
47
|
+
onNegativeFeedback?: () => void | Promise<void>;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Callback after prompt is shown
|
|
51
|
+
*/
|
|
52
|
+
onPromptShown?: () => void | Promise<void>;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Callback when prompt is dismissed
|
|
56
|
+
*/
|
|
57
|
+
onPromptDismissed?: () => void | Promise<void>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Custom translations for rating prompt
|
|
62
|
+
*/
|
|
63
|
+
export interface RatingTranslations {
|
|
64
|
+
title: string;
|
|
65
|
+
message: string;
|
|
66
|
+
positiveButton: string;
|
|
67
|
+
negativeButton: string;
|
|
68
|
+
laterButton?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Rating state stored in AsyncStorage
|
|
73
|
+
*/
|
|
74
|
+
export interface RatingState {
|
|
75
|
+
/**
|
|
76
|
+
* Event count for specific event type
|
|
77
|
+
*/
|
|
78
|
+
eventCount: number;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Last time prompt was shown (ISO date string)
|
|
82
|
+
*/
|
|
83
|
+
lastPromptDate: string | null;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Whether user has rated the app
|
|
87
|
+
*/
|
|
88
|
+
hasRated: boolean;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Whether user permanently dismissed the prompt
|
|
92
|
+
*/
|
|
93
|
+
dismissed: boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Result of useAppRating hook
|
|
98
|
+
*/
|
|
99
|
+
export interface UseAppRatingResult {
|
|
100
|
+
/**
|
|
101
|
+
* Track an event (e.g., generation completed)
|
|
102
|
+
*/
|
|
103
|
+
trackEvent: () => Promise<void>;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Check if should show prompt and show it automatically
|
|
107
|
+
*/
|
|
108
|
+
checkAndShow: () => Promise<boolean>;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Manually check if criteria is met (without showing)
|
|
112
|
+
*/
|
|
113
|
+
shouldShow: () => Promise<boolean>;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Manually show the rating prompt
|
|
117
|
+
*/
|
|
118
|
+
showPrompt: () => Promise<void>;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Reset all rating data (for testing)
|
|
122
|
+
*/
|
|
123
|
+
reset: () => Promise<void>;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Get current rating state
|
|
127
|
+
*/
|
|
128
|
+
getState: () => Promise<RatingState>;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Whether prompt is currently visible
|
|
132
|
+
*/
|
|
133
|
+
isVisible: boolean;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Modal component to render in your screen
|
|
137
|
+
*/
|
|
138
|
+
modal: React.ReactNode;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Default rating configuration
|
|
143
|
+
*/
|
|
144
|
+
export const DEFAULT_RATING_CONFIG: Required<
|
|
145
|
+
Omit<RatingConfig, "onPositiveFeedback" | "onNegativeFeedback" | "onPromptShown" | "onPromptDismissed">
|
|
146
|
+
> = {
|
|
147
|
+
eventType: "app_usage",
|
|
148
|
+
minEventCount: 3,
|
|
149
|
+
cooldownDays: 90,
|
|
150
|
+
appName: "this app",
|
|
151
|
+
translations: {
|
|
152
|
+
title: "Enjoying the app?",
|
|
153
|
+
message: "If you love using our app, would you mind taking a moment to rate it?",
|
|
154
|
+
positiveButton: "Yes, I love it!",
|
|
155
|
+
negativeButton: "Not really",
|
|
156
|
+
laterButton: "Maybe later",
|
|
157
|
+
},
|
|
158
|
+
};
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Rating Domain
|
|
3
|
-
* Star ratings, user reviews, and
|
|
3
|
+
* Star ratings, user reviews, statistics, and app store rating prompts
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
// =============================================================================
|
|
7
|
-
// DOMAIN LAYER - Entities
|
|
7
|
+
// DOMAIN LAYER - Entities (Star Ratings)
|
|
8
8
|
// =============================================================================
|
|
9
9
|
|
|
10
10
|
export type {
|
|
@@ -14,10 +14,36 @@ export type {
|
|
|
14
14
|
} from './domain/entities/Rating';
|
|
15
15
|
|
|
16
16
|
// =============================================================================
|
|
17
|
-
//
|
|
17
|
+
// DOMAIN LAYER - Entities (App Store Rating)
|
|
18
|
+
// =============================================================================
|
|
19
|
+
|
|
20
|
+
export type {
|
|
21
|
+
RatingConfig,
|
|
22
|
+
RatingState,
|
|
23
|
+
RatingTranslations,
|
|
24
|
+
UseAppRatingResult,
|
|
25
|
+
} from './domain/entities/RatingConfig';
|
|
26
|
+
|
|
27
|
+
export { DEFAULT_RATING_CONFIG } from './domain/entities/RatingConfig';
|
|
28
|
+
|
|
29
|
+
// =============================================================================
|
|
30
|
+
// PRESENTATION LAYER - Components (Star Ratings)
|
|
18
31
|
// =============================================================================
|
|
19
32
|
|
|
20
33
|
export { StarRating } from './presentation/components/StarRating';
|
|
21
34
|
export type { StarRatingProps } from './presentation/components/StarRating';
|
|
22
35
|
|
|
36
|
+
// =============================================================================
|
|
37
|
+
// PRESENTATION LAYER - Components (App Store Rating)
|
|
38
|
+
// =============================================================================
|
|
39
|
+
|
|
40
|
+
export { RatingPromptModal } from './presentation/components/RatingPromptModal';
|
|
41
|
+
export type { RatingPromptModalProps } from './presentation/components/RatingPromptModal';
|
|
42
|
+
|
|
43
|
+
// =============================================================================
|
|
44
|
+
// PRESENTATION LAYER - Hooks (App Store Rating)
|
|
45
|
+
// =============================================================================
|
|
46
|
+
|
|
47
|
+
export { useAppRating } from './presentation/hooks/useAppRating';
|
|
48
|
+
|
|
23
49
|
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rating Storage Repository
|
|
3
|
+
* Storage layer for rating system using design system storageRepository
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { storageRepository, unwrap } from "@umituz/react-native-design-system";
|
|
7
|
+
import type { RatingState } from "../../domain/entities/RatingConfig";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Storage key generator
|
|
11
|
+
*/
|
|
12
|
+
const KEYS = {
|
|
13
|
+
eventCount: (eventType: string) => `rating.${eventType}.count`,
|
|
14
|
+
lastPrompt: (eventType: string) => `rating.${eventType}.lastPrompt`,
|
|
15
|
+
hasRated: "rating.hasRated",
|
|
16
|
+
dismissed: "rating.dismissed",
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Get event count for specific event type
|
|
21
|
+
*/
|
|
22
|
+
export async function getEventCount(eventType: string): Promise<number> {
|
|
23
|
+
try {
|
|
24
|
+
const result = await storageRepository.getString(KEYS.eventCount(eventType), "0");
|
|
25
|
+
const count = parseInt(unwrap(result, "0"), 10);
|
|
26
|
+
return Number.isNaN(count) ? 0 : count;
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
29
|
+
console.error("[RatingStorage] Error getting event count:", error);
|
|
30
|
+
}
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Set event count for specific event type
|
|
37
|
+
*/
|
|
38
|
+
export async function setEventCount(eventType: string, count: number): Promise<void> {
|
|
39
|
+
try {
|
|
40
|
+
await storageRepository.setString(KEYS.eventCount(eventType), count.toString());
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
43
|
+
console.error("[RatingStorage] Error setting event count:", error);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Increment event count for specific event type
|
|
50
|
+
*/
|
|
51
|
+
export async function incrementEventCount(eventType: string): Promise<void> {
|
|
52
|
+
const currentCount = await getEventCount(eventType);
|
|
53
|
+
await setEventCount(eventType, currentCount + 1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Get last prompt date for specific event type
|
|
58
|
+
*/
|
|
59
|
+
export async function getLastPromptDate(eventType: string): Promise<string | null> {
|
|
60
|
+
try {
|
|
61
|
+
const result = await storageRepository.getString(KEYS.lastPrompt(eventType), "");
|
|
62
|
+
const date = unwrap(result, "");
|
|
63
|
+
return date || null;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
66
|
+
console.error("[RatingStorage] Error getting last prompt date:", error);
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Set last prompt date for specific event type
|
|
74
|
+
*/
|
|
75
|
+
export async function setLastPromptDate(eventType: string, date: string): Promise<void> {
|
|
76
|
+
try {
|
|
77
|
+
await storageRepository.setString(KEYS.lastPrompt(eventType), date);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
80
|
+
console.error("[RatingStorage] Error setting last prompt date:", error);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Check if user has rated the app
|
|
87
|
+
*/
|
|
88
|
+
export async function getHasRated(): Promise<boolean> {
|
|
89
|
+
try {
|
|
90
|
+
const result = await storageRepository.getString(KEYS.hasRated, "false");
|
|
91
|
+
return unwrap(result, "false") === "true";
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
94
|
+
console.error("[RatingStorage] Error getting hasRated:", error);
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Set whether user has rated the app
|
|
102
|
+
*/
|
|
103
|
+
export async function setHasRated(value: boolean): Promise<void> {
|
|
104
|
+
try {
|
|
105
|
+
await storageRepository.setString(KEYS.hasRated, value.toString());
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
108
|
+
console.error("[RatingStorage] Error setting hasRated:", error);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Check if user permanently dismissed the prompt
|
|
115
|
+
*/
|
|
116
|
+
export async function getDismissed(): Promise<boolean> {
|
|
117
|
+
try {
|
|
118
|
+
const result = await storageRepository.getString(KEYS.dismissed, "false");
|
|
119
|
+
return unwrap(result, "false") === "true";
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
122
|
+
console.error("[RatingStorage] Error getting dismissed:", error);
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Set whether user permanently dismissed the prompt
|
|
130
|
+
*/
|
|
131
|
+
export async function setDismissed(value: boolean): Promise<void> {
|
|
132
|
+
try {
|
|
133
|
+
await storageRepository.setString(KEYS.dismissed, value.toString());
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
136
|
+
console.error("[RatingStorage] Error setting dismissed:", error);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Get complete rating state for specific event type
|
|
143
|
+
*/
|
|
144
|
+
export async function getRatingState(eventType: string): Promise<RatingState> {
|
|
145
|
+
const [eventCount, lastPromptDate, hasRated, dismissed] = await Promise.all([
|
|
146
|
+
getEventCount(eventType),
|
|
147
|
+
getLastPromptDate(eventType),
|
|
148
|
+
getHasRated(),
|
|
149
|
+
getDismissed(),
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
eventCount,
|
|
154
|
+
lastPromptDate,
|
|
155
|
+
hasRated,
|
|
156
|
+
dismissed,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Reset rating data for specific event type or all data
|
|
162
|
+
*/
|
|
163
|
+
export async function reset(eventType?: string): Promise<void> {
|
|
164
|
+
try {
|
|
165
|
+
if (eventType) {
|
|
166
|
+
await storageRepository.removeItem(KEYS.eventCount(eventType));
|
|
167
|
+
await storageRepository.removeItem(KEYS.lastPrompt(eventType));
|
|
168
|
+
} else {
|
|
169
|
+
const allKeysResult = await storageRepository.getAllKeys();
|
|
170
|
+
const allKeys = unwrap(allKeysResult, []);
|
|
171
|
+
const ratingKeys = allKeys.filter((key) => key.startsWith("rating."));
|
|
172
|
+
|
|
173
|
+
await Promise.all(
|
|
174
|
+
ratingKeys.map((key) => storageRepository.removeItem(key))
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
} catch (error) {
|
|
178
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
179
|
+
console.error("[RatingStorage] Error resetting data:", error);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rating Prompt Modal
|
|
3
|
+
* 2-step rating prompt: Custom modal → Native review prompt
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { Modal, View, StyleSheet, type StyleProp, type ViewStyle } from "react-native";
|
|
8
|
+
import {
|
|
9
|
+
AtomicText,
|
|
10
|
+
AtomicButton,
|
|
11
|
+
AtomicIcon,
|
|
12
|
+
useAppDesignTokens,
|
|
13
|
+
useResponsive,
|
|
14
|
+
} from "@umituz/react-native-design-system";
|
|
15
|
+
import type { RatingTranslations } from "../../domain/entities/RatingConfig";
|
|
16
|
+
|
|
17
|
+
export interface RatingPromptModalProps {
|
|
18
|
+
visible: boolean;
|
|
19
|
+
onPositive: () => void;
|
|
20
|
+
onNegative: () => void;
|
|
21
|
+
onLater: () => void;
|
|
22
|
+
onDismiss: () => void;
|
|
23
|
+
translations?: RatingTranslations;
|
|
24
|
+
appName?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const RatingPromptModal: React.FC<RatingPromptModalProps> = ({
|
|
28
|
+
visible,
|
|
29
|
+
onPositive,
|
|
30
|
+
onNegative,
|
|
31
|
+
onLater,
|
|
32
|
+
onDismiss,
|
|
33
|
+
translations,
|
|
34
|
+
appName = "this app",
|
|
35
|
+
}) => {
|
|
36
|
+
const tokens = useAppDesignTokens();
|
|
37
|
+
const responsive = useResponsive();
|
|
38
|
+
|
|
39
|
+
const defaultTranslations: RatingTranslations = {
|
|
40
|
+
title: translations?.title ?? "Enjoying the app?",
|
|
41
|
+
message:
|
|
42
|
+
translations?.message ??
|
|
43
|
+
`If you love using ${appName}, would you mind taking a moment to rate it?`,
|
|
44
|
+
positiveButton: translations?.positiveButton ?? "Yes, I love it!",
|
|
45
|
+
negativeButton: translations?.negativeButton ?? "Not really",
|
|
46
|
+
laterButton: translations?.laterButton ?? "Maybe later",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<Modal
|
|
51
|
+
visible={visible}
|
|
52
|
+
transparent
|
|
53
|
+
animationType="none"
|
|
54
|
+
onRequestClose={onDismiss}
|
|
55
|
+
statusBarTranslucent
|
|
56
|
+
>
|
|
57
|
+
<View
|
|
58
|
+
style={[
|
|
59
|
+
styles.overlay,
|
|
60
|
+
{ backgroundColor: "rgba(0, 0, 0, 0.5)" },
|
|
61
|
+
]}
|
|
62
|
+
>
|
|
63
|
+
<View
|
|
64
|
+
style={[
|
|
65
|
+
styles.container,
|
|
66
|
+
{
|
|
67
|
+
backgroundColor: tokens.colors.surface,
|
|
68
|
+
borderRadius: tokens.borders.radius.xl,
|
|
69
|
+
padding: tokens.spacing.lg,
|
|
70
|
+
maxWidth: responsive.maxContentWidth * 0.85,
|
|
71
|
+
width: "90%",
|
|
72
|
+
},
|
|
73
|
+
]}
|
|
74
|
+
>
|
|
75
|
+
<View style={styles.iconContainer}>
|
|
76
|
+
<AtomicIcon name="star" size="xl" color="primary" />
|
|
77
|
+
</View>
|
|
78
|
+
|
|
79
|
+
<AtomicText
|
|
80
|
+
type="headlineMedium"
|
|
81
|
+
color="onSurface"
|
|
82
|
+
style={[
|
|
83
|
+
styles.title,
|
|
84
|
+
{ marginBottom: tokens.spacing.sm },
|
|
85
|
+
]}
|
|
86
|
+
>
|
|
87
|
+
{defaultTranslations.title}
|
|
88
|
+
</AtomicText>
|
|
89
|
+
|
|
90
|
+
<AtomicText
|
|
91
|
+
type="bodyMedium"
|
|
92
|
+
color="onSurfaceVariant"
|
|
93
|
+
style={[
|
|
94
|
+
styles.message,
|
|
95
|
+
{ marginBottom: tokens.spacing.lg },
|
|
96
|
+
]}
|
|
97
|
+
>
|
|
98
|
+
{defaultTranslations.message}
|
|
99
|
+
</AtomicText>
|
|
100
|
+
|
|
101
|
+
<View style={[styles.buttonContainer, { gap: tokens.spacing.sm }]}>
|
|
102
|
+
<AtomicButton
|
|
103
|
+
variant="fill"
|
|
104
|
+
onPress={onPositive}
|
|
105
|
+
style={styles.button}
|
|
106
|
+
>
|
|
107
|
+
{defaultTranslations.positiveButton}
|
|
108
|
+
</AtomicButton>
|
|
109
|
+
|
|
110
|
+
<AtomicButton
|
|
111
|
+
variant="outline"
|
|
112
|
+
onPress={onNegative}
|
|
113
|
+
style={styles.button}
|
|
114
|
+
>
|
|
115
|
+
{defaultTranslations.negativeButton}
|
|
116
|
+
</AtomicButton>
|
|
117
|
+
|
|
118
|
+
<AtomicButton
|
|
119
|
+
variant="text"
|
|
120
|
+
onPress={onLater}
|
|
121
|
+
style={styles.button}
|
|
122
|
+
>
|
|
123
|
+
{defaultTranslations.laterButton}
|
|
124
|
+
</AtomicButton>
|
|
125
|
+
</View>
|
|
126
|
+
</View>
|
|
127
|
+
</View>
|
|
128
|
+
</Modal>
|
|
129
|
+
);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const styles = StyleSheet.create({
|
|
133
|
+
overlay: {
|
|
134
|
+
flex: 1,
|
|
135
|
+
justifyContent: "center",
|
|
136
|
+
alignItems: "center",
|
|
137
|
+
},
|
|
138
|
+
container: {
|
|
139
|
+
alignItems: "center",
|
|
140
|
+
},
|
|
141
|
+
iconContainer: {
|
|
142
|
+
marginBottom: 16,
|
|
143
|
+
},
|
|
144
|
+
title: {
|
|
145
|
+
textAlign: "center",
|
|
146
|
+
},
|
|
147
|
+
message: {
|
|
148
|
+
textAlign: "center",
|
|
149
|
+
},
|
|
150
|
+
buttonContainer: {
|
|
151
|
+
width: "100%",
|
|
152
|
+
},
|
|
153
|
+
button: {
|
|
154
|
+
width: "100%",
|
|
155
|
+
},
|
|
156
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useAppRating Hook
|
|
3
|
+
* React hook for app rating system
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, { useState, useCallback } from "react";
|
|
7
|
+
import * as StoreReview from "expo-store-review";
|
|
8
|
+
import type {
|
|
9
|
+
RatingConfig,
|
|
10
|
+
UseAppRatingResult,
|
|
11
|
+
RatingState,
|
|
12
|
+
} from "../../domain/entities/RatingConfig";
|
|
13
|
+
import { DEFAULT_RATING_CONFIG } from "../../domain/entities/RatingConfig";
|
|
14
|
+
import * as RatingService from "../../application/services/RatingService";
|
|
15
|
+
import { RatingPromptModal } from "../components/RatingPromptModal";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* App rating hook with 2-step prompt flow
|
|
19
|
+
*/
|
|
20
|
+
export function useAppRating(config: RatingConfig): UseAppRatingResult {
|
|
21
|
+
const [isVisible, setIsVisible] = useState(false);
|
|
22
|
+
|
|
23
|
+
const mergedConfig: RatingConfig = {
|
|
24
|
+
...DEFAULT_RATING_CONFIG,
|
|
25
|
+
...config,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const trackEvent = useCallback(async (): Promise<void> => {
|
|
29
|
+
await RatingService.trackEvent(mergedConfig.eventType);
|
|
30
|
+
}, [mergedConfig.eventType]);
|
|
31
|
+
|
|
32
|
+
const shouldShow = useCallback(async (): Promise<boolean> => {
|
|
33
|
+
return RatingService.shouldShowPrompt(mergedConfig);
|
|
34
|
+
}, [mergedConfig]);
|
|
35
|
+
|
|
36
|
+
const showPrompt = useCallback(async (): Promise<void> => {
|
|
37
|
+
setIsVisible(true);
|
|
38
|
+
await RatingService.markPromptShown(mergedConfig.eventType);
|
|
39
|
+
|
|
40
|
+
if (mergedConfig.onPromptShown) {
|
|
41
|
+
await mergedConfig.onPromptShown();
|
|
42
|
+
}
|
|
43
|
+
}, [mergedConfig]);
|
|
44
|
+
|
|
45
|
+
const checkAndShow = useCallback(async (): Promise<boolean> => {
|
|
46
|
+
const should = await shouldShow();
|
|
47
|
+
|
|
48
|
+
if (should) {
|
|
49
|
+
await showPrompt();
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return false;
|
|
54
|
+
}, [shouldShow, showPrompt]);
|
|
55
|
+
|
|
56
|
+
const reset = useCallback(async (): Promise<void> => {
|
|
57
|
+
await RatingService.reset(mergedConfig.eventType);
|
|
58
|
+
}, [mergedConfig.eventType]);
|
|
59
|
+
|
|
60
|
+
const getState = useCallback(async (): Promise<RatingState> => {
|
|
61
|
+
return RatingService.getState(mergedConfig.eventType);
|
|
62
|
+
}, [mergedConfig.eventType]);
|
|
63
|
+
|
|
64
|
+
const handlePositive = useCallback(async () => {
|
|
65
|
+
setIsVisible(false);
|
|
66
|
+
await RatingService.markRated();
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const isAvailable = await StoreReview.isAvailableAsync();
|
|
70
|
+
|
|
71
|
+
if (isAvailable) {
|
|
72
|
+
await StoreReview.requestReview();
|
|
73
|
+
}
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
76
|
+
console.error("[useAppRating] Error requesting review:", error);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (mergedConfig.onPositiveFeedback) {
|
|
81
|
+
await mergedConfig.onPositiveFeedback();
|
|
82
|
+
}
|
|
83
|
+
}, [mergedConfig]);
|
|
84
|
+
|
|
85
|
+
const handleNegative = useCallback(async () => {
|
|
86
|
+
setIsVisible(false);
|
|
87
|
+
|
|
88
|
+
if (mergedConfig.onNegativeFeedback) {
|
|
89
|
+
await mergedConfig.onNegativeFeedback();
|
|
90
|
+
}
|
|
91
|
+
}, [mergedConfig]);
|
|
92
|
+
|
|
93
|
+
const handleLater = useCallback(() => {
|
|
94
|
+
setIsVisible(false);
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
const handleDismiss = useCallback(async () => {
|
|
98
|
+
setIsVisible(false);
|
|
99
|
+
await RatingService.markDismissed();
|
|
100
|
+
|
|
101
|
+
if (mergedConfig.onPromptDismissed) {
|
|
102
|
+
await mergedConfig.onPromptDismissed();
|
|
103
|
+
}
|
|
104
|
+
}, [mergedConfig]);
|
|
105
|
+
|
|
106
|
+
const modal = (
|
|
107
|
+
<RatingPromptModal
|
|
108
|
+
visible={isVisible}
|
|
109
|
+
onPositive={handlePositive}
|
|
110
|
+
onNegative={handleNegative}
|
|
111
|
+
onLater={handleLater}
|
|
112
|
+
onDismiss={handleDismiss}
|
|
113
|
+
translations={mergedConfig.translations}
|
|
114
|
+
appName={mergedConfig.appName}
|
|
115
|
+
/>
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
trackEvent,
|
|
120
|
+
checkAndShow,
|
|
121
|
+
shouldShow,
|
|
122
|
+
showPrompt,
|
|
123
|
+
reset,
|
|
124
|
+
getState,
|
|
125
|
+
isVisible,
|
|
126
|
+
modal,
|
|
127
|
+
} as UseAppRatingResult & { modal: React.ReactNode };
|
|
128
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -109,8 +109,22 @@ export * from './domains/feedback';
|
|
|
109
109
|
// FAQs Domain - Frequently asked questions
|
|
110
110
|
export * from './domains/faqs';
|
|
111
111
|
|
|
112
|
-
// Rating Domain - Star ratings, reviews, statistics
|
|
113
|
-
export
|
|
112
|
+
// Rating Domain - Star ratings, reviews, statistics, app store rating
|
|
113
|
+
export {
|
|
114
|
+
StarRating,
|
|
115
|
+
RatingPromptModal,
|
|
116
|
+
useAppRating,
|
|
117
|
+
DEFAULT_RATING_CONFIG,
|
|
118
|
+
type RatingValue,
|
|
119
|
+
type Rating,
|
|
120
|
+
type RatingStats,
|
|
121
|
+
type RatingConfig as AppStoreRatingConfig,
|
|
122
|
+
type RatingState as AppStoreRatingState,
|
|
123
|
+
type RatingTranslations as AppStoreRatingTranslations,
|
|
124
|
+
type UseAppRatingResult,
|
|
125
|
+
type StarRatingProps,
|
|
126
|
+
type RatingPromptModalProps,
|
|
127
|
+
} from "./domains/rating";
|
|
114
128
|
|
|
115
129
|
// Video Tutorials Domain - Learning resources, tutorials
|
|
116
130
|
export * from "./domains/video-tutorials";
|