@@ -6,13 +6,27 @@ import { useAuth } from "../../context/AuthContext";
66const GOOGLE_CLIENT_ID =
77 "82985006549-i2dn8m92d760lsdcsvj4sao19a4e46et.apps.googleusercontent.com" ;
88
9- // Minimal type for Google Identity Services (loaded from script tag)
109interface GoogleAccountsId {
1110 initialize : ( config : {
1211 client_id : string ;
1312 callback : ( response : { credential : string } ) => void ;
13+ use_fedcm_for_prompt ?: boolean ;
14+ auto_select ?: boolean ;
15+ cancel_on_tap_outside ?: boolean ;
1416 } ) => void ;
1517 prompt : ( ) => void ;
18+ renderButton : (
19+ parent : HTMLElement ,
20+ options : {
21+ type ?: "standard" | "icon" ;
22+ theme ?: "outline" | "filled_blue" | "filled_black" ;
23+ size ?: "small" | "medium" | "large" ;
24+ shape ?: "rectangular" | "pill" | "circle" | "square" ;
25+ text ?: "signin_with" | "signup_with" | "continue_with" | "signin" ;
26+ logo_alignment ?: "left" | "center" ;
27+ width ?: number | string ;
28+ } ,
29+ ) => void ;
1630}
1731
1832declare global {
@@ -27,15 +41,35 @@ declare global {
2741
2842const GSI_SRC = "https://accounts.google.com/gsi/client" ;
2943
44+ // Single shared promise so multiple mounts / remounts do not append duplicate
45+ // <script> tags. Duplicates were the root cause of the "Failed to load Google
46+ // Sign-In script" error: each mount appended a new tag, and the onload/onerror
47+ // of the *second* tag never fired reliably in some browsers, leaving us in a
48+ // perpetual "loading" state that surfaced as the Hebrew error message.
49+ let gsiLoadPromise : Promise < void > | null = null ;
50+
3051function loadGoogleScript ( ) : Promise < void > {
31- return new Promise ( ( resolve , reject ) => {
32- if ( document . querySelector ( `script[src="${ GSI_SRC } "]` ) ) {
33- // Already loaded or loading — wait a tick
34- if ( window . google ?. accounts ?. id ) {
35- resolve ( ) ;
36- return ;
37- }
52+ if ( gsiLoadPromise ) return gsiLoadPromise ;
53+
54+ gsiLoadPromise = new Promise < void > ( ( resolve , reject ) => {
55+ if ( window . google ?. accounts ?. id ) {
56+ resolve ( ) ;
57+ return ;
58+ }
59+
60+ const existing = document . querySelector < HTMLScriptElement > (
61+ `script[src="${ GSI_SRC } "]` ,
62+ ) ;
63+ if ( existing ) {
64+ existing . addEventListener ( "load" , ( ) => resolve ( ) , { once : true } ) ;
65+ existing . addEventListener (
66+ "error" ,
67+ ( ) => reject ( new Error ( "Failed to load Google Sign-In script" ) ) ,
68+ { once : true } ,
69+ ) ;
70+ return ;
3871 }
72+
3973 const script = document . createElement ( "script" ) ;
4074 script . src = GSI_SRC ;
4175 script . async = true ;
@@ -44,6 +78,14 @@ function loadGoogleScript(): Promise<void> {
4478 script . onerror = ( ) => reject ( new Error ( "Failed to load Google Sign-In script" ) ) ;
4579 document . head . appendChild ( script ) ;
4680 } ) ;
81+
82+ // If it rejects, allow a later mount to try again rather than being
83+ // permanently stuck on the cached rejection.
84+ gsiLoadPromise . catch ( ( ) => {
85+ gsiLoadPromise = null ;
86+ } ) ;
87+
88+ return gsiLoadPromise ;
4789}
4890
4991interface SocialLoginButtonsProps {
@@ -56,13 +98,19 @@ export function SocialLoginButtons({ label = "התחברות באמצעות", on
5698 const navigate = useNavigate ( ) ;
5799 const [ loading , setLoading ] = useState ( false ) ;
58100 const [ error , setError ] = useState < string | null > ( null ) ;
59- const initializedRef = useRef ( false ) ;
101+ const [ ready , setReady ] = useState ( false ) ;
102+ const buttonHostRef = useRef < HTMLDivElement | null > ( null ) ;
60103
61104 useEffect ( ( ) => {
105+ let cancelled = false ;
106+
62107 loadGoogleScript ( )
63108 . then ( ( ) => {
64- if ( initializedRef . current ) return ;
65- if ( ! window . google ?. accounts ?. id ) return ;
109+ if ( cancelled ) return ;
110+ if ( ! window . google ?. accounts ?. id ) {
111+ setError ( "לא ניתן לטעון את שירות Google" ) ;
112+ return ;
113+ }
66114
67115 window . google . accounts . id . initialize ( {
68116 client_id : GOOGLE_CLIENT_ID ,
@@ -81,20 +129,31 @@ export function SocialLoginButtons({ label = "התחברות באמצעות", on
81129 setLoading ( false ) ;
82130 }
83131 } ,
132+ use_fedcm_for_prompt : true ,
133+ auto_select : false ,
134+ cancel_on_tap_outside : true ,
84135 } ) ;
85- initializedRef . current = true ;
136+
137+ if ( buttonHostRef . current ) {
138+ buttonHostRef . current . innerHTML = "" ;
139+ window . google . accounts . id . renderButton ( buttonHostRef . current , {
140+ type : "icon" ,
141+ theme : "outline" ,
142+ size : "large" ,
143+ shape : "rectangular" ,
144+ } ) ;
145+ }
146+
147+ setReady ( true ) ;
86148 } )
87- . catch ( ( ) => setError ( "לא ניתן לטעון את שירות Google" ) ) ;
88- } , [ loginWithGoogle , navigate , onSuccess ] ) ;
149+ . catch ( ( ) => {
150+ if ( ! cancelled ) setError ( "לא ניתן לטעון את שירות Google" ) ;
151+ } ) ;
89152
90- const handleGoogleClick = ( ) => {
91- if ( ! window . google ?. accounts ?. id ) {
92- setError ( "שירות Google עדיין לא מוכן, נסו שוב" ) ;
93- return ;
94- }
95- setError ( null ) ;
96- window . google . accounts . id . prompt ( ) ;
97- } ;
153+ return ( ) => {
154+ cancelled = true ;
155+ } ;
156+ } , [ loginWithGoogle , navigate , onSuccess ] ) ;
98157
99158 return (
100159 < div className = "flex flex-col items-center gap-4" >
@@ -103,37 +162,40 @@ export function SocialLoginButtons({ label = "התחברות באמצעות", on
103162 </ p >
104163 < div className = "flex gap-3 justify-center" >
105164 { /* Google — the only provider enabled right now.
106- Facebook and Apple are intentionally commented out until we add server-side verification for them. */ }
107- < button
108- type = "button"
109- onClick = { handleGoogleClick }
110- disabled = { loading }
111- aria-label = "התחברות עם Google"
112- className = "bg-white border border-[#dadce0] hover:bg-[#f8f9fa] transition-colors w-[60px] h-[44px] rounded-[10px] flex items-center justify-center cursor-pointer shadow-[0px_1px_3px_rgba(0,0,0,0.08)] disabled:opacity-60"
113- >
114- { /* Official Google 'G' mark */ }
115- < svg width = "22" height = "22" viewBox = "0 0 48 48" aria-hidden = "true" >
116- < path fill = "#EA4335" d = "M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z" />
117- < path fill = "#4285F4" d = "M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z" />
118- < path fill = "#FBBC05" d = "M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z" />
119- < path fill = "#34A853" d = "M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z" />
120- </ svg >
121- </ button >
165+ Facebook and Apple are intentionally commented out until we add server-side verification for them.
166+
167+ Implementation note: Google Identity Services' `prompt()` call is unreliable
168+ on modern browsers (FedCM, third-party cookie blockers, incognito). We use
169+ `renderButton()` instead, then visually overlay our branded button on top of
170+ the Google-rendered element so clicks fall through to Google's handler.
171+ This is the pattern Google recommends and it works across browsers. */ }
172+ < div className = "relative w-[60px] h-[44px]" >
173+ { /* Branded visual — pointer-events:none so clicks hit the Google button below */ }
174+ < div
175+ aria-hidden = "true"
176+ className = { `absolute inset-0 pointer-events-none bg-white border border-[#dadce0] rounded-[10px] flex items-center justify-center shadow-[0px_1px_3px_rgba(0,0,0,0.08)] ${
177+ ! ready || loading ? "opacity-60" : ""
178+ } `}
179+ >
180+ < svg width = "22" height = "22" viewBox = "0 0 48 48" aria-hidden = "true" >
181+ < path fill = "#EA4335" d = "M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z" />
182+ < path fill = "#4285F4" d = "M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z" />
183+ < path fill = "#FBBC05" d = "M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z" />
184+ < path fill = "#34A853" d = "M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z" />
185+ </ svg >
186+ </ div >
187+ { /* Google-rendered clickable surface — transparent, sits on top, handles auth */ }
188+ < div
189+ ref = { buttonHostRef }
190+ aria-label = "התחברות עם Google"
191+ className = "absolute inset-0 opacity-0"
192+ style = { { colorScheme : "light" } }
193+ />
194+ </ div >
122195
123196 { /*
124197 // Facebook — disabled until server-side Facebook token verification is added.
125- <button className="bg-[#ececec] w-[60px] h-[44px] rounded-[10px] flex items-center justify-center cursor-pointer">
126- <svg width="24" height="24" viewBox="0 0 24 24" fill="none">
127- <path d={svgPaths.pb8cc300} fill="black" />
128- </svg>
129- </button>
130-
131198 // Apple — disabled until server-side Apple token verification (JWKS + private key) is added.
132- <button className="bg-[#ececec] w-[60px] h-[44px] rounded-[10px] flex items-center justify-center cursor-pointer">
133- <svg width="24" height="24" viewBox="0 0 24 24" fill="none">
134- <path d={svgPaths.p1a1f100} fill="black" />
135- </svg>
136- </button>
137199 */ }
138200 </ div >
139201 { loading && (
0 commit comments