Initial commit: code as received (Create/Anything export)

Insole-production time tracker exported from the Create/Anything AI
platform. Baseline snapshot before any reverse-engineering or cleanup.

- apps/mobile: Expo Router app (iOS/Android/web), the only workspace
- publisher/: standalone OpenNext/AWS deploy tooling for the web side
- Backend (/api/tasks, /api/logs + DB) lives remotely, not in this repo
This commit is contained in:
Bas van Rossem
2026-06-17 10:19:33 +02:00
commit d94d0b188b
192 changed files with 50705 additions and 0 deletions

View File

@@ -0,0 +1,105 @@
import React, { Component, type ReactNode } from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
function postErrorToParent(error: Error) {
try {
if (typeof window !== 'undefined' && window.parent !== window) {
window.parent.postMessage(
{
type: 'sandbox:error:detected',
error: {
message: error.message,
name: error.name || 'Error',
stack: error.stack || '',
},
},
'*'
);
}
} catch {}
}
function postErrorResolvedToParent() {
try {
if (typeof window !== 'undefined' && window.parent !== window) {
window.parent.postMessage({ type: 'sandbox:error:resolved' }, '*');
}
} catch {}
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error) {
postErrorToParent(error);
}
render() {
if (this.state.hasError) {
return (
<View style={styles.container}>
<Text style={styles.title}>Something went wrong</Text>
<Text style={styles.message}>
{this.state.error?.message ?? 'An unexpected error occurred'}
</Text>
<TouchableOpacity
style={styles.button}
onPress={() => {
this.setState({ hasError: false, error: null });
postErrorResolvedToParent();
}}
>
<Text style={styles.buttonText}>Try again</Text>
</TouchableOpacity>
</View>
);
}
return this.props.children;
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 24,
backgroundColor: '#fff',
},
title: {
fontSize: 18,
fontWeight: '600',
color: '#18191B',
marginBottom: 8,
},
message: {
fontSize: 14,
color: '#959697',
textAlign: 'center',
marginBottom: 24,
},
button: {
backgroundColor: '#18191B',
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 8,
},
buttonText: {
color: '#fff',
fontSize: 14,
fontWeight: '500',
},
});

View File

@@ -0,0 +1,84 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { usePathname } from "expo-router";
import { useEffect } from "react";
import { Platform } from "react-native";
const VISITOR_ID_KEY = "anything_analytics_visitor_id";
// Mirror the gating used by Sentry / the TestFlight logger: only emit from
// real (production) builds, never from the in-builder dev runtime.
function isActive(): boolean {
return !__DEV__ && process.env.EXPO_PUBLIC_CREATE_ENV !== "DEVELOPMENT";
}
function generateVisitorId(): string {
const rand = () => Math.random().toString(36).slice(2);
return `${rand()}${rand()}`.slice(0, 32);
}
let visitorIdPromise: Promise<string> | null = null;
// Stable, anonymous, per-install id. Not a secret, so AsyncStorage (not the
// keychain) is the right home. Generated once and reused for the install.
function getVisitorId(): Promise<string> {
if (!visitorIdPromise) {
visitorIdPromise = (async () => {
try {
const existing = await AsyncStorage.getItem(VISITOR_ID_KEY);
if (existing) return existing;
const created = generateVisitorId();
await AsyncStorage.setItem(VISITOR_ID_KEY, created);
return created;
} catch {
// If persistence fails, fall back to a session-scoped id so the
// current run still attributes its views to one visitor.
return generateVisitorId();
}
})();
}
return visitorIdPromise;
}
// Records one screen view per route change. The endpoint enforces the global
// flag and the project's analytics opt-in, dropping events (204) when off, so
// this always fires and the server decides whether to keep it.
export function ScreenViewTracker() {
const pathname = usePathname();
useEffect(() => {
if (!isActive()) return;
const endpoint = process.env.EXPO_PUBLIC_ANALYTICS_ENDPOINT;
const host = process.env.EXPO_PUBLIC_HOST;
const projectGroupId = process.env.EXPO_PUBLIC_PROJECT_GROUP_ID;
if (!endpoint || !host || !projectGroupId || !pathname) return;
let cancelled = false;
void (async () => {
try {
const visitorId = await getVisitorId();
if (cancelled) return;
await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
d: host,
p: pathname,
pgid: projectGroupId,
vid: visitorId,
os: Platform.OS,
dt: "mobile",
}),
});
} catch {
// Analytics must never crash or block the host app.
}
})();
return () => {
cancelled = true;
};
}, [pathname]);
return null;
}

View File

@@ -0,0 +1,19 @@
import LauncherMenuContainer from '@anythingai/app/screens/launcher-menu';
import React from 'react';
import { StyleSheet, View } from 'react-native';
const isExpoGo = globalThis.expo?.modules?.ExpoGo;
export default () => {
if (isExpoGo) {
return null;
}
return (
<View
style={{ ...StyleSheet.absoluteFillObject, zIndex: 9999 }}
pointerEvents="box-none"
>
<LauncherMenuContainer />
</View>
);
};

View File

@@ -0,0 +1,586 @@
import type React from "react";
import { useCallback, useEffect, useMemo, memo, useRef, useReducer } from "react";
import {
StyleSheet,
Text,
TouchableOpacity,
View,
PanResponder,
Platform,
useWindowDimensions,
} from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
interpolate,
withTiming,
Easing,
} from "react-native-reanimated";
import {
SafeAreaProvider,
useSafeAreaInsets,
} from "react-native-safe-area-context";
import Svg, {
Path,
Rect,
Mask,
Circle,
G,
Defs,
ClipPath,
Line,
} from "react-native-svg";
import { NativeModule, requireNativeModule } from "expo-modules-core";
import { MotiView } from "moti";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { WebView } from "react-native-webview";
declare class AnythingLauncherModule extends NativeModule {
open(url: string): Promise<void>;
reset(): Promise<void>;
reload(): Promise<void>;
isWeb(): Promise<boolean>;
}
const TINT_DURATION_MS = 3000;
const CIRCLE_DIAMETER = 80;
const GAP = 16;
const ICON_SIZE = 18;
const getWebAppUrl = () => {
return process.env.EXPO_PUBLIC_APP_URL ?? "";
};
const isAnythingApp =
Platform.OS !== "web" &&
process.env.EXPO_PUBLIC_IS_ANYTHING_APP === JSON.stringify(true);
const AnythingLauncher = isAnythingApp
? requireNativeModule<AnythingLauncherModule>("AnythingLauncherModule")
: null;
const RefreshIcon = memo(() => {
return (
<Svg width={ICON_SIZE} height={ICON_SIZE} viewBox="0 0 18 18" fill="none">
<Path
d="M1.5 7.5s1.504-2.049 2.725-3.271a6.75 6.75 0 11-1.712 6.646M1.5 7.5V3m0 4.5H6"
stroke="#7E7F80"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
});
const CloseIcon = memo(() => {
return (
<Svg width={ICON_SIZE} height={ICON_SIZE} viewBox="0 0 18 18" fill="none">
<Path
d="M2.25 15.75l13.5-13.5M15.75 15.75L2.25 2.25"
stroke="#7E7F80"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
});
const MobileViewIcon = memo(({ color }: { color: string }) => {
return (
<Svg width={ICON_SIZE} height={ICON_SIZE} viewBox="0 0 18 18" fill="none">
<Path
d="M11.8125 1.5H6.1875C5.15197 1.5 4.3125 2.33947 4.3125 3.375V14.625C4.3125 15.6605 5.15197 16.5 6.1875 16.5H11.8125C12.848 16.5 13.6875 15.6605 13.6875 14.625V3.375C13.6875 2.33947 12.848 1.5 11.8125 1.5Z"
stroke={color}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<Line
x1={7.89575}
y1={13.3832}
x2={10.104}
y2={13.3832}
stroke={color}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
});
const WebViewIcon = memo(({ color }: { color: string }) => {
return (
<Svg width={ICON_SIZE} height={ICON_SIZE} viewBox="0 0 18 18" fill="none">
<G clipPath="url(#clip0_340_2754)">
<Path
d="M15 1.5H3C2.17157 1.5 1.5 2.17157 1.5 3V12C1.5 12.8284 2.17157 13.5 3 13.5H15C15.8284 13.5 16.5 12.8284 16.5 12V3C16.5 2.17157 15.8284 1.5 15 1.5Z"
stroke={color}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<Path
d="M9 13.5V16.5"
stroke={color}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<Path
d="M6 16.5H12"
stroke={color}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</G>
<Defs>
<ClipPath id="clip0_340_2754">
<Rect width={18} height={18} fill="white" />
</ClipPath>
</Defs>
</Svg>
);
});
const ActiveDot = memo(() => {
return (
<View
style={{
backgroundColor: "#000",
borderRadius: 50,
width: 4,
height: 4,
position: "absolute",
bottom: -8,
}}
/>
);
});
const InstructionsOverlay = memo(
({
showTint,
width,
height,
}: {
showTint: boolean;
width: number;
height: number;
}) => {
const r = CIRCLE_DIAMETER / 2;
const totalWidth = CIRCLE_DIAMETER * 2 + GAP;
const left = (width - totalWidth) / 2;
const cx1 = left + r;
const cx2 = cx1 + CIRCLE_DIAMETER + GAP;
const cy = height / 2 + 64;
return (
<>
<MotiView
from={{ opacity: 0 }}
animate={{ opacity: showTint ? 1 : 0 }}
transition={{ type: "timing", duration: 350 }}
style={menuStyles.holdTwoFingersTextContainer}
>
<Text style={menuStyles.holdTwoFingersText}>
Hold with 2 fingers for menu
</Text>
</MotiView>
<MotiView
from={{ opacity: 0 }}
animate={{ opacity: showTint ? 1 : 0 }}
transition={{ type: "timing", duration: 350 }}
style={StyleSheet.absoluteFill}
>
<Svg width={width} height={height} style={StyleSheet.absoluteFill}>
<Mask id="holes">
<Rect x="0" y="0" width={width} height={height} fill="white" />
<Circle cx={cx1} cy={cy} r={r} fill="black" />
<Circle cx={cx2} cy={cy} r={r} fill="black" />
</Mask>
<Rect
x="0"
y="0"
width={width}
height={height}
fill="black"
opacity={0.8}
mask="url(#holes)"
/>
</Svg>
</MotiView>
</>
);
}
);
type State = {
isLoading: boolean;
showTint: boolean;
showWebView: boolean;
}
type Action = { type: 'INITIALIZE', payload: { showWebView: boolean, showTint: boolean } } | { type: 'TOGGLE_WEB_VIEW' } | { type: 'HIDE_TINT' }
const initialState: State = { isLoading: true, showTint: false, showWebView: false };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'INITIALIZE':
return { ...state, ...action.payload, isLoading: false };
case 'TOGGLE_WEB_VIEW':
return { ...state, showWebView: !state.showWebView };
case 'HIDE_TINT':
return { ...state, showTint: false };
default:
return state;
}
}
const AnythingMenu = isAnythingApp
? ({ children }: { children: React.ReactNode }) => {
const insets = useSafeAreaInsets();
const [state, dispatch] = useReducer(reducer, initialState);
const { width, height } = useWindowDimensions();
useEffect(() => {
if (!AnythingLauncher) {
throw new Error("AnythingLauncher is not available");
}
if (state.isLoading) {
Promise.all([
AnythingLauncher.isWeb(),
AsyncStorage.getItem("hasSeenOnboarding"),
]).then(([isWeb, hasSeenOnboarding]) => {
dispatch({ type: 'INITIALIZE', payload: { showWebView: Boolean(isWeb), showTint: hasSeenOnboarding !== 'true' } });
}).catch(() => {
dispatch({ type: 'INITIALIZE', payload: { showWebView: false, showTint: false } });
});
}
}, [state.isLoading]);
useEffect(() => {
if (!state.isLoading && state.showTint) {
const timeout = setTimeout(() => {
void AsyncStorage.setItem("hasSeenOnboarding", "true");
dispatch({ type: 'HIDE_TINT' });
}, TINT_DURATION_MS);
return () => clearTimeout(timeout);
}
}, [state.isLoading, state.showTint])
const menuProgress = useSharedValue(0);
const hideMenuOffset = -(44 + 36 + insets.top + 10);
const exitApp = useCallback(() => {
void AnythingLauncher?.reset();
}, []);
const reloadApp = useCallback(() => {
void AnythingLauncher?.reload();
}, []);
const toggleWebView = useCallback(() => {
dispatch({ type: 'TOGGLE_WEB_VIEW' });
}, []);
const animatedStyle = useAnimatedStyle(() => {
const scale = interpolate(menuProgress.value, [0, 1], [1, 0.9]);
const shadowOpacity = interpolate(menuProgress.value, [0, 1], [0, 0.4]);
const elevation = interpolate(menuProgress.value, [0, 1], [0, 8]);
return {
transform: [{ scale }],
shadowOpacity,
shadowOffset: { width: 0, height: 0 },
shadowRadius: 32,
elevation,
};
}, []);
const menuAnimatedStyle = useAnimatedStyle(() => {
const translateY = interpolate(
menuProgress.value,
[0, 1],
[hideMenuOffset, 0]
);
return {
transform: [{ translateY }],
};
}, [hideMenuOffset]);
const appPointerEvents = useAnimatedStyle(() => {
return {
pointerEvents: menuProgress.value === 1 ? "box-only" : "auto",
};
}, [menuProgress]);
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const panResponder = useMemo(
() =>
PanResponder.create({
onStartShouldSetPanResponder: (evt, gestureState) => {
if (menuProgress.value === 1) {
menuProgress.value = withTiming(0, {
duration: 300,
easing: Easing.ease,
});
if (longPressTimer.current) {
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
return false;
}
if (gestureState.numberActiveTouches === 2) {
longPressTimer.current = setTimeout(() => {
menuProgress.value = withTiming(1, {
duration: 300,
easing: Easing.ease,
});
longPressTimer.current = null;
}, 500);
return true;
}
return false;
},
onPanResponderEnd: (_evt, _gestureState) => {
if (longPressTimer.current) {
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
},
}),
[menuProgress.value]
);
const menuHeaderStyle = useMemo(
() => ({
...menuStyles.menuHeader,
marginTop: insets.top + 10,
}),
[insets.top]
);
if (state.isLoading) {
return null
}
return (
<View style={styles.container}>
<Animated.View
style={[styles.fill, animatedStyle]}
pointerEvents="box-none"
{...panResponder.panHandlers}
>
<Animated.View style={[styles.fillContent, appPointerEvents]}>
{!state.showWebView ? (
children
) : (
<WebView
source={{ uri: getWebAppUrl() }}
style={[styles.webView, { paddingTop: insets.top }]}
/>
)}
</Animated.View>
</Animated.View>
<Animated.View style={[styles.menuContainer, menuAnimatedStyle]}>
<View style={menuStyles.menuContainerStyle}>
<View style={menuHeaderStyle}>
<View style={menuStyles.leftSection}>
<TouchableOpacity
onPress={toggleWebView}
style={menuStyles.button}
>
<MobileViewIcon
color={state.showWebView ? "#7E7F80" : "#18191B"}
/>
{!state.showWebView && <ActiveDot />}
</TouchableOpacity>
<TouchableOpacity
onPress={toggleWebView}
style={menuStyles.button}
>
<WebViewIcon color={state.showWebView ? "#18191B" : "#7E7F80"} />
{state.showWebView && <ActiveDot />}
</TouchableOpacity>
</View>
<View style={menuStyles.buttonContainer}>
<TouchableOpacity
onPress={reloadApp}
style={menuStyles.button}
>
<RefreshIcon />
</TouchableOpacity>
<TouchableOpacity onPress={exitApp} style={menuStyles.button}>
<CloseIcon />
</TouchableOpacity>
</View>
</View>
</View>
</Animated.View>
<InstructionsOverlay
showTint={state.showTint}
width={width}
height={height}
/>
</View>
);
}
: ({ children }: { children: React.ReactNode }) => children;
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#fff",
},
fillContent: {
flex: 1,
borderRadius: 16,
overflow: "hidden",
},
fill: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
},
menuContainer: {
position: "absolute",
top: 0,
left: 0,
right: 0,
zIndex: 1000,
},
menuTouchable: {
flex: 1,
},
bottomSheetBackground: {
backgroundColor: "white",
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
},
contentContainer: {
flex: 1,
paddingHorizontal: 20,
paddingVertical: 20,
},
webViewContainer: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "#fff",
zIndex: 2000,
},
webViewHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 20,
paddingVertical: 18,
backgroundColor: "#fff",
borderBottomWidth: 1,
borderBottomColor: "#e0e0e0",
},
webViewTitle: {
fontSize: 18,
fontWeight: "600",
color: "#18191B",
},
webViewCloseButton: {
width: 18,
height: 18,
justifyContent: "center",
alignItems: "center",
},
webView: {
flex: 1,
},
});
const menuStyles = StyleSheet.create({
menuContainerStyle: {
backgroundColor: "#fff",
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 3.84,
elevation: 5,
},
menuHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 20,
paddingVertical: 18,
},
appIcon: {
width: 44,
height: 44,
borderRadius: 12,
marginRight: 20,
},
appTitle: {
fontSize: 18,
fontWeight: "600",
color: "#18191B",
flex: 1,
},
buttonContainer: {
flexDirection: "row",
alignItems: "center",
gap: 28,
},
button: {
width: 18,
height: 18,
justifyContent: "center",
alignItems: "center",
},
leftSection: {
flexDirection: "row",
alignItems: "center",
gap: 28,
flex: 1,
},
holdTwoFingersTextContainer: {
zIndex: 1,
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: "center",
alignItems: "center",
transform: [{ translateY: -24 }],
},
holdTwoFingersText: {
fontSize: 28,
color: "#fff",
fontWeight: "600",
},
});
export default function Screen({ children }: { children: React.ReactNode }) {
return (
<SafeAreaProvider>
<AnythingMenu>{children}</AnythingMenu>
</SafeAreaProvider>
);
}

