Article·  

Виджет обратной связи с приоритетом конфиденциальности

Лёгкий виджет для сбора отзывов по документации Nuxt на Drizzle, NuxtHub и Motion Vue.
Hugo Richard

Hugo Richard

@hugorcd

Sébastien Chopin

Sébastien Chopin

@Atinux

Документация — центр опыта разработчика в Nuxt. Чтобы постоянно её улучшать, нужен был простой способ собирать отзывы прямо на каждой странице. Ниже — как мы спроектировали и сделали виджет, вдохновляясь privacy-first подходом Plausible.

Зачем виджет отзывов?

Сейчас отзывы по документации можно оставить через GitHub issues или напрямую. Эти каналы важны, но пользователю нужно уходить со страницы и делать несколько шагов.

Мы хотели другое:

  • По контексту — встроено в каждую страницу документации
  • Без трения — не больше 2 кликов до отправки отзыва
  • С уважением к приватности — без персонального трекинга, по умолчанию совместимо с GDPR

Архитектура

Решение состоит из трёх частей:

1. Фронтенд с анимациями Motion

Интерфейс сочетает Composition API Vue 3 и Motion for Vue. В виджете — layout-анимации для смены состояний и пружинная физика. Composable 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="Отзыв успешно отправлен"
  >
    <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">
        Спасибо за отзыв!
      </div>
      <div class="text-xs text-muted mt-1">
        Ваши ответы помогают улучшать документацию.
      </div>
    </motion.div>
  </motion.div>
  <!-- ... -->
</template>

Исходный код виджета — здесь.

2. Анонимизация по мотивам Plausible

Задача — отличать дубликаты (пользователь передумал), не нарушая приватность. Мы опирались на подход Plausible к подсчёту уникальных посетителей без cookies.

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 + User-Agent — приходят с каждым HTTP-запросом
  • Домен — изоляция окружений
  • Текущая дата — идентификаторы меняются каждый день

Почему это безопасно?

  • IP и User-Agent не сохраняются в БД
  • Хеш меняется ежедневно, долгосрочный трекинг невозможен
  • Восстановить исходные данные из хеша практически нереально
  • По конструкции совместимо с GDPR (нет постоянных персональных данных)

3. Сохранение в БД и обработка конфликтов

Сначала задаём схему таблицы отзывов и уникальное ограничение по path и fingerprint.

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)])

Then, in the server, we use Drizzle with an UPSERT strategy:

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 для валидации в runtime и генерации типов:

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 — можно смотреть и контрибьютить!

← Вернуться в блог
Nuxt on LinkedInNuxt on BlueskyNuxt on X