@thanhphi/create-360-miniapp 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +328 -2
  3. package/bin/create-360-miniapp.cjs +54 -0
  4. package/lib/create.cjs +56 -0
  5. package/package.json +24 -4
  6. package/template/.eslintrc.cjs +1 -0
  7. package/template/.prettierrc.cjs +1 -0
  8. package/template/.watchmanconfig +1 -0
  9. package/template/App.tsx +2 -0
  10. package/template/README.md +215 -0
  11. package/template/__tests__/auth.test.ts +287 -0
  12. package/template/aliases.cjs +16 -0
  13. package/template/app.config.js +23 -0
  14. package/template/app.json +17 -0
  15. package/template/assets/.gitkeep +0 -0
  16. package/template/babel.config.js +7 -0
  17. package/template/declare/.gitkeep +0 -0
  18. package/template/declare/assets.d.ts +11 -0
  19. package/template/env/beta.json +16 -0
  20. package/template/env/prod.json +16 -0
  21. package/template/env/staging.json +16 -0
  22. package/template/gitignore +19 -0
  23. package/template/index.js +2 -0
  24. package/template/index.repack.js +14 -0
  25. package/template/jest.config.cjs +29 -0
  26. package/template/metro.config.js +6 -0
  27. package/template/package.json +88 -0
  28. package/template/pipelines/beta.yml +91 -0
  29. package/template/pipelines/production.yml +91 -0
  30. package/template/pipelines/staging.yml +91 -0
  31. package/template/pipelines/templates/build-and-zip.yml +89 -0
  32. package/template/pipelines/templates/upload-and-register.yml +89 -0
  33. package/template/react-native.config.js +3 -0
  34. package/template/rspack.config.cjs +53 -0
  35. package/template/scripts/clean-mini-app-output.js +9 -0
  36. package/template/scripts/sync-env.js +29 -0
  37. package/template/src/App.tsx +50 -0
  38. package/template/src/StandaloneApp.tsx +43 -0
  39. package/template/src/assets/icons/.gitkeep +1 -0
  40. package/template/src/assets/images/.gitkeep +1 -0
  41. package/template/src/assets/svg/.gitkeep +1 -0
  42. package/template/src/bootstrap.tsx +7 -0
  43. package/template/src/common/mini-app/provider.tsx +45 -0
  44. package/template/src/common/mini-app/types.ts +13 -0
  45. package/template/src/config/.gitkeep +0 -0
  46. package/template/src/constants/.gitkeep +0 -0
  47. package/template/src/hooks/.gitkeep +0 -0
  48. package/template/src/library/components/.gitkeep +0 -0
  49. package/template/src/library/networking/client.ts +53 -0
  50. package/template/src/library/networking/errors.ts +25 -0
  51. package/template/src/library/utils/responsive.ts +7 -0
  52. package/template/src/library/utils/storage/.gitkeep +0 -0
  53. package/template/src/modules/__MODULE__/assets/.gitkeep +0 -0
  54. package/template/src/modules/__MODULE__/components/ProfileCard.tsx +38 -0
  55. package/template/src/modules/__MODULE__/constants/.gitkeep +0 -0
  56. package/template/src/modules/__MODULE__/navigation/ModuleNavigator.tsx +18 -0
  57. package/template/src/modules/__MODULE__/redux/home-slice.ts +13 -0
  58. package/template/src/modules/__MODULE__/screens/home/hooks/use-home.ts +30 -0
  59. package/template/src/modules/__MODULE__/screens/home/index.tsx +100 -0
  60. package/template/src/modules/__MODULE__/services/profile.service.ts +32 -0
  61. package/template/src/modules/__MODULE__/types/.gitkeep +0 -0
  62. package/template/src/modules/auth/components/.gitkeep +0 -0
  63. package/template/src/modules/auth/constants/.gitkeep +0 -0
  64. package/template/src/modules/auth/hooks/use-auth.ts +55 -0
  65. package/template/src/modules/auth/navigation/AuthenticationNavigator.tsx +16 -0
  66. package/template/src/modules/auth/redux/auth-slice.ts +36 -0
  67. package/template/src/modules/auth/redux/selectors.ts +6 -0
  68. package/template/src/modules/auth/screens/LoginScreen.tsx +78 -0
  69. package/template/src/modules/auth/services/oidc.ts +64 -0
  70. package/template/src/modules/auth/services/standalone-session.ts +121 -0
  71. package/template/src/navigation/MainStack.tsx +46 -0
  72. package/template/src/redux/RootReducers.ts +8 -0
  73. package/template/src/redux/hooks.ts +5 -0
  74. package/template/src/redux/store.ts +10 -0
  75. package/template/src/services/application.ts +87 -0
  76. package/template/src/themes/colors/dark.ts +90 -0
  77. package/template/src/themes/colors/light.ts +90 -0
  78. package/template/src/themes/index.ts +38 -0
  79. package/template/src/themes/standalone.ts +6 -0
  80. package/template/src/themes/ui-geometry.ts +12 -0
  81. package/template/src/types/.gitkeep +0 -0
  82. package/template/tsconfig.json +65 -0