View File

@@ -0,0 +1,106 @@
import * as SecureStore from 'expo-secure-store';
import { fetch as expoFetch } from 'expo/fetch';
const originalFetch = fetch;
const authKey = `${process.env.EXPO_PUBLIC_PROJECT_GROUP_ID}-jwt`;
const getURLFromArgs = (...args: Parameters<typeof fetch>) => {
const [urlArg] = args;
if (typeof urlArg === 'string') {
return urlArg;
}
if (urlArg instanceof Request) {
return urlArg.url;
}
// URL type may not be in the fetch signature for all TS environments
if (typeof urlArg === 'object' && urlArg !== null && 'href' in urlArg) {
return (urlArg as URL).href;
}
return null;
};
const isFileURL = (url: string) => {
return url.startsWith('file://') || url.startsWith('data:');
};
const isStaticAssetURL = (url: string) => {
return /\.(wasm|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|otf|eot)(\?|$)/i.test(url);
};
const isFirstPartyURL = (url: string) => {
return (
url.startsWith('/') ||
(process.env.EXPO_PUBLIC_BASE_URL && url.startsWith(process.env.EXPO_PUBLIC_BASE_URL))
);
};
const isSecondPartyURL = (url: string) => {
return url.startsWith('/_create/');
};
type Params = Parameters<typeof expoFetch>;
const fetchToWeb = async function fetchWithHeaders(...args: Params) {
const firstPartyURL = process.env.EXPO_PUBLIC_BASE_URL;
const secondPartyURL = process.env.EXPO_PUBLIC_PROXY_BASE_URL;
if (!firstPartyURL || !secondPartyURL) {
return expoFetch(...args);
}
const [input, init] = args;
const url = getURLFromArgs(input, init);
if (!url) {
return expoFetch(input, init);
}
if (isFileURL(url) || isStaticAssetURL(url)) {
return originalFetch(input, init);
}
const isExternalFetch = !isFirstPartyURL(url);
// we should not add headers to requests that don't go to our own server
if (isExternalFetch) {
return expoFetch(input, init);
}
let finalInput = input;
const baseURL = isSecondPartyURL(url) ? secondPartyURL : firstPartyURL;
if (typeof input === 'string') {
finalInput = input.startsWith('/') ? `${baseURL}${input}` : input;
} else {
return expoFetch(input, init);
}
const initHeaders = init?.headers ?? {};
const finalHeaders = new Headers(initHeaders);
const headers = {
'x-createxyz-project-group-id': process.env.EXPO_PUBLIC_PROJECT_GROUP_ID,
host: process.env.EXPO_PUBLIC_HOST,
'x-forwarded-host': process.env.EXPO_PUBLIC_HOST,
'x-createxyz-host': process.env.EXPO_PUBLIC_HOST,
};
for (const [key, value] of Object.entries(headers)) {
if (value) {
finalHeaders.set(key, value);
}
}
const auth = await SecureStore.getItemAsync(authKey)
.then((auth) => {
return auth ? JSON.parse(auth) : null;
})
.catch(() => {
return null;
});
if (auth) {
finalHeaders.set('authorization', `Bearer ${auth.jwt}`);
}
return expoFetch(finalInput, {
...init,
headers: finalHeaders,
});
};
export default fetchToWeb;

View File

@@ -0,0 +1,20 @@
<svg width={128} height={128} viewBox="0 0 895 895" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="895" height="895" rx="19" fill="#E9E7E7" />
<g stroke="#C0C0C0" stroke-width="1.00975">
<line x1="447.505" y1="-23" x2="447.505" y2="901" />
<line x1="889.335" y1="447.505" x2="5.66443" y2="447.505" />
<line x1="889.335" y1="278.068" x2="5.66443" y2="278.068" />
<line x1="889.335" y1="57.1505" x2="5.66443" y2="57.1504" />
<line x1="61.8051" y1="883.671" x2="61.8051" y2="0.000061" />
<line x1="282.495" y1="907" x2="282.495" y2="-30" />
<line x1="611.495" y1="907" x2="611.495" y2="-30" />
<line x1="832.185" y1="883.671" x2="832.185" y2="0.000061" />
<line x1="889.335" y1="827.53" x2="5.66443" y2="827.53" />
<line x1="889.335" y1="606.613" x2="5.66443" y2="606.612" />
<line x1="4.3568" y1="4.6428" x2="889.357" y2="888.643" />
<line x1="-0.3568" y1="894.643" x2="894.643" y2="0.642772" />
<circle cx="447.5" cy="441.5" r="163.995" />
<circle cx="447.911" cy="447.911" r="237.407" />
<circle cx="448" cy="442" r="384.495" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,3 @@
import updatedFetch from './fetch';
// @ts-expect-error -- updatedFetch wraps the native fetch with custom headers
global.fetch = updatedFetch;

View File

@@ -0,0 +1,46 @@
import { Tabs } from 'expo-router';
import { Timer, History, Settings } from 'lucide-react-native';
export default function TabLayout() {
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarStyle: {
backgroundColor: '#ffffff',
borderTopWidth: 1,
borderTopColor: '#E5E7EB',
paddingTop: 4,
},
tabBarActiveTintColor: '#2563EB',
tabBarInactiveTintColor: '#6B7280',
tabBarLabelStyle: {
fontSize: 12,
fontWeight: '500',
},
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Stopwatch',
tabBarIcon: ({ color }) => <Timer color={color} size={24} />,
}}
/>
<Tabs.Screen
name="history"
options={{
title: 'Geschiedenis',
tabBarIcon: ({ color }) => <History color={color} size={24} />,
}}
/>
<Tabs.Screen
name="tasks"
options={{
title: 'Instellingen',
tabBarIcon: ({ color }) => <Settings color={color} size={24} />,
}}
/>
</Tabs>
);
}

View File

@@ -0,0 +1,233 @@
import React from 'react';
import { View, Text, ScrollView, TouchableOpacity, Linking, Alert } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Download, Clock, Calendar, Layers } from 'lucide-react-native';
import { useQuery } from '@tanstack/react-query';
import { useFonts, Inter_400Regular, Inter_600SemiBold } from '@expo-google-fonts/inter';
export default function HistoryScreen() {
const insets = useSafeAreaInsets();
const [fontsLoaded, fontError] = useFonts({ Inter_400Regular, Inter_600SemiBold });
const { data: logs = [], isLoading } = useQuery({
queryKey: ['logs'],
queryFn: async () => {
const res = await fetch(`${process.env.EXPO_PUBLIC_BASE_URL}/api/logs`);
if (!res.ok) throw new Error('Failed to fetch logs');
return res.json();
},
});
const handleExport = async () => {
const exportUrl = `${process.env.EXPO_PUBLIC_BASE_URL}/api/export`;
const supported = await Linking.canOpenURL(exportUrl);
if (supported) {
await Linking.openURL(exportUrl);
} else {
Alert.alert('Fout', 'Kan de export-URL niet openen');
}
};
const formatDuration = (seconds: number) => {
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hrs > 0) return `${hrs}h ${mins}m`;
if (mins > 0) return `${mins}m ${secs}s`;
return `${secs}s`;
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
};
const formatTime = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
};
if (!fontsLoaded && !fontError) return null;
return (
<View style={{ flex: 1, backgroundColor: '#ffffff', paddingTop: insets.top }}>
<View
style={{
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 24,
paddingVertical: 16,
borderBottomWidth: 1,
borderBottomColor: '#E5E7EB',
}}
>
<Text
style={{
fontSize: 24,
fontWeight: '600',
color: '#111827',
fontFamily: 'Inter_600SemiBold',
}}
>
Geschiedenis
</Text>
<TouchableOpacity
onPress={handleExport}
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#EFF6FF',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 999,
gap: 6,
}}
>
<Download color="#2563EB" size={16} />
<Text
style={{
color: '#2563EB',
fontWeight: '500',
fontSize: 13,
fontFamily: 'Inter_600SemiBold',
}}
>
Exporteer CSV
</Text>
</TouchableOpacity>
</View>
<ScrollView contentContainerStyle={{ padding: 20 }}>
{logs.length === 0 && !isLoading ? (
<View style={{ alignItems: 'center', marginTop: 100 }}>
<Text style={{ color: '#6B7280', fontSize: 16, fontFamily: 'Inter_400Regular' }}>
Nog geen opgeslagen sessies.
</Text>
</View>
) : (
logs.map((log: any) => (
<View
key={log.id}
style={{
backgroundColor: '#ffffff',
borderRadius: 12,
borderWidth: 1,
borderColor: '#E5E7EB',
padding: 16,
marginBottom: 12,
}}
>
<View
style={{
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
}}
>
<View>
<Text
style={{
fontSize: 16,
fontWeight: '600',
color: '#111827',
marginBottom: 4,
fontFamily: 'Inter_600SemiBold',
}}
>
{log.task_name}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
<Calendar color="#6B7280" size={12} />
<Text
style={{ fontSize: 12, color: '#6B7280', fontFamily: 'Inter_400Regular' }}
>
{formatDate(log.start_time)} {formatTime(log.start_time)}
</Text>
</View>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
{log.insole_type && (
<View
style={{
backgroundColor: '#F3F4F6',
borderWidth: 1,
borderColor: '#E5E7EB',
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 4,
}}
>
<Text
style={{
fontSize: 13,
fontWeight: '600',
color: '#374151',
fontFamily: 'Inter_600SemiBold',
}}
>
{log.insole_type}
</Text>
</View>
)}
{log.pair_count != null && (
<View
style={{
backgroundColor: '#EFF6FF',
borderWidth: 1,
borderColor: '#BFDBFE',
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 4,
flexDirection: 'row',
alignItems: 'center',
gap: 4,
}}
>
<Layers color="#2563EB" size={12} />
<Text
style={{
fontSize: 13,
fontWeight: '600',
color: '#2563EB',
fontFamily: 'Inter_600SemiBold',
}}
>
{log.pair_count} {log.pair_count === 1 ? 'inlegzool' : 'inlegzolen'}
</Text>
</View>
)}
<View
style={{
backgroundColor: '#F9FAFB',
borderWidth: 1,
borderColor: '#E5E7EB',
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 4,
flexDirection: 'row',
alignItems: 'center',
gap: 4,
}}
>
<Clock color="#111827" size={12} />
<Text
style={{
fontSize: 13,
fontWeight: '600',
color: '#111827',
fontFamily: 'Inter_600SemiBold',
}}
>
{formatDuration(log.duration_seconds)}
</Text>
</View>
</View>
</View>
</View>
))
)}
</ScrollView>
</View>
);
}

View File

@@ -0,0 +1,658 @@
import React, { useState, useEffect, useRef } from 'react';
import {
View,
Text,
TouchableOpacity,
ScrollView,
TextInput,
Modal,
Animated,
Pressable,
Dimensions,
Platform,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Play, Square, ChevronDown, Check } from 'lucide-react-native';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useFonts, Inter_400Regular, Inter_600SemiBold } from '@expo-google-fonts/inter';
const BASE_URL = process.env.EXPO_PUBLIC_BASE_URL;
const SCREEN_HEIGHT = Dimensions.get('window').height;
const SHEET_HEIGHT = SCREEN_HEIGHT * 0.75;
const INSOLE_TYPES = ['Kurk', 'Berk', '3D'] as const;
type InsoleType = (typeof INSOLE_TYPES)[number];
export default function TimerScreen() {
const insets = useSafeAreaInsets();
const queryClient = useQueryClient();
// fontError: if fonts fail to load on Android we still render (no freeze)
const [fontsLoaded, fontError] = useFonts({ Inter_400Regular, Inter_600SemiBold });
const [activeTaskId, setActiveTaskId] = useState<number | null>(null);
const [insoleType, setInsoleType] = useState<InsoleType>('Kurk');
const [isRunning, setIsRunning] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [startTime, setStartTime] = useState<Date | null>(null);
const [elapsedTime, setElapsedTime] = useState(0);
const [showPicker, setShowPicker] = useState(false);
const [discardPending, setDiscardPending] = useState(false);
const [insoleCount, setInsoleCount] = useState(2);
const [insoleCountText, setInsoleCountText] = useState('2');
const timerRef = useRef<NodeJS.Timeout | null>(null);
const discardTimerRef = useRef<NodeJS.Timeout | null>(null);
const slideAnim = useRef(new Animated.Value(SHEET_HEIGHT)).current;
const { data: tasks = [] } = useQuery({
queryKey: ['tasks'],
queryFn: async () => {
const res = await fetch(`${BASE_URL}/api/tasks`);
if (!res.ok) throw new Error('Failed to fetch tasks');
return res.json();
},
});
const saveLogMutation = useMutation({
mutationFn: async (log: any) => {
const res = await fetch(`${BASE_URL}/api/logs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(log),
});
if (!res.ok) throw new Error('Failed to save log');
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['logs'] });
},
});
useEffect(() => {
if (isRunning && !isPaused) {
timerRef.current = setInterval(() => setElapsedTime((prev) => prev + 1), 1000);
} else {
if (timerRef.current) clearInterval(timerRef.current);
}
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, [isRunning, isPaused]);
const openPicker = () => {
setShowPicker(true);
Animated.timing(slideAnim, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}).start();
};
const closePicker = () => {
Animated.timing(slideAnim, {
toValue: SHEET_HEIGHT,
duration: 250,
useNativeDriver: true,
}).start(() => setShowPicker(false));
};
const handleStart = () => {
if (!activeTaskId) return;
setIsRunning(true);
setIsPaused(false);
setStartTime(new Date());
};
const handlePause = () => setIsPaused(true);
const handleResume = () => setIsPaused(false);
const handleStop = () => {
if (!activeTaskId || !startTime) return;
setIsRunning(false);
setIsPaused(false);
const endTime = new Date();
saveLogMutation.mutate({
task_id: activeTaskId,
start_time: startTime.toISOString(),
end_time: endTime.toISOString(),
duration_seconds: elapsedTime,
pair_count: insoleCount,
insole_type: insoleType,
});
setStartTime(null);
setElapsedTime(0);
setDiscardPending(false);
if (discardTimerRef.current) clearTimeout(discardTimerRef.current);
};
const handleDiscard = () => {
if (!discardPending) {
setDiscardPending(true);
discardTimerRef.current = setTimeout(() => setDiscardPending(false), 3000);
} else {
if (discardTimerRef.current) clearTimeout(discardTimerRef.current);
setIsRunning(false);
setIsPaused(false);
setStartTime(null);
setElapsedTime(0);
setDiscardPending(false);
}
};
const handleInsoleCountChange = (text: string) => {
setInsoleCountText(text);
const parsed = parseInt(text, 10);
if (!isNaN(parsed) && parsed > 0) setInsoleCount(parsed);
};
const adjustInsoleCount = (delta: number) => {
const next = Math.max(1, insoleCount + delta);
setInsoleCount(next);
setInsoleCountText(String(next));
};
const formatTime = (seconds: number) => {
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
// Wait for fonts — but if font loading errored, render anyway (prevents Android freeze)
if (!fontsLoaded && !fontError) return null;
const regular = fontError ? undefined : 'Inter_400Regular';
const semibold = fontError ? undefined : 'Inter_600SemiBold';
const selectedTask = tasks.find((t: any) => t.id === activeTaskId);
const canStart = !!activeTaskId;
const filteredTasks = tasks.filter((t: any) =>
Array.isArray(t.insole_types) ? t.insole_types.includes(insoleType) : true
);
return (
<View style={{ flex: 1, backgroundColor: '#ffffff', paddingTop: insets.top }}>
<ScrollView contentContainerStyle={{ padding: 24 }}>
{/* 1. Type zool */}
<View style={{ marginBottom: 24 }}>
<Text
style={{
fontSize: 12,
fontWeight: '500',
color: '#6B7280',
marginBottom: 8,
textTransform: 'uppercase',
letterSpacing: 0.5,
fontFamily: semibold,
}}
>
Type zool
</Text>
<View style={{ flexDirection: 'row' }}>
{INSOLE_TYPES.map((type, i) => {
const selected = insoleType === type;
return (
<TouchableOpacity
key={type}
onPress={() => {
if (isRunning) return;
setInsoleType(type);
setActiveTaskId(null);
}}
disabled={isRunning}
style={{
flex: 1,
paddingVertical: 14,
borderRadius: 12,
borderWidth: 2,
borderColor: selected ? '#2563EB' : '#E5E7EB',
backgroundColor: selected ? '#EFF6FF' : '#F9FAFB',
alignItems: 'center',
justifyContent: 'center',
marginRight: i < INSOLE_TYPES.length - 1 ? 10 : 0,
}}
>
<Text
style={{
fontSize: 16,
fontWeight: '600',
color: selected ? '#2563EB' : isRunning ? '#9CA3AF' : '#374151',
fontFamily: semibold,
}}
>
{type}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
{/* 2. Type handeling */}
<View style={{ marginBottom: 24 }}>
<Text
style={{
fontSize: 12,
fontWeight: '500',
color: '#6B7280',
marginBottom: 8,
textTransform: 'uppercase',
letterSpacing: 0.5,
fontFamily: semibold,
}}
>
Type handeling
</Text>
<TouchableOpacity
onPress={() => !isRunning && openPicker()}
disabled={isRunning}
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 14,
backgroundColor: isRunning ? '#F9FAFB' : '#ffffff',
borderWidth: 1,
borderColor: '#E5E7EB',
borderRadius: 12,
}}
>
<Text
style={{
fontSize: 16,
color: activeTaskId ? '#111827' : '#9CA3AF',
fontFamily: regular,
}}
>
{selectedTask ? selectedTask.name : 'Kies een handeling...'}
</Text>
<ChevronDown color="#6B7280" size={20} />
</TouchableOpacity>
</View>
{/* 3. Aantal zolen */}
<View style={{ marginBottom: 40 }}>
<Text
style={{
fontSize: 12,
fontWeight: '500',
color: '#6B7280',
marginBottom: 8,
textTransform: 'uppercase',
letterSpacing: 0.5,
fontFamily: semibold,
}}
>
Aantal zolen
</Text>
<View
style={{
flexDirection: 'row',
alignItems: 'stretch',
borderWidth: 1,
borderColor: '#E5E7EB',
borderRadius: 12,
overflow: 'hidden',
}}
>
<TouchableOpacity
onPress={() => adjustInsoleCount(-1)}
disabled={isRunning || insoleCount <= 1}
style={{
width: 64,
backgroundColor: '#F9FAFB',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 14,
}}
>
<Text
style={{
fontSize: 28,
lineHeight: 34,
color: insoleCount <= 1 || isRunning ? '#D1D5DB' : '#111827',
fontFamily: semibold,
textAlign: 'center',
}}
>
</Text>
</TouchableOpacity>
<TextInput
value={insoleCountText}
onChangeText={handleInsoleCountChange}
keyboardType="number-pad"
editable={!isRunning}
style={{
flex: 1,
textAlign: 'center',
fontSize: 22,
fontWeight: '600',
color: isRunning ? '#9CA3AF' : '#111827',
fontFamily: semibold,
paddingVertical: Platform.OS === 'android' ? 10 : 14,
paddingHorizontal: 0,
}}
/>
<TouchableOpacity
onPress={() => adjustInsoleCount(1)}
disabled={isRunning}
style={{
width: 64,
backgroundColor: '#F9FAFB',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 14,
}}
>
<Text
style={{
fontSize: 28,
lineHeight: 34,
color: isRunning ? '#D1D5DB' : '#111827',
fontFamily: semibold,
textAlign: 'center',
}}
>
+
</Text>
</TouchableOpacity>
</View>
</View>
{/* 4. Stopwatch display */}
<TouchableOpacity
onPress={() => {
if (!isRunning && canStart) handleStart();
else if (isRunning) {
if (isPaused) handleResume();
else handlePause();
}
}}
activeOpacity={canStart || isRunning ? 0.75 : 1}
style={{
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
backgroundColor: '#F9FAFB',
borderRadius: 24,
borderWidth: 1,
borderColor: isPaused ? '#FDE68A' : '#E5E7EB',
}}
>
<Text
style={{
fontSize: 64,
fontWeight: '600',
color: isRunning ? (isPaused ? '#D97706' : '#111827') : '#9CA3AF',
fontFamily: semibold,
letterSpacing: -2,
}}
>
{formatTime(elapsedTime)}
</Text>
{isRunning ? (
<View
style={{
marginTop: 16,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: isPaused ? '#FFFBEB' : '#EFF6FF',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 999,
}}
>
<View
style={{
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: isPaused ? '#F59E0B' : '#2563EB',
marginRight: 8,
}}
/>
<Text
style={{
fontSize: 12,
color: isPaused ? '#D97706' : '#2563EB',
fontWeight: '500',
fontFamily: semibold,
}}
>
{isPaused ? 'Gepauzeerd — tik om te hervatten' : 'Tik om te pauzeren'}
</Text>
</View>
) : canStart ? (
<View
style={{
marginTop: 16,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#EFF6FF',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 999,
}}
>
<View
style={{
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: '#2563EB',
marginRight: 8,
}}
/>
<Text
style={{ fontSize: 12, color: '#2563EB', fontWeight: '500', fontFamily: semibold }}
>
Tik om te starten
</Text>
</View>
) : null}
</TouchableOpacity>
{/* 5. Knoppen */}
<View style={{ marginTop: 40 }}>
{!isRunning ? (
<TouchableOpacity
onPress={handleStart}
disabled={!canStart}
style={{
backgroundColor: canStart ? '#2563EB' : '#E5E7EB',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 18,
borderRadius: 16,
}}
>
<Play
fill={canStart ? 'white' : '#9CA3AF'}
color={canStart ? 'white' : '#9CA3AF'}
size={24}
style={{ marginRight: 8 }}
/>
<Text
style={{
color: canStart ? 'white' : '#9CA3AF',
fontSize: 18,
fontWeight: '600',
fontFamily: semibold,
}}
>
Start Stopwatch
</Text>
</TouchableOpacity>
) : (
<>
<TouchableOpacity
onPress={handleStop}
style={{
backgroundColor: '#DC2626',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 18,
borderRadius: 16,
marginBottom: 12,
}}
>
<Square fill="white" color="white" size={22} style={{ marginRight: 8 }} />
<Text
style={{ color: 'white', fontSize: 18, fontWeight: '600', fontFamily: semibold }}
>
Stop & Opslaan
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleDiscard}
style={{
backgroundColor: discardPending ? '#374151' : '#F3F4F6',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 18,
borderRadius: 16,
}}
>
<Text
style={{
color: discardPending ? '#ffffff' : '#6B7280',
fontSize: 18,
fontWeight: '600',
fontFamily: semibold,
}}
>
{discardPending ? 'Nogmaals tikken ter bevestiging' : 'Annuleren'}
</Text>
</TouchableOpacity>
</>
)}
</View>
</ScrollView>
{/* Bottom Sheet — uses Pressable instead of nested TouchableWithoutFeedback (Android fix) */}
<Modal
visible={showPicker}
transparent
animationType="none"
onRequestClose={closePicker}
statusBarTranslucent
>
<View style={{ flex: 1 }}>
{/* Backdrop */}
<Pressable
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.45)',
}}
onPress={closePicker}
/>
{/* Sheet */}
<Animated.View
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: SHEET_HEIGHT,
backgroundColor: '#ffffff',
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
transform: [{ translateY: slideAnim }],
}}
>
{/* Drag handle */}
<View style={{ alignItems: 'center', paddingTop: 12, paddingBottom: 4 }}>
<View style={{ width: 40, height: 4, borderRadius: 2, backgroundColor: '#D1D5DB' }} />
</View>
{/* Sheet header */}
<View
style={{
paddingHorizontal: 24,
paddingTop: 12,
paddingBottom: 16,
borderBottomWidth: 1,
borderBottomColor: '#F3F4F6',
}}
>
<Text
style={{ fontSize: 18, fontWeight: '600', color: '#111827', fontFamily: semibold }}
>
Type handeling
</Text>
<Text style={{ fontSize: 13, color: '#6B7280', marginTop: 2, fontFamily: regular }}>
Kies een handeling
</Text>
</View>
{/* Task list */}
<ScrollView
contentContainerStyle={{ paddingVertical: 8, paddingBottom: insets.bottom + 32 }}
showsVerticalScrollIndicator={false}
>
{filteredTasks.length === 0 ? (
<View style={{ alignItems: 'center', paddingTop: 48, paddingHorizontal: 32 }}>
<Text
style={{
fontSize: 15,
color: '#9CA3AF',
textAlign: 'center',
fontFamily: regular,
}}
>
Geen handelingen beschikbaar voor {insoleType} zolen. Voeg ze toe via
Instellingen.
</Text>
</View>
) : (
filteredTasks.map((task: any) => {
const selected = activeTaskId === task.id;
return (
<TouchableOpacity
key={task.id}
onPress={() => {
setActiveTaskId(task.id);
closePicker();
}}
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 24,
paddingVertical: 18,
backgroundColor: selected ? '#F0F7FF' : '#ffffff',
borderBottomWidth: 1,
borderBottomColor: '#F3F4F6',
}}
>
<Text
style={{
flex: 1,
fontSize: 16,
color: selected ? '#2563EB' : '#374151',
fontFamily: selected ? semibold : regular,
}}
>
{task.name}
</Text>
{selected && <Check size={20} color="#2563EB" />}
</TouchableOpacity>
);
})
)}
</ScrollView>
</Animated.View>
</View>
</Modal>
</View>
);
}

View File

@@ -0,0 +1,574 @@
import React, { useState } from 'react';
import {
View,
Text,
ScrollView,
TouchableOpacity,
TextInput,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
Alert,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Plus, Pencil, Trash2, Check, X } from 'lucide-react-native';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useFonts, Inter_400Regular, Inter_600SemiBold } from '@expo-google-fonts/inter';
const BASE_URL = process.env.EXPO_PUBLIC_BASE_URL;
const ALL_TYPES = ['Kurk', 'Berk', '3D'] as const;
type InsoleType = (typeof ALL_TYPES)[number];
const TYPE_COLORS: Record<InsoleType, { bg: string; border: string; text: string }> = {
Kurk: { bg: '#FEF9C3', border: '#FDE047', text: '#854D0E' },
Berk: { bg: '#DCFCE7', border: '#86EFAC', text: '#166534' },
'3D': { bg: '#EDE9FE', border: '#C4B5FD', text: '#5B21B6' },
};
function TypeToggle({
type,
selected,
onPress,
}: {
type: InsoleType;
selected: boolean;
onPress: () => void;
}) {
const c = TYPE_COLORS[type];
return (
<TouchableOpacity
onPress={onPress}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 12,
paddingVertical: 7,
borderRadius: 999,
borderWidth: 2,
borderColor: selected ? c.border : '#E5E7EB',
backgroundColor: selected ? c.bg : '#F9FAFB',
}}
>
{selected && <Check size={13} color={c.text} />}
<Text
style={{
fontSize: 13,
fontWeight: '600',
color: selected ? c.text : '#9CA3AF',
fontFamily: 'Inter_600SemiBold',
}}
>
{type}
</Text>
</TouchableOpacity>
);
}
function TypeBadge({ type }: { type: InsoleType }) {
const c = TYPE_COLORS[type];
return (
<View
style={{
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 999,
backgroundColor: c.bg,
borderWidth: 1,
borderColor: c.border,
}}
>
<Text
style={{ fontSize: 11, fontWeight: '600', color: c.text, fontFamily: 'Inter_600SemiBold' }}
>
{type}
</Text>
</View>
);
}
export default function TasksScreen() {
const insets = useSafeAreaInsets();
const queryClient = useQueryClient();
const [fontsLoaded, fontError] = useFonts({ Inter_400Regular, Inter_600SemiBold });
const [newTaskName, setNewTaskName] = useState('');
const [newTaskTypes, setNewTaskTypes] = useState<InsoleType[]>(['Kurk', 'Berk', '3D']);
const [editingId, setEditingId] = useState<number | null>(null);
const [editingName, setEditingName] = useState('');
const [editingTypes, setEditingTypes] = useState<InsoleType[]>([]);
const { data: tasks = [], isLoading } = useQuery({
queryKey: ['tasks'],
queryFn: async () => {
const res = await fetch(`${BASE_URL}/api/tasks`);
if (!res.ok) throw new Error('Failed to fetch tasks');
return res.json();
},
});
const addTaskMutation = useMutation({
mutationFn: async ({ name, insole_types }: { name: string; insole_types: string[] }) => {
const res = await fetch(`${BASE_URL}/api/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, insole_types }),
});
if (!res.ok) throw new Error('Failed to add task');
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
setNewTaskName('');
setNewTaskTypes(['Kurk', 'Berk', '3D']);
},
});
const updateTaskMutation = useMutation({
mutationFn: async ({
id,
name,
insole_types,
}: {
id: number;
name: string;
insole_types: string[];
}) => {
const res = await fetch(`${BASE_URL}/api/tasks/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, insole_types }),
});
if (!res.ok) throw new Error('Failed to update task');
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
setEditingId(null);
},
});
const deleteTaskMutation = useMutation({
mutationFn: async (id: number) => {
const res = await fetch(`${BASE_URL}/api/tasks/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete task');
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
queryClient.invalidateQueries({ queryKey: ['logs'] });
},
});
const toggleNewType = (type: InsoleType) => {
setNewTaskTypes((prev) =>
prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type]
);
};
const toggleEditType = (type: InsoleType) => {
setEditingTypes((prev) =>
prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type]
);
};
const handleAddTask = () => {
if (!newTaskName.trim() || newTaskTypes.length === 0) return;
addTaskMutation.mutate({ name: newTaskName.trim(), insole_types: newTaskTypes });
};
const handleStartEdit = (task: any) => {
setEditingId(task.id);
setEditingName(task.name);
setEditingTypes(Array.isArray(task.insole_types) ? task.insole_types : ['Kurk', 'Berk', '3D']);
};
const handleConfirmEdit = () => {
if (!editingName.trim() || editingId === null || editingTypes.length === 0) return;
updateTaskMutation.mutate({
id: editingId,
name: editingName.trim(),
insole_types: editingTypes,
});
};
const handleCancelEdit = () => {
setEditingId(null);
setEditingName('');
setEditingTypes([]);
};
const handleDelete = (task: any) => {
Alert.alert(
'Taak verwijderen',
`"${task.name}" verwijderen? Alle tijdsregistraties voor deze taak worden ook verwijderd.`,
[
{ text: 'Annuleren', style: 'cancel' },
{
text: 'Verwijderen',
style: 'destructive',
onPress: () => deleteTaskMutation.mutate(task.id),
},
]
);
};
if (!fontsLoaded && !fontError) return null;
return (
<KeyboardAvoidingView
style={{ flex: 1, backgroundColor: '#ffffff' }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<View style={{ paddingTop: insets.top, flex: 1 }}>
{/* Header */}
<View
style={{
paddingHorizontal: 24,
paddingVertical: 16,
borderBottomWidth: 1,
borderBottomColor: '#E5E7EB',
}}
>
<Text
style={{
fontSize: 24,
fontWeight: '600',
color: '#111827',
fontFamily: 'Inter_600SemiBold',
}}
>
Instellingen
</Text>
<Text
style={{ fontSize: 14, color: '#6B7280', marginTop: 4, fontFamily: 'Inter_400Regular' }}
>
Beheer handelingen per zooltype
</Text>
</View>
<ScrollView
contentContainerStyle={{ padding: 20, paddingBottom: 60 }}
keyboardShouldPersistTaps="handled"
>
{/* Add New Task */}
<View
style={{
backgroundColor: '#F9FAFB',
borderRadius: 16,
padding: 16,
borderWidth: 1,
borderColor: '#E5E7EB',
marginBottom: 28,
}}
>
<Text
style={{
fontSize: 12,
fontWeight: '600',
color: '#6B7280',
marginBottom: 12,
textTransform: 'uppercase',
letterSpacing: 0.5,
fontFamily: 'Inter_600SemiBold',
}}
>
Nieuwe handeling toevoegen
</Text>
{/* Name input */}
<TextInput
value={newTaskName}
onChangeText={setNewTaskName}
placeholder="Naam van de stap, bijv. Leerrand"
placeholderTextColor="#9CA3AF"
returnKeyType="done"
onSubmitEditing={handleAddTask}
style={{
backgroundColor: '#ffffff',
borderWidth: 1,
borderColor: '#E5E7EB',
borderRadius: 10,
paddingHorizontal: 14,
paddingVertical: 11,
fontSize: 15,
color: '#111827',
fontFamily: 'Inter_400Regular',
marginBottom: 12,
}}
/>
{/* Insole type toggles */}
<Text
style={{
fontSize: 11,
fontWeight: '600',
color: '#9CA3AF',
marginBottom: 8,
textTransform: 'uppercase',
letterSpacing: 0.4,
fontFamily: 'Inter_600SemiBold',
}}
>
Van toepassing op
</Text>
<View style={{ flexDirection: 'row', gap: 8, marginBottom: 14 }}>
{ALL_TYPES.map((type) => (
<TypeToggle
key={type}
type={type}
selected={newTaskTypes.includes(type)}
onPress={() => toggleNewType(type)}
/>
))}
</View>
<TouchableOpacity
onPress={handleAddTask}
disabled={
addTaskMutation.isPending || !newTaskName.trim() || newTaskTypes.length === 0
}
style={{
backgroundColor:
newTaskName.trim() && newTaskTypes.length > 0 ? '#2563EB' : '#E5E7EB',
borderRadius: 10,
paddingVertical: 12,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
gap: 8,
}}
>
{addTaskMutation.isPending ? (
<ActivityIndicator color="white" size="small" />
) : (
<>
<Plus
color={newTaskName.trim() && newTaskTypes.length > 0 ? 'white' : '#9CA3AF'}
size={18}
/>
<Text
style={{
color: newTaskName.trim() && newTaskTypes.length > 0 ? 'white' : '#9CA3AF',
fontSize: 15,
fontWeight: '600',
fontFamily: 'Inter_600SemiBold',
}}
>
Stap toevoegen
</Text>
</>
)}
</TouchableOpacity>
</View>
{/* Task List */}
<Text
style={{
fontSize: 12,
fontWeight: '600',
color: '#6B7280',
marginBottom: 12,
textTransform: 'uppercase',
letterSpacing: 0.5,
fontFamily: 'Inter_600SemiBold',
}}
>
Huidige stappen ({tasks.length})
</Text>
{isLoading ? (
<ActivityIndicator color="#2563EB" style={{ marginTop: 40 }} />
) : tasks.length === 0 ? (
<View style={{ alignItems: 'center', marginTop: 40 }}>
<Text style={{ color: '#9CA3AF', fontSize: 15, fontFamily: 'Inter_400Regular' }}>
Nog geen stappen. Voeg er een toe hierboven.
</Text>
</View>
) : (
tasks.map((task: any) => {
const types: InsoleType[] = Array.isArray(task.insole_types) ? task.insole_types : [];
const isEditing = editingId === task.id;
return (
<View
key={task.id}
style={{
backgroundColor: '#ffffff',
borderRadius: 12,
borderWidth: 1,
borderColor: isEditing ? '#2563EB' : '#E5E7EB',
padding: 14,
marginBottom: 10,
}}
>
{isEditing ? (
<>
{/* Edit name */}
<TextInput
value={editingName}
onChangeText={setEditingName}
autoFocus
returnKeyType="done"
onSubmitEditing={handleConfirmEdit}
style={{
fontSize: 15,
color: '#111827',
fontFamily: 'Inter_400Regular',
borderBottomWidth: 1,
borderBottomColor: '#E5E7EB',
paddingBottom: 8,
marginBottom: 12,
}}
/>
{/* Edit insole types */}
<Text
style={{
fontSize: 11,
fontWeight: '600',
color: '#9CA3AF',
marginBottom: 8,
textTransform: 'uppercase',
letterSpacing: 0.4,
fontFamily: 'Inter_600SemiBold',
}}
>
Van toepassing op
</Text>
<View style={{ flexDirection: 'row', gap: 8, marginBottom: 14 }}>
{ALL_TYPES.map((type) => (
<TypeToggle
key={type}
type={type}
selected={editingTypes.includes(type)}
onPress={() => toggleEditType(type)}
/>
))}
</View>
{/* Confirm / Cancel */}
<View style={{ flexDirection: 'row', gap: 8 }}>
<TouchableOpacity
onPress={handleConfirmEdit}
disabled={updateTaskMutation.isPending || editingTypes.length === 0}
style={{
flex: 1,
backgroundColor: '#DCFCE7',
borderRadius: 8,
paddingVertical: 10,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
gap: 6,
}}
>
{updateTaskMutation.isPending ? (
<ActivityIndicator color="#16A34A" size="small" />
) : (
<>
<Check size={16} color="#16A34A" />
<Text
style={{
color: '#16A34A',
fontWeight: '600',
fontFamily: 'Inter_600SemiBold',
fontSize: 14,
}}
>
Opslaan
</Text>
</>
)}
</TouchableOpacity>
<TouchableOpacity
onPress={handleCancelEdit}
style={{
flex: 1,
backgroundColor: '#F3F4F6',
borderRadius: 8,
paddingVertical: 10,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
gap: 6,
}}
>
<X size={16} color="#6B7280" />
<Text
style={{
color: '#6B7280',
fontWeight: '600',
fontFamily: 'Inter_600SemiBold',
fontSize: 14,
}}
>
Annuleren
</Text>
</TouchableOpacity>
</View>
</>
) : (
<>
{/* Task name + actions */}
<View
style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 10 }}
>
<Text
style={{
flex: 1,
fontSize: 15,
color: '#374151',
fontFamily: 'Inter_400Regular',
}}
>
{task.name}
</Text>
<View style={{ flexDirection: 'row', gap: 8 }}>
<TouchableOpacity
onPress={() => handleStartEdit(task)}
style={{
backgroundColor: '#EFF6FF',
borderRadius: 8,
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Pencil color="#2563EB" size={16} />
</TouchableOpacity>
<TouchableOpacity
onPress={() => handleDelete(task)}
disabled={deleteTaskMutation.isPending}
style={{
backgroundColor: '#FEF2F2',
borderRadius: 8,
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Trash2 color="#DC2626" size={16} />
</TouchableOpacity>
</View>
</View>
{/* Insole type badges */}
<View style={{ flexDirection: 'row', gap: 6 }}>
{types.map((type) => (
<TypeBadge key={type} type={type} />
))}
</View>
</>
)}
</View>
);
})
)}
</ScrollView>
</View>
</KeyboardAvoidingView>
);
}

View File

@@ -0,0 +1,436 @@
import { Ionicons } from '@expo/vector-icons';
import {
type RelativePathString,
type SitemapType,
Stack,
useGlobalSearchParams,
useRouter,
useSitemap,
} from 'expo-router';
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
interface ParentSitemap {
expoPages?: Array<{
id: string;
name: string;
filePath: string;
cleanRoute?: string;
}>;
}
function NotFoundScreen() {
const router = useRouter();
const params = useGlobalSearchParams();
const expoSitemap = useSitemap();
const [sitemap, setSitemap] = useState<SitemapType | ParentSitemap | null>(expoSitemap);
useEffect(() => {
if (typeof window !== 'undefined' && window.parent && window.parent !== window) {
const handler = (event: MessageEvent) => {
if (event.data.type === 'sandbox:sitemap') {
window.removeEventListener('message', handler);
setSitemap(event.data.sitemap);
}
};
window.parent.postMessage(
{
type: 'sandbox:sitemap',
},
'*'
);
window.addEventListener('message', handler);
return () => {
window.removeEventListener('message', handler);
};
}
}, []);
const isExpoSitemap = sitemap === expoSitemap;
const missingPath = params['not-found']?.[0] || '';
const availableRoutes = useMemo(() => {
return (
expoSitemap?.children?.filter(
(child) =>
child.href &&
child.contextKey !== './auth.jsx' &&
child.contextKey !== './auth.web.jsx' &&
child.contextKey !== './+not-found.tsx' &&
child.contextKey !== 'expo-router/build/views/Sitemap.js'
) || []
);
}, [expoSitemap]);
const handleBack = () => {
if (router.canGoBack()) {
router.back();
} else {
const hasTabsIndex = expoSitemap?.children?.some(
(child) =>
child.contextKey === './(tabs)/_layout.jsx' &&
child.children.some((child) => child.contextKey === './(tabs)/index.jsx')
);
if (isExpoSitemap) {
if (hasTabsIndex) {
router.replace('../(tabs)/index.jsx');
} else {
router.replace('../');
}
} else {
router.replace('..');
}
}
};
const handleNavigate = (url: string) => {
try {
if (url) {
router.push(url as RelativePathString);
}
} catch (error) {
console.error('Navigation error:', error);
}
};
const handleCreatePage = useCallback(() => {
if (typeof window !== 'undefined' && window.parent && window.parent !== window) {
window.parent.postMessage(
{
type: 'sandbox:web:create',
path: missingPath,
view: 'mobile',
},
'*'
);
}
}, [missingPath]);
return (
<>
<Stack.Screen options={{ title: 'Page Not Found', headerShown: false }} />
<SafeAreaView style={styles.safeArea}>
<ScrollView style={styles.container} contentContainerStyle={styles.contentContainer}>
<View style={styles.header}>
<TouchableOpacity onPress={handleBack} style={styles.backButton}>
<Ionicons name="arrow-back" size={18} color="#666" />
</TouchableOpacity>
<View style={styles.pathContainer}>
<View style={styles.pathPrefix}>
<Text style={styles.pathPrefixText}>/</Text>
</View>
<View style={styles.pathContent}>
<Text style={styles.pathText} numberOfLines={1}>
{missingPath}
</Text>
</View>
</View>
</View>
<View style={styles.mainContent}>
<Text style={styles.title}>Uh-oh! This screen doesn't exist (yet).</Text>
<Text style={styles.subtitle}>
Looks like "<Text style={styles.boldText}>/{missingPath}</Text>" isn't part of your
project. But no worries, you've got options!
</Text>
{typeof window !== 'undefined' && window.parent && window.parent !== window && (
<View style={styles.createPageContainer}>
<View style={styles.createPageContent}>
<View style={styles.createPageTextContainer}>
<Text style={styles.createPageTitle}>Build it from scratch</Text>
<Text style={styles.createPageDescription}>
Create a new screen to live at "/{missingPath}"
</Text>
</View>
<View style={styles.createPageButtonContainer}>
<TouchableOpacity
onPress={() => handleCreatePage()}
style={styles.createPageButton}
>
<Text style={styles.createPageButtonText}>Create Screen</Text>
</TouchableOpacity>
</View>
</View>
</View>
)}
<Text style={styles.routesLabel}>Check out all your project's routes here </Text>
{!isExpoSitemap && sitemap ? (
<View style={styles.pagesContainer}>
<View style={styles.pagesListContainer}>
<Text style={styles.pagesLabel}>MOBILE</Text>
{((sitemap as ParentSitemap).expoPages || []).map((route, _index: number) => (
<TouchableOpacity
key={route.id}
onPress={() => handleNavigate(route.cleanRoute || '')}
style={styles.pageButton}
>
<Text style={styles.routeName}>{route.name}</Text>
</TouchableOpacity>
))}
</View>
</View>
) : (
<View style={styles.pagesContainer}>
<View style={styles.pagesListContainer}>
<Text style={styles.pagesLabel}>MOBILE</Text>
{(availableRoutes as SitemapType[]).map((route: SitemapType, _index: number) => {
const url =
typeof route.href === 'string' ? route.href : route.href?.pathname || '/';
if (url === '/(tabs)' && route.children) {
return route.children.map((childRoute: SitemapType) => {
const childUrl =
typeof childRoute.href === 'string'
? childRoute.href
: childRoute.href.pathname || '/';
const displayPath =
childUrl === '/(tabs)'
? 'Homepage'
: childUrl.replace(/^\//, '').replace(/^\(tabs\)\//, '');
return (
<TouchableOpacity
key={childRoute.contextKey}
onPress={() => handleNavigate(childUrl)}
style={styles.pageButton}
>
<Text style={styles.routeName}>{displayPath}</Text>
</TouchableOpacity>
);
});
}
const displayPath = url === '/' ? 'Homepage' : url.replace(/^\//, '');
return (
<TouchableOpacity
key={route.contextKey}
onPress={() => handleNavigate(url)}
style={styles.pageButton}
>
<Text style={styles.routeName}>{displayPath}</Text>
</TouchableOpacity>
);
})}
</View>
</View>
)}
</View>
</ScrollView>
</SafeAreaView>
</>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: '#fff',
},
container: {
flex: 1,
backgroundColor: '#fff',
},
contentContainer: {
flexGrow: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
padding: 20,
gap: 8,
},
backButton: {
width: 40,
height: 40,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
},
pathContainer: {
flexDirection: 'row',
height: 32,
width: 300,
borderWidth: 1,
borderColor: '#e5e5e5',
borderRadius: 8,
backgroundColor: '#f9f9f9',
overflow: 'hidden',
},
pathPrefix: {
paddingHorizontal: 14,
paddingVertical: 5,
justifyContent: 'center',
borderRightWidth: 1,
borderRightColor: '#e5e5e5',
},
pathPrefixText: {
color: '#666',
},
pathContent: {
flex: 1,
paddingHorizontal: 12,
justifyContent: 'center',
},
pathText: {
color: '#666',
},
mainContent: {
flex: 1,
alignItems: 'center',
paddingTop: 40,
paddingHorizontal: 20,
},
title: {
fontSize: 32,
fontWeight: '500',
color: '#111',
marginBottom: 16,
textAlign: 'center',
},
subtitle: {
paddingTop: 16,
paddingBottom: 48,
color: '#666',
textAlign: 'center',
fontSize: 16,
lineHeight: 24,
},
boldText: {
fontWeight: 'bold',
},
routesLabel: {
color: '#666',
marginBottom: 80,
textAlign: 'center',
},
createPageContainer: {
width: '100%',
maxWidth: 800,
marginBottom: 40,
paddingHorizontal: 20,
},
createPageContent: {
flexDirection: 'column',
alignItems: 'center',
borderWidth: 1,
borderColor: '#e5e5e5',
borderRadius: 8,
padding: 20,
backgroundColor: '#fff',
gap: 15,
},
createPageTextContainer: {
gap: 10,
},
createPageTitle: {
fontSize: 14,
color: '#000',
fontWeight: '500',
textAlign: 'center',
},
createPageDescription: {
fontSize: 14,
color: '#666',
textAlign: 'center',
},
createPageButtonContainer: {
alignItems: 'flex-start',
justifyContent: 'center',
},
createPageButton: {
backgroundColor: '#000',
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 6,
},
createPageButtonText: {
color: '#fff',
fontSize: 14,
fontWeight: '500',
},
pagesContainer: {
width: '100%',
alignItems: 'center',
paddingHorizontal: 20,
},
pagesLabel: {
fontSize: 14,
color: '#ccc',
alignSelf: 'flex-start',
marginBottom: 10,
paddingHorizontal: 16,
},
pagesListContainer: {
width: '100%',
maxWidth: 600,
gap: 10,
},
pageButton: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
borderRadius: 8,
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#e5e5e5',
boxShadow: '0px 1px 2px rgba(0, 0, 0, 0.05)',
elevation: 1,
},
routeName: {
fontSize: 16,
fontWeight: '500',
color: '#111',
},
routePath: {
fontSize: 14,
color: '#999',
},
routesContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'center',
width: '100%',
paddingHorizontal: 20,
paddingBottom: 20,
gap: 40,
},
routeCard: {
width: '100%',
maxWidth: 300,
minWidth: 150,
alignItems: 'center',
marginBottom: 12,
},
routeButton: {
width: '100%',
aspectRatio: 1.4,
borderRadius: 8,
borderWidth: 1,
borderColor: '#e5e5e5',
overflow: 'hidden',
},
routePreview: {
flex: 1,
backgroundColor: '#f9f9f9',
},
routeLabel: {
paddingTop: 12,
color: '#666',
textAlign: 'left',
width: '100%',
},
});
export default () => {
return (
<NotFoundScreen />
);
};

View File

@@ -0,0 +1,71 @@
/**
* This file is customizable BUT — do not remove:
* • `<AuthModal />` render (shipped v2 auth modal; removing it breaks
* signin/signup since useAuth().signIn() only flips state, not render)
* • `useAuth().initiate()` + `isReady` gate (loads persisted session from
* SecureStore — removing causes user to appear signed-out on app launch)
*
* Safe to change: the Stack routes, QueryClient config, splash behavior, the
* wrapping providers, or to add nested providers around <Stack>.
*/
'use client';
import { ErrorBoundary } from '@/__create/ErrorBoundary';
import { useAuth } from '@/utils/auth/useAuth';
import { AuthModal } from '@/utils/auth/useAuthModal';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { useEffect, useState } from 'react';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
void SplashScreen.preventAutoHideAsync();
const SPLASH_TIMEOUT_MS = 10_000;
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 30, // 30 minutes
retry: 1,
refetchOnWindowFocus: false,
},
},
});
export default function RootLayout() {
const { initiate, isReady } = useAuth();
const [timedOut, setTimedOut] = useState(false);
useEffect(() => {
initiate();
}, [initiate]);
useEffect(() => {
const timeout = setTimeout(() => setTimedOut(true), SPLASH_TIMEOUT_MS);
return () => clearTimeout(timeout);
}, []);
useEffect(() => {
if (isReady || timedOut) {
void SplashScreen.hideAsync();
}
}, [isReady, timedOut]);
if (!isReady && !timedOut) {
return null;
}
return (
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<GestureHandlerRootView style={{ flex: 1 }}>
<Stack screenOptions={{ headerShown: false }} initialRouteName="(tabs)">
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
</Stack>
<AuthModal />
</GestureHandlerRootView>
</QueryClientProvider>
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,3 @@
export default function Index() {
return null;
}

View File

@@ -0,0 +1,147 @@
import React, { useRef, useEffect, forwardRef } from 'react';
import { Platform, Keyboard, KeyboardAvoidingView, LayoutChangeEvent, ViewStyle, KeyboardEvent } from 'react-native';
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
interface Layout {
x: number;
y: number;
width: number;
height: number;
}
interface KeyboardAvoidingAnimatedViewProps {
children: React.ReactNode;
behavior?: 'padding' | 'height' | 'position';
keyboardVerticalOffset?: number;
style?: ViewStyle;
contentContainerStyle?: ViewStyle;
enabled?: boolean;
onLayout?: (event: LayoutChangeEvent) => void;
}
const KeyboardAvoidingAnimatedView = forwardRef<Animated.View, KeyboardAvoidingAnimatedViewProps>((props, ref) => {
const {
children,
behavior = Platform.OS === 'ios' ? 'padding' : 'height',
keyboardVerticalOffset = 0,
style,
contentContainerStyle,
enabled = true,
onLayout,
...leftoverProps
} = props;
const animatedViewRef = useRef<Layout | null>(null); // ref to animated view in this polyfill
const initialHeight = useSharedValue(0); // original height of animated view before keyboard appears
const bottomHeight = useSharedValue(0); // whats going to be added to the bottom when keyboard appears
useEffect(() => {
if (!enabled) return;
const onKeyboardShow = (event: KeyboardEvent) => {
const { duration, endCoordinates } = event;
const animatedView = animatedViewRef.current;
if (!animatedView) return;
let height = 0;
// calculate how much the view needs to move up
const keyboardY = endCoordinates.screenY - keyboardVerticalOffset;
height = Math.max(animatedView.y + animatedView.height - keyboardY, 0);
bottomHeight.value = withTiming(height, {
duration: duration > 10 ? duration : 300,
});
};
const onKeyboardHide = () => {
bottomHeight.value = withTiming(0, { duration: 300 });
};
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
const showSubscription = Keyboard.addListener(showEvent, onKeyboardShow);
const hideSubscription = Keyboard.addListener(hideEvent, onKeyboardHide);
return () => {
showSubscription.remove();
hideSubscription.remove();
};
}, [keyboardVerticalOffset, enabled, bottomHeight]);
const animatedStyle = useAnimatedStyle(() => {
if (behavior === 'height') {
return {
height: initialHeight.value - bottomHeight.value,
flex: bottomHeight.value > 0 ? 0 : (null as never),
};
}
if (behavior === 'padding') {
return {
paddingBottom: bottomHeight.value,
};
}
return {};
});
const positionAnimatedStyle = useAnimatedStyle(() => ({
bottom: bottomHeight.value,
}));
const handleLayout = (event: LayoutChangeEvent) => {
const layout = event.nativeEvent.layout;
animatedViewRef.current = layout;
// initial height before keybaord appears
if (initialHeight.value === 0) {
initialHeight.value = layout.height;
}
if (onLayout) {
onLayout(event);
}
};
const renderContent = () => {
if (behavior === 'position') {
return (
<Animated.View style={[contentContainerStyle, positionAnimatedStyle]}>
{children}
</Animated.View>
);
}
// render children if padding or height
return children;
};
// for web, default to unused keyboard avoiding view
if (Platform.OS === 'web') {
return (
<KeyboardAvoidingView
behavior={behavior}
style={style}
contentContainerStyle={contentContainerStyle}
{...leftoverProps}
>
{children}
</KeyboardAvoidingView>
);
}
return (
<Animated.View
ref={ref}
style={[style, animatedStyle]}
onLayout={handleLayout}
{...leftoverProps}
>
{renderContent()}
</Animated.View>
);
});
KeyboardAvoidingAnimatedView.displayName = 'KeyboardAvoidingAnimatedView';
export default KeyboardAvoidingAnimatedView;

View File

@@ -0,0 +1,163 @@
/**
* ⚠ ANYTHING PLATFORM — DO NOT REWRITE THIS FILE ⚠
*
* Shipped v2 auth WebView. Handles both native (iOS/Android WebView +
* onShouldStartLoadWithRequest → fetch /api/auth/token → setAuth) and web
* (iframe + window.addEventListener('message') listening for AUTH_SUCCESS
* postMessage from /api/auth/expo-web-success). BOTH code paths are
* load-bearing; do NOT delete the web branch because you're only testing
* native, and vice versa. The postMessage contract { type, jwt, user } must
* stay in sync with /api/auth/expo-web-success/route.ts.
*/
'use client';
import { router } from 'expo-router';
import { useEffect, useRef, useState } from 'react';
import { Platform } from 'react-native';
import { WebView } from 'react-native-webview';
import type { WebViewNavigation } from 'react-native-webview/lib/WebViewTypes';
import { useAuthStore } from './store';
const callbackUrl = '/api/auth/token';
const callbackQueryString = `callbackUrl=${callbackUrl}`;
// Normalize the expected origin once. `new URL(...).origin` strips trailing
// slashes, paths, and query — so a stray slash in EXPO_PUBLIC_PROXY_BASE_URL
// no longer silently drops every postMessage from the auth iframe.
const allowedOrigin = (() => {
const raw = process.env.EXPO_PUBLIC_PROXY_BASE_URL;
if (!raw) return null;
try {
return new URL(raw).origin;
} catch {
return null;
}
})();
interface AuthWebViewProps {
mode: 'signup' | 'signin';
proxyURL: string;
baseURL: string;
}
interface AuthMessageData {
type: 'AUTH_SUCCESS' | 'AUTH_ERROR';
jwt?: string;
user?: {
id: string;
email: string;
name: string;
image: string;
};
error?: string;
}
/**
* This renders a WebView for authentication and handles both web and native platforms.
*/
export const AuthWebView = ({ mode, proxyURL, baseURL }: AuthWebViewProps) => {
const [currentURI, setURI] = useState(`${baseURL}/account/${mode}?${callbackQueryString}`);
const { auth, setAuth, isReady } = useAuthStore();
const isAuthenticated = isReady ? !!auth : null;
const iframeRef = useRef<HTMLIFrameElement>(null);
useEffect(() => {
if (Platform.OS === 'web') {
return;
}
if (isAuthenticated) {
router.back();
}
}, [isAuthenticated]);
useEffect(() => {
if (isAuthenticated) {
return;
}
setURI(`${baseURL}/account/${mode}?${callbackQueryString}`);
}, [mode, baseURL, isAuthenticated]);
useEffect(() => {
if (typeof window === 'undefined' || !window.addEventListener) {
return;
}
const handleMessage = (event: MessageEvent<AuthMessageData>) => {
// Verify the origin for security. Compare normalized origins so a
// trailing slash or path in EXPO_PUBLIC_PROXY_BASE_URL doesn't drop
// legitimate messages. Surface drops via console.warn instead of
// silently swallowing them.
if (allowedOrigin && event.origin !== allowedOrigin) {
console.warn(
`AuthWebView: dropping message from unexpected origin ${event.origin}; expected ${allowedOrigin}`
);
return;
}
if (event.data.type === 'AUTH_SUCCESS') {
setAuth({
jwt: event.data.jwt!,
user: event.data.user!,
});
} else if (event.data.type === 'AUTH_ERROR') {
console.error('Auth error:', event.data.error);
}
};
window.addEventListener('message', handleMessage);
return () => {
window.removeEventListener('message', handleMessage);
};
}, [setAuth]);
if (Platform.OS === 'web') {
const handleIframeError = () => {
console.error('Failed to load auth iframe');
};
return (
<iframe
ref={iframeRef}
title="Authentication"
src={`${proxyURL}/account/${mode}?callbackUrl=/api/auth/expo-web-success`}
style={{ width: '100%', height: '100%', border: 'none' }}
onError={handleIframeError}
/>
);
}
return (
<WebView
sharedCookiesEnabled
source={{
uri: currentURI,
}}
headers={{
'x-createxyz-project-group-id': process.env.EXPO_PUBLIC_PROJECT_GROUP_ID!,
host: process.env.EXPO_PUBLIC_HOST!,
'x-forwarded-host': process.env.EXPO_PUBLIC_HOST!,
'x-createxyz-host': process.env.EXPO_PUBLIC_HOST!,
}}
onShouldStartLoadWithRequest={(request: WebViewNavigation) => {
if (request.url === `${baseURL}${callbackUrl}`) {
fetch(request.url)
.then((response) => response.json())
.then((data) => {
setAuth({ jwt: data.jwt, user: data.user });
})
.catch(() => {});
return false;
}
if (request.url === currentURI) return true;
// Add query string properly by checking if URL already has parameters
const hasParams = request.url.includes('?');
const separator = hasParams ? '&' : '?';
const newURL = request.url.replaceAll(proxyURL, baseURL);
if (newURL.endsWith(callbackUrl)) {
setURI(newURL);
return false;
}
setURI(`${newURL}${separator}${callbackQueryString}`);
return false;
}}
style={{ flex: 1 }}
/>
);
};

View File

@@ -0,0 +1,40 @@
/**
* ⚠ ANYTHING PLATFORM — DO NOT REWRITE THIS FILE ⚠
*
* Shipped v2 auth helpers. `authFetch` auto-adds Authorization: Bearer <jwt>
* when a session exists — use it instead of bare fetch() for calls to the
* web app's API routes. The web server's better-auth bearer() plugin
* validates these headers. DO NOT reimplement these helpers in user code.
*/
'use client';
import { useAuthStore } from './store';
/**
* Read the current session (jwt + user) synchronously from the auth store.
* Returns null if the user is not authenticated.
*/
export const getSession = () => useAuthStore.getState().auth;
/**
* Read the current session's JWT for use in an Authorization header.
* Returns null if the user is not authenticated.
*/
export const getJwt = () => useAuthStore.getState().auth?.jwt ?? null;
/**
* Drop-in replacement for fetch() that automatically adds the
* `Authorization: Bearer <jwt>` header when the user is signed in. Use this
* for calls from the mobile app to the web app's API routes — the web server
* uses better-auth's `bearer()` plugin to authenticate these requests.
*
* Existing Authorization headers on the caller's `init.headers` are preserved.
*/
export const authFetch: typeof fetch = (input, init) => {
const jwt = getJwt();
const headers = new Headers(init?.headers);
if (jwt && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${jwt}`);
}
return fetch(input, { ...init, headers });
};

View File

@@ -0,0 +1,14 @@
/**
* ⚠ ANYTHING PLATFORM — DO NOT REWRITE THIS FILE ⚠
*
* Shipped v2 auth barrel. Keeps the public import surface stable so user
* code can `import { useAuth, useUser, useAuthModal, authFetch } from '@/utils/auth'`.
* DO NOT remove or rename these re-exports — downstream code breaks.
*/
import { useAuth, useRequireAuth } from './useAuth';
import { useUser } from './useUser';
import { useAuthModal } from './store';
export { useAuth, useRequireAuth, useUser, useAuthModal };
export { authFetch, getJwt, getSession } from './getSession';
export default useAuth;

View File

@@ -0,0 +1,95 @@
/**
* ⚠ ANYTHING PLATFORM — DO NOT REWRITE THIS FILE ⚠
*
* Shipped v2 zustand stores for auth session (persisted to SecureStore) and
* auth modal open/close state. `useAuth`, `useAuthModal` component, and
* `AuthWebView` all read from these stores — renaming fields or changing
* shape breaks all three. DO NOT replace with Context, DO NOT merge into a
* single store.
*/
import * as SecureStore from 'expo-secure-store';
import { create } from 'zustand';
export const authKey = `${process.env.EXPO_PUBLIC_PROJECT_GROUP_ID}-jwt`;
/**
* Explicit Keychain options used on every SecureStore call in the auth flow.
*
* - keychainService: pinned to a stable name so reads and writes always hit
* the same partition. Without this, SecureStore derives a service name from
* the bundle that can drift between Classic and EAS builds, causing reads
* to miss writes from a previous build.
* - keychainAccessible: AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY allows the auth
* token to be read on every cold launch after the device has been unlocked
* once since boot. The default (WHEN_UNLOCKED) refuses access during the
* first-unlock window, which is the most common TestFlight failure mode.
* - requireAuthentication: false keeps SecureStore on its non-biometric code
* path, so it never reads NSFaceIDUsageDescription or constructs a
* biometry-current-set access control object — both of which can throw
* NSException and trip iOS 26's unhandled async-void TurboModule rethrow.
*/
export const secureStoreOptions: SecureStore.SecureStoreOptions = {
keychainService: 'anything-auth',
keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY,
requireAuthentication: false,
};
export interface User {
id: string;
email: string;
name: string;
image: string;
}
export interface Auth {
jwt: string;
user: User;
}
interface AuthState {
isReady: boolean;
auth: Auth | null;
setAuth: (auth: Auth | null) => void;
}
/**
* This store manages the authentication state of the application.
*/
export const useAuthStore = create<AuthState>((set) => ({
isReady: false,
auth: null,
setAuth: (auth) => {
if (auth) {
SecureStore.setItemAsync(
authKey,
JSON.stringify(auth),
secureStoreOptions,
).catch(() => {
// Swallow Keychain write errors — the app remains in-memory authed
// for this session and the next launch will re-auth via the WebView.
// Throwing here would propagate into the unhandled-rejection /
// TurboModule rethrow path and crash on iOS 26.x.
});
} else {
SecureStore.deleteItemAsync(authKey, secureStoreOptions).catch(() => {});
}
set({ auth });
},
}));
interface AuthModalState {
isOpen: boolean;
mode: 'signup' | 'signin';
open: (options?: { mode?: 'signup' | 'signin' }) => void;
close: () => void;
}
/**
* This store manages the state of the authentication modal.
*/
export const useAuthModal = create<AuthModalState>((set) => ({
isOpen: false,
mode: 'signup',
open: (options) => set({ isOpen: true, mode: options?.mode || 'signup' }),
close: () => set({ isOpen: false }),
}));

View File

@@ -0,0 +1,101 @@
/**
* ⚠ ANYTHING PLATFORM — DO NOT REWRITE THIS FILE ⚠
*
* Shipped v2 mobile auth hook. `useAuth()` is the public surface for
* user apps — `{ signIn, signUp, signOut, auth, isAuthenticated, isReady }`.
* These names are documented in the v2 auth prompt; user code imports them
* directly. DO NOT rename them, DO NOT remove `initiate()` (it loads the
* persisted session from SecureStore), and DO NOT add side effects that run
* before isReady flips true.
*/
import * as SecureStore from 'expo-secure-store';
import { useCallback, useEffect } from 'react';
import { authKey, type Auth, secureStoreOptions, useAuthModal, useAuthStore } from './store';
interface UseAuthReturn {
isReady: boolean;
isAuthenticated: boolean | null;
signIn: () => void;
signOut: () => void;
signUp: () => void;
auth: Auth | null;
setAuth: (auth: Auth | null) => void;
initiate: () => void;
}
/**
* This hook provides authentication functionality.
* It may be easier to use the `useAuthModal` or `useRequireAuth` hooks
* instead as those will also handle showing authentication to the user
* directly.
*/
export const useAuth = (): UseAuthReturn => {
const { isReady, auth, setAuth } = useAuthStore();
const { isOpen: _isOpen, close, open } = useAuthModal();
const initiate = useCallback(() => {
// The auth state machine must always reach a terminal state. SecureStore
// can throw or hang in TestFlight release builds (Keychain access denied,
// missing keychain-access-groups entitlement after EAS migration, locked
// device first-unlock state, or iOS 26 TurboModule rethrow). Without a
// catch the unhandled rejection leaves isReady=false forever and the
// RootLayout renders null — the user sees a blank screen indefinitely.
Promise.race<string | null>([
SecureStore.getItemAsync(authKey, secureStoreOptions),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 3000)),
])
.then((authString) => {
useAuthStore.setState({
auth: authString ? (JSON.parse(authString) as Auth) : null,
isReady: true,
});
})
.catch(() => {
useAuthStore.setState({ auth: null, isReady: true });
});
}, []);
useEffect(() => {}, []);
const signIn = useCallback(() => {
open({ mode: 'signin' });
}, [open]);
const signUp = useCallback(() => {
open({ mode: 'signup' });
}, [open]);
const signOut = useCallback(() => {
setAuth(null);
close();
}, [close, setAuth]);
return {
isReady,
isAuthenticated: isReady ? !!auth : null,
signIn,
signOut,
signUp,
auth,
setAuth,
initiate,
};
};
interface UseRequireAuthOptions {
mode?: 'signup' | 'signin';
}
export const useRequireAuth = (options?: UseRequireAuthOptions): UseAuthReturn => {
const authReturn = useAuth();
const { open } = useAuthModal();
useEffect(() => {
if (!authReturn.isAuthenticated && authReturn.isReady) {
open({ mode: options?.mode });
}
}, [authReturn.isAuthenticated, open, options?.mode, authReturn.isReady]);
return authReturn;
};
export default useAuth;

View File

@@ -0,0 +1,104 @@
/**
* ⚠ ANYTHING PLATFORM — DO NOT REWRITE THIS FILE ⚠
*
* Shipped v2 <AuthModal /> — the modal that wraps the AuthWebView. It's
* already mounted in app/_layout.tsx; DO NOT mount it again. The env-var
* preflight (returns a "not configured" modal when EXPO_PUBLIC_BASE_URL or
* EXPO_PUBLIC_PROXY_BASE_URL is missing) is intentional — removing it turns
* env-var misconfig into a silent "nothing happens" bug. The named export of
* useAuthModal at the top is also load-bearing (user code imports it from
* this file, not just from ./store).
*/
'use client';
import React from 'react';
import { Modal, Text, View } from 'react-native';
import { AuthWebView } from './AuthWebView';
import { useAuthModal, useAuthStore } from './store';
export { useAuthModal } from './store';
/**
* This component renders a modal for authentication purposes.
* To show it programmatically, you should either use the `useRequireAuth` hook or the `useAuthModal` hook.
*
* @example
* ```js
* import { useAuthModal } from '@/utils/useAuthModal';
* function MyComponent() {
* const { open } = useAuthModal();
* return <Button title="Login" onPress={() => open({ mode: 'signin' })} />;
* }
* ```
*
* @example
* ```js
* import { useRequireAuth } from '@/utils/auth';
* function MyComponent() {
* // automatically opens the auth modal if the user is not authenticated
* useRequireAuth();
* return <Text>Protected Content</Text>;
* }
*
*/
export const AuthModal = () => {
const { auth } = useAuthStore();
const { isOpen, mode } = useAuthModal();
const proxyURL = process.env.EXPO_PUBLIC_PROXY_BASE_URL;
const baseURL = process.env.EXPO_PUBLIC_BASE_URL;
if (!proxyURL || !baseURL) {
const missing = [
!proxyURL && 'EXPO_PUBLIC_PROXY_BASE_URL',
!baseURL && 'EXPO_PUBLIC_BASE_URL',
]
.filter(Boolean)
.join(', ');
console.error(
`AuthModal: missing required env var(s): ${missing}. Auth cannot open.`
);
return (
<Modal
visible={isOpen && !auth}
animationType="slide"
presentationStyle="pageSheet"
>
<View className="flex-1 items-center justify-center bg-white p-[24px]">
<Text className="mb-[8px] text-[18px] font-semibold">
Auth is not configured
</Text>
<Text className="text-center text-[14px] text-gray-600">
Missing environment variable{missing.includes(',') ? 's' : ''}:{' '}
{missing}. Set {missing.includes(',') ? 'them' : 'it'} in your .env
and restart the app.
</Text>
</View>
</Modal>
);
}
return (
<Modal visible={isOpen && !auth} animationType="slide" presentationStyle='pageSheet'>
<View
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: '100%',
width: '100%',
backgroundColor: '#fff',
padding: 0,
}}
>
<AuthWebView
mode={mode}
proxyURL={proxyURL}
baseURL={baseURL}
/>
</View>
</Modal>
);
};
export default useAuthModal;

View File

@@ -0,0 +1,24 @@
/**
* ⚠ ANYTHING PLATFORM — DO NOT REWRITE THIS FILE ⚠
*
* V1-compatible mobile user hook. Migrated apps commonly import
* `@/utils/auth/useUser` and expect `{ user, data, loading, refetch }`.
* Keep this surface stable; the V2 auth state still comes from `useAuth()`.
*/
import { useCallback } from 'react';
import { useAuth } from './useAuth';
export const useUser = () => {
const { auth, isReady } = useAuth();
const user = auth?.user ?? null;
const refetch = useCallback(async () => user, [user]);
return {
user,
data: user,
loading: !isReady,
refetch,
};
};
export default useUser;

View File

@@ -0,0 +1,17 @@
export const mockConfigure = jest.fn();
export const mockSetLogLevel = jest.fn();
export const mockGetOfferings = jest.fn();
export const mockPurchasePackage = jest.fn();
export const mockRestorePurchases = jest.fn();
const Purchases = {
configure: (...args: any[]) => mockConfigure(...args),
setLogLevel: (...args: any[]) => mockSetLogLevel(...args),
getOfferings: (...args: any[]) => mockGetOfferings(...args),
purchasePackage: (...args: any[]) => mockPurchasePackage(...args),
restorePurchases: (...args: any[]) => mockRestorePurchases(...args),
};
export default Purchases;
export const LOG_LEVEL = { INFO: 'INFO' };
export const PRODUCT_CATEGORY = { SUBSCRIPTION: 'SUBSCRIPTION' };

View File

@@ -0,0 +1,4 @@
export const Platform = {
select: (opts: Record<string, any>) => opts.ios,
OS: 'ios',
};

View File

@@ -0,0 +1,382 @@
/**
* Tests for the useInAppPurchase logic functions.
*
* These verify:
* 1. Original behavior from the inlined documentation.ts code is preserved
* (SDK configuration, offerings loading, subscription status, purchasing)
* 2. Bug fixes over the old inline code:
* - Offerings are awaited before isReady is set (was fire-and-forget)
* - Retry logic handles TestFlight cold-start failures
* - getAvailablePackages returns [] instead of throwing on null offerings
* - Purchases.configure() is only called once
* - restorePurchases is included (App Store Guideline 3.1.1)
*/
import {
mockConfigure,
mockSetLogLevel,
mockGetOfferings,
mockPurchasePackage,
mockRestorePurchases,
} from './__mocks__/react-native-purchases';
import {
getRevenueCatAPIKey,
loadOfferings,
fetchSubscriptionStatus,
initiatePurchases,
getAvailablePackagesFromOfferings,
getSubscriptionsFromOfferings,
executePurchase,
executeRestore,
} from '../useInAppPurchase';
// --- Helpers ---
const makeOfferings = (hasCurrent = true) => ({
current: hasCurrent
? {
availablePackages: [
{
identifier: 'lifetime',
product: {
priceString: '$1.99',
productCategory: 'SUBSCRIPTION',
},
},
{
identifier: 'credits',
product: {
priceString: '$4.99',
productCategory: 'NON_SUBSCRIPTION',
},
},
],
}
: null,
});
function makeStoreCallbacks() {
return {
setOfferings: jest.fn(),
setIsSubscribed: jest.fn(),
setIsReady: jest.fn(),
isConfigured: { current: false },
};
}
// --- Setup ---
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
process.env.EXPO_PUBLIC_CREATE_ENV = 'PRODUCTION';
process.env.EXPO_PUBLIC_REVENUE_CAT_APP_STORE_API_KEY = 'pk_ios_test';
process.env.EXPO_PUBLIC_REVENUE_CAT_PLAY_STORE_API_KEY = 'pk_android_test';
process.env.EXPO_PUBLIC_REVENUE_CAT_TEST_STORE_API_KEY = 'pk_test_test';
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ hasAccess: false }),
});
});
afterEach(() => {
jest.useRealTimers();
});
// --- Tests ---
describe('getRevenueCatAPIKey', () => {
test('returns iOS key in production', () => {
expect(getRevenueCatAPIKey()).toBe('pk_ios_test');
});
test('returns test store key in DEVELOPMENT', () => {
process.env.EXPO_PUBLIC_CREATE_ENV = 'DEVELOPMENT';
expect(getRevenueCatAPIKey()).toBe('pk_test_test');
});
test('returns undefined when no keys are set', () => {
delete process.env.EXPO_PUBLIC_REVENUE_CAT_APP_STORE_API_KEY;
delete process.env.EXPO_PUBLIC_REVENUE_CAT_PLAY_STORE_API_KEY;
delete process.env.EXPO_PUBLIC_REVENUE_CAT_TEST_STORE_API_KEY;
expect(getRevenueCatAPIKey()).toBeUndefined();
});
});
describe('initiatePurchases', () => {
test('configures SDK with correct API key', async () => {
mockGetOfferings.mockResolvedValue(makeOfferings());
const cbs = makeStoreCallbacks();
await initiatePurchases(cbs);
expect(mockConfigure).toHaveBeenCalledWith({ apiKey: 'pk_ios_test' });
});
test('sets log level to INFO', async () => {
mockGetOfferings.mockResolvedValue(makeOfferings());
const cbs = makeStoreCallbacks();
await initiatePurchases(cbs);
expect(mockSetLogLevel).toHaveBeenCalledWith('INFO');
});
test('loads offerings and fetches subscription status in parallel', async () => {
mockGetOfferings.mockResolvedValue(makeOfferings());
const cbs = makeStoreCallbacks();
await initiatePurchases(cbs);
expect(cbs.setOfferings).toHaveBeenCalledWith(makeOfferings());
expect(global.fetch).toHaveBeenCalledWith(
'/api/revenue-cat/get-subscription-status',
{ method: 'POST' }
);
});
test('sets isReady true after completion', async () => {
mockGetOfferings.mockResolvedValue(makeOfferings());
const cbs = makeStoreCallbacks();
await initiatePurchases(cbs);
expect(cbs.setIsReady).toHaveBeenCalledWith(true);
});
test('does not configure when no API key is available', async () => {
delete process.env.EXPO_PUBLIC_REVENUE_CAT_APP_STORE_API_KEY;
delete process.env.EXPO_PUBLIC_REVENUE_CAT_PLAY_STORE_API_KEY;
delete process.env.EXPO_PUBLIC_REVENUE_CAT_TEST_STORE_API_KEY;
const cbs = makeStoreCallbacks();
await initiatePurchases(cbs);
expect(mockConfigure).not.toHaveBeenCalled();
expect(cbs.setIsReady).toHaveBeenCalledWith(true);
});
test('BUG FIX: isReady only set AFTER offerings have loaded (was fire-and-forget)', async () => {
let resolveOfferings!: Function;
mockGetOfferings.mockImplementation(
() => new Promise((resolve) => { resolveOfferings = () => resolve(makeOfferings()); })
);
const cbs = makeStoreCallbacks();
const promise = initiatePurchases(cbs);
// Before offerings resolve, setIsReady should NOT have been called
expect(cbs.setIsReady).not.toHaveBeenCalled();
resolveOfferings();
await promise;
// Now it should be called
expect(cbs.setIsReady).toHaveBeenCalledWith(true);
expect(cbs.setOfferings).toHaveBeenCalled();
});
test('BUG FIX: configure() only called once even if initiate() called multiple times', async () => {
mockGetOfferings.mockResolvedValue(makeOfferings());
const cbs = makeStoreCallbacks();
await initiatePurchases(cbs);
await initiatePurchases(cbs);
await initiatePurchases(cbs);
expect(mockConfigure).toHaveBeenCalledTimes(1);
});
});
describe('loadOfferings', () => {
test('stores offerings on success', async () => {
const offerings = makeOfferings();
mockGetOfferings.mockResolvedValue(offerings);
const setOfferings = jest.fn();
await loadOfferings(setOfferings);
expect(setOfferings).toHaveBeenCalledWith(offerings);
});
test('BUG FIX: retries up to 3 times on failure', async () => {
mockGetOfferings
.mockRejectedValueOnce(new Error('cold start'))
.mockRejectedValueOnce(new Error('still loading'))
.mockResolvedValueOnce(makeOfferings());
const setOfferings = jest.fn();
const promise = loadOfferings(setOfferings);
await jest.advanceTimersByTimeAsync(1500);
await jest.advanceTimersByTimeAsync(1500);
await promise;
expect(mockGetOfferings).toHaveBeenCalledTimes(3);
expect(setOfferings).toHaveBeenCalledWith(makeOfferings());
});
test('BUG FIX: retries when offerings load but current is null', async () => {
mockGetOfferings
.mockResolvedValueOnce(makeOfferings(false))
.mockResolvedValueOnce(makeOfferings(false))
.mockResolvedValueOnce(makeOfferings(true));
const setOfferings = jest.fn();
const promise = loadOfferings(setOfferings);
await jest.advanceTimersByTimeAsync(1500);
await jest.advanceTimersByTimeAsync(1500);
await promise;
expect(mockGetOfferings).toHaveBeenCalledTimes(3);
expect(setOfferings).toHaveBeenCalledWith(makeOfferings(true));
});
test('BUG FIX: does not call setOfferings when all retries fail', async () => {
mockGetOfferings.mockRejectedValue(new Error('permanent failure'));
const setOfferings = jest.fn();
const promise = loadOfferings(setOfferings);
await jest.advanceTimersByTimeAsync(3000);
await promise;
expect(mockGetOfferings).toHaveBeenCalledTimes(3);
expect(setOfferings).not.toHaveBeenCalled();
});
test('stops retrying early when current offering is found', async () => {
mockGetOfferings.mockResolvedValue(makeOfferings(true));
const setOfferings = jest.fn();
await loadOfferings(setOfferings);
expect(mockGetOfferings).toHaveBeenCalledTimes(1);
expect(setOfferings).toHaveBeenCalledTimes(1);
});
});
describe('fetchSubscriptionStatus', () => {
test('sets subscribed true when server returns hasAccess true', async () => {
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ hasAccess: true }),
});
const setIsSubscribed = jest.fn();
await fetchSubscriptionStatus(setIsSubscribed);
expect(setIsSubscribed).toHaveBeenCalledWith(true);
});
test('sets subscribed false when server returns hasAccess false', async () => {
const setIsSubscribed = jest.fn();
await fetchSubscriptionStatus(setIsSubscribed);
expect(setIsSubscribed).toHaveBeenCalledWith(false);
});
test('sets subscribed false on network error', async () => {
(global.fetch as jest.Mock).mockRejectedValue(new Error('network'));
const setIsSubscribed = jest.fn();
await fetchSubscriptionStatus(setIsSubscribed);
expect(setIsSubscribed).toHaveBeenCalledWith(false);
});
test('sets subscribed false on non-ok response', async () => {
(global.fetch as jest.Mock).mockResolvedValue({ ok: false });
const setIsSubscribed = jest.fn();
await fetchSubscriptionStatus(setIsSubscribed);
expect(setIsSubscribed).toHaveBeenCalledWith(false);
});
});
describe('getAvailablePackagesFromOfferings', () => {
test('returns packages from current offering', () => {
const offerings = makeOfferings();
const packages = getAvailablePackagesFromOfferings(offerings);
expect(packages).toHaveLength(2);
expect(packages[0].identifier).toBe('lifetime');
});
test('BUG FIX: returns [] when offerings is null (old code threw)', () => {
expect(() => getAvailablePackagesFromOfferings(null)).not.toThrow();
expect(getAvailablePackagesFromOfferings(null)).toEqual([]);
});
test('BUG FIX: returns [] when current is null (old code threw)', () => {
expect(() => getAvailablePackagesFromOfferings(makeOfferings(false))).not.toThrow();
expect(getAvailablePackagesFromOfferings(makeOfferings(false))).toEqual([]);
});
});
describe('getSubscriptionsFromOfferings', () => {
test('filters by SUBSCRIPTION category', () => {
const subs = getSubscriptionsFromOfferings(makeOfferings());
expect(subs).toHaveLength(1);
expect(subs[0].identifier).toBe('lifetime');
});
test('BUG FIX: returns [] when offerings is null (old code threw)', () => {
expect(() => getSubscriptionsFromOfferings(null)).not.toThrow();
expect(getSubscriptionsFromOfferings(null)).toEqual([]);
});
});
describe('executePurchase', () => {
test('calls SDK and returns success with customerInfo', async () => {
const customerInfo = { entitlements: { active: { pro: {} } } };
mockPurchasePackage.mockResolvedValue({ customerInfo });
const result = await executePurchase({
pkg: { identifier: 'test' },
setIsSubscribed: jest.fn(),
});
expect(mockPurchasePackage).toHaveBeenCalledWith({ identifier: 'test' });
expect(result.success).toBe(true);
expect(result.customerInfo).toBe(customerInfo);
});
test('returns cancelled when user cancels', async () => {
mockPurchasePackage.mockRejectedValue({ userCancelled: true });
const result = await executePurchase({
pkg: { identifier: 'test' },
setIsSubscribed: jest.fn(),
});
expect(result).toEqual({ success: false, cancelled: true });
});
test('returns failure on error', async () => {
mockPurchasePackage.mockRejectedValue(new Error('payment failed'));
const result = await executePurchase({
pkg: { identifier: 'test' },
setIsSubscribed: jest.fn(),
});
expect(result).toEqual({ success: false, cancelled: false });
});
test('refreshes subscription status after purchase', async () => {
mockPurchasePackage.mockResolvedValue({
customerInfo: { entitlements: { active: {} } },
});
const setIsSubscribed = jest.fn();
await executePurchase({ pkg: { identifier: 'test' }, setIsSubscribed });
expect(global.fetch).toHaveBeenCalledWith(
'/api/revenue-cat/get-subscription-status',
{ method: 'POST' }
);
});
});
describe('executeRestore', () => {
test('BUG FIX: restorePurchases works (App Store Guideline 3.1.1)', async () => {
const customerInfo = { entitlements: { active: { premium: {} } } };
mockRestorePurchases.mockResolvedValue(customerInfo);
const result = await executeRestore(jest.fn());
expect(mockRestorePurchases).toHaveBeenCalled();
expect(result.success).toBe(true);
expect(result.customerInfo).toBe(customerInfo);
});
test('returns success false when no active entitlements', async () => {
mockRestorePurchases.mockResolvedValue({ entitlements: { active: {} } });
const result = await executeRestore(jest.fn());
expect(result.success).toBe(false);
});
test('handles errors gracefully', async () => {
mockRestorePurchases.mockRejectedValue(new Error('network error'));
const result = await executeRestore(jest.fn());
expect(result).toEqual({ success: false });
});
test('refreshes subscription status after restore', async () => {
mockRestorePurchases.mockResolvedValue({
entitlements: { active: { pro: {} } },
});
await executeRestore(jest.fn());
expect(global.fetch).toHaveBeenCalledWith(
'/api/revenue-cat/get-subscription-status',
{ method: 'POST' }
);
});
});

