👤

Nuxt3 + Firebaseでプロフィール情報を取得・表示する機能を実装

に公開

Nuxt3 + Firebaseでプロフィール情報を取得・表示する機能を実装

はじめに

Nuxt3とFirebaseを使用して、ユーザーのプロフィール情報を取得し、ホーム画面で表示する機能を実装しました。この記事では、Firestoreからプロフィールデータを取得し、Vue3のリアクティブな状態管理を使って表示する実装方法を紹介します。

実装内容

今回実装した機能は以下の通りです:

  • ユーザー認証後にプロフィール情報を自動取得
  • プロフィール情報の表示(ニックネーム、年齢、性別)
  • プロフィール未登録時の適切な表示とナビゲーション
  • エラーハンドリングとローディング状態の管理

ホーム画面の実装

まず、ホーム画面でプロフィール情報を表示する部分の実装を見てみましょう。

app/pages/home/index.vue
<script setup lang="ts">
import type { ProfileData } from '~/composables/useProfile'

// 認証関連のcomposableを使用
const { user, logout, loading } = useAuth()

// プロフィール関連のcomposableを使用
const { getProfile, loading: profileLoading } = useProfile()

// エラー状態とローディング状態の管理
const error = ref('')
const logoutLoading = ref(false)
const profileData = ref<ProfileData | null>(null)
const profileError = ref('')

// ログアウト処理
const handleLogout = async () => {
  error.value = ''
  logoutLoading.value = true
  const result = await logout()

  if (result.error) {
    error.value = result.error.message
  } else {
    // ログアウト成功時にログインページに遷移
    await navigateTo('/login')
  }
  logoutLoading.value = false
}

// プロフィール情報取得
const fetchProfile = async () => {
  if (!user.value?.uid) return

  profileError.value = ''
  const result = await getProfile(user.value.uid)

  if (result.error) {
    profileError.value = result.error.message
  } else {
    profileData.value = result.data
  }
}

// ユーザー情報が取得できたらプロフィール情報を取得
watch(
  user,
  (newUser) => {
    if (newUser?.uid) {
      fetchProfile()
    }
  },
  { immediate: true }
)

// 日付フォーマット関数
const formatDate = (dateString: string | undefined) => {
  if (!dateString) return '不明'
  return new Date(dateString).toLocaleDateString('ja-JP', {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
    hour: '2-digit',
    minute: '2-digit',
  })
}
</script>

<template>
  <!-- 認証済みユーザー -->
  <v-card v-if="!loading" class="mx-auto" max-width="600">
    <v-card-item>
      <v-card-title class="text-center">
        ようこそ、{{ user?.email }}さん!
      </v-card-title>
      <v-card-subtitle class="text-center">
        ログイン認証が完了しました
      </v-card-subtitle>
    </v-card-item>

    <v-card-text>
      <v-alert
        v-if="error"
        type="error"
        :text="error"
        variant="tonal"
        class="mb-4"
      ></v-alert>

      <div class="user-info">
        <v-list>
          <v-list-item>
            <template #prepend>
              <v-icon>mdi-email</v-icon>
            </template>
            <v-list-item-title>メールアドレス</v-list-item-title>
            <v-list-item-subtitle>{{ user?.email }}</v-list-item-subtitle>
          </v-list-item>

          <v-list-item>
            <template #prepend>
              <v-icon>mdi-account</v-icon>
            </template>
            <v-list-item-title>ユーザーID</v-list-item-title>
            <v-list-item-subtitle>{{ user?.uid }}</v-list-item-subtitle>
          </v-list-item>

          <v-list-item>
            <template #prepend>
              <v-icon>mdi-calendar</v-icon>
            </template>
            <v-list-item-title>アカウント作成日</v-list-item-title>
            <v-list-item-subtitle>
              {{ formatDate(user?.metadata.creationTime) }}
            </v-list-item-subtitle>
          </v-list-item>
        </v-list>
      </div>

      <!-- プロフィール情報セクション -->
      <v-divider class="my-4"></v-divider>

      <div class="profile-section">
        <h3 class="text-h6 mb-3">プロフィール情報</h3>

        <v-alert
          v-if="profileError"
          type="error"
          :text="profileError"
          variant="tonal"
          class="mb-4"
        ></v-alert>

        <div v-if="profileLoading" class="text-center py-4">
          <v-progress-circular
            indeterminate
            color="primary"
          ></v-progress-circular>
          <p class="mt-2">プロフィール情報を読み込み中...</p>
        </div>

        <div v-else-if="profileData" class="profile-info">
          <v-list>
            <v-list-item>
              <template #prepend>
                <v-icon>mdi-account-circle</v-icon>
              </template>
              <v-list-item-title>ニックネーム</v-list-item-title>
              <v-list-item-subtitle>{{
                profileData.nickname
              }}</v-list-item-subtitle>
            </v-list-item>

            <v-list-item>
              <template #prepend>
                <v-icon>mdi-cake</v-icon>
              </template>
              <v-list-item-title>年齢</v-list-item-title>
              <v-list-item-subtitle
                >{{ profileData.age }}歳</v-list-item-subtitle
              >
            </v-list-item>

            <v-list-item>
              <template #prepend>
                <v-icon>mdi-gender-male-female</v-icon>
              </template>
              <v-list-item-title>性別</v-list-item-title>
              <v-list-item-subtitle>{{
                profileData.gender
              }}</v-list-item-subtitle>
            </v-list-item>
          </v-list>
        </div>

        <div v-else class="text-center py-4">
          <v-icon size="48" color="grey">mdi-account-plus</v-icon>
          <p class="mt-2 text-grey">プロフィール情報が登録されていません</p>
          <v-btn
            color="primary"
            variant="outlined"
            prepend-icon="mdi-account-edit"
            class="mt-2"
            @click="navigateTo('/home/create-profile')"
          >
            プロフィールを作成
          </v-btn>
        </div>
      </div>
    </v-card-text>

    <v-card-actions class="justify-center">
      <v-btn
        color="primary"
        variant="outlined"
        prepend-icon="mdi-lock-reset"
        @click="navigateTo('/home/change-password')"
      >
        パスワード変更
      </v-btn>
      <v-btn
        color="error"
        variant="outlined"
        :loading="logoutLoading"
        prepend-icon="mdi-logout"
        @click="handleLogout"
      >
        ログアウト
      </v-btn>
    </v-card-actions>
  </v-card>
