14 changed files with 596 additions and 51 deletions
-
30next.config.ts
-
36src/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