@@ -0,0 +1,78 @@
1
+ import React from 'react';
2
+ import { Button, UIText } from '@thanhphi/ui360';
3
+ import { ActivityIndicator, ScrollView, View } from 'react-native';
4
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
5
+ import { StyleSheet, useUnistyles } from 'react-native-unistyles';
6
+ import type {} from '../../../themes';
7
+ import { uiGeometry } from '../../../themes/ui-geometry';
8
+ import { useAuth } from '../hooks/use-auth';
9
+
10
+ export default function LoginScreen() {
11
+ const {
12
+ theme: { color },
13
+ } = useUnistyles();
14
+ const { spacing } = uiGeometry;
15
+ const auth = useAuth();
16
+ const insets = useSafeAreaInsets();
17
+ return (
18
+ <ScrollView
19
+ contentInsetAdjustmentBehavior="automatic"
20
+ contentContainerStyle={[
21
+ styles.content,
22
+ { paddingBottom: insets.bottom + spacing.xxl },
23
+ ]}
24
+ >
25
+ <View style={styles.intro}>
26
+ <UIText variant="title" weight="600" accessibilityRole="header">
27
+ Đăng nhập để tiếp tục
28
+ </UIText>
29
+ <UIText color="content2">
30
+ {auth.isStandalone
31
+ ? auth.canSignIn
32
+ ? 'Đăng nhập bằng tài khoản được cấu hình cho ứng dụng.'
33
+ : 'Cấu hình issuer, clientId và redirectUrl trong env, rồi tạo lại native app để sử dụng OIDC.'
34
+ : 'Mini app cần phiên đăng nhập từ MySGR. Host phải hỗ trợ và truyền hostCapabilities theo contract SDK hiện tại.'}
35
+ </UIText>
36
+ </View>
37
+ {auth.message && (
38
+ <UIText selectable accessibilityRole="alert" color="danger">
39
+ {auth.message}
40
+ </UIText>
41
+ )}
42
+ {auth.pending && (
43
+ <ActivityIndicator
44
+ accessibilityLabel="Đang đăng nhập"
45
+ color={color.primary500}
46
+ />
47
+ )}
48
+ {auth.isStandalone ? (
49
+ <Button
50
+ label={auth.pending ? 'Đang đăng nhập…' : 'Đăng nhập'}
51
+ disabled={!auth.canSignIn}
52
+ loading={auth.pending}
53
+ onPress={() => {
54
+ void auth.signIn();
55
+ }}
56
+ />
57
+ ) : (
58
+ <Button
59
+ label="Quay lại MySGR"
60
+ onPress={() => {
61
+ void auth.closeMiniApp();
62
+ }}
63
+ />
64
+ )}
65
+ </ScrollView>
66
+ );
67
+ }
68
+
69
+ const { spacing } = uiGeometry;
70
+ const styles = StyleSheet.create(({ color }) => ({
71
+ content: {
72
+ padding: spacing.xxl,
73
+ gap: spacing.xxl,
74
+ flexGrow: 1,
75
+ backgroundColor: color.base3,
76
+ },
77
+ intro: { gap: spacing.lg },
78
+ }));
@@ -0,0 +1,64 @@
1
+ import {
2
+ authorize,
3
+ refresh,
4
+ type AuthConfiguration,
5
+ } from 'react-native-app-auth';
6
+ import { AuthError } from '@thanhphi/miniapp-sdk';
7
+ import config from '@env-config';
8
+ import type { OidcOperations } from './standalone-session';
9
+
10
+ const authConfig: AuthConfiguration = {
11
+ issuer: config.AUTH.issuer,
12
+ clientId: config.AUTH.clientId,
13
+ redirectUrl: config.AUTH.redirectUrl,
14
+ scopes: [...config.AUTH.scopes],
15
+ };
16
+
17
+ export function isOidcConfigured(value: {
18
+ issuer: string;
19
+ clientId: string;
20
+ redirectUrl: string;
21
+ scopes: readonly string[];
22
+ }): boolean {
23
+ const filled = (field: string) =>
24
+ Boolean(field.trim()) && !field.includes('YOUR_');
25
+ return (
26
+ filled(value.issuer) &&
27
+ filled(value.clientId) &&
28
+ filled(value.redirectUrl) &&
29
+ value.scopes.length > 0 &&
30
+ value.scopes.every(filled)
31
+ );
32
+ }
33
+
34
+ export const oidcOperations: OidcOperations = {
35
+ isConfigured: isOidcConfigured(config.AUTH),
36
+ async authorize() {
37
+ const result = await authorize(authConfig);
38
+ return {
39
+ accessToken: result.accessToken,
40
+ refreshToken: result.refreshToken || undefined,
41
+ };
42
+ },
43
+ async refresh(refreshToken) {
44
+ try {
45
+ const result = await refresh(authConfig, { refreshToken });
46
+ return {
47
+ accessToken: result.accessToken,
48
+ refreshToken: result.refreshToken || undefined,
49
+ };
50
+ } catch (error) {
51
+ if (error instanceof AuthError) throw error;
52
+ // AppAuth's documented OAuth token error for a revoked/expired refresh grant.
53
+ const code =
54
+ typeof error === 'object' && error !== null && 'code' in error
55
+ ? error.code
56
+ : undefined;
57
+ throw new AuthError(
58
+ code === 'invalid_grant' ? 'expired' : 'unavailable',
59
+ undefined,
60
+ error,
61
+ );
62
+ }
63
+ },
64
+ };
@@ -0,0 +1,121 @@
1
+ import {
2
+ AuthError,
3
+ createSessionCoordinator,
4
+ type HostCapabilities,
5
+ type SessionSnapshot,
6
+ } from '@thanhphi/miniapp-sdk';
7
+ import type { AuthActions } from '../../../common/mini-app/types';
8
+
9
+ export interface OidcTokens {
10
+ accessToken: string;
11
+ refreshToken?: string;
12
+ }
13
+
14
+ export interface OidcOperations {
15
+ isConfigured: boolean;
16
+ authorize(): Promise<OidcTokens>;
17
+ refresh(refreshToken: string): Promise<OidcTokens>;
18
+ }
19
+
20
+ /** Standalone owns its session. No credential is written to Redux or disk. */
21
+ export function createStandaloneSession(operations: OidcOperations) {
22
+ let snapshot: SessionSnapshot = { sessionId: null, accessToken: null };
23
+ let refreshToken: string | undefined;
24
+ let generation = 0;
25
+ let disposed = false;
26
+ let loginPending: Promise<void> | undefined;
27
+ const listeners = new Set<(value: SessionSnapshot) => void>();
28
+ const publish = (next: SessionSnapshot) => {
29
+ snapshot = next;
30
+ for (const listener of listeners) listener({ ...snapshot });
31
+ };
32
+ const signOut = () => {
33
+ generation += 1;
34
+ refreshToken = undefined;
35
+ loginPending = undefined;
36
+ publish({ sessionId: null, accessToken: null });
37
+ };
38
+ const coordinator = createSessionCoordinator({
39
+ getSnapshot: () => ({ ...snapshot }),
40
+ async refresh(before) {
41
+ const credential = refreshToken;
42
+ if (!credential) throw new AuthError('expired');
43
+ const currentGeneration = generation;
44
+ const result = await operations.refresh(credential);
45
+ if (
46
+ disposed ||
47
+ currentGeneration !== generation ||
48
+ before.sessionId !== snapshot.sessionId
49
+ ) {
50
+ throw new AuthError('session-changed');
51
+ }
52
+ if (!result.accessToken) throw new AuthError('expired');
53
+ refreshToken = result.refreshToken || credential;
54
+ publish({ ...snapshot, accessToken: result.accessToken });
55
+ return result.accessToken;
56
+ },
57
+ });
58
+ const actions: AuthActions = {
59
+ isConfigured: operations.isConfigured,
60
+ signIn() {
61
+ if (disposed) return Promise.reject(new AuthError('unavailable'));
62
+ if (!operations.isConfigured) {
63
+ return Promise.reject(
64
+ new AuthError(
65
+ 'unavailable',
66
+ 'Hãy cấu hình OIDC trong env trước khi đăng nhập.',
67
+ ),
68
+ );
69
+ }
70
+ if (loginPending) return loginPending;
71
+ const attempt = ++generation;
72
+ const request = Promise.resolve().then(async () => {
73
+ if (disposed || attempt !== generation)
74
+ throw new AuthError('session-changed');
75
+ const result = await operations.authorize();
76
+ if (disposed || attempt !== generation)
77
+ throw new AuthError('session-changed');
78
+ if (!result.accessToken) throw new AuthError('unavailable');
79
+ refreshToken = result.refreshToken;
80
+ publish({
81
+ sessionId: `standalone-${attempt}`,
82
+ accessToken: result.accessToken,
83
+ });
84
+ });
85
+ loginPending = request;
86
+ const release = () => {
87
+ if (loginPending === request) loginPending = undefined;
88
+ };
89
+ request.then(release, release);
90
+ return request;
91
+ },
92
+ // Clears the local app session; provider-wide logout is a separate host policy.
93
+ signOut,
94
+ };
95
+ const capabilities: HostCapabilities = {
96
+ auth: {
97
+ getSnapshot: () => ({ ...snapshot }),
98
+ refreshIfNeeded: coordinator.refreshIfNeeded,
99
+ subscribe(listener) {
100
+ if (disposed) return () => {};
101
+ listeners.add(listener);
102
+ return () => {
103
+ listeners.delete(listener);
104
+ };
105
+ },
106
+ },
107
+ closeMiniApp: signOut,
108
+ };
109
+ return {
110
+ capabilities,
111
+ actions,
112
+ dispose() {
113
+ disposed = true;
114
+ generation += 1;
115
+ refreshToken = undefined;
116
+ snapshot = { sessionId: null, accessToken: null };
117
+ listeners.clear();
118
+ coordinator.dispose();
119
+ },
120
+ };
121
+ }
@@ -0,0 +1,46 @@
1
+ import React from 'react';
2
+ import {
3
+ DefaultTheme,
4
+ DarkTheme,
5
+ NavigationContainer,
6
+ NavigationIndependentTree,
7
+ } from '@react-navigation/native';
8
+ import { useAppSelector } from '../redux/hooks';
9
+ import {
10
+ selectIsAuthenticated,
11
+ selectSessionId,
12
+ } from '../modules/auth/redux/selectors';
13
+ import AuthenticationNavigator from '../modules/auth/navigation/AuthenticationNavigator';
14
+ import ModuleNavigator from '../modules/__MODULE__/navigation/ModuleNavigator';
15
+ import { useUnistyles } from 'react-native-unistyles';
16
+
17
+ export function MainStack() {
18
+ const {
19
+ theme: { type, color },
20
+ } = useUnistyles();
21
+ const base = type === 'dark' ? DarkTheme : DefaultTheme;
22
+ const navigationTheme = {
23
+ ...base,
24
+ colors: {
25
+ ...base.colors,
26
+ background: color.base3,
27
+ card: color.background,
28
+ primary: color.primary500,
29
+ text: color.content1,
30
+ border: color.divider,
31
+ notification: color.danger,
32
+ },
33
+ };
34
+ const authenticated = useAppSelector(selectIsAuthenticated);
35
+ const sessionId = useAppSelector(selectSessionId);
36
+ return (
37
+ <NavigationIndependentTree>
38
+ <NavigationContainer
39
+ key={sessionId ?? 'signed-out'}
40
+ theme={navigationTheme}
41
+ >
42
+ {authenticated ? <ModuleNavigator /> : <AuthenticationNavigator />}
43
+ </NavigationContainer>
44
+ </NavigationIndependentTree>
45
+ );
46
+ }
@@ -0,0 +1,8 @@
1
+ import { combineReducers, createAction } from '@reduxjs/toolkit';
2
+ import auth from '../modules/auth/redux/auth-slice';
3
+ import home from '../modules/__MODULE__/redux/home-slice';
4
+
5
+ export const resetApp = createAction('app/reset');
6
+ const combined = combineReducers({ auth, home });
7
+ export const rootReducer: typeof combined = (state, action) =>
8
+ combined(resetApp.match(action) ? undefined : state, action);
@@ -0,0 +1,5 @@
1
+ import { useDispatch, useSelector } from 'react-redux';
2
+ import type { AppDispatch, RootState } from './store';
3
+
4
+ export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
5
+ export const useAppSelector = useSelector.withTypes<RootState>();
@@ -0,0 +1,10 @@
1
+ import { configureStore } from '@reduxjs/toolkit';
2
+ import { rootReducer } from './RootReducers';
3
+
4
+ /** Factory: each mounted mini app has its own state. Tokens are never persisted. */
5
+ export function createAppStore() {
6
+ return configureStore({ reducer: rootReducer });
7
+ }
8
+ export type AppStore = ReturnType<typeof createAppStore>;
9
+ export type RootState = ReturnType<AppStore['getState']>;
10
+ export type AppDispatch = AppStore['dispatch'];
@@ -0,0 +1,87 @@
1
+ import { QueryClient } from '@tanstack/react-query';
2
+ import {
3
+ AuthError,
4
+ HttpError,
5
+ createMiniAppRuntime,
6
+ type HostCapabilities,
7
+ type SessionSnapshot,
8
+ } from '@thanhphi/miniapp-sdk';
9
+ import type { AuthActions } from '../common/mini-app/types';
10
+ import { createApiClient } from '../library/networking/client';
11
+ import { createAppStore } from '../redux/store';
12
+ import { resetApp } from '../redux/RootReducers';
13
+ import {
14
+ authenticationFailed,
15
+ sessionReceived,
16
+ } from '../modules/auth/redux/auth-slice';
17
+
18
+ export function createApplicationServices(
19
+ capabilities: HostCapabilities,
20
+ authActions?: AuthActions,
21
+ ) {
22
+ const runtime = createMiniAppRuntime(capabilities);
23
+ const store = createAppStore();
24
+ const queryClient = new QueryClient({
25
+ defaultOptions: {
26
+ queries: {
27
+ staleTime: 60_000,
28
+ retry: (count, error) =>
29
+ !(error instanceof AuthError) &&
30
+ !(
31
+ error instanceof HttpError &&
32
+ ['cancelled', 'disposed'].includes(error.kind)
33
+ ) &&
34
+ count < 1,
35
+ },
36
+ },
37
+ });
38
+ let session: SessionSnapshot | undefined;
39
+ const receiveSession = (next: SessionSnapshot) => {
40
+ if (
41
+ session?.sessionId !== next.sessionId ||
42
+ (session?.accessToken && !next.accessToken)
43
+ ) {
44
+ // Cancellation reaches SDK fetch through React Query's AbortSignal.
45
+ void queryClient.cancelQueries();
46
+ queryClient.clear();
47
+ store.dispatch(resetApp());
48
+ }
49
+ session = { ...next };
50
+ store.dispatch(
51
+ sessionReceived({
52
+ sessionId: next.sessionId,
53
+ authenticated: Boolean(next.sessionId && next.accessToken),
54
+ }),
55
+ );
56
+ };
57
+ const unsubscribe = runtime.auth.subscribe(receiveSession);
58
+ receiveSession(runtime.auth.getSnapshot());
59
+ const api = createApiClient(runtime.auth, (error, failedSessionId) => {
60
+ if (runtime.auth.getSnapshot().sessionId !== failedSessionId) return;
61
+ if (error.kind === 'expired') {
62
+ void queryClient.cancelQueries();
63
+ queryClient.clear();
64
+ authActions?.signOut();
65
+ store.dispatch(resetApp());
66
+ store.dispatch(
67
+ authenticationFailed('Phiên đăng nhập đã hết hạn. Hãy đăng nhập lại.'),
68
+ );
69
+ }
70
+ });
71
+ runtime.onDispose(() => {
72
+ unsubscribe();
73
+ api.dispose();
74
+ void queryClient.cancelQueries();
75
+ queryClient.clear();
76
+ store.dispatch(resetApp());
77
+ });
78
+ return {
79
+ runtime,
80
+ store,
81
+ queryClient,
82
+ api,
83
+ authActions,
84
+ dispose: runtime.dispose,
85
+ };
86
+ }
87
+ export type ApplicationServices = ReturnType<typeof createApplicationServices>;
@@ -0,0 +1,90 @@
1
+ import type { UIColors } from '@thanhphi/ui360';
2
+
3
+ export const darkColors = {
4
+ backdrop: 'rgba(0,0,0,0.5)',
5
+ background: '#18181B',
6
+ base2: '#27272A',
7
+ base3: '#27272A',
8
+ divider: '#3F3F46',
9
+ focus: '#006FEE',
10
+
11
+ content1: '#FAFAFA',
12
+ content2: '#ADAEB5',
13
+ content3: '#797A85',
14
+ content4: '#636470',
15
+
16
+ textPrimary: '#FFFFFF',
17
+
18
+ default: '#3F3F46',
19
+ primary: '#B50101',
20
+ secondary: '#F5A524',
21
+ success: '#17C964',
22
+ warning: '#F5A524',
23
+ danger: '#F5222D',
24
+
25
+ gray50: '#18181B',
26
+ gray100: '#27272A',
27
+ gray200: '#3F3F46',
28
+ gray300: '#52525B',
29
+ gray400: '#71717A',
30
+ gray500: '#A1A1AA',
31
+ gray600: '#D4D4D8',
32
+ gray700: '#E4E4E7',
33
+ gray800: '#F4F4F5',
34
+ gray900: '#FAFAFA',
35
+
36
+ primary50: '#240000',
37
+ primary100: '#480000',
38
+ primary200: '#5B0101',
39
+ primary300: '#6D0101',
40
+ primary400: '#910101',
41
+ primary500: '#B50101',
42
+ primary600: '#C43434',
43
+ primary700: '#D36767',
44
+ primary800: '#E19999',
45
+ primary900: '#F0CCCC',
46
+
47
+ secondary50: '#312107',
48
+ secondary100: '#62420E',
49
+ secondary200: '#7B5312',
50
+ secondary300: '#936316',
51
+ secondary400: '#C4841D',
52
+ secondary500: '#F5A524',
53
+ secondary600: '#F7B750',
54
+ secondary700: '#F9C97C',
55
+ secondary800: '#FBDBA7',
56
+ secondary900: '#FDEDD3',
57
+
58
+ success50: '#052814',
59
+ success100: '#095028',
60
+ success200: '#0E793C',
61
+ success300: '#12A150',
62
+ success400: '#17C964',
63
+ success500: '#45D483',
64
+ success600: '#74DFA2',
65
+ success700: '#A2E9C1',
66
+ success800: '#D1F4E0',
67
+ success900: '#E8FAF0',
68
+
69
+ warning50: '#312107',
70
+ warning100: '#62420E',
71
+ warning200: '#936316',
72
+ warning300: '#C4841D',
73
+ warning400: '#F5A524',
74
+ warning500: '#F7B750',
75
+ warning600: '#F9C97C',
76
+ warning700: '#FBDBA7',
77
+ warning800: '#FDEDD3',
78
+ warning900: '#FEFCE8',
79
+
80
+ danger50: '#310709',
81
+ danger100: '#620E12',
82
+ danger200: '#7B1117',
83
+ danger300: '#93141B',
84
+ danger400: '#C41B24',
85
+ danger500: '#F5222D',
86
+ danger600: '#F74E57',
87
+ danger700: '#F97A81',
88
+ danger800: '#FBA7AB',
89
+ danger900: '#FDD3D5',
90
+ } as const satisfies UIColors;
@@ -0,0 +1,90 @@
1
+ import type { UIColors } from '@thanhphi/ui360';
2
+
3
+ export const lightColors = {
4
+ backdrop: 'rgba(0,0,0,0.5)',
5
+ background: '#FFFFFF',
6
+ base2: '#FFF7F7',
7
+ base3: '#F7F7F7',
8
+ divider: '#F4F4F4',
9
+ focus: '#006FEE',
10
+
11
+ content1: '#232323',
12
+ content2: '#666666',
13
+ content3: '#AFB4B8',
14
+ content4: '#DBDFE2',
15
+
16
+ textPrimary: '#FFFFFF',
17
+
18
+ default: '#D4D4D8',
19
+ primary: '#B50101',
20
+ secondary: '#F5A524',
21
+ success: '#17C964',
22
+ warning: '#F5A524',
23
+ danger: '#F5222D',
24
+
25
+ gray50: '#FAFAFA',
26
+ gray100: '#F4F4F5',
27
+ gray200: '#E4E4E7',
28
+ gray300: '#D4D4D8',
29
+ gray400: '#A1A1AA',
30
+ gray500: '#71717A',
31
+ gray600: '#52525B',
32
+ gray700: '#3F3F46',
33
+ gray800: '#27272A',
34
+ gray900: '#18181B',
35
+
36
+ primary50: '#F8E6E6',
37
+ primary100: '#F0CCCC',
38
+ primary200: '#E19999',
39
+ primary300: '#D36767',
40
+ primary400: '#C43434',
41
+ primary500: '#B50101',
42
+ primary600: '#910101',
43
+ primary700: '#6D0101',
44
+ primary800: '#480000',
45
+ primary900: '#240000',
46
+
47
+ secondary50: '#FEF6E9',
48
+ secondary100: '#FDEDD3',
49
+ secondary200: '#FBDBA7',
50
+ secondary300: '#F9C97C',
51
+ secondary400: '#F7B750',
52
+ secondary500: '#F5A524',
53
+ secondary600: '#C4841D',
54
+ secondary700: '#936316',
55
+ secondary800: '#7B5312',
56
+ secondary900: '#62420E',
57
+
58
+ success50: '#E8FAF0',
59
+ success100: '#D1F4E0',
60
+ success200: '#A2E9C1',
61
+ success300: '#74DFA2',
62
+ success400: '#45D483',
63
+ success500: '#17C964',
64
+ success600: '#12A150',
65
+ success700: '#0E793C',
66
+ success800: '#095028',
67
+ success900: '#095028',
68
+
69
+ warning50: '#FEFCE8',
70
+ warning100: '#FDEDD3',
71
+ warning200: '#FBDBA7',
72
+ warning300: '#F9C97C',
73
+ warning400: '#F7B750',
74
+ warning500: '#F5A524',
75
+ warning600: '#C4841D',
76
+ warning700: '#936316',
77
+ warning800: '#62420E',
78
+ warning900: '#312107',
79
+
80
+ danger50: '#FEE9EA',
81
+ danger100: '#FDD3D5',
82
+ danger200: '#FBA7AB',
83
+ danger300: '#F97A81',
84
+ danger400: '#F74E57',
85
+ danger500: '#F5222D',
86
+ danger600: '#C41B24',
87
+ danger700: '#93141B',
88
+ danger800: '#620E12',
89
+ danger900: '#310709',
90
+ } as const satisfies UIColors;
@@ -0,0 +1,38 @@
1
+ import { lightColors } from './colors/light';
2
+ import { darkColors } from './colors/dark';
3
+ import { create360Theme, type UIThemeOptions } from '@thanhphi/ui360';
4
+ import { StyleSheet } from 'react-native-unistyles';
5
+ import { ratioW } from '../library/utils/responsive';
6
+
7
+ function createAppTheme(mode: 'light' | 'dark', options: UIThemeOptions = {}) {
8
+ const colors = mode === 'dark' ? darkColors : lightColors;
9
+ return create360Theme({
10
+ // Match the 360 host; default tokens are scaled exactly once during creation.
11
+ scale: ratioW(1),
12
+ ...options,
13
+ mode,
14
+ colors: { ...colors, ...options.colors },
15
+ });
16
+ }
17
+
18
+ // Pure theme values: importing a screen must never reconfigure the host runtime.
19
+ export const appThemes = {
20
+ light: createAppTheme('light'),
21
+ dark: createAppTheme('dark'),
22
+ };
23
+
24
+ /** Only standalone entries configure native themes; hosted App never calls this. */
25
+ export function configureStandaloneTheme(options: UIThemeOptions = {}) {
26
+ StyleSheet.configure({
27
+ themes: {
28
+ light: createAppTheme('light', options),
29
+ dark: createAppTheme('dark', options),
30
+ },
31
+ settings: { initialTheme: options.mode ?? 'light' },
32
+ });
33
+ }
34
+
35
+ type AppThemes = typeof appThemes;
36
+ declare module 'react-native-unistyles' {
37
+ export interface UnistylesThemes extends AppThemes {}
38
+ }
@@ -0,0 +1,6 @@
1
+ import type { UIThemeOptions } from '@thanhphi/ui360';
2
+ import { configureStandaloneTheme } from './index';
3
+
4
+ // Change dev-only palette/mode overrides here. Hosted App never imports this file.
5
+ export const standaloneThemeOptions: UIThemeOptions = { mode: 'light' };
6
+ configureStandaloneTheme(standaloneThemeOptions);
@@ -0,0 +1,12 @@
1
+ import { createUITheme } from '@thanhphi/ui360';
2
+ import { ratioW } from '../library/utils/responsive';
3
+ import { lightColors } from './colors/light';
4
+
5
+ // Only export geometry. The palette here never replaces the active host colors.
6
+ // Pure factory: no Unistyles registration, no dependency on host geometry.
7
+ const { spacing, radius, typography } = createUITheme({
8
+ colors: lightColors,
9
+ scale: ratioW(1),
10
+ });
11
+
12
+ export const uiGeometry = { spacing, radius, typography };
File without changes