@klyftig/mobile-ui 1.6.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 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.6.0",
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
- "react": ">=19.0.0",
24
- "react-native": ">=0.83.0",
27
+ "expo-blur": "*",
25
28
  "expo-glass-effect": "*",
26
29
  "expo-linear-gradient": "*",
27
- "expo-blur": "*",
28
- "@expo/vector-icons": "*",
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
- "react-native-safe-area-context": "*"
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
  }
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
+ }