View File

@@ -0,0 +1,2 @@
export { useInAppPurchase } from './useInAppPurchase';
export { useInAppPurchaseStore } from './store';

View File

@@ -0,0 +1,19 @@
import { create } from 'zustand';
interface InAppPurchaseState {
isReady: boolean;
offerings: any | null;
isSubscribed: boolean;
setIsSubscribed: (isSubscribed: boolean) => void;
setOfferings: (offerings: any | null) => void;
setIsReady: (isReady: boolean) => void;
}
export const useInAppPurchaseStore = create<InAppPurchaseState>((set) => ({
isReady: false,
offerings: null,
isSubscribed: false,
setIsSubscribed: (isSubscribed) => set({ isSubscribed }),
setOfferings: (offerings) => set({ offerings }),
setIsReady: (isReady) => set({ isReady }),
}));

View File

@@ -0,0 +1,211 @@
import Purchases, { LOG_LEVEL, PRODUCT_CATEGORY } from 'react-native-purchases';
import { Platform } from 'react-native';
import { useCallback, useRef, useState } from 'react';
import { useInAppPurchaseStore } from './store';
export const RETRY_ATTEMPTS = 3;
export const RETRY_DELAY_MS = 1500;
export const getRevenueCatAPIKey = (): string | undefined => {
if (process.env.EXPO_PUBLIC_CREATE_ENV === 'DEVELOPMENT') {
return process.env.EXPO_PUBLIC_REVENUE_CAT_TEST_STORE_API_KEY;
}
return Platform.select({
ios: process.env.EXPO_PUBLIC_REVENUE_CAT_APP_STORE_API_KEY,
android: process.env.EXPO_PUBLIC_REVENUE_CAT_PLAY_STORE_API_KEY,
web: process.env.EXPO_PUBLIC_REVENUE_CAT_TEST_STORE_API_KEY,
});
};
export async function loadOfferings(setOfferings: (o: any) => void) {
for (let attempt = 0; attempt < RETRY_ATTEMPTS; attempt++) {
try {
const result = await Purchases.getOfferings();
if (result?.current) {
setOfferings(result);
return;
}
console.warn(
`RevenueCat offerings loaded but no current offering (attempt ${attempt + 1}/${RETRY_ATTEMPTS})`
);
} catch (error) {
console.warn(
`Failed to load offerings (attempt ${attempt + 1}/${RETRY_ATTEMPTS}):`,
error
);
}
if (attempt < RETRY_ATTEMPTS - 1) {
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
}
}
}
export async function fetchSubscriptionStatus(
setIsSubscribed: (v: boolean) => void
) {
try {
const response = await fetch('/api/revenue-cat/get-subscription-status', {
method: 'POST',
});
if (!response.ok) {
throw new Error('Failed to check subscription status');
}
const data = await response.json();
setIsSubscribed(data.hasAccess);
} catch (error) {
console.error('Error fetching subscription status:', error);
setIsSubscribed(false);
}
}
export async function initiatePurchases({
isConfigured,
setIsReady,
setOfferings,
setIsSubscribed,
}: {
isConfigured: { current: boolean };
setIsReady: (v: boolean) => void;
setOfferings: (o: any) => void;
setIsSubscribed: (v: boolean) => void;
}) {
if (isConfigured.current) return;
try {
void Purchases.setLogLevel(LOG_LEVEL.INFO);
const apiKey = getRevenueCatAPIKey();
if (apiKey) {
Purchases.configure({ apiKey });
isConfigured.current = true;
await Promise.allSettled([
loadOfferings(setOfferings),
fetchSubscriptionStatus(setIsSubscribed),
]);
} else {
console.warn('No RevenueCat API key found for platform:', Platform.OS);
}
} catch (error) {
console.warn('Failed to initialize RevenueCat:', error);
} finally {
setIsReady(true);
}
}
export function getAvailablePackagesFromOfferings(offerings: any) {
const offering = offerings?.current;
if (!offering) {
return [];
}
return offering.availablePackages;
}
export function getSubscriptionsFromOfferings(offerings: any) {
return getAvailablePackagesFromOfferings(offerings).filter(
(pkg: any) =>
pkg.product.productCategory === PRODUCT_CATEGORY.SUBSCRIPTION
);
}
export async function executePurchase({
pkg,
setIsSubscribed,
}: {
pkg: any;
setIsSubscribed: (v: boolean) => void;
}) {
try {
const { customerInfo } = await Purchases.purchasePackage(pkg);
await fetchSubscriptionStatus(setIsSubscribed);
return { success: true, customerInfo };
} catch (error: any) {
if (error.userCancelled) {
return { success: false, cancelled: true };
}
console.error('Failed to purchase:', error);
return { success: false, cancelled: false };
}
}
export async function executeRestore(
setIsSubscribed: (v: boolean) => void
) {
try {
const customerInfo = await Purchases.restorePurchases();
await fetchSubscriptionStatus(setIsSubscribed);
return {
success: Object.keys(customerInfo.entitlements.active).length > 0,
customerInfo,
};
} catch (error) {
console.error('Failed to restore purchases:', error);
return { success: false };
}
}
export function useInAppPurchase() {
const {
isReady,
offerings,
setOfferings,
setIsSubscribed,
isSubscribed,
setIsReady,
} = useInAppPurchaseStore();
const [isPurchasing, setIsPurchasing] = useState(false);
const isConfigured = useRef(false);
const initiate = useCallback(
() =>
initiatePurchases({
isConfigured,
setIsReady,
setOfferings,
setIsSubscribed,
}),
[setIsReady, setOfferings, setIsSubscribed]
);
const getAvailablePackages = useCallback(
() => getAvailablePackagesFromOfferings(offerings),
[offerings]
);
const getAvailableSubscriptions = useCallback(
() => getSubscriptionsFromOfferings(offerings),
[offerings]
);
const purchasePackage = useCallback(
async ({ pkg }: { pkg: any }) => {
setIsPurchasing(true);
try {
return await executePurchase({ pkg, setIsSubscribed });
} finally {
setIsPurchasing(false);
}
},
[setIsPurchasing, setIsSubscribed]
);
const restorePurchases = useCallback(async () => {
setIsPurchasing(true);
try {
return await executeRestore(setIsSubscribed);
} finally {
setIsPurchasing(false);
}
}, [setIsPurchasing, setIsSubscribed]);
return {
isReady,
offerings,
isSubscribed,
isPurchasing,
initiate,
getAvailablePackages,
getAvailableSubscriptions,
purchasePackage,
restorePurchases,
};
}
export default useInAppPurchase;

