14 changed files with 596 additions and 51 deletions
-
30next.config.ts
-
34src/app/api/proxy/route.ts
-
61src/app/intro/page.tsx
-
116src/app/layout.tsx
-
16src/app/loading.tsx
-
2src/app/providers.tsx
-
3src/app/questions-list/page.tsx
-
3src/components/questions/required-steps-card.tsx
-
25src/components/ui/report-actions-sheet.tsx
-
218src/lib/auth-bridge.ts
-
65src/lib/cookies.ts
-
23src/lib/http.ts
-
27src/lib/performance.ts
-
22src/types/window.d.ts
@ -0,0 +1,16 @@ |
|||||
|
export default function Loading() { |
||||
|
return ( |
||||
|
<div className="min-h-screen bg-white p-4 animate-pulse"> |
||||
|
<div className="h-8 bg-gray-200 rounded-lg w-3/4 mb-6"></div> |
||||
|
<div className="space-y-3"> |
||||
|
<div className="h-4 bg-gray-200 rounded w-full"></div> |
||||
|
<div className="h-4 bg-gray-200 rounded w-5/6"></div> |
||||
|
<div className="h-4 bg-gray-200 rounded w-4/6"></div> |
||||
|
</div> |
||||
|
<div className="mt-8 space-y-4"> |
||||
|
<div className="h-24 bg-gray-200 rounded-lg"></div> |
||||
|
<div className="h-24 bg-gray-200 rounded-lg"></div> |
||||
|
</div> |
||||
|
</div> |
||||
|
); |
||||
|
} |
||||
@ -0,0 +1,218 @@ |
|||||
|
import { getClientCookie } from "./cookies"; |
||||
|
|
||||
|
const TOKEN_COOKIE_NAME = "HABIB_TOKEN"; |
||||
|
const COINS_COOKIE_NAME = "HABIB_COINS"; |
||||
|
|
||||
|
function postFlutterMessage(payload: Record<string, unknown>) { |
||||
|
if (typeof window === "undefined") { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
const app = window.HabibApp; |
||||
|
if (!app?.postMessage) { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
app.postMessage(JSON.stringify(payload)); |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
class AuthBridge { |
||||
|
private token: string | null = null; |
||||
|
private coins = 0; |
||||
|
private isReady = false; |
||||
|
private loginRequested = false; |
||||
|
private readyCallbacks: Array<() => void> = []; |
||||
|
private pendingResolvers: Array<(token: string | null) => void> = []; |
||||
|
private flutterResponseUnsubscribe?: () => void; |
||||
|
|
||||
|
constructor() { |
||||
|
this.init(); |
||||
|
} |
||||
|
|
||||
|
private syncFromStorage() { |
||||
|
if (typeof window === "undefined") { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
const win = window as Window & { |
||||
|
HABIB_TOKEN?: string; |
||||
|
HABIB_COINS?: number; |
||||
|
}; |
||||
|
|
||||
|
const token = |
||||
|
win.HABIB_TOKEN ?? |
||||
|
getClientCookie(TOKEN_COOKIE_NAME) ?? |
||||
|
getClientCookie("habib_token"); |
||||
|
const coinsValue = win.HABIB_COINS; |
||||
|
const coinsCookie = |
||||
|
getClientCookie(COINS_COOKIE_NAME) ?? getClientCookie("habib_coins"); |
||||
|
|
||||
|
if (!token) { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
this.token = token; |
||||
|
this.coins = coinsValue ?? Number(coinsCookie ?? 0); |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
private setupFlutterResponseListener() { |
||||
|
if (typeof window === "undefined" || this.flutterResponseUnsubscribe) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const win = window as Window & { |
||||
|
addFlutterResponseListener?: ( |
||||
|
listener: (event: FlutterResponseEvent) => void, |
||||
|
) => () => void; |
||||
|
}; |
||||
|
|
||||
|
const attach = () => { |
||||
|
if (typeof win.addFlutterResponseListener !== "function") { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
this.flutterResponseUnsubscribe = win.addFlutterResponseListener((event) => { |
||||
|
if (event.action === "login") { |
||||
|
this.handleLoginResponse(event.success); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
return true; |
||||
|
}; |
||||
|
|
||||
|
if (attach()) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const fallbackInterval = window.setInterval(() => { |
||||
|
if (attach()) { |
||||
|
window.clearInterval(fallbackInterval); |
||||
|
} |
||||
|
}, 50); |
||||
|
} |
||||
|
|
||||
|
private handleLoginResponse(success: boolean) { |
||||
|
if (success) { |
||||
|
this.loginRequested = false; |
||||
|
|
||||
|
if (this.syncFromStorage()) { |
||||
|
this.markReady(this.token); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
console.warn( |
||||
|
"⚠️ Flutter login succeeded, but token is not available yet. Set cookie before emitting success.", |
||||
|
); |
||||
|
this.resolvePending(null); |
||||
|
this.markReady(null); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
this.loginRequested = false; |
||||
|
console.warn("⚠️ Flutter login failed"); |
||||
|
this.resolvePending(null); |
||||
|
this.markReady(null); |
||||
|
} |
||||
|
|
||||
|
private requestLogin() { |
||||
|
if (this.loginRequested || this.token) { |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
this.loginRequested = true; |
||||
|
const sent = postFlutterMessage({ action: "login" }); |
||||
|
|
||||
|
if (!sent) { |
||||
|
this.loginRequested = false; |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
private markReady(token: string | null) { |
||||
|
this.isReady = true; |
||||
|
this.resolvePending(token); |
||||
|
this.notifyReady(); |
||||
|
} |
||||
|
|
||||
|
private resolvePending(token: string | null) { |
||||
|
const resolvers = this.pendingResolvers.splice(0, this.pendingResolvers.length); |
||||
|
for (const resolve of resolvers) { |
||||
|
resolve(token); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private notifyReady() { |
||||
|
const callbacks = this.readyCallbacks.splice(0, this.readyCallbacks.length); |
||||
|
for (const callback of callbacks) { |
||||
|
callback(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private init() { |
||||
|
if (typeof window === "undefined") { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
this.setupFlutterResponseListener(); |
||||
|
|
||||
|
if (this.syncFromStorage()) { |
||||
|
this.markReady(this.token); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
this.isReady = true; |
||||
|
this.notifyReady(); |
||||
|
} |
||||
|
|
||||
|
public onReady(callback: () => void) { |
||||
|
if (this.isReady) { |
||||
|
callback(); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
this.readyCallbacks.push(callback); |
||||
|
} |
||||
|
|
||||
|
public async ensureToken() { |
||||
|
if (typeof window === "undefined") { |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
if (this.syncFromStorage()) { |
||||
|
return this.token; |
||||
|
} |
||||
|
|
||||
|
this.setupFlutterResponseListener(); |
||||
|
|
||||
|
return await new Promise<string | null>((resolve) => { |
||||
|
this.pendingResolvers.push(resolve); |
||||
|
if (!this.requestLogin()) { |
||||
|
this.resolvePending(null); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public getToken(): string | null { |
||||
|
if (this.token) { |
||||
|
return this.token; |
||||
|
} |
||||
|
|
||||
|
return ( |
||||
|
getClientCookie(TOKEN_COOKIE_NAME) ?? getClientCookie("habib_token") |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
public getCoins(): number { |
||||
|
return this.coins; |
||||
|
} |
||||
|
|
||||
|
public isAuthenticated(): boolean { |
||||
|
return !!this.getToken(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export const authBridge = new AuthBridge(); |
||||
@ -0,0 +1,65 @@ |
|||||
|
const DEFAULT_COOKIE_PATH = "/"; |
||||
|
const DEFAULT_COOKIE_MAX_AGE = 60 * 60 * 24 * 30; |
||||
|
|
||||
|
function isClient() { |
||||
|
return typeof document !== "undefined"; |
||||
|
} |
||||
|
|
||||
|
function escapeCookieName(name: string) { |
||||
|
return name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); |
||||
|
} |
||||
|
|
||||
|
export function getClientCookie(name: string) { |
||||
|
if (!isClient()) { |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
const match = document.cookie.match( |
||||
|
new RegExp(`(?:^|; )${escapeCookieName(name)}=([^;]*)`), |
||||
|
); |
||||
|
|
||||
|
return match ? decodeURIComponent(match[1]) : null; |
||||
|
} |
||||
|
|
||||
|
export function setClientCookie( |
||||
|
name: string, |
||||
|
value: string, |
||||
|
options?: { |
||||
|
maxAge?: number; |
||||
|
path?: string; |
||||
|
sameSite?: "Lax" | "Strict" | "None"; |
||||
|
secure?: boolean; |
||||
|
}, |
||||
|
) { |
||||
|
if (!isClient()) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const maxAge = options?.maxAge ?? DEFAULT_COOKIE_MAX_AGE; |
||||
|
const path = options?.path ?? DEFAULT_COOKIE_PATH; |
||||
|
const sameSite = options?.sameSite ?? "Lax"; |
||||
|
const secure = options?.secure ?? window.location.protocol === "https:"; |
||||
|
|
||||
|
let cookie = `${name}=${encodeURIComponent(value)}; Path=${path}; Max-Age=${maxAge}; SameSite=${sameSite}`; |
||||
|
|
||||
|
if (secure) { |
||||
|
cookie += "; Secure"; |
||||
|
} |
||||
|
|
||||
|
document.cookie = cookie; |
||||
|
} |
||||
|
|
||||
|
export function deleteClientCookie(name: string, path = DEFAULT_COOKIE_PATH) { |
||||
|
if (!isClient()) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const secure = window.location.protocol === "https:"; |
||||
|
let cookie = `${name}=; Path=${path}; Max-Age=0; SameSite=Lax`; |
||||
|
|
||||
|
if (secure) { |
||||
|
cookie += "; Secure"; |
||||
|
} |
||||
|
|
||||
|
document.cookie = cookie; |
||||
|
} |
||||
@ -0,0 +1,27 @@ |
|||||
|
export function measurePerformance() { |
||||
|
if (typeof window === 'undefined') return; |
||||
|
|
||||
|
window.addEventListener('load', () => { |
||||
|
const perfData = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming; |
||||
|
|
||||
|
const metrics = { |
||||
|
dns: perfData.domainLookupEnd - perfData.domainLookupStart, |
||||
|
tcp: perfData.connectEnd - perfData.connectStart, |
||||
|
ttfb: perfData.responseStart - perfData.requestStart, |
||||
|
download: perfData.responseEnd - perfData.responseStart, |
||||
|
domInteractive: perfData.domInteractive - perfData.fetchStart, |
||||
|
domComplete: perfData.domComplete - perfData.fetchStart, |
||||
|
loadComplete: perfData.loadEventEnd - perfData.fetchStart, |
||||
|
}; |
||||
|
|
||||
|
console.log('⏱️ Performance Metrics:', metrics); |
||||
|
|
||||
|
// ارسال به analytics (اختیاری)
|
||||
|
if ((window as any).HabibApp) { |
||||
|
(window as any).HabibApp.postMessage(JSON.stringify({ |
||||
|
action: 'performance_metrics', |
||||
|
data: metrics, |
||||
|
})); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
@ -0,0 +1,22 @@ |
|||||
|
declare global { |
||||
|
interface FlutterResponseEvent { |
||||
|
action: string; |
||||
|
success: boolean; |
||||
|
data?: { latitude: number; longitude: number }; |
||||
|
} |
||||
|
|
||||
|
interface Window { |
||||
|
HABIB_TOKEN?: string; |
||||
|
HABIB_COINS?: number; |
||||
|
onFlutterResponse?: (event: FlutterResponseEvent) => void; |
||||
|
__flutterResponseListeners?: Array<(event: FlutterResponseEvent) => void>; |
||||
|
addFlutterResponseListener?: ( |
||||
|
listener: (event: FlutterResponseEvent) => void, |
||||
|
) => () => void; |
||||
|
HabibApp?: { |
||||
|
postMessage: (message: string) => void; |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export {}; |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue