Skip to content

Mobile UX Guidelines

Sport Tech Club - Experiência Mobile-First

Visão Geral

Este documento define as diretrizes de UX para a experiência mobile do Sport Tech Club, focando em usabilidade, performance e engajamento.


1. Princípios Mobile-First

1.1 Core Principles

yaml
principios:
  thumb_zone:
    descricao: Áreas de fácil alcance com o polegar
    aplicacao: Ações principais na zona inferior

  content_first:
    descricao: Conteúdo priorizado sobre navegação
    aplicacao: Mínimo de chrome, máximo de conteúdo

  progressive_disclosure:
    descricao: Informações reveladas progressivamente
    aplicacao: Detalhes sob demanda, resumos first

  offline_first:
    descricao: Funciona sem conexão quando possível
    aplicacao: Cache, filas de sync, estados offline

  performance:
    descricao: Rápido e responsivo
    aplicacao: Skeleton loading, optimistic UI, lazy load

1.2 Device Targets

yaml
dispositivos:
  mobile_portrait:
    width: 320px - 428px
    prioridade: 1 (primário)
    touch: true

  mobile_landscape:
    width: 568px - 926px
    prioridade: 2
    touch: true

  tablet:
    width: 768px - 1024px
    prioridade: 3
    touch: true

  desktop:
    width: 1024px+
    prioridade: 4
    touch: false

2. Touch Targets e Gestos

2.1 Touch Target Guidelines

scss
// Tamanhos mínimos de touch target
$touch-target-min: 44px;      // Mínimo absoluto (Apple HIG)
$touch-target-comfortable: 48px; // Recomendado
$touch-target-large: 56px;    // Para ações principais

// Espaçamento entre touch targets
$touch-spacing-min: 8px;
$touch-spacing-comfortable: 12px;

// Mixin para touch targets
@mixin touch-target($size: 'comfortable') {
  @if $size == 'min' {
    min-height: $touch-target-min;
    min-width: $touch-target-min;
  } @else if $size == 'comfortable' {
    min-height: $touch-target-comfortable;
    min-width: $touch-target-comfortable;
  } @else if $size == 'large' {
    min-height: $touch-target-large;
    min-width: $touch-target-large;
  }

  // Área de toque expandida
  position: relative;
  &::before {
    content: '';
    position: absolute;
    top: -8px;
    right: -8px;
    bottom: -8px;
    left: -8px;
  }
}

2.2 Gestos Suportados

yaml
gestos:
  tap:
    uso: Seleção, ativação
    feedback: Ripple effect, highlight

  long_press:
    uso: Ações contextuais, seleção múltipla
    duracao: 500ms
    feedback: Vibração háptica + menu

  swipe_horizontal:
    uso: Navegação entre tabs, ações em lista
    threshold: 50px
    velocidade: 0.3

  swipe_vertical:
    uso: Scroll, pull-to-refresh
    threshold: 40px para refresh

  pinch:
    uso: Zoom em imagens/mapas
    min_scale: 1
    max_scale: 3

  drag:
    uso: Reordenação, arrastar para soltar
    feedback: Elevação, sombra

2.3 Haptic Feedback

typescript
// Tipos de feedback háptico
enum HapticType {
  LIGHT = 'light',      // Seleção, toggle
  MEDIUM = 'medium',    // Ação confirmada
  HEAVY = 'heavy',      // Erro, alerta
  SUCCESS = 'success',  // Reserva confirmada
  WARNING = 'warning',  // Atenção necessária
  ERROR = 'error',      // Falha
}

// Hook para haptic feedback
function useHaptic() {
  const trigger = (type: HapticType) => {
    if ('vibrate' in navigator) {
      const patterns: Record<HapticType, number[]> = {
        [HapticType.LIGHT]: [10],
        [HapticType.MEDIUM]: [20],
        [HapticType.HEAVY]: [30],
        [HapticType.SUCCESS]: [10, 50, 10],
        [HapticType.WARNING]: [20, 30, 20],
        [HapticType.ERROR]: [30, 50, 30, 50, 30],
      };
      navigator.vibrate(patterns[type]);
    }
  };

  return { trigger };
}

3. Thumb Zone Design

3.1 Zonas de Alcance