View File

@@ -0,0 +1,34 @@
import React from 'react';
interface UseHandleStreamResponseProps {
onChunk: (content: string) => void;
onFinish: (content: string) => void;
}
export function useHandleStreamResponse({ onChunk, onFinish }: UseHandleStreamResponseProps) {
const handleStreamResponse = React.useCallback(
async (response: Response) => {
if (response.body) {
const reader = response.body.getReader();
if (reader) {
const decoder = new TextDecoder();
let content = '';
while (true) {
const { done, value } = await reader.read();
if (done) {
onFinish(content);
break;
}
const chunk = decoder.decode(value, { stream: true });
content += chunk;
onChunk(content);
}
}
}
},
[onChunk, onFinish]
);
return handleStreamResponse;
}
export default useHandleStreamResponse;

View File

@@ -0,0 +1,35 @@
import { useFocusEffect } from '@react-navigation/native';
import { useNavigation } from 'expo-router';
import { BackHandler } from 'react-native';
export const usePreventBack = (): void => {
const navigation = useNavigation();
useFocusEffect(() => {
navigation.setOptions({
headerLeft: () => null,
gestureEnabled: false,
});
navigation.getParent()?.setOptions({ gestureEnabled: false });
// Android back button handler
const hardwareBackPressHandler = BackHandler.addEventListener(
'hardwareBackPress',
() => {
// Prevent default behavior of leaving the screen
return true;
}
);
return () => {
navigation.getParent()?.setOptions({ gestureEnabled: true });
navigation.setOptions({
gestureEnabled: true,
});
hardwareBackPressHandler.remove();
};
});
};
export default usePreventBack;

