記事·  

プライバシーを最優先したフィードバックウィジェットの構築

Nuxtのドキュメントに関するフィードバックを収集するための、軽量でプライバシー重視のウィジェット。Drizzle、NuxtHubデータベース、Motion Vueで構築。
Hugo Richard

ユーゴ・リシャール

@hugorcd__

Sébastien Chopin

セバスチャン・ショパン

@Atinux

ドキュメントはNuxt開発者体験の中心です。継続的に改善するために、各ページでユーザーフィードバックを直接収集するシンプルで効果的な方法が必要でした。Plausibleのプライバシーファーストのアプローチからインスピレーションを得て、フィードバックウィジェットをどのように設計し実装したかをご紹介します。

なぜフィードバックウィジェットなのか?

現在、ユーザーはGitHubイシューを作成するか、直接問い合わせることでドキュメントに関するフィードバックを提供できます。これらのチャネルは貴重であり重要ですが、ユーザーは現在のコンテキストを離れ、考えを共有するためにいくつかの手順を踏む必要があります。

私たちは何か違うものを求めていました

  • 文脈に沿ったもの:各ドキュメントページに直接統合
  • 手軽なもの:フィードバック提供まで最大2クリック
  • プライバシーを尊重するもの:個人追跡なし、設計上GDPR準拠

技術アーキテクチャ

私たちのソリューションは3つの主要コンポーネントで構成されています

1. Motionアニメーションによるフロントエンド

インターフェースはVue 3のComposition APIとMotion for Vueを組み合わせて、魅力的なユーザーエクスペリエンスを作成しています。ウィジェットは、スムーズな状態遷移のためにレイアウトアニメーションを、自然なフィードバックのためにバネ物理を使用しています。useFeedbackコンポーザブルがすべての状態管理を処理し、ユーザーがページ間を移動すると自動的にリセットされます。

例えば、成功状態のアニメーションはこちらです

<template>
  <!-- ... -->
  <motion.div
    v-if="isSubmitted"
    key="success"
    :initial="{ opacity: 0, scale: 0.95 }"
    :animate="{ opacity: 1, scale: 1 }"
    :transition="{ duration: 0.3 }"
    class="flex items-center gap-3 py-2"
    role="status"
    aria-live="polite"
    aria-label="Feedback submitted successfully"
  >
    <motion.div
      :initial="{ scale: 0 }"
      :animate="{ scale: 1 }"
      :transition="{ delay: 0.1, type: 'spring', visualDuration: 0.4 }"
      class="text-xl"
      aria-hidden="true"
    >
    </motion.div>
    <motion.div
      :initial="{ opacity: 0, x: 10 }"
      :animate="{ opacity: 1, x: 0 }"
      :transition="{ delay: 0.2, duration: 0.3 }"
    >
      <div class="text-sm font-medium text-highlighted">
        Thank you for your feedback!
      </div>
      <div class="text-xs text-muted mt-1">
        Your input helps us improve the documentation.
      </div>
    </motion.div>
  </motion.div>
  <!-- ... -->
</template>

フィードバックウィジェットのソースコードはこちら.

2. Plausibleにインスパイアされた匿名化

課題は、プライバシーを保護しながら重複(ユーザーが考えを変えた場合)を検出することでした。私たちはPlausibleクッキーなしでユニーク訪問者をカウントする.

export async function generateHash(
  today: string,
  ip: string,
  domain: string,
  userAgent: string
): Promise<string> {
  const data = `${today}+${domain}+${ip}+${userAgent}`

  const buffer = await crypto.subtle.digest(
    'SHA-1',
    new TextEncoder().encode(data)
  )

  return [...new Uint8Array(buffer)]
    .map(b => b.toString(16).padStart(2, '0'))
    .join('')
}

アプローチからインスピレーションを得ました。この方法では、以下を組み合わせて日ごとのユニークな識別子を生成します。

  • IP + ユーザーエージェント:すべてのHTTPリクエストと共に自然に送信されます
  • ドメイン:環境分離を可能にします
  • 現在の日付:識別子の日ごとのローテーションを強制します

なぜこれが安全なのか?

  • IPとユーザーエージェントはデータベースに保存されません
  • ハッシュは毎日変更されるため、長期的な追跡を防ぎます
  • ハッシュから元のデータを逆アセンブルすることは非常に困難です
  • 設計上GDPRに準拠しています(永続的な個人データなし)

3. 競合処理を伴うデータベース永続化

まず、フィードバックテーブルのスキーマを定義し、pathfingerprintカラムに一意制約を追加します。

export const feedback = sqliteTable('feedback', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  rating: text('rating').notNull(),
  feedback: text('feedback'),
  path: text('path').notNull(),
  title: text('title').notNull(),
  stem: text('stem').notNull(),
  country: text('country').notNull(),
  fingerprint: text('fingerprint').notNull(),
  createdAt: integer({ mode: 'timestamp' }).notNull(),
  updatedAt: integer({ mode: 'timestamp' }).notNull()
}, table => [uniqueIndex('path_fingerprint_idx').on(table.path, table.fingerprint)])

次に、サーバーではDrizzleUPSERT戦略

await drizzle.insert(tables.feedback).values({
  rating: data.rating,
  feedback: data.feedback || null,
  path: data.path,
  title: data.title,
  stem: data.stem,
  country: event.context.cf?.country || 'unknown',
  fingerprint,
  createdAt: new Date(),
  updatedAt: new Date()
}).onConflictDoUpdate({
  target: [tables.feedback.path, tables.feedback.fingerprint],
  set: {
    rating: data.rating,
    feedback: data.feedback || null,
    country,
    updatedAt: new Date()
  }
})

と共に使用します。このアプローチにより、ユーザーがその日のうちに考えを変えた場合は更新が可能になり、新しいフィードバックの場合は作成され、ページごとおよびユーザーごとに自動的に重複が排除されます。

サーバーサイドのソースコードはこちら.

一貫性のための共有型

実行時検証と型生成にZodを使用しています。

export const FEEDBACK_RATINGS = [
  'very-helpful',
  'helpful', 
  'not-helpful',
  'confusing'
] as const

export const feedbackSchema = z.object({
  rating: z.enum(FEEDBACK_RATINGS),
  feedback: z.string().optional(),
  path: z.string(),
  title: z.string(),
  stem: z.string()
})

export type FeedbackInput = z.infer<typeof feedbackSchema>

このアプローチにより、フロントエンド、API、データベース全体での一貫性が保証されます。

次は何をするか

ウィジェットは現在、すべてのドキュメントページで稼働しています。次のステップは、フィードバックパターンを分析し、改善が必要なページを特定するための管理インターフェースをnuxt.com内に構築することです。これにより、実際のユーザーフィードバックに基づいてドキュメントの品質を継続的に向上させることができます。

完全なソースコードはGitHubインスピレーションと貢献のために利用可能です!