┌─────────────────────────────────┐
│                                 │
│    ░░░░░░░░░░░░░░░░░░░░░░░░    │  Difícil alcance
│    ░░░░░░░░░░░░░░░░░░░░░░░░    │  (Ações secundárias)
│                                 │
├─────────────────────────────────┤
│                                 │
│    ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒    │  Alcance médio
│    ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒    │  (Navegação)
│                                 │
├─────────────────────────────────┤
│                                 │
│    ████████████████████████    │  Fácil alcance
│    ████████████████████████    │  (Ações principais)
│    ████████████████████████    │
│                                 │
└─────────────────────────────────┘
         Bottom Navigation

3.2 Layout Bottom-First

vue
<template>
  <div class="mobile-layout">
    <!-- Conteúdo scrollável -->
    <main class="content">
      <slot></slot>
    </main>

    <!-- Ação flutuante (FAB) -->
    <button
      v-if="showFab"
      class="fab"
      @click="$emit('fab-click')"
    >
      <PlusIcon />
      <span class="fab-label">{{ fabLabel }}</span>
    </button>

    <!-- Navegação inferior -->
    <nav class="bottom-nav" role="navigation">
      <router-link
        v-for="item in navItems"
        :key="item.path"
        :to="item.path"
        class="nav-item"
        :aria-current="isActive(item.path) ? 'page' : undefined"
      >
        <component :is="item.icon" class="nav-icon" />
        <span class="nav-label">{{ item.label }}</span>
      </router-link>
    </nav>

    <!-- Safe area para notch/home indicator -->
    <div class="safe-area-bottom"></div>
  </div>
</template>

<style lang="scss" scoped>
.mobile-layout {
  display: flex;
  flex-direction: column;
  height: 100vh;
  height: 100dvh; // Dynamic viewport height
}

.content {
  flex: 1;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  padding-bottom: calc(60px + env(safe-area-inset-bottom));
}

.fab {
  position: fixed;
  right: 16px;
  bottom: calc(76px + env(safe-area-inset-bottom));
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 16px 24px;
  background-color: $color-primary-500;
  color: white;
  border: none;
  border-radius: 28px;
  box-shadow: $shadow-lg;
  cursor: pointer;
  z-index: 100;

  &:active {
    transform: scale(0.95);
  }
}

.bottom-nav {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  display: flex;
  justify-content: space-around;
  height: 60px;
  background-color: var(--color-surface-0);
  border-top: 1px solid var(--color-border);
  padding-bottom: env(safe-area-inset-bottom);
}

.nav-item {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  flex: 1;
  padding: 8px;
  color: var(--color-text-muted);
  text-decoration: none;
  transition: color 0.2s;

  &.router-link-active {
    color: $color-primary-500;
  }
}

.nav-icon {
  width: 24px;
  height: 24px;
}

.nav-label {
  font-size: 10px;
  margin-top: 4px;
}

.safe-area-bottom {
  height: env(safe-area-inset-bottom);
  background-color: var(--color-surface-0);
}
</style>

4. Padrões de Navegação

4.1 Bottom Navigation

yaml
bottom_navigation:
  itens_maximos: 5
  itens_recomendados: 4

  itens:
    - label: Início
      icon: home
      path: /
      badge: null

    - label: Reservas
      icon: calendar
      path: /bookings
      badge: pending_count

    - label: Buscar
      icon: search
      path: /search
      badge: null

    - label: Perfil
      icon: user
      path: /profile
      badge: notifications_count

4.2 Pull-to-Refresh

vue
<template>
  <div
    class="pull-to-refresh"
    @touchstart="onTouchStart"
    @touchmove="onTouchMove"
    @touchend="onTouchEnd"
  >
    <div
      class="refresh-indicator"
      :class="{ 'is-refreshing': isRefreshing }"
      :style="{ transform: `translateY(${pullDistance}px)` }"
    >
      <LoaderIcon v-if="isRefreshing" class="spin" />
      <ArrowDownIcon v-else :style="{ transform: `rotate(${rotation}deg)` }" />
      <span>{{ statusText }}</span>
    </div>

    <div
      class="content"
      :style="{ transform: `translateY(${pullDistance}px)` }"
    >
      <slot></slot>
    </div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue';

const emit = defineEmits(['refresh']);

const THRESHOLD = 80;
const MAX_PULL = 120;

const startY = ref(0);
const pullDistance = ref(0);
const isRefreshing = ref(false);

const rotation = computed(() => {
  return Math.min(pullDistance.value / THRESHOLD * 180, 180);
});

const statusText = computed(() => {
  if (isRefreshing.value) return 'Atualizando...';
  if (pullDistance.value >= THRESHOLD) return 'Solte para atualizar';
  return 'Puxe para atualizar';
});

function onTouchStart(e) {
  if (window.scrollY === 0) {
    startY.value = e.touches[0].clientY;
  }
}

function onTouchMove(e) {
  if (startY.value === 0 || isRefreshing.value) return;

  const currentY = e.touches[0].clientY;
  const diff = currentY - startY.value;

  if (diff > 0) {
    pullDistance.value = Math.min(diff * 0.5, MAX_PULL);
    e.preventDefault();
  }
}

async function onTouchEnd() {
  if (pullDistance.value >= THRESHOLD) {
    isRefreshing.value = true;
    pullDistance.value = 60;

    await emit('refresh');

    isRefreshing.value = false;
  }

  pullDistance.value = 0;
  startY.value = 0;
}
</script>

4.3 Swipe Actions

vue
<template>
  <div
    class="swipe-item"
    @touchstart="onTouchStart"
    @touchmove="onTouchMove"
    @touchend="onTouchEnd"
  >
    <!-- Ações da esquerda (reveal on swipe right) -->
    <div class="swipe-actions left">
      <button class="action action-success" @click="$emit('archive')">
        <ArchiveIcon />
      </button>
    </div>

    <!-- Conteúdo principal -->
    <div
      class="swipe-content"
      :style="{ transform: `translateX(${offset}px)` }"
    >
      <slot></slot>
    </div>

    <!-- Ações da direita (reveal on swipe left) -->
    <div class="swipe-actions right">
      <button class="action action-warning" @click="$emit('edit')">
        <EditIcon />
      </button>
      <button class="action action-danger" @click="$emit('delete')">
        <TrashIcon />
      </button>
    </div>
  </div>
</template>

<style lang="scss" scoped>
.swipe-item {
  position: relative;
  overflow: hidden;
}

.swipe-content {
  position: relative;
  background-color: var(--color-surface-0);
  transition: transform 0.2s ease;
  z-index: 1;
}

.swipe-actions {
  position: absolute;
  top: 0;
  bottom: 0;
  display: flex;

  &.left {
    left: 0;
  }

  &.right {
    right: 0;
  }
}

.action {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 80px;
  border: none;
  cursor: pointer;

  &-success { background-color: $color-success-500; }
  &-warning { background-color: $color-warning-500; }
  &-danger { background-color: $color-error-500; }

  svg {
    width: 24px;
    height: 24px;
    color: white;
  }
}
</style>

5. Loading States

5.1 Skeleton Loading

vue
<template>
  <div class="booking-card-skeleton">
    <div class="skeleton-header">
      <div class="skeleton skeleton-badge"></div>
      <div class="skeleton skeleton-status"></div>
    </div>

    <div class="skeleton-body">
      <div class="skeleton skeleton-title"></div>
      <div class="skeleton skeleton-subtitle"></div>
      <div class="skeleton skeleton-info"></div>
    </div>

    <div class="skeleton-footer">
      <div class="skeleton skeleton-price"></div>
      <div class="skeleton skeleton-button"></div>
    </div>
  </div>
</template>

<style lang="scss" scoped>
.skeleton {
  background: linear-gradient(
    90deg,
    var(--color-surface-1) 25%,
    var(--color-surface-2) 50%,
    var(--color-surface-1) 75%
  );
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
  border-radius: 4px;
}

@keyframes shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

.skeleton-badge {
  width: 80px;
  height: 24px;
  border-radius: 12px;
}

.skeleton-status {
  width: 60px;
  height: 20px;
}

.skeleton-title {
  width: 70%;
  height: 20px;
  margin-bottom: 8px;
}

.skeleton-subtitle {
  width: 50%;
  height: 16px;
  margin-bottom: 8px;
}

.skeleton-info {
  width: 40%;
  height: 14px;
}

.skeleton-price {
  width: 80px;
  height: 28px;
}

.skeleton-button {
  width: 120px;
  height: 44px;
  border-radius: 8px;
}
</style>

5.2 Optimistic UI