View File

@@ -0,0 +1,149 @@
import type { ReactNativeAsset } from '@uploadcare/upload-client';
import * as SecureStore from 'expo-secure-store';
import * as React from 'react';
interface UploadInputReactNative {
reactNativeAsset: ReactNativeAsset & { file?: File };
}
interface UploadInputUrl {
url: string;
}
interface UploadInputBase64 {
base64: string;
}
interface UploadInputBuffer {
buffer: Buffer;
}
type UploadInput = UploadInputReactNative | UploadInputUrl | UploadInputBase64 | UploadInputBuffer;
interface UploadResult {
url?: string;
mimeType?: string | null;
error?: string;
}
interface UploadHookResult {
loading: boolean;
}
// Both paths upload via the proxy's /_create/api/upload/ (respects S3 flag).
// Web: globalThis.fetch with full proxy URL + no custom headers (avoids CORS
// preflight — the proxy adds project-group-id from the hostname server-side).
// Native: FileSystem.uploadAsync to same URL with manual auth headers.
function useUpload(): [(input: UploadInput) => Promise<UploadResult>, UploadHookResult] {
const [loading, setLoading] = React.useState(false);
const upload = React.useCallback(async (input: UploadInput): Promise<UploadResult> => {
try {
setLoading(true);
let response: Response | undefined;
if ('reactNativeAsset' in input && input.reactNativeAsset) {
const asset = input.reactNativeAsset;
if (asset.file) {
const proxyBaseUrl = process.env.EXPO_PUBLIC_PROXY_BASE_URL;
const formData = new FormData();
formData.append('file', asset.file);
response = await globalThis.fetch(`${proxyBaseUrl}/_create/api/upload/`, {
method: 'POST',
body: formData,
});
} else {
const FileSystem = require('expo-file-system/legacy');
const proxyBaseUrl = process.env.EXPO_PUBLIC_PROXY_BASE_URL;
const projectGroupId = process.env.EXPO_PUBLIC_PROJECT_GROUP_ID;
const host = process.env.EXPO_PUBLIC_HOST;
const headers: Record<string, string> = {
'x-createxyz-project-group-id': projectGroupId || '',
'host': host || '',
'x-forwarded-host': host || '',
'x-createxyz-host': host || '',
};
try {
const authStr = await SecureStore.getItemAsync(`${projectGroupId}-jwt`);
if (authStr) {
const auth = JSON.parse(authStr);
if (auth?.jwt) headers['authorization'] = `Bearer ${auth.jwt}`;
}
} catch {}
const uploadResult = await FileSystem.uploadAsync(
`${proxyBaseUrl}/_create/api/upload/`,
asset.uri,
{
uploadType: FileSystem.FileSystemUploadType.MULTIPART,
fieldName: 'file',
headers,
},
);
if (uploadResult.status < 200 || uploadResult.status >= 300) {
throw new Error(`Upload failed (${uploadResult.status}): ${uploadResult.body}`);
}
let data: { url?: string; mimeType?: string | null };
try {
data = JSON.parse(uploadResult.body);
} catch {
throw new Error('Upload failed: invalid response from upload service');
}
return { url: data.url, mimeType: data.mimeType || null };
}
} else if ('url' in input) {
response = await fetch('/_create/api/upload/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ url: input.url }),
});
} else if ('base64' in input) {
response = await fetch('/_create/api/upload/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ base64: input.base64 }),
});
} else if ('buffer' in input) {
response = await fetch('/_create/api/upload/', {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream',
},
body: input.buffer as unknown as BodyInit,
});
}
if (!response || !response.ok) {
if (response?.status === 413) {
throw new Error('Upload failed: File too large.');
}
const body = await response?.text().catch(() => '');
throw new Error(`Upload failed (${response?.status ?? 'no response'}): ${body}`);
}
const data = await response.json();
return { url: data.url, mimeType: data.mimeType || null };
} catch (uploadError) {
if (uploadError instanceof Error) {
return { error: uploadError.message };
}
if (typeof uploadError === 'string') {
return { error: uploadError };
}
return { error: 'Upload failed' };
} finally {
setLoading(false);
}
}, []);
return [upload, { loading }];
}
export { useUpload };
export default useUpload;