feat(mobile): integrate broadcast and survey clients
This commit is contained in:
parent
97c9eaed24
commit
48896ab879
@ -18,11 +18,13 @@
|
||||
"@bytelyst/api-client": "^0.1.0",
|
||||
"@bytelyst/auth-client": "^0.1.0",
|
||||
"@bytelyst/blob-client": "^0.1.0",
|
||||
"@bytelyst/broadcast-client": "^0.1.0",
|
||||
"@bytelyst/design-tokens": "^0.1.0",
|
||||
"@bytelyst/diagnostics-client": "^0.1.0",
|
||||
"@bytelyst/feature-flag-client": "^0.1.0",
|
||||
"@bytelyst/kill-switch-client": "^0.1.0",
|
||||
"@bytelyst/offline-queue": "^0.1.0",
|
||||
"@bytelyst/survey-client": "^0.1.0",
|
||||
"@bytelyst/telemetry-client": "^0.1.0",
|
||||
"expo": "~55.0.4",
|
||||
"expo-router": "~6.0.4",
|
||||
|
||||
@ -1,15 +1,26 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { Text, View } from 'react-native';
|
||||
import { Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||
import { useAuthStore, type AuthState } from '../store/auth-store';
|
||||
import { useInboxStore, type InboxState } from '../store/inbox-store';
|
||||
import { useNotesStore, type NotesState } from '../store/notes-store';
|
||||
import { useWorkspaceStore, type WorkspaceState } from '../store/workspace-store';
|
||||
import { checkKillSwitch, initPlatform } from '../lib/platform';
|
||||
import { getBroadcastClient } from '../lib/broadcast-client';
|
||||
import { getSurveyClient } from '../lib/survey-client';
|
||||
import type { InAppMessage } from '@bytelyst/broadcast-client';
|
||||
import type { ActiveSurvey, Question, QuestionAnswer } from '@bytelyst/survey-client';
|
||||
import { colors } from '../theme';
|
||||
|
||||
export default function RootLayout() {
|
||||
const [broadcastMessages, setBroadcastMessages] = useState<InAppMessage[]>([]);
|
||||
const [activeSurvey, setActiveSurvey] = useState<ActiveSurvey | null>(null);
|
||||
const [surveyStarted, setSurveyStarted] = useState(false);
|
||||
const [surveyIndex, setSurveyIndex] = useState(0);
|
||||
const [surveyAnswers, setSurveyAnswers] = useState<Record<string, QuestionAnswer>>({});
|
||||
const [textAnswer, setTextAnswer] = useState('');
|
||||
|
||||
const [killSwitchState, setKillSwitchState] = useState<{
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
@ -25,6 +36,38 @@ export default function RootLayout() {
|
||||
const hydrateNotes = useNotesStore((state: NotesState) => state.hydrate);
|
||||
const hydrateWorkspaces = useWorkspaceStore((state: WorkspaceState) => state.hydrate);
|
||||
|
||||
function toSurveyAnswer(question: Question, value: string): QuestionAnswer {
|
||||
if (question.type === 'single_choice' || question.type === 'dropdown') {
|
||||
return { type: 'single_choice', optionId: value };
|
||||
}
|
||||
if (question.type === 'multiple_choice') {
|
||||
return { type: 'multiple_choice', optionIds: [value] };
|
||||
}
|
||||
if (question.type === 'ranking') {
|
||||
return { type: 'ranking', rankedOptionIds: [value] };
|
||||
}
|
||||
if (question.type === 'rating' || question.type === 'nps' || question.type === 'scale') {
|
||||
return { type: 'rating', value: Number(value) };
|
||||
}
|
||||
return { type: 'text', value };
|
||||
}
|
||||
|
||||
async function loadBroadcasts(): Promise<void> {
|
||||
try {
|
||||
const { messages } = await getBroadcastClient().listMessages();
|
||||
setBroadcastMessages(messages.filter((message) => message.status !== 'dismissed'));
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSurvey(): Promise<void> {
|
||||
try {
|
||||
const { survey } = await getSurveyClient().getActiveSurvey();
|
||||
setActiveSurvey((current) => current ?? survey);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void checkKillSwitch()
|
||||
.then((state) => {
|
||||
@ -43,6 +86,21 @@ export default function RootLayout() {
|
||||
void hydrateNotes();
|
||||
void hydrateWorkspaces();
|
||||
void hydrateInbox();
|
||||
void loadBroadcasts();
|
||||
void loadSurvey();
|
||||
|
||||
const broadcastTimer = setInterval(() => {
|
||||
void loadBroadcasts();
|
||||
}, 5 * 60_000);
|
||||
|
||||
const surveyTimer = setInterval(() => {
|
||||
void loadSurvey();
|
||||
}, 10 * 60_000);
|
||||
|
||||
return () => {
|
||||
clearInterval(broadcastTimer);
|
||||
clearInterval(surveyTimer);
|
||||
};
|
||||
}, [bootstrapAuth, hydrateInbox, hydrateNotes, hydrateWorkspaces]);
|
||||
|
||||
if (killSwitchState.checked && killSwitchState.disabled) {
|
||||
@ -68,15 +126,334 @@ export default function RootLayout() {
|
||||
);
|
||||
}
|
||||
|
||||
const currentQuestion = activeSurvey?.questions[surveyIndex];
|
||||
|
||||
async function dismissBroadcast(messageId: string): Promise<void> {
|
||||
try {
|
||||
await getBroadcastClient().markDismissed(messageId);
|
||||
} catch {
|
||||
}
|
||||
setBroadcastMessages((current) => current.filter((message) => message.id !== messageId));
|
||||
}
|
||||
|
||||
async function startSurvey(): Promise<void> {
|
||||
if (!activeSurvey) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await getSurveyClient().startSurvey(activeSurvey.id);
|
||||
} catch {
|
||||
}
|
||||
setSurveyStarted(true);
|
||||
}
|
||||
|
||||
async function dismissSurvey(): Promise<void> {
|
||||
if (activeSurvey) {
|
||||
try {
|
||||
await getSurveyClient().dismissSurvey(activeSurvey.id);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
setActiveSurvey(null);
|
||||
setSurveyStarted(false);
|
||||
setSurveyIndex(0);
|
||||
setSurveyAnswers({});
|
||||
setTextAnswer('');
|
||||
}
|
||||
|
||||
async function submitAnswer(value: string): Promise<void> {
|
||||
if (!activeSurvey || !currentQuestion) {
|
||||
return;
|
||||
}
|
||||
|
||||
const answer = toSurveyAnswer(currentQuestion, value);
|
||||
setSurveyAnswers((current) => ({
|
||||
...current,
|
||||
[currentQuestion.id]: answer,
|
||||
}));
|
||||
|
||||
try {
|
||||
await getSurveyClient().submitAnswer(activeSurvey.id, currentQuestion.id, answer);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const isLastQuestion = surveyIndex >= activeSurvey.questions.length - 1;
|
||||
if (isLastQuestion) {
|
||||
try {
|
||||
await getSurveyClient().completeSurvey(activeSurvey.id);
|
||||
} catch {
|
||||
}
|
||||
await dismissSurvey();
|
||||
return;
|
||||
}
|
||||
|
||||
setSurveyIndex((current) => current + 1);
|
||||
setTextAnswer('');
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={styles.root}>
|
||||
<StatusBar style="light" />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
animation: 'fade',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
{broadcastMessages.length > 0 ? (
|
||||
<View style={styles.bannerSection}>
|
||||
{broadcastMessages.map((message) => (
|
||||
<View key={message.id} style={styles.bannerCard}>
|
||||
<View style={styles.bannerContent}>
|
||||
<Text style={styles.bannerTitle}>{message.title}</Text>
|
||||
{message.body ? <Text style={styles.bannerBody}>{message.body}</Text> : null}
|
||||
</View>
|
||||
{message.dismissible !== false ? (
|
||||
<Pressable onPress={() => void dismissBroadcast(message.id)}>
|
||||
<Text style={styles.bannerDismiss}>×</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{activeSurvey && !surveyStarted ? (
|
||||
<View style={styles.surveyPrompt}>
|
||||
<Text style={styles.surveyPromptText}>{activeSurvey.title}</Text>
|
||||
<View style={styles.surveyActions}>
|
||||
<Pressable style={styles.primaryButton} onPress={() => void startSurvey()}>
|
||||
<Text style={styles.primaryButtonText}>Start</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.secondaryButton} onPress={() => void dismissSurvey()}>
|
||||
<Text style={styles.secondaryButtonText}>Dismiss</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.stackContainer}>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
animation: 'fade',
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Modal animationType="slide" transparent visible={Boolean(activeSurvey && surveyStarted && currentQuestion)}>
|
||||
<View style={styles.modalBackdrop}>
|
||||
<View style={styles.modalCard}>
|
||||
<Text style={styles.modalTitle}>{activeSurvey?.title}</Text>
|
||||
<Text style={styles.modalQuestion}>{currentQuestion?.text}</Text>
|
||||
<Text style={styles.modalMeta}>
|
||||
{surveyIndex + 1}/{activeSurvey?.questions.length ?? 1}
|
||||
</Text>
|
||||
|
||||
{currentQuestion?.options?.length ? (
|
||||
<ScrollView contentContainerStyle={styles.optionsList}>
|
||||
{currentQuestion.options.map((option) => (
|
||||
<Pressable
|
||||
key={option.id}
|
||||
style={styles.optionButton}
|
||||
onPress={() => void submitAnswer(option.id)}
|
||||
>
|
||||
<Text style={styles.optionText}>{option.emoji ? `${option.emoji} ` : ''}{option.text}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
) : currentQuestion && (currentQuestion.type === 'rating' || currentQuestion.type === 'nps' || currentQuestion.type === 'scale') ? (
|
||||
<View style={styles.ratingRow}>
|
||||
{Array.from(
|
||||
{ length: (currentQuestion.maxValue ?? 5) - (currentQuestion.minValue ?? 1) + 1 },
|
||||
(_, index) => (currentQuestion.minValue ?? 1) + index,
|
||||
).map((value) => (
|
||||
<Pressable key={value} style={styles.ratingButton} onPress={() => void submitAnswer(String(value))}>
|
||||
<Text style={styles.ratingText}>{value}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<TextInput
|
||||
value={textAnswer}
|
||||
onChangeText={setTextAnswer}
|
||||
placeholder="Your answer"
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
style={styles.answerInput}
|
||||
multiline={currentQuestion?.type === 'text_long'}
|
||||
/>
|
||||
<Pressable
|
||||
style={[styles.primaryButton, textAnswer.trim().length === 0 ? styles.disabledButton : null]}
|
||||
disabled={textAnswer.trim().length === 0}
|
||||
onPress={() => void submitAnswer(textAnswer.trim())}
|
||||
>
|
||||
<Text style={styles.primaryButtonText}>Next</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Pressable style={styles.secondaryButton} onPress={() => void dismissSurvey()}>
|
||||
<Text style={styles.secondaryButtonText}>Close</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgCanvas,
|
||||
},
|
||||
bannerSection: {
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 10,
|
||||
gap: 8,
|
||||
},
|
||||
bannerCard: {
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderDefault,
|
||||
backgroundColor: colors.bgElevated,
|
||||
padding: 10,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
bannerContent: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
paddingRight: 10,
|
||||
},
|
||||
bannerTitle: {
|
||||
color: colors.textPrimary,
|
||||
fontWeight: '700',
|
||||
fontSize: 13,
|
||||
},
|
||||
bannerBody: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: 12,
|
||||
},
|
||||
bannerDismiss: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: 18,
|
||||
lineHeight: 18,
|
||||
},
|
||||
surveyPrompt: {
|
||||
margin: 12,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderDefault,
|
||||
backgroundColor: colors.surfaceCard,
|
||||
padding: 12,
|
||||
gap: 10,
|
||||
},
|
||||
surveyPromptText: {
|
||||
color: colors.textPrimary,
|
||||
fontWeight: '700',
|
||||
},
|
||||
surveyActions: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
primaryButton: {
|
||||
borderRadius: 10,
|
||||
backgroundColor: colors.accentPrimary,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
primaryButtonText: {
|
||||
color: colors.textPrimary,
|
||||
fontWeight: '700',
|
||||
},
|
||||
secondaryButton: {
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderDefault,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
secondaryButtonText: {
|
||||
color: colors.textSecondary,
|
||||
fontWeight: '600',
|
||||
},
|
||||
stackContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
modalBackdrop: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.45)',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
modalCard: {
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderDefault,
|
||||
backgroundColor: colors.bgCanvas,
|
||||
padding: 16,
|
||||
gap: 12,
|
||||
maxHeight: '85%',
|
||||
},
|
||||
modalTitle: {
|
||||
color: colors.textPrimary,
|
||||
fontWeight: '700',
|
||||
fontSize: 18,
|
||||
},
|
||||
modalQuestion: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: 15,
|
||||
lineHeight: 21,
|
||||
},
|
||||
modalMeta: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: 12,
|
||||
},
|
||||
optionsList: {
|
||||
gap: 8,
|
||||
},
|
||||
optionButton: {
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderDefault,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
optionText: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: 14,
|
||||
},
|
||||
ratingRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
},
|
||||
ratingButton: {
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 19,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderDefault,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
ratingText: {
|
||||
color: colors.textPrimary,
|
||||
fontWeight: '600',
|
||||
},
|
||||
answerInput: {
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderDefault,
|
||||
backgroundColor: colors.bgElevated,
|
||||
color: colors.textPrimary,
|
||||
padding: 10,
|
||||
minHeight: 42,
|
||||
textAlignVertical: 'top' as const,
|
||||
},
|
||||
disabledButton: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
});
|
||||
|
||||
20
mobile/src/lib/broadcast-client.ts
Normal file
20
mobile/src/lib/broadcast-client.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { createBroadcastClient, type BroadcastClient } from '@bytelyst/broadcast-client';
|
||||
import { API_CONFIG, PRODUCT_ID } from '../api/config';
|
||||
import { getAuthClient } from '../api/auth';
|
||||
|
||||
let broadcastClient: BroadcastClient | null = null;
|
||||
|
||||
export function getBroadcastClient(): BroadcastClient {
|
||||
if (!broadcastClient) {
|
||||
broadcastClient = createBroadcastClient({
|
||||
baseUrl: API_CONFIG.platformBaseUrl,
|
||||
productId: PRODUCT_ID,
|
||||
getAuthToken: () => getAuthClient().getAccessToken() ?? '',
|
||||
platform: 'ios',
|
||||
appVersion: '0.1.0',
|
||||
osVersion: 'unknown',
|
||||
});
|
||||
}
|
||||
|
||||
return broadcastClient;
|
||||
}
|
||||
20
mobile/src/lib/survey-client.ts
Normal file
20
mobile/src/lib/survey-client.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { createSurveyClient, type SurveyClient } from '@bytelyst/survey-client';
|
||||
import { API_CONFIG, PRODUCT_ID } from '../api/config';
|
||||
import { getAuthClient } from '../api/auth';
|
||||
|
||||
let surveyClient: SurveyClient | null = null;
|
||||
|
||||
export function getSurveyClient(): SurveyClient {
|
||||
if (!surveyClient) {
|
||||
surveyClient = createSurveyClient({
|
||||
baseUrl: API_CONFIG.platformBaseUrl,
|
||||
productId: PRODUCT_ID,
|
||||
getAuthToken: () => getAuthClient().getAccessToken() ?? '',
|
||||
platform: 'ios',
|
||||
appVersion: '0.1.0',
|
||||
osVersion: 'unknown',
|
||||
});
|
||||
}
|
||||
|
||||
return surveyClient;
|
||||
}
|
||||
708
pnpm-lock.yaml
generated
708
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user