Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
F
finwise-miniapp-fe
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ThinhNC
finwise-miniapp-fe
Commits
2981f315
Commit
2981f315
authored
Sep 13, 2026
by
ThinhNC
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix(fe): optimize mobile viewport scaling and ensure persistent auth session
parent
dd816551
Changes
12
Show whitespace changes
Inline
Side-by-side
Showing
12 changed files
with
145 additions
and
87 deletions
+145
-87
layout.tsx
src/components/layout.tsx
+44
-7
Badge.tsx
src/components/ui/Badge.tsx
+1
-1
Button.tsx
src/components/ui/Button.tsx
+1
-1
Card.tsx
src/components/ui/Card.tsx
+1
-1
app.scss
src/css/app.scss
+13
-3
api-client.ts
src/lib/api-client.ts
+0
-1
login.tsx
src/pages/auth/login.tsx
+4
-4
register.tsx
src/pages/auth/register.tsx
+4
-4
index.tsx
src/pages/index.tsx
+9
-9
index.tsx
src/pages/profile/index.tsx
+49
-46
auth.service.ts
src/services/auth.service.ts
+5
-0
auth-store.ts
src/stores/auth-store.ts
+14
-10
No files found.
src/components/layout.tsx
View file @
2981f315
...
...
@@ -11,6 +11,7 @@ import { QueryClientProvider } from "@tanstack/react-query";
import
{
queryClient
}
from
"@/lib/query-client"
;
import
{
useAuthStore
}
from
"@/stores/auth-store"
;
import
{
authService
}
from
"@/services/auth.service"
;
import
{
safeStorage
}
from
"@/lib/storage"
;
import
{
AuthGuard
}
from
"@/components/shared/AuthGuard"
;
import
{
DocumentTitle
}
from
"@/components/shared/DocumentTitle"
;
import
{
ThemeControl
}
from
"@/components/shared/ThemeControl"
;
...
...
@@ -78,16 +79,52 @@ const SubscriptionsRedirect: React.FC = () => {
};
const
AuthInitializer
:
React
.
FC
<
{
children
:
React
.
ReactNode
}
>
=
({
children
})
=>
{
const
{
setAuth
,
clearAuth
,
setInitialized
}
=
useAuthStore
();
const
{
setAuth
,
setUser
,
clearAuth
,
setInitialized
}
=
useAuthStore
();
useEffect
(()
=>
{
const
initAuth
=
async
()
=>
{
try
{
const
storedAccessToken
=
safeStorage
.
getItem
(
"accessToken"
);
const
storedRefreshToken
=
safeStorage
.
getItem
(
"refreshToken"
);
// If no tokens exist in storage, user is not logged in
if
(
!
storedAccessToken
&&
!
storedRefreshToken
)
{
clearAuth
();
return
;
}
// Try getting current user profile with stored access token
try
{
const
response
=
await
authService
.
getMe
();
if
(
response
.
success
&&
response
.
data
)
{
// Access tokens are primarily managed via HTTP-only cookies in development/production
setAuth
(
response
.
data
,
""
,
""
);
setUser
(
response
.
data
);
return
;
}
}
catch
{
// If getMe failed (e.g. 401 token expired after 30 mins), attempt refresh with storedRefreshToken
if
(
storedRefreshToken
)
{
try
{
const
refreshResponse
=
await
authService
.
refresh
({
refreshToken
:
storedRefreshToken
});
if
(
refreshResponse
.
success
&&
refreshResponse
.
data
)
{
const
{
accessToken
:
newAccess
,
refreshToken
:
newRefresh
,
user
}
=
refreshResponse
.
data
;
setAuth
(
user
||
undefined
,
newAccess
,
newRefresh
||
storedRefreshToken
);
if
(
!
user
)
{
const
meResponse
=
await
authService
.
getMe
();
if
(
meResponse
.
success
&&
meResponse
.
data
)
{
setUser
(
meResponse
.
data
);
return
;
}
}
else
{
return
;
}
}
}
catch
{
// Refresh token is expired (after 7 days) or invalid
clearAuth
();
return
;
}
}
clearAuth
();
}
}
catch
{
...
...
@@ -98,7 +135,7 @@ const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children })
};
initAuth
();
},
[
clearAuth
,
setAuth
,
setInitialized
]);
},
[
clearAuth
,
setAuth
,
set
User
,
set
Initialized
]);
return
<>
{
children
}
</>;
};
...
...
src/components/ui/Badge.tsx
View file @
2981f315
...
...
@@ -23,7 +23,7 @@ export const Badge: React.FC<BadgeProps> = ({
return
(
<
span
className=
{
`
inline-flex items-center px-
3 py-1
font-nunito font-bold text-xs rounded-full
inline-flex items-center px-
2.5 py-0.5
font-nunito font-bold text-xs rounded-full
${typeStyles[type]}
${className}
`
}
...
...
src/components/ui/Button.tsx
View file @
2981f315
...
...
@@ -38,7 +38,7 @@ export const Button: React.FC<ButtonProps> = ({
return
(
<
button
className=
{
`
inline-flex items-center justify-center font-baloo font-semibold text-
lg px-6
py-2.5
inline-flex items-center justify-center font-baloo font-semibold text-
sm sm:text-base px-4 sm:px-5 py-2 sm:
py-2.5
transition-all duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-clay-primary/35 select-none
${variantStyles[variant]}
${shapeStyles[shape]}
...
...
src/components/ui/Card.tsx
View file @
2981f315
...
...
@@ -33,7 +33,7 @@ export const Card: React.FC<CardProps> = ({
onClick=
{
onClick
}
onKeyDown=
{
isClickable
||
onKeyDown
?
handleKeyDown
:
undefined
}
className=
{
`
bg-clay-surface rounded-clay-lg shadow-clay-raised p-
6
bg-clay-surface rounded-clay-lg shadow-clay-raised p-
4 sm:p-5
transition-all duration-200 ease-in-out border border-clay-highlight/40
${hoverable || isClickable ? "hover:shadow-clay-hover hover:-translate-y-[2px] cursor-pointer" : ""}
${className}
...
...
src/css/app.scss
View file @
2981f315
...
...
@@ -100,11 +100,17 @@
}
html
{
font-size
:
1
6
px
;
font-size
:
1
5
px
;
-webkit-text-size-adjust
:
100%
;
text-size-adjust
:
100%
;
}
@media
(
min-width
:
400px
)
{
html
{
font-size
:
16px
;
}
}
html
,
body
,
#app
{
...
...
@@ -114,7 +120,7 @@ body,
body
{
font-family
:
"Nunito"
,
sans-serif
;
font-size
:
1
6px
;
font-size
:
1
rem
;
margin
:
0
;
color
:
rgb
(
var
(
--
color-clay-text
));
-webkit-font-smoothing
:
antialiased
;
...
...
@@ -130,7 +136,11 @@ body {
padding
:
calc
(
var
(
--
zaui-safe-area-inset-top
,
env
(
safe-area-inset-top
,
0px
))
+
60px
)
16px
96px
;
14px
96px
;
@media
(
min-width
:
400px
)
{
padding-left
:
16px
;
padding-right
:
16px
;
}
background-color
:
rgb
(
var
(
--
color-clay-bg
));
min-height
:
100vh
;
color
:
rgb
(
var
(
--
color-clay-text
));
...
...
src/lib/api-client.ts
View file @
2981f315
...
...
@@ -73,7 +73,6 @@ apiClient.interceptors.response.use(
originalRequest
.
url
?.
includes
(
'/auth/forgot-password'
)
||
originalRequest
.
url
?.
includes
(
'/auth/reset-password'
)
||
originalRequest
.
url
?.
includes
(
'/auth/zalo-login'
)
||
originalRequest
.
url
?.
includes
(
'/auth/me'
)
||
originalRequest
.
url
?.
includes
(
'/auth/logout'
);
// Check if the error is 401, not an auth endpoint, and the request hasn't been retried yet
...
...
src/pages/auth/login.tsx
View file @
2981f315
...
...
@@ -112,11 +112,11 @@ const LoginPage: React.FC = () => {
<
div
className=
"w-full max-w-sm mx-auto flex flex-col gap-5 pt-2"
>
{
/* Top: Branding */
}
<
div
className=
"flex flex-col items-center gap-2
.5
text-center"
>
<
Logo
size=
{
72
}
alt=
"FinWise"
/>
<
div
className=
"flex flex-col items-center gap-2 text-center"
>
<
Logo
size=
{
64
}
alt=
"FinWise"
/>
<
div
className=
"space-y-1"
>
<
h1
className=
"clay-title-h1 text-clay-primary"
>
FinWise
</
h1
>
<
p
className=
"clay-caption"
>
{
t
(
"auth.login.subtitle"
)
}
</
p
>
<
h1
className=
"clay-title-h1 text-clay-primary
text-2xl sm:text-3xl
"
>
FinWise
</
h1
>
<
p
className=
"clay-caption
text-xs sm:text-sm whitespace-nowrap overflow-hidden text-ellipsis
"
>
{
t
(
"auth.login.subtitle"
)
}
</
p
>
</
div
>
</
div
>
...
...
src/pages/auth/register.tsx
View file @
2981f315
...
...
@@ -90,12 +90,12 @@ const RegisterPage: React.FC = () => {
<
Header
title=
{
t
(
"auth.register.header"
)
}
showBackIcon=
{
true
}
onBackClick=
{
()
=>
navigate
(
"/login"
)
}
/>
<
IconGradients
/>
<
div
className=
"w-full max-w-sm mx-auto flex flex-col gap-6 p
x-2 p
t-2"
>
<
div
className=
"flex flex-col items-center gap-2
.5
text-center"
>
<
div
className=
"w-full max-w-sm mx-auto flex flex-col gap-6 pt-2"
>
<
div
className=
"flex flex-col items-center gap-2 text-center"
>
<
Logo
size=
{
64
}
alt=
"FinWise"
/>
<
div
className=
"space-y-1"
>
<
h1
className=
"clay-title-h1 text-clay-primary"
>
{
t
(
"auth.register.title"
)
}
</
h1
>
<
p
className=
"clay-caption"
>
{
t
(
"auth.register.subtitle"
)
}
</
p
>
<
h1
className=
"clay-title-h1 text-clay-primary
text-2xl sm:text-3xl
"
>
{
t
(
"auth.register.title"
)
}
</
h1
>
<
p
className=
"clay-caption
text-xs sm:text-sm whitespace-nowrap overflow-hidden text-ellipsis
"
>
{
t
(
"auth.register.subtitle"
)
}
</
p
>
</
div
>
</
div
>
...
...
src/pages/index.tsx
View file @
2981f315
...
...
@@ -104,26 +104,26 @@ function HomePage() {
<
Header
title=
{
t
(
"home.header"
)
}
showBackIcon=
{
false
}
/>
<
IconGradients
/>
<
div
className=
"flex flex-col items-center justify-start gap-
4 pt-2 pb-6
"
>
<
div
className=
"flex flex-col items-center justify-start gap-
3.5 pt-2 pb-5
"
>
{
/* Logo/Avatar Area */
}
<
div
className=
"relative"
>
<
Avatar
size=
"
lg
"
size=
"
md
"
src=
{
user
?.
avatarUrl
||
""
}
positionX=
{
user
?.
avatarPositionX
}
positionY=
{
user
?.
avatarPositionY
}
className=
"border-clay-primary shadow-clay-hover"
className=
"border-clay-primary shadow-clay-hover
!w-16 !h-16 sm:!w-20 sm:!h-20
"
/>
<
div
className=
"absolute -bottom-
2 -right-2 bg-clay-primary text-clay-on-primary p-2
rounded-full shadow-clay-raised border border-clay-highlight"
>
<
AIAssistantIcon
size=
{
20
}
/>
<
div
className=
"absolute -bottom-
1 -right-1 bg-clay-primary text-clay-on-primary p-1.5
rounded-full shadow-clay-raised border border-clay-highlight"
>
<
AIAssistantIcon
size=
{
18
}
/>
</
div
>
</
div
>
{
/* Text Area */
}
<
div
className=
"text-center space-y-1
.5
max-w-sm mx-auto"
>
<
h1
className=
"clay-title-h1 text-clay-primary"
>
{
t
(
"home.greeting"
,
{
name
:
user
?.
fullName
||
t
(
"common.user"
)
})
}
</
h1
>
<
h2
className=
"
clay-title-h3 text-clay-text [text-wrap:balance]
"
>
{
t
(
"home.subtitle"
)
}
</
h2
>
<
p
className=
"clay-caption max-w-xs mx-auto"
>
<
div
className=
"text-center space-y-1 max-w-sm mx-auto"
>
<
h1
className=
"clay-title-h1 text-clay-primary
text-xl sm:text-2xl
"
>
{
t
(
"home.greeting"
,
{
name
:
user
?.
fullName
||
t
(
"common.user"
)
})
}
</
h1
>
<
h2
className=
"
font-baloo font-bold text-clay-text text-[12.5px] min-[390px]:text-sm sm:text-base tracking-tight whitespace-nowrap overflow-hidden text-ellipsis
"
>
{
t
(
"home.subtitle"
)
}
</
h2
>
<
p
className=
"clay-caption max-w-xs mx-auto
text-xs sm:text-sm
"
>
{
t
(
"home.account"
)
}
<
span
className=
"font-semibold text-clay-primary"
>
{
user
?.
email
}
</
span
>
</
p
>
</
div
>
...
...
src/pages/profile/index.tsx
View file @
2981f315
...
...
@@ -434,41 +434,44 @@ const ProfilePage: React.FC = () => {
<
div
className=
"flex flex-col gap-6 mt-4 pb-20"
>
{
/* User Card Header */
}
<
Card
className=
"flex items-start gap-4 py-4 bg-clay-surface border border-clay-highlight/50"
>
<
div
className=
"relative h-20 w-20 flex-none self-center sm:self-start"
>
<
Card
className=
"p-4 sm:p-5 bg-clay-surface border border-clay-highlight/50"
>
<
div
className=
"flex items-center gap-3.5"
>
<
div
className=
"relative h-16 w-16 sm:h-20 sm:w-20 flex-none"
>
<
button
type=
"button"
aria
-
label=
{
t
(
"profile.editAvatar"
)
}
onClick=
{
openAvatarEditor
}
className=
"flex h-20
w-20 items-center justify-center rounded-full p-0 leading-none transition-all duration-200 ease-in-out focus:outline-none focus:ring-4 focus:ring-clay-primary/25 active:scale-95"
className=
"flex h-16 w-16 sm:h-20 sm:
w-20 items-center justify-center rounded-full p-0 leading-none transition-all duration-200 ease-in-out focus:outline-none focus:ring-4 focus:ring-clay-primary/25 active:scale-95"
>
<
Avatar
size=
"lg
"
size=
"md
"
src=
{
user
?.
avatarUrl
||
""
}
positionX=
{
user
?.
avatarPositionX
}
positionY=
{
user
?.
avatarPositionY
}
className=
"pointer-events-none border-clay-primary shadow-clay-hover
"
className=
"pointer-events-none border-clay-primary shadow-clay-hover !w-16 !h-16 sm:!w-20 sm:!h-20
"
/>
</
button
>
<
span
className=
"pointer-events-none absolute bottom-0 right-0 flex h-7
w-7 items-center justify-center rounded-full border-2 border-clay-highlight bg-clay-primary text-clay-on-primary shadow-clay-raised transition-all duration-200 ease-in-out"
>
<
svg
aria
-
hidden=
"true"
viewBox=
"0 0 24 24"
className=
"h-4
w-4"
fill=
"none"
stroke=
"currentColor"
strokeWidth=
"2.5"
strokeLinecap=
"round"
strokeLinejoin=
"round"
>
<
span
className=
"pointer-events-none absolute bottom-0 right-0 flex h-6 w-6 sm:h-7 sm:
w-7 items-center justify-center rounded-full border-2 border-clay-highlight bg-clay-primary text-clay-on-primary shadow-clay-raised transition-all duration-200 ease-in-out"
>
<
svg
aria
-
hidden=
"true"
viewBox=
"0 0 24 24"
className=
"h-3.5 w-3.5 sm:h-4 sm:
w-4"
fill=
"none"
stroke=
"currentColor"
strokeWidth=
"2.5"
strokeLinecap=
"round"
strokeLinejoin=
"round"
>
<
path
d=
"M14.5 4 20 9.5 9 20H4v-5Z"
/>
<
path
d=
"m12.5 6 5.5 5.5"
/>
</
svg
>
</
span
>
</
div
>
<
div
className=
"flex-1 min-w-0 space-y-1.5"
>
<
div
>
<
h2
className=
"clay-title-h2 truncate text-clay-primary"
>
{
user
?.
fullName
||
t
(
"profile.notSet"
)
}
</
h2
>
<
p
className=
"clay-caption truncate text-clay-text-muted"
>
{
user
?.
email
||
"—"
}
</
p
>
<
div
className=
"flex-1 min-w-0 space-y-1"
>
<
h2
className=
"clay-title-h2 truncate text-clay-primary text-base sm:text-lg font-bold"
>
{
user
?.
fullName
||
t
(
"profile.notSet"
)
}
</
h2
>
<
p
className=
"clay-caption truncate text-clay-text-muted text-xs"
>
{
user
?.
email
||
"—"
}
</
p
>
<
div
className=
"flex items-center gap-1.5 pt-0.5"
>
<
Badge
type=
"primary"
className=
"text-[10.5px] px-2 py-0.5 whitespace-nowrap"
>
{
user
?.
role
?.
name
||
"USER"
}
</
Badge
>
{
user
?.
isActive
&&
<
Badge
type=
"income"
className=
"text-[10.5px] px-2 py-0.5 whitespace-nowrap"
>
{
t
(
"common.active"
)
}
</
Badge
>
}
</
div
>
<
div
className=
"flex flex-wrap items-center gap-1.5"
>
<
Badge
type=
"primary"
>
{
user
?.
role
?.
name
||
"USER"
}
</
Badge
>
{
user
?.
isActive
&&
<
Badge
type=
"income"
>
{
t
(
"common.active"
)
}
</
Badge
>
}
</
div
>
<
div
className=
"flex flex-col gap-1 border-t border-clay-highlight/40 pt-2 text-xs"
>
<
div
className=
"flex items-center gap-1.5 min-w-0"
>
</
div
>
<
div
className=
"mt-3 pt-2.5 border-t border-clay-highlight/40 flex flex-col gap-1 text-xs"
>
<
div
className=
"flex items-center justify-between min-w-0"
>
<
span
className=
"font-semibold text-clay-text-muted shrink-0"
>
{
t
(
"profile.userId"
)
}
:
</
span
>
<
div
className=
"flex items-center gap-1 min-w-0"
>
<
span
className=
"font-mono text-[11px] text-clay-text truncate select-all"
title=
{
user
?.
id
}
>
{
user
?.
id
||
"—"
}
</
span
>
...
...
@@ -478,7 +481,7 @@ const ProfilePage: React.FC = () => {
onClick=
{
handleCopyId
}
aria
-
label=
{
t
(
"profile.copyId"
)
}
title=
{
t
(
"profile.copyId"
)
}
className=
"
ml-auto
inline-flex shrink-0 items-center justify-center rounded-clay-sm p-1 text-clay-text-muted transition-all duration-200 ease-in-out hover:bg-clay-primary/10 hover:text-clay-primary active:scale-95"
className=
"inline-flex shrink-0 items-center justify-center rounded-clay-sm p-1 text-clay-text-muted transition-all duration-200 ease-in-out hover:bg-clay-primary/10 hover:text-clay-primary active:scale-95"
>
{
isIdCopied
?
(
<
CheckIcon
size=
{
14
}
className=
"text-clay-income"
/>
...
...
@@ -488,7 +491,8 @@ const ProfilePage: React.FC = () => {
</
button
>
)
}
</
div
>
<
div
className=
"flex items-center gap-1.5"
>
</
div
>
<
div
className=
"flex items-center justify-between min-w-0"
>
<
span
className=
"font-semibold text-clay-text-muted shrink-0"
>
{
t
(
"profile.createdAt"
)
}
:
</
span
>
<
span
className=
"text-[11px] font-medium text-clay-text"
>
{
user
?.
createdAt
...
...
@@ -501,7 +505,6 @@ const ProfilePage: React.FC = () => {
</
span
>
</
div
>
</
div
>
</
div
>
</
Card
>
...
...
src/services/auth.service.ts
View file @
2981f315
...
...
@@ -34,6 +34,11 @@ export const authService = {
return
response
.
data
;
},
async
refresh
(
data
?:
{
refreshToken
?:
string
}):
Promise
<
ApiResponse
<
{
user
?:
User
;
accessToken
:
string
;
refreshToken
?:
string
}
>>
{
const
response
=
await
apiClient
.
post
(
"/auth/refresh"
,
data
||
{});
return
response
.
data
;
},
async
logout
():
Promise
<
ApiResponse
>
{
const
response
=
await
apiClient
.
post
(
"/auth/logout"
,
{});
return
response
.
data
;
...
...
src/stores/auth-store.ts
View file @
2981f315
...
...
@@ -10,7 +10,7 @@ interface AuthState {
refreshToken
:
string
|
null
;
isAuthenticated
:
boolean
;
isInitialized
:
boolean
;
setAuth
:
(
user
:
User
|
null
,
accessToken
:
string
,
refreshToken
:
string
)
=>
void
;
setAuth
:
(
user
?:
User
|
null
,
accessToken
?:
string
,
refreshToken
?
:
string
)
=>
void
;
clearAuth
:
()
=>
void
;
setUser
:
(
user
:
User
|
null
)
=>
void
;
setInitialized
:
(
initialized
:
boolean
)
=>
void
;
...
...
@@ -28,14 +28,18 @@ export const useAuthStore = create<AuthState>((set) => {
isAuthenticated
:
!!
accessToken
,
isInitialized
:
false
,
setAuth
:
(
user
,
accessToken
,
refreshToken
)
=>
{
if
(
accessToken
)
{
safeStorage
.
setItem
(
"accessToken"
,
accessToken
);
}
if
(
refreshToken
)
{
safeStorage
.
setItem
(
"refreshToken"
,
refreshToken
);
set
({
user
,
accessToken
,
refreshToken
,
isAuthenticated
:
true
,
});
}
set
((
state
)
=>
({
user
:
user
!==
undefined
?
user
:
state
.
user
,
accessToken
:
accessToken
||
state
.
accessToken
,
refreshToken
:
refreshToken
||
state
.
refreshToken
,
isAuthenticated
:
Boolean
(
accessToken
||
state
.
accessToken
||
user
||
state
.
user
),
}));
},
clearAuth
:
()
=>
{
safeStorage
.
removeItem
(
"accessToken"
);
...
...
@@ -50,7 +54,7 @@ export const useAuthStore = create<AuthState>((set) => {
});
},
setUser
:
(
user
)
=>
{
set
(
{
user
}
);
set
(
(
state
)
=>
({
user
,
isAuthenticated
:
Boolean
(
user
||
state
.
accessToken
)
})
);
},
setInitialized
:
(
isInitialized
)
=>
{
set
({
isInitialized
});
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment