diff --git a/apps/api/v1/constants/category.const.ts b/apps/api/v1/constants/category.const.ts new file mode 100644 index 00000000..2d9ef00c --- /dev/null +++ b/apps/api/v1/constants/category.const.ts @@ -0,0 +1,85 @@ +export const DEFAULT_INCOME_CATEGORIES = [ + { + en: 'Salary', + vi: 'Tiền lương', + icon: 'BriefcaseBusiness', + }, + { + en: 'Gift', + vi: 'Quà tặng', + icon: 'Clover', + }, + { + en: 'Investment', + vi: 'Đầu tư', + icon: 'ChartLine', + }, + { + en: 'Interest', + vi: 'Lãi suất', + icon: 'BadgePercent', + }, + { + en: 'Other Income', + vi: 'Thu nhập khác', + icon: 'HandCoins', + }, +] + +export const DEFAULT_EXPENSE_CATEGORIES = [ + { + en: 'Groceries', + vi: 'Nhu yếu phẩm', + icon: 'ShoppingBasket', + }, + { + en: 'Mortgage', + vi: 'Tiền nhà', + icon: 'House', + }, + { + en: 'Bills & Utilities', + vi: 'Hóa đơn & Tiện ích', + icon: 'HousePlug', + }, + { + en: 'Food & Beverage', + vi: 'Thức ăn & Đồ uống', + icon: 'UtensilsCrossed', + }, + { + en: 'Transportation', + vi: 'Di chuyển', + icon: 'CarFront', + }, + { + en: 'Shopping', + vi: 'Mua sắm', + icon: 'ShoppingBag', + }, + { + en: 'Health', + vi: 'Sức khỏe', + icon: 'HeartPulse', + }, + { + en: 'Entertainment', + vi: 'Giải trí', + icon: 'Drama', + }, + { + en: 'Education', + vi: 'Giáo dục', + icon: 'BookHeart', + }, + { + en: 'Investment', + vi: 'Đầu tư', + icon: 'ChartLine', + }, + { + en: 'Other Expense', + vi: 'Chi phí khác', + icon: 'Coins', + }, +] diff --git a/apps/api/v1/routes/users.ts b/apps/api/v1/routes/users.ts index 162339a3..3bc707da 100644 --- a/apps/api/v1/routes/users.ts +++ b/apps/api/v1/routes/users.ts @@ -2,13 +2,17 @@ import { zCreateUser } from '@6pm/validation' import { zValidator } from '@hono/zod-validator' import { Hono } from 'hono' import { getAuthUser } from '../middlewares/auth' +import { bootstrapUserDefaultCategories } from '../services/category.service' import { createUser } from '../services/user.service' +import { zDeviceLanguageHeader } from './utils' const router = new Hono().post( '/', zValidator('json', zCreateUser), + zDeviceLanguageHeader, async (c) => { const existingUser = getAuthUser(c) + const deviceLanguage = c.req.valid('header')['x-device-language'] if (existingUser) { return c.json({ message: 'user already exists' }, 409) @@ -18,7 +22,11 @@ const router = new Hono().post( const data = c.req.valid('json') try { - const user = await createUser({ ...data, id: userId }) + const user = await createUser({ data: { ...data, id: userId } }) + + // bootstrap user data + await bootstrapUserDefaultCategories({ user, language: deviceLanguage }) + return c.json(user, 201) } catch (e) { return c.json({ userId, message: 'failed to create user', cause: e }, 500) diff --git a/apps/api/v1/routes/utils.ts b/apps/api/v1/routes/utils.ts new file mode 100644 index 00000000..f57aac15 --- /dev/null +++ b/apps/api/v1/routes/utils.ts @@ -0,0 +1,9 @@ +import { zValidator } from '@hono/zod-validator' +import { z } from 'zod' + +export const zDeviceLanguageHeader = zValidator( + 'header', + z.object({ + 'x-device-language': z.string().optional(), + }), +) diff --git a/apps/api/v1/services/budget-invitation.service.ts b/apps/api/v1/services/budget-invitation.service.ts index 5d6996c6..0df5c7c5 100644 --- a/apps/api/v1/services/budget-invitation.service.ts +++ b/apps/api/v1/services/budget-invitation.service.ts @@ -217,7 +217,8 @@ export async function respondToBudgetInvitation({ // Create find or new user const user = - (await findUserByEmail(userData.email)) || (await createUser(userData)) + (await findUserByEmail(userData.email)) || + (await createUser({ data: userData })) // Create invitation response const acceptedAt = new Date() diff --git a/apps/api/v1/services/category.service.ts b/apps/api/v1/services/category.service.ts index 1ee5f6d6..be346a61 100644 --- a/apps/api/v1/services/category.service.ts +++ b/apps/api/v1/services/category.service.ts @@ -1,6 +1,10 @@ import type { CreateCategory, UpdateCategory } from '@6pm/validation' -import type { Category, User } from '@prisma/client' +import { type Category, CategoryType, type User } from '@prisma/client' import prisma from '../../lib/prisma' +import { + DEFAULT_EXPENSE_CATEGORIES, + DEFAULT_INCOME_CATEGORIES, +} from '../constants/category.const' export async function canUserCreateCategory({ // biome-ignore lint/correctness/noUnusedVariables: @@ -117,3 +121,29 @@ export async function findCategoriesOfUser({ where: { userId: user.id }, }) } + +export async function bootstrapUserDefaultCategories({ + user, + language = 'en', +}: { user: User; language?: string }) { + const defaultCategories = [ + ...DEFAULT_INCOME_CATEGORIES.map((c) => ({ + ...c, + type: CategoryType.INCOME, + })), + ...DEFAULT_EXPENSE_CATEGORIES.map((c) => ({ + ...c, + type: CategoryType.EXPENSE, + })), + ].map(({ en, vi, ...category }) => ({ + ...category, + name: language === 'vi' ? vi : en, + })) + + return prisma.category.createMany({ + data: defaultCategories.map((category) => ({ + ...category, + userId: user.id, + })), + }) +} diff --git a/apps/api/v1/services/user.service.ts b/apps/api/v1/services/user.service.ts index f9d118f7..2c71c751 100644 --- a/apps/api/v1/services/user.service.ts +++ b/apps/api/v1/services/user.service.ts @@ -17,7 +17,7 @@ export async function findUserByEmail(email: string) { }) } -export async function createUser(data: CreateUser) { +export async function createUser({ data }: { data: CreateUser }) { return await prisma.user.create({ data, }) diff --git a/apps/mobile/app/(app)/(tabs)/settings.tsx b/apps/mobile/app/(app)/(tabs)/settings.tsx index caf255b4..59c497a4 100644 --- a/apps/mobile/app/(app)/(tabs)/settings.tsx +++ b/apps/mobile/app/(app)/(tabs)/settings.tsx @@ -13,6 +13,8 @@ import { useLocale } from '@/locales/provider' import { useAuth } from '@clerk/clerk-expo' import { t } from '@lingui/macro' import { useLingui } from '@lingui/react' +import AsyncStorage from '@react-native-async-storage/async-storage' +import { useQueryClient } from '@tanstack/react-query' import { LinearGradient } from 'expo-linear-gradient' import { Link } from 'expo-router' import { @@ -38,6 +40,7 @@ export default function SettingsScreen() { const { i18n } = useLingui() const { language } = useLocale() const { colorScheme } = useColorScheme() + const queryClient = useQueryClient() return ( @@ -185,7 +188,11 @@ export default function SettingsScreen() { { text: t(i18n)`Sign out`, style: 'destructive', - onPress: () => signOut(), + onPress: async () => { + await AsyncStorage.clear() + queryClient.clear() + await signOut() + }, }, ]) } diff --git a/apps/mobile/components/common/generic-icon.tsx b/apps/mobile/components/common/generic-icon.tsx index fbbe1ae9..f2d16e12 100644 --- a/apps/mobile/components/common/generic-icon.tsx +++ b/apps/mobile/components/common/generic-icon.tsx @@ -12,6 +12,11 @@ const GenericIcon: FC< > = ({ name, ...props }) => { const LucideIcon = icons[name] + if (!LucideIcon) { + console.error(`Icon "${name}" not found`) + return null + } + return } diff --git a/apps/mobile/lib/icons/category-icons.ts b/apps/mobile/lib/icons/category-icons.ts index d0478681..0e1a41c1 100644 --- a/apps/mobile/lib/icons/category-icons.ts +++ b/apps/mobile/lib/icons/category-icons.ts @@ -22,7 +22,7 @@ export const CATEGORY_EXPENSE_ICONS: Array = [ 'Lamp', 'BedDouble', 'Drill', - 'Home', + 'House', 'Refrigerator', 'Cat', 'Bird', @@ -63,10 +63,10 @@ export const CATEGORY_INCOME_ICONS: Array = [ 'Handshake', 'PiggyBank', 'SmartphoneNfc', - 'BadgeCent', + 'BadgePercent', 'Trophy', 'Clover', - 'LineChart', + 'ChartLine', 'Store', 'BriefcaseBusiness', 'Building2', diff --git a/apps/mobile/package.json b/apps/mobile/package.json index ef6e33f8..173e9822 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -79,7 +79,7 @@ "expo-web-browser": "~13.0.3", "hono": "^4.4.8", "lodash-es": "^4.17.21", - "lucide-react-native": "^0.390.0", + "lucide-react-native": "^0.417.0", "nativewind": "^4.0.36", "posthog-react-native": "^3.1.1", "react": "18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8f6dd55..f5f1e601 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -266,7 +266,7 @@ importers: version: 15.0.3(expo@51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))) expo-router: specifier: ~3.5.15 - version: 3.5.16(expo-constants@16.0.2(expo@51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))))(expo-linking@6.3.1(expo@51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))))(expo-modules-autolinking@1.11.1)(expo-status-bar@1.12.1)(expo@51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7)))(react-native-reanimated@3.10.1(@babel/core@7.24.7)(react-native@0.74.2(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.2.79)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@4.10.1(react-native@0.74.2(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.2.79)(react@18.3.1))(react@18.3.1))(react-native-screens@3.31.1(react-native@0.74.2(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.2.79)(react@18.3.1))(react@18.3.1))(react-native@0.74.2(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.2.79)(react@18.3.1))(react@18.3.1)(typescript@5.3.3) + version: 3.5.16(4mulymdxn7fmzuvb6n5wc7wvuq) expo-secure-store: specifier: ^13.0.1 version: 13.0.1(expo@51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))) @@ -289,8 +289,8 @@ importers: specifier: ^4.17.21 version: 4.17.21 lucide-react-native: - specifier: ^0.390.0 - version: 0.390.0(react-native-svg@15.2.0(react-native@0.74.2(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.2.79)(react@18.3.1))(react@18.3.1))(react-native@0.74.2(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.2.79)(react@18.3.1))(react@18.3.1) + specifier: ^0.417.0 + version: 0.417.0(react-native-svg@15.2.0(react-native@0.74.2(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.2.79)(react@18.3.1))(react@18.3.1))(react-native@0.74.2(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.2.79)(react@18.3.1))(react@18.3.1) nativewind: specifier: ^4.0.36 version: 4.0.36(@babel/core@7.24.7)(react-native-reanimated@3.10.1(@babel/core@7.24.7)(react-native@0.74.2(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.2.79)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@4.10.1(react-native@0.74.2(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.2.79)(react@18.3.1))(react@18.3.1))(react-native-svg@15.2.0(react-native@0.74.2(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.2.79)(react@18.3.1))(react@18.3.1))(react-native@0.74.2(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.2.79)(react@18.3.1))(react@18.3.1)(tailwindcss@3.4.4) @@ -1176,7 +1176,7 @@ packages: '@backpackapp-io/react-native-toast@0.11.0': resolution: {integrity: sha512-ZRVYQPK6QOvt6vP1bF0I5oBpN5pnssdrX1JLV+KHPyxXWSNNvl1oo0qdJ5uzM7zj2TxMMUBPIsMofFeLA8E/dw==} peerDependencies: - react: 18.2.0 + react: '*' react-native: '*' react-native-gesture-handler: '>=2.2.1' react-native-reanimated: '>=2.8.0' @@ -1618,7 +1618,7 @@ packages: peerDependencies: '@types/react': '*' '@types/react-native': '*' - react: 18.2.0 + react: '*' react-native: '*' react-native-gesture-handler: '>=1.10.1' react-native-reanimated: '>=2.2.0' @@ -2578,7 +2578,7 @@ packages: resolution: {integrity: sha512-plhc8UvCZs0UkV+sI+3bisIyn78wz9O/BiWZXpounu72k/R/Sj5PuZYFJ1fi6psvriUveMCGh4LeZckAZu2qiQ==} peerDependencies: '@react-navigation/native': ^6.0.0 - react: 18.2.0 + react: '*' react-native: '*' react-native-safe-area-context: '>= 3.0.0' @@ -2594,7 +2594,7 @@ packages: '@react-navigation/native@6.1.17': resolution: {integrity: sha512-mer3OvfwWOHoUSMJyLa4vnBH3zpFmCwuzrBPlw7feXklurr/ZDiLjLxUScOot6jLRMz/67GyilEYMmP99LL0RQ==} peerDependencies: - react: 18.2.0 + react: '*' react-native: '*' '@react-navigation/routers@6.1.9': @@ -5386,8 +5386,8 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} - lucide-react-native@0.390.0: - resolution: {integrity: sha512-JFxyXoRP2GLW2Zft4znWXbNpfexA4kC60KKXyJT3BZ+jDK5dvhZVzp1/3A2jCFbNYsZFJFVBXJcD+ahstBKl7w==} + lucide-react-native@0.417.0: + resolution: {integrity: sha512-ktflg1BMTX3NiuHZ2hYkUhPyz3ekSXSN4T0kWCmlmRWEjRE3xUN7Bk8ojWg2nQpK5lvOK2Oa5ttu/7Q/a0kRhg==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 react-native: '*' @@ -12199,8 +12199,8 @@ snapshots: dependencies: invariant: 2.2.4 - ? expo-router@3.5.16(expo-constants@16.0.2(expo@51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))))(expo-linking@6.3.1(expo@51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))))(expo-modules-autolinking@1.11.1)(expo-status-bar@1.12.1)(expo@51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7)))(react-native-reanimated@3.10.1(@babel/core@7.24.7)(react-native@0.74.2(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.2.79)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@4.10.1(react-native@0.74.2(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.2.79)(react@18.3.1))(react@18.3.1))(react-native-screens@3.31.1(react-native@0.74.2(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.2.79)(react@18.3.1))(react@18.3.1))(react-native@0.74.2(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.2.79)(react@18.3.1))(react@18.3.1)(typescript@5.3.3) - : dependencies: + expo-router@3.5.16(4mulymdxn7fmzuvb6n5wc7wvuq): + dependencies: '@expo/metro-runtime': 3.2.1(react-native@0.74.2(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.2.79)(react@18.3.1)) '@expo/server': 0.4.3(typescript@5.3.3) '@radix-ui/react-slot': 1.0.1(react@18.3.1) @@ -13641,7 +13641,7 @@ snapshots: dependencies: yallist: 4.0.0 - lucide-react-native@0.390.0(react-native-svg@15.2.0(react-native@0.74.2(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.2.79)(react@18.3.1))(react@18.3.1))(react-native@0.74.2(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.2.79)(react@18.3.1))(react@18.3.1): + lucide-react-native@0.417.0(react-native-svg@15.2.0(react-native@0.74.2(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.2.79)(react@18.3.1))(react@18.3.1))(react-native@0.74.2(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.2.79)(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 react-native: 0.74.2(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.2.79)(react@18.3.1)