Especialização em Front-End com Vue • Demonstrações Práticas Compiláveis


💻 Código de Exemplo 13 — Estado Global Reativo com Pinia Setup Stores e storeToRefs

Este exemplo apresenta a criação da Store oficial do ecossistema Vue (Pinia) utilizando a sintaxe moderna de Setup Stores (equivalente à Composition API com ref e computed) e o consumo seguro via storeToRefs para desestruturação sem perda de reatividade.

1. Definição da Store Pinia: src/stores/cartStore.ts

import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

export interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

export const useCartStore = defineStore('cart', () => {
  // 1. STATE (refs reativos)
  const items = ref<CartItem[]>([
    { id: 'prod-01', name: 'Teclado Mecânico Low-Profile', price: 450.0, quantity: 1 },
    { id: 'prod-02', name: 'Mouse Ergonômico Vertical', price: 280.0, quantity: 2 }
  ]);
  const couponDiscountRate = ref<number>(0.1); // 10% de desconto inicial

  // 2. GETTERS (computed properties)
  const totalItemCount = computed<number>(() => {
    return items.value.reduce((acc, item) => acc + item.quantity, 0);
  });

  const subtotalPrice = computed<number>(() => {
    return items.value.reduce((acc, item) => acc + (item.price * item.quantity), 0);
  });

  const finalPrice = computed<number>(() => {
    return subtotalPrice.value * (1 - couponDiscountRate.value);
  });

  // 3. ACTIONS (funções que mutam o estado diretamente)
  function addItem(product: Omit<CartItem, 'quantity'>): void {
    const existing = items.value.find((i) => i.id === product.id);
    if (existing) {
      existing.quantity++;
    } else {
      items.value.push({ ...product, quantity: 1 });
    }
  }

  function removeItem(productId: string): void {
    items.value = items.value.filter((i) => i.id !== productId);
  }

  function updateQuantity(productId: string, delta: number): void {
    const target = items.value.find((i) => i.id === productId);
    if (!target) return;

    target.quantity += delta;
    if (target.quantity <= 0) {
      removeItem(productId);
    }
  }

  function applyCoupon(code: string): boolean {
    if (code.toUpperCase() === 'VUE30') {
      couponDiscountRate.value = 0.3;
      return true;
    }
    return false;
  }

  function clearCart(): void {
    items.value = [];
  }

  return {
    items,
    couponDiscountRate,
    totalItemCount,
    subtotalPrice,
    finalPrice,
    addItem,
    removeItem,
    updateQuantity,
    applyCoupon,
    clearCart
  };
});

2. Componente Consumidor: src/components/ShoppingCartView.vue

<!-- src/components/ShoppingCartView.vue -->
<script setup lang="ts">
import { ref } from 'vue';
import { storeToRefs } from 'pinia';
import { useCartStore } from '../stores/cartStore';

const cartStore = useCartStore();

// storeToRefs garante que getters e refs mantenham a reatividade ao serem desestruturados!
const { items, totalItemCount, subtotalPrice, finalPrice, couponDiscountRate } = storeToRefs(cartStore);

// Actions podem ser desestruturadas diretamente sem utilitários
const { updateQuantity, removeItem, applyCoupon, clearCart, addItem } = cartStore;

const couponInput = ref<string>('');
const couponFeedback = ref<string | null>(null);

function handleCouponSubmit(): void {
  const success = applyCoupon(couponInput.value.trim());
  if (success) {
    couponFeedback.value = 'Cupom VUE30 aplicado: 30% de desconto!';
  } else {
    couponFeedback.value = 'Cupom inválido. Tente "VUE30".';
  }
}

function addMonitor(): void {
  addItem({
    id: `prod-${Date.now()}`,
    name: 'Monitor UltraWide 34"',
    price: 2490.0
  });
}
</script>

