diff --git a/.github/workflows/mobile-ci.yml b/.github/workflows/mobile-ci.yml new file mode 100644 index 0000000..985d7eb --- /dev/null +++ b/.github/workflows/mobile-ci.yml @@ -0,0 +1,36 @@ +name: Mobile CI + +on: + pull_request: + paths: + - 'mobile/**' + - '.github/workflows/mobile-ci.yml' + push: + branches: [main] + paths: + - 'mobile/**' + - '.github/workflows/mobile-ci.yml' + +permissions: + contents: read + +jobs: + mobile: + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: mobile + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22.13' + - name: Install dependencies + run: npm install --no-audit --no-fund + - name: Expo dependency and config health + run: npm run doctor + - name: Unit tests + run: npm run test:ci + - name: Export bundle smoke test + run: npx expo export --platform web --output-dir dist-ci diff --git a/.gitignore b/.gitignore index 1318423..e2cb691 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,10 @@ node_modules/ dist/ npm-debug.log* +# Expo local state +.expo/ +mobile/.expo/ + # Logs logs *.log diff --git a/docs/MOBILE_APP.md b/docs/MOBILE_APP.md new file mode 100644 index 0000000..22d8726 --- /dev/null +++ b/docs/MOBILE_APP.md @@ -0,0 +1,100 @@ +# BragStack Mobile App + +BragStack Mobile is the native iOS and Android client for the BragStack career-evidence platform. It is under active pre-release development and is tracked by issue #200. + +## Product goal + +Mobile should make it easier to capture meaningful work while the details are still fresh without creating a separate account system, separate career history, or weaker privacy model. + +The mobile client reuses the existing FastAPI backend, authentication model, Impact Receipt semantics, profile model, and private-by-default trust principles. + +## Current foundation + +- React Native / Expo cross-platform application under `mobile/` +- iOS bundle identifier and Android package: `com.bragstack.app` +- canonical BragStack vector brandmark from `frontend/public/brandmark.svg` +- authenticated-app palette from the BragStack Brand Guide +- Home, Proof, Add, Profile, and Settings navigation +- real password sign-in against `POST /auth/login` +- verified-email enforcement inherited from the backend +- encrypted access-token storage with Expo SecureStore +- session restore through `GET /auth/me` +- sign out that clears the local mobile token +- shared authenticated Axios client +- interactive private-by-default Impact Receipt preview +- EAS preview and production build profiles +- no unnecessary native permissions in the foundation + +## Authentication model + +Mobile does not maintain a separate identity system. + +1. The user signs in using the existing BragStack account. +2. `/auth/login` returns the normal BragStack bearer token and serialized user. +3. The token is stored through Expo SecureStore rather than plaintext application storage. +4. On app launch, the client attempts `/auth/me` to restore the session. +5. Invalid or expired sessions are cleared and the user returns to sign-in. +6. Sign out removes the stored token. + +Registration, email verification, password reset, recovery deep links, and account-deletion UX must be completed and tested before store release. + +## Brand system + +The mobile product follows the authenticated BragStack app context rather than using the marketing palette as its primary UI. + +- app background: `#090909` +- primary text: `#F7F4EE` +- secondary text: `#AAA39A` +- primary action / accent: `#FFB184` +- canonical logo/brandmark retains the approved blue-purple-cyan gradient identity + +The mobile app must not substitute a generic lettermark or create a separate mobile-only visual identity. + +## Data and privacy model + +Mobile should remain another client for the same BragStack record. + +- private workplace evidence stays private by default +- sharing is intentional and separate from capture +- missing results, metrics, evidence, or confirmation are not invented +- evidence permissions are requested only when a user initiates a feature that needs them +- auth tokens and sensitive proof must not be written to logs, URLs, analytics payloads, or crash breadcrumbs +- public profile behavior must preserve the same publication boundaries as web + +## Store-readiness gate + +Before the PR or later release work can be called store-ready, BragStack must complete: + +- live accomplishment and Impact Receipt reads/writes +- registration, verification, reset, recovery, and deletion flows +- loading, empty, retry, offline, and expired-session states +- accessibility and dynamic-text validation +- device/OS compatibility testing +- production API configuration +- app icon, splash, screenshots, and store metadata +- Apple privacy disclosures and required-reason review +- Google Play Data safety disclosure +- Terms, Privacy, support, and account-deletion links +- signing and release credentials +- TestFlight and Play internal testing +- mobile CI, dependency/security validation, and production build verification +- security review of token lifecycle, deep links, logs, evidence handling, and third-party SDKs + +## Release truth + +The mobile app is **pre-release** until production listings are actually live. Documentation, marketing, support, and investor materials should distinguish between: + +- implemented mobile foundation +- functionality still under development +- internal/beta availability +- public App Store / Google Play availability + +Do not describe the app as publicly downloadable before the applicable production listing is live. + +## Related documentation + +- `mobile/README.md` — local development and current implementation status +- `docs/ROADMAP.md` — phased mobile delivery plan +- `docs/MOBILE_CUSTOMER_GUIDE.md` — customer-facing mobile copy source +- issue #200 — mobile program epic +- PR #201 — initial mobile foundation diff --git a/docs/MOBILE_CUSTOMER_GUIDE.md b/docs/MOBILE_CUSTOMER_GUIDE.md new file mode 100644 index 0000000..3153a4c --- /dev/null +++ b/docs/MOBILE_CUSTOMER_GUIDE.md @@ -0,0 +1,63 @@ +# BragStack Mobile — Customer Guide + +> **Status: pre-release.** BragStack Mobile is in active development for iOS and Android and is not yet publicly available in the Apple App Store or Google Play. + +## What BragStack Mobile is for + +BragStack Mobile is designed to help you capture meaningful work while the details are still fresh, then use the same professional proof across BragStack on web and mobile. + +The first public release is intended to support: + +- signing in with an existing BragStack account +- restoring a secure signed-in session on your device +- viewing a mobile dashboard and proof library +- quickly capturing a new accomplishment +- working with Impact Receipts using the same trust model as the web product +- accessing profile and account settings +- keeping private workplace evidence private unless you intentionally share it + +## What is already built into the mobile foundation + +The current mobile foundation includes the native iOS/Android app structure, official BragStack branding, the authenticated BragStack theme, real account sign-in, encrypted on-device session storage, session restore, sign out, mobile navigation, and internal preview/release build configuration. + +Some screens still use preview data while live mobile data flows are completed. + +## What is still being completed before launch + +Before public release, BragStack is completing and validating: + +- live accomplishment and Impact Receipt persistence across mobile screens +- registration, email verification, password reset, and recovery flows +- loading, retry, offline, expired-session, and API error states +- accessibility and dynamic-text behavior +- device and operating-system compatibility +- account-deletion behavior and privacy/support links +- Apple privacy disclosures and Google Play Data safety disclosures +- production signing, TestFlight, Google Play internal testing, store assets, and final security/release review + +## Privacy and permissions + +BragStack Mobile follows the same private-first approach as the web product. Sensitive workplace evidence should not become public automatically. + +The app should ask for a device permission only when a feature genuinely needs it and, where possible, only after you start that action. A missing result, metric, or piece of evidence stays missing; BragStack should not invent professional outcomes to make a record look more complete. + +## Your account and data + +BragStack Mobile is being built to use the same BragStack account and backend as the web product rather than creating a separate mobile-only account system. + +At launch, supported mobile data should sync through the same BragStack record so you can move between devices without maintaining two separate career histories. + +## Support + +During pre-release testing, include the following when reporting a mobile problem when available: + +- device type +- operating-system version +- BragStack app/build version +- the exact non-sensitive error message or behavior + +Never send BragStack passwords, access tokens, confidential employer evidence, or full payment information to support. + +## Store availability + +Installation links and supported OS versions will be added only after BragStack has completed internal testing and the production listings are live. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..8ae89b6 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,88 @@ +# BragStack Roadmap + +## Mobile initiative — iOS + Android + +BragStack Mobile is an active pre-release initiative tracked by issue #200. It is intended to extend the same BragStack account, proof record, trust model, and privacy defaults to a native cross-platform client rather than create a separate mobile product. + +### Phase 1: Foundation — substantially complete +- Expo / React Native application scaffold +- canonical BragStack brandmark +- authenticated-app theme tokens from the BragStack Brand Guide +- navigation shell for Home, Proof, Add, Profile, and Settings +- real sign-in against the existing `/auth/login` endpoint +- verified-email behavior inherited from the backend +- secure on-device token storage with Expo SecureStore +- session restore through `/auth/me` +- sign out that clears the local mobile session +- authenticated API client configuration +- EAS preview / production build profiles +- store-safe default permission posture +- mobile engineering and customer documentation foundation + +### Phase 2: Core product parity — next +- Dashboard backed by live API data +- Accomplishments / Impact Receipts list and detail views backed by live data +- Quick-add accomplishment persistence +- Edit and delete flows with confirmation +- Registration and email verification UX +- Password reset / recovery deep-link flow +- Profile editing and public-profile controls +- Loading, empty, expired-session, offline, retry, and API error states +- Automated tests for auth/session/data flows + +### Phase 3: Mobile-native value +- Camera/file evidence capture with explicit privacy controls +- Native share sheet for public profile and selected proof +- Deep links into public profiles and selected mobile screens +- Optional biometric re-entry protection where appropriate +- Carefully scoped, opt-in notifications only when they provide clear user value +- Mobile analytics focused on activation/friction without sending sensitive proof content + +### Phase 4: Store readiness +- Accessibility and dynamic-text review +- App icon, splash screen, screenshots, and store copy +- Apple privacy disclosures / required-reason review +- Google Play Data safety disclosure +- Account deletion flow validation +- Terms, Privacy Policy, and support links +- Production API configuration +- iOS signing / TestFlight +- Android signing / Play internal testing +- Crash reporting decision, implementation, and disclosure if adopted +- Device / OS compatibility matrix +- Third-party SDK and mobile dependency review +- Mobile CI and production-build verification + +### Phase 5: Release and hardening +- Beta feedback pass +- Performance and crash-free-session targets +- Security review of token lifecycle, deep links, logs, analytics, and evidence handling +- App Store and Google Play production submission +- Store-review issue handling +- Support playbooks and customer-facing known limitations +- Post-launch crash/auth/API/error monitoring + +## Mobile success criteria +1. Reduce time from a real-world win to a useful BragStack record. +2. Preserve reliable session behavior without weakening account security. +3. Keep private workplace evidence private by default. +4. Make web ↔ mobile movement feel like one BragStack account and record. +5. Drive useful repeat capture/review behavior rather than notification spam. +6. Ship only when store disclosures and customer documentation match actual production behavior. + +## Mobile product principles +1. Keep private workplace evidence private by default. +2. Reuse BragStack's existing backend and trust model instead of duplicating business logic in the client. +3. Ask for device permissions only at the moment a feature genuinely needs them. +4. Keep core accomplishment capture fast enough to use immediately after a win. +5. Preserve user control over what becomes public, exported, or shared. +6. Never invent missing professional results, evidence, verification, or metrics. +7. Distinguish pre-release capability from publicly shipped store availability. + +## Documentation +- `docs/MOBILE_APP.md` — architecture, auth, brand, privacy, and release gates +- `docs/MOBILE_CUSTOMER_GUIDE.md` — source for customer-facing mobile guidance +- `mobile/README.md` — local setup and implementation status + +Tracking epic: #200 +Foundation PR: #201 diff --git a/mobile/.env.example b/mobile/.env.example new file mode 100644 index 0000000..d6ebadd --- /dev/null +++ b/mobile/.env.example @@ -0,0 +1,4 @@ +# BragStack API reachable by the simulator/emulator/device. +# Android emulator commonly uses http://10.0.2.2:8000 for a host-local API. +# Physical devices need your computer's LAN address or a deployed HTTPS API. +EXPO_PUBLIC_API_URL=http://localhost:8000 diff --git a/mobile/App.js b/mobile/App.js new file mode 100644 index 0000000..2827b0c --- /dev/null +++ b/mobile/App.js @@ -0,0 +1,816 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { NavigationContainer } from '@react-navigation/native'; +import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; +import { StatusBar } from 'expo-status-bar'; +import { + ActivityIndicator, + Linking, + Platform, + Pressable, + RefreshControl, + ScrollView, + StyleSheet, + Text, + TextInput, + useWindowDimensions, + View, +} from 'react-native'; +import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'; +import Svg, { Path } from 'react-native-svg'; +import Brandmark from './src/Brandmark'; +import { apiBaseURL } from './src/api'; +import { + getAuthErrorMessage, + login, + logout, + register, + requestPasswordReset, + resendVerification, + restoreSession, +} from './src/authApi'; +import { + createPrivateEntry, + getProductErrorMessage, + loadProofOverview, + updateProfile, +} from './src/productApi'; +import { colors, navigationTheme, radius } from './src/theme'; + +const Tab = createBottomTabNavigator(); +const icons = { Home: '⌂', Proof: '✓', Add: '+', Profile: '◉', Settings: '⚙' }; +const EMPTY_OVERVIEW = { entries: [], receipts: [], totalEntries: 0, totalReceipts: 0 }; + +function useLayoutProfile() { + const { width, height } = useWindowDimensions(); + const isLandscape = width > height; + const isTablet = width >= 768; + const isWideTablet = width >= 900; + const isShort = height < 650; + const horizontalPadding = isWideTablet ? 36 : isTablet ? 28 : 14; + return { width, height, isLandscape, isTablet, isWideTablet, isShort, horizontalPadding }; +} + +function GoogleMark() { + return ( + + + + + + + ); +} + +function GitHubMark() { + return ( + + + + ); +} + +function Brand({ small = false }) { + return ( + + + + BragStack + {!small && Proof of the impact you create.} + + + ); +} + +function AuthScreen({ onSuccess }) { + const layout = useLayoutProfile(); + const compact = layout.isShort || layout.width < 390; + const split = layout.isWideTablet && layout.isLandscape; + const contentWidth = Math.max( + 280, + Math.min(layout.width - layout.horizontalPadding * 2, split ? 1120 : 560), + ); + const [mode, setMode] = useState('login'); + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [message, setMessage] = useState(''); + const [verificationEmail, setVerificationEmail] = useState(''); + + const changeMode = (nextMode) => { + setMode(nextMode); + setError(''); + setMessage(''); + }; + + const submit = async () => { + if (busy) return; + setBusy(true); + setError(''); + setMessage(''); + try { + if (mode === 'register') { + if (!name.trim() || !email.trim() || password.length < 8) { + throw new Error('Enter your name, a valid email, and a password with at least 8 characters.'); + } + const result = await register(name, email, password); + setVerificationEmail(email.trim().toLowerCase()); + setMessage( + result?.email_sent === false + ? 'Account created. Verification email delivery needs another try.' + : 'Account created. Check your email to verify it, then sign in.', + ); + return; + } + if (mode === 'reset') { + if (!email.trim()) throw new Error('Enter the email address on your BragStack account.'); + await requestPasswordReset(email); + setMessage('If that email belongs to an account, a password reset link has been sent.'); + return; + } + if (!email.trim() || !password) throw new Error('Enter your email and password.'); + onSuccess(await login(email, password)); + } catch (authError) { + setError(authError?.response ? getAuthErrorMessage(authError) : authError.message); + } finally { + setBusy(false); + } + }; + + const resend = async () => { + if (!verificationEmail || busy) return; + setBusy(true); + setError(''); + try { + await resendVerification(verificationEmail); + setMessage('A fresh verification email has been requested. Check your inbox.'); + } catch (authError) { + setError(getAuthErrorMessage(authError)); + } finally { + setBusy(false); + } + }; + + const startOAuth = async (provider) => { + if (Platform.OS !== 'web') return; + await Linking.openURL(`${apiBaseURL}/auth/${provider}/login`); + }; + + const title = mode === 'register' + ? 'Build proof that travels with your career.' + : mode === 'reset' + ? 'Get back into your proof.' + : 'Your proof is ready when you are.'; + + return ( + + + + + + + + + + + PRIVATE CAREER PROOF + + {split ? ( + + Career proof that works wherever you work. + Capture evidence on the device in your hand, then keep using the same BragStack account on the web. + + ) : null} + + + + {mode === 'register' ? 'CREATE ACCOUNT' : mode === 'reset' ? 'ACCOUNT RECOVERY' : 'WELCOME BACK'} + {title} + + {mode === 'register' + ? 'Create one BragStack account for web and mobile. Your career evidence stays private by default.' + : mode === 'reset' + ? 'We will send a secure reset link to your account email.' + : 'Open your private workspace and keep building evidence while the details are still fresh.'} + + + {mode === 'register' ? ( + <> + NAME + + + ) : null} + + EMAIL + + + {mode !== 'reset' ? ( + <> + PASSWORD + + + ) : null} + + {error ? {error} : null} + {message ? {message} : null} + + + {busy ? : {mode === 'register' ? 'Create BragStack account' : mode === 'reset' ? 'Send reset link' : 'Sign in to BragStack'}} + + + {verificationEmail ? ( + + Resend verification email + + ) : null} + + {mode === 'login' ? ( + changeMode('reset')} style={styles.linkButton}> + Forgot password? + + ) : null} + + {Platform.OS === 'web' && mode === 'login' ? ( + <> + + + OR CONTINUE WITH + + + + startOAuth('google')} style={[styles.socialButton, styles.googleButton]}> + + Continue with Google + + startOAuth('github')} style={[styles.socialButton, styles.githubButton]}> + + Continue with GitHub + + + + ) : null} + + {mode === 'login' ? ( + changeMode('register')} style={styles.authSwitch}> + New to BragStack? Create an account + + ) : ( + changeMode('login')} style={styles.authSwitch}> + Already have an account? Sign in + + )} + + + + Secure session storage • private by default + + + + + + ); +} + +function Page({ kicker, title, children, refreshing = false, onRefresh, narrow = false }) { + const layout = useLayoutProfile(); + const maxWidth = narrow ? 820 : layout.isWideTablet ? 1120 : layout.isTablet ? 900 : 760; + const contentWidth = Math.max(280, Math.min(layout.width - layout.horizontalPadding * 2, maxWidth)); + return ( + + : undefined} + > + + + + {kicker} + {title} + {children} + + + + + ); +} + +function Pill({ children }) { + return {children}; +} + +function ProofCard({ title, body, status = 'Private proof', isPublic = false }) { + return ( + + + {status} + {isPublic ? '◉ Public' : '◌ Private'} + + {title} + {body} + + ); +} + +function LoadingCard({ label = 'Loading your proof…' }) { + return {label}; +} + +function Home({ navigation, user, overview, loading, error, refreshing, onRefresh }) { + const layout = useLayoutProfile(); + const wide = layout.isWideTablet; + const verified = overview.receipts.filter((receipt) => + (receipt.confirmations || []).some((confirmation) => confirmation.status === 'confirmed'), + ).length; + const recent = overview.entries[0]; + + return ( + + + + + PROOF PULSE + Turn fresh wins into durable career proof. + Capture the work, result, evidence, skills, and credit while the details are fresh. + + {overview.totalEntries}Wins + {overview.totalReceipts}Receipts + {verified}Confirmed + + + + + + navigation.navigate('Add')}> + + + + Capture a win + Save it privately to your real BragStack account. + + + + + {loading ? : null} + {error ? {error} : null} + {!loading && recent ? ( + + ) : null} + {!loading && !recent && !error ? ( + Your first win goes here.Use Add to capture a real accomplishment. Nothing becomes public automatically. + ) : null} + + + + ); +} + +function ProofGrid({ items, renderItem }) { + const layout = useLayoutProfile(); + const twoColumns = layout.isWideTablet; + return ( + + {items.map((item) => ( + + {renderItem(item.value)} + + ))} + + ); +} + +function Proof({ overview, loading, error, refreshing, onRefresh }) { + const receiptItems = overview.receipts.map((receipt) => ({ key: `receipt-${receipt.id}`, value: receipt })); + const entryItems = overview.entries.map((entry) => ({ key: `entry-${entry.id}`, value: entry })); + return ( + + {loading ? : null} + {error ? {error} : null} + + {!loading && receiptItems.length > 0 ? IMPACT RECEIPTS : null} + { + const confirmed = (receipt.confirmations || []).some((confirmation) => confirmation.status === 'confirmed'); + const evidenceCount = (receipt.evidence || []).length; + return ( + + ); + }} + /> + + {!loading && entryItems.length > 0 ? ACCOMPLISHMENTS : null} + ( + + )} + /> + + {!loading && !error && entryItems.length === 0 && receiptItems.length === 0 ? ( + No proof yet.Your mobile library is connected to production data. Add a win to start it. + ) : null} + + ); +} + +function Field({ label, value, onChangeText, placeholder, multiline = false, autoCapitalize = 'sentences' }) { + return ( + + {label} + + + ); +} + +function Add({ navigation, onCreated }) { + const [form, setForm] = useState({ title: '', situation: '', action: '', impact: '', category: '', tags: '' }); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [message, setMessage] = useState(''); + const setField = (key) => (value) => setForm((current) => ({ ...current, [key]: value })); + + const save = async () => { + if (busy) return; + setBusy(true); + setError(''); + setMessage(''); + try { + const created = await createPrivateEntry(form); + setForm({ title: '', situation: '', action: '', impact: '', category: '', tags: '' }); + setMessage('Saved privately to BragStack.'); + await onCreated(created); + navigation.navigate('Proof'); + } catch (saveError) { + setError(saveError?.response ? getProductErrorMessage(saveError, 'Could not save this accomplishment.') : saveError.message); + } finally { + setBusy(false); + } + }; + + return ( + + + This saves a real accomplishment to your BragStack account. The four proof fields are required so the app never invents missing career evidence. + + + + + + + New mobile captures are private by default. + {error ? {error} : null} + {message ? {message} : null} + + {busy ? : Save private accomplishment} + + + + ); +} + +function Profile({ user, onUserChange }) { + const [form, setForm] = useState({ name: user?.name || '', headline: user?.headline || '', bio: user?.bio || '', location: user?.location || '' }); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [message, setMessage] = useState(''); + + useEffect(() => { + setForm({ name: user?.name || '', headline: user?.headline || '', bio: user?.bio || '', location: user?.location || '' }); + }, [user]); + + const save = async () => { + if (busy) return; + setBusy(true); + setError(''); + setMessage(''); + try { + const updated = await updateProfile(user, form); + onUserChange(updated); + setMessage('Profile updated.'); + } catch (saveError) { + setError(getProductErrorMessage(saveError, 'Could not update your profile.')); + } finally { + setBusy(false); + } + }; + + return ( + + + + + + {user?.name || 'BragStack Member'} + {user?.public_slug ? `Public proof: usebragstack.com/brag/${user.public_slug}` : 'Public sharing stays under your control.'} + + + setForm((current) => ({ ...current, name: value }))} placeholder="Your name" autoCapitalize="words" /> + setForm((current) => ({ ...current, headline: value }))} placeholder="Platform Support Engineer" /> + setForm((current) => ({ ...current, location: value }))} placeholder="City, region" autoCapitalize="words" /> + setForm((current) => ({ ...current, bio: value }))} placeholder="A short professional summary" multiline /> + {error ? {error} : null} + {message ? {message} : null} + + {busy ? : Save profile} + + + + ); +} + +function Settings({ user, onSignOut }) { + const layout = useLayoutProfile(); + const split = layout.isTablet; + const open = (url) => Linking.openURL(url); + return ( + + + + Signed in{user?.email} + + + Production API{apiBaseURL} + + + + Privacy & support + open('https://usebragstack.com/privacy')} style={styles.settingsLink}>Privacy policy + open('https://usebragstack.com/terms')} style={styles.settingsLink}>Terms + open('https://usebragstack.com/docs')} style={styles.settingsLink}>Help & documentation + + Sign out + + ); +} + +function Tabs({ user, onUserChange, onSignOut }) { + const { width } = useWindowDimensions(); + const tabletNavigation = width >= 768; + const [overview, setOverview] = useState(EMPTY_OVERVIEW); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(''); + + const refresh = useCallback(async (quiet = false) => { + if (quiet) setRefreshing(true); + else setLoading(true); + setError(''); + try { + setOverview(await loadProofOverview()); + } catch (loadError) { + setError(getProductErrorMessage(loadError)); + } finally { + setLoading(false); + setRefreshing(false); + } + }, []); + + useEffect(() => { void refresh(false); }, [refresh]); + + const shared = useMemo( + () => ({ overview, loading, error, refreshing, onRefresh: () => refresh(true) }), + [overview, loading, error, refreshing, refresh], + ); + + return ( + + + ({ + headerShown: false, + tabBarActiveTintColor: colors.primary, + tabBarInactiveTintColor: colors.mutedStrong, + tabBarHideOnKeyboard: true, + tabBarPosition: tabletNavigation ? 'left' : 'bottom', + tabBarVariant: tabletNavigation ? 'material' : 'uikit', + tabBarLabelPosition: 'below-icon', + tabBarStyle: tabletNavigation ? styles.tabBarSide : styles.tabBarBottom, + tabBarItemStyle: tabletNavigation ? styles.tabItemSide : styles.tabItemBottom, + tabBarLabelStyle: tabletNavigation ? styles.tabLabelSide : styles.tabLabelBottom, + tabBarIcon: ({ color }) => {icons[route.name]}, + })} + > + {(props) => } + {(props) => } + {(props) => refresh(true)} />} + {(props) => } + {(props) => } + + + ); +} + +export default function App() { + const [user, setUser] = useState(null); + const [booting, setBooting] = useState(true); + + useEffect(() => { + if (Platform.OS !== 'web' || typeof document === 'undefined') return undefined; + const root = document.getElementById('root'); + const nodes = [document.documentElement, document.body, root].filter(Boolean); + nodes.forEach((node) => { + node.style.width = '100%'; + node.style.maxWidth = '100%'; + node.style.height = '100%'; + node.style.minWidth = '0'; + node.style.minHeight = '0'; + node.style.margin = '0'; + node.style.padding = '0'; + }); + if (root) { + root.style.display = 'flex'; + root.style.flexDirection = 'column'; + root.style.overflow = 'hidden'; + } + document.body.style.overflow = 'hidden'; + return undefined; + }, []); + + useEffect(() => { + let live = true; + restoreSession() + .then((restored) => { if (live) setUser(restored); }) + .catch(() => {}) + .finally(() => { if (live) setBooting(false); }); + return () => { live = false; }; + }, []); + + const signOut = async () => { + await logout(); + setUser(null); + }; + + const content = booting + ? Opening your BragStack… + : user + ? + : ; + + return {content}; +} + +const styles = StyleSheet.create({ + appRoot: { flex: 1, minWidth: 0, minHeight: 0, alignSelf: 'stretch', backgroundColor: colors.background }, + safe: { flex: 1, minWidth: 0, minHeight: 0, alignSelf: 'stretch', backgroundColor: colors.background }, + boot: { flex: 1, backgroundColor: colors.background, justifyContent: 'center', alignItems: 'center', gap: 20 }, + flexOne: { flex: 1, minWidth: 0 }, + + loginScroll: { flex: 1, minWidth: 0, width: '100%' }, + loginPage: { flexGrow: 1, alignItems: 'center', justifyContent: 'center', paddingVertical: 34, width: '100%', minWidth: 0 }, + loginPageCompact: { justifyContent: 'flex-start', paddingVertical: 14 }, + loginShell: { alignSelf: 'center', gap: 20, minWidth: 0, maxWidth: '100%' }, + loginShellSplit: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 48 }, + brandHeader: { gap: 12, minWidth: 0 }, + brandHeaderCompact: { gap: 9 }, + authAside: { flex: 1, maxWidth: 440, justifyContent: 'center' }, + authAsideCopy: { gap: 10, marginTop: 30 }, + authAsideTitle: { color: colors.text, fontSize: 36, lineHeight: 41, fontWeight: '900', letterSpacing: -0.7 }, + brand: { flexDirection: 'row', alignItems: 'center', gap: 12, minWidth: 0 }, + brandCopy: { flexShrink: 1, minWidth: 0 }, + brandName: { color: colors.text, fontSize: 26, fontWeight: '900', flexShrink: 1 }, + brandSmall: { fontSize: 20 }, + brandTagline: { color: colors.muted, fontSize: 12, marginTop: 2 }, + signalPill: { alignSelf: 'flex-start', flexDirection: 'row', alignItems: 'center', gap: 7, paddingHorizontal: 11, paddingVertical: 7, borderRadius: 999, backgroundColor: 'rgba(105,228,246,0.08)', borderWidth: 1, borderColor: 'rgba(105,228,246,0.20)' }, + signalDot: { color: colors.cyan, fontSize: 9 }, + signalText: { color: colors.cyan, fontSize: 9, fontWeight: '900', letterSpacing: 1.4 }, + orbBlue: { position: 'absolute', width: 250, height: 250, borderRadius: 125, backgroundColor: 'rgba(166,220,255,0.08)', top: -90, right: -100 }, + orbPurple: { position: 'absolute', width: 220, height: 220, borderRadius: 110, backgroundColor: 'rgba(173,145,255,0.08)', bottom: -90, left: -100 }, + + pageFrame: { flexGrow: 1, alignItems: 'center', paddingBottom: 110, paddingTop: 10, width: '100%', minWidth: 0 }, + pageFrameTablet: { paddingBottom: 42, paddingTop: 18 }, + pageFrameLandscapePhone: { paddingBottom: 88, paddingTop: 4 }, + pageShell: { alignSelf: 'center', maxWidth: '100%', minWidth: 0 }, + page: { paddingTop: 8, gap: 14, minWidth: 0 }, + kicker: { color: colors.primary, fontSize: 10, fontWeight: '900', letterSpacing: 1.8 }, + title: { color: colors.text, fontSize: 34, lineHeight: 39, fontWeight: '900', letterSpacing: -0.5, flexShrink: 1 }, + titleTablet: { fontSize: 40, lineHeight: 45 }, + titleCompact: { fontSize: 29, lineHeight: 34 }, + loginTitle: { color: colors.text, fontSize: 30, lineHeight: 35, fontWeight: '900', letterSpacing: -0.6, flexShrink: 1 }, + loginTitleCompact: { fontSize: 26, lineHeight: 31 }, + muted: { color: colors.muted, fontSize: 14, lineHeight: 21, flexShrink: 1 }, + note: { color: colors.mutedStrong, fontSize: 11, textAlign: 'center', flexShrink: 1 }, + + card: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: radius.lg, padding: 18, gap: 12, minWidth: 0, width: '100%' }, + loginCard: { backgroundColor: 'rgba(13,21,38,0.96)', borderColor: 'rgba(173,145,255,0.26)', padding: 20, gap: 12, shadowColor: '#000000', shadowOffset: { width: 0, height: 16 }, shadowOpacity: 0.28, shadowRadius: 30, elevation: 12 }, + loginCardCompact: { padding: 16, gap: 10, borderRadius: 22 }, + loginCardSplit: { flex: 1, maxWidth: 540 }, + label: { color: colors.muted, fontSize: 10, fontWeight: '900', letterSpacing: 1.4, marginTop: 2 }, + fieldGroup: { gap: 7, width: '100%' }, + input: { minHeight: 50, width: '100%', backgroundColor: 'rgba(19,30,51,0.92)', borderWidth: 1, borderColor: 'rgba(166,220,255,0.16)', borderRadius: 14, color: colors.text, paddingHorizontal: 14, paddingVertical: 12, fontSize: 15, minWidth: 0 }, + textarea: { minHeight: 88 }, + button: { minHeight: 52, width: '100%', borderRadius: radius.pill, backgroundColor: colors.primary, borderWidth: 1, borderColor: 'rgba(255,255,255,0.22)', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 16 }, + buttonText: { color: colors.background, fontWeight: '900', textAlign: 'center' }, + secondaryButton: { minHeight: 46, width: '100%', borderRadius: radius.pill, borderWidth: 1, borderColor: colors.border, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 16 }, + secondaryButtonText: { color: colors.text, fontWeight: '800' }, + disabled: { opacity: 0.45 }, + linkButton: { alignSelf: 'center', paddingVertical: 4, paddingHorizontal: 8 }, + linkText: { color: colors.primary, fontWeight: '850' }, + authSwitch: { alignSelf: 'center', paddingVertical: 2 }, + error: { color: colors.danger, fontSize: 13, lineHeight: 19 }, + success: { color: colors.cyan, fontSize: 13, lineHeight: 19 }, + divider: { flexDirection: 'row', alignItems: 'center', gap: 10, marginVertical: 2, minWidth: 0 }, + dividerLine: { flex: 1, height: 1, backgroundColor: colors.border }, + dividerText: { color: colors.mutedStrong, fontSize: 9, fontWeight: '900', letterSpacing: 1.1, flexShrink: 1 }, + socialStack: { gap: 9 }, + socialButton: { minHeight: 50, width: '100%', borderRadius: 14, paddingHorizontal: 16, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10 }, + googleButton: { backgroundColor: '#FFFFFF', borderWidth: 1, borderColor: '#DADCE0' }, + googleText: { color: '#202124', fontWeight: '800', flexShrink: 1 }, + githubButton: { backgroundColor: '#24292F', borderWidth: 1, borderColor: '#57606A' }, + githubText: { color: '#FFFFFF', fontWeight: '800', flexShrink: 1 }, + securityRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, marginTop: 2, flexWrap: 'wrap' }, + securityIcon: { color: colors.cyan, fontSize: 15 }, + + homeGrid: { gap: 14, width: '100%' }, + homeGridWide: { flexDirection: 'row', alignItems: 'stretch', gap: 18 }, + homePrimary: { width: '100%' }, + homePrimaryWide: { flex: 1.18, minWidth: 0 }, + homeSecondary: { width: '100%', gap: 14 }, + homeSecondaryWide: { flex: 0.82, minWidth: 0 }, + hero: { backgroundColor: colors.surface, borderWidth: 1, borderColor: 'rgba(166,220,255,0.28)', borderRadius: radius.lg, padding: 18, gap: 10, width: '100%' }, + heroLabel: { color: colors.primary, fontWeight: '900', fontSize: 10, letterSpacing: 1.8 }, + heroTitle: { color: colors.text, fontSize: 23, lineHeight: 28, fontWeight: '900' }, + heroTitleWide: { fontSize: 31, lineHeight: 36 }, + metrics: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 6 }, + metric: { flexGrow: 1, flexBasis: 88, backgroundColor: colors.surfaceElevated, borderRadius: radius.md, padding: 10 }, + metricNum: { color: colors.primary, fontSize: 21, fontWeight: '900' }, + metricText: { color: colors.muted, fontSize: 10 }, + action: { flexDirection: 'row', alignItems: 'center', gap: 12, backgroundColor: colors.surfaceElevated, borderRadius: radius.lg, borderWidth: 1, borderColor: colors.border, padding: 15, minWidth: 0, width: '100%' }, + plus: { width: 40, height: 40, borderRadius: 20, backgroundColor: colors.primary, color: colors.background, textAlign: 'center', textAlignVertical: 'center', fontSize: 27 }, + arrow: { color: colors.primary, fontSize: 28 }, + arrowSmall: { color: colors.primary, fontSize: 22 }, + cardTitle: { color: colors.text, fontSize: 17, fontWeight: '900', flexShrink: 1 }, + row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', gap: 8, flexWrap: 'wrap' }, + pill: { borderRadius: radius.pill, borderWidth: 1, borderColor: 'rgba(166,220,255,0.28)', backgroundColor: 'rgba(166,220,255,0.10)', paddingHorizontal: 10, paddingVertical: 6 }, + pillText: { color: colors.text, fontSize: 10, fontWeight: '900' }, + private: { color: colors.mutedStrong, fontSize: 11 }, + loadingCard: { minHeight: 90, borderRadius: radius.lg, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface, alignItems: 'center', justifyContent: 'center', gap: 10, padding: 16, width: '100%' }, + emptyCard: { borderRadius: radius.lg, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface, padding: 18, gap: 8, width: '100%' }, + sectionLabel: { color: colors.mutedStrong, fontSize: 10, fontWeight: '900', letterSpacing: 1.5, marginTop: 6 }, + + proofGrid: { width: '100%', gap: 12 }, + proofGridWide: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'stretch', gap: 14 }, + proofGridItem: { width: '100%' }, + proofGridItemWide: { width: '48.9%' }, + + profileHeader: { flexDirection: 'row', alignItems: 'center', gap: 12, minWidth: 0, marginBottom: 2 }, + profileName: { color: colors.text, fontSize: 20, fontWeight: '900', flexShrink: 1 }, + settingsGrid: { gap: 12, width: '100%' }, + settingsGridWide: { flexDirection: 'row', alignItems: 'stretch' }, + settingsGridItem: { width: '100%' }, + settingsGridItemWide: { flex: 1, minWidth: 0 }, + settingsLink: { minHeight: 46, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', borderTopWidth: 1, borderTopColor: colors.border, gap: 10 }, + signout: { minHeight: 52, borderRadius: radius.pill, borderWidth: 1, borderColor: 'rgba(255,176,176,0.3)', alignItems: 'center', justifyContent: 'center', width: '100%' }, + signoutText: { color: colors.danger, fontWeight: '900' }, + + tabBarBottom: { backgroundColor: colors.sidebar, borderTopColor: colors.border, height: 76, paddingTop: 7, paddingBottom: 9 }, + tabBarSide: { backgroundColor: colors.sidebar, borderTopWidth: 0, borderRightWidth: 1, borderRightColor: colors.border, width: 96, paddingVertical: 16 }, + tabItemBottom: { minHeight: 54 }, + tabItemSide: { minHeight: 70, marginVertical: 2, borderRadius: 18 }, + tabLabelBottom: { fontSize: 10, fontWeight: '800' }, + tabLabelSide: { fontSize: 10, fontWeight: '850', marginTop: 3 }, + tabIcon: { fontSize: 18, fontWeight: '800' }, +}); diff --git a/mobile/README.md b/mobile/README.md new file mode 100644 index 0000000..904decd --- /dev/null +++ b/mobile/README.md @@ -0,0 +1,76 @@ +# BragStack Mobile + +Cross-platform iOS and Android client for BragStack, built with React Native and Expo. + +> **Status:** active pre-release development. The app is not yet publicly available in the Apple App Store or Google Play. + +## Run locally + +```bash +cd mobile +npm install +EXPO_PUBLIC_API_URL=http://localhost:8000 npm start +``` + +For a physical device, set `EXPO_PUBLIC_API_URL` to an address the device can reach rather than `localhost`. + +Preview and production EAS profiles are configured to use the canonical BragStack production API at `https://api.usebragstack.com`. + +## Current implementation + +- React Native / Expo app for iOS and Android +- Official BragStack vector brandmark and authenticated-app theme +- Responsive phone/tablet layout with safe-area handling +- Home, Proof, Add, Profile, and Settings navigation +- Real password sign-in through `/auth/login` +- In-app account registration through `/auth/register` +- Verification-email resend support +- Password-reset request support +- Encrypted access-token storage through Expo SecureStore +- Session restore through `/auth/me` +- Sign out that clears the local token +- Live accomplishments and Impact Receipts loaded from the production APIs +- Pull-to-refresh, loading, empty, retry/error presentation +- Real private-by-default accomplishment capture through `/entries` +- Profile editing through `/auth/me/profile` +- EAS preview and production build profiles +- Mobile CI with Expo Doctor, unit/coverage tests, and bundle export smoke testing +- No unnecessary native permissions + +## Authentication behavior + +BragStack Mobile shares the same identity system as the web app. Password login requires a verified email. Registration and reset requests use the existing BragStack email flows. + +Verification and password-reset emails currently finish in the BragStack web experience. Native deep-link completion remains a release-hardening task. + +Google and GitHub sign-in are intentionally not exposed in the native app yet. The current backend OAuth callback returns to the web app, and an iOS release that exposes third-party social login must also satisfy Apple's sign-in requirements. Web preview may continue to expose those providers. + +## Data behavior + +Home and Proof use live authenticated BragStack data. Quick Capture writes a real accomplishment to the signed-in account and always creates it as private. The capture form requires context, contribution, and impact instead of inventing missing career evidence. + +Impact Receipts are read live. Full receipt creation/editing, evidence attachment, and verification workflows remain later mobile slices. + +## Public-store blockers + +Do not call the app store-ready until these gates are complete: + +- in-app account deletion and end-to-end deletion verification +- native verification/reset deep links or an explicitly validated web-return flow +- full accessibility and dynamic-text review +- app icon, splash, screenshots, store copy, and support metadata +- Apple privacy disclosures / required-reason review +- Google Play Data safety disclosure +- iOS signing and TestFlight validation +- Android signing and Play internal testing +- device/OS compatibility matrix and release QA +- security review of tokens, deep links, logs, analytics, and evidence handling +- native social sign-in only after the callback flow and Apple requirements are satisfied + +## Documentation + +- `../docs/MOBILE_APP.md` — architecture, auth, brand, privacy, and store-readiness gates +- `../docs/MOBILE_CUSTOMER_GUIDE.md` — customer-facing mobile guidance source +- `../docs/ROADMAP.md` — phased delivery plan and success criteria +- issue #200 — mobile program epic +- PR #201 — mobile foundation and store-readiness work diff --git a/mobile/TESTING.md b/mobile/TESTING.md new file mode 100644 index 0000000..bb02ce8 --- /dev/null +++ b/mobile/TESTING.md @@ -0,0 +1,117 @@ +# BragStack Mobile Testing + +BragStack Mobile is pre-release. Use this checklist before treating a build as store-ready. + +## Codespaces / local code checks + +From the repository root: + +```bash +cd mobile +node --version +npm install +npm run doctor +npm run test:ci +npx expo export --platform web --output-dir dist-ci +``` + +Expo SDK 57 requires Node 22.13 or newer. If `node --version` is older, switch the environment to Node 22 before installing dependencies. + +Expected result: Expo Doctor passes, the Jest suites pass with coverage thresholds, and the web bundle export completes without an unhandled error. + +## Interactive Codespaces preview + +For the quickest end-to-end UI test in Codespaces, use the canonical BragStack production API rather than `localhost`: + +```bash +cd mobile +EXPO_PUBLIC_API_URL=https://api.usebragstack.com npm run web +``` + +Or use the repository shortcut: + +```bash +npm run web:codespaces +``` + +Open the forwarded Expo web port shown by Codespaces (normally 8081). If the Ports panel does not open it automatically, open the forwarded port from the Codespaces **Ports** tab. + +Do not copy `.env.example` unchanged for a browser preview in Codespaces because `http://localhost:8000` would refer to the computer running the browser rather than the Codespace. If you intentionally run the backend inside the Codespace, either leave `EXPO_PUBLIC_API_URL` unset so the app can derive the forwarded `-8000.app.github.dev` host, or set it to the exact forwarded backend URL. + +Browser testing is useful for JavaScript/runtime/UI checks but does not validate native SecureStore, native rotation callbacks, iOS/Android lifecycle, signing, or store behavior. + +## Test phones and tablets in Chrome DevTools + +In the Expo web preview: + +1. Open Chrome DevTools. +2. Toggle the device toolbar (`Ctrl+Shift+M`). +3. Test each size in portrait. +4. Rotate the device toolbar and repeat in landscape. +5. Keep zoom at 100% while checking layout; use browser zoom separately only for accessibility checks. + +Minimum viewport matrix: + +| Device class | Portrait | Landscape | +| --- | ---: | ---: | +| Small phone / iPhone SE class | 375 × 667 | 667 × 375 | +| Standard Android phone | 412 × 915 | 915 × 412 | +| Large iPhone / Pro Max class | 440 × 956 | 956 × 440 | +| Small tablet / iPad mini class | 744 × 1133 | 1133 × 744 | +| Standard tablet / iPad class | 820 × 1180 | 1180 × 820 | +| Large tablet / iPad Pro class | 1032 × 1376 | 1376 × 1032 | +| Android tablet baseline | 800 × 1280 | 1280 × 800 | + +For every viewport, verify there is **no horizontal clipping**, no unreachable action, no content hidden behind the tab bar or safe area, and forms remain usable when the on-screen keyboard would reduce vertical space. + +## Landscape and tablet acceptance checks + +- The app can rotate between portrait and landscape; Expo configuration uses `orientation: default`. +- iOS tablet support remains enabled with `supportsTablet: true`. +- Login/register/reset content stays centered and scrollable on short landscape screens. +- Home, Proof, Add, Profile, and Settings remain usable in both orientations. +- Cards never extend beyond the visible viewport. +- Long titles, email addresses, API URLs, tags, and proof text wrap instead of causing horizontal overflow. +- Bottom navigation remains reachable on phones and does not cover page content. +- Tablet layouts use the available width without stretching form controls to an unreadable line length. +- Landscape phone layouts prioritize vertical space and remain scrollable. +- Rotation does not discard unsaved form values. + +## Manual product acceptance checks + +- Official BragStack brandmark is visible and not replaced by a placeholder. +- Authenticated UI uses the approved BragStack mobile visual system. +- Login rejects empty credentials and surfaces backend errors. +- A valid verified BragStack account can sign in. +- Registration reaches the real backend and communicates verification state. +- Password reset request reaches the real backend. +- Relaunch restores a valid session on native builds. +- Expired/invalid sessions return to sign-in instead of trapping the user. +- Sign out clears the local session. +- Tabs navigate without crashes or blank screens. +- Home and Proof show real synced data rather than demo metrics/cards. +- Quick Capture writes a real private accomplishment. +- Missing professional results are never fabricated. +- New proof remains private by default. +- Profile edits persist through the production API. +- Pull-to-refresh works on data screens. +- Loading, empty, and API-error states remain legible at all target sizes. +- No unexpected device permission prompt appears during normal launch/navigation. +- No passwords, access tokens, or confidential evidence appear in logs/errors. + +## Real-device checks required before store release + +Codespaces responsive preview is not enough to certify device support. Run preview/internal builds on at least: + +- one smaller iPhone +- one current large iPhone +- one current Android phone +- one iPad or iPad mini +- one larger iPad/iPad Pro class device or simulator +- one Android tablet class device or emulator + +Test both portrait and landscape where the OS/device supports rotation. Verify SecureStore persistence, keyboard behavior, safe areas, gestures, background/foreground lifecycle, network loss/retry behavior, deep links when implemented, accessibility/dynamic text, account deletion/recovery flows, and native rotation behavior. + +## Store gate + +Do not submit to App Store Connect or Google Play production until automated checks pass, the phone/tablet orientation matrix passes, real-device testing passes, production signing/configuration is complete, privacy/data-safety disclosures match actual behavior, account deletion is verified, Terms/Privacy links work, store metadata/screenshots are final, and the release candidate uses the production API. diff --git a/mobile/__tests__/api.test.js b/mobile/__tests__/api.test.js new file mode 100644 index 0000000..799aa1a --- /dev/null +++ b/mobile/__tests__/api.test.js @@ -0,0 +1,42 @@ +const mockUseRequestInterceptor = jest.fn(); +const mockCreate = jest.fn(() => ({ + interceptors: { request: { use: mockUseRequestInterceptor } }, +})); + +jest.mock('axios', () => ({ create: mockCreate })); +jest.mock('../src/authStorage', () => ({ getAccessToken: jest.fn() })); + +describe('authenticated API client', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseRequestInterceptor.mockClear(); + mockCreate.mockClear(); + }); + + it('uses the configured API URL and a bounded timeout', () => { + process.env.EXPO_PUBLIC_API_URL = 'https://api.example.test'; + jest.isolateModules(() => { + require('../src/api'); + }); + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ + baseURL: 'https://api.example.test', + timeout: 15000, + })); + }); + + it('adds a bearer token when one exists and leaves anonymous requests alone', async () => { + const { getAccessToken } = require('../src/authStorage'); + jest.isolateModules(() => { + require('../src/api'); + }); + const interceptor = mockUseRequestInterceptor.mock.calls[0][0]; + + getAccessToken.mockResolvedValueOnce('abc123'); + const signed = await interceptor({ headers: {} }); + expect(signed.headers.Authorization).toBe('Bearer abc123'); + + getAccessToken.mockResolvedValueOnce(null); + const anonymous = await interceptor({ headers: {} }); + expect(anonymous.headers.Authorization).toBeUndefined(); + }); +}); diff --git a/mobile/__tests__/authApi.test.js b/mobile/__tests__/authApi.test.js new file mode 100644 index 0000000..ca2acf7 --- /dev/null +++ b/mobile/__tests__/authApi.test.js @@ -0,0 +1,102 @@ +jest.mock('../src/api', () => ({ api: { post: jest.fn(), get: jest.fn() } })); +jest.mock('../src/authStorage', () => ({ + setAccessToken: jest.fn(), + clearAccessToken: jest.fn(), +})); + +import { api } from '../src/api'; +import { clearAccessToken, setAccessToken } from '../src/authStorage'; +import { + confirmEmailVerification, + confirmPasswordReset, + getAuthErrorMessage, + login, + logout, + register, + requestPasswordReset, + resendVerification, + restoreSession, +} from '../src/authApi'; + +describe('mobile auth API', () => { + beforeEach(() => jest.clearAllMocks()); + + it('normalizes email, stores the returned token, and returns the user on login', async () => { + api.post.mockResolvedValue({ data: { access_token: 'token-123', user: { email: 'tee@example.com' } } }); + + await expect(login(' Tee@Example.COM ', 'secret')).resolves.toEqual({ email: 'tee@example.com' }); + expect(api.post).toHaveBeenCalledWith('/auth/login', 'username=tee%40example.com&password=secret', { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }); + expect(setAccessToken).toHaveBeenCalledWith('token-123'); + }); + + it('registers using trimmed identity data', async () => { + api.post.mockResolvedValue({ data: { verification_required: true, email_sent: true } }); + await expect(register(' Tee ', ' Tee@Example.COM ', 'password123')).resolves.toEqual({ verification_required: true, email_sent: true }); + expect(api.post).toHaveBeenCalledWith('/auth/register', { + name: 'Tee', + email: 'tee@example.com', + password: 'password123', + }); + }); + + it('resends verification and requests password recovery with normalized email', async () => { + api.post + .mockResolvedValueOnce({ data: { message: 'verification sent' } }) + .mockResolvedValueOnce({ data: { message: 'reset sent' } }); + + await expect(resendVerification(' Tee@Example.COM ')).resolves.toEqual({ message: 'verification sent' }); + await expect(requestPasswordReset(' Tee@Example.COM ')).resolves.toEqual({ message: 'reset sent' }); + expect(api.post).toHaveBeenNthCalledWith(1, '/auth/email-verification/resend', { email: 'tee@example.com' }); + expect(api.post).toHaveBeenNthCalledWith(2, '/auth/password-reset/request', { email: 'tee@example.com' }); + }); + + it('confirms email verification, stores the token, and returns the user', async () => { + api.post.mockResolvedValue({ data: { access_token: 'verified-token', user: { name: 'Tee' } } }); + await expect(confirmEmailVerification('verify-token-value')).resolves.toEqual({ name: 'Tee' }); + expect(api.post).toHaveBeenCalledWith('/auth/email-verification/confirm', { token: 'verify-token-value' }); + expect(setAccessToken).toHaveBeenCalledWith('verified-token'); + }); + + it('confirms a password reset', async () => { + api.post.mockResolvedValue({ data: { message: 'Password updated.' } }); + await expect(confirmPasswordReset('reset-token-value', 'new-password')).resolves.toEqual({ message: 'Password updated.' }); + expect(api.post).toHaveBeenCalledWith('/auth/password-reset/confirm', { + token: 'reset-token-value', + password: 'new-password', + }); + }); + + it('restores the signed-in user from /auth/me', async () => { + api.get.mockResolvedValue({ data: { name: 'Tee' } }); + await expect(restoreSession()).resolves.toEqual({ name: 'Tee' }); + }); + + it('clears an expired token when /auth/me returns 401', async () => { + const error = { response: { status: 401 } }; + api.get.mockRejectedValue(error); + await expect(restoreSession()).rejects.toBe(error); + expect(clearAccessToken).toHaveBeenCalledTimes(1); + }); + + it('preserves credentials for non-auth server errors', async () => { + const error = { response: { status: 503 } }; + api.get.mockRejectedValue(error); + await expect(restoreSession()).rejects.toBe(error); + expect(clearAccessToken).not.toHaveBeenCalled(); + }); + + it('clears local credentials on logout', async () => { + await logout(); + expect(clearAccessToken).toHaveBeenCalledTimes(1); + }); + + it('returns specific and safe auth error messages', () => { + expect(getAuthErrorMessage({ response: { data: { detail: 'Email verification required.' }, status: 403 } })).toBe('Email verification required.'); + expect(getAuthErrorMessage({ response: { data: { detail: { message: 'Receipt already exists.' } }, status: 409 } })).toBe('Receipt already exists.'); + expect(getAuthErrorMessage(new Error('network'))).toMatch(/Could not reach BragStack/); + expect(getAuthErrorMessage({ response: { data: {}, status: 503 } })).toMatch(/having trouble/); + expect(getAuthErrorMessage({ response: { data: {}, status: 400 } })).toMatch(/check your details/i); + }); +}); diff --git a/mobile/__tests__/authStorage.test.js b/mobile/__tests__/authStorage.test.js new file mode 100644 index 0000000..fb38300 --- /dev/null +++ b/mobile/__tests__/authStorage.test.js @@ -0,0 +1,32 @@ +jest.mock('expo-secure-store', () => ({ + getItemAsync: jest.fn(), + setItemAsync: jest.fn(), + deleteItemAsync: jest.fn(), +})); + +import * as SecureStore from 'expo-secure-store'; +import { clearAccessToken, getAccessToken, setAccessToken } from '../src/authStorage'; + +describe('encrypted auth storage', () => { + beforeEach(() => jest.clearAllMocks()); + + it('uses the stable BragStack token key', async () => { + SecureStore.getItemAsync.mockResolvedValue('abc'); + await expect(getAccessToken()).resolves.toBe('abc'); + expect(SecureStore.getItemAsync).toHaveBeenCalledWith('bragstack.accessToken'); + }); + + it('stores and deletes the token through SecureStore', async () => { + await setAccessToken('abc'); + expect(SecureStore.setItemAsync).toHaveBeenCalledWith('bragstack.accessToken', 'abc'); + + await clearAccessToken(); + expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith('bragstack.accessToken'); + }); + + it('treats an empty token as a delete', async () => { + await setAccessToken(''); + expect(SecureStore.setItemAsync).not.toHaveBeenCalled(); + expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith('bragstack.accessToken'); + }); +}); diff --git a/mobile/__tests__/productApi.test.js b/mobile/__tests__/productApi.test.js new file mode 100644 index 0000000..9200bfb --- /dev/null +++ b/mobile/__tests__/productApi.test.js @@ -0,0 +1,149 @@ +jest.mock('../src/api', () => ({ + api: { + get: jest.fn(), + post: jest.fn(), + patch: jest.fn(), + }, +})); + +import { api } from '../src/api'; +import { + buildEntryPayload, + createPrivateEntry, + getEntries, + getImpactReceipts, + getProductErrorMessage, + loadProofOverview, + updateProfile, +} from '../src/productApi'; + +describe('mobile product API', () => { + beforeEach(() => jest.clearAllMocks()); + + it('loads entries and Impact Receipts with pagination', async () => { + api.get + .mockResolvedValueOnce({ data: { entries: [{ id: 'e1' }] } }) + .mockResolvedValueOnce({ data: { receipts: [{ id: 'r1' }] } }); + + await expect(getEntries(5, 10)).resolves.toEqual({ entries: [{ id: 'e1' }] }); + await expect(getImpactReceipts(7, 3)).resolves.toEqual({ receipts: [{ id: 'r1' }] }); + expect(api.get).toHaveBeenNthCalledWith(1, '/entries', { params: { limit: 5, skip: 10 } }); + expect(api.get).toHaveBeenNthCalledWith(2, '/impact-receipts', { params: { limit: 7, skip: 3 } }); + }); + + it('combines proof pages into a dashboard overview', async () => { + api.get + .mockResolvedValueOnce({ data: { total_entries: 2, entries: [{ id: 'e1' }, { id: 'e2' }] } }) + .mockResolvedValueOnce({ data: { total_receipts: 1, receipts: [{ id: 'r1' }] } }); + + await expect(loadProofOverview()).resolves.toEqual({ + entries: [{ id: 'e1' }, { id: 'e2' }], + receipts: [{ id: 'r1' }], + totalEntries: 2, + totalReceipts: 1, + }); + }); + + it('uses safe empty defaults when proof pages omit optional collections', async () => { + api.get + .mockResolvedValueOnce({ data: {} }) + .mockResolvedValueOnce({ data: {} }); + + await expect(loadProofOverview()).resolves.toEqual({ + entries: [], + receipts: [], + totalEntries: 0, + totalReceipts: 0, + }); + }); + + it('builds a truthful private accomplishment payload', () => { + const payload = buildEntryPayload({ + title: ' Fixed production deploys ', + situation: ' Deploys were failing ', + action: ' Added validation ', + impact: ' Restored reliable releases ', + category: ' Platform ', + tags: 'Docker, CI, Docker, ', + }); + + expect(payload).toEqual(expect.objectContaining({ + title: 'Fixed production deploys', + situation: 'Deploys were failing', + action: 'Added validation', + impact: 'Restored reliable releases', + category: 'Platform', + entry_type: 'Current Job', + lesson: null, + tags: ['Docker', 'CI'], + is_public: false, + })); + expect(payload.entry_date).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it('defaults the category and rejects incomplete proof', () => { + expect(buildEntryPayload({ title: 'Win', situation: 'Context', action: 'Action', impact: 'Impact' }).category).toBe('General'); + expect(() => buildEntryPayload({ title: 'Win', situation: '', action: 'Action', impact: 'Impact' })).toThrow(/required/); + }); + + it('persists a private accomplishment through /entries', async () => { + api.post.mockResolvedValue({ data: { id: 'entry-1', title: 'Win' } }); + await expect(createPrivateEntry({ + title: 'Win', + situation: 'Context', + action: 'Action', + impact: 'Impact', + tags: '', + })).resolves.toEqual({ id: 'entry-1', title: 'Win' }); + + expect(api.post).toHaveBeenCalledWith('/entries', expect.objectContaining({ + title: 'Win', + is_public: false, + })); + }); + + it('updates profile while preserving fields not edited on mobile', async () => { + api.patch.mockResolvedValue({ data: { name: 'Tee', headline: 'Platform Engineer' } }); + const user = { + name: 'Old Name', + headline: 'Old headline', + bio: 'Old bio', + location: 'Old location', + github_url: 'https://github.com/example', + portfolio_url: 'https://example.com', + resume_url: 'https://example.com/resume.pdf', + profile_theme: 'engineer', + profile_primary_color: '#112233', + profile_secondary_color: '#223344', + profile_background_color: '#334455', + }; + + await expect(updateProfile(user, { + name: ' Tee ', + headline: ' Platform Engineer ', + bio: ' Builds reliable systems ', + location: ' Georgia ', + })).resolves.toEqual({ name: 'Tee', headline: 'Platform Engineer' }); + + expect(api.patch).toHaveBeenCalledWith('/auth/me/profile', { + name: 'Tee', + headline: 'Platform Engineer', + bio: 'Builds reliable systems', + location: 'Georgia', + github_url: 'https://github.com/example', + portfolio_url: 'https://example.com', + resume_url: 'https://example.com/resume.pdf', + profile_theme: 'engineer', + profile_primary_color: '#112233', + profile_secondary_color: '#223344', + profile_background_color: '#334455', + }); + }); + + it('uses backend detail, offline, and fallback product errors safely', () => { + expect(getProductErrorMessage({ response: { data: { detail: 'Specific problem' } } })).toBe('Specific problem'); + expect(getProductErrorMessage({ response: { data: { detail: { message: 'Structured problem' } } } })).toBe('Structured problem'); + expect(getProductErrorMessage(new Error('network'))).toMatch(/Could not reach BragStack/); + expect(getProductErrorMessage({ response: { data: {} } }, 'Try again later')).toBe('Try again later'); + }); +}); diff --git a/mobile/__tests__/receiptDraft.test.js b/mobile/__tests__/receiptDraft.test.js new file mode 100644 index 0000000..6b9035b --- /dev/null +++ b/mobile/__tests__/receiptDraft.test.js @@ -0,0 +1,23 @@ +import { createReceiptDraft } from '../src/receiptDraft'; + +describe('createReceiptDraft', () => { + it('requires an accomplishment', () => { + expect(createReceiptDraft(' ', 'Saved 20 minutes')).toBeNull(); + }); + + it('normalizes user-entered accomplishment and result', () => { + expect(createReceiptDraft(' Shipped safer deploys ', ' 30% fewer rollbacks ')).toEqual({ + win: 'Shipped safer deploys', + result: '30% fewer rollbacks', + resultDisplay: '30% fewer rollbacks', + visibility: 'private', + }); + }); + + it('never fabricates a missing result', () => { + const draft = createReceiptDraft('Documented the incident response flow', ''); + expect(draft.result).toBe(''); + expect(draft.resultDisplay).toBe('Result not added yet — BragStack will not invent one.'); + expect(draft.visibility).toBe('private'); + }); +}); diff --git a/mobile/__tests__/theme.test.js b/mobile/__tests__/theme.test.js new file mode 100644 index 0000000..698c7d3 --- /dev/null +++ b/mobile/__tests__/theme.test.js @@ -0,0 +1,18 @@ +import { colors, navigationTheme } from '../src/theme'; + +describe('BragStack mobile brand tokens', () => { + it('uses the canonical mobile brand palette', () => { + expect(colors.background).toBe('#070B14'); + expect(colors.text).toBe('#F8FAFC'); + expect(colors.primary).toBe('#A6DCFF'); + expect(colors.secondary).toBe('#AD91FF'); + expect(colors.cyan).toBe('#69E4F6'); + }); + + it('keeps navigation aligned with the same app palette', () => { + expect(navigationTheme.dark).toBe(true); + expect(navigationTheme.colors.primary).toBe(colors.primary); + expect(navigationTheme.colors.background).toBe(colors.background); + expect(navigationTheme.colors.notification).toBe(colors.secondary); + }); +}); diff --git a/mobile/app.json b/mobile/app.json new file mode 100644 index 0000000..6444697 --- /dev/null +++ b/mobile/app.json @@ -0,0 +1,23 @@ +{ + "expo": { + "name": "BragStack", + "slug": "bragstack", + "version": "0.1.0", + "orientation": "default", + "userInterfaceStyle": "dark", + "scheme": "bragstack", + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.bragstack.app" + }, + "android": { + "package": "com.bragstack.app", + "adaptiveIcon": { + "backgroundColor": "#09090f" + } + }, + "extra": { + "apiUrl": "${EXPO_PUBLIC_API_URL}" + } + } +} diff --git a/mobile/assets/brandmark.svg b/mobile/assets/brandmark.svg new file mode 100644 index 0000000..e83deed --- /dev/null +++ b/mobile/assets/brandmark.svg @@ -0,0 +1,19 @@ + + BragStack brandmark + A stylized B built from stacked proof cards with an upward career arrow. + + + + + + + + + + + + + + + + diff --git a/mobile/eas.json b/mobile/eas.json new file mode 100644 index 0000000..3e7a1a8 --- /dev/null +++ b/mobile/eas.json @@ -0,0 +1,22 @@ +{ + "cli": { + "appVersionSource": "remote" + }, + "build": { + "preview": { + "distribution": "internal", + "env": { + "EXPO_PUBLIC_API_URL": "https://api.usebragstack.com" + } + }, + "production": { + "autoIncrement": true, + "env": { + "EXPO_PUBLIC_API_URL": "https://api.usebragstack.com" + } + } + }, + "submit": { + "production": {} + } +} diff --git a/mobile/index.js b/mobile/index.js new file mode 100644 index 0000000..112bddf --- /dev/null +++ b/mobile/index.js @@ -0,0 +1,37 @@ +if (typeof document !== 'undefined') { + let viewport = document.querySelector('meta[name="viewport"]'); + if (!viewport) { + viewport = document.createElement('meta'); + viewport.setAttribute('name', 'viewport'); + document.head.appendChild(viewport); + } + viewport.setAttribute('content', 'width=device-width, initial-scale=1, viewport-fit=cover'); + + const style = document.createElement('style'); + style.setAttribute('data-bragstack-viewport', 'true'); + style.textContent = ` + html, body, #root { + width: 100%; + min-width: 0; + height: 100%; + min-height: 100%; + margin: 0; + padding: 0; + overflow-x: hidden; + background: #070B14; + } + #root { + display: flex; + flex-direction: column; + } + *, *::before, *::after { + box-sizing: border-box; + } + `; + document.head.appendChild(style); +} + +const { registerRootComponent } = require('expo'); +const App = require('./App').default; + +registerRootComponent(App); diff --git a/mobile/package.json b/mobile/package.json new file mode 100644 index 0000000..521beea --- /dev/null +++ b/mobile/package.json @@ -0,0 +1,54 @@ +{ + "name": "bragstack-mobile", + "version": "0.1.0", + "private": true, + "main": "index.js", + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web", + "web:codespaces": "EXPO_PUBLIC_API_URL=https://api.usebragstack.com expo start --web", + "doctor": "expo-doctor", + "test": "jest", + "test:watch": "jest --watch", + "test:ci": "jest --runInBand --coverage" + }, + "dependencies": { + "@react-navigation/bottom-tabs": "^7.2.0", + "@react-navigation/native": "^7.1.0", + "axios": "^1.7.9", + "expo": "~57.0.9", + "expo-secure-store": "~57.0.2", + "expo-status-bar": "~57.0.1", + "react": "19.2.3", + "react-native": "0.86.3", + "react-native-safe-area-context": "~5.7.0", + "react-native-screens": "4.26.0", + "react-native-svg": "15.15.4", + "react-dom": "19.2.3", + "react-native-web": "^0.21.2" + }, + "devDependencies": { + "@babel/core": "^7.25.2", + "@testing-library/react-native": "^14.0.1", + "expo-doctor": "^1.20.4", + "jest": "^29.7.0", + "jest-expo": "~57.0.5" + }, + "jest": { + "preset": "jest-expo", + "collectCoverageFrom": [ + "src/**/*.js", + "!src/Brandmark.js" + ], + "coverageThreshold": { + "global": { + "branches": 65, + "functions": 75, + "lines": 75, + "statements": 75 + } + } + } +} diff --git a/mobile/src/Brandmark.js b/mobile/src/Brandmark.js new file mode 100644 index 0000000..34a0eba --- /dev/null +++ b/mobile/src/Brandmark.js @@ -0,0 +1,26 @@ +import React from 'react'; +import Svg, { Defs, LinearGradient, Path, Rect, Stop } from 'react-native-svg'; + +// Exact path geometry and gradient stops from frontend/public/brandmark.svg. +// Kept as a React Native component so the canonical vector renders natively. +export default function Brandmark({ size = 48 }) { + return ( + + + + + + + + + + + + + + + + + + ); +} diff --git a/mobile/src/api.js b/mobile/src/api.js new file mode 100644 index 0000000..6b64ab4 --- /dev/null +++ b/mobile/src/api.js @@ -0,0 +1,31 @@ +import axios from 'axios'; +import { getAccessToken } from './authStorage'; + +function resolveApiBaseURL() { + const configured = process.env.EXPO_PUBLIC_API_URL?.replace(/\/$/, ''); + if (configured) return configured; + + if (typeof window !== 'undefined') { + const host = window.location.hostname; + if (host.endsWith('.app.github.dev')) { + const backendHost = host.replace(/-\d+\.app\.github\.dev$/, '-8000.app.github.dev'); + if (backendHost !== host) return `https://${backendHost}`; + } + } + + return 'http://localhost:8000'; +} + +export const apiBaseURL = resolveApiBaseURL(); + +export const api = axios.create({ + baseURL: apiBaseURL, + timeout: 15000, + headers: { 'Content-Type': 'application/json' }, +}); + +api.interceptors.request.use(async (config) => { + const token = await getAccessToken(); + if (token) config.headers.Authorization = `Bearer ${token}`; + return config; +}); diff --git a/mobile/src/authApi.js b/mobile/src/authApi.js new file mode 100644 index 0000000..cb85647 --- /dev/null +++ b/mobile/src/authApi.js @@ -0,0 +1,76 @@ +import { api } from './api'; +import { clearAccessToken, setAccessToken } from './authStorage'; + +function normalizeEmail(email) { + return String(email || '').trim().toLowerCase(); +} + +export async function login(email, password) { + const form = new URLSearchParams(); + form.append('username', normalizeEmail(email)); + form.append('password', password); + + const response = await api.post('/auth/login', form.toString(), { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }); + + await setAccessToken(response.data.access_token); + return response.data.user; +} + +export async function register(name, email, password) { + const response = await api.post('/auth/register', { + name: String(name || '').trim(), + email: normalizeEmail(email), + password, + }); + return response.data; +} + +export async function resendVerification(email) { + const response = await api.post('/auth/email-verification/resend', { + email: normalizeEmail(email), + }); + return response.data; +} + +export async function confirmEmailVerification(token) { + const response = await api.post('/auth/email-verification/confirm', { token }); + await setAccessToken(response.data.access_token); + return response.data.user; +} + +export async function requestPasswordReset(email) { + const response = await api.post('/auth/password-reset/request', { + email: normalizeEmail(email), + }); + return response.data; +} + +export async function confirmPasswordReset(token, password) { + const response = await api.post('/auth/password-reset/confirm', { token, password }); + return response.data; +} + +export async function restoreSession() { + try { + const response = await api.get('/auth/me'); + return response.data; + } catch (error) { + if (error?.response?.status === 401) await clearAccessToken(); + throw error; + } +} + +export async function logout() { + await clearAccessToken(); +} + +export function getAuthErrorMessage(error) { + const detail = error?.response?.data?.detail; + if (typeof detail === 'string') return detail; + if (detail && typeof detail.message === 'string') return detail.message; + if (!error?.response) return 'Could not reach BragStack. Check your connection and try again.'; + if (error.response.status >= 500) return 'BragStack is having trouble right now. Please try again in a moment.'; + return 'That request could not be completed. Please check your details and try again.'; +} diff --git a/mobile/src/authStorage.js b/mobile/src/authStorage.js new file mode 100644 index 0000000..c4a2e63 --- /dev/null +++ b/mobile/src/authStorage.js @@ -0,0 +1,16 @@ +import * as SecureStore from 'expo-secure-store'; + +const ACCESS_TOKEN_KEY = 'bragstack.accessToken'; + +export async function getAccessToken() { + return SecureStore.getItemAsync(ACCESS_TOKEN_KEY); +} + +export async function setAccessToken(token) { + if (!token) return clearAccessToken(); + return SecureStore.setItemAsync(ACCESS_TOKEN_KEY, token); +} + +export async function clearAccessToken() { + return SecureStore.deleteItemAsync(ACCESS_TOKEN_KEY); +} diff --git a/mobile/src/productApi.js b/mobile/src/productApi.js new file mode 100644 index 0000000..966e5cf --- /dev/null +++ b/mobile/src/productApi.js @@ -0,0 +1,89 @@ +import { api } from './api'; + +export function getProductErrorMessage(error, fallback = 'Could not load your BragStack data.') { + const detail = error?.response?.data?.detail; + if (typeof detail === 'string') return detail; + if (detail && typeof detail.message === 'string') return detail.message; + if (!error?.response) return 'Could not reach BragStack. Check your connection and try again.'; + return fallback; +} + +export async function getEntries(limit = 30, skip = 0) { + const response = await api.get('/entries', { params: { limit, skip } }); + return response.data; +} + +export async function getImpactReceipts(limit = 30, skip = 0) { + const response = await api.get('/impact-receipts', { params: { limit, skip } }); + return response.data; +} + +export async function loadProofOverview() { + const [entriesPage, receiptsPage] = await Promise.all([ + getEntries(), + getImpactReceipts(), + ]); + return { + entries: entriesPage?.entries || [], + receipts: receiptsPage?.receipts || [], + totalEntries: Number(entriesPage?.total_entries || 0), + totalReceipts: Number(receiptsPage?.total_receipts || 0), + }; +} + +function todayIsoDate() { + return new Date().toISOString().slice(0, 10); +} + +export function buildEntryPayload(input) { + const title = String(input?.title || '').trim(); + const situation = String(input?.situation || '').trim(); + const action = String(input?.action || '').trim(); + const impact = String(input?.impact || '').trim(); + const category = String(input?.category || '').trim() || 'General'; + const tags = String(input?.tags || '') + .split(',') + .map((tag) => tag.trim()) + .filter(Boolean); + + if (!title || !situation || !action || !impact) { + throw new Error('Title, context, action, and impact are required.'); + } + + return { + title, + category, + entry_date: todayIsoDate(), + entry_type: 'Current Job', + situation, + action, + impact, + lesson: null, + tags: [...new Set(tags)], + is_public: false, + }; +} + +export async function createPrivateEntry(input) { + const response = await api.post('/entries', buildEntryPayload(input)); + return response.data; +} + +export async function updateProfile(user, changes) { + const payload = { + name: String(changes?.name ?? user?.name ?? '').trim(), + headline: String(changes?.headline ?? user?.headline ?? '').trim(), + bio: String(changes?.bio ?? user?.bio ?? '').trim(), + location: String(changes?.location ?? user?.location ?? '').trim(), + github_url: String(user?.github_url || '').trim(), + portfolio_url: String(user?.portfolio_url || '').trim(), + resume_url: String(user?.resume_url || '').trim(), + profile_theme: user?.profile_theme || 'default', + profile_primary_color: user?.profile_primary_color || '', + profile_secondary_color: user?.profile_secondary_color || '', + profile_background_color: user?.profile_background_color || '', + }; + + const response = await api.patch('/auth/me/profile', payload); + return response.data; +} diff --git a/mobile/src/receiptDraft.js b/mobile/src/receiptDraft.js new file mode 100644 index 0000000..a9883a2 --- /dev/null +++ b/mobile/src/receiptDraft.js @@ -0,0 +1,13 @@ +export function createReceiptDraft(win, result) { + const accomplishment = String(win || '').trim(); + const outcome = String(result || '').trim(); + + if (!accomplishment) return null; + + return { + win: accomplishment, + result: outcome, + resultDisplay: outcome || 'Result not added yet — BragStack will not invent one.', + visibility: 'private', + }; +} diff --git a/mobile/src/theme.js b/mobile/src/theme.js new file mode 100644 index 0000000..fa1bc65 --- /dev/null +++ b/mobile/src/theme.js @@ -0,0 +1,55 @@ +export const colors = { + // BragStack product palette — no warm/brown accent drift. + background: '#070B14', + sidebar: '#0B1020', + surface: '#0D1526', + surfaceElevated: '#131E33', + border: 'rgba(166, 220, 255, 0.16)', + text: '#F8FAFC', + muted: '#A7B4C9', + mutedStrong: '#7F90AA', + primary: '#A6DCFF', + primarySoft: '#D8F1FF', + secondary: '#AD91FF', + cyan: '#69E4F6', + danger: '#FFB0B0', + + // Canonical identity aliases retained for shared components. + brandBackground: '#070B14', + brandSurface: '#0D1526', + brandSurfaceLight: '#131E33', + brandText: '#F8FAFC', + brandMuted: '#A7B4C9', + brandBlue: '#A6DCFF', + brandPurple: '#AD91FF', + brandCyan: '#69E4F6', + + success: '#86E3B2', +}; + +export const spacing = { + xs: 6, + sm: 10, + md: 16, + lg: 24, + xl: 32, +}; + +export const radius = { + sm: 12, + md: 18, + lg: 28, + pill: 999, +}; + +export const navigationTheme = { + dark: true, + colors: { + primary: colors.primary, + background: colors.background, + card: colors.sidebar, + text: colors.text, + border: colors.border, + notification: colors.secondary, + }, +};