@klyftig/mobile-ui 1.5.0 → 1.7.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 +80 -0
- package/package.json +32 -8
- package/src/components/WheelPicker.tsx +47 -13
- package/src/index.ts +3 -0
- package/src/review/RateAppModal.tsx +143 -0
- package/src/review/createReviewPrompt.ts +221 -0
- package/src/review/index.ts +25 -0
- package/src/review/storeLinks.ts +122 -0
- package/src/review/strings.ts +47 -0
package/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# @klyftig/mobile-ui
|
|
2
|
+
|
|
3
|
+
Shared UI for Klyftig's React Native / Expo apps (Kaelo, Nagger, Sleepwise,
|
|
4
|
+
Clev, Stampic). It ships TypeScript source (`main: src/index.ts`), so each app's
|
|
5
|
+
Metro/Babel compiles it. Apps' jest configs must keep `@klyftig/.*` in
|
|
6
|
+
`transformIgnorePatterns`.
|
|
7
|
+
|
|
8
|
+
Exports: theme tokens (`colors`, `typography`, `spacing`, …), glass components
|
|
9
|
+
(`GlassButton`, `GlassCard`, `CenterDialog`, `WheelPicker`, …) and the
|
|
10
|
+
**review ("Rate Us") module** described below. See `CHANGELOG.md` for the
|
|
11
|
+
version history.
|
|
12
|
+
|
|
13
|
+
## Peer dependencies
|
|
14
|
+
|
|
15
|
+
`expo`, `react`, `react-native`, `@expo/vector-icons`, `expo-blur`,
|
|
16
|
+
`expo-glass-effect`, `expo-linear-gradient`, `react-native-svg`,
|
|
17
|
+
`react-native-safe-area-context`, `expo-store-review` (native module, needs a
|
|
18
|
+
dev-client rebuild when added), and `zustand` (>= 5).
|
|
19
|
+
|
|
20
|
+
## Review ("Rate Us") module
|
|
21
|
+
|
|
22
|
+
This is a sentiment gate. 4–5★ asks for a store review and 1–3★ opens a feedback
|
|
23
|
+
email. The prompt appears at milestone counts of a happy-moment event the app
|
|
24
|
+
chooses. Each app configures it once:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
// src/utils/storeLinks.ts — the ONE place the App Store id lives
|
|
28
|
+
export const STORE_TARGET: StoreTarget = { iosAppId: '6759951153', androidPackage: 'com.klyftig.kaelo' };
|
|
29
|
+
|
|
30
|
+
// src/features/review/review.ts
|
|
31
|
+
export const review = createReviewPrompt({
|
|
32
|
+
...STORE_TARGET,
|
|
33
|
+
appName: 'Kaelo',
|
|
34
|
+
supportEmail: 'support@klyftig.co',
|
|
35
|
+
storageKey: 'review.prompt.v1', // keep stable once shipped
|
|
36
|
+
storage: AsyncStorage,
|
|
37
|
+
defaultMilestones: [3, 6], // optional, this is the default
|
|
38
|
+
haptics, // optional { selection() }
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Then:
|
|
43
|
+
|
|
44
|
+
- **Count** the happy moment with `review.recordEvent(reason?)`. For a once-ever
|
|
45
|
+
trigger (e.g. "2nd habit created"), use `review.recordOneTimeTrigger(id, reason?)`.
|
|
46
|
+
Both are async, hydrate first, and never throw.
|
|
47
|
+
- **Mount** the modal once, globally:
|
|
48
|
+
`<RateAppModal review={review} strings={reviewStringsFromT(t)} />`. Without
|
|
49
|
+
`strings` it shows English defaults built from `appName`. `strings.thankYou`
|
|
50
|
+
may be a `(reason) => string` function.
|
|
51
|
+
- **Surface** the prompt by calling `review.showIfPending()` from a screen where
|
|
52
|
+
a modal is safe (not over a sheet, a tour, the camera or a recording).
|
|
53
|
+
- **Remote config** goes through `review.configure({ enabled, milestones })`.
|
|
54
|
+
Invalid or missing fields keep the current value.
|
|
55
|
+
- **Tests** reset state with `review.resetForTests()`. Set
|
|
56
|
+
`review.useStore.setState({ visible: true })` to render the modal.
|
|
57
|
+
|
|
58
|
+
Store behaviour (`requestStoreReview`):
|
|
59
|
+
|
|
60
|
+
| Platform | Condition | Action |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| iOS | numeric `iosAppId` | `itms-apps://apps.apple.com/app/id<id>?action=write-review` (native prompt if it can't open) |
|
|
63
|
+
| iOS | unset / placeholder / any non-numeric id | `StoreReview.requestReview()` after `isAvailableAsync()`. No URL is ever opened |
|
|
64
|
+
| Android | always | Play In-App Review, then `market://details?id=…`, then `https://play.google.com/store/apps/details?id=…` |
|
|
65
|
+
|
|
66
|
+
`openStoreListing(target, androidUrl?)` opens the listing (e.g. on a
|
|
67
|
+
force-update screen). On iOS without a numeric id it opens nothing and resolves
|
|
68
|
+
`false`.
|
|
69
|
+
|
|
70
|
+
## Development
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
npm test # jest (jest-expo); tests live next to the source, excluded from the tarball
|
|
74
|
+
npm run sync # rsync src/ into Kaelo's node_modules for a quick dev loop (not a release)
|
|
75
|
+
npm pack # build the tarball; install it into an app with `npm i --no-save <tgz>` to verify
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Release: bump `version` and add a `CHANGELOG.md` entry, commit, run
|
|
79
|
+
`npm publish` (public), then bump `@klyftig/mobile-ui` in each app and run
|
|
80
|
+
`npm install`.
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@klyftig/mobile-ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"scripts": {
|
|
5
|
+
"test": "jest",
|
|
5
6
|
"sync": "bash scripts/sync-to-kaelo.sh",
|
|
6
7
|
"pack-install": "npm pack && mv klyftig-mobile-ui-*.tgz /tmp/klyftig-mobile-ui-local.tgz && cd ../kaelo/mobile && npm install /tmp/klyftig-mobile-ui-local.tgz"
|
|
7
8
|
},
|
|
@@ -9,7 +10,9 @@
|
|
|
9
10
|
"main": "src/index.ts",
|
|
10
11
|
"types": "src/index.ts",
|
|
11
12
|
"files": [
|
|
12
|
-
"src"
|
|
13
|
+
"src",
|
|
14
|
+
"!src/**/*.test.ts",
|
|
15
|
+
"!src/**/*.test.tsx"
|
|
13
16
|
],
|
|
14
17
|
"keywords": [
|
|
15
18
|
"react-native",
|
|
@@ -19,22 +22,43 @@
|
|
|
19
22
|
],
|
|
20
23
|
"license": "MIT",
|
|
21
24
|
"peerDependencies": {
|
|
25
|
+
"@expo/vector-icons": "*",
|
|
22
26
|
"expo": "*",
|
|
23
|
-
"
|
|
24
|
-
"react-native": ">=0.83.0",
|
|
27
|
+
"expo-blur": "*",
|
|
25
28
|
"expo-glass-effect": "*",
|
|
26
29
|
"expo-linear-gradient": "*",
|
|
27
|
-
"expo-
|
|
28
|
-
"
|
|
30
|
+
"expo-store-review": "*",
|
|
31
|
+
"react": ">=19.0.0",
|
|
32
|
+
"react-native": ">=0.83.0",
|
|
33
|
+
"react-native-safe-area-context": "*",
|
|
29
34
|
"react-native-svg": "*",
|
|
30
|
-
"
|
|
35
|
+
"zustand": ">=5.0.0"
|
|
31
36
|
},
|
|
32
37
|
"publishConfig": {
|
|
33
38
|
"access": "public"
|
|
34
39
|
},
|
|
35
40
|
"devDependencies": {
|
|
41
|
+
"@testing-library/react-native": "^13.3.3",
|
|
42
|
+
"@types/jest": "^29.5.14",
|
|
43
|
+
"@types/react": "~19.2.0",
|
|
36
44
|
"expo": "~55.0.11",
|
|
45
|
+
"expo-store-review": "~55.0.14",
|
|
46
|
+
"jest": "^29.7.0",
|
|
47
|
+
"jest-expo": "~55.0.14",
|
|
37
48
|
"react": "19.2.0",
|
|
38
|
-
"react-native": "0.83.4"
|
|
49
|
+
"react-native": "0.83.4",
|
|
50
|
+
"react-test-renderer": "^19.2.0",
|
|
51
|
+
"typescript": "^6.0.3",
|
|
52
|
+
"zustand": "^5.0.15"
|
|
53
|
+
},
|
|
54
|
+
"jest": {
|
|
55
|
+
"preset": "jest-expo",
|
|
56
|
+
"testMatch": [
|
|
57
|
+
"**/*.test.ts",
|
|
58
|
+
"**/*.test.tsx"
|
|
59
|
+
],
|
|
60
|
+
"transformIgnorePatterns": [
|
|
61
|
+
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|react-native-svg|zustand)"
|
|
62
|
+
]
|
|
39
63
|
}
|
|
40
64
|
}
|
|
@@ -13,12 +13,23 @@ import { colors } from '../theme/colors';
|
|
|
13
13
|
export interface WheelPickerProps {
|
|
14
14
|
values: number[];
|
|
15
15
|
selected: number;
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* `wraps` (only with `loop`): how many times this scroll went past the end
|
|
18
|
+
* of the list — +1 for 59 → 00 going down, -1 for 00 → 59 going up. A
|
|
19
|
+
* caller can carry it, e.g. bump the hour when the minutes wrap.
|
|
20
|
+
*/
|
|
21
|
+
onChange: (value: number, info: { wraps: number }) => void;
|
|
17
22
|
formatLabel?: (value: number) => string;
|
|
18
23
|
itemHeight?: number;
|
|
19
24
|
visibleCount?: number;
|
|
20
25
|
width?: number;
|
|
21
26
|
flex?: number;
|
|
27
|
+
/**
|
|
28
|
+
* Wrap around (59 → 00 → 01, and back from 00 to 59), like a clock. The
|
|
29
|
+
* values are laid out LOOP_COPIES times and the wheel recentres on the
|
|
30
|
+
* middle copy after every scroll, so it never reaches an end in practice.
|
|
31
|
+
*/
|
|
32
|
+
loop?: boolean;
|
|
22
33
|
}
|
|
23
34
|
|
|
24
35
|
const DEFAULT_ITEM_HEIGHT = 48;
|
|
@@ -32,6 +43,9 @@ const DEFAULT_VISIBLE_COUNT = 3;
|
|
|
32
43
|
// and momentum behave normally, but we don't mount 600 <Text> nodes on first
|
|
33
44
|
// paint of MeasurementsStep.
|
|
34
45
|
const WINDOW_OVERSCAN = 8;
|
|
46
|
+
// Odd, so there is a middle copy to start in and snap back to. 21 copies of
|
|
47
|
+
// 60 minutes is 1260 rows — only the overscan window is mounted.
|
|
48
|
+
const LOOP_COPIES = 21;
|
|
35
49
|
|
|
36
50
|
export function WheelPicker({
|
|
37
51
|
values,
|
|
@@ -42,6 +56,7 @@ export function WheelPicker({
|
|
|
42
56
|
visibleCount = DEFAULT_VISIBLE_COUNT,
|
|
43
57
|
width,
|
|
44
58
|
flex,
|
|
59
|
+
loop = false,
|
|
45
60
|
}: WheelPickerProps) {
|
|
46
61
|
const { colors } = useTheme();
|
|
47
62
|
const scrollRef = useRef<ScrollView>(null);
|
|
@@ -49,10 +64,18 @@ export function WheelPicker({
|
|
|
49
64
|
const padding = (containerHeight - itemHeight) / 2;
|
|
50
65
|
const lastIndexRef = useRef<number>(-1);
|
|
51
66
|
|
|
52
|
-
|
|
67
|
+
// Rows are virtual indices; with `loop` they span LOOP_COPIES copies of
|
|
68
|
+
// `values` and row i shows values[i mod n].
|
|
69
|
+
const n = values.length;
|
|
70
|
+
const rowCount = loop ? n * LOOP_COPIES : n;
|
|
71
|
+
const middleStart = loop ? Math.floor(LOOP_COPIES / 2) * n : 0;
|
|
72
|
+
const valueIndexOf = useCallback((row: number) => ((row % n) + n) % n, [n]);
|
|
73
|
+
|
|
74
|
+
const valueIndex = useMemo(() => {
|
|
53
75
|
const idx = values.indexOf(selected);
|
|
54
76
|
return idx >= 0 ? idx : 0;
|
|
55
77
|
}, [values, selected]);
|
|
78
|
+
const selectedIndex = middleStart + valueIndex;
|
|
56
79
|
|
|
57
80
|
// Track the row currently centered under the selection overlay during scroll
|
|
58
81
|
// so the rendered window follows the user's drag, not just the committed
|
|
@@ -73,38 +96,49 @@ export function WheelPicker({
|
|
|
73
96
|
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
74
97
|
const offsetY = e.nativeEvent.contentOffset.y;
|
|
75
98
|
const index = Math.round(offsetY / itemHeight);
|
|
76
|
-
const clamped = Math.max(0, Math.min(
|
|
99
|
+
const clamped = Math.max(0, Math.min(rowCount - 1, index));
|
|
77
100
|
setWindowCenter((prev) => (Math.abs(prev - clamped) >= 2 ? clamped : prev));
|
|
78
101
|
},
|
|
79
|
-
[itemHeight,
|
|
102
|
+
[itemHeight, rowCount],
|
|
80
103
|
);
|
|
81
104
|
|
|
82
105
|
const handleMomentumEnd = useCallback(
|
|
83
106
|
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
84
107
|
const offsetY = e.nativeEvent.contentOffset.y;
|
|
85
108
|
const index = Math.round(offsetY / itemHeight);
|
|
86
|
-
const clamped = Math.max(0, Math.min(
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
109
|
+
const clamped = Math.max(0, Math.min(rowCount - 1, index));
|
|
110
|
+
const value = values[valueIndexOf(clamped)];
|
|
111
|
+
// Every scroll starts in the middle copy, so copies crossed = wraps.
|
|
112
|
+
const wraps = loop ? Math.floor(clamped / n) - Math.floor(LOOP_COPIES / 2) : 0;
|
|
113
|
+
let row = clamped;
|
|
114
|
+
if (loop) {
|
|
115
|
+
// Jump back to the same value in the middle copy (invisible: the rows
|
|
116
|
+
// look identical) so the next fling has room either way.
|
|
117
|
+
row = middleStart + valueIndexOf(clamped);
|
|
118
|
+
if (row !== clamped) scrollRef.current?.scrollTo({ y: row * itemHeight, animated: false });
|
|
119
|
+
lastIndexRef.current = row;
|
|
120
|
+
}
|
|
121
|
+
setWindowCenter(row);
|
|
122
|
+
if (value !== selected || wraps !== 0) {
|
|
123
|
+
onChange(value, { wraps });
|
|
90
124
|
}
|
|
91
125
|
},
|
|
92
|
-
[itemHeight, values, selected, onChange],
|
|
126
|
+
[itemHeight, n, rowCount, values, valueIndexOf, loop, middleStart, selected, onChange],
|
|
93
127
|
);
|
|
94
128
|
|
|
95
129
|
const windowStart = Math.max(0, windowCenter - WINDOW_OVERSCAN);
|
|
96
|
-
const windowEnd = Math.min(
|
|
130
|
+
const windowEnd = Math.min(rowCount - 1, windowCenter + WINDOW_OVERSCAN);
|
|
97
131
|
|
|
98
132
|
const dimColor = colors.text.tertiary;
|
|
99
133
|
const activeColor = colors.text.primary;
|
|
100
134
|
|
|
101
135
|
const items = [];
|
|
102
136
|
for (let i = windowStart; i <= windowEnd; i++) {
|
|
103
|
-
const value = values[i];
|
|
137
|
+
const value = values[valueIndexOf(i)];
|
|
104
138
|
const isSelected = i === selectedIndex;
|
|
105
139
|
items.push(
|
|
106
140
|
<View
|
|
107
|
-
key={
|
|
141
|
+
key={i}
|
|
108
142
|
style={[
|
|
109
143
|
styles.item,
|
|
110
144
|
{
|
|
@@ -128,7 +162,7 @@ export function WheelPicker({
|
|
|
128
162
|
);
|
|
129
163
|
}
|
|
130
164
|
|
|
131
|
-
const totalContentHeight =
|
|
165
|
+
const totalContentHeight = rowCount * itemHeight + padding * 2;
|
|
132
166
|
|
|
133
167
|
return (
|
|
134
168
|
<View style={[styles.wrapper, { height: containerHeight, width, flex }]}>
|
package/src/index.ts
CHANGED
|
@@ -56,3 +56,6 @@ export { CircularProgress } from './components/progress/CircularProgress';
|
|
|
56
56
|
export type { CircularProgressMode } from './components/progress/CircularProgress';
|
|
57
57
|
export { CArcProgress } from './components/progress/CArcProgress';
|
|
58
58
|
export { MacroBar } from './components/progress/MacroBar';
|
|
59
|
+
|
|
60
|
+
// Review (Rate Us)
|
|
61
|
+
export * from './review';
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import { View, Text, StyleSheet, TouchableOpacity, Linking } from 'react-native';
|
|
3
|
+
import { MaterialCommunityIcons as Icon } from '@expo/vector-icons';
|
|
4
|
+
import { CenterDialog } from '../components/CenterDialog';
|
|
5
|
+
import { colors } from '../theme/colors';
|
|
6
|
+
import type { ReviewPrompt } from './createReviewPrompt';
|
|
7
|
+
import { requestStoreReview } from './storeLinks';
|
|
8
|
+
import { defaultReviewStrings, type RateAppStrings } from './strings';
|
|
9
|
+
|
|
10
|
+
export interface RateAppModalProps {
|
|
11
|
+
review: ReviewPrompt;
|
|
12
|
+
/** Overrides for the default English strings (see `reviewStringsFromT`). */
|
|
13
|
+
strings?: Partial<RateAppStrings>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const STARS = [1, 2, 3, 4, 5];
|
|
17
|
+
const STAR_ON = '#F5A623';
|
|
18
|
+
const STAR_OFF = '#C5C5C5';
|
|
19
|
+
|
|
20
|
+
/** Lowest rating sent to the store; anything below goes to private feedback. */
|
|
21
|
+
export const POSITIVE_RATING_THRESHOLD = 4;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Sentiment gate: 4–5★ → store review (`requestStoreReview`), 1–3★ → feedback
|
|
25
|
+
* email to the configured support address. Either way the user counts as rated.
|
|
26
|
+
* Mount once, globally; it shows itself when the review store is visible.
|
|
27
|
+
*/
|
|
28
|
+
export const RateAppModal: React.FC<RateAppModalProps> = ({ review, strings }) => {
|
|
29
|
+
const visible = review.useStore((s) => s.visible);
|
|
30
|
+
const reason = review.useStore((s) => s.reason);
|
|
31
|
+
const [selected, setSelected] = useState(0);
|
|
32
|
+
const s: RateAppStrings = { ...defaultReviewStrings(review.config.appName), ...strings };
|
|
33
|
+
const thankYou = typeof s.thankYou === 'function' ? s.thankYou(reason) : s.thankYou;
|
|
34
|
+
|
|
35
|
+
// A fresh prompt never shows the stars from a previous one.
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (visible) setSelected(0);
|
|
38
|
+
}, [visible]);
|
|
39
|
+
|
|
40
|
+
const handleSelect = (rating: number) => {
|
|
41
|
+
review.config.haptics?.selection?.();
|
|
42
|
+
setSelected(rating);
|
|
43
|
+
if (rating >= POSITIVE_RATING_THRESHOLD) {
|
|
44
|
+
void requestStoreReview(review.config);
|
|
45
|
+
} else {
|
|
46
|
+
const subject = encodeURIComponent(s.feedbackSubject);
|
|
47
|
+
Linking.openURL(`mailto:${review.config.supportEmail}?subject=${subject}`).catch(() => {});
|
|
48
|
+
}
|
|
49
|
+
review.markRated();
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<CenterDialog
|
|
54
|
+
visible={visible}
|
|
55
|
+
onDismiss={review.dismiss}
|
|
56
|
+
backdropAccessibilityLabel={s.close}
|
|
57
|
+
contentStyle={styles.content}
|
|
58
|
+
>
|
|
59
|
+
<TouchableOpacity
|
|
60
|
+
style={styles.closeButton}
|
|
61
|
+
onPress={review.dismiss}
|
|
62
|
+
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
|
|
63
|
+
accessibilityRole="button"
|
|
64
|
+
accessibilityLabel={s.close}
|
|
65
|
+
>
|
|
66
|
+
<Icon name="close" size={22} color={colors.text.tertiary} />
|
|
67
|
+
</TouchableOpacity>
|
|
68
|
+
|
|
69
|
+
<Text style={styles.thankYou}>{thankYou}</Text>
|
|
70
|
+
<Text style={styles.title}>{s.title}</Text>
|
|
71
|
+
<Text style={styles.subtitle}>{s.subtitle}</Text>
|
|
72
|
+
|
|
73
|
+
<View style={styles.stars}>
|
|
74
|
+
{STARS.map((rating) => (
|
|
75
|
+
<TouchableOpacity
|
|
76
|
+
key={rating}
|
|
77
|
+
onPress={() => handleSelect(rating)}
|
|
78
|
+
hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
|
|
79
|
+
accessibilityRole="button"
|
|
80
|
+
accessibilityLabel={s.star(rating)}
|
|
81
|
+
>
|
|
82
|
+
<Icon
|
|
83
|
+
name={rating <= selected ? 'star' : 'star-outline'}
|
|
84
|
+
size={40}
|
|
85
|
+
color={rating <= selected ? STAR_ON : STAR_OFF}
|
|
86
|
+
/>
|
|
87
|
+
</TouchableOpacity>
|
|
88
|
+
))}
|
|
89
|
+
</View>
|
|
90
|
+
|
|
91
|
+
<TouchableOpacity style={styles.later} onPress={review.dismiss} accessibilityRole="button">
|
|
92
|
+
<Text style={styles.laterText}>{s.later}</Text>
|
|
93
|
+
</TouchableOpacity>
|
|
94
|
+
</CenterDialog>
|
|
95
|
+
);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const styles = StyleSheet.create({
|
|
99
|
+
content: {
|
|
100
|
+
alignItems: 'center',
|
|
101
|
+
},
|
|
102
|
+
closeButton: {
|
|
103
|
+
position: 'absolute',
|
|
104
|
+
top: 14,
|
|
105
|
+
right: 14,
|
|
106
|
+
},
|
|
107
|
+
thankYou: {
|
|
108
|
+
fontSize: 13,
|
|
109
|
+
fontWeight: '600',
|
|
110
|
+
color: colors.accent.warning,
|
|
111
|
+
textAlign: 'center',
|
|
112
|
+
marginTop: 8,
|
|
113
|
+
},
|
|
114
|
+
title: {
|
|
115
|
+
fontSize: 19,
|
|
116
|
+
fontWeight: '700',
|
|
117
|
+
color: colors.text.primary,
|
|
118
|
+
textAlign: 'center',
|
|
119
|
+
marginTop: 4,
|
|
120
|
+
},
|
|
121
|
+
subtitle: {
|
|
122
|
+
fontSize: 14,
|
|
123
|
+
color: colors.text.secondary,
|
|
124
|
+
textAlign: 'center',
|
|
125
|
+
marginTop: 8,
|
|
126
|
+
},
|
|
127
|
+
stars: {
|
|
128
|
+
flexDirection: 'row',
|
|
129
|
+
gap: 6,
|
|
130
|
+
marginTop: 20,
|
|
131
|
+
marginBottom: 8,
|
|
132
|
+
},
|
|
133
|
+
later: {
|
|
134
|
+
marginTop: 12,
|
|
135
|
+
paddingVertical: 8,
|
|
136
|
+
paddingHorizontal: 16,
|
|
137
|
+
},
|
|
138
|
+
laterText: {
|
|
139
|
+
fontSize: 14,
|
|
140
|
+
fontWeight: '600',
|
|
141
|
+
color: colors.text.tertiary,
|
|
142
|
+
},
|
|
143
|
+
});
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { create, type StoreApi, type UseBoundStore } from 'zustand';
|
|
2
|
+
import type { StoreTarget } from './storeLinks';
|
|
3
|
+
|
|
4
|
+
/** AsyncStorage-compatible key/value storage (pass `AsyncStorage` itself). */
|
|
5
|
+
export interface ReviewStorage {
|
|
6
|
+
getItem(key: string): Promise<string | null>;
|
|
7
|
+
setItem(key: string, value: string): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ReviewPromptConfig extends StoreTarget {
|
|
11
|
+
/** Shown in the default English strings ("How do you like Kaelo?"). */
|
|
12
|
+
appName: string;
|
|
13
|
+
/** 1–3★ feedback goes to `mailto:<supportEmail>`. */
|
|
14
|
+
supportEmail: string;
|
|
15
|
+
/** Persistence key, e.g. `review.prompt.v1`. Keep it stable once shipped. */
|
|
16
|
+
storageKey: string;
|
|
17
|
+
storage: ReviewStorage;
|
|
18
|
+
/** Event counts that surface the prompt until {@link ReviewPrompt.configure} overrides them. Default {@link DEFAULT_REVIEW_MILESTONES}. */
|
|
19
|
+
defaultMilestones?: number[];
|
|
20
|
+
/** Fired on every star tap. */
|
|
21
|
+
haptics?: { selection?: () => void };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Shown at the 3rd happy-moment event; if dismissed, once more at the 6th; then never. */
|
|
25
|
+
export const DEFAULT_REVIEW_MILESTONES: readonly number[] = [3, 6];
|
|
26
|
+
|
|
27
|
+
/** Reason recorded by {@link ReviewPrompt.recordEvent} when the caller gives none. */
|
|
28
|
+
export const DEFAULT_REVIEW_REASON = 'milestone';
|
|
29
|
+
|
|
30
|
+
export interface PersistedReviewState {
|
|
31
|
+
/** Cumulative happy-moment events. */
|
|
32
|
+
logCount: number;
|
|
33
|
+
/** Highest milestone already surfaced. */
|
|
34
|
+
lastMilestoneShown: number;
|
|
35
|
+
/** Rated (any sentiment) → never prompt again. */
|
|
36
|
+
rated: boolean;
|
|
37
|
+
/** One-time triggers ({@link ReviewPrompt.recordOneTimeTrigger}) already surfaced. */
|
|
38
|
+
triggersShown: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ReviewPromptState extends PersistedReviewState {
|
|
42
|
+
/** Modal is on screen. */
|
|
43
|
+
visible: boolean;
|
|
44
|
+
/** A trigger fired; waiting for {@link ReviewPrompt.showIfPending} at a safe moment. */
|
|
45
|
+
pending: boolean;
|
|
46
|
+
/** What surfaced the current prompt — lets the modal pick its thank-you line. */
|
|
47
|
+
reason: string;
|
|
48
|
+
/** Remote kill-switch. Not persisted. */
|
|
49
|
+
enabled: boolean;
|
|
50
|
+
/** Active milestones. Not persisted. */
|
|
51
|
+
milestones: number[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface RemoteReviewConfig {
|
|
55
|
+
enabled?: unknown;
|
|
56
|
+
milestones?: unknown;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ReviewPrompt {
|
|
60
|
+
readonly config: ReviewPromptConfig;
|
|
61
|
+
/** Zustand hook + `getState`/`setState`/`subscribe`. */
|
|
62
|
+
readonly useStore: UseBoundStore<StoreApi<ReviewPromptState>>;
|
|
63
|
+
/** Load persisted counters. Idempotent: every caller awaits the same load. */
|
|
64
|
+
hydrate(): Promise<void>;
|
|
65
|
+
/** Apply remote overrides (fail-open: missing/invalid fields keep the current value). */
|
|
66
|
+
configure(remote: RemoteReviewConfig): void;
|
|
67
|
+
/** Count one happy-moment event; at a not-yet-shown milestone the prompt goes pending. */
|
|
68
|
+
recordEvent(reason?: string): Promise<void>;
|
|
69
|
+
/** Mark the prompt pending for `triggerId`, once ever per trigger (e.g. "2nd habit created"). */
|
|
70
|
+
recordOneTimeTrigger(triggerId: string, reason?: string): Promise<void>;
|
|
71
|
+
/** Promote a pending prompt to visible. Call from a screen where a modal is safe. */
|
|
72
|
+
showIfPending(): void;
|
|
73
|
+
/** User finished the flow (any rating). Never prompt again. */
|
|
74
|
+
markRated(): void;
|
|
75
|
+
/** User closed without rating. Eligible again at the next milestone only. */
|
|
76
|
+
dismiss(): void;
|
|
77
|
+
/** Test-only: back to defaults and forget hydration. */
|
|
78
|
+
resetForTests(): void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Positive, ascending, unique integers; null when nothing valid remains. */
|
|
82
|
+
export function sanitizeMilestones(input: unknown): number[] | null {
|
|
83
|
+
if (!Array.isArray(input)) return null;
|
|
84
|
+
const clean = [
|
|
85
|
+
...new Set(
|
|
86
|
+
input
|
|
87
|
+
.filter((m): m is number => typeof m === 'number' && Number.isFinite(m) && m >= 1)
|
|
88
|
+
.map((m) => Math.floor(m))
|
|
89
|
+
),
|
|
90
|
+
].sort((a, b) => a - b);
|
|
91
|
+
return clean.length > 0 ? clean : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const PERSISTED_DEFAULTS: PersistedReviewState = {
|
|
95
|
+
logCount: 0,
|
|
96
|
+
lastMilestoneShown: 0,
|
|
97
|
+
rated: false,
|
|
98
|
+
triggersShown: [],
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
function parsePersisted(raw: string): PersistedReviewState {
|
|
102
|
+
const parsed: unknown = JSON.parse(raw);
|
|
103
|
+
if (typeof parsed !== 'object' || parsed === null) return PERSISTED_DEFAULTS;
|
|
104
|
+
const p = parsed as Record<string, unknown>;
|
|
105
|
+
const count = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? Math.floor(v) : 0);
|
|
106
|
+
return {
|
|
107
|
+
logCount: count(p.logCount),
|
|
108
|
+
lastMilestoneShown: count(p.lastMilestoneShown),
|
|
109
|
+
rated: p.rated === true,
|
|
110
|
+
triggersShown: Array.isArray(p.triggersShown)
|
|
111
|
+
? p.triggersShown.filter((t): t is string => typeof t === 'string')
|
|
112
|
+
: [],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Create an app's review-prompt store. Call once per app (module scope) and
|
|
118
|
+
* share the returned object; render `<RateAppModal review={…} />` with it.
|
|
119
|
+
*/
|
|
120
|
+
export function createReviewPrompt(config: ReviewPromptConfig): ReviewPrompt {
|
|
121
|
+
const baseMilestones = sanitizeMilestones(config.defaultMilestones) ?? [...DEFAULT_REVIEW_MILESTONES];
|
|
122
|
+
const initialState = (): ReviewPromptState => ({
|
|
123
|
+
...PERSISTED_DEFAULTS,
|
|
124
|
+
triggersShown: [],
|
|
125
|
+
visible: false,
|
|
126
|
+
pending: false,
|
|
127
|
+
reason: DEFAULT_REVIEW_REASON,
|
|
128
|
+
enabled: true,
|
|
129
|
+
milestones: baseMilestones,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const useStore = create<ReviewPromptState>(initialState);
|
|
133
|
+
let hydration: Promise<void> | null = null;
|
|
134
|
+
|
|
135
|
+
async function load(): Promise<void> {
|
|
136
|
+
try {
|
|
137
|
+
const raw = await config.storage.getItem(config.storageKey);
|
|
138
|
+
if (raw) useStore.setState(parsePersisted(raw));
|
|
139
|
+
} catch {
|
|
140
|
+
// Unreadable or corrupt — keep defaults.
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function persist(): void {
|
|
145
|
+
const { logCount, lastMilestoneShown, rated, triggersShown } = useStore.getState();
|
|
146
|
+
const snapshot: PersistedReviewState = { logCount, lastMilestoneShown, rated, triggersShown };
|
|
147
|
+
try {
|
|
148
|
+
config.storage.setItem(config.storageKey, JSON.stringify(snapshot)).catch(() => {});
|
|
149
|
+
} catch {
|
|
150
|
+
// Synchronous storage failure — the counters live on in memory.
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const hydrate = (): Promise<void> => {
|
|
155
|
+
if (!hydration) hydration = load();
|
|
156
|
+
return hydration;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
config,
|
|
161
|
+
useStore,
|
|
162
|
+
hydrate,
|
|
163
|
+
|
|
164
|
+
configure(remote) {
|
|
165
|
+
const next: Partial<ReviewPromptState> = {};
|
|
166
|
+
if (typeof remote.enabled === 'boolean') next.enabled = remote.enabled;
|
|
167
|
+
const milestones = sanitizeMilestones(remote.milestones);
|
|
168
|
+
if (milestones) next.milestones = milestones;
|
|
169
|
+
if (Object.keys(next).length > 0) useStore.setState(next);
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
// Hydrates first: a cold launch can record an event before anything else
|
|
173
|
+
// loaded the counters, and persisting in-memory defaults then would erase
|
|
174
|
+
// a stored "rated".
|
|
175
|
+
async recordEvent(reason = DEFAULT_REVIEW_REASON) {
|
|
176
|
+
await hydrate();
|
|
177
|
+
const state = useStore.getState();
|
|
178
|
+
const logCount = state.logCount + 1;
|
|
179
|
+
const next: Partial<ReviewPromptState> = { logCount };
|
|
180
|
+
if (state.enabled && !state.rated) {
|
|
181
|
+
const reached = state.milestones.filter((m) => logCount >= m && m > state.lastMilestoneShown);
|
|
182
|
+
if (reached.length > 0) {
|
|
183
|
+
next.lastMilestoneShown = Math.max(...reached);
|
|
184
|
+
next.pending = true;
|
|
185
|
+
next.reason = reason;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
useStore.setState(next);
|
|
189
|
+
persist();
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
async recordOneTimeTrigger(triggerId, reason = triggerId) {
|
|
193
|
+
await hydrate();
|
|
194
|
+
const state = useStore.getState();
|
|
195
|
+
if (!state.enabled || state.rated || state.triggersShown.includes(triggerId)) return;
|
|
196
|
+
useStore.setState({ triggersShown: [...state.triggersShown, triggerId], pending: true, reason });
|
|
197
|
+
persist();
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
showIfPending() {
|
|
201
|
+
const { pending, visible, rated, enabled } = useStore.getState();
|
|
202
|
+
if (pending && !visible && !rated && enabled) {
|
|
203
|
+
useStore.setState({ visible: true, pending: false });
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
markRated() {
|
|
208
|
+
useStore.setState({ rated: true, visible: false, pending: false });
|
|
209
|
+
persist();
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
dismiss() {
|
|
213
|
+
useStore.setState({ visible: false, pending: false });
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
resetForTests() {
|
|
217
|
+
hydration = null;
|
|
218
|
+
useStore.setState(initialState(), true);
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createReviewPrompt,
|
|
3
|
+
sanitizeMilestones,
|
|
4
|
+
DEFAULT_REVIEW_MILESTONES,
|
|
5
|
+
DEFAULT_REVIEW_REASON,
|
|
6
|
+
} from './createReviewPrompt';
|
|
7
|
+
export type {
|
|
8
|
+
ReviewPrompt,
|
|
9
|
+
ReviewPromptConfig,
|
|
10
|
+
ReviewPromptState,
|
|
11
|
+
PersistedReviewState,
|
|
12
|
+
RemoteReviewConfig,
|
|
13
|
+
ReviewStorage,
|
|
14
|
+
} from './createReviewPrompt';
|
|
15
|
+
export {
|
|
16
|
+
isValidIosAppId,
|
|
17
|
+
getStoreUrls,
|
|
18
|
+
requestStoreReview,
|
|
19
|
+
openStoreListing,
|
|
20
|
+
} from './storeLinks';
|
|
21
|
+
export type { StoreTarget, StoreUrls, StoreReviewOutcome } from './storeLinks';
|
|
22
|
+
export { RateAppModal, POSITIVE_RATING_THRESHOLD } from './RateAppModal';
|
|
23
|
+
export type { RateAppModalProps } from './RateAppModal';
|
|
24
|
+
export { defaultReviewStrings, reviewStringsFromT } from './strings';
|
|
25
|
+
export type { RateAppStrings, ReviewTranslate } from './strings';
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { Linking, Platform } from 'react-native';
|
|
2
|
+
import * as StoreReview from 'expo-store-review';
|
|
3
|
+
|
|
4
|
+
/** Where an app lives in the stores. One per app, defined once in its review config. */
|
|
5
|
+
export interface StoreTarget {
|
|
6
|
+
/**
|
|
7
|
+
* Numeric App Store id (App Store Connect → App Information → Apple ID).
|
|
8
|
+
* Anything non-numeric — unset, empty, or a `SET_…` placeholder — is treated
|
|
9
|
+
* as "no id": no App Store URL is ever built from it.
|
|
10
|
+
*/
|
|
11
|
+
iosAppId?: string | null;
|
|
12
|
+
/** Android applicationId, e.g. `com.klyftig.kaelo`. */
|
|
13
|
+
androidPackage: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface StoreUrls {
|
|
17
|
+
/** `itms-apps://` listing, or null when no valid iOS id is configured. */
|
|
18
|
+
iosListing: string | null;
|
|
19
|
+
/** `itms-apps://…?action=write-review`, or null when no valid iOS id is configured. */
|
|
20
|
+
iosWriteReview: string | null;
|
|
21
|
+
/** `market://` deep link into the Play Store app. */
|
|
22
|
+
androidMarket: string;
|
|
23
|
+
/** https Play listing — works without the Play Store app installed. */
|
|
24
|
+
androidWeb: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** How a {@link requestStoreReview} call was satisfied. */
|
|
28
|
+
export type StoreReviewOutcome =
|
|
29
|
+
/** iOS: opened the App Store's write-review sheet via the numeric id. */
|
|
30
|
+
| 'ios-write-review'
|
|
31
|
+
/** `expo-store-review` `requestReview()` (SKStoreReviewController / Play In-App Review). */
|
|
32
|
+
| 'native-prompt'
|
|
33
|
+
/** Android: opened the Play Store app via `market://`. */
|
|
34
|
+
| 'android-market'
|
|
35
|
+
/** Android: opened the https Play listing. */
|
|
36
|
+
| 'android-web'
|
|
37
|
+
/** Nothing could be opened (simulator, no store, every fallback failed). */
|
|
38
|
+
| 'unavailable';
|
|
39
|
+
|
|
40
|
+
const IOS_APP_ID_PATTERN = /^[0-9]+$/;
|
|
41
|
+
|
|
42
|
+
/** True only for a purely numeric App Store id; placeholders such as `SET_NAGGER_IOS_APP_ID` are rejected. */
|
|
43
|
+
export function isValidIosAppId(id: string | null | undefined): id is string {
|
|
44
|
+
return typeof id === 'string' && IOS_APP_ID_PATTERN.test(id);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// itms-apps:// opens the App Store app directly. https://apps.apple.com/ is a
|
|
48
|
+
// universal link that React Native's Linking rejects, because the calling app
|
|
49
|
+
// is not associated with that domain.
|
|
50
|
+
export function getStoreUrls(target: StoreTarget): StoreUrls {
|
|
51
|
+
const iosId = isValidIosAppId(target.iosAppId) ? target.iosAppId : null;
|
|
52
|
+
return {
|
|
53
|
+
iosListing: iosId ? `itms-apps://apps.apple.com/app/id${iosId}` : null,
|
|
54
|
+
iosWriteReview: iosId ? `itms-apps://apps.apple.com/app/id${iosId}?action=write-review` : null,
|
|
55
|
+
androidMarket: `market://details?id=${target.androidPackage}`,
|
|
56
|
+
androidWeb: `https://play.google.com/store/apps/details?id=${target.androidPackage}`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function tryOpen(url: string): Promise<boolean> {
|
|
61
|
+
try {
|
|
62
|
+
await Linking.openURL(url);
|
|
63
|
+
return true;
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function tryNativePrompt(): Promise<boolean> {
|
|
70
|
+
try {
|
|
71
|
+
if (!(await StoreReview.isAvailableAsync())) return false;
|
|
72
|
+
await StoreReview.requestReview();
|
|
73
|
+
return true;
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function openAndroidStore(urls: StoreUrls): Promise<StoreReviewOutcome> {
|
|
80
|
+
// market:// fails on devices without the Play Store app; https always has a handler.
|
|
81
|
+
if (await tryOpen(urls.androidMarket)) return 'android-market';
|
|
82
|
+
if (await tryOpen(urls.androidWeb)) return 'android-web';
|
|
83
|
+
return 'unavailable';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Ask the user for a store review — the positive branch of the sentiment gate.
|
|
88
|
+
*
|
|
89
|
+
* - iOS with a numeric id → App Store write-review deep link (native prompt if
|
|
90
|
+
* the URL cannot be opened).
|
|
91
|
+
* - iOS without one → `requestReview()` after `isAvailableAsync()`. A URL is
|
|
92
|
+
* never built from a placeholder id.
|
|
93
|
+
* - Android → Play In-App Review via `requestReview()`, falling back to
|
|
94
|
+
* `market://`, then the https listing.
|
|
95
|
+
*
|
|
96
|
+
* Never throws.
|
|
97
|
+
*/
|
|
98
|
+
export async function requestStoreReview(target: StoreTarget): Promise<StoreReviewOutcome> {
|
|
99
|
+
const urls = getStoreUrls(target);
|
|
100
|
+
if (Platform.OS === 'ios') {
|
|
101
|
+
if (urls.iosWriteReview && (await tryOpen(urls.iosWriteReview))) return 'ios-write-review';
|
|
102
|
+
return (await tryNativePrompt()) ? 'native-prompt' : 'unavailable';
|
|
103
|
+
}
|
|
104
|
+
if (await tryNativePrompt()) return 'native-prompt';
|
|
105
|
+
return openAndroidStore(urls);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Open the app's store listing (e.g. a force-update screen).
|
|
110
|
+
*
|
|
111
|
+
* iOS needs a numeric id — without one this resolves false and opens nothing.
|
|
112
|
+
* Android opens `androidUrl` when given (e.g. a backend-supplied listing),
|
|
113
|
+
* else `market://` with the https listing as fallback. Never throws.
|
|
114
|
+
*/
|
|
115
|
+
export async function openStoreListing(target: StoreTarget, androidUrl?: string | null): Promise<boolean> {
|
|
116
|
+
const urls = getStoreUrls(target);
|
|
117
|
+
if (Platform.OS === 'ios') {
|
|
118
|
+
return urls.iosListing ? tryOpen(urls.iosListing) : false;
|
|
119
|
+
}
|
|
120
|
+
if (androidUrl && (await tryOpen(androidUrl))) return true;
|
|
121
|
+
return (await openAndroidStore(urls)) !== 'unavailable';
|
|
122
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Every user-visible string in {@link RateAppModal}. */
|
|
2
|
+
export interface RateAppStrings {
|
|
3
|
+
/** Small line above the title; a function picks a line per prompt reason. */
|
|
4
|
+
thankYou: string | ((reason: string) => string);
|
|
5
|
+
title: string;
|
|
6
|
+
subtitle: string;
|
|
7
|
+
later: string;
|
|
8
|
+
/** Accessibility label of the close button and the backdrop. */
|
|
9
|
+
close: string;
|
|
10
|
+
/** Accessibility label of each star. */
|
|
11
|
+
star: (stars: number) => string;
|
|
12
|
+
/** Subject of the 1–3★ feedback email. */
|
|
13
|
+
feedbackSubject: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** English fallbacks for apps without translations. */
|
|
17
|
+
export function defaultReviewStrings(appName: string): RateAppStrings {
|
|
18
|
+
return {
|
|
19
|
+
thankYou: `Thank you for using ${appName}!`,
|
|
20
|
+
title: `How do you like ${appName}?`,
|
|
21
|
+
subtitle: 'Tap a star to rate your experience.',
|
|
22
|
+
later: 'Maybe later',
|
|
23
|
+
close: 'Close',
|
|
24
|
+
star: (stars) => `Rate ${stars} stars`,
|
|
25
|
+
feedbackSubject: `${appName} feedback`,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Minimal translate signature — an i18next `t` bound to the review namespace fits. */
|
|
30
|
+
export type ReviewTranslate = (key: string, options?: { stars: number }) => string;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build strings from a translate function using the shared Klyftig `review`
|
|
34
|
+
* namespace keys: `thankYou`, `title`, `subtitle`, `later`, `a11yClose`,
|
|
35
|
+
* `a11yStar` (`{{stars}}`), `feedbackSubject`.
|
|
36
|
+
*/
|
|
37
|
+
export function reviewStringsFromT(t: ReviewTranslate): RateAppStrings {
|
|
38
|
+
return {
|
|
39
|
+
thankYou: t('thankYou'),
|
|
40
|
+
title: t('title'),
|
|
41
|
+
subtitle: t('subtitle'),
|
|
42
|
+
later: t('later'),
|
|
43
|
+
close: t('a11yClose'),
|
|
44
|
+
star: (stars) => t('a11yStar', { stars }),
|
|
45
|
+
feedbackSubject: t('feedbackSubject'),
|
|
46
|
+
};
|
|
47
|
+
}
|