mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 11:13:14 +00:00
Fix React version mismatch, auth fallback, and UI polish
Pin React to 19.2.3 (exact version RN 0.85.3's bundled renderer requires — 19.2.6 caused "Cannot read property 'default' of undefined" crash at getPaperRenderer). Auth: fall back to parsing user info from Cognito ID token when backend API is unreachable, removing the hard dependency on localhost:5000 for credential login. UI fixes: - Safe area insets on Settings screen (top edge was missing) - Separate refresh indicator from filter-triggered loading on proposal queue (chips no longer trigger pull-to-refresh animation) - Dashboard welcome shows first name instead of full email - Dashboard/AdminDashboard remove duplicate top safe area when navigation header already provides it - Service category selector uses wrapping chips instead of cramped SegmentedButtons - Add ErrorBoundary to App root for crash visibility
This commit is contained in:
parent
bd9fbb4da5
commit
686b42330c
12 changed files with 1068 additions and 874 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -8,6 +8,8 @@
|
|||
<string>Sea Haven Proposals</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIconName</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
|
|
@ -20,6 +22,17 @@
|
|||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.seahavenind.proposals</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>com.seahavenind.proposals</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
|
|
@ -31,35 +44,14 @@
|
|||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>com.seahavenind.proposals</string>
|
||||
</array>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.seahavenind.proposals</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Sea Haven Proposals needs camera access to capture vendor proposal documents.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Sea Haven Proposals needs photo library access to attach vendor proposal images.</string>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>Sea Haven Proposals uses Face ID to secure your login session.</string>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Sea Haven Proposals needs photo library access to attach vendor proposal images.</string>
|
||||
<key>RCTNewArchEnabled</key>
|
||||
<true/>
|
||||
<key>UIAppFonts</key>
|
||||
<array>
|
||||
<string>MaterialCommunityIcons.ttf</string>
|
||||
|
|
@ -82,9 +74,19 @@
|
|||
<string>FontAwesome6_Brands.ttf</string>
|
||||
<string>MaterialIcons.ttf</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<false/>
|
||||
<key>CFBundleIconName</key>
|
||||
<string>AppIcon</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
9
mobile/package-lock.json
generated
9
mobile/package-lock.json
generated
|
|
@ -7,6 +7,7 @@
|
|||
"": {
|
||||
"name": "proposal-system-mobile",
|
||||
"version": "0.1.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@proposal-system/api-contracts": "file:../shared/api-contracts",
|
||||
"@react-native-async-storage/async-storage": "^2.1.0",
|
||||
|
|
@ -18,7 +19,7 @@
|
|||
"@tanstack/react-query": "^5.100.10",
|
||||
"amazon-cognito-identity-js": "^6.3.0",
|
||||
"axios": "^1.16.0",
|
||||
"react": "^19.0.0",
|
||||
"react": "19.2.3",
|
||||
"react-native": "^0.85.3",
|
||||
"react-native-app-auth": "^8.0.0",
|
||||
"react-native-document-picker": "^9.3.0",
|
||||
|
|
@ -5835,9 +5836,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.6",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
||||
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
"@tanstack/react-query": "^5.100.10",
|
||||
"amazon-cognito-identity-js": "^6.3.0",
|
||||
"axios": "^1.16.0",
|
||||
"react": "^19.0.0",
|
||||
"react": "19.2.3",
|
||||
"react-native": "^0.85.3",
|
||||
"react-native-app-auth": "^8.0.0",
|
||||
"react-native-document-picker": "^9.3.0",
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { Provider as ReduxProvider, useDispatch } from 'react-redux';
|
||||
import { PaperProvider } from 'react-native-paper';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import NetInfo from '@react-native-community/netinfo';
|
||||
import { Alert } from 'react-native';
|
||||
import { Alert, Text, View } from 'react-native';
|
||||
|
||||
import { store } from './store';
|
||||
import { theme } from './theme';
|
||||
|
|
@ -15,6 +15,26 @@ import { setUser, setLoading } from './store/slices/authSlice';
|
|||
import { processOfflineQueue } from './hooks/useOfflineDraft';
|
||||
import { proposalsApi, CreateProposalRequest } from './lib/api/proposals';
|
||||
|
||||
class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
|
||||
state = { error: null as Error | null };
|
||||
static getDerivedStateFromError(error: Error) { return { error }; }
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error('ErrorBoundary caught:', error, info.componentStack);
|
||||
}
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: 'center', padding: 32, backgroundColor: '#fff' }}>
|
||||
<Text style={{ fontSize: 18, fontWeight: 'bold', color: 'red', marginBottom: 8 }}>App Error</Text>
|
||||
<Text style={{ fontSize: 14, color: '#333' }}>{this.state.error.message}</Text>
|
||||
<Text style={{ fontSize: 12, color: '#666', marginTop: 8 }}>{this.state.error.stack?.slice(0, 500)}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function AuthBootstrap({ children }: { children: React.ReactNode }) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
|
|
@ -61,16 +81,18 @@ function AuthBootstrap({ children }: { children: React.ReactNode }) {
|
|||
|
||||
export default function App() {
|
||||
return (
|
||||
<ReduxProvider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PaperProvider theme={theme}>
|
||||
<SafeAreaProvider>
|
||||
<AuthBootstrap>
|
||||
<RootNavigator />
|
||||
</AuthBootstrap>
|
||||
</SafeAreaProvider>
|
||||
</PaperProvider>
|
||||
</QueryClientProvider>
|
||||
</ReduxProvider>
|
||||
<ErrorBoundary>
|
||||
<ReduxProvider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PaperProvider theme={theme}>
|
||||
<SafeAreaProvider>
|
||||
<AuthBootstrap>
|
||||
<RootNavigator />
|
||||
</AuthBootstrap>
|
||||
</SafeAreaProvider>
|
||||
</PaperProvider>
|
||||
</QueryClientProvider>
|
||||
</ReduxProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export function usePaginatedList<T>(
|
|||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
|
|
@ -37,8 +38,7 @@ export function usePaginatedList<T>(
|
|||
};
|
||||
}, [search]);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
setLoading(true);
|
||||
const fetchData = useCallback(() => {
|
||||
setErr('');
|
||||
fetchFn({ search: debouncedSearch, page, pageSize, ...extraParams })
|
||||
.then(({ items, totalCount: total }) => {
|
||||
|
|
@ -46,13 +46,22 @@ export function usePaginatedList<T>(
|
|||
setTotalCount(total);
|
||||
})
|
||||
.catch((e: Error) => setErr(e.message || 'Failed to load'))
|
||||
.finally(() => setLoading(false));
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fetchFn, debouncedSearch, page, pageSize, extraKey]);
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, [reload]);
|
||||
setLoading(true);
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
setRefreshing(true);
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
|
|
@ -65,9 +74,10 @@ export function usePaginatedList<T>(
|
|||
setPageSize,
|
||||
totalCount,
|
||||
loading,
|
||||
refreshing,
|
||||
err,
|
||||
reload,
|
||||
}),
|
||||
[rows, search, page, pageSize, totalCount, loading, err, reload],
|
||||
[rows, search, page, pageSize, totalCount, loading, refreshing, err, reload],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,22 @@ import {
|
|||
StoredUser,
|
||||
} from '../storage';
|
||||
|
||||
function parseUserFromIdToken(idToken: string): StoredUser {
|
||||
const base64Url = idToken.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const payload = JSON.parse(atob(base64));
|
||||
const groups: string[] = payload['cognito:groups'] || [];
|
||||
const role = groups.includes('admins') || groups.includes('sysadmins')
|
||||
? 'Admin'
|
||||
: 'Dispatcher';
|
||||
return {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
displayName: payload.name || payload.email,
|
||||
role: role as StoredUser['role'],
|
||||
};
|
||||
}
|
||||
|
||||
const cognitoConfig = {
|
||||
clientId: Config.COGNITO_CLIENT_ID,
|
||||
redirectUrl: Config.COGNITO_REDIRECT_URI,
|
||||
|
|
@ -53,7 +69,12 @@ export const authApi = {
|
|||
};
|
||||
await tokenStorage.save(tokens);
|
||||
|
||||
const profile = await authApi.getMe();
|
||||
let profile: StoredUser;
|
||||
try {
|
||||
profile = await authApi.getMe();
|
||||
} catch {
|
||||
profile = parseUserFromIdToken(cognitoTokens.idToken);
|
||||
}
|
||||
await userStorage.save(profile);
|
||||
return profile;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import type { CognitoUserSession } from 'amazon-cognito-identity-js';
|
||||
import Config from '../../config';
|
||||
|
||||
export interface CognitoTokens {
|
||||
|
|
@ -8,7 +7,8 @@ export interface CognitoTokens {
|
|||
expiresAt: string;
|
||||
}
|
||||
|
||||
function extractTokens(session: CognitoUserSession): CognitoTokens {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function extractTokens(session: any): CognitoTokens {
|
||||
const accessToken = session.getAccessToken();
|
||||
return {
|
||||
accessToken: accessToken.getJwtToken(),
|
||||
|
|
@ -22,7 +22,6 @@ export async function authenticateWithCredentials(
|
|||
email: string,
|
||||
password: string,
|
||||
): Promise<CognitoTokens> {
|
||||
// Lazy-load to keep amazon-cognito-identity-js out of the startup module graph
|
||||
const {
|
||||
CognitoUserPool,
|
||||
CognitoUser,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export function SettingsScreen() {
|
|||
const { user, logout } = useAuth();
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
<View style={styles.content}>
|
||||
<Card style={styles.card} mode="elevated">
|
||||
<Card.Content>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export function ProposalQueueScreen() {
|
|||
search,
|
||||
setSearch,
|
||||
loading,
|
||||
refreshing,
|
||||
reload,
|
||||
} = usePaginatedList<ProposalListItem>(
|
||||
(params) =>
|
||||
|
|
@ -90,7 +91,7 @@ export function ProposalQueueScreen() {
|
|||
<ProposalCard proposal={item} onPress={() => handlePress(item)} />
|
||||
)}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={loading} onRefresh={reload} />
|
||||
<RefreshControl refreshing={refreshing} onRefresh={reload} />
|
||||
}
|
||||
contentContainerStyle={rows.length === 0 ? styles.empty : styles.list}
|
||||
ListEmptyComponent={
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ export function DashboardScreen() {
|
|||
}
|
||||
>
|
||||
<Text variant="headlineSmall" style={styles.welcome}>
|
||||
Welcome, {user?.displayName?.split(' ')[0] || 'there'}
|
||||
Welcome, {user?.displayName?.includes('@') ? user.displayName.split('@')[0] : user?.displayName?.split(' ')[0] || 'there'}
|
||||
</Text>
|
||||
|
||||
{stats && (
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
TextInput,
|
||||
Button,
|
||||
Text,
|
||||
Chip,
|
||||
SegmentedButtons,
|
||||
Snackbar,
|
||||
} from 'react-native-paper';
|
||||
|
|
@ -146,16 +147,19 @@ export function NewProposalScreen() {
|
|||
<Text variant="labelLarge" style={styles.label}>
|
||||
Service Category
|
||||
</Text>
|
||||
<SegmentedButtons
|
||||
value={draft.serviceCategory}
|
||||
onValueChange={(v) => updateDraft({ serviceCategory: v })}
|
||||
buttons={SERVICE_CATEGORIES.map((c) => ({
|
||||
value: c,
|
||||
label: c,
|
||||
}))}
|
||||
style={styles.segmented}
|
||||
density="small"
|
||||
/>
|
||||
<View style={styles.chipRow}>
|
||||
{SERVICE_CATEGORIES.map((c) => (
|
||||
<Chip
|
||||
key={c}
|
||||
selected={draft.serviceCategory === c}
|
||||
showSelectedOverlay
|
||||
onPress={() => updateDraft({ serviceCategory: c })}
|
||||
style={styles.chip}
|
||||
>
|
||||
{c}
|
||||
</Chip>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Text variant="labelLarge" style={styles.label}>
|
||||
Priority
|
||||
|
|
@ -247,6 +251,15 @@ const styles = StyleSheet.create({
|
|||
segmented: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
chipRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
marginBottom: 16,
|
||||
},
|
||||
chip: {
|
||||
marginBottom: 0,
|
||||
},
|
||||
hint: {
|
||||
color: '#9E9E9E',
|
||||
marginBottom: 12,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue