diff --git a/app.json b/app.json index f589c29..fca9a35 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "CheckOut", "slug": "CheckOut", - "version": "1.2.6", + "version": "1.2.7", "orientation": "portrait", "icon": "./assets/images/icon.png", "scheme": "checkoutattendance", diff --git a/app/(tabs)/checkout.tsx b/app/(tabs)/checkout.tsx index 6789d58..5650a65 100644 --- a/app/(tabs)/checkout.tsx +++ b/app/(tabs)/checkout.tsx @@ -23,6 +23,7 @@ import { UndoBanner } from '@/components/checkout/UndoBanner'; import { Confetti } from '@/components/checkout/Confetti'; import { OptionsSheet } from '@/components/checkout/OptionsSheet'; import { ShareSheet } from '@/components/checkout/ShareSheet'; +import { ReportSheet } from '@/components/checkout/ReportSheet'; import { VerificationSheet } from '@/components/checkout/VerificationSheet'; import { AutoSplashModal } from '@/components/checkout/AutoSplashModal'; import { UndoModal } from '@/components/checkout/UndoModal'; @@ -78,6 +79,9 @@ export default function CheckoutScreen() { const [selectedCode, setSelectedCode] = useState(null); const [showSubmitForm, setShowSubmitForm] = useState(null); const [showConfetti, setShowConfetti] = useState(false); + const [reportSheetVisible, setReportSheetVisible] = useState(false); + const [reportSession, setReportSession] = useState(null); + const [reportCode, setReportCode] = useState(null); // Undo modal state const [undoModalVisible, setUndoModalVisible] = useState(false); @@ -294,7 +298,7 @@ export default function CheckoutScreen() { setUndoModalSessionId(sessionId); setUndoModalTk(tk); setUndoModalVisible(true); - blockRefresh('modal'); + blockRefresh('undo-modal'); }, [blockRefresh] ); @@ -304,7 +308,7 @@ export default function CheckoutScreen() { setUndoModalVisible(false); setUndoModalSessionId(null); setUndoModalTk(null); - unblockRefresh('modal'); + unblockRefresh('undo-modal'); }, [unblockRefresh]); // Handle undo modal confirm - stays open until complete @@ -319,7 +323,7 @@ export default function CheckoutScreen() { setUndoModalVisible(false); setUndoModalSessionId(null); setUndoModalTk(null); - unblockRefresh('modal'); + unblockRefresh('undo-modal'); if (!result.success) { toast.error(result.error || 'Failed to undo submission'); @@ -340,7 +344,7 @@ export default function CheckoutScreen() { (session: Session) => { setSelectedSession(session); setOptionsSheetVisible(true); - blockRefresh('modal'); + blockRefresh('options-modal'); }, [blockRefresh] ); @@ -350,7 +354,7 @@ export default function CheckoutScreen() { (session: Session) => { setSelectedSession(session); setShareSheetVisible(true); - blockRefresh('modal'); + blockRefresh('share-modal'); }, [blockRefresh] ); @@ -360,7 +364,7 @@ export default function CheckoutScreen() { (code: Code) => { setSelectedCode(code); setVerificationSheetVisible(true); - blockRefresh('modal'); + blockRefresh('verification-modal'); }, [blockRefresh] ); @@ -369,19 +373,40 @@ export default function CheckoutScreen() { const closeOptions = useCallback(() => { setOptionsSheetVisible(false); setSelectedSession(null); - unblockRefresh('modal'); + unblockRefresh('options-modal'); }, [unblockRefresh]); const closeShare = useCallback(() => { setShareSheetVisible(false); setSelectedSession(null); - unblockRefresh('modal'); + unblockRefresh('share-modal'); }, [unblockRefresh]); const closeVerification = useCallback(() => { setVerificationSheetVisible(false); setSelectedCode(null); - unblockRefresh('modal'); + unblockRefresh('verification-modal'); + }, [unblockRefresh]); + + // Open report sheet + const openReport = useCallback( + (session: Session, code?: Code) => { + const sortedCodes = [...(session.codes || [])].sort((a, b) => b.rejectScore - a.rejectScore); + const targetCode = code || sortedCodes[0]; + if (!targetCode) return; + setReportSession(session); + setReportCode(targetCode); + setReportSheetVisible(true); + blockRefresh('report-modal'); + }, + [blockRefresh] + ); + + const closeReport = useCallback(() => { + setReportSheetVisible(false); + setReportSession(null); + setReportCode(null); + unblockRefresh('report-modal'); }, [unblockRefresh]); // Handle input focus/blur @@ -610,6 +635,7 @@ export default function CheckoutScreen() { onOpenOptions={() => openOptions(session)} onOpenShare={() => openShare(session)} onOpenVerification={openVerification} + onReportCode={code => openReport(session, code)} onInputFocus={() => handleInputFocus(session.rejectID)} onInputBlur={handleInputBlur} onInputValueChange={handleInputValueChange} @@ -657,6 +683,7 @@ export default function CheckoutScreen() { closeOptions(); openVerification(code); }} + onReportCode={code => openReport(selectedSession, code)} /> @@ -669,6 +696,17 @@ export default function CheckoutScreen() { code={selectedCode} /> + {/* Report sheet */} + {reportSession && reportCode && ( + b.rejectScore - a.rejectScore)} + initialCode={reportCode} + /> + )} + {/* AutoSplash modal */} void; onViewVerification?: (code: Code) => void; + onReportCode?: (code: Code) => void; } export function OptionsSheet({ @@ -22,27 +21,17 @@ export function OptionsSheet({ session, onSubmitDifferentCode, onViewVerification, + onReportCode, }: OptionsSheetProps) { const { theme, isDark } = useAppTheme(); const [showMoreInfo, setShowMoreInfo] = React.useState(false); - const handleReportCode = async () => { - const primaryCode = session.codes?.[0]; - if (primaryCode) { - const codeId = primaryCode.codeIDs?.[0] || ''; - const path = `/support?pre=report-code&chc=${primaryCode.checkinCode}&codeID=${codeId}`; - const apiUrl = await getApiUrl(); - openAuthenticatedUrl(path, `${apiUrl}${path}`); - } - onClose(); - }; - const handleOpenSettings = () => { onClose(); router.push('/settings'); }; - const primaryCode = session.codes?.[0]; + const primaryCode = [...(session.codes || [])].sort((a, b) => b.rejectScore - a.rejectScore)[0]; return ( @@ -87,15 +76,18 @@ export function OptionsSheet({ )} {/* Report incorrect code */} - {primaryCode && ( + {primaryCode && onReportCode && ( + onPress={() => { + onReportCode?.(primaryCode); + onClose(); + }}> Report incorrect code - + )} diff --git a/components/checkout/ReportSheet.tsx b/components/checkout/ReportSheet.tsx new file mode 100644 index 0000000..9e21c97 --- /dev/null +++ b/components/checkout/ReportSheet.tsx @@ -0,0 +1,315 @@ +import React, { useState, useEffect } from 'react'; +import { View, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator } from 'react-native'; +import { ThemedText } from '@/components/ThemedText'; +import { useAppTheme } from '@/hooks/useAppTheme'; +import { useAuth } from '@/hooks/useAuth'; +import { useToast } from '@/hooks/useToast'; +import { checkoutApi } from '@/utils/api'; +import { BottomSheet } from './BottomSheet'; +import type { Session, Code } from '@/types/liveClasses'; + +interface ReportSheetProps { + visible: boolean; + onClose: () => void; + session: Session; + codes: Code[]; + initialCode: Code; +} + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export function ReportSheet({ visible, onClose, session, codes, initialCode }: ReportSheetProps) { + const { theme, isDark } = useAppTheme(); + const { isLoggedIn, isAuthReady } = useAuth(); + const toast = useToast(); + + const [message, setMessage] = useState(''); + const [email, setEmail] = useState(''); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [selectedCode, setSelectedCode] = useState(initialCode); + + // Reset state when sheet opens/closes + useEffect(() => { + if (visible) { + setSelectedCode(initialCode); + } else { + setMessage(''); + setEmail(''); + setError(null); + setIsSubmitting(false); + setSelectedCode(initialCode); + } + }, [visible, initialCode]); + + const handleSubmit = async () => { + setError(null); + + if (isAuthReady && !isLoggedIn && !EMAIL_REGEX.test(email)) { + setError('Please enter a valid email address'); + return; + } + + if (!selectedCode.codeIDs?.[0]) { + setError('No code ID available. Please try again.'); + return; + } + + setIsSubmitting(true); + + try { + const body: Record = { + issueType: 'report-code', + codeId: selectedCode.codeIDs[0], + chc: selectedCode.checkinCode, + }; + if (message.trim()) body.message = message.trim(); + if (isAuthReady && !isLoggedIn && email) body.email = email; + + const result = await checkoutApi('/api/support/submit', { + method: 'POST', + body, + }); + + if (result.success) { + onClose(); + toast.success('Report submitted'); + } else if (result.status === 429) { + setError('Rate limit exceeded. Please try again later.'); + } else { + setError(result.error || 'Something went wrong. Please try again.'); + } + } catch { + setError('Something went wrong. Please try again.'); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + Report Code + + + {session.description || session.moduleName} + + + {/* Code selector */} + {codes.length <= 1 ? ( + + {selectedCode.checkinCode} + + {selectedCode.count} submission{selectedCode.count !== 1 ? 's' : ''} + + + ) : ( + <> + + Which code are you reporting? + + + {codes.map(code => { + const isSelected = code.checkinCode === selectedCode.checkinCode; + return ( + setSelectedCode(code)} + activeOpacity={0.7}> + + {isSelected && ( + + )} + + {code.checkinCode} + + {code.count} submission{code.count !== 1 ? 's' : ''} + + + ); + })} + + + )} + + {/* Message input */} + setMessage(text.slice(0, 500))} + multiline + maxLength={500} + /> + + {/* Email input (only when not logged in) */} + {isAuthReady && !isLoggedIn && ( + + )} + + {/* Error text */} + {error && ( + {error} + )} + + {/* Button row */} + + + Cancel + + + {isSubmitting ? ( + + ) : ( + + Submit Report + + )} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + paddingBottom: 20, + }, + title: { + fontSize: 20, + fontWeight: '700', + marginBottom: 4, + }, + sessionInfo: { + fontSize: 14, + marginBottom: 16, + }, + selectorLabel: { + fontSize: 13, + marginBottom: 10, + }, + selectorList: { + gap: 8, + marginBottom: 16, + }, + codeBox: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + borderRadius: 12, + }, + radioOuter: { + width: 18, + height: 18, + borderRadius: 9, + borderWidth: 1.5, + alignItems: 'center', + justifyContent: 'center', + marginRight: 12, + }, + radioInner: { + width: 10, + height: 10, + borderRadius: 5, + }, + codeText: { + fontSize: 18, + fontWeight: '600', + letterSpacing: 2, + }, + codeCount: { + fontSize: 13, + }, + input: { + padding: 14, + borderRadius: 12, + fontSize: 15, + marginBottom: 12, + }, + messageInput: { + minHeight: 80, + textAlignVertical: 'top', + }, + errorText: { + fontSize: 13, + marginBottom: 12, + }, + buttonContainer: { + flexDirection: 'row', + gap: 12, + }, + button: { + flex: 1, + paddingVertical: 14, + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + minHeight: 48, + }, + buttonText: { + fontSize: 16, + fontWeight: '600', + }, +}); diff --git a/components/checkout/SessionCard.tsx b/components/checkout/SessionCard.tsx index 9a07eae..7f526df 100644 --- a/components/checkout/SessionCard.tsx +++ b/components/checkout/SessionCard.tsx @@ -1,10 +1,8 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState } from 'react'; import { View, TouchableOpacity, StyleSheet } from 'react-native'; import { ThemedText } from '@/components/ThemedText'; import { ThemedView } from '@/components/ThemedView'; import { useAppTheme } from '@/hooks/useAppTheme'; -import { getApiUrl } from '@/constants/api'; -import { openAuthenticatedUrl } from '@/utils/api'; import { CodeDisplay } from './CodeDisplay'; import { CodeInput } from './CodeInput'; import type { Session, Code } from '@/types/liveClasses'; @@ -23,6 +21,7 @@ interface SessionCardProps { onOpenOptions: () => void; onOpenShare: () => void; onOpenVerification: (code: Code) => void; + onReportCode?: (code: Code) => void; onInputFocus?: () => void; onInputBlur?: () => void; onInputValueChange?: (hasValue: boolean) => void; @@ -39,6 +38,7 @@ export function SessionCard({ onOpenOptions, onOpenShare, onOpenVerification, + onReportCode, onInputFocus, onInputBlur, onInputValueChange, @@ -79,16 +79,6 @@ export function SessionCard({ return `${formatTime(session.startTime)} - ${formatTime(session.endTime)}`; }; - // Handle report code - const handleReportCode = useCallback(async () => { - if (primaryCode) { - const codeId = primaryCode.codeIDs?.[0] || ''; - const path = `/support?pre=report-code&chc=${primaryCode.checkinCode}&codeID=${codeId}`; - const apiUrl = await getApiUrl(); - openAuthenticatedUrl(path, `${apiUrl}${path}`); - } - }, [primaryCode]); - const hasCode = session.codesCount > 0 && primaryCode; const shouldShowInput = !hasCode || showSubmitForm; @@ -190,6 +180,15 @@ export function SessionCard({ {code.count} submission{code.count !== 1 ? 's' : ''} + {onReportCode && ( + onReportCode(code)} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}> + + Report + + + )} ))} @@ -205,8 +204,8 @@ export function SessionCard({ Share - {hasCode && ( - + {hasCode && onReportCode && ( + onReportCode(primaryCode!)}> Report )} @@ -284,6 +283,13 @@ const styles = StyleSheet.create({ }, otherCodeCount: { fontSize: 12, + flex: 1, + textAlign: 'right', + }, + otherCodeReport: { + fontSize: 12, + fontWeight: '500', + marginLeft: 12, }, footer: { flexDirection: 'row', diff --git a/hooks/useAuth.ts b/hooks/useAuth.ts index 24dcad8..e7c6ab7 100644 --- a/hooks/useAuth.ts +++ b/hooks/useAuth.ts @@ -20,17 +20,36 @@ export function useAuth() { const [error, setError] = useState(null); const [username, setUsername] = useState(null); const [accountInfo, setAccountInfo] = useState(null); + const [isAuthReady, setIsAuthReady] = useState(false); useEffect(() => { const initAuth = async () => { - const [storedToken, storedLoggedIn] = await Promise.all([ - storage.getItem(API_TOKEN_KEY), - storage.getItem(IS_LOGGED_IN_KEY), - ]); - - if (storedToken && storedLoggedIn === 'true') { - setToken(storedToken); - setIsLoggedIn(true); + try { + const [storedToken, storedLoggedIn, storedAccountInfo] = await Promise.all([ + storage.getItem(API_TOKEN_KEY), + storage.getItem(IS_LOGGED_IN_KEY), + storage.getItem(ACCOUNT_INFO_KEY), + ]); + + if (storedToken && storedLoggedIn === 'true') { + setToken(storedToken); + setIsLoggedIn(true); + if (storedAccountInfo) { + try { + setAccountInfo(JSON.parse(storedAccountInfo)); + } catch { + setAccountInfo(null); + await storage.deleteItem(ACCOUNT_INFO_KEY); + } + } + } + } catch (error) { + console.error('Auth hydration failed:', error); + setToken(null); + setIsLoggedIn(false); + setAccountInfo(null); + } finally { + setIsAuthReady(true); } }; @@ -204,6 +223,7 @@ export function useAuth() { logout, getStoredToken, isLoggedIn, + isAuthReady, isLoggingOut, accountInfo, modalState: { diff --git a/types/liveClasses.ts b/types/liveClasses.ts index dbe5c55..abdee26 100644 --- a/types/liveClasses.ts +++ b/types/liveClasses.ts @@ -127,7 +127,15 @@ export interface SubmissionState { } // Refresh blocker reasons -export type RefreshBlocker = 'typing' | 'submission' | 'modal' | 'interaction'; +export type RefreshBlocker = + | 'typing' + | 'submission' + | 'undo-modal' + | 'options-modal' + | 'share-modal' + | 'verification-modal' + | 'report-modal' + | 'interaction'; // Live classes state for the hook export interface LiveClassesState {