feat(mobile): integrate broadcast and survey clients

This commit is contained in:
saravanakumardb1 2026-03-31 00:03:20 -07:00
parent 97c9eaed24
commit 48896ab879
5 changed files with 1123 additions and 22 deletions

View File

@ -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",

View File

@ -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,
},
});

View 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;
}

View 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

File diff suppressed because it is too large Load Diff