Fix Safari iOS reload loop with static device detection and caching
All checks were successful
Build And Push Image / docker (push) Successful in 3m7s

- Replace reactive device detection with static utilities to prevent
  infinite reload loops on mobile Safari
- Add static-device-detection.ts for one-time device info computation
- Add config-cache.ts for improved configuration loading performance
- Apply mobile Safari viewport and CSS optimizations across auth pages
- Remove reactive dependencies that caused rendering issues on iOS
This commit is contained in:
2025-08-10 15:18:34 +02:00
parent 4e53e7ea10
commit 30136117ce
6 changed files with 797 additions and 204 deletions

View File

@@ -163,8 +163,13 @@ definePageMeta({
middleware: 'guest'
});
// Static CSS classes based on device (no reactive dependencies)
const containerClasses = ref('password-setup-page');
import { getStaticDeviceInfo, getDeviceCssClasses, applyMobileSafariOptimizations, getMobileSafariViewportMeta } from '~/utils/static-device-detection';
// Static device detection - no reactive dependencies
const deviceInfo = getStaticDeviceInfo();
// Static CSS classes - computed once, never reactive
const containerClasses = ref(getDeviceCssClasses('password-setup-page'));
// Reactive state
const loading = ref(false);
@@ -238,7 +243,7 @@ useHead({
name: 'description',
content: 'Set your password to complete your MonacoUSA Portal registration.'
},
{ name: 'viewport', content: 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no' }
{ name: 'viewport', content: getMobileSafariViewportMeta() }
]
});
@@ -306,35 +311,20 @@ const setupPassword = async () => {
}
};
// Component initialization
// Component initialization - Safari iOS reload loop prevention
onMounted(() => {
console.log('[setup-password] Password setup page loaded for:', email.value);
// Static device detection from Nuxt Device Module - no reactive dependencies
const { isMobile, isIos, isSafari } = useDevice();
// Detect mobile Safari specifically
const isMobileSafari = isMobile && isIos && isSafari;
// Apply classes once (static, no reactivity)
const containerClassList = ['password-setup-page'];
if (isMobile) containerClassList.push('is-mobile');
if (isMobileSafari) containerClassList.push('is-mobile-safari');
if (isIos) containerClassList.push('is-ios');
containerClasses.value = containerClassList.join(' ');
// Apply mobile Safari optimizations early
if (deviceInfo.isMobileSafari) {
applyMobileSafariOptimizations();
console.log('[setup-password] Mobile Safari optimizations applied');
}
// Check if we have required parameters
if (!email.value) {
errorMessage.value = 'No email address provided. Please use the link from your verification email.';
}
// Prevent auto-zoom on iOS when focusing input fields
if (isIos) {
const metaViewport = document.querySelector('meta[name="viewport"]');
if (metaViewport) {
metaViewport.setAttribute('content', 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no');
}
}
});
</script>

View File

@@ -138,8 +138,13 @@ const error = ref('');
let verificationStarted = false;
let verificationComplete = false;
import { getStaticDeviceInfo, getDeviceCssClasses, applyMobileSafariOptimizations, getMobileSafariViewportMeta } from '~/utils/static-device-detection';
// Static device detection - no reactive dependencies
const deviceInfo = getStaticDeviceInfo();
// Static container classes - compute once to prevent re-renders
let containerClasses = 'verification-page';
let containerClasses = getDeviceCssClasses('verification-page');
// Set page title with mobile viewport optimization
useHead({
@@ -149,7 +154,7 @@ useHead({
name: 'description',
content: 'Verifying your email address for the MonacoUSA Portal.'
},
{ name: 'viewport', content: 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no' }
{ name: 'viewport', content: getMobileSafariViewportMeta() }
]
});
@@ -236,24 +241,15 @@ const retryVerification = () => {
verifyEmail();
};
// Initialize mobile detection and classes AFTER component is stable
// Component initialization - Safari iOS reload loop prevention
onMounted(() => {
// Only set mobile classes once to prevent re-renders
if (typeof window !== 'undefined') {
const userAgent = navigator.userAgent;
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent);
const isIOS = /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream;
const isSafari = /^((?!chrome|android).)*safari/i.test(userAgent);
const isMobileSafari = isIOS && isSafari;
const classes = ['verification-page'];
if (isMobile) classes.push('is-mobile');
if (isMobileSafari) classes.push('is-mobile-safari');
if (isIOS) classes.push('is-ios');
containerClasses = classes.join(' ');
}
console.log('[auth/verify] Component mounted with token:', token?.substring(0, 20) + '...');
// Apply mobile Safari optimizations early
if (deviceInfo.isMobileSafari) {
applyMobileSafariOptimizations();
console.log('[auth/verify] Mobile Safari optimizations applied');
}
// Start verification process with a small delay to ensure stability
setTimeout(() => {

View File

@@ -28,7 +28,6 @@
<v-col cols="12" sm="6">
<v-text-field
v-model="firstName"
:key="'first-name-field'"
name="firstName"
autocomplete="given-name"
label="First Name"
@@ -42,7 +41,6 @@
<v-col cols="12" sm="6">
<v-text-field
v-model="lastName"
:key="'last-name-field'"
name="lastName"
autocomplete="family-name"
label="Last Name"
@@ -56,7 +54,6 @@
<v-text-field
v-model="email"
:key="'email-field'"
name="email"
autocomplete="email"
label="Email Address"
@@ -70,7 +67,6 @@
<PhoneInputWrapper
v-model="phone"
:key="'phone-field'"
label="Phone Number"
:rules="phoneRules"
:disabled="loading"
@@ -79,7 +75,6 @@
<v-text-field
v-model="dateOfBirth"
:key="'dob-field'"
label="Date of Birth"
type="date"
:rules="dobRules"
@@ -91,7 +86,6 @@
<v-textarea
v-model="address"
:key="'address-field'"
name="address"
autocomplete="street-address"
label="Address"
@@ -105,13 +99,22 @@
<MultipleNationalityInput
v-model="nationality"
:key="'nationality-field'"
label="Nationality"
:rules="nationalityRules"
:disabled="loading"
required
/>
<!-- reCAPTCHA v2 Checkbox (only if configured) -->
<div v-if="!!recaptchaSiteKey" class="mb-4">
<div
ref="recaptchaContainer"
class="g-recaptcha"
:data-sitekey="recaptchaSiteKey"
data-callback="onRecaptchaVerified"
data-expired-callback="onRecaptchaExpired"
></div>
</div>
<!-- Error Alert -->
<v-alert
@@ -126,13 +129,12 @@
{{ errorMessage }}
</v-alert>
<v-btn
type="submit"
color="primary"
size="large"
block
:disabled="!valid || loading"
:disabled="!valid || loading || (!!recaptchaSiteKey && !recaptchaToken)"
:loading="loading"
class="mb-4"
style="background-color: #a31515 !important; color: white !important;"
@@ -160,25 +162,25 @@
<span class="text-body-2 font-weight-bold" style="color: #000 !important;">Amount:</span>
</v-col>
<v-col cols="8">
<span class="text-body-2" style="color: #000 !important;">{{ registrationConfig.membershipFee }}/year</span>
<span class="text-body-2" style="color: #000 !important;">{{ membershipFee }}/year</span>
</v-col>
</v-row>
<v-row dense class="mb-2" v-if="registrationConfig.iban">
<v-row dense class="mb-2" v-if="iban">
<v-col cols="4">
<span class="text-body-2 font-weight-bold" style="color: #000 !important;">IBAN:</span>
</v-col>
<v-col cols="8">
<span class="text-body-2 font-family-monospace" style="color: #000 !important;">{{ registrationConfig.iban }}</span>
<span class="text-body-2 font-family-monospace" style="color: #000 !important;">{{ iban }}</span>
</v-col>
</v-row>
<v-row dense class="mb-2" v-if="registrationConfig.accountHolder">
<v-row dense class="mb-2" v-if="accountHolder">
<v-col cols="4">
<span class="text-body-2 font-weight-bold" style="color: #000 !important;">Account:</span>
</v-col>
<v-col cols="8">
<span class="text-body-2" style="color: #000 !important;">{{ registrationConfig.accountHolder }}</span>
<span class="text-body-2" style="color: #000 !important;">{{ accountHolder }}</span>
</v-col>
</v-row>
@@ -201,40 +203,46 @@
<RegistrationSuccessDialog
v-model="showSuccessDialog"
:member-data="registrationResult"
:payment-info="registrationConfig"
:payment-info="{ membershipFee, iban, accountHolder }"
@go-to-login="goToLogin"
/>
</div>
</template>
<script setup lang="ts">
import type { RegistrationFormData, RecaptchaConfig, RegistrationConfig } from '~/utils/types';
// Declare global grecaptcha interface for TypeScript
declare global {
interface Window {
grecaptcha: {
ready: (callback: () => void) => void;
execute: (siteKey: string, options: { action: string }) => Promise<string>;
};
}
}
import type { RegistrationFormData } from '~/utils/types';
import { getStaticDeviceInfo, getDeviceCssClasses, applyMobileSafariOptimizations, getMobileSafariViewportMeta } from '~/utils/static-device-detection';
import { loadAllConfigs } from '~/utils/config-cache';
// Page metadata
definePageMeta({
layout: false
});
// Static device detection - no reactive dependencies
const deviceInfo = getStaticDeviceInfo();
// Configs with fallback defaults
const recaptchaConfig = ref<RecaptchaConfig>({ siteKey: '', secretKey: '' });
const registrationConfig = ref<RegistrationConfig>({
membershipFee: 150,
iban: 'MC58 1756 9000 0104 0050 1001 860',
accountHolder: 'ASSOCIATION MONACO USA'
// Head configuration with static device-optimized viewport
useHead({
title: 'Register - MonacoUSA Portal',
meta: [
{ name: 'description', content: 'Register to become a member of MonacoUSA Association' },
{ name: 'robots', content: 'noindex, nofollow' },
{ name: 'viewport', content: getMobileSafariViewportMeta() }
]
});
// Reactive data - Using individual refs to prevent Vue reactivity corruption
// Static CSS classes - computed once, never reactive
const containerClasses = ref(getDeviceCssClasses('signup-container'));
const cardClasses = ref(() => {
const classes = ['signup-card'];
if (deviceInfo.isMobileSafari) {
classes.push('performance-optimized', 'no-backdrop-filter');
}
return classes.join(' ');
});
// Form data - individual refs to prevent Vue reactivity corruption
const firstName = ref('');
const lastName = ref('');
const email = ref('');
@@ -243,6 +251,24 @@ const dateOfBirth = ref('');
const address = ref('');
const nationality = ref('');
// Form state
const valid = ref(false);
const loading = ref(false);
const errorMessage = ref('');
// reCAPTCHA v2 state (static, not reactive)
let recaptchaSiteKey = '';
const recaptchaToken = ref('');
// Registration config (static, not reactive)
let membershipFee = 150;
let iban = 'MC58 1756 9000 0104 0050 1001 860';
let accountHolder = 'ASSOCIATION MONACO USA';
// Success dialog state
const showSuccessDialog = ref(false);
const registrationResult = ref<{ memberId: string; email: string } | undefined>(undefined);
// Computed property to create form object for submission
const form = computed(() => ({
first_name: firstName.value,
@@ -254,31 +280,6 @@ const form = computed(() => ({
nationality: nationality.value
}));
const valid = ref(false);
const loading = ref(false);
const recaptchaToken = ref('');
const successMessage = ref('');
const errorMessage = ref('');
const configsLoaded = ref(false);
// Success dialog state
const showSuccessDialog = ref(false);
const registrationResult = ref<{ memberId: string; email: string } | undefined>(undefined);
// Head configuration
useHead({
title: 'Register - MonacoUSA Portal',
meta: [
{ name: 'description', content: 'Register to become a member of MonacoUSA Association' },
{ name: 'robots', content: 'noindex, nofollow' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no' }
]
});
// Static CSS classes based on device (no reactive dependencies)
const containerClasses = ref('signup-container');
const cardClasses = ref('signup-card');
// Form validation rules
const nameRules = [
(v: string) => !!v || 'Name is required',
@@ -321,39 +322,39 @@ const nationalityRules = [
(v: string) => !!v || 'Nationality is required'
];
// reCAPTCHA handling
function onRecaptchaVerified(token: string) {
recaptchaToken.value = token;
// reCAPTCHA v2 callbacks - global functions for Google's API
function setupRecaptchaCallbacks() {
if (typeof window === 'undefined') return;
(window as any).onRecaptchaVerified = (token: string) => {
console.log('[signup] reCAPTCHA verified');
recaptchaToken.value = token;
};
(window as any).onRecaptchaExpired = () => {
console.log('[signup] reCAPTCHA expired');
recaptchaToken.value = '';
};
}
function onRecaptchaExpired() {
recaptchaToken.value = '';
}
// Template refs
const recaptcha = ref<any>(null);
// reCAPTCHA v3 token generation
async function generateRecaptchaToken(): Promise<string> {
if (!recaptchaConfig.value.siteKey || typeof window === 'undefined') {
return '';
// Load reCAPTCHA v2 script
function loadRecaptchaScript(siteKey: string) {
if (typeof window === 'undefined' || !siteKey) return;
// Check if already loaded
if (document.querySelector('script[src*="recaptcha/api.js"]')) {
return;
}
return new Promise((resolve) => {
if (window.grecaptcha && window.grecaptcha.ready) {
window.grecaptcha.ready(() => {
window.grecaptcha.execute(recaptchaConfig.value.siteKey, { action: 'registration' }).then((token: string) => {
resolve(token);
}).catch(() => {
resolve('');
});
});
} else {
resolve('');
}
});
console.log('[signup] Loading reCAPTCHA v2 script');
const script = document.createElement('script');
script.src = 'https://www.google.com/recaptcha/api.js';
script.async = true;
script.defer = true;
script.onload = () => {
console.log('[signup] reCAPTCHA v2 script loaded');
};
document.head.appendChild(script);
}
// Form submission
@@ -364,36 +365,32 @@ async function submitRegistration() {
loading.value = true;
errorMessage.value = '';
successMessage.value = '';
try {
// Generate reCAPTCHA v3 token if configured
let token = '';
if (recaptchaConfig.value.siteKey) {
token = await generateRecaptchaToken();
}
const registrationData: RegistrationFormData = {
...form.value,
recaptcha_token: token
recaptcha_token: recaptchaToken.value || ''
};
console.log('[signup] Submitting registration...');
const response = await $fetch('/api/registration', {
method: 'POST',
body: registrationData
}) as any;
if (response?.success) {
console.log('[signup] Registration successful');
// Set registration result data for dialog
registrationResult.value = {
memberId: response.data?.memberId || 'N/A',
email: form.value.email
};
// Show success dialog instead of just alert
// Show success dialog
showSuccessDialog.value = true;
// Reset form by resetting individual refs
// Reset form
firstName.value = '';
lastName.value = '';
email.value = '';
@@ -401,10 +398,20 @@ async function submitRegistration() {
dateOfBirth.value = '';
address.value = '';
nationality.value = '';
recaptchaToken.value = '';
// Reset reCAPTCHA widget if present
if (typeof window !== 'undefined' && (window as any).grecaptcha) {
try {
(window as any).grecaptcha.reset();
} catch (e) {
console.debug('[signup] Could not reset reCAPTCHA:', e);
}
}
}
} catch (error: any) {
console.error('Registration failed:', error);
console.error('[signup] Registration failed:', error);
errorMessage.value = error.data?.message || error.message || 'Registration failed. Please try again.';
} finally {
loading.value = false;
@@ -416,79 +423,66 @@ const goToLogin = () => {
navigateTo('/login');
};
// Load reCAPTCHA script dynamically
function loadRecaptchaScript(siteKey: string) {
if (typeof window === 'undefined' || document.querySelector(`script[src*="recaptcha/api.js"]`)) {
return; // Already loaded or not in browser
}
// Flag to prevent multiple initialization calls
let initialized = false;
const script = document.createElement('script');
script.src = `https://www.google.com/recaptcha/api.js?render=${siteKey}`;
script.async = true;
script.defer = true;
document.head.appendChild(script);
}
// Simplified initialization - prevent reload loops
// Component initialization - Safari iOS reload loop prevention
onMounted(async () => {
// Prevent multiple initializations
if (typeof window === 'undefined') return;
// Static device detection from Nuxt Device Module - no reactive dependencies
const { isMobile, isIos, isSafari } = useDevice();
// Detect mobile Safari specifically
const isMobileSafari = isMobile && isIos && isSafari;
// Apply classes once (static, no reactivity)
const containerClassList = ['signup-container'];
if (isMobile) containerClassList.push('is-mobile');
if (isMobileSafari) containerClassList.push('is-mobile-safari');
if (isIos) containerClassList.push('is-ios');
containerClasses.value = containerClassList.join(' ');
if (initialized || typeof window === 'undefined') return;
initialized = true;
const cardClassList = ['signup-card'];
if (isMobileSafari) {
cardClassList.push('performance-optimized');
cardClassList.push('no-backdrop-filter');
console.log('[signup] Initializing signup page...');
// Apply mobile Safari optimizations early
if (deviceInfo.isMobileSafari) {
applyMobileSafariOptimizations();
console.log('[signup] Mobile Safari optimizations applied');
}
cardClasses.value = cardClassList.filter(Boolean).join(' ');
// Set up reCAPTCHA callbacks before loading configs
setupRecaptchaCallbacks();
try {
// Load reCAPTCHA config
$fetch('/api/recaptcha-config')
.then((response: any) => {
if (response?.success && response?.data?.siteKey) {
recaptchaConfig.value.siteKey = response.data.siteKey;
loadRecaptchaScript(response.data.siteKey);
}
})
.catch(() => {
// Silently fail for reCAPTCHA - not critical
console.debug('reCAPTCHA config not available');
});
// Load configs using cached utility (prevents repeated API calls)
console.log('[signup] Loading configurations...');
const configs = await loadAllConfigs();
// Load registration config
$fetch('/api/registration-config')
.then((response: any) => {
if (response?.success) {
registrationConfig.value = response.data;
}
})
.catch(() => {
// Use defaults if config fails to load
console.debug('Using default registration config');
registrationConfig.value = {
membershipFee: 150,
iban: 'MC58 1756 9000 0104 0050 1001 860',
accountHolder: 'ASSOCIATION MONACO USA'
};
});
// Set static config values
if (configs.recaptcha?.siteKey) {
recaptchaSiteKey = configs.recaptcha.siteKey;
console.log('[signup] reCAPTCHA configured');
// Load reCAPTCHA v2 script with delay to prevent render blocking
setTimeout(() => {
loadRecaptchaScript(recaptchaSiteKey);
}, 100);
}
if (configs.registration) {
membershipFee = configs.registration.membershipFee || 150;
iban = configs.registration.iban || 'MC58 1756 9000 0104 0050 1001 860';
accountHolder = configs.registration.accountHolder || 'ASSOCIATION MONACO USA';
console.log('[signup] Registration config loaded');
}
} catch (error) {
// Prevent any errors from bubbling up and causing reload
console.warn('Signup page initialization error:', error);
console.warn('[signup] Configuration loading error (using defaults):', error);
// Use default values which are already set above
}
console.log('[signup] Signup page initialization complete');
});
// Cleanup on component unmount
onUnmounted(() => {
// Clean up global reCAPTCHA callbacks
if (typeof window !== 'undefined') {
delete (window as any).onRecaptchaVerified;
delete (window as any).onRecaptchaExpired;
}
initialized = false;
});
</script>
@@ -515,7 +509,7 @@ onMounted(async () => {
background-attachment: scroll !important; /* Force scroll attachment */
}
.signup-container.performance-mode {
.signup-container.performance-optimized {
will-change: auto; /* Reduce repainting */
transform: translateZ(0); /* Force hardware acceleration but lighter */
}
@@ -539,7 +533,7 @@ onMounted(async () => {
}
/* Performance mode background - simpler for mobile */
.signup-container.performance-mode::before {
.signup-container.performance-optimized::before {
background: linear-gradient(rgba(163, 21, 21, 0.8), rgba(0, 0, 0, 0.6));
/* Remove background image on low-performance devices */
}
@@ -591,6 +585,19 @@ onMounted(async () => {
color: #000 !important;
}
/* reCAPTCHA v2 styling */
.g-recaptcha {
transform: scale(0.9);
transform-origin: 0 0;
margin-bottom: 10px;
}
@media (max-width: 600px) {
.g-recaptcha {
transform: scale(0.8);
}
}
/* Custom scrollbar for mobile */
::-webkit-scrollbar {
width: 4px;