</template>

コードの解説

1. プロフィール情報の取得処理

// プロフィール情報取得
const fetchProfile = async () => {
  if (!user.value?.uid) return

  profileError.value = ''
  const result = await getProfile(user.value.uid)

  if (result.error) {
    profileError.value = result.error.message
  } else {
    profileData.value = result.data
  }
}

ユーザーIDが存在する場合にのみプロフィール情報を取得し、エラーハンドリングも適切に行っています。

2. リアクティブな状態管理

// ユーザー情報が取得できたらプロフィール情報を取得
watch(
  user,
  (newUser) => {
    if (newUser?.uid) {
      fetchProfile()
    }
  },
  { immediate: true }
)

watchを使用してユーザー情報の変更を監視し、認証完了後に自動的にプロフィール情報を取得します。

3. プロフィール情報の表示

テンプレート部分では、以下の3つの状態を適切に表示しています:

  • ローディング中: プログレスサーキュラーを表示
  • プロフィール情報あり: ニックネーム、年齢、性別を表示
  • プロフィール未登録: プロフィール作成ボタンを表示

useProfile Composableの実装

プロフィール情報の取得・保存を管理するcomposableの実装です。

app/composables/useProfile.ts
import { doc, setDoc, getDoc, type Firestore } from 'firebase/firestore'
import { useNuxtApp } from 'nuxt/app'

export interface ProfileData {
  nickname: string
  age: number
  gender: string
  createdAt: Date
  updatedAt: Date
}

export const useProfile = () => {
  const { $firestore } = useNuxtApp()
  const firestore = $firestore as Firestore | null
  const loading = ref(false)

  // プロフィール保存
  const saveProfile = async (
    nickname: string,
    age: string,
    gender: string,
    userId: string
  ) => {
    loading.value = true
    if (!firestore) {
      return {
        error: new Error('Firestoreが初期化されていません'),
      }
    }
    try {
      const profileData: ProfileData = {
        nickname,
        age: parseInt(age),
        gender,
        createdAt: new Date(),
        updatedAt: new Date(),
      }
      await setDoc(doc(firestore, 'profiles', userId), profileData)
      return { error: null }
    } catch (error) {
      return { error: error as Error }
    } finally {
      loading.value = false
    }
  }

  // プロフィール取得
  const getProfile = async (userId: string) => {
    loading.value = true
    if (!firestore) {
      return {
        data: null,
        error: new Error('Firestoreが初期化されていません'),
      }
    }
    try {
      const profileDoc = await getDoc(doc(firestore, 'profiles', userId))
      if (profileDoc.exists()) {
        return {
          data: profileDoc.data() as ProfileData,
          error: null,
        }
      } else {
        return {
          data: null,
          error: null,
        }
      }
    } catch (error) {
      return {
        data: null,
        error: error as Error,
      }
    } finally {
      loading.value = false
    }
  }

  return {
    saveProfile,
    getProfile,
    loading: readonly(loading),
  }
}

Composableの解説

1. ProfileDataインターフェース

export interface ProfileData {
  nickname: string
  age: number
  gender: string
  createdAt: Date
  updatedAt: Date
}

プロフィール情報の型定義を明確にし、TypeScriptの型安全性を確保しています。

2. プロフィール取得処理

const getProfile = async (userId: string) => {
  loading.value = true
  if (!firestore) {
    return {
      data: null,
      error: new Error('Firestoreが初期化されていません'),
    }
  }
  try {
    const profileDoc = await getDoc(doc(firestore, 'profiles', userId))
    if (profileDoc.exists()) {
      return {
        data: profileDoc.data() as ProfileData,
        error: null,
      }
    } else {
      return {
        data: null,
        error: null,
      }
    }
  } catch (error) {
    return {
      data: null,
      error: error as Error,
    }
  } finally {
    loading.value = false
  }
}

Firestoreからプロフィール情報を取得し、適切なエラーハンドリングとローディング状態の管理を行っています。

まとめ

この実装により、以下の機能を実現できました:

  • 自動的なプロフィール取得: ユーザー認証完了後に自動的にプロフィール情報を取得
  • 適切な状態管理: ローディング、エラー、データの3つの状態を適切に管理
  • ユーザビリティの向上: プロフィール未登録時には作成ボタンを表示
  • 型安全性: TypeScriptを使用して型安全な実装を実現

Nuxt3のComposition APIとFirebaseの組み合わせにより、保守性が高く、ユーザーフレンドリーなプロフィール表示機能を実装することができました。

今後の拡張予定

  • プロフィール情報の編集機能
  • プロフィール画像のアップロード機能
  • プロフィール情報のバリデーション強化
GitHubで編集を提案

Discussion