typescript
// Hook para optimistic updates
function useOptimisticUpdate<T>() {
  const pending = ref(false);
  const error = ref<Error | null>(null);

  async function execute(
    optimisticUpdate: () => void,
    serverAction: () => Promise<T>,
    rollback: () => void,
  ): Promise<T | null> {
    pending.value = true;
    error.value = null;

    // Aplica mudança otimista imediatamente
    optimisticUpdate();

    try {
      // Tenta persistir no servidor
      const result = await serverAction();
      return result;
    } catch (e) {
      // Rollback em caso de erro
      error.value = e as Error;
      rollback();
      return null;
    } finally {
      pending.value = false;
    }
  }

  return { pending, error, execute };
}

// Exemplo de uso
const { execute } = useOptimisticUpdate();

async function toggleFavorite(arenaId: string) {
  const previousState = isFavorite.value;

  await execute(
    // Optimistic update
    () => { isFavorite.value = !isFavorite.value; },
    // Server action
    () => api.toggleFavorite(arenaId),
    // Rollback
    () => { isFavorite.value = previousState; },
  );
}

6. Formulários Mobile

6.1 Input Types Otimizados

vue
<template>
  <form class="mobile-form" @submit.prevent="onSubmit">
    <!-- Email com teclado otimizado -->
    <div class="form-group">
      <label for="email">E-mail</label>
      <input
        id="email"
        v-model="form.email"
        type="email"
        inputmode="email"
        autocomplete="email"
        autocapitalize="off"
        autocorrect="off"
        placeholder="seu@email.com"
      />
    </div>

    <!-- Telefone -->
    <div class="form-group">
      <label for="phone">Telefone</label>
      <input
        id="phone"
        v-model="form.phone"
        type="tel"
        inputmode="tel"
        autocomplete="tel"
        placeholder="(11) 99999-9999"
      />
    </div>

    <!-- CPF (numérico) -->
    <div class="form-group">
      <label for="cpf">CPF</label>
      <input
        id="cpf"
        v-model="form.cpf"
        type="text"
        inputmode="numeric"
        pattern="[0-9]*"
        placeholder="000.000.000-00"
      />
    </div>

    <!-- Data de nascimento -->
    <div class="form-group">
      <label for="birthdate">Data de nascimento</label>
      <input
        id="birthdate"
        v-model="form.birthdate"
        type="date"
        max="2010-01-01"
      />
    </div>

    <!-- Busca -->
    <div class="form-group">
      <label for="search">Buscar arena</label>
      <input
        id="search"
        v-model="form.search"
        type="search"
        inputmode="search"
        enterkeyhint="search"
        placeholder="Nome ou cidade..."
      />
    </div>

    <button type="submit" class="btn btn-primary btn-block">
      Continuar
    </button>
  </form>
</template>

<style lang="scss" scoped>
.mobile-form {
  .form-group {
    margin-bottom: $spacing-4;
  }

  label {
    display: block;
    margin-bottom: $spacing-1;
    font-size: $font-size-sm;
    font-weight: $font-weight-medium;
  }

  input {
    width: 100%;
    padding: $spacing-3 $spacing-4;
    font-size: 16px; // Evita zoom no iOS
    border: 1px solid var(--color-border);
    border-radius: $radius-md;
    background-color: var(--color-bg-input);

    &:focus {
      outline: none;
      border-color: $color-primary-500;
      box-shadow: 0 0 0 3px rgba($color-primary-500, 0.1);
    }
  }
}
</style>

6.2 Stepper Form

vue
<template>
  <div class="booking-stepper">
    <!-- Progress indicator -->
    <div class="stepper-progress">
      <div
        v-for="(step, index) in steps"
        :key="step.id"
        class="step-indicator"
        :class="{
          'is-active': currentStep === index,
          'is-completed': currentStep > index,
        }"
      >
        <div class="step-circle">
          <CheckIcon v-if="currentStep > index" />
          <span v-else>{{ index + 1 }}</span>
        </div>
        <span class="step-label">{{ step.label }}</span>
      </div>
    </div>

    <!-- Step content -->
    <transition :name="transitionName" mode="out-in">
      <component
        :is="steps[currentStep].component"
        :key="currentStep"
        v-model="formData"
        @next="nextStep"
        @prev="prevStep"
      />
    </transition>

    <!-- Navigation -->
    <div class="stepper-nav">
      <button
        v-if="currentStep > 0"
        class="btn btn-ghost"
        @click="prevStep"
      >
        <ChevronLeftIcon />
        Voltar
      </button>

      <button
        v-if="currentStep < steps.length - 1"
        class="btn btn-primary"
        :disabled="!canProceed"
        @click="nextStep"
      >
        Próximo
        <ChevronRightIcon />
      </button>

      <button
        v-else
        class="btn btn-primary"
        :disabled="!canProceed"
        @click="submit"
      >
        Confirmar reserva
      </button>
    </div>
  </div>
