Compare commits

...

2 Commits

Author SHA1 Message Date
dios.one 53a75adfdd fixing login sosmed dan ganti logo/icon app 2026-05-11 11:56:57 +07:00
dios.one 87b65d3822 fix login google & fb, fix icon 2026-05-07 16:07:01 +07:00
21 changed files with 1248 additions and 3033 deletions
+1 -9
View File
@@ -1,22 +1,15 @@
import 'react-native-gesture-handler';
import React, { useEffect } from 'react';
import React from 'react';
import { LogBox } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import ErrorBoundary from './src/components/ErrorBoundary';
import NovaAlertProvider from './src/components/NovaAlert';
import AppNavigator from './src/navigation/AppNavigator';
import { initCrashlytics } from './src/services/crashlytics';
LogBox.ignoreLogs(['TypeError: Network request failed']);
export default function App() {
useEffect(() => {
initCrashlytics();
}, []);
return (
<ErrorBoundary>
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<NovaAlertProvider>
@@ -24,6 +17,5 @@ export default function App() {
</NovaAlertProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
</ErrorBoundary>
);
}
+14 -7
View File
@@ -14,16 +14,14 @@
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.nova40.app",
"googleServicesFile": "./GoogleService-Info.plist"
"bundleIdentifier": "com.heyaciell.nova40"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#0A0E1A"
},
"package": "com.nova40.app",
"googleServicesFile": "./google-services.json",
"package": "com.heyaciell.nova40",
"permissions": [
"WRITE_EXTERNAL_STORAGE",
"READ_EXTERNAL_STORAGE"
@@ -33,9 +31,18 @@
"favicon": "./assets/favicon.png"
},
"plugins": [
"@react-native-firebase/app",
"@react-native-firebase/crashlytics",
"expo-web-browser"
"expo-web-browser",
"@react-native-google-signin/google-signin",
[
"react-native-fbsdk-next",
{
"appID": "1003904135650335",
"clientToken": "7d79de6ad89e838d6f452feabdbaeb7f",
"displayName": "Nova40",
"scheme": "fb1003904135650335"
}
],
"expo-sharing"
],
"extra": {
"eas": {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 582 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 582 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 582 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 731 KiB

After

Width:  |  Height:  |  Size: 582 KiB

+791 -2535
View File
File diff suppressed because it is too large Load Diff
+21 -23
View File
@@ -4,46 +4,44 @@
"main": "index.js",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web"
},
"dependencies": {
"@expo/config-plugins": "^55.0.8",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-firebase/analytics": "^24.0.0",
"@react-native-firebase/app": "^24.0.0",
"@react-native-firebase/crashlytics": "^24.0.0",
"@react-native-google-signin/google-signin": "^16.1.2",
"@react-navigation/bottom-tabs": "^7.15.9",
"@react-navigation/native": "^7.2.2",
"@react-navigation/native-stack": "^7.14.11",
"@supabase/ssr": "^0.10.2",
"@supabase/supabase-js": "^2.103.0",
"babel-preset-expo": "~54.0.10",
"expo": "~54.0.0",
"expo-auth-session": "~7.0.11",
"expo-crypto": "~15.0.9",
"expo-dev-client": "~6.0.21",
"expo-image-picker": "~17.0.11",
"expo-linear-gradient": "~15.0.8",
"expo-media-library": "~18.2.1",
"expo-print": "~15.0.8",
"expo-sharing": "~14.0.8",
"expo-status-bar": "~3.0.9",
"expo-web-browser": "~15.0.11",
"react": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1",
"babel-preset-expo": "~55.0.8",
"expo": "^55.0.23",
"expo-auth-session": "~55.0.15",
"expo-crypto": "~55.0.14",
"expo-dev-client": "~55.0.32",
"expo-image-picker": "~55.0.20",
"expo-linear-gradient": "~55.0.13",
"expo-media-library": "~55.0.16",
"expo-print": "~55.0.14",
"expo-sharing": "~55.0.18",
"expo-status-bar": "~55.0.6",
"expo-web-browser": "~55.0.15",
"react": "19.2.0",
"react-native": "0.83.6",
"react-native-fbsdk-next": "^13.4.3",
"react-native-gesture-handler": "~2.30.0",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-screens": "~4.23.0",
"react-native-url-polyfill": "^3.0.0",
"react-native-view-shot": "4.0.3",
"zustand": "^5.0.12"
},
"private": true,
"devDependencies": {
"@types/react": "~19.1.10",
"@types/react": "~19.2.10",
"typescript": "~5.9.2"
}
}
+2 -2
View File
@@ -64,8 +64,8 @@ export default function PlanetCore({ size = 90, glowIntensity = 0.5 }) {
{/* Planet image */}
<Image
source={require('../../assets/icon.png')}
style={{ width: size, height: size }}
resizeMode="contain"
style={{ width: size, height: size * 0.88 }}
resizeMode="cover"
/>
</View>
);
+3
View File
@@ -2,3 +2,6 @@
export const BACKEND_URL = 'https://acil.imola.ai';
export const AI_SERVICE_URL = 'http://182.23.12.142:3106';
export const AI_MODEL = 'claude-haiku-4-5-20251001';
// Google Sign-In
export const GOOGLE_WEB_CLIENT_ID = '112566583381-29vsg1bg7ifnq5csg5o8fisdgtllb2sn.apps.googleusercontent.com';
+73 -28
View File
@@ -12,8 +12,7 @@ import useAuthStore from '../store/useAuthStore';
import { colors, fonts, spacing, borderRadius } from '../utils/theme';
const REMEMBER_KEY = 'remember_email';
const DEMO_EMAIL = 'demo@nova40.app';
const DEMO_PASSWORD = '123456';
const LAST_OAUTH_KEY = 'nova40_last_oauth';
export default function LoginScreen({ navigation }) {
const [email, setEmail] = useState('');
@@ -21,31 +20,31 @@ export default function LoginScreen({ navigation }) {
const [showPassword, setShowPassword] = useState(false);
const [rememberMe, setRememberMe] = useState(false);
const [loading, setLoading] = useState(false);
const [demoLoading, setDemoLoading] = useState(false);
const [googleLoading, setGoogleLoading] = useState(false);
const [facebookLoading, setFacebookLoading] = useState(false);
const [lastOAuth, setLastOAuth] = useState(null); // { provider, email, name }
const login = useAuthStore((s) => s.login);
const loginAsDemo = useAuthStore((s) => s.loginAsDemo);
const loginWithGoogle = useAuthStore((s) => s.loginWithGoogle);
const loginWithFacebook = useAuthStore((s) => s.loginWithFacebook);
// Load remembered email on mount
// Load remembered email + last OAuth on mount
useEffect(() => {
const load = async () => {
try {
// Check last OAuth login
const oauthRaw = await AsyncStorage.getItem(LAST_OAUTH_KEY);
if (oauthRaw) {
setLastOAuth(JSON.parse(oauthRaw));
}
// Check remembered email
const saved = await AsyncStorage.getItem(REMEMBER_KEY);
if (saved) {
setEmail(saved);
setRememberMe(true);
} else {
setEmail(DEMO_EMAIL);
setPassword(DEMO_PASSWORD);
}
} catch (_) {
setEmail(DEMO_EMAIL);
setPassword(DEMO_PASSWORD);
}
} catch (_) {}
};
load();
}, []);
@@ -82,22 +81,17 @@ export default function LoginScreen({ navigation }) {
}
};
const handleDemoLogin = async () => {
setDemoLoading(true);
try {
await loginAsDemo();
goToApp();
} catch (error) {
showAlert("Demo didn't work", error.message);
} finally {
setDemoLoading(false);
}
};
const handleGoogleLogin = async () => {
setGoogleLoading(true);
try {
await loginWithGoogle();
const result = await loginWithGoogle();
const userEmail = result?.user?.email || result?.session?.user?.email;
const userName = result?.user?.name || userEmail?.split('@')[0];
if (userEmail) {
await AsyncStorage.setItem(LAST_OAUTH_KEY, JSON.stringify({
provider: 'google', email: userEmail, name: userName,
}));
}
goToApp();
} catch (error) {
if (!error.message?.includes('cancel')) {
@@ -111,7 +105,14 @@ export default function LoginScreen({ navigation }) {
const handleFacebookLogin = async () => {
setFacebookLoading(true);
try {
await loginWithFacebook();
const result = await loginWithFacebook();
const userEmail = result?.user?.email || result?.session?.user?.email;
const userName = result?.user?.name || userEmail?.split('@')[0];
if (userEmail) {
await AsyncStorage.setItem(LAST_OAUTH_KEY, JSON.stringify({
provider: 'facebook', email: userEmail, name: userName,
}));
}
goToApp();
} catch (error) {
if (!error.message?.includes('cancel')) {
@@ -122,7 +123,7 @@ export default function LoginScreen({ navigation }) {
}
};
const anyLoading = loading || demoLoading || googleLoading || facebookLoading;
const anyLoading = loading || googleLoading || facebookLoading;
return (
<ScreenWrapper>
@@ -182,7 +183,6 @@ export default function LoginScreen({ navigation }) {
style={styles.loginBtn}
/>
{/* Divider */}
<View style={styles.divider}>
<View style={styles.dividerLine} />
<Text style={styles.dividerText}>or</Text>
@@ -351,6 +351,51 @@ const styles = StyleSheet.create({
},
// App info
// Last OAuth card
lastOAuthCard: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: colors.surface,
borderRadius: borderRadius.md,
padding: spacing.md,
borderWidth: 1,
borderColor: colors.primary,
marginBottom: spacing.xl,
},
lastOAuthLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
lastOAuthIcon: {
fontSize: 18,
fontWeight: fonts.weights.bold,
color: '#4285F4',
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: 'rgba(66, 133, 244, 0.1)',
textAlign: 'center',
lineHeight: 36,
marginRight: spacing.md,
},
lastOAuthName: {
color: colors.text,
fontSize: fonts.sizes.md,
fontWeight: fonts.weights.semibold,
},
lastOAuthEmail: {
color: colors.textMuted,
fontSize: fonts.sizes.xs,
marginTop: 2,
},
lastOAuthAction: {
color: colors.primary,
fontSize: fonts.sizes.sm,
fontWeight: fonts.weights.semibold,
},
appInfo: {
alignItems: 'center',
paddingTop: spacing.xl,
+2 -2
View File
@@ -107,8 +107,8 @@ function PageContent({ item, isActive }) {
)}
<Image
source={require('../../assets/icon.png')}
style={{ width: item.iconSize, height: item.iconSize }}
resizeMode="contain"
style={{ width: item.iconSize, height: item.iconSize * 0.88 }}
resizeMode="cover"
/>
</Animated.View>
+2 -2
View File
@@ -96,7 +96,7 @@ export default function SplashScreen({ navigation }) {
<Image
source={require('../../assets/icon.png')}
style={styles.logo}
resizeMode="contain"
resizeMode="cover"
/>
</Animated.View>
@@ -119,7 +119,7 @@ const styles = StyleSheet.create({
width: 200, height: 200, borderRadius: 100,
backgroundColor: colors.planetGlow, opacity: 0.4, position: 'absolute',
},
logo: { width: 140, height: 140 },
logo: { width: 160, height: 140 },
title: {
color: colors.text, fontSize: fonts.sizes.hero,
fontWeight: fonts.weights.bold, letterSpacing: 8, marginBottom: 16,
+33 -14
View File
@@ -1,5 +1,5 @@
import safeParseAI from '../utils/safeParseAI';
import { getSuggestionsForIdentity } from '../utils/helpers';
import { getSuggestionsForIdentity, isIndonesian, setStoryLanguage } from '../utils/helpers';
import { AI_SERVICE_URL, AI_MODEL } from '../config/keys';
function buildPrompt(story) {
@@ -108,7 +108,7 @@ async function callAI(prompt) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: AI_MODEL,
max_tokens: 2048,
max_tokens: 4096,
messages: [{ role: 'user', content: prompt }],
}),
});
@@ -128,6 +128,9 @@ async function callAI(prompt) {
* Falls back to local generation if AI fails.
*/
export async function generateFromStory(story) {
// Set language detection from the full story BEFORE anything else
setStoryLanguage(story);
try {
const rawText = await callAI(buildPrompt(story));
const parsed = safeParseAI(rawText);
@@ -186,30 +189,46 @@ function generateFallback(story) {
}
}
// Generate varied habits from the story text and convert to rich objects
let habitTitles = getSuggestionsForIdentity(words);
if (habitTitles.length < 8) {
const titleHabits = getSuggestionsForIdentity(title);
titleHabits.forEach((h) => { if (!habitTitles.includes(h)) habitTitles.push(h); });
// Generate bilingual habits — pass story as both search text AND language text
const { generateHabitsFromIdentity } = require('../utils/helpers');
let allHabits = generateHabitsFromIdentity(story, story);
// If not enough from story keywords, also try title keywords
if (allHabits.length < 20) {
const titleHabits = generateHabitsFromIdentity(title, story);
titleHabits.forEach((h) => {
if (!allHabits.some((a) => a.title === h.title)) allHabits.push(h);
});
}
// Ensure at least 20
allHabits = allHabits.slice(0, 20);
const useId = isIndonesian(story);
const times = ['06:00', '06:30', '07:00', '07:30', '08:00', '12:00', '17:00', '18:00', '19:00', '20:00', '21:00', '21:30'];
const durations = [5, 10, 15, 20, 30, 10, 15, 20, 5, 10];
const toRichHabit = (t, i) => ({
title: t,
const toRichHabit = (h, i) => ({
title: h.title || h,
best_time: times[i % times.length],
duration: durations[i % durations.length],
frequency: i < 5 ? 'Every day' : ['Every day', '3x per week', 'Every morning', 'Every evening', 'Weekdays only'][i % 5],
frequency: i < 5
? (useId ? 'Setiap hari' : 'Every day')
: [useId ? 'Setiap hari' : 'Every day', '3x per week', useId ? 'Setiap pagi' : 'Every morning', useId ? 'Setiap malam' : 'Every evening', useId ? 'Hari kerja' : 'Weekdays only'][i % 5],
category: ['Discipline', 'Body', 'Mind', 'Emotion', 'Health', 'Skill', 'Social', 'Creative'][i % 8],
difficulty: i < 3 ? 'Easy' : i < 7 ? 'Medium' : 'Hard',
why: '',
why: h.description || '',
});
const summary = useId
? `Berdasarkan ceritamu, perjalanan ini tentang menjadi ${title.toLowerCase()}. Setiap hari adalah langkah lebih dekat menuju dirimu yang baru.`
: `Based on your story, this journey is about becoming ${title.toLowerCase()}. Every day is a step closer to the person you described.`;
return {
identity_title: title,
identity_summary: `Based on your story, this journey is about becoming ${title.toLowerCase()}. Every day is a step closer to the person you described.`,
priority_habits: habitTitles.slice(0, 5).map(toRichHabit),
suggested_habits: habitTitles.slice(5, 20).map((t, i) => toRichHabit(t, i + 5)),
identity_summary: summary,
priority_habits: allHabits.slice(0, 5).map((h, i) => toRichHabit(h, i)),
suggested_habits: allHabits.slice(5, 20).map((h, i) => toRichHabit(h, i + 5)),
source: 'fallback',
};
}
+11 -94
View File
@@ -1,94 +1,11 @@
let analytics = null;
try {
analytics = require('@react-native-firebase/analytics').default;
analytics();
} catch (_) {
analytics = null;
}
const isAvailable = !!analytics;
/**
* Log a screen view.
*/
export function logScreenView(screenName) {
if (!isAvailable) return;
try {
analytics().logScreenView({ screen_name: screenName, screen_class: screenName });
} catch (_) {}
}
/**
* Log a custom event.
*/
export function logEvent(name, params = {}) {
if (!isAvailable) return;
try {
analytics().logEvent(name, params);
} catch (_) {}
}
/**
* Set user ID for analytics.
*/
export function setAnalyticsUser(userId) {
if (!isAvailable) return;
try {
analytics().setUserId(userId);
} catch (_) {}
}
/**
* Set user property.
*/
export function setUserProperty(name, value) {
if (!isAvailable) return;
try {
analytics().setUserProperty(name, value);
} catch (_) {}
}
// ============================
// Pre-defined event helpers
// ============================
export function trackSignUp(method) {
logEvent('sign_up', { method });
}
export function trackLogin(method) {
logEvent('login', { method });
}
export function trackIdentityCreated(title, source) {
logEvent('identity_created', { title, source });
}
export function trackHabitCompleted(habitTitle) {
logEvent('habit_completed', { habit: habitTitle });
}
export function trackDayCompleted(dayNumber, identityCheck, mood) {
logEvent('day_completed', { day: dayNumber, identity_check: identityCheck, mood });
}
export function trackGamePlayed(gameType, score) {
logEvent('game_played', { game_type: gameType, score });
}
export function trackJournalSaved(dayNumber) {
logEvent('journal_saved', { day: dayNumber });
}
export function trackJourneyCompleted(totalScore, daysLogged) {
logEvent('journey_completed', { total_score: totalScore, days_logged: daysLogged });
}
export function trackShare(contentType) {
logEvent('share', { content_type: contentType });
}
export function trackJourneyReset(reason) {
logEvent('journey_reset', { reason });
}
// Analytics stub — Firebase removed, all functions are no-ops
export function setAnalyticsUser() {}
export function trackSignUp() {}
export function trackLogin() {}
export function trackIdentityCreated() {}
export function trackDayCompleted() {}
export function trackJournalSaved() {}
export function trackGamePlayed() {}
export function trackJourneyCompleted() {}
export function trackShare() {}
export function logScreenView() {}
+55 -81
View File
@@ -1,10 +1,6 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as AuthSession from 'expo-auth-session';
import * as WebBrowser from 'expo-web-browser';
import { supabase } from './supabase';
WebBrowser.maybeCompleteAuthSession();
// ============================================
// OFFLINE-FIRST AUTH (dummy mode)
// Uses AsyncStorage when Supabase is unreachable.
@@ -207,110 +203,88 @@ export async function signInAsDemo() {
});
}
// ============================================
// Google OAuth
// To setup:
// 1. Go to https://console.cloud.google.com/apis/credentials
// 2. Create OAuth 2.0 Client ID (Web application)
// 3. Add redirect URI: https://auth.expo.io/@heyaciell/Nova40
// 4. Paste Client ID below
// ============================================
const GOOGLE_CLIENT_ID = '112566583381-29vsg1bg7ifnq5csg5o8fisdgtllb2sn.apps.googleusercontent.com';
export async function signInWithGoogle() {
try {
const redirectUri = AuthSession.makeRedirectUri({ scheme: 'nova40' });
const discovery = {
authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
tokenEndpoint: 'https://oauth2.googleapis.com/token',
};
const request = new AuthSession.AuthRequest({
clientId: GOOGLE_CLIENT_ID,
scopes: ['openid', 'profile', 'email'],
redirectUri,
responseType: AuthSession.ResponseType.Token,
});
const result = await request.promptAsync(discovery);
if (result.type === 'success' && result.authentication?.accessToken) {
// Fetch user info from Google
const userInfo = await fetch('https://www.googleapis.com/userinfo/v2/me', {
headers: { Authorization: `Bearer ${result.authentication.accessToken}` },
}).then((r) => r.json());
// Create offline session with Google user data
const email = userInfo.email || 'google-user@nova40.app';
// Shared helper for OAuth login (Google/Facebook)
async function createOAuthSession(email, provider, name, picture) {
const user = makeUser(email);
// Store user if not exists
const users = await getStoredUsers();
const key = email.toLowerCase();
if (!users[key]) {
users[key] = { id: user.id, password: '__google_oauth__', provider: 'google', name: userInfo.name, picture: userInfo.picture };
users[key] = { id: user.id, password: `__${provider}_oauth__`, provider, name, picture };
await saveUsers(users);
} else {
user.id = users[key].id;
}
const session = makeSession(user);
await saveSession(session);
return { session, user };
}
}
if (result.type === 'cancel') throw new Error('Google sign-in was cancelled.');
throw new Error('Google sign-in failed.');
// ============================================
// Native Google Sign-In (requires development build)
// ============================================
import { GoogleSignin } from '@react-native-google-signin/google-signin';
const GOOGLE_WEB_CLIENT_ID = '112566583381-29vsg1bg7ifnq5csg5o8fisdgtllb2sn.apps.googleusercontent.com';
GoogleSignin.configure({
webClientId: GOOGLE_WEB_CLIENT_ID,
offlineAccess: true,
});
export async function signInWithGoogle() {
try {
await GoogleSignin.hasPlayServices();
const response = await GoogleSignin.signIn();
const user = response?.data?.user || response?.user;
if (!user?.email) throw new Error('No email returned from Google.');
return await createOAuthSession(
user.email,
'google',
user.name || user.givenName,
user.photo
);
} catch (e) {
if (e.message?.includes('cancel')) throw e;
console.warn('Google sign-in error:', e.code, e.message);
if (e.code === 'SIGN_IN_CANCELLED' || e.message?.includes('cancel')) {
throw new Error('Google sign-in was cancelled.');
}
throw new Error(e.message || 'Google sign-in failed. Please try again.');
}
}
// ============================================
// Facebook OAuth
// To setup:
// 1. Go to https://developers.facebook.com
// 2. Create app → Facebook Login
// 3. Add redirect URI: https://auth.expo.io/@heyaciell/Nova40
// 4. Paste App ID below
// Native Facebook Login (requires development build)
// ============================================
const FACEBOOK_APP_ID = '1696508695097482';
import { LoginManager, AccessToken, Profile } from 'react-native-fbsdk-next';
export async function signInWithFacebook() {
try {
const redirectUri = AuthSession.makeRedirectUri({ scheme: 'nova40' });
// Reset previous login
LoginManager.logOut();
const result = await AuthSession.startAsync({
authUrl: `https://www.facebook.com/v18.0/dialog/oauth?client_id=${FACEBOOK_APP_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=token&scope=email,public_profile`,
});
const result = await LoginManager.logInWithPermissions(['public_profile', 'email']);
if (result.type === 'success' && result.params?.access_token) {
// Fetch user info from Facebook
const userInfo = await fetch(`https://graph.facebook.com/me?fields=id,name,email,picture&access_token=${result.params.access_token}`)
.then((r) => r.json());
if (result.isCancelled) throw new Error('Facebook sign-in was cancelled.');
const tokenData = await AccessToken.getCurrentAccessToken();
if (!tokenData?.accessToken) throw new Error('No access token from Facebook.');
// Get user info
const userInfo = await fetch(
`https://graph.facebook.com/me?fields=id,name,email,picture.type(large)&access_token=${tokenData.accessToken}`
).then((r) => r.json());
const email = userInfo.email || `fb-${userInfo.id}@nova40.app`;
const user = makeUser(email);
const users = await getStoredUsers();
const key = email.toLowerCase();
if (!users[key]) {
users[key] = { id: user.id, password: '__facebook_oauth__', provider: 'facebook', name: userInfo.name, picture: userInfo.picture?.data?.url };
await saveUsers(users);
} else {
user.id = users[key].id;
}
const session = makeSession(user);
await saveSession(session);
return { session, user };
}
if (result.type === 'cancel') throw new Error('Facebook sign-in was cancelled.');
throw new Error('Facebook sign-in failed.');
return await createOAuthSession(
email,
'facebook',
userInfo.name,
userInfo.picture?.data?.url
);
} catch (e) {
console.warn('Facebook sign-in error:', e.message);
if (e.message?.includes('cancel')) throw e;
throw new Error(e.message || 'Facebook sign-in failed. Please try again.');
}
+6 -61
View File
@@ -1,61 +1,6 @@
let crashlytics = null;
try {
crashlytics = require('@react-native-firebase/crashlytics').default;
// Test if native module is available
crashlytics();
} catch (_) {
crashlytics = null;
}
const isAvailable = !!crashlytics;
export function initCrashlytics() {
if (!isAvailable) {
console.log('Crashlytics: native module not available (Expo Go). Skipping.');
return;
}
try {
crashlytics().setCrashlyticsCollectionEnabled(true);
const originalHandler = ErrorUtils.getGlobalHandler();
ErrorUtils.setGlobalHandler((error, isFatal) => {
crashlytics().recordError(error);
if (isFatal) crashlytics().log(`Fatal: ${error.message}`);
if (originalHandler) originalHandler(error, isFatal);
});
console.log('Crashlytics initialized');
} catch (e) {
console.warn('Crashlytics init failed:', e.message);
}
}
export function setUser(userId, email) {
if (!isAvailable) return;
try {
if (userId) crashlytics().setUserId(userId);
if (email) crashlytics().setAttribute('email', email);
} catch (_) {}
}
export function log(message) {
if (!isAvailable) return;
try { crashlytics().log(message); } catch (_) {}
}
export function recordError(error, context) {
if (!isAvailable) return;
try {
if (context) crashlytics().log(context);
crashlytics().recordError(error instanceof Error ? error : new Error(String(error)));
} catch (_) {}
}
export function testCrash() {
if (!isAvailable) {
console.warn('Crashlytics not available in Expo Go');
return;
}
crashlytics().crash();
}
// Crashlytics stub — Firebase removed, all functions are no-ops
export function initCrashlytics() {}
export function setUser() {}
export function log() {}
export function recordError() {}
export function testCrash() {}
+5 -2
View File
@@ -1,6 +1,6 @@
import { supabase } from './supabase';
import * as offline from './offlineStorage';
import { generateHabitsFromIdentity } from '../utils/helpers';
import { generateHabitsFromIdentity, setStoryLanguage } from '../utils/helpers';
import { todayISO, addDays } from '../utils/date';
// Must match authService.js
@@ -30,6 +30,9 @@ export async function getIdentity(userId) {
// CREATE IDENTITY
// ========================
export async function createIdentity(userId, title, description, customHabits, storyText) {
// Set language detection from story or title for habit generation
setStoryLanguage(storyText || title || '');
const startDate = todayISO();
const endDate = addDays(new Date(), 40);
@@ -46,7 +49,7 @@ export async function createIdentity(userId, title, description, customHabits, s
const habitsList = customHabits && customHabits.length > 0
? customHabits
: generateHabitsFromIdentity(title);
: generateHabitsFromIdentity(title, storyText || title);
const habitsToInsert = habitsList.map((h) => ({
identity_id: identity.id,
+201 -161
View File
@@ -95,203 +95,243 @@ function shuffle(arr) {
return a;
}
// Habit pool — each keyword maps to many habits, pick random subset
const HABIT_POOL = {
// Fitness / Body
'fit,gym,exercise,workout,body,weight,muscle,run,jog,active': [
{ title: 'Move your body 30 minutes', description: 'Walk, run, lift — just move' },
{ title: 'Take 10,000 steps', description: 'Stay active throughout the day' },
{ title: 'Do a morning stretch', description: '10 minutes to wake your body up' },
{ title: 'No elevator — take stairs', description: 'Small movement adds up' },
{ title: 'Try a new workout', description: 'Keep your body guessing' },
{ title: 'Track your workout', description: 'Log sets, reps, or distance' },
{ title: 'Cool down after exercise', description: '5 min stretch post-workout' },
// Detect if text is Indonesian
export function isIndonesian(text) {
if (!text) return false;
// Use word boundary matching to avoid false positives (e.g. "dance" matching "dan")
const idWords = [
'saya','ingin','menjadi','yang','dan','untuk','dengan','tidak','bisa',
'aku','mau','lebih','hari','hidup','diri','sudah','akan','harus',
'masih','dari','juga','ini','itu','sangat','perlu','merasa','seperti',
'karena','tapi','setiap','selalu','waktu','pagi','malam','belajar',
'kerja','sehat','fokus','malas','takut','capek','lelah','semangat',
'kebiasaan','olahraga','tidur','makan','menulis','membaca','berdoa',
'sedang','sekali','banyak','sering','jarang','kadang','belum','lagi',
'kalau','kalian','kita','mereka','sendiri','baru','lama','terlalu',
'rasanya','pengen','banget','gak','nggak','gue','gw','lo','lu',
];
// Split into words and match whole words only
const words = text.toLowerCase().split(/[\s\.,!?\-;:'"()]+/);
let count = 0;
const matched = new Set();
words.forEach((w) => {
if (w.length >= 2 && idWords.includes(w) && !matched.has(w)) {
count++;
matched.add(w);
}
});
console.log(`isIndonesian: found ${count} ID words in "${text.slice(0, 50)}..."`);
return count >= 2;
}
// Bilingual habit pool — EN + ID per entry
const HABIT_POOL_BILINGUAL = {
'fit,gym,exercise,workout,body,weight,muscle,run,jog,active,olahraga,tubuh,badan,lari,sehat,otot,kurus,gemuk': [
{ en: 'Move your body 30 minutes', id: 'Gerakkan tubuh 30 menit', desc_en: 'Walk, run, lift — just move', desc_id: 'Jalan, lari, angkat beban — yang penting gerak' },
{ en: 'Take 10,000 steps', id: 'Jalan 10.000 langkah', desc_en: 'Stay active throughout the day', desc_id: 'Tetap aktif sepanjang hari' },
{ en: 'Do a morning stretch', id: 'Peregangan pagi 10 menit', desc_en: '10 minutes to wake your body up', desc_id: '10 menit untuk bangunkan tubuh' },
{ en: 'No elevator — take stairs', id: 'Naik tangga, bukan lift', desc_en: 'Small movement adds up', desc_id: 'Gerakan kecil menumpuk jadi besar' },
{ en: 'Try a new workout', id: 'Coba olahraga baru', desc_en: 'Keep your body guessing', desc_id: 'Tantang tubuhmu dengan hal baru' },
{ en: 'Track your workout', id: 'Catat latihan hari ini', desc_en: 'Log sets, reps, or distance', desc_id: 'Catat set, repetisi, atau jarak' },
{ en: 'Cool down after exercise', id: 'Pendinginan setelah olahraga', desc_en: '5 min stretch post-workout', desc_id: '5 menit peregangan setelah latihan' },
],
// Health / Nutrition
'health,eat,food,diet,nutrition,sugar,meal,cook,clean': [
{ title: 'Eat one clean meal', description: 'Whole foods, no processed stuff' },
{ title: 'Drink 2L of water', description: 'Hydrate before you caffeinate' },
{ title: 'No junk food today', description: 'Choose fuel over comfort' },
{ title: 'Eat a fruit or vegetable', description: 'Add color to your plate' },
{ title: 'Cook one meal at home', description: 'Control what goes in your body' },
{ title: 'Skip sugar after 6pm', description: 'Let your body rest clean' },
{ title: 'Eat slowly — no screen', description: 'Be present with your meal' },
'health,eat,food,diet,nutrition,sugar,meal,cook,clean,makan,masak,sehat,diet,gula,sayur,buah': [
{ en: 'Eat one clean meal', id: 'Makan satu porsi makanan bersih', desc_en: 'Whole foods, no processed stuff', desc_id: 'Makanan utuh, tanpa olahan' },
{ en: 'Drink 2L of water', id: 'Minum 2 liter air putih', desc_en: 'Hydrate before you caffeinate', desc_id: 'Hidrasi sebelum kafein' },
{ en: 'No junk food today', id: 'Tidak makan junk food hari ini', desc_en: 'Choose fuel over comfort', desc_id: 'Pilih nutrisi, bukan kenyamanan' },
{ en: 'Eat a fruit or vegetable', id: 'Makan buah atau sayur', desc_en: 'Add color to your plate', desc_id: 'Tambahkan warna di piringmu' },
{ en: 'Cook one meal at home', id: 'Masak satu kali di rumah', desc_en: 'Control what goes in your body', desc_id: 'Kendalikan apa yang masuk ke tubuh' },
{ en: 'Skip sugar after 6pm', id: 'Tanpa gula setelah jam 6 sore', desc_en: 'Let your body rest clean', desc_id: 'Biarkan tubuh istirahat bersih' },
{ en: 'Eat slowly — no screen', id: 'Makan pelan tanpa layar', desc_en: 'Be present with your meal', desc_id: 'Hadir sepenuhnya saat makan' },
],
// Sleep / Rest
'sleep,rest,tired,exhaust,energy,insomnia,bed': [
{ title: 'Sleep by 11pm', description: 'Protect your rest window' },
{ title: 'No screens 30min before bed', description: 'Let your brain wind down' },
{ title: 'Sleep 7+ hours', description: 'Make rest non-negotiable' },
{ title: 'Create a bedtime ritual', description: 'Tea, book, stretch — your call' },
{ title: 'Wake at the same time daily', description: 'Consistency over alarm snoozing' },
{ title: 'No caffeine after 2pm', description: 'Protect your sleep quality' },
'sleep,rest,tired,exhaust,energy,insomnia,bed,tidur,istirahat,capek,lelah,energi,bangun': [
{ en: 'Sleep by 11pm', id: 'Tidur sebelum jam 11 malam', desc_en: 'Protect your rest window', desc_id: 'Lindungi waktu istirahatmu' },
{ en: 'No screens 30min before bed', id: 'Tanpa layar 30 menit sebelum tidur', desc_en: 'Let your brain wind down', desc_id: 'Biarkan otak beristirahat' },
{ en: 'Sleep 7+ hours', id: 'Tidur minimal 7 jam', desc_en: 'Make rest non-negotiable', desc_id: 'Jadikan istirahat tidak bisa ditawar' },
{ en: 'Wake at the same time daily', id: 'Bangun di jam yang sama tiap hari', desc_en: 'Consistency over alarm snoozing', desc_id: 'Konsistensi lebih penting dari snooze' },
{ en: 'Create a bedtime ritual', id: 'Buat ritual sebelum tidur', desc_en: 'Tea, book, stretch — your call', desc_id: 'Teh, buku, peregangan — pilihanmu' },
{ en: 'No caffeine after 2pm', id: 'Tanpa kafein setelah jam 2 siang', desc_en: 'Protect your sleep quality', desc_id: 'Jaga kualitas tidurmu' },
],
// Reading / Learning
'read,book,learn,study,knowledge,smart,curious,educat': [
{ title: 'Read 15 pages', description: 'A small chapter changes everything' },
{ title: 'Listen to a podcast', description: 'Learn while you commute' },
{ title: 'Write one thing you learned', description: 'Retention through reflection' },
{ title: 'Replace 30min scrolling with reading', description: 'Swap consumption types' },
{ title: 'Teach someone what you learned', description: 'Teaching deepens understanding' },
{ title: 'Read before bed instead of phone', description: 'End the day growing' },
{ title: 'Take handwritten notes', description: 'Pen to paper locks it in' },
'read,book,learn,study,knowledge,smart,curious,educat,baca,buku,belajar,ilmu,pintar,kuliah': [
{ en: 'Read 15 pages', id: 'Baca 15 halaman', desc_en: 'A small chapter changes everything', desc_id: 'Satu bab kecil mengubah segalanya' },
{ en: 'Listen to a podcast', id: 'Dengarkan podcast', desc_en: 'Learn while you commute', desc_id: 'Belajar sambil perjalanan' },
{ en: 'Write one thing you learned', id: 'Tulis satu hal yang kamu pelajari', desc_en: 'Retention through reflection', desc_id: 'Ingatan lewat refleksi' },
{ en: 'Teach someone what you learned', id: 'Ajarkan sesuatu ke orang lain', desc_en: 'Teaching deepens understanding', desc_id: 'Mengajar memperdalam pemahaman' },
{ en: 'Read before bed instead of phone', id: 'Baca buku sebelum tidur, bukan HP', desc_en: 'End the day growing', desc_id: 'Akhiri hari dengan bertumbuh' },
{ en: 'Take handwritten notes', id: 'Tulis catatan tangan', desc_en: 'Pen to paper locks it in', desc_id: 'Menulis tangan mengunci ingatan' },
],
// Discipline / Routine
'disciplin,routine,consist,habit,lazy,procrastinat,structure': [
{ title: 'Wake up at the same time', description: 'Discipline starts with your alarm' },
{ title: 'Make your bed immediately', description: 'First win of the day' },
{ title: 'Follow a written schedule', description: 'Plan it, then do it' },
{ title: 'Do the hardest task first', description: 'Eat the frog before noon' },
{ title: 'No snooze button', description: 'Rise when the alarm rings' },
{ title: 'End the day with a review', description: 'What went well? What to fix?' },
{ title: 'Say no to one distraction', description: 'Guard your time like your life' },
'disciplin,routine,consist,habit,lazy,procrastinat,structure,disiplin,rutin,konsisten,kebiasaan,malas,menunda,teratur': [
{ en: 'Wake up at the same time', id: 'Bangun di jam yang sama', desc_en: 'Discipline starts with your alarm', desc_id: 'Disiplin dimulai dari alarm' },
{ en: 'Make your bed immediately', id: 'Rapikan tempat tidur langsung', desc_en: 'First win of the day', desc_id: 'Kemenangan pertama hari ini' },
{ en: 'Follow a written schedule', id: 'Ikuti jadwal tertulis', desc_en: 'Plan it, then do it', desc_id: 'Rencanakan, lalu kerjakan' },
{ en: 'Do the hardest task first', id: 'Kerjakan tugas tersulit duluan', desc_en: 'Eat the frog before noon', desc_id: 'Selesaikan yang berat sebelum siang' },
{ en: 'No snooze button', id: 'Jangan pencet snooze', desc_en: 'Rise when the alarm rings', desc_id: 'Bangun saat alarm berbunyi' },
{ en: 'End the day with a review', id: 'Akhiri hari dengan evaluasi', desc_en: 'What went well? What to fix?', desc_id: 'Apa yang baik? Apa yang perlu diperbaiki?' },
{ en: 'Say no to one distraction', id: 'Tolak satu gangguan', desc_en: 'Guard your time like your life', desc_id: 'Jaga waktumu seperti nyawamu' },
],
// Focus / Productivity
'focus,product,distract,attention,deep work,concentrat,procrast': [
{ title: 'One 90-min deep work block', description: 'Phone off, door closed, create' },
{ title: 'Use a Pomodoro timer', description: '25 on, 5 off — repeat' },
{ title: 'Plan your top 3 tasks tonight', description: 'Wake up with clarity' },
{ title: 'Clear your workspace', description: 'Clean space, clear mind' },
{ title: 'Single-task for 1 hour', description: 'No tabs, no switching' },
{ title: 'Delete one app that wastes time', description: 'Remove temptation at the source' },
{ title: 'Review your week every Sunday', description: 'Reflect and recalibrate' },
'focus,product,distract,attention,concentrat,procrast,fokus,produktif,konsentrasi,perhatian,terganggu': [
{ en: 'One 90-min deep work block', id: 'Satu sesi deep work 90 menit', desc_en: 'Phone off, door closed, create', desc_id: 'HP mati, pintu tutup, berkarya' },
{ en: 'Use a Pomodoro timer', id: 'Gunakan timer Pomodoro', desc_en: '25 on, 5 off — repeat', desc_id: '25 menit kerja, 5 menit istirahat' },
{ en: 'Plan your top 3 tasks tonight', id: 'Rencanakan 3 tugas utama malam ini', desc_en: 'Wake up with clarity', desc_id: 'Bangun dengan kejelasan' },
{ en: 'Clear your workspace', id: 'Bersihkan meja kerja', desc_en: 'Clean space, clear mind', desc_id: 'Ruang bersih, pikiran jernih' },
{ en: 'Single-task for 1 hour', id: 'Fokus satu tugas selama 1 jam', desc_en: 'No tabs, no switching', desc_id: 'Tanpa berpindah tab' },
{ en: 'Delete one app that wastes time', id: 'Hapus satu aplikasi pembuang waktu', desc_en: 'Remove temptation at the source', desc_id: 'Hilangkan godaan dari sumbernya' },
],
// Confidence / Social
'confiden,shy,social,anxi,fear,brave,courage,speak,voice': [
{ title: 'Give one genuine compliment', description: 'Kindness builds confidence' },
{ title: 'Speak up once in a group', description: 'Your voice matters' },
{ title: 'Make eye contact with strangers', description: 'Presence over avoidance' },
{ title: 'Write 3 wins from today', description: 'Train your brain to see strength' },
{ title: 'Say no to something you don\'t want', description: 'Boundaries are self-respect' },
{ title: 'Do one thing that scares you', description: 'Growth lives outside comfort' },
{ title: 'Record a voice note to yourself', description: 'Hear your own conviction' },
'confiden,shy,social,anxi,fear,brave,courage,speak,voice,percaya diri,malu,takut,berani,bicara,gugup,cemas': [
{ en: 'Give one genuine compliment', id: 'Berikan satu pujian tulus', desc_en: 'Kindness builds confidence', desc_id: 'Kebaikan membangun kepercayaan diri' },
{ en: 'Speak up once in a group', id: 'Bicara satu kali di grup', desc_en: 'Your voice matters', desc_id: 'Suaramu penting' },
{ en: 'Write 3 wins from today', id: 'Tulis 3 pencapaian hari ini', desc_en: 'Train your brain to see strength', desc_id: 'Latih otak melihat kekuatan' },
{ en: 'Do one thing that scares you', id: 'Lakukan satu hal yang menakutkan', desc_en: 'Growth lives outside comfort', desc_id: 'Pertumbuhan ada di luar zona nyaman' },
{ en: 'Make eye contact with strangers', id: 'Tatap mata orang yang baru kamu temui', desc_en: 'Presence over avoidance', desc_id: 'Hadir, bukan menghindar' },
{ en: 'Record a voice note to yourself', id: 'Rekam pesan suara untuk dirimu', desc_en: 'Hear your own conviction', desc_id: 'Dengarkan keyakinanmu sendiri' },
],
// Calm / Mental health
'calm,stress,anxious,peace,mental,meditat,mindful,relax,overwhelm,worry': [
{ title: 'Meditate for 10 minutes', description: 'Sit still. Breathe. That\'s enough' },
{ title: 'Write in a gratitude journal', description: '3 things you\'re thankful for' },
{ title: 'Take a walk in nature', description: 'No phone, just presence' },
{ title: 'Do a 5-minute breathing exercise', description: 'Inhale 4, hold 4, exhale 4' },
{ title: 'Digital detox for 1 hour', description: 'Unplug and reconnect with yourself' },
{ title: 'Stretch for 10 minutes', description: 'Release tension from your body' },
{ title: 'Listen to calming music', description: 'Let sound reset your mood' },
'calm,stress,anxious,peace,mental,meditat,mindful,relax,overwhelm,worry,tenang,stres,cemas,damai,meditasi,rileks,khawatir': [
{ en: 'Meditate for 10 minutes', id: 'Meditasi 10 menit', desc_en: 'Sit still. Breathe.', desc_id: 'Duduk tenang. Bernapas.' },
{ en: 'Write in a gratitude journal', id: 'Tulis jurnal syukur', desc_en: '3 things you\'re thankful for', desc_id: '3 hal yang kamu syukuri' },
{ en: 'Take a walk in nature', id: 'Jalan-jalan di alam', desc_en: 'No phone, just presence', desc_id: 'Tanpa HP, hanya hadir' },
{ en: 'Do a 5-minute breathing exercise', id: 'Latihan napas 5 menit', desc_en: 'Inhale 4, hold 4, exhale 4', desc_id: 'Hirup 4, tahan 4, hembuskan 4' },
{ en: 'Digital detox for 1 hour', id: 'Detoks digital 1 jam', desc_en: 'Unplug and reconnect with yourself', desc_id: 'Lepaskan gadget, sambungkan diri' },
{ en: 'Stretch for 10 minutes', id: 'Peregangan 10 menit', desc_en: 'Release tension from your body', desc_id: 'Lepaskan ketegangan dari tubuh' },
{ en: 'Listen to calming music', id: 'Dengarkan musik yang menenangkan', desc_en: 'Let sound reset your mood', desc_id: 'Biarkan musik mereset suasana hati' },
],
// Creative / Art
'creat,art,music,draw,paint,design,write,craft,imagin,innovat': [
{ title: 'Create something for 20 minutes', description: 'Draw, write, build — anything' },
{ title: 'Brainstorm 5 ideas', description: 'Quantity unlocks quality' },
{ title: 'Practice your craft', description: 'Show up even without inspiration' },
{ title: 'Consume inspiring work', description: 'Study someone you admire' },
{ title: 'Share one thing you made', description: 'Put your work into the world' },
{ title: 'Try a new creative technique', description: 'Experiment without pressure' },
{ title: 'Freewrite for 10 minutes', description: 'No editing, just flow' },
'creat,art,music,draw,paint,design,write,craft,imagin,innovat,kreatif,seni,musik,gambar,tulis,desain,cipta': [
{ en: 'Create something for 20 minutes', id: 'Ciptakan sesuatu selama 20 menit', desc_en: 'Draw, write, build — anything', desc_id: 'Gambar, tulis, bangun — apa saja' },
{ en: 'Brainstorm 5 ideas', id: 'Brainstorm 5 ide', desc_en: 'Quantity unlocks quality', desc_id: 'Kuantitas membuka kualitas' },
{ en: 'Practice your craft', id: 'Latih keahlianmu', desc_en: 'Show up even without inspiration', desc_id: 'Hadir walau tanpa inspirasi' },
{ en: 'Share one thing you made', id: 'Bagikan satu karya yang kamu buat', desc_en: 'Put your work into the world', desc_id: 'Taruh karyamu ke dunia' },
{ en: 'Freewrite for 10 minutes', id: 'Menulis bebas 10 menit', desc_en: 'No editing, just flow', desc_id: 'Tanpa edit, mengalir saja' },
],
// Finance / Career
'money,financ,save,earn,career,business,invest,rich,wealth,job,work': [
{ title: 'Track every expense today', description: 'Awareness is the first step' },
{ title: 'Save before you spend', description: 'Pay yourself first' },
{ title: 'Learn one new skill for your career', description: '15 minutes of growth' },
{ title: 'Review your financial goals', description: 'Are you on track?' },
{ title: 'Avoid one impulse purchase', description: 'Ask: do I need this or want this?' },
{ title: 'Network — reach out to someone', description: 'Opportunities come through people' },
{ title: 'Read about your industry', description: 'Stay sharp and informed' },
'money,financ,save,earn,career,business,invest,rich,wealth,job,work,uang,keuangan,tabung,hemat,karir,bisnis,investasi,kerja,gaji': [
{ en: 'Track every expense today', id: 'Catat semua pengeluaran hari ini', desc_en: 'Awareness is the first step', desc_id: 'Kesadaran adalah langkah pertama' },
{ en: 'Save before you spend', id: 'Menabung sebelum belanja', desc_en: 'Pay yourself first', desc_id: 'Bayar dirimu dulu' },
{ en: 'Learn one new skill for your career', id: 'Pelajari satu skill baru untuk karir', desc_en: '15 minutes of growth', desc_id: '15 menit pertumbuhan' },
{ en: 'Avoid one impulse purchase', id: 'Hindari satu pembelian impulsif', desc_en: 'Ask: do I need this?', desc_id: 'Tanya: apakah aku butuh ini?' },
{ en: 'Network — reach out to someone', id: 'Jaringan — hubungi seseorang', desc_en: 'Opportunities come through people', desc_id: 'Peluang datang dari koneksi' },
{ en: 'Read about your industry', id: 'Baca tentang industrimu', desc_en: 'Stay sharp and informed', desc_id: 'Tetap tajam dan terinformasi' },
],
// Relationships
'relationship,friend,family,love,connect,lonel,kind,empath,partner': [
{ title: 'Call or text someone you care about', description: 'Connection takes 2 minutes' },
{ title: 'Practice active listening', description: 'Listen to understand, not to reply' },
{ title: 'Give an unexpected compliment', description: 'Brighten someone\'s day' },
{ title: 'Be fully present in a conversation', description: 'Phone down, eyes up' },
{ title: 'Write a thank-you message', description: 'Gratitude strengthens bonds' },
{ title: 'Forgive one small thing', description: 'Carrying grudges weighs you down' },
{ title: 'Plan quality time with someone', description: 'Make people a priority, not an afterthought' },
'relationship,friend,family,love,connect,lonel,kind,empath,partner,hubungan,teman,keluarga,cinta,sahabat,kesepian,pasangan': [
{ en: 'Call or text someone you care about', id: 'Hubungi seseorang yang kamu sayangi', desc_en: 'Connection takes 2 minutes', desc_id: 'Koneksi hanya butuh 2 menit' },
{ en: 'Practice active listening', id: 'Latih mendengarkan aktif', desc_en: 'Listen to understand, not to reply', desc_id: 'Dengarkan untuk mengerti, bukan menjawab' },
{ en: 'Give an unexpected compliment', id: 'Berikan pujian tak terduga', desc_en: 'Brighten someone\'s day', desc_id: 'Cerahkan hari seseorang' },
{ en: 'Be fully present in a conversation', id: 'Hadir sepenuhnya saat ngobrol', desc_en: 'Phone down, eyes up', desc_id: 'HP turun, mata ke atas' },
{ en: 'Write a thank-you message', id: 'Tulis pesan terima kasih', desc_en: 'Gratitude strengthens bonds', desc_id: 'Rasa syukur memperkuat ikatan' },
{ en: 'Plan quality time with someone', id: 'Rencanakan waktu berkualitas', desc_en: 'Make people a priority', desc_id: 'Jadikan orang lain prioritas' },
],
// Morning / Routine
'morn,wake,alarm,sunrise,start,begin,early': [
{ title: 'Wake before 6:30 AM', description: 'Own the morning, own the day' },
{ title: 'No phone for first 30 minutes', description: 'Your mind deserves a slow start' },
{ title: 'Drink water before coffee', description: 'Rehydrate first' },
{ title: 'Move your body within 15 min of waking', description: 'Jumpstart your energy' },
{ title: 'Set 3 intentions for the day', description: 'Know what matters before you start' },
{ title: 'Eat a real breakfast', description: 'Fuel, not just caffeine' },
'morn,wake,alarm,sunrise,start,begin,early,pagi,bangun,subuh,awal': [
{ en: 'Wake before 6:30 AM', id: 'Bangun sebelum jam 6:30', desc_en: 'Own the morning', desc_id: 'Kuasai pagi hari' },
{ en: 'No phone for first 30 minutes', id: 'Tanpa HP 30 menit pertama', desc_en: 'Your mind deserves a slow start', desc_id: 'Pikiranmu butuh awal yang tenang' },
{ en: 'Drink water before coffee', id: 'Minum air sebelum kopi', desc_en: 'Rehydrate first', desc_id: 'Hidrasi dulu' },
{ en: 'Set 3 intentions for the day', id: 'Tetapkan 3 niat hari ini', desc_en: 'Know what matters before you start', desc_id: 'Ketahui apa yang penting sebelum mulai' },
{ en: 'Eat a real breakfast', id: 'Makan sarapan yang benar', desc_en: 'Fuel, not just caffeine', desc_id: 'Bahan bakar, bukan hanya kafein' },
{ en: 'Move your body within 15 min of waking', id: 'Gerakkan tubuh dalam 15 menit setelah bangun', desc_en: 'Jumpstart your energy', desc_id: 'Awali energimu' },
],
// Spiritual / Purpose
'spirit,purpose,meaning,faith,pray,god,soul,grateful,thank': [
{ title: 'Spend 10 minutes in stillness', description: 'Prayer, meditation, or silence' },
{ title: 'Write what you\'re grateful for', description: 'Gratitude changes your lens' },
{ title: 'Read something that feeds your soul', description: 'A verse, a poem, a passage' },
{ title: 'Reflect on your purpose', description: 'Why are you here? What matters?' },
{ title: 'Do one act of kindness', description: 'Give without expecting return' },
{ title: 'Forgive yourself for something', description: 'Grace is a daily practice' },
'spirit,purpose,meaning,faith,pray,god,soul,grateful,thank,ibadah,doa,tuhan,syukur,iman,sholat,rohani,bermakna': [
{ en: 'Spend 10 minutes in stillness', id: 'Habiskan 10 menit dalam keheningan', desc_en: 'Prayer, meditation, or silence', desc_id: 'Doa, meditasi, atau keheningan' },
{ en: 'Write what you\'re grateful for', id: 'Tulis hal yang kamu syukuri', desc_en: 'Gratitude changes your lens', desc_id: 'Rasa syukur mengubah sudut pandang' },
{ en: 'Read something that feeds your soul', id: 'Baca sesuatu yang menyentuh jiwa', desc_en: 'A verse, a poem, a passage', desc_id: 'Ayat, puisi, atau bacaan bermakna' },
{ en: 'Reflect on your purpose', id: 'Renungkan tujuan hidupmu', desc_en: 'Why are you here?', desc_id: 'Kenapa kamu ada di sini?' },
{ en: 'Do one act of kindness', id: 'Lakukan satu kebaikan', desc_en: 'Give without expecting return', desc_id: 'Memberi tanpa mengharap balasan' },
{ en: 'Forgive yourself for something', id: 'Maafkan dirimu atas sesuatu', desc_en: 'Grace is a daily practice', desc_id: 'Mengampuni adalah latihan harian' },
],
};
// Universal habits that apply to anyone
const UNIVERSAL_HABITS = [
{ title: 'Journal for 5 minutes', description: 'Put your thoughts on paper' },
{ title: 'Drink a full glass of water first thing', description: 'Start clean' },
{ title: 'Take a 10-minute walk', description: 'Movement clears the mind' },
{ title: 'Do one thing outside your comfort zone', description: 'Growth is on the other side' },
{ title: 'Spend 5 minutes planning tomorrow', description: 'Never wake up without a plan' },
{ title: 'Say something kind to yourself', description: 'You talk to yourself more than anyone' },
{ title: 'Avoid complaining for 1 hour', description: 'Replace complaints with solutions' },
{ title: 'Tidy one small area', description: 'Order outside creates order inside' },
{ title: 'Do something you\'ve been avoiding', description: 'The relief is worth the effort' },
{ title: 'Go to bed 15 minutes earlier', description: 'Small shifts, big impact' },
// Universal habits — bilingual
const UNIVERSAL_BILINGUAL = [
{ en: 'Journal for 5 minutes', id: 'Jurnal 5 menit', desc_en: 'Put your thoughts on paper', desc_id: 'Tuangkan pikiranmu di kertas' },
{ en: 'Drink a full glass of water first thing', id: 'Minum segelas penuh air putih pertama kali', desc_en: 'Start clean', desc_id: 'Mulai dengan bersih' },
{ en: 'Take a 10-minute walk', id: 'Jalan kaki 10 menit', desc_en: 'Movement clears the mind', desc_id: 'Gerakan menjernihkan pikiran' },
{ en: 'Do one thing outside your comfort zone', id: 'Lakukan satu hal di luar zona nyaman', desc_en: 'Growth is on the other side', desc_id: 'Pertumbuhan ada di sisi lain' },
{ en: 'Spend 5 minutes planning tomorrow', id: 'Habiskan 5 menit merencanakan besok', desc_en: 'Never wake up without a plan', desc_id: 'Jangan bangun tanpa rencana' },
{ en: 'Say something kind to yourself', id: 'Katakan sesuatu yang baik untuk dirimu', desc_en: 'You talk to yourself more than anyone', desc_id: 'Kamu bicara pada dirimu lebih dari siapa pun' },
{ en: 'Avoid complaining for 1 hour', id: 'Hindari mengeluh selama 1 jam', desc_en: 'Replace complaints with solutions', desc_id: 'Ganti keluhan dengan solusi' },
{ en: 'Tidy one small area', id: 'Rapikan satu area kecil', desc_en: 'Order outside creates order inside', desc_id: 'Kerapian luar menciptakan kerapian dalam' },
{ en: 'Do something you\'ve been avoiding', id: 'Lakukan sesuatu yang kamu hindari', desc_en: 'The relief is worth the effort', desc_id: 'Lega-nya sepadan dengan usaha' },
{ en: 'Go to bed 15 minutes earlier', id: 'Tidur 15 menit lebih awal', desc_en: 'Small shifts, big impact', desc_id: 'Perubahan kecil, dampak besar' },
];
export function generateHabitsFromIdentity(title) {
const text = (title || '').toLowerCase();
const matched = [];
// Collect all matching habits from the pool
for (const [keys, habits] of Object.entries(HABIT_POOL)) {
const keyList = keys.split(',');
if (keyList.some((k) => text.includes(k.trim()))) {
habits.forEach((h) => {
if (!matched.some((m) => m.title === h.title)) matched.push(h);
});
}
}
// Shuffle matched and pick 3-5
if (matched.length > 0) {
const picked = shuffle(matched).slice(0, 3 + Math.floor(Math.random() * 2));
return picked;
}
// No keyword match — return random universal habits
return shuffle(UNIVERSAL_HABITS).slice(0, 3);
// Convert bilingual entry to habit object based on language
function toHabit(entry, useId) {
return {
title: useId ? entry.id : entry.en,
description: useId ? entry.desc_id : entry.desc_en,
};
}
export function getSuggestionsForIdentity(title) {
if (!title || title.trim().length < 2) return [];
const text = title.toLowerCase();
// No module state — detect language from text directly every time
// Build habits from bilingual pool, matching keywords in searchText
// useId determined from langText (the full story)
function getPoolHabits(searchText, langText) {
const useId = isIndonesian(langText || searchText);
const lower = searchText.toLowerCase();
const matched = [];
for (const [keys, habits] of Object.entries(HABIT_POOL)) {
for (const [keys, habits] of Object.entries(HABIT_POOL_BILINGUAL)) {
const keyList = keys.split(',');
if (keyList.some((k) => text.includes(k.trim()))) {
if (keyList.some((k) => lower.includes(k.trim()))) {
habits.forEach((h) => {
if (!matched.includes(h.title)) matched.push(h.title);
const habit = toHabit(h, useId);
if (!matched.some((m) => m.title === habit.title)) matched.push(habit);
});
}
}
// Shuffle so same input doesn't always show same order
return matched;
}
function getUniversalHabits(langText) {
const useId = isIndonesian(langText);
return UNIVERSAL_BILINGUAL.map((h) => toHabit(h, useId));
}
/**
* Generate up to 20 bilingual habits.
* @param {string} searchText - text to match keywords against
* @param {string} [langText] - full story text for language detection (optional, defaults to searchText)
*/
export function generateHabitsFromIdentity(searchText, langText) {
const text = (searchText || '').toLowerCase();
const lang = langText || searchText || '';
const matched = getPoolHabits(text, lang);
const shuffled = shuffle(matched);
if (shuffled.length < 20) {
const universals = getUniversalHabits(lang);
universals.forEach((h) => {
if (!shuffled.some((m) => m.title === h.title)) shuffled.push(h);
});
}
return shuffled.length > 0 ? shuffled.slice(0, 20) : getUniversalHabits(lang).slice(0, 5);
}
/**
* Get habit title suggestions (strings).
* @param {string} searchText - text to match keywords against
* @param {string} [langText] - full story for language detection
*/
export function getSuggestionsForIdentity(searchText, langText) {
if (!searchText || searchText.trim().length < 2) return [];
const text = searchText.toLowerCase();
const lang = langText || searchText || '';
const matched = getPoolHabits(text, lang).map((h) => h.title);
const shuffled = shuffle(matched);
// If few matches, pad with universal habits
if (shuffled.length < 5) {
const universalTitles = shuffle(UNIVERSAL_HABITS).map((h) => h.title);
universalTitles.forEach((t) => {
const universals = getUniversalHabits(lang).map((h) => h.title);
universals.forEach((t) => {
if (!shuffled.includes(t)) shuffled.push(t);
});
}
return shuffled.slice(0, 8);
return shuffled.slice(0, 20);
}
// Keep for backward compat — no-op now
export function setStoryLanguage() {}
export function formatDate(date) {
return new Date(date).toLocaleDateString('en-US', {
month: 'short',
+17 -1
View File
@@ -18,7 +18,23 @@ const FALLBACK = {
{ title: 'Move your body for 20 minutes', best_time: '06:30', duration: 20, frequency: 'Every day', category: 'Body', difficulty: 'Easy', why: 'Movement lifts everything' },
{ title: 'Write one thing you are grateful for', best_time: '06:00', duration: 2, frequency: 'Every morning', category: 'Emotion', difficulty: 'Easy', why: 'Gratitude shifts perspective' },
],
suggested_habits: [],
suggested_habits: [
{ title: 'Read 15 pages', best_time: '07:00', duration: 20, frequency: 'Every day', category: 'Mind', difficulty: 'Easy', why: 'A chapter a day compounds into wisdom' },
{ title: 'Drink 2L of water', best_time: '08:00', duration: 0, frequency: 'Every day', category: 'Health', difficulty: 'Easy', why: 'Hydration fuels everything' },
{ title: 'Take a 10-minute walk', best_time: '12:00', duration: 10, frequency: 'Every day', category: 'Body', difficulty: 'Easy', why: 'Movement clears the mind' },
{ title: 'Plan your top 3 tasks tonight', best_time: '21:00', duration: 5, frequency: 'Every evening', category: 'Discipline', difficulty: 'Easy', why: 'Wake up with clarity' },
{ title: 'No phone first 30 minutes', best_time: '06:00', duration: 30, frequency: 'Every morning', category: 'Discipline', difficulty: 'Medium', why: 'Your mind deserves a slow start' },
{ title: 'Deep focus block 1 hour', best_time: '09:00', duration: 60, frequency: 'Weekdays only', category: 'Skill', difficulty: 'Medium', why: 'Deep work builds mastery' },
{ title: 'Journal for 5 minutes', best_time: '21:30', duration: 5, frequency: 'Every evening', category: 'Mind', difficulty: 'Easy', why: 'Put your thoughts on paper' },
{ title: 'Stretch for 10 minutes', best_time: '06:30', duration: 10, frequency: 'Every morning', category: 'Body', difficulty: 'Easy', why: 'Release tension from your body' },
{ title: 'Practice saying no once', best_time: '—', duration: 0, frequency: 'Every day', category: 'Social', difficulty: 'Medium', why: 'Boundaries are self-respect' },
{ title: 'Cook one meal at home', best_time: '18:00', duration: 30, frequency: '3x per week', category: 'Health', difficulty: 'Medium', why: 'Control what goes in your body' },
{ title: 'Call or text someone you care about', best_time: '19:00', duration: 5, frequency: '3x per week', category: 'Social', difficulty: 'Easy', why: 'Connection takes 2 minutes' },
{ title: 'Create something for 20 minutes', best_time: '17:00', duration: 20, frequency: 'Every day', category: 'Creative', difficulty: 'Medium', why: 'Show up even without inspiration' },
{ title: 'Meditate for 10 minutes', best_time: '06:00', duration: 10, frequency: 'Every morning', category: 'Mind', difficulty: 'Easy', why: 'Stillness sharpens everything' },
{ title: 'Track every expense today', best_time: '20:00', duration: 5, frequency: 'Every day', category: 'Discipline', difficulty: 'Easy', why: 'Awareness is the first step' },
{ title: 'Sleep by 11pm', best_time: '22:30', duration: 0, frequency: 'Every day', category: 'Health', difficulty: 'Medium', why: 'Protect your rest window' },
],
};
function parseDuration(val) {