@sayparlo/react-native 0.0.0-stage → 0.1.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 CHANGED
@@ -1,3 +1,127 @@
1
- # Temporary Holding Version
1
+ # @sayparlo/react-native
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Voice feedback and help requests for Expo and React Native apps, powered by [Parlo](https://sayparlo.com).
4
+
5
+ Your users tap a button, speak (or type), and the feedback lands in your Parlo inbox, transcribed, summarized and sorted.
6
+
7
+ ## Requirements
8
+
9
+ - Expo SDK 53 or newer (or a bare React Native app with Expo modules)
10
+ - An app added in Parlo: **Sites → Add → Mobile app**, to get its key (`pk_…`)
11
+
12
+ ## Install
13
+
14
+ ```sh
15
+ npx expo install @sayparlo/react-native expo-audio expo-application expo-device
16
+ ```
17
+
18
+ Declare the microphone in `app.json`, then rebuild your development build:
19
+
20
+ ```json
21
+ {
22
+ "expo": {
23
+ "plugins": [["expo-audio", { "microphonePermission": "Allow $(PRODUCT_NAME) to use the microphone for voice feedback." }]]
24
+ }
25
+ }
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ Wrap your app once, at the root (`App.tsx`, or `app/_layout.tsx` with Expo Router):
31
+
32
+ ```tsx
33
+ import { ParloProvider } from '@sayparlo/react-native'
34
+
35
+ export default function App() {
36
+ return (
37
+ <ParloProvider siteKey="pk_your_key" apiUrl="https://parlo.threading.site" showButton>
38
+ <Navigation />
39
+ </ParloProvider>
40
+ )
41
+ }
42
+ ```
43
+
44
+ `showButton` displays a floating "Give feedback" button. To open Parlo from your own buttons:
45
+
46
+ ```tsx
47
+ import { useParlo } from '@sayparlo/react-native'
48
+
49
+ function Settings() {
50
+ const parlo = useParlo()
51
+ return (
52
+ <>
53
+ <Button title="Give feedback" onPress={() => parlo.open()} />
54
+ <Button title="Get help" onPress={() => parlo.open({ intent: 'support' })} />
55
+ </>
56
+ )
57
+ }
58
+ ```
59
+
60
+ ## Identify your users
61
+
62
+ ```tsx
63
+ const parlo = useParlo()
64
+
65
+ useEffect(() => {
66
+ parlo.identify(user ? { id: user.id, email: user.email, name: user.name, hash: user.parloHash } : null)
67
+ }, [user])
68
+ ```
69
+
70
+ `hash` proves the identity: compute it **on your server**, as the HMAC-SHA256 of the user id with the secret shown in Parlo (Sites → your app → Identification). Never ship the secret in the app.
71
+
72
+ ```js
73
+ // Node.js, on your server
74
+ const hash = createHmac('sha256', PARLO_IDENTITY_SECRET).update(String(user.id)).digest('hex')
75
+ ```
76
+
77
+ ## Context, screen and version
78
+
79
+ ```tsx
80
+ parlo.setContext({ plan: 'pro', account: 'Acme' }) // 20 keys at most: text, number or boolean
81
+ parlo.setScreen('Checkout') // shown as the "page" of the feedback
82
+ parlo.setVersion('2.4.1') // read from the native build by default
83
+ ```
84
+
85
+ With Expo Router, follow the current route:
86
+
87
+ ```tsx
88
+ const pathname = usePathname()
89
+ useEffect(() => parlo.setScreen(pathname), [pathname])
90
+ ```
91
+
92
+ ## Screenshots
93
+
94
+ Install `react-native-view-shot` and pass a capture function: users can then attach a screenshot of the app to their feedback.
95
+
96
+ ```tsx
97
+ import { captureScreen } from 'react-native-view-shot'
98
+
99
+ <ParloProvider siteKey="pk_your_key" captureScreen={() => captureScreen({ format: 'jpg', quality: 0.7 })}>
100
+ ```
101
+
102
+ ## Props
103
+
104
+ | Prop | Default | |
105
+ | --- | --- | --- |
106
+ | `siteKey` | (required) | Key of the app in Parlo |
107
+ | `apiUrl` | `https://parlo.threading.site` | Address of Parlo |
108
+ | `showButton` | `false` | Floating "Give feedback" button |
109
+ | `buttonPosition` | `bottom-right` | Or `bottom-left` |
110
+ | `user` | | Signed-in user (same as `identify`) |
111
+ | `context` | | Context sent with each feedback |
112
+ | `version` | native version | App version |
113
+ | `locale` | Parlo setting, then device | `fr`, `en`, `es`, `de`, `it`, `pt`, `nl` |
114
+ | `testMode` | `__DEV__` | Marks feedbacks as tests (left out of statistics) |
115
+ | `captureScreen` | | Enables screenshots |
116
+ | `appId` | bundle id | Shown in the "page" of feedbacks |
117
+
118
+ Colour, question and language are set in Parlo (Sites → your app → Appearance) and apply without a new release.
119
+
120
+ ## Privacy
121
+
122
+ The microphone is only requested when the user taps "Speak". No device identifier is collected: feedbacks carry the device model, OS version, app version and what you pass yourself.
123
+
124
+ ## Releasing (maintainers)
125
+
126
+ 1. Bump `version` in `package.json`, merge to `main`.
127
+ 2. Create a GitHub release with the tag `vX.Y.Z`: the **Publish to npm** workflow publishes it with npm trusted publishing (no token).
@@ -0,0 +1,26 @@
1
+ import type { Client } from './api';
2
+ import type { Strings } from './i18n';
3
+ import type { ParloContext, ParloIntent, ParloRemoteConfig, ParloUser } from './types';
4
+ export type SheetSession = {
5
+ user: ParloUser | null;
6
+ context: ParloContext | null;
7
+ version: string | null;
8
+ screen: string | null;
9
+ appId: string;
10
+ device: string;
11
+ locale: string;
12
+ isTest: boolean;
13
+ };
14
+ type Props = {
15
+ visible: boolean;
16
+ onClose: () => void;
17
+ intent: ParloIntent;
18
+ onIntent: (intent: ParloIntent) => void;
19
+ config: ParloRemoteConfig | null;
20
+ strings: Strings;
21
+ client: Client;
22
+ getSession: () => SheetSession;
23
+ captureScreen?: () => Promise<string>;
24
+ };
25
+ export declare function FeedbackSheet({ visible, onClose, intent, onIntent, config, strings: t, client, getSession, captureScreen }: Props): import("react").JSX.Element;
26
+ export {};
@@ -0,0 +1,280 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { AudioModule, RecordingPresets, setAudioModeAsync, useAudioPlayer, useAudioPlayerStatus, useAudioRecorder, useAudioRecorderState, } from 'expo-audio';
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { ActivityIndicator, Animated, Image, KeyboardAvoidingView, Linking, Modal, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from 'react-native';
5
+ const RECORDING = { ...RecordingPresets.HIGH_QUALITY, numberOfChannels: 1, bitRate: 64000 };
6
+ const INK = '#2B2926';
7
+ const MUTED = '#5E5A55';
8
+ const BORDER = '#E2DED8';
9
+ const DANGER = '#C9533F';
10
+ function fmt(ms) {
11
+ const s = Math.max(0, Math.round(ms / 1000));
12
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
13
+ }
14
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
15
+ function file(uri, name, type) {
16
+ return { uri, name, type };
17
+ }
18
+ export function FeedbackSheet({ visible, onClose, intent, onIntent, config, strings: t, client, getSession, captureScreen }) {
19
+ const color = /^#[0-9a-f]{3,8}$/i.test(config?.color ?? '') ? config?.color : INK;
20
+ const maxMs = (config?.maxSeconds ?? 90) * 1000;
21
+ const support = intent === 'support' && config?.support !== false;
22
+ const recorder = useAudioRecorder(RECORDING);
23
+ const recorderState = useAudioRecorderState(recorder, 250);
24
+ const player = useAudioPlayer(null);
25
+ const playerStatus = useAudioPlayerStatus(player);
26
+ const [view, setView] = useState('intro');
27
+ const [audio, setAudio] = useState(null);
28
+ const [text, setText] = useState('');
29
+ const [email, setEmail] = useState('');
30
+ const [shot, setShot] = useState(null);
31
+ const [error, setError] = useState('');
32
+ const [failures, setFailures] = useState(0);
33
+ const [micBlocked, setMicBlocked] = useState(false);
34
+ const [hidden, setHidden] = useState(false);
35
+ const [trackingUrl, setTrackingUrl] = useState(null);
36
+ const startedAt = useRef(0);
37
+ const retryView = useRef('text');
38
+ const pulse = useRef(new Animated.Value(1)).current;
39
+ useEffect(() => {
40
+ if (visible && view === 'thanks') {
41
+ setView('intro');
42
+ setTrackingUrl(null);
43
+ setError('');
44
+ }
45
+ }, [visible]);
46
+ useEffect(() => {
47
+ if (view === 'recording' && recorderState.durationMillis >= maxMs)
48
+ void stopRecording(false);
49
+ }, [recorderState.durationMillis, view]);
50
+ useEffect(() => {
51
+ if (view !== 'recording')
52
+ return;
53
+ const loop = Animated.loop(Animated.sequence([
54
+ Animated.timing(pulse, { toValue: 0.3, duration: 500, useNativeDriver: true }),
55
+ Animated.timing(pulse, { toValue: 1, duration: 500, useNativeDriver: true }),
56
+ ]));
57
+ loop.start();
58
+ return () => loop.stop();
59
+ }, [view, pulse]);
60
+ async function startRecording() {
61
+ setError('');
62
+ try {
63
+ const permission = await AudioModule.requestRecordingPermissionsAsync();
64
+ if (!permission.granted) {
65
+ client.event('mic_denied');
66
+ setMicBlocked(true);
67
+ setView('text');
68
+ return;
69
+ }
70
+ await setAudioModeAsync({ allowsRecording: true, playsInSilentMode: true });
71
+ await recorder.prepareToRecordAsync();
72
+ recorder.record();
73
+ startedAt.current = Date.now();
74
+ setView('recording');
75
+ }
76
+ catch {
77
+ setMicBlocked(true);
78
+ setView('text');
79
+ }
80
+ }
81
+ async function stopRecording(discard) {
82
+ const durationMs = Date.now() - startedAt.current;
83
+ try {
84
+ await recorder.stop();
85
+ }
86
+ catch {
87
+ }
88
+ await setAudioModeAsync({ allowsRecording: false, playsInSilentMode: true }).catch(() => { });
89
+ if (discard) {
90
+ setView('intro');
91
+ return;
92
+ }
93
+ const uri = recorder.uri;
94
+ if (!uri || durationMs < 1200) {
95
+ setError(t.tooShort);
96
+ setView('intro');
97
+ return;
98
+ }
99
+ setAudio({ uri, durationMs: Math.min(durationMs, maxMs + 2000) });
100
+ player.replace({ uri });
101
+ setView('review');
102
+ }
103
+ function togglePlay() {
104
+ if (playerStatus.playing) {
105
+ player.pause();
106
+ return;
107
+ }
108
+ if (playerStatus.duration > 0 && playerStatus.currentTime >= playerStatus.duration - 0.1)
109
+ void player.seekTo(0);
110
+ player.play();
111
+ }
112
+ async function attachScreenshot() {
113
+ if (!captureScreen)
114
+ return;
115
+ setError('');
116
+ setHidden(true);
117
+ await wait(500);
118
+ try {
119
+ setShot(await captureScreen());
120
+ }
121
+ catch {
122
+ setError(t.shotFailed);
123
+ }
124
+ finally {
125
+ setHidden(false);
126
+ }
127
+ }
128
+ async function send(fromView) {
129
+ setError('');
130
+ const audioMode = fromView === 'review' && audio !== null;
131
+ const s = getSession();
132
+ const form = new FormData();
133
+ if (audioMode && audio) {
134
+ form.append('audio', file(audio.uri, 'retour.m4a', 'audio/mp4'));
135
+ form.append('durationMs', String(Math.min(audio.durationMs, 95000)));
136
+ }
137
+ else {
138
+ const body = text.trim();
139
+ if (!body) {
140
+ setError(t.emptyText);
141
+ return;
142
+ }
143
+ form.append('text', body.slice(0, 4000));
144
+ }
145
+ if (shot && config?.screenshots !== false)
146
+ form.append('screenshot', file(shot, 'capture.jpg', 'image/jpeg'));
147
+ form.append('pageUrl', `app://${s.appId}${s.screen ? `/${encodeURIComponent(s.screen)}` : ''}`);
148
+ form.append('locale', s.locale);
149
+ if (s.user?.id) {
150
+ form.append('endUserId', String(s.user.id));
151
+ if (s.user.name)
152
+ form.append('endUserName', s.user.name);
153
+ if (s.user.hash)
154
+ form.append('userHash', s.user.hash);
155
+ }
156
+ if (s.user?.email)
157
+ form.append('userEmail', s.user.email);
158
+ if (email.trim())
159
+ form.append('contactEmail', email.trim());
160
+ if (s.context)
161
+ form.append('context', JSON.stringify(s.context).slice(0, 4000));
162
+ if (s.version)
163
+ form.append('appVersion', s.version.slice(0, 40));
164
+ form.append('device', s.device);
165
+ if (s.isTest)
166
+ form.append('isTest', '1');
167
+ if (support)
168
+ form.append('intent', 'support');
169
+ retryView.current = audioMode ? 'review' : 'text';
170
+ setView('sending');
171
+ const result = await client.submit(form);
172
+ if (result.ok) {
173
+ player.pause();
174
+ setAudio(null);
175
+ setText('');
176
+ setShot(null);
177
+ setFailures(0);
178
+ setTrackingUrl(result.trackingUrl);
179
+ setView('thanks');
180
+ return;
181
+ }
182
+ setFailures((n) => n + 1);
183
+ setError(result.message ?? t.failed);
184
+ setView('error');
185
+ }
186
+ function close() {
187
+ if (view === 'recording')
188
+ void stopRecording(true);
189
+ player.pause();
190
+ onClose();
191
+ }
192
+ const question = support ? t.helpQuestion : config && !config.questionIsDefault && config.question ? config.question : t.defaultQuestion;
193
+ const knowsEmail = Boolean(getSession().user?.email);
194
+ const segmented = config?.support === false ? null : (_jsx(View, { style: styles.seg, children: ['feedback', 'support'].map((value) => {
195
+ const active = (value === 'support') === support;
196
+ return (_jsx(Pressable, { onPress: () => onIntent(value), style: [styles.segItem, active && styles.segActive], accessibilityRole: "button", accessibilityState: { selected: active }, children: _jsx(Text, { style: [styles.segText, active && styles.segTextActive], children: value === 'support' ? t.chooseHelp : t.chooseFeedback }) }, value));
197
+ }) }));
198
+ const primary = (label, onPress, flex) => (_jsx(Pressable, { onPress: onPress, style: [styles.primary, { backgroundColor: color }, flex && styles.flex], accessibilityRole: "button", children: _jsx(Text, { style: styles.primaryText, children: label }) }));
199
+ const secondary = (label, onPress) => (_jsx(Pressable, { onPress: onPress, style: [styles.secondary, styles.flex], accessibilityRole: "button", children: _jsx(Text, { style: styles.secondaryText, children: label }) }));
200
+ const link = (label, onPress) => (_jsx(Pressable, { onPress: onPress, style: styles.linkWrap, accessibilityRole: "button", children: _jsx(Text, { style: styles.link, children: label }) }));
201
+ const shotBlock = config?.screenshots === false || !captureScreen ? null : shot ? (_jsxs(View, { style: styles.shot, children: [_jsx(Image, { source: { uri: shot }, style: styles.shotImage }), _jsx(Text, { style: styles.note, children: t.shotLabel }), _jsx(Pressable, { onPress: () => setShot(null), style: styles.shotRemove, children: _jsx(Text, { style: styles.danger, children: t.removeShot }) })] })) : (_jsx(Pressable, { onPress: () => void attachScreenshot(), style: styles.attach, accessibilityRole: "button", children: _jsx(Text, { style: styles.attachText, children: t.attachScreen }) }));
202
+ const emailField = knowsEmail ? null : (_jsxs(View, { style: styles.field, children: [_jsx(Text, { style: styles.label, children: support ? t.helpEmail : t.email }), _jsx(TextInput, { value: email, onChangeText: setEmail, keyboardType: "email-address", autoCapitalize: "none", autoComplete: "email", autoCorrect: false, maxLength: 254, style: styles.input })] }));
203
+ const errorText = error ? (_jsx(Text, { style: [styles.note, styles.danger], accessibilityRole: "alert", children: error })) : null;
204
+ let body = null;
205
+ if (view === 'intro') {
206
+ body = (_jsxs(_Fragment, { children: [segmented, _jsxs(Text, { style: styles.consent, children: [t.consent, ' ', _jsx(Text, { style: styles.consentLink, onPress: () => void Linking.openURL(client.url('/confidentialite')), children: t.privacy })] }), primary(`🎙 ${t.talk}`, () => void startRecording()), errorText, link(t.write, () => setView('text'))] }));
207
+ }
208
+ else if (view === 'recording') {
209
+ body = (_jsxs(_Fragment, { children: [_jsxs(View, { style: styles.recRow, children: [_jsx(Animated.View, { style: [styles.dot, { opacity: pulse }] }), _jsxs(Text, { style: styles.time, children: [fmt(recorderState.durationMillis), " / ", fmt(maxMs)] })] }), primary(t.stop, () => void stopRecording(false))] }));
210
+ }
211
+ else if (view === 'review') {
212
+ body = (_jsxs(_Fragment, { children: [_jsxs(Pressable, { onPress: togglePlay, style: styles.player, accessibilityRole: "button", children: [_jsx(Text, { style: [styles.playIcon, { color }], children: playerStatus.playing ? '❚❚' : '▶' }), _jsx(Text, { style: styles.playerText, children: playerStatus.playing ? t.pause : t.play }), _jsx(Text, { style: styles.time, children: fmt(audio?.durationMs ?? 0) })] }), shotBlock, emailField, errorText, _jsxs(View, { style: styles.row, children: [secondary(t.redo, () => {
213
+ setAudio(null);
214
+ void startRecording();
215
+ }), primary(t.send, () => void send('review'), true)] })] }));
216
+ }
217
+ else if (view === 'text') {
218
+ body = (_jsxs(_Fragment, { children: [segmented, micBlocked ? _jsx(Text, { style: styles.note, children: t.micUnavailable }) : null, _jsx(TextInput, { value: text, onChangeText: setText, placeholder: support ? t.helpPlaceholder : t.textPlaceholder, placeholderTextColor: "#8F8A84", multiline: true, maxLength: 4000, style: [styles.input, styles.textarea], textAlignVertical: "top" }), shotBlock, emailField, errorText, primary(t.send, () => void send('text')), micBlocked ? null : link(t.backToVoice, () => void startRecording())] }));
219
+ }
220
+ else if (view === 'sending') {
221
+ body = (_jsxs(View, { style: styles.center, children: [_jsx(ActivityIndicator, { color: color }), _jsx(Text, { style: styles.note, children: t.sending })] }));
222
+ }
223
+ else if (view === 'error') {
224
+ body = (_jsxs(_Fragment, { children: [errorText, primary(t.retry, () => void send(retryView.current)), failures >= 3 && audio ? link(t.switchToText, () => setView('text')) : null] }));
225
+ }
226
+ else if (view === 'thanks') {
227
+ body = (_jsxs(View, { style: styles.center, children: [_jsx(Text, { style: styles.big, children: support ? t.helpThanks : t.thanks }), _jsx(Text, { style: styles.note, children: support ? t.helpThanksSub : t.thanksSub }), trackingUrl && /^https:\/\//.test(trackingUrl) ? link(`${t.track} ↗`, () => void Linking.openURL(trackingUrl)) : null] }));
228
+ }
229
+ return (_jsx(Modal, { visible: visible && !hidden, transparent: true, animationType: "slide", onRequestClose: close, statusBarTranslucent: true, children: _jsxs(KeyboardAvoidingView, { behavior: Platform.OS === 'ios' ? 'padding' : undefined, style: styles.flex, children: [_jsx(Pressable, { style: styles.backdrop, onPress: close, accessibilityLabel: t.close }), _jsxs(View, { style: styles.sheet, children: [_jsxs(View, { style: styles.header, children: [_jsx(Text, { style: styles.question, children: view === 'thanks' ? '' : question }), _jsx(Pressable, { onPress: close, hitSlop: 12, accessibilityRole: "button", accessibilityLabel: t.close, children: _jsx(Text, { style: styles.closeIcon, children: "\u00D7" }) })] }), _jsxs(ScrollView, { keyboardShouldPersistTaps: "handled", bounces: false, children: [body, config?.branding ? (_jsx(Text, { style: styles.brand, onPress: () => void Linking.openURL(client.url('/decouvrir?ref=app')), children: t.brand })) : null] })] })] }) }));
230
+ }
231
+ const styles = StyleSheet.create({
232
+ flex: { flex: 1 },
233
+ backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.35)' },
234
+ sheet: {
235
+ backgroundColor: '#fff',
236
+ borderTopLeftRadius: 18,
237
+ borderTopRightRadius: 18,
238
+ paddingHorizontal: 20,
239
+ paddingTop: 18,
240
+ paddingBottom: 34,
241
+ maxHeight: '88%',
242
+ },
243
+ header: { flexDirection: 'row', alignItems: 'flex-start', gap: 12, marginBottom: 6 },
244
+ question: { flex: 1, fontSize: 17, fontWeight: '600', color: INK, lineHeight: 23 },
245
+ closeIcon: { fontSize: 26, lineHeight: 26, color: '#8F8A84' },
246
+ seg: { flexDirection: 'row', gap: 4, padding: 3, backgroundColor: '#F4F2EE', borderRadius: 10, marginTop: 10 },
247
+ segItem: { flex: 1, paddingVertical: 8, borderRadius: 8, alignItems: 'center' },
248
+ segActive: { backgroundColor: '#fff', shadowColor: '#000', shadowOpacity: 0.08, shadowRadius: 3, shadowOffset: { width: 0, height: 1 }, elevation: 1 },
249
+ segText: { fontSize: 13.5, fontWeight: '500', color: MUTED },
250
+ segTextActive: { color: INK },
251
+ consent: { fontSize: 12.5, color: MUTED, marginVertical: 14, lineHeight: 18 },
252
+ consentLink: { textDecorationLine: 'underline' },
253
+ primary: { borderRadius: 11, paddingVertical: 14, paddingHorizontal: 16, alignItems: 'center', marginTop: 12 },
254
+ primaryText: { color: '#fff', fontSize: 15.5, fontWeight: '600' },
255
+ secondary: { borderRadius: 11, paddingVertical: 13, alignItems: 'center', marginTop: 12, borderWidth: 1, borderColor: BORDER },
256
+ secondaryText: { color: INK, fontSize: 15, fontWeight: '500' },
257
+ row: { flexDirection: 'row', gap: 8 },
258
+ linkWrap: { alignSelf: 'center', marginTop: 12, padding: 4 },
259
+ link: { color: MUTED, fontSize: 14, textDecorationLine: 'underline' },
260
+ recRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginVertical: 22, justifyContent: 'center' },
261
+ dot: { width: 11, height: 11, borderRadius: 6, backgroundColor: DANGER },
262
+ time: { fontSize: 14, color: '#8F8A84', fontVariant: ['tabular-nums'] },
263
+ player: { flexDirection: 'row', alignItems: 'center', gap: 12, marginTop: 14, padding: 14, borderWidth: 1, borderColor: BORDER, borderRadius: 11 },
264
+ playIcon: { fontSize: 18, width: 22, textAlign: 'center' },
265
+ playerText: { flex: 1, fontSize: 15, color: INK },
266
+ field: { marginTop: 14 },
267
+ label: { fontSize: 12.5, color: MUTED, marginBottom: 6 },
268
+ input: { borderWidth: 1, borderColor: BORDER, borderRadius: 9, paddingHorizontal: 12, paddingVertical: 10, fontSize: 15, color: INK },
269
+ textarea: { minHeight: 120, marginTop: 14 },
270
+ note: { fontSize: 13, color: MUTED, marginTop: 10, lineHeight: 18 },
271
+ danger: { color: DANGER },
272
+ attach: { marginTop: 12, borderWidth: 1, borderStyle: 'dashed', borderColor: '#D6D1CA', borderRadius: 9, paddingVertical: 10, alignItems: 'center' },
273
+ attachText: { fontSize: 13.5, color: MUTED },
274
+ shot: { flexDirection: 'row', alignItems: 'center', gap: 10, marginTop: 12 },
275
+ shotImage: { width: 44, height: 72, borderRadius: 6, borderWidth: 1, borderColor: '#ECE9E4' },
276
+ shotRemove: { marginLeft: 'auto', padding: 4 },
277
+ center: { alignItems: 'center', paddingVertical: 20, gap: 4 },
278
+ big: { fontSize: 17, fontWeight: '600', color: INK, textAlign: 'center' },
279
+ brand: { marginTop: 16, textAlign: 'center', fontSize: 11.5, color: '#8F8A84' },
280
+ });
@@ -0,0 +1,18 @@
1
+ import { type ReactNode } from 'react';
2
+ import type { ParloApi, ParloContext, ParloUser } from './types';
3
+ export type ParloProviderProps = {
4
+ siteKey: string;
5
+ apiUrl?: string;
6
+ user?: ParloUser | null;
7
+ context?: ParloContext | null;
8
+ version?: string;
9
+ locale?: string;
10
+ showButton?: boolean;
11
+ buttonPosition?: 'bottom-right' | 'bottom-left';
12
+ testMode?: boolean;
13
+ captureScreen?: () => Promise<string>;
14
+ appId?: string;
15
+ children: ReactNode;
16
+ };
17
+ export declare function useParlo(): ParloApi;
18
+ export declare function ParloProvider({ siteKey, apiUrl, user, context, version, locale, showButton, buttonPosition, testMode, captureScreen, appId, children, }: ParloProviderProps): import("react").JSX.Element;
@@ -0,0 +1,125 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as Application from 'expo-application';
3
+ import * as Device from 'expo-device';
4
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
5
+ import { Platform, Pressable, StyleSheet, Text } from 'react-native';
6
+ import { Client, DEFAULT_API_URL } from './api';
7
+ import { FeedbackSheet } from './FeedbackSheet';
8
+ import { pickStrings } from './i18n';
9
+ const ParloReactContext = createContext(null);
10
+ export function useParlo() {
11
+ const value = useContext(ParloReactContext);
12
+ if (!value)
13
+ throw new Error('useParlo() must be used inside <ParloProvider>.');
14
+ return value;
15
+ }
16
+ function deviceLabel() {
17
+ const kind = Device.deviceType === Device.DeviceType.TABLET ? 'Tablette' : 'Mobile';
18
+ const os = Platform.OS === 'ios' ? 'iOS' : Platform.OS === 'android' ? 'Android' : Platform.OS;
19
+ return [kind, `${os}${Device.osVersion ? ` ${Device.osVersion}` : ''}`, Device.modelName]
20
+ .filter(Boolean)
21
+ .join(' · ')
22
+ .slice(0, 120);
23
+ }
24
+ function deviceLocale() {
25
+ try {
26
+ return Intl.DateTimeFormat().resolvedOptions().locale || '';
27
+ }
28
+ catch {
29
+ return '';
30
+ }
31
+ }
32
+ export function ParloProvider({ siteKey, apiUrl = DEFAULT_API_URL, user, context, version, locale, showButton = false, buttonPosition = 'bottom-right', testMode, captureScreen, appId, children, }) {
33
+ const client = useMemo(() => new Client(apiUrl, siteKey), [apiUrl, siteKey]);
34
+ const [config, setConfig] = useState(null);
35
+ const [visible, setVisible] = useState(false);
36
+ const [buttonHidden, setButtonHidden] = useState(false);
37
+ const [intent, setIntent] = useState('feedback');
38
+ const opened = useRef(false);
39
+ const userRef = useRef(user ?? null);
40
+ const contextRef = useRef(context ?? null);
41
+ const versionRef = useRef(version ?? Application.nativeApplicationVersion ?? null);
42
+ const screenRef = useRef(null);
43
+ useEffect(() => {
44
+ if (user !== undefined)
45
+ userRef.current = user;
46
+ }, [user]);
47
+ useEffect(() => {
48
+ if (context !== undefined)
49
+ contextRef.current = context;
50
+ }, [context]);
51
+ useEffect(() => {
52
+ if (version !== undefined)
53
+ versionRef.current = version;
54
+ }, [version]);
55
+ useEffect(() => {
56
+ let cancelled = false;
57
+ client.ping();
58
+ void client.config().then((c) => {
59
+ if (!cancelled)
60
+ setConfig(c);
61
+ });
62
+ return () => {
63
+ cancelled = true;
64
+ };
65
+ }, [client]);
66
+ const open = useCallback((options) => {
67
+ if (options?.intent)
68
+ setIntent(options.intent);
69
+ if (!opened.current) {
70
+ opened.current = true;
71
+ client.event('opened');
72
+ }
73
+ setVisible(true);
74
+ }, [client]);
75
+ const api = useMemo(() => ({
76
+ open,
77
+ close: () => setVisible(false),
78
+ identify: (next) => {
79
+ userRef.current = next;
80
+ },
81
+ logout: () => {
82
+ userRef.current = null;
83
+ },
84
+ setContext: (next) => {
85
+ contextRef.current = next;
86
+ },
87
+ setVersion: (next) => {
88
+ versionRef.current = next;
89
+ },
90
+ setScreen: (next) => {
91
+ screenRef.current = next;
92
+ },
93
+ ready: config !== null,
94
+ }), [open, config]);
95
+ const getSession = useCallback(() => ({
96
+ user: userRef.current,
97
+ context: contextRef.current,
98
+ version: versionRef.current,
99
+ screen: screenRef.current,
100
+ appId: (appId || Application.applicationId || 'app').replace(/[^a-zA-Z0-9._-]/g, ''),
101
+ device: deviceLabel(),
102
+ locale: locale || deviceLocale(),
103
+ isTest: testMode ?? (typeof __DEV__ !== 'undefined' && __DEV__ === true),
104
+ }), [appId, locale, testMode]);
105
+ const strings = pickStrings(locale, config?.locale);
106
+ const color = /^#[0-9a-f]{3,8}$/i.test(config?.color ?? '') ? config?.color : '#2B2926';
107
+ return (_jsxs(ParloReactContext.Provider, { value: api, children: [children, showButton && !buttonHidden && !visible ? (_jsx(Pressable, { onPress: () => open(), onLongPress: () => setButtonHidden(true), style: [styles.button, buttonPosition === 'bottom-left' ? styles.left : styles.right, { backgroundColor: color }], accessibilityRole: "button", accessibilityLabel: strings.button, children: _jsxs(Text, { style: styles.buttonText, children: ["\uD83C\uDF99 ", strings.button] }) })) : null, _jsx(FeedbackSheet, { visible: visible, onClose: () => setVisible(false), intent: intent, onIntent: setIntent, config: config, strings: strings, client: client, getSession: getSession, captureScreen: captureScreen })] }));
108
+ }
109
+ const styles = StyleSheet.create({
110
+ button: {
111
+ position: 'absolute',
112
+ bottom: 34,
113
+ borderRadius: 999,
114
+ paddingVertical: 12,
115
+ paddingHorizontal: 16,
116
+ shadowColor: '#000',
117
+ shadowOpacity: 0.18,
118
+ shadowRadius: 10,
119
+ shadowOffset: { width: 0, height: 4 },
120
+ elevation: 6,
121
+ },
122
+ right: { right: 18 },
123
+ left: { left: 18 },
124
+ buttonText: { color: '#fff', fontSize: 14.5, fontWeight: '600' },
125
+ });
package/lib/api.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ import type { ParloRemoteConfig } from './types';
2
+ export declare const DEFAULT_API_URL = "https://parlo.threading.site";
3
+ export type SubmitResult = {
4
+ ok: true;
5
+ trackingUrl: string | null;
6
+ } | {
7
+ ok: false;
8
+ message?: string;
9
+ };
10
+ export declare class Client {
11
+ private readonly siteKey;
12
+ private readonly base;
13
+ constructor(apiUrl: string, siteKey: string);
14
+ url(path: string): string;
15
+ private endpoint;
16
+ config(): Promise<ParloRemoteConfig | null>;
17
+ ping(): void;
18
+ event(type: 'opened' | 'mic_denied'): void;
19
+ private post;
20
+ submit(form: FormData): Promise<SubmitResult>;
21
+ }
package/lib/api.js ADDED
@@ -0,0 +1,50 @@
1
+ export const DEFAULT_API_URL = 'https://parlo.threading.site';
2
+ export class Client {
3
+ constructor(apiUrl, siteKey) {
4
+ this.siteKey = siteKey;
5
+ this.base = apiUrl.replace(/\/+$/, '');
6
+ }
7
+ url(path) {
8
+ return `${this.base}${path}`;
9
+ }
10
+ endpoint(path) {
11
+ return `${this.base}/api/v1/widget/${encodeURIComponent(this.siteKey)}${path}`;
12
+ }
13
+ async config() {
14
+ try {
15
+ const response = await fetch(this.endpoint('/config'));
16
+ if (!response.ok)
17
+ return null;
18
+ return (await response.json());
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ ping() {
25
+ this.post('/ping', {});
26
+ }
27
+ event(type) {
28
+ this.post('/event', { type });
29
+ }
30
+ post(path, body) {
31
+ fetch(this.endpoint(path), {
32
+ method: 'POST',
33
+ headers: { 'content-type': 'text/plain' },
34
+ body: JSON.stringify(body),
35
+ }).catch(() => { });
36
+ }
37
+ async submit(form) {
38
+ try {
39
+ const response = await fetch(this.endpoint('/feedback'), { method: 'POST', body: form });
40
+ const data = (await response.json().catch(() => null));
41
+ if (response.status === 202) {
42
+ return { ok: true, trackingUrl: typeof data?.trackingUrl === 'string' ? data.trackingUrl : null };
43
+ }
44
+ return { ok: false, message: typeof data?.message === 'string' ? data.message : undefined };
45
+ }
46
+ catch {
47
+ return { ok: false };
48
+ }
49
+ }
50
+ }
package/lib/i18n.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ export type Strings = {
2
+ button: string;
3
+ close: string;
4
+ chooseFeedback: string;
5
+ chooseHelp: string;
6
+ consent: string;
7
+ privacy: string;
8
+ talk: string;
9
+ write: string;
10
+ backToVoice: string;
11
+ stop: string;
12
+ play: string;
13
+ pause: string;
14
+ redo: string;
15
+ send: string;
16
+ email: string;
17
+ helpEmail: string;
18
+ attachScreen: string;
19
+ removeShot: string;
20
+ shotLabel: string;
21
+ shotFailed: string;
22
+ textPlaceholder: string;
23
+ helpPlaceholder: string;
24
+ micUnavailable: string;
25
+ sending: string;
26
+ thanks: string;
27
+ thanksSub: string;
28
+ helpThanks: string;
29
+ helpThanksSub: string;
30
+ failed: string;
31
+ retry: string;
32
+ switchToText: string;
33
+ tooShort: string;
34
+ emptyText: string;
35
+ track: string;
36
+ brand: string;
37
+ defaultQuestion: string;
38
+ helpQuestion: string;
39
+ };
40
+ export declare const STRINGS: Record<'fr' | 'en' | 'es' | 'de' | 'it' | 'pt' | 'nl', Strings>;
41
+ export declare function pickStrings(forced: string | undefined, remote: string | undefined): Strings;
package/lib/i18n.js ADDED
@@ -0,0 +1,287 @@
1
+ export const STRINGS = {
2
+ fr: {
3
+ button: 'Donner mon avis',
4
+ close: 'Fermer',
5
+ chooseFeedback: 'Donner un avis',
6
+ chooseHelp: "Demander de l'aide",
7
+ consent: "Votre voix est enregistrée puis transcrite pour aider l'équipe. Elle ne sert jamais à vous identifier.",
8
+ privacy: 'Confidentialité',
9
+ talk: 'Parler',
10
+ write: 'Je préfère écrire',
11
+ backToVoice: 'Revenir à la voix',
12
+ stop: 'Terminer',
13
+ play: 'Réécouter',
14
+ pause: 'Pause',
15
+ redo: 'Recommencer',
16
+ send: 'Envoyer',
17
+ email: "Votre e-mail (facultatif, pour qu'on puisse vous répondre)",
18
+ helpEmail: 'Votre e-mail, pour recevoir notre réponse',
19
+ attachScreen: "Joindre une capture de l'écran",
20
+ removeShot: 'Retirer',
21
+ shotLabel: 'Capture jointe',
22
+ shotFailed: "La capture n'a pas pu être faite.",
23
+ textPlaceholder: 'Dites-nous ce que vous pensez…',
24
+ helpPlaceholder: 'Expliquez ce qui vous bloque…',
25
+ micUnavailable: "Le micro n'est pas disponible : vous pouvez nous écrire à la place.",
26
+ sending: 'Envoi…',
27
+ thanks: 'Merci ! Votre retour a bien été envoyé.',
28
+ thanksSub: "L'équipe le lira très vite.",
29
+ helpThanks: 'Merci ! Votre demande a bien été envoyée.',
30
+ helpThanksSub: 'Nous vous répondrons dès que possible.',
31
+ failed: "L'envoi a échoué. Votre enregistrement est conservé.",
32
+ retry: 'Réessayer',
33
+ switchToText: "Passer à l'écrit",
34
+ tooShort: 'Enregistrement trop court, réessayez.',
35
+ emptyText: 'Écrivez quelques mots avant d’envoyer.',
36
+ track: 'Suivre mon retour',
37
+ brand: 'Propulsé par Parlo',
38
+ defaultQuestion: 'Une remarque, une idée ? Dites-le-nous à voix haute.',
39
+ helpQuestion: 'Décrivez votre problème, nous allons vous aider.',
40
+ },
41
+ en: {
42
+ button: 'Give feedback',
43
+ close: 'Close',
44
+ chooseFeedback: 'Give feedback',
45
+ chooseHelp: 'Get help',
46
+ consent: 'Your voice is recorded and transcribed to help the team. It is never used to identify you.',
47
+ privacy: 'Privacy',
48
+ talk: 'Speak',
49
+ write: "I'd rather type",
50
+ backToVoice: 'Back to voice',
51
+ stop: 'Done',
52
+ play: 'Listen',
53
+ pause: 'Pause',
54
+ redo: 'Start over',
55
+ send: 'Send',
56
+ email: 'Your email (optional, so we can reply)',
57
+ helpEmail: 'Your email, to get our answer',
58
+ attachScreen: 'Attach a screenshot',
59
+ removeShot: 'Remove',
60
+ shotLabel: 'Screenshot attached',
61
+ shotFailed: 'The screenshot could not be taken.',
62
+ textPlaceholder: 'Tell us what you think…',
63
+ helpPlaceholder: 'Explain what is blocking you…',
64
+ micUnavailable: 'The microphone is not available: you can type instead.',
65
+ sending: 'Sending…',
66
+ thanks: 'Thank you! Your feedback was sent.',
67
+ thanksSub: 'The team will read it soon.',
68
+ helpThanks: 'Thank you! Your request was sent.',
69
+ helpThanksSub: 'We will get back to you as soon as possible.',
70
+ failed: 'Sending failed. Your recording is kept.',
71
+ retry: 'Try again',
72
+ switchToText: 'Type instead',
73
+ tooShort: 'Recording too short, try again.',
74
+ emptyText: 'Write a few words before sending.',
75
+ track: 'Follow my feedback',
76
+ brand: 'Powered by Parlo',
77
+ defaultQuestion: 'A thought or an idea? Tell us out loud.',
78
+ helpQuestion: 'Describe your problem and we will help you.',
79
+ },
80
+ es: {
81
+ button: 'Dar mi opinión',
82
+ close: 'Cerrar',
83
+ chooseFeedback: 'Dar mi opinión',
84
+ chooseHelp: 'Pedir ayuda',
85
+ consent: 'Tu voz se graba y se transcribe para ayudar al equipo. Nunca se usa para identificarte.',
86
+ privacy: 'Privacidad',
87
+ talk: 'Hablar',
88
+ write: 'Prefiero escribir',
89
+ backToVoice: 'Volver a la voz',
90
+ stop: 'Terminar',
91
+ play: 'Escuchar',
92
+ pause: 'Pausa',
93
+ redo: 'Empezar de nuevo',
94
+ send: 'Enviar',
95
+ email: 'Tu email (opcional, para poder responderte)',
96
+ helpEmail: 'Tu email, para recibir nuestra respuesta',
97
+ attachScreen: 'Adjuntar una captura',
98
+ removeShot: 'Quitar',
99
+ shotLabel: 'Captura adjunta',
100
+ shotFailed: 'No se pudo hacer la captura.',
101
+ textPlaceholder: 'Cuéntanos qué piensas…',
102
+ helpPlaceholder: 'Explica qué te bloquea…',
103
+ micUnavailable: 'El micrófono no está disponible: puedes escribirnos.',
104
+ sending: 'Enviando…',
105
+ thanks: '¡Gracias! Tu opinión se ha enviado.',
106
+ thanksSub: 'El equipo la leerá muy pronto.',
107
+ helpThanks: '¡Gracias! Tu solicitud se ha enviado.',
108
+ helpThanksSub: 'Te responderemos lo antes posible.',
109
+ failed: 'El envío ha fallado. Tu grabación se ha conservado.',
110
+ retry: 'Reintentar',
111
+ switchToText: 'Pasar a escribir',
112
+ tooShort: 'Grabación demasiado corta, inténtalo de nuevo.',
113
+ emptyText: 'Escribe unas palabras antes de enviar.',
114
+ track: 'Seguir mi opinión',
115
+ brand: 'Con la tecnología de Parlo',
116
+ defaultQuestion: '¿Un comentario, una idea? Cuéntanoslo en voz alta.',
117
+ helpQuestion: 'Describe tu problema y te ayudaremos.',
118
+ },
119
+ de: {
120
+ button: 'Feedback geben',
121
+ close: 'Schließen',
122
+ chooseFeedback: 'Feedback geben',
123
+ chooseHelp: 'Hilfe erhalten',
124
+ consent: 'Deine Stimme wird aufgenommen und transkribiert, um dem Team zu helfen. Sie wird nie verwendet, um dich zu identifizieren.',
125
+ privacy: 'Datenschutz',
126
+ talk: 'Sprechen',
127
+ write: 'Lieber schreiben',
128
+ backToVoice: 'Zurück zur Sprache',
129
+ stop: 'Fertig',
130
+ play: 'Anhören',
131
+ pause: 'Pause',
132
+ redo: 'Neu aufnehmen',
133
+ send: 'Senden',
134
+ email: 'Deine E-Mail (optional, damit wir antworten können)',
135
+ helpEmail: 'Deine E-Mail, um unsere Antwort zu erhalten',
136
+ attachScreen: 'Screenshot anhängen',
137
+ removeShot: 'Entfernen',
138
+ shotLabel: 'Screenshot angehängt',
139
+ shotFailed: 'Der Screenshot konnte nicht erstellt werden.',
140
+ textPlaceholder: 'Sag uns, was du denkst…',
141
+ helpPlaceholder: 'Erklär, was dich blockiert…',
142
+ micUnavailable: 'Das Mikrofon ist nicht verfügbar: Du kannst uns stattdessen schreiben.',
143
+ sending: 'Wird gesendet…',
144
+ thanks: 'Danke! Dein Feedback wurde gesendet.',
145
+ thanksSub: 'Das Team liest es bald.',
146
+ helpThanks: 'Danke! Deine Anfrage wurde gesendet.',
147
+ helpThanksSub: 'Wir melden uns so schnell wie möglich.',
148
+ failed: 'Senden fehlgeschlagen. Deine Aufnahme bleibt erhalten.',
149
+ retry: 'Erneut versuchen',
150
+ switchToText: 'Lieber schreiben',
151
+ tooShort: 'Aufnahme zu kurz, bitte erneut versuchen.',
152
+ emptyText: 'Schreib ein paar Worte, bevor du sendest.',
153
+ track: 'Mein Feedback verfolgen',
154
+ brand: 'Bereitgestellt von Parlo',
155
+ defaultQuestion: 'Eine Anmerkung, eine Idee? Sag es uns laut.',
156
+ helpQuestion: 'Beschreib dein Problem, wir helfen dir.',
157
+ },
158
+ it: {
159
+ button: 'Dai il tuo parere',
160
+ close: 'Chiudi',
161
+ chooseFeedback: 'Dai il tuo parere',
162
+ chooseHelp: 'Chiedi aiuto',
163
+ consent: 'La tua voce viene registrata e trascritta per aiutare il team. Non viene mai usata per identificarti.',
164
+ privacy: 'Privacy',
165
+ talk: 'Parla',
166
+ write: 'Preferisco scrivere',
167
+ backToVoice: 'Torna alla voce',
168
+ stop: 'Fine',
169
+ play: 'Ascolta',
170
+ pause: 'Pausa',
171
+ redo: 'Ricomincia',
172
+ send: 'Invia',
173
+ email: 'La tua email (facoltativa, per poterti rispondere)',
174
+ helpEmail: 'La tua email, per ricevere la nostra risposta',
175
+ attachScreen: 'Allega una schermata',
176
+ removeShot: 'Rimuovi',
177
+ shotLabel: 'Schermata allegata',
178
+ shotFailed: 'Impossibile catturare la schermata.',
179
+ textPlaceholder: 'Dicci cosa ne pensi…',
180
+ helpPlaceholder: 'Spiega cosa ti blocca…',
181
+ micUnavailable: 'Il microfono non è disponibile: puoi scriverci.',
182
+ sending: 'Invio…',
183
+ thanks: 'Grazie! Il tuo parere è stato inviato.',
184
+ thanksSub: 'Il team lo leggerà presto.',
185
+ helpThanks: 'Grazie! La tua richiesta è stata inviata.',
186
+ helpThanksSub: 'Ti risponderemo il prima possibile.',
187
+ failed: 'Invio non riuscito. La tua registrazione è stata conservata.',
188
+ retry: 'Riprova',
189
+ switchToText: 'Passa alla scrittura',
190
+ tooShort: 'Registrazione troppo breve, riprova.',
191
+ emptyText: 'Scrivi qualche parola prima di inviare.',
192
+ track: 'Segui il mio parere',
193
+ brand: 'Realizzato con Parlo',
194
+ defaultQuestion: 'Un commento, un’idea? Diccelo a voce.',
195
+ helpQuestion: 'Descrivi il tuo problema, ti aiuteremo.',
196
+ },
197
+ pt: {
198
+ button: 'Dar a minha opinião',
199
+ close: 'Fechar',
200
+ chooseFeedback: 'Dar a minha opinião',
201
+ chooseHelp: 'Pedir ajuda',
202
+ consent: 'A sua voz é gravada e transcrita para ajudar a equipa. Nunca é usada para o identificar.',
203
+ privacy: 'Privacidade',
204
+ talk: 'Falar',
205
+ write: 'Prefiro escrever',
206
+ backToVoice: 'Voltar à voz',
207
+ stop: 'Terminar',
208
+ play: 'Ouvir',
209
+ pause: 'Pausa',
210
+ redo: 'Recomeçar',
211
+ send: 'Enviar',
212
+ email: 'O seu email (opcional, para lhe podermos responder)',
213
+ helpEmail: 'O seu email, para receber a nossa resposta',
214
+ attachScreen: 'Anexar uma captura',
215
+ removeShot: 'Remover',
216
+ shotLabel: 'Captura anexada',
217
+ shotFailed: 'Não foi possível fazer a captura.',
218
+ textPlaceholder: 'Diga-nos o que pensa…',
219
+ helpPlaceholder: 'Explique o que o está a bloquear…',
220
+ micUnavailable: 'O microfone não está disponível: pode escrever-nos.',
221
+ sending: 'A enviar…',
222
+ thanks: 'Obrigado! A sua opinião foi enviada.',
223
+ thanksSub: 'A equipa vai lê-la em breve.',
224
+ helpThanks: 'Obrigado! O seu pedido foi enviado.',
225
+ helpThanksSub: 'Responderemos o mais rapidamente possível.',
226
+ failed: 'O envio falhou. A sua gravação foi guardada.',
227
+ retry: 'Tentar novamente',
228
+ switchToText: 'Passar a escrever',
229
+ tooShort: 'Gravação demasiado curta, tente novamente.',
230
+ emptyText: 'Escreva algumas palavras antes de enviar.',
231
+ track: 'Acompanhar a minha opinião',
232
+ brand: 'Com tecnologia Parlo',
233
+ defaultQuestion: 'Um comentário, uma ideia? Diga-nos em voz alta.',
234
+ helpQuestion: 'Descreva o seu problema e vamos ajudá-lo.',
235
+ },
236
+ nl: {
237
+ button: 'Feedback geven',
238
+ close: 'Sluiten',
239
+ chooseFeedback: 'Feedback geven',
240
+ chooseHelp: 'Hulp vragen',
241
+ consent: 'Je stem wordt opgenomen en uitgeschreven om het team te helpen. Hij wordt nooit gebruikt om je te identificeren.',
242
+ privacy: 'Privacy',
243
+ talk: 'Spreken',
244
+ write: 'Ik typ liever',
245
+ backToVoice: 'Terug naar spraak',
246
+ stop: 'Klaar',
247
+ play: 'Beluisteren',
248
+ pause: 'Pauze',
249
+ redo: 'Opnieuw',
250
+ send: 'Versturen',
251
+ email: 'Je e-mail (optioneel, zodat we kunnen antwoorden)',
252
+ helpEmail: 'Je e-mail, om ons antwoord te ontvangen',
253
+ attachScreen: 'Screenshot toevoegen',
254
+ removeShot: 'Verwijderen',
255
+ shotLabel: 'Screenshot toegevoegd',
256
+ shotFailed: 'De screenshot kon niet worden gemaakt.',
257
+ textPlaceholder: 'Vertel ons wat je denkt…',
258
+ helpPlaceholder: 'Leg uit wat je tegenhoudt…',
259
+ micUnavailable: 'De microfoon is niet beschikbaar: je kunt ons ook schrijven.',
260
+ sending: 'Versturen…',
261
+ thanks: 'Bedankt! Je feedback is verstuurd.',
262
+ thanksSub: 'Het team leest hem snel.',
263
+ helpThanks: 'Bedankt! Je vraag is verstuurd.',
264
+ helpThanksSub: 'We antwoorden zo snel mogelijk.',
265
+ failed: 'Versturen mislukt. Je opname is bewaard.',
266
+ retry: 'Opnieuw proberen',
267
+ switchToText: 'Liever typen',
268
+ tooShort: 'Opname te kort, probeer het opnieuw.',
269
+ emptyText: 'Schrijf een paar woorden voordat je verstuurt.',
270
+ track: 'Mijn feedback volgen',
271
+ brand: 'Mogelijk gemaakt door Parlo',
272
+ defaultQuestion: 'Een opmerking, een idee? Zeg het ons hardop.',
273
+ helpQuestion: 'Beschrijf je probleem, we helpen je.',
274
+ },
275
+ };
276
+ export function pickStrings(forced, remote) {
277
+ let device = 'en';
278
+ try {
279
+ device = Intl.DateTimeFormat().resolvedOptions().locale || 'en';
280
+ }
281
+ catch {
282
+ device = 'en';
283
+ }
284
+ const wanted = forced || (remote && remote !== 'auto' ? remote : device);
285
+ const code = wanted.toLowerCase().slice(0, 2);
286
+ return code in STRINGS ? STRINGS[code] : STRINGS.en;
287
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { ParloProvider, useParlo, type ParloProviderProps } from './ParloProvider';
2
+ export type { ParloApi, ParloContext, ParloIntent, ParloOpenOptions, ParloRemoteConfig, ParloUser } from './types';
package/lib/index.js ADDED
@@ -0,0 +1 @@
1
+ export { ParloProvider, useParlo } from './ParloProvider';
package/lib/types.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ export type ParloUser = {
2
+ id: string;
3
+ email?: string;
4
+ name?: string;
5
+ hash?: string;
6
+ };
7
+ export type ParloIntent = 'feedback' | 'support';
8
+ export type ParloContext = Record<string, string | number | boolean>;
9
+ export type ParloOpenOptions = {
10
+ intent?: ParloIntent;
11
+ };
12
+ export type ParloRemoteConfig = {
13
+ color: string;
14
+ question: string;
15
+ questionIsDefault: boolean;
16
+ locale: string;
17
+ maxSeconds: number;
18
+ branding: boolean;
19
+ screenshots: boolean;
20
+ support: boolean;
21
+ };
22
+ export type ParloApi = {
23
+ open: (options?: ParloOpenOptions) => void;
24
+ close: () => void;
25
+ identify: (user: ParloUser | null) => void;
26
+ logout: () => void;
27
+ setContext: (context: ParloContext | null) => void;
28
+ setVersion: (version: string | null) => void;
29
+ setScreen: (screen: string | null) => void;
30
+ ready: boolean;
31
+ };
package/lib/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,53 @@
1
1
  {
2
2
  "name": "@sayparlo/react-native",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Voice feedback and help requests for Expo and React Native apps, powered by Parlo.",
5
+ "main": "lib/index.js",
6
+ "module": "lib/index.js",
7
+ "types": "lib/index.d.ts",
8
+ "react-native": "lib/index.js",
9
+ "files": [
10
+ "lib",
11
+ "README.md"
12
+ ],
13
+ "sideEffects": false,
14
+ "scripts": {
15
+ "build": "rm -rf lib && tsc -p tsconfig.build.json",
16
+ "typecheck": "tsc --noEmit",
17
+ "prepublishOnly": "npm run typecheck && npm run build"
18
+ },
19
+ "keywords": [
20
+ "feedback",
21
+ "voice",
22
+ "expo",
23
+ "react-native",
24
+ "support",
25
+ "parlo"
26
+ ],
27
+ "homepage": "https://sayparlo.com/docs/react-native",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/nobscorp/parlo-react-native.git"
31
+ },
32
+ "license": "UNLICENSED",
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "peerDependencies": {
37
+ "expo-application": "*",
38
+ "expo-audio": ">=0.4.0",
39
+ "expo-device": "*",
40
+ "react": ">=18.2.0",
41
+ "react-native": ">=0.76.0"
42
+ },
43
+ "devDependencies": {
44
+ "@types/react": "^19.3.0",
45
+ "expo": "^58.0.25",
46
+ "expo-application": "^58.1.2",
47
+ "expo-audio": "^58.0.12",
48
+ "expo-device": "^58.0.5",
49
+ "react": "^19.3.1",
50
+ "react-native": "^0.88.1",
51
+ "typescript": "^7.0.2"
52
+ }
53
+ }