</template>

7. Offline Support

7.1 Service Worker Strategy

typescript
// sw.js - Service Worker
const CACHE_NAME = 'stc-v1';
const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/manifest.json',
  '/css/app.css',
  '/js/app.js',
  '/icons/icon-192.png',
  '/icons/icon-512.png',
];

// Install - cache static assets
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.addAll(STATIC_ASSETS);
    })
  );
});

// Fetch - network first, fallback to cache
self.addEventListener('fetch', (event) => {
  // API requests - network only with offline queue
  if (event.request.url.includes('/api/')) {
    event.respondWith(
      fetch(event.request).catch(() => {
        // Queue for later sync
        return new Response(
          JSON.stringify({ offline: true }),
          { headers: { 'Content-Type': 'application/json' } }
        );
      })
    );
    return;
  }

  // Static assets - cache first
  event.respondWith(
    caches.match(event.request).then((cached) => {
      return cached || fetch(event.request).then((response) => {
        // Cache new responses
        const clone = response.clone();
        caches.open(CACHE_NAME).then((cache) => {
          cache.put(event.request, clone);
        });
        return response;
      });
    })
  );
});

// Background sync for offline actions
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-bookings') {
    event.waitUntil(syncPendingBookings());
  }
});

7.2 Offline UI

vue
<template>
  <div class="offline-indicator" v-if="!isOnline">
    <WifiOffIcon />
    <span>Você está offline</span>
    <span class="subtext">Algumas funcionalidades podem estar limitadas</span>
  </div>

  <!-- Componente com estado offline -->
  <div class="booking-list" :class="{ 'is-offline': !isOnline }">
    <div v-for="booking in bookings" :key="booking.id" class="booking-item">
      <!-- Conteúdo -->
      <div v-if="booking.pendingSync" class="sync-indicator">
        <CloudOffIcon />
        Aguardando sincronização
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';

const isOnline = ref(navigator.onLine);

function updateOnlineStatus() {
  isOnline.value = navigator.onLine;
}

onMounted(() => {
  window.addEventListener('online', updateOnlineStatus);
  window.addEventListener('offline', updateOnlineStatus);
});

onUnmounted(() => {
  window.removeEventListener('online', updateOnlineStatus);
  window.removeEventListener('offline', updateOnlineStatus);
});
</script>

<style lang="scss" scoped>
.offline-indicator {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  padding: 8px 16px;
  background-color: $color-warning-500;
  color: white;
  font-size: $font-size-sm;
  z-index: 1000;
}

.is-offline {
  opacity: 0.7;
  pointer-events: none;
}

.sync-indicator {
  display: flex;
  align-items: center;
  gap: 4px;
  padding: 4px 8px;
  background-color: $color-neutral-100;
  border-radius: 4px;
  font-size: $font-size-xs;
  color: $color-neutral-600;
}
</style>

8. PWA Configuration

8.1 Manifest

json
{
  "name": "Sport Tech Club",
  "short_name": "STC",
  "description": "Reserve quadras de esportes de praia",
  "start_url": "/",
  "display": "standalone",
  "orientation": "portrait",
  "background_color": "#FFFFFF",
  "theme_color": "#2196F3",
  "scope": "/",

  "icons": [
    {
      "src": "/icons/icon-72.png",
      "sizes": "72x72",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-96.png",
      "sizes": "96x96",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-128.png",
      "sizes": "128x128",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-144.png",
      "sizes": "144x144",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-152.png",
      "sizes": "152x152",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "/icons/icon-384.png",
      "sizes": "384x384",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any maskable"
    }
  ],

  "shortcuts": [
    {
      "name": "Minhas Reservas",
      "url": "/bookings",
      "icons": [{ "src": "/icons/shortcut-bookings.png", "sizes": "96x96" }]
    },
    {
      "name": "Nova Reserva",
      "url": "/search",
      "icons": [{ "src": "/icons/shortcut-new.png", "sizes": "96x96" }]
    }
  ],

  "share_target": {
    "action": "/share",
    "method": "POST",
    "enctype": "multipart/form-data",
    "params": {
      "title": "title",
      "text": "text",
      "url": "url"
    }
  }
}