<template>
  <div class="max-w-2xl mx-auto p-6 bg-white border rounded-2xl shadow-sm space-y-6">
    <div class="flex items-center justify-between border-b pb-4">
      <div>
        <h2 class="text-xl font-bold text-slate-800">🛒 Carrinho de Compras (Pinia Store)</h2>
        <p class="text-xs text-slate-500">Estado compartilhado reativo e imune a perdas de referência</p>
      </div>
      <span class="bg-indigo-50 text-indigo-700 text-xs font-bold px-3 py-1 rounded-full">
        {{ totalItemCount }} itens no carrinho
      </span>
    </div>

    <!-- Lista de Itens do Carrinho -->
    <div v-if="items.length > 0" class="divide-y border rounded-xl overflow-hidden">
      <div
        v-for="item in items"
        :key="item.id"
        class="p-4 flex items-center justify-between hover:bg-slate-50"
      >
        <div>
          <h4 class="font-semibold text-slate-800 text-sm">{{ item.name }}</h4>
          <span class="text-xs text-slate-500 font-mono">
            R$ {{ item.price.toFixed(2) }} un. × {{ item.quantity }} =
            <strong class="text-slate-800">R$ {{ (item.price * item.quantity).toFixed(2) }}</strong>
          </span>
        </div>

        <div class="flex items-center gap-3">
          <div class="flex items-center border rounded-lg overflow-hidden bg-white">
            <button
              @click="updateQuantity(item.id, -1)"
              class="px-2.5 py-1 text-xs hover:bg-slate-100 font-bold"
            >
              -
            </button>
            <span class="px-3 text-xs font-mono font-bold">{{ item.quantity }}</span>
            <button
              @click="updateQuantity(item.id, 1)"
              class="px-2.5 py-1 text-xs hover:bg-slate-100 font-bold"
            >
              +
            </button>
          </div>

          <button
            @click="removeItem(item.id)"
            class="text-xs text-red-500 hover:text-red-700"
            title="Remover produto"
          >
            ✕
          </button>
        </div>
      </div>
    </div>

    <div v-else class="text-center py-8 bg-slate-50 border border-dashed rounded-xl text-xs text-slate-400">
      O carrinho está vazio.
    </div>

    <!-- Seção de Cupom e Totais -->
    <div class="p-4 bg-slate-50 border rounded-xl space-y-3">
      <div class="flex gap-2">
        <input
          type="text"
          v-model="couponInput"
          placeholder="Código de cupom (ex: VUE30)"
          class="border px-3 py-1.5 rounded-lg text-xs flex-1 uppercase font-mono"
        />
        <button
          @click="handleCouponSubmit"
          class="bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-semibold px-4 py-1.5 rounded-lg"
        >
          Aplicar
        </button>
      </div>
      <p v-if="couponFeedback" class="text-xs font-medium" :class="couponDiscountRate === 0.3 ? 'text-emerald-600' : 'text-red-600'">
        {{ couponFeedback }}
      </p>

      <div class="pt-2 border-t space-y-1 text-xs text-slate-600">
        <div class="flex justify-between">
          <span>Subtotal:</span>
          <span>R$ {{ subtotalPrice.toFixed(2) }}</span>
        </div>
        <div class="flex justify-between text-emerald-600 font-semibold">
          <span>Desconto ({{ (couponDiscountRate * 100).toFixed(0) }}%):</span>
          <span>- R$ {{ (subtotalPrice * couponDiscountRate).toFixed(2) }}</span>
        </div>
        <div class="flex justify-between text-base font-bold text-slate-900 border-t pt-2">
          <span>Total Final:</span>
          <span class="text-indigo-600">R$ {{ finalPrice.toFixed(2) }}</span>
        </div>
      </div>
    </div>

    <div class="flex justify-between items-center pt-2">
      <button
        @click="addMonitor"
        class="text-xs text-indigo-600 hover:underline font-semibold"
      >
        + Adicionar Monitor de Teste
      </button>
      <button
        @click="clearCart"
        class="text-xs text-red-500 hover:underline"
      >
        Esvaziar Carrinho
      </button>
    </div>
  </div>
</template>

🖥️ Saída / Comportamento Esperado

Renderização Inicial:
Total de Itens: 3
Subtotal: (450 * 1) + (280 * 2) = R$ 1.010,00
Desconto Padrão (10%): - R$ 101,00
Total Final: R$ 909,00

Interação 1 — Alterar Quantidade do Teclado (+) 1 vez:
Quantidade passa para 2. Total de Itens: 4.
Subtotal recalculado para: R$ 1.460,00. Total final: R$ 1.314,00.

Interação 2 — Aplicar Cupom "VUE30":
couponDiscountRate passa de 0.1 para 0.3 na Store.
Banner verde: "Cupom VUE30 aplicado: 30% de desconto!".
Total final recalculado para: R$ 1.022,00.

🔍 Destaques da Implementação

  1. storeToRefs Fundamental: Desestruturar const { items } = cartStore sem storeToRefs faria items perder a reatividade de leitura. Com storeToRefs, as variáveis permanecem sincronizadas.
  2. Setup Store Simplicidade: Elimina mutações arcaicas do Vuex. Atribuições simples de propriedades no interior de actions são detectadas automaticamente.
  3. Autocompletion Completo: TypeScript infere o retorno de todos os getters e a assinatura das actions sem declarações de interfaces redundantes.