Refactor mobile detection to use unified composable
Build And Push Image / docker (push) Successful in 2m52s Details

- Add useMobileDetection composable to centralize device detection logic
- Replace direct utility imports with composable usage across components
- Update MultipleNationalityInput, PhoneInputWrapper, and auth pages
- Simplify mobile-specific styling and behavior handling
- Improve code maintainability by consolidating detection logic
This commit is contained in:
Matt 2025-08-09 19:27:15 +02:00
parent d14008efd4
commit 2b2cd5891f
8 changed files with 310 additions and 352 deletions

View File

@ -214,12 +214,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getAllCountries, getCountryName } from '~/utils/countries'; import { getAllCountries, searchCountries } from '~/utils/countries';
import { import { useMobileDetection } from '~/composables/useMobileDetection';
getDeviceInfo,
needsPerformanceOptimization,
getOptimizedClasses
} from '~/utils/mobile-safari-utils';
interface Props { interface Props {
modelValue?: string; // Comma-separated string like "FR,MC,US" modelValue?: string; // Comma-separated string like "FR,MC,US"
@ -245,10 +241,10 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>(); const emit = defineEmits<Emits>();
// Mobile Safari detection // Use unified mobile detection
const deviceInfo = ref(getDeviceInfo()); const mobileDetection = useMobileDetection();
const isMobileSafari = computed(() => deviceInfo.value.isMobileSafari); const isMobileSafari = computed(() => mobileDetection.isMobileSafari);
const needsPerformanceMode = computed(() => needsPerformanceOptimization()); const needsPerformanceMode = computed(() => mobileDetection.isMobileSafari || mobileDetection.isMobile);
// Parse initial nationalities from comma-separated string // Parse initial nationalities from comma-separated string
const parseNationalities = (value: string): string[] => { const parseNationalities = (value: string): string[] => {
@ -344,6 +340,14 @@ const updateNationalities = () => {
emit('update:modelValue', result); emit('update:modelValue', result);
}; };
// Helper methods
const getCountryName = (countryCode: string): string => {
if (!countryCode) return '';
const countries = getAllCountries();
const country = countries.find(c => c.code === countryCode);
return country?.name || '';
};
// Mobile Safari specific methods // Mobile Safari specific methods
const getSelectedCountryName = (countryCode: string): string => { const getSelectedCountryName = (countryCode: string): string => {
if (!countryCode) return ''; if (!countryCode) return '';

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="phone-input-wrapper" :class="{ 'phone-input-wrapper--mobile': isMobile }"> <div class="phone-input-wrapper" :class="{ 'phone-input-wrapper--mobile': mobileDetection.isMobile }">
<v-text-field <v-text-field
v-model="localNumber" v-model="localNumber"
:label="label" :label="label"
@ -11,7 +11,7 @@
:required="required" :required="required"
:disabled="disabled" :disabled="disabled"
variant="outlined" variant="outlined"
:density="isMobile ? 'default' : 'comfortable'" :density="mobileDetection.isMobile ? 'default' : 'comfortable'"
class="phone-text-field" class="phone-text-field"
@input="handleInput" @input="handleInput"
@blur="handleBlur" @blur="handleBlur"
@ -21,13 +21,11 @@
<v-menu <v-menu
v-model="dropdownOpen" v-model="dropdownOpen"
:close-on-content-click="false" :close-on-content-click="false"
:location="isMobile ? undefined : 'bottom start'" location="bottom start"
:offset="isMobile ? 0 : 4" :offset="4"
:min-width="isMobile ? undefined : '280'" :min-width="mobileDetection.isMobile ? '90vw' : '280'"
:transition="isMobile ? 'fade-transition' : 'fade-transition'" transition="fade-transition"
:no-click-animation="isMobile" :no-click-animation="true"
:attach="isMobile"
:strategy="isMobile ? 'fixed' : 'absolute'"
> >
<template #activator="{ props: menuProps }"> <template #activator="{ props: menuProps }">
<div <div
@ -35,7 +33,7 @@
class="country-selector" class="country-selector"
:class="{ :class="{
'country-selector--open': dropdownOpen, 'country-selector--open': dropdownOpen,
'country-selector--mobile': isMobile 'country-selector--mobile': mobileDetection.isMobile
}" }"
> >
<img <img
@ -46,7 +44,7 @@
/> />
<span class="country-code">{{ selectedCountry.dialCode }}</span> <span class="country-code">{{ selectedCountry.dialCode }}</span>
<v-icon <v-icon
:size="isMobile ? 18 : 16" :size="mobileDetection.isMobile ? 18 : 16"
class="dropdown-icon" class="dropdown-icon"
:class="{ 'dropdown-icon--rotated': dropdownOpen }" :class="{ 'dropdown-icon--rotated': dropdownOpen }"
> >
@ -55,23 +53,14 @@
</div> </div>
</template> </template>
<!-- Mobile Full-Screen Overlay -->
<div
v-if="isMobile && dropdownOpen"
class="mobile-overlay"
@click="closeDropdown"
@touchstart="handleOverlayTouch"
/>
<!-- Dropdown Content --> <!-- Dropdown Content -->
<v-card <v-card
class="country-dropdown" class="country-dropdown"
:class="{ 'country-dropdown--mobile': isMobile }" :class="{ 'country-dropdown--mobile': mobileDetection.isMobile }"
elevation="8" :elevation="mobileDetection.isMobile ? 24 : 8"
:style="isMobile ? 'display: block !important;' : ''"
> >
<!-- Mobile Header --> <!-- Mobile Header -->
<div v-if="isMobile" class="mobile-header"> <div v-if="mobileDetection.isMobile" class="mobile-header">
<h3 class="mobile-title">Select Country</h3> <h3 class="mobile-title">Select Country</h3>
<v-btn <v-btn
icon="mdi-close" icon="mdi-close"
@ -88,11 +77,11 @@
v-model="searchQuery" v-model="searchQuery"
placeholder="Search countries..." placeholder="Search countries..."
variant="outlined" variant="outlined"
:density="isMobile ? 'default' : 'compact'" :density="mobileDetection.isMobile ? 'default' : 'compact'"
prepend-inner-icon="mdi-magnify" prepend-inner-icon="mdi-magnify"
hide-details hide-details
class="search-input" class="search-input"
:autofocus="!isMobile" :autofocus="!mobileDetection.isMobile"
clearable clearable
/> />
</div> </div>
@ -100,8 +89,8 @@
<!-- Country List --> <!-- Country List -->
<v-list <v-list
class="country-list" class="country-list"
:class="{ 'country-list--mobile': isMobile }" :class="{ 'country-list--mobile': mobileDetection.isMobile }"
:density="isMobile ? 'default' : 'compact'" :density="mobileDetection.isMobile ? 'default' : 'compact'"
> >
<v-list-item <v-list-item
v-for="country in filteredCountries" v-for="country in filteredCountries"
@ -110,25 +99,24 @@
'country-item': true, 'country-item': true,
'country-item--selected': country.iso2 === selectedCountry.iso2, 'country-item--selected': country.iso2 === selectedCountry.iso2,
'country-item--preferred': isPreferredCountry(country.iso2), 'country-item--preferred': isPreferredCountry(country.iso2),
'country-item--mobile': isMobile 'country-item--mobile': mobileDetection.isMobile
}" }"
@click="selectCountry(country)" @click="selectCountry(country)"
@touchstart="handleCountryTouch" :ripple="mobileDetection.isMobile"
:ripple="isMobile"
> >
<template #prepend> <template #prepend>
<img <img
:src="getCountryFlagUrl(country.iso2)" :src="getCountryFlagUrl(country.iso2)"
:alt="`${country.name} flag`" :alt="`${country.name} flag`"
class="list-flag" class="list-flag"
:class="{ 'list-flag--mobile': isMobile }" :class="{ 'list-flag--mobile': mobileDetection.isMobile }"
@error="handleFlagError" @error="handleFlagError"
/> />
</template> </template>
<v-list-item-title <v-list-item-title
class="country-name" class="country-name"
:class="{ 'country-name--mobile': isMobile }" :class="{ 'country-name--mobile': mobileDetection.isMobile }"
> >
{{ country.name }} {{ country.name }}
</v-list-item-title> </v-list-item-title>
@ -136,7 +124,7 @@
<template #append> <template #append>
<span <span
class="dial-code" class="dial-code"
:class="{ 'dial-code--mobile': isMobile }" :class="{ 'dial-code--mobile': mobileDetection.isMobile }"
> >
{{ country.dialCode }} {{ country.dialCode }}
</span> </span>
@ -145,7 +133,7 @@
</v-list> </v-list>
<!-- Mobile Footer --> <!-- Mobile Footer -->
<div v-if="isMobile" class="mobile-footer"> <div v-if="mobileDetection.isMobile" class="mobile-footer">
<v-btn <v-btn
block block
variant="text" variant="text"
@ -165,6 +153,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { parsePhoneNumber, AsYouType } from 'libphonenumber-js'; import { parsePhoneNumber, AsYouType } from 'libphonenumber-js';
import { getPhoneCountriesWithPreferred, searchPhoneCountries, getPhoneCountryByCode, type PhoneCountry } from '~/utils/phone-countries'; import { getPhoneCountriesWithPreferred, searchPhoneCountries, getPhoneCountryByCode, type PhoneCountry } from '~/utils/phone-countries';
import { useMobileDetection } from '~/composables/useMobileDetection';
interface Props { interface Props {
modelValue?: string; modelValue?: string;
@ -197,6 +186,9 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>(); const emit = defineEmits<Emits>();
// Use unified mobile detection
const mobileDetection = useMobileDetection();
// Get comprehensive countries list // Get comprehensive countries list
const countries = getPhoneCountriesWithPreferred(props.preferredCountries); const countries = getPhoneCountriesWithPreferred(props.preferredCountries);
@ -208,26 +200,9 @@ const selectedCountry = ref<PhoneCountry>(
getPhoneCountryByCode(props.defaultCountry) || countries[0] getPhoneCountryByCode(props.defaultCountry) || countries[0]
); );
// Mobile detection
const isMobile = ref(false);
// Computed // Computed
const flagUrl = computed(() => getCountryFlagUrl(selectedCountry.value.iso2)); const flagUrl = computed(() => getCountryFlagUrl(selectedCountry.value.iso2));
// Mobile detection on mount
onMounted(() => {
const checkMobile = () => {
isMobile.value = window.innerWidth <= 768 || 'ontouchstart' in window;
};
checkMobile();
window.addEventListener('resize', checkMobile);
onUnmounted(() => {
window.removeEventListener('resize', checkMobile);
});
});
const filteredCountries = computed(() => { const filteredCountries = computed(() => {
return searchPhoneCountries(searchQuery.value, props.preferredCountries); return searchPhoneCountries(searchQuery.value, props.preferredCountries);
}); });
@ -241,18 +216,10 @@ const isPreferredCountry = (iso2: string) => {
return props.preferredCountries.includes(iso2); return props.preferredCountries.includes(iso2);
}; };
const toggleDropdown = () => {
if (!props.disabled) {
dropdownOpen.value = !dropdownOpen.value;
if (dropdownOpen.value) {
searchQuery.value = '';
}
}
};
const selectCountry = (country: PhoneCountry) => { const selectCountry = (country: PhoneCountry) => {
selectedCountry.value = country; selectedCountry.value = country;
dropdownOpen.value = false; dropdownOpen.value = false;
searchQuery.value = ''; // Clear search on selection
emit('country-changed', country); emit('country-changed', country);
// Reformat existing number with new country // Reformat existing number with new country
@ -314,25 +281,6 @@ const closeDropdown = () => {
searchQuery.value = ''; searchQuery.value = '';
}; };
const handleTouchStart = (event: TouchEvent) => {
// Allow natural touch behavior, just ensure dropdown works
if (!dropdownOpen.value && !props.disabled) {
dropdownOpen.value = true;
searchQuery.value = '';
}
};
const handleOverlayTouch = (event: TouchEvent) => {
// Close dropdown when touching overlay
event.preventDefault();
closeDropdown();
};
const handleCountryTouch = (event: TouchEvent) => {
// Allow touch to work naturally for country selection
// Don't prevent default as it interferes with click events
};
// Initialize from modelValue // Initialize from modelValue
watch(() => props.modelValue, (newValue) => { watch(() => props.modelValue, (newValue) => {
if (newValue && newValue !== selectedCountry.value.dialCode + localNumber.value.replace(/\D/g, '')) { if (newValue && newValue !== selectedCountry.value.dialCode + localNumber.value.replace(/\D/g, '')) {
@ -357,6 +305,16 @@ watch(() => props.modelValue, (newValue) => {
} }
} }
}, { immediate: true }); }, { immediate: true });
// Clean up search query when dropdown closes
watch(dropdownOpen, (isOpen) => {
if (!isOpen) {
// Clear search after a small delay to allow selection to complete
setTimeout(() => {
searchQuery.value = '';
}, 100);
}
});
</script> </script>
<style scoped> <style scoped>
@ -375,6 +333,8 @@ watch(() => props.modelValue, (newValue) => {
background: rgba(var(--v-theme-surface), 1); background: rgba(var(--v-theme-surface), 1);
border: 1px solid transparent; border: 1px solid transparent;
margin-right: 8px; margin-right: 8px;
user-select: none;
-webkit-tap-highlight-color: transparent;
} }
.country-selector:hover { .country-selector:hover {
@ -438,6 +398,7 @@ watch(() => props.modelValue, (newValue) => {
max-height: 300px; max-height: 300px;
overflow-y: auto; overflow-y: auto;
background: rgba(var(--v-theme-surface), 1); background: rgba(var(--v-theme-surface), 1);
-webkit-overflow-scrolling: touch;
} }
.country-list::-webkit-scrollbar { .country-list::-webkit-scrollbar {
@ -494,19 +455,6 @@ watch(() => props.modelValue, (newValue) => {
font-family: 'Roboto Mono', monospace; font-family: 'Roboto Mono', monospace;
} }
/* Mobile Overlay */
.mobile-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1999;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
/* Mobile Header */ /* Mobile Header */
.mobile-header { .mobile-header {
display: flex; display: flex;
@ -550,57 +498,33 @@ watch(() => props.modelValue, (newValue) => {
border-radius: 8px; border-radius: 8px;
min-height: 44px; /* Touch-friendly size */ min-height: 44px; /* Touch-friendly size */
align-items: center; align-items: center;
-webkit-tap-highlight-color: transparent;
} }
.country-selector--mobile:hover { .country-selector--mobile:active {
background: rgba(var(--v-theme-primary), 0.12); background: rgba(var(--v-theme-primary), 0.16);
} }
.country-dropdown--mobile { .country-dropdown--mobile {
position: fixed !important;
top: 10% !important;
left: 5% !important;
right: 5% !important;
width: 90vw !important; width: 90vw !important;
max-width: 400px !important; max-width: 400px !important;
min-height: 400px !important; max-height: 70vh !important;
max-height: 80vh !important;
height: auto !important;
margin: 0 auto !important;
border-radius: 16px !important;
z-index: 2000 !important;
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.3) !important;
overflow: hidden !important;
display: flex !important;
flex-direction: column !important;
} }
.country-list--mobile { .country-list--mobile {
flex: 1 !important; max-height: calc(50vh - 120px) !important;
min-height: 200px !important;
max-height: calc(60vh - 120px) !important;
overflow-y: auto !important;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
.country-list--mobile::-webkit-scrollbar {
width: 8px;
}
.country-list--mobile::-webkit-scrollbar-thumb {
background: rgba(var(--v-theme-primary), 0.4);
border-radius: 4px;
}
.country-item--mobile { .country-item--mobile {
min-height: 56px !important; min-height: 56px !important;
padding: 12px 20px !important; padding: 12px 20px !important;
border-left-width: 4px !important; border-left-width: 4px !important;
-webkit-tap-highlight-color: transparent;
} }
.country-item--mobile:active { .country-item--mobile:active {
background: rgba(var(--v-theme-primary), 0.16) !important; background: rgba(var(--v-theme-primary), 0.16) !important;
transform: scale(0.98);
} }
.list-flag--mobile { .list-flag--mobile {
@ -636,7 +560,7 @@ watch(() => props.modelValue, (newValue) => {
} }
.country-list { .country-list {
max-height: 200px; max-height: 250px;
} }
.country-selector { .country-selector {
@ -644,74 +568,11 @@ watch(() => props.modelValue, (newValue) => {
padding: 6px 10px; padding: 6px 10px;
} }
.country-flag { .search-input :deep(.v-field__input) {
width: 24px;
height: 18px;
}
.country-code {
font-size: 0.875rem;
min-width: 36px;
}
.search-container {
padding: 16px;
}
.search-input :deep(.v-field) {
font-size: 16px !important; /* Prevent zoom */ font-size: 16px !important; /* Prevent zoom */
} }
} }
@media (max-width: 480px) {
.country-dropdown--mobile {
width: 95vw !important;
max-height: 85vh !important;
border-radius: 12px !important;
}
.country-list--mobile {
max-height: calc(65vh - 140px) !important;
}
.country-item--mobile {
min-height: 52px !important;
padding: 10px 16px !important;
}
.mobile-header,
.mobile-footer,
.search-container {
padding: 12px 16px;
}
.mobile-title {
font-size: 1rem;
}
}
/* Landscape orientation adjustments */
@media (max-height: 500px) and (orientation: landscape) {
.country-dropdown--mobile {
max-height: 90vh !important;
width: 60vw !important;
max-width: 500px !important;
}
.country-list--mobile {
max-height: calc(75vh - 120px) !important;
}
}
/* High DPI displays */
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
.country-flag,
.list-flag {
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
}
}
/* iOS specific fixes */ /* iOS specific fixes */
@supports (-webkit-touch-callout: none) { @supports (-webkit-touch-callout: none) {
.phone-input-wrapper--mobile .phone-text-field :deep(.v-field__input) { .phone-input-wrapper--mobile .phone-text-field :deep(.v-field__input) {
@ -719,79 +580,22 @@ watch(() => props.modelValue, (newValue) => {
-webkit-appearance: none; -webkit-appearance: none;
} }
.country-dropdown--mobile { .search-input :deep(.v-field__input) {
-webkit-backdrop-filter: blur(8px); font-size: 16px !important;
-webkit-appearance: none;
} }
.mobile-overlay { .country-list {
-webkit-backdrop-filter: blur(4px); -webkit-overflow-scrolling: touch;
} }
} }
/* Android specific fixes */ /* Accessibility improvements */
@media (pointer: coarse) {
.country-item--mobile {
min-height: 56px !important; /* Material Design touch target */
}
.country-selector--mobile {
min-height: 48px !important;
}
}
/* Accessibility improvements for mobile */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.country-item--mobile, .country-item,
.country-selector--mobile { .country-selector,
.dropdown-icon {
transition: none !important; transition: none !important;
} }
.mobile-overlay {
backdrop-filter: none;
}
.country-dropdown--mobile {
backdrop-filter: none;
}
}
/* High contrast mode for mobile */
@media (prefers-contrast: high) {
.country-dropdown--mobile {
border: 3px solid rgb(var(--v-theme-outline));
}
.country-item--mobile {
border-bottom: 1px solid rgb(var(--v-theme-outline));
}
.mobile-header {
border-bottom: 2px solid rgb(var(--v-theme-outline));
}
}
/* Dark theme support */
@media (prefers-color-scheme: dark) {
.country-dropdown {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.country-dropdown--mobile {
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.6) !important;
}
.mobile-overlay {
background: rgba(0, 0, 0, 0.7);
}
}
/* Safe area handling for notched devices */
@supports (padding: max(0px)) {
.country-dropdown--mobile {
padding-top: max(16px, env(safe-area-inset-top));
padding-bottom: max(16px, env(safe-area-inset-bottom));
padding-left: max(16px, env(safe-area-inset-left));
padding-right: max(16px, env(safe-area-inset-right));
}
} }
</style> </style>

View File

@ -0,0 +1,138 @@
/**
* Unified Mobile Detection Composable
* Provides consistent mobile detection across the app
*/
export interface MobileDetectionState {
isMobile: boolean;
isSafari: boolean;
isMobileSafari: boolean;
isIOS: boolean;
isAndroid: boolean;
safariVersion?: number;
viewportHeight: number;
isInitialized: boolean;
}
// Global state to ensure single source of truth
const globalState = reactive<MobileDetectionState>({
isMobile: false,
isSafari: false,
isMobileSafari: false,
isIOS: false,
isAndroid: false,
safariVersion: undefined,
viewportHeight: 0,
isInitialized: false
});
// Track if listeners are already set up
let listenersSetup = false;
export const useMobileDetection = () => {
// Initialize detection on first use
if (!globalState.isInitialized && typeof window !== 'undefined') {
detectDevice();
globalState.isInitialized = true;
// Set up listeners only once globally
if (!listenersSetup) {
setupListeners();
listenersSetup = true;
}
}
// Return readonly state to prevent external modifications
return readonly(globalState);
};
function detectDevice() {
if (typeof window === 'undefined') return;
const userAgent = navigator.userAgent;
// Detect mobile
globalState.isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent);
// Detect iOS
globalState.isIOS = /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream;
// Detect Android
globalState.isAndroid = /Android/i.test(userAgent);
// Detect Safari
globalState.isSafari = /^((?!chrome|android).)*safari/i.test(userAgent);
// Detect Mobile Safari specifically
globalState.isMobileSafari = globalState.isIOS && globalState.isSafari;
// Extract Safari version
if (globalState.isSafari) {
const match = userAgent.match(/Version\/(\d+)/);
if (match) {
globalState.safariVersion = parseInt(match[1]);
}
}
// Set initial viewport height
globalState.viewportHeight = window.innerHeight;
}
function setupListeners() {
if (typeof window === 'undefined') return;
let resizeTimeout: NodeJS.Timeout;
let lastHeight = window.innerHeight;
const handleResize = () => {
clearTimeout(resizeTimeout);
// Debounce and only update if height actually changed
resizeTimeout = setTimeout(() => {
const newHeight = window.innerHeight;
// Only update if there's a significant change (more than 20px)
// This prevents minor fluctuations from triggering updates
if (Math.abs(newHeight - lastHeight) > 20) {
lastHeight = newHeight;
globalState.viewportHeight = newHeight;
// Update CSS custom property for mobile Safari
if (globalState.isMobileSafari) {
const vh = newHeight * 0.01;
document.documentElement.style.setProperty('--vh', `${vh}px`);
}
}
}, 150); // Increased debounce time
};
// Add resize listener with passive option for better performance
window.addEventListener('resize', handleResize, { passive: true });
// Also listen for orientation changes on mobile
if (globalState.isMobile) {
window.addEventListener('orientationchange', () => {
// Wait for orientation change to complete
setTimeout(handleResize, 100);
});
}
// Clean up on app unmount (if needed)
if (typeof window !== 'undefined') {
const cleanup = () => {
window.removeEventListener('resize', handleResize);
if (globalState.isMobile) {
window.removeEventListener('orientationchange', handleResize);
}
};
// Store cleanup function for manual cleanup if needed
(window as any).__mobileDetectionCleanup = cleanup;
}
}
// Export helper functions
export const isMobileSafari = () => globalState.isMobileSafari;
export const isMobile = () => globalState.isMobile;
export const isIOS = () => globalState.isIOS;
export const getViewportHeight = () => globalState.viewportHeight;

View File

@ -153,21 +153,24 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { import { useMobileDetection } from '~/composables/useMobileDetection';
getOptimizedClasses,
applyMobileSafariFixes
} from '~/utils/mobile-safari-utils';
definePageMeta({ definePageMeta({
layout: false, layout: false,
middleware: 'guest' middleware: 'guest'
}); });
// Use unified mobile detection
const mobileDetection = useMobileDetection();
// Mobile Safari optimization classes // Mobile Safari optimization classes
const containerClasses = computed(() => [ const containerClasses = computed(() => {
'password-setup-page', const classes = ['password-setup-page'];
...getOptimizedClasses() if (mobileDetection.isMobile) classes.push('is-mobile');
].join(' ')); if (mobileDetection.isMobileSafari) classes.push('is-mobile-safari');
if (mobileDetection.isIOS) classes.push('is-ios');
return classes.join(' ');
});
// Reactive state // Reactive state
const loading = ref(false); const loading = ref(false);
@ -300,13 +303,8 @@ const setupPassword = async () => {
} }
}; };
// Apply mobile Safari fixes // Component initialization
onMounted(() => { onMounted(() => {
// Apply mobile Safari fixes
if (typeof window !== 'undefined') {
applyMobileSafariFixes();
}
console.log('[setup-password] Password setup page loaded for:', email.value); console.log('[setup-password] Password setup page loaded for:', email.value);
// Check if we have required parameters // Check if we have required parameters

View File

@ -91,10 +91,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { import { useMobileDetection } from '~/composables/useMobileDetection';
getOptimizedClasses,
applyMobileSafariFixes
} from '~/utils/mobile-safari-utils';
definePageMeta({ definePageMeta({
layout: false, layout: false,
@ -106,11 +103,17 @@ const route = useRoute();
const email = computed(() => route.query.email as string || ''); const email = computed(() => route.query.email as string || '');
const partialWarning = computed(() => route.query.warning === 'partial'); const partialWarning = computed(() => route.query.warning === 'partial');
// Use unified mobile detection
const mobileDetection = useMobileDetection();
// Mobile Safari optimization classes // Mobile Safari optimization classes
const containerClasses = computed(() => [ const containerClasses = computed(() => {
'verification-success', const classes = ['verification-success'];
...getOptimizedClasses() if (mobileDetection.isMobile) classes.push('is-mobile');
].join(' ')); if (mobileDetection.isMobileSafari) classes.push('is-mobile-safari');
if (mobileDetection.isIOS) classes.push('is-ios');
return classes.join(' ');
});
// Setup password URL for Keycloak - Fixed URL structure // Setup password URL for Keycloak - Fixed URL structure
const setupPasswordUrl = computed(() => { const setupPasswordUrl = computed(() => {
@ -144,13 +147,8 @@ const goToPasswordSetup = () => {
}); });
}; };
// Apply mobile Safari fixes and track verification // Track verification
onMounted(() => { onMounted(() => {
// Apply mobile Safari fixes
if (typeof window !== 'undefined') {
applyMobileSafariFixes();
}
console.log('[verify-success] Email verification completed', { console.log('[verify-success] Email verification completed', {
email: email.value, email: email.value,
partialWarning: partialWarning.value, partialWarning: partialWarning.value,

View File

@ -121,21 +121,24 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { import { useMobileDetection } from '~/composables/useMobileDetection';
getOptimizedClasses,
applyMobileSafariFixes
} from '~/utils/mobile-safari-utils';
definePageMeta({ definePageMeta({
layout: false, layout: false,
middleware: 'guest' middleware: 'guest'
}); });
// Use unified mobile detection
const mobileDetection = useMobileDetection();
// Mobile Safari optimization classes // Mobile Safari optimization classes
const containerClasses = computed(() => [ const containerClasses = computed(() => {
'verification-page', const classes = ['verification-page'];
...getOptimizedClasses() if (mobileDetection.isMobile) classes.push('is-mobile');
].join(' ')); if (mobileDetection.isMobileSafari) classes.push('is-mobile-safari');
if (mobileDetection.isIOS) classes.push('is-ios');
return classes.join(' ');
});
// Reactive state // Reactive state
const verifying = ref(true); const verifying = ref(true);
@ -202,13 +205,8 @@ const retryVerification = () => {
verifyEmail(); verifyEmail();
}; };
// Apply mobile Safari fixes and start verification // Start verification on mount
onMounted(() => { onMounted(() => {
// Apply mobile Safari fixes
if (typeof window !== 'undefined') {
applyMobileSafariFixes();
}
console.log('[auth/verify] Starting email verification with token:', token.value?.substring(0, 20) + '...'); console.log('[auth/verify] Starting email verification with token:', token.value?.substring(0, 20) + '...');
// Start verification process // Start verification process

View File

@ -209,14 +209,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { RegistrationFormData, RecaptchaConfig, RegistrationConfig } from '~/utils/types'; import type { RegistrationFormData, RecaptchaConfig, RegistrationConfig } from '~/utils/types';
import { import { useMobileDetection } from '~/composables/useMobileDetection';
getDeviceInfo,
needsPerformanceOptimization,
shouldDisableBackdropFilter,
getOptimizedClasses,
applyMobileSafariFixes,
debounce
} from '~/utils/mobile-safari-utils';
// Declare global grecaptcha interface for TypeScript // Declare global grecaptcha interface for TypeScript
declare global { declare global {
@ -233,10 +226,8 @@ definePageMeta({
layout: false layout: false
}); });
// Mobile Safari optimization flags // Use unified mobile detection
const deviceInfo = ref(getDeviceInfo()); const mobileDetection = useMobileDetection();
const performanceMode = ref(needsPerformanceOptimization());
const disableBackdropFilter = ref(shouldDisableBackdropFilter());
// Configs with fallback defaults // Configs with fallback defaults
const recaptchaConfig = ref<RecaptchaConfig>({ siteKey: '', secretKey: '' }); const recaptchaConfig = ref<RecaptchaConfig>({ siteKey: '', secretKey: '' });
@ -288,16 +279,22 @@ useHead({
}); });
// Dynamic CSS classes based on device // Dynamic CSS classes based on device
const containerClasses = computed(() => [ const containerClasses = computed(() => {
'signup-container', const classes = ['signup-container'];
...getOptimizedClasses() if (mobileDetection.isMobile) classes.push('is-mobile');
].join(' ')); if (mobileDetection.isMobileSafari) classes.push('is-mobile-safari');
if (mobileDetection.isIOS) classes.push('is-ios');
return classes.join(' ');
});
const cardClasses = computed(() => [ const cardClasses = computed(() => {
'signup-card', const classes = ['signup-card'];
performanceMode.value ? 'performance-optimized' : '', if (mobileDetection.isMobileSafari) {
disableBackdropFilter.value ? 'no-backdrop-filter' : '' classes.push('performance-optimized');
].filter(Boolean).join(' ')); classes.push('no-backdrop-filter');
}
return classes.filter(Boolean).join(' ');
});
// Form validation rules // Form validation rules
const nameRules = [ const nameRules = [
@ -455,34 +452,35 @@ onMounted(async () => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
try { try {
// Load configs without complex timeout logic // Load reCAPTCHA config
Promise.all([ $fetch('/api/recaptcha-config')
// Load reCAPTCHA config .then((response: any) => {
$fetch('/api/recaptcha-config').then((response: any) => {
if (response?.success && response?.data?.siteKey) { if (response?.success && response?.data?.siteKey) {
recaptchaConfig.value.siteKey = response.data.siteKey; recaptchaConfig.value.siteKey = response.data.siteKey;
loadRecaptchaScript(response.data.siteKey); loadRecaptchaScript(response.data.siteKey);
} }
}).catch(() => { })
.catch(() => {
// Silently fail for reCAPTCHA - not critical // Silently fail for reCAPTCHA - not critical
}), console.debug('reCAPTCHA config not available');
});
// Load registration config
$fetch('/api/registration-config').then((response: any) => { // Load registration config
$fetch('/api/registration-config')
.then((response: any) => {
if (response?.success) { if (response?.success) {
registrationConfig.value = response.data; registrationConfig.value = response.data;
} }
}).catch(() => { })
.catch(() => {
// Use defaults if config fails to load // Use defaults if config fails to load
console.debug('Using default registration config');
registrationConfig.value = { registrationConfig.value = {
membershipFee: 150, membershipFee: 150,
iban: 'MC58 1756 9000 0104 0050 1001 860', iban: 'MC58 1756 9000 0104 0050 1001 860',
accountHolder: 'ASSOCIATION MONACO USA' accountHolder: 'ASSOCIATION MONACO USA'
}; };
}) });
]).catch(() => {
// Global fallback - don't let errors cause page reload
});
} catch (error) { } catch (error) {
// Prevent any errors from bubbling up and causing reload // Prevent any errors from bubbling up and causing reload

View File

@ -1,23 +1,43 @@
/** /**
* Mobile Safari Fixes Plugin * Mobile Safari Fixes Plugin
* Applies mobile Safari specific optimizations on client side * Applies mobile Safari specific optimizations on client side
* Now uses unified mobile detection to prevent conflicts
*/ */
import { applyMobileSafariFixes } from '~/utils/mobile-safari-utils'; import { useMobileDetection } from '~/composables/useMobileDetection';
// Track if fixes have been applied to prevent duplicate execution
let fixesApplied = false;
export default defineNuxtPlugin(() => { export default defineNuxtPlugin(() => {
// Apply mobile Safari fixes on client-side mount // Apply mobile Safari fixes on client-side mount
if (typeof window !== 'undefined') { if (typeof window !== 'undefined' && !fixesApplied) {
// Apply fixes immediately // Use the unified mobile detection composable
applyMobileSafariFixes(); const mobileDetection = useMobileDetection();
// Also apply on route changes (for SPA navigation) // Apply initial fixes only for mobile Safari
const router = useRouter(); if (mobileDetection.isMobileSafari) {
router.afterEach(() => { // Set initial viewport height CSS variable
// Small delay to ensure DOM is ready const vh = window.innerHeight * 0.01;
nextTick(() => { document.documentElement.style.setProperty('--vh', `${vh}px`);
applyMobileSafariFixes();
}); // Add optimization classes
}); const classes = [];
if (mobileDetection.isMobile) classes.push('is-mobile');
if (mobileDetection.isMobileSafari) classes.push('is-mobile-safari');
if (mobileDetection.isIOS) classes.push('is-ios');
// Add classes to document element
if (classes.length > 0) {
document.documentElement.classList.add(...classes);
}
}
// Mark fixes as applied
fixesApplied = true;
} }
// Note: We're NOT applying fixes on route changes anymore
// as this was causing the reload loop. The composable handles
// all necessary viewport updates.
}); });