fixing login sosmed dan ganti logo/icon app
@@ -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,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": {
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 582 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 582 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 582 KiB |
|
Before Width: | Height: | Size: 731 KiB After Width: | Height: | Size: 582 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, borderRadius: size / 2 }}
|
||||
resizeMode="contain"
|
||||
style={{ width: size, height: size * 0.88 }}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -107,8 +107,8 @@ function PageContent({ item, isActive }) {
|
||||
)}
|
||||
<Image
|
||||
source={require('../../assets/icon.png')}
|
||||
style={{ width: item.iconSize, height: item.iconSize, borderRadius: item.iconSize / 2 }}
|
||||
resizeMode="contain"
|
||||
style={{ width: item.iconSize, height: item.iconSize * 0.88 }}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
|
||||
@@ -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: 160, height: 160, borderRadius: 80, overflow: 'hidden' },
|
||||
logo: { width: 160, height: 140 },
|
||||
title: {
|
||||
color: colors.text, fontSize: fonts.sizes.hero,
|
||||
fontWeight: fonts.weights.bold, letterSpacing: 8, marginBottom: 16,
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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() {}
|
||||
|
||||
@@ -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.
|
||||
@@ -224,145 +220,71 @@ async function createOAuthSession(email, provider, name, picture) {
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 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
|
||||
// Native Google Sign-In (requires development build)
|
||||
// ============================================
|
||||
const GOOGLE_CLIENT_ID = '112566583381-29vsg1bg7ifnq5csg5o8fisdgtllb2sn.apps.googleusercontent.com';
|
||||
import { GoogleSignin } from '@react-native-google-signin/google-signin';
|
||||
|
||||
// Parse params from redirect URL (both ? and # fragments)
|
||||
function parseUrlParams(url) {
|
||||
const params = {};
|
||||
// Try query string first (?code=xxx&...)
|
||||
const query = url.split('?')[1]?.split('#')[0];
|
||||
if (query) {
|
||||
query.split('&').forEach((part) => {
|
||||
const [k, v] = part.split('=');
|
||||
if (k) params[decodeURIComponent(k)] = decodeURIComponent(v || '');
|
||||
});
|
||||
}
|
||||
// Also try hash fragment (#access_token=xxx&...)
|
||||
const hash = url.split('#')[1];
|
||||
if (hash) {
|
||||
hash.split('&').forEach((part) => {
|
||||
const [k, v] = part.split('=');
|
||||
if (k) params[decodeURIComponent(k)] = decodeURIComponent(v || '');
|
||||
});
|
||||
}
|
||||
return params;
|
||||
}
|
||||
const GOOGLE_WEB_CLIENT_ID = '112566583381-29vsg1bg7ifnq5csg5o8fisdgtllb2sn.apps.googleusercontent.com';
|
||||
|
||||
// Generate random string for PKCE
|
||||
function generateRandom(length) {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
return result;
|
||||
}
|
||||
GoogleSignin.configure({
|
||||
webClientId: GOOGLE_WEB_CLIENT_ID,
|
||||
offlineAccess: true,
|
||||
});
|
||||
|
||||
export async function signInWithGoogle() {
|
||||
try {
|
||||
const redirectUri = AuthSession.makeRedirectUri({ preferLocalhost: false });
|
||||
await GoogleSignin.hasPlayServices();
|
||||
const response = await GoogleSignin.signIn();
|
||||
const user = response?.data?.user || response?.user;
|
||||
|
||||
// Use authorization code flow with PKCE (required by Google for mobile)
|
||||
const state = generateRandom(16);
|
||||
const codeVerifier = generateRandom(64);
|
||||
if (!user?.email) throw new Error('No email returned from Google.');
|
||||
|
||||
// For S256 we need crypto, but plain is simpler and works
|
||||
const authUrl =
|
||||
`https://accounts.google.com/o/oauth2/v2/auth` +
|
||||
`?client_id=${GOOGLE_CLIENT_ID}` +
|
||||
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||
`&response_type=code` +
|
||||
`&scope=${encodeURIComponent('openid profile email')}` +
|
||||
`&state=${state}` +
|
||||
`&code_challenge=${codeVerifier}` +
|
||||
`&code_challenge_method=plain` +
|
||||
`&access_type=offline`;
|
||||
|
||||
const result = await WebBrowser.openAuthSessionAsync(authUrl, redirectUri);
|
||||
|
||||
if (result.type === 'success' && result.url) {
|
||||
const params = parseUrlParams(result.url);
|
||||
|
||||
if (params.code) {
|
||||
// Exchange code for tokens
|
||||
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: [
|
||||
`code=${encodeURIComponent(params.code)}`,
|
||||
`client_id=${encodeURIComponent(GOOGLE_CLIENT_ID)}`,
|
||||
`redirect_uri=${encodeURIComponent(redirectUri)}`,
|
||||
`grant_type=authorization_code`,
|
||||
`code_verifier=${encodeURIComponent(codeVerifier)}`,
|
||||
].join('&'),
|
||||
});
|
||||
|
||||
const tokens = await tokenRes.json();
|
||||
|
||||
if (tokens.access_token) {
|
||||
const userInfo = await fetch('https://www.googleapis.com/userinfo/v2/me', {
|
||||
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||
}).then((r) => r.json());
|
||||
|
||||
const email = userInfo.email || 'google-user@nova40.app';
|
||||
return await createOAuthSession(email, 'google', userInfo.name, userInfo.picture);
|
||||
}
|
||||
|
||||
throw new Error(tokens.error_description || 'Failed to exchange code for token.');
|
||||
}
|
||||
}
|
||||
|
||||
if (result.type === 'cancel' || result.type === 'dismiss') throw new Error('Google sign-in was cancelled.');
|
||||
throw new Error('Google sign-in failed.');
|
||||
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({ preferLocalhost: false });
|
||||
// Reset previous login
|
||||
LoginManager.logOut();
|
||||
|
||||
const 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']);
|
||||
|
||||
const result = await WebBrowser.openAuthSessionAsync(authUrl, redirectUri);
|
||||
if (result.isCancelled) throw new Error('Facebook sign-in was cancelled.');
|
||||
|
||||
if (result.type === 'success' && result.url) {
|
||||
const params = parseUrlParams(result.url);
|
||||
if (params.access_token) {
|
||||
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&access_token=${params.access_token}`
|
||||
`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`;
|
||||
return await createOAuthSession(email, 'facebook', userInfo.name, userInfo.picture?.data?.url);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.type === 'cancel' || result.type === 'dismiss') 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.');
|
||||
}
|
||||
|
||||
@@ -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() {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
|
||||
// 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 = [];
|
||||
|
||||
// Collect all matching habits from the pool
|
||||
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.some((m) => m.title === h.title)) matched.push(h);
|
||||
const habit = toHabit(h, useId);
|
||||
if (!matched.some((m) => m.title === habit.title)) matched.push(habit);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Shuffle matched and pick 3-5
|
||||
if (matched.length > 0) {
|
||||
const picked = shuffle(matched).slice(0, 3 + Math.floor(Math.random() * 2));
|
||||
return picked;
|
||||
return matched;
|
||||
}
|
||||
|
||||
// No keyword match — return random universal habits
|
||||
return shuffle(UNIVERSAL_HABITS).slice(0, 3);
|
||||
function getUniversalHabits(langText) {
|
||||
const useId = isIndonesian(langText);
|
||||
return UNIVERSAL_BILINGUAL.map((h) => toHabit(h, useId));
|
||||
}
|
||||
|
||||
export function getSuggestionsForIdentity(title) {
|
||||
if (!title || title.trim().length < 2) return [];
|
||||
const text = title.toLowerCase();
|
||||
const matched = [];
|
||||
/**
|
||||
* 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);
|
||||
|
||||
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.includes(h.title)) matched.push(h.title);
|
||||
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);
|
||||
}
|
||||
|
||||
// Shuffle so same input doesn't always show same order
|
||||
/**
|
||||
* 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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||