Документация — центр опыта разработчика в 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 — можно смотреть и контрибьютить!