8.2 Install Prompt

vue
<template>
  <transition name="slide-up">
    <div v-if="showInstallPrompt" class="install-prompt">
      <div class="install-content">
        <img src="/icons/icon-72.png" alt="Sport Tech Club" />
        <div>
          <h3>Instalar Sport Tech Club</h3>
          <p>Acesse mais rápido direto da sua tela inicial</p>
        </div>
      </div>

      <div class="install-actions">
        <button class="btn btn-ghost" @click="dismiss">Agora não</button>
        <button class="btn btn-primary" @click="install">Instalar</button>
      </div>
    </div>
  </transition>
</template>

<script setup>
import { ref, onMounted } from 'vue';

const showInstallPrompt = ref(false);
let deferredPrompt = null;

onMounted(() => {
  window.addEventListener('beforeinstallprompt', (e) => {
    e.preventDefault();
    deferredPrompt = e;

    // Mostra após 30 segundos de uso
    setTimeout(() => {
      if (!localStorage.getItem('pwa-dismissed')) {
        showInstallPrompt.value = true;
      }
    }, 30000);
  });
});

async function install() {
  if (deferredPrompt) {
    deferredPrompt.prompt();
    const { outcome } = await deferredPrompt.userChoice;

    if (outcome === 'accepted') {
      // Track install
      analytics.track('pwa_installed');
    }

    deferredPrompt = null;
    showInstallPrompt.value = false;
  }
}

function dismiss() {
  showInstallPrompt.value = false;
  localStorage.setItem('pwa-dismissed', 'true');
}
</script>

9. Performance Móvel

9.1 Métricas Alvo

yaml
metricas:
  core_web_vitals:
    LCP: < 2.5s  # Largest Contentful Paint
    FID: < 100ms # First Input Delay
    CLS: < 0.1   # Cumulative Layout Shift
    INP: < 200ms # Interaction to Next Paint

  custom:
    TTI: < 3.5s      # Time to Interactive
    TBT: < 300ms     # Total Blocking Time
    speed_index: < 3s

  bundle:
    initial: < 150KB  # gzipped
    route_chunk: < 50KB
    total: < 400KB

9.2 Otimizações

typescript
// Lazy loading de rotas
const routes = [
  {
    path: '/',
    component: () => import('./views/Home.vue'),
  },
  {
    path: '/bookings',
    component: () => import('./views/Bookings.vue'),
  },
  {
    path: '/arena/:id',
    component: () => import('./views/ArenaDetail.vue'),
  },
];

// Preload de rotas críticas
router.beforeEach((to, from, next) => {
  // Preload rotas relacionadas
  if (to.path === '/search') {
    import('./views/ArenaDetail.vue');
  }
  next();
});

// Image lazy loading
const LazyImage = {
  mounted(el: HTMLImageElement) {
    const observer = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          el.src = el.dataset.src!;
          observer.unobserve(el);
        }
      });
    });
    observer.observe(el);
  },
};

10. Checklist Mobile UX

10.1 Touch & Gestures

  • [ ] Touch targets mínimo 44x44px
  • [ ] Espaçamento entre elementos tocáveis
  • [ ] Feedback visual ao toque
  • [ ] Gestos de swipe implementados
  • [ ] Pull-to-refresh funcional

10.2 Performance

  • [ ] Bundle < 200KB inicial
  • [ ] LCP < 2.5s
  • [ ] Skeleton loading implementado
  • [ ] Lazy loading de imagens
  • [ ] Lazy loading de rotas

10.3 PWA

  • [ ] Manifest configurado
  • [ ] Service Worker funcional
  • [ ] Offline mode básico
  • [ ] Install prompt
  • [ ] Icons em todos tamanhos

10.4 Acessibilidade

  • [ ] Teclado virtual não cobre inputs
  • [ ] Font-size mínimo 16px em inputs
  • [ ] Contraste adequado
  • [ ] Screen reader compatível

Este documento serve como guia para garantir uma experiência mobile consistente e de alta qualidade no Sport Tech Club.