Formulir feedback untuk website atau aplikasi

Formulir feedback untuk website atau aplikasi7 menit baca

Buat formulir feedback dalam aplikasi di Nuxt

Formulir feedback dalam aplikasi Nuxt: server route yang membaca sesi pengguna, mengambil halaman dari Referer, dan tetap bekerja saat JavaScript belum termuat.

Preet SutharPendiri, Ikiform

Kirim formulir dua pertanyaan ke server route Nuxt di server/api/feedback.post.ts. Route membaca pengguna yang login dengan getUserSession dari nuxt-auth-utils, mengambil halaman dari Referer request, memvalidasi skor dengan Zod, dan meneruskan semuanya ke backend formulir Anda dengan secret key yang disimpan di runtimeConfig. Dengan JavaScript, formulir mengirim lewat $fetch dan tetap di tempat; tanpa JavaScript, route yang sama me-redirect kembali dengan hasil di URL.

Tab Nuxt di panduan utama membuat feedback halaman untuk siapa pun yang berkunjung. Di dalam aplikasi Nuxt dengan akun, tugasnya lebih sempit dan datanya lebih kaya: pengguna sudah login, server tahu siapa mereka, dan router tahu layar mana yang mereka buka. Halaman ini membangun versi itu. Key, allowed domains, dan penyiapan produk lainnya ada di panduan dokumentasi Nuxt.

Apa yang diubah aplikasi Nuxt dengan login

Di formulir publik, browser yang menentukan apa yang disimpan. Di sini, browser hanya menentukan skor dan komentar. Semua yang menyatakan siapa dan di mana ditambahkan oleh server route, tempat tidak ada yang bisa mengubahnya:

FieldDiatur olehCara
rating, commentPenggunaTombol radio dan kotak teks
user_id, emailServer routegetUserSession(event) membaca cookie sesi yang tersegel
pageServer routeHeader Referer, yang dikirim browser pada kiriman same-origin dengan path lengkap
screenKomponen, diperiksa oleh routeuseRoute().name, seperti projects-id-settings, dibuat Nuxt dari path file halaman

Nama route adalah kunci pengelompokan gratis di Nuxt. Halaman pengaturan setiap proyek berbagi projects-id-settings, jadi Anda bisa menghitung rata-rata skor per layar sementara page menyimpan alamat persisnya.

Bagaimana satu server route Nuxt menangani feedback dengan dan tanpa JavaScriptDengan JavaScriptTanpa JavaScriptFeedbackForm.vue$fetch,bodyJSONfeedback.post.tsorigin,sesi, ZodIkiformsecretkey,menyimpannya{ sent:true }terimakasihmenggantikanformulirkirimanformulirbiasabodyurlencodedfeedback.post.tspemeriksaansamaIkiformkirimansama303kembali?feedback=sentPOST+ pengguna, halaman200POST+ pengguna, halamanredirectBagaimana satu server route Nuxt menangani feedback dengan dan tanpa JavaScriptDengan JavaScriptTanpa JavaScriptFeedbackForm.vue$fetch, body JSONfeedback.post.tsorigin, sesi, ZodIkiformsecret key, menyimpannya{ sent: true }terima kasih menggantikanformulirkiriman formulir biasabody urlencodedfeedback.post.tspemeriksaan samaIkiformkiriman sama303 kembali?feedback=sentPOST+ pengguna, halaman200POST+ pengguna, halamanredirect
  1. Setelah hydration, komponen menghentikan submit native dan mengirim field sebagai JSON dengan $fetch.
  2. Server route memeriksa Origin, membaca pengguna dari sesi, memvalidasi skor, dan menambahkan halaman dari Referer.
  3. Route meneruskan jawaban ke Ikiform dengan secret key dari runtimeConfig.
  4. Request JSON mendapat { sent: true } atau error dengan pesan, dan formulir diperbarui di tempat.
  5. Sebelum hydration, browser sendiri yang mengirim formulir. Route menjalankan pemeriksaan dan kiriman yang sama.
  6. Lalu route menjawab dengan 303 kembali ke halaman, dengan hasilnya di query string untuk ditampilkan komponen.
Route membedakan keduanya dari content type request. Kedua jalur menyimpan jawaban yang persis sama.

Setup proyek

Ini memakai struktur Nuxt 4, dengan kode aplikasi di app/ dan kode server di root. Instal nuxt-auth-utils dan zod. Jika aplikasi Anda sudah punya cara login lain, pertahankan dan baca pengguna dari sesi Anda sendiri di route.

Struktur proyek
app/
  components/FeedbackForm.vue
  pages/projects/[id]/settings.vue
server/
  api/feedback.post.ts
shared/
  types/auth.d.ts
nuxt.config.ts
.env

Deklarasikan secret key di runtimeConfig dengan default kosong. Nuxt mengisinya saat runtime dari NUXT_IKIFORM_SECRET_KEY, dan karena key tidak berada di bawah public, key hanya ada di server.

nuxt.config.ts
export default defineNuxtConfig({
  modules: ["nuxt-auth-utils"],
  runtimeConfig: {
    ikiformSecretKey: "",
  },
});
.env
NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NUXT_SESSION_PASSWORD=at-least-32-characters-long-random-string

Buat key di API keys dengan scope submissions:write. nuxt-auth-utils menyegel cookie sesinya dengan NUXT_SESSION_PASSWORD, yang minimal 32 karakter; di pengembangan, ia menuliskannya untuk Anda saat pertama kali dijalankan. Beri tahu bentuk pengguna Anda, agar session.user.id punya tipe:

shared/types/auth.d.ts
declare module "#auth-utils" {
  interface User {
    id: string;
    email: string;
  }
}

export {};

Lalu tempel definisi di tab Fields formulir. Dua field pertama adalah formulirnya; server route menambahkan sisanya, dan field email membuat Reply di setiap notifikasi menuju ke pengguna.

Pratinjau

Menjalankan validator Ikiform di browser Anda. Tidak ada yang dikirim.

Bagaimana layar ini bekerja untuk Anda?

Diatur oleh server Anda dari request.

Preview menjalankan validator Ikiform, jadi skor 6 menampilkan 422 yang akan diterima route.

Bangun

Server route

server/api/feedback.post.ts
import { z } from "zod";

const Feedback = z.object({
  rating: z.coerce.number().int().min(1).max(5),
  comment: z.string().trim().max(4000).default(""),
  screen: z.string().regex(/^[a-z0-9_-]{1,120}$/).catch("unknown"),
  _gotcha: z.string().default(""),
});

function pageFrom(referer: string | undefined) {
  try {
    const url = new URL(referer ?? "");
    return url.origin + url.pathname;
  } catch {
    return "";
  }
}

export default defineEventHandler(async (event) => {
  const own = getRequestURL(event, { xForwardedHost: true });
  if (getRequestHeader(event, "origin") !== own.origin) {
    throw createError({ status: 403, message: "Posts from other sites are refused." });
  }

  const wantsJson = getRequestHeader(event, "content-type")?.includes("application/json") ?? false;
  const referer = getRequestHeader(event, "referer");
  const back = (outcome: string) => {
    const target = new URL(referer ?? "/", own.origin);
    return sendRedirect(event, `${target.pathname}?feedback=${outcome}`, 303);
  };

  const session = await getUserSession(event);
  if (!session.user) {
    if (wantsJson) throw createError({ status: 401, message: "Your session ended. Sign in again, then send it." });
    return back("signed-out");
  }

  const parsed = await readValidatedBody(event, (body) => Feedback.safeParse(body));
  if (!parsed.success) {
    if (wantsJson) throw createError({ status: 422, message: "Pick a score from 1 to 5." });
    return back("invalid");
  }

  const { ikiformSecretKey } = useRuntimeConfig(event);
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${ikiformSecretKey}`,
      Origin: own.origin,
    },
    body: JSON.stringify({
      ...parsed.data,
      page: pageFrom(referer),
      user_id: session.user.id,
      email: session.user.email,
    }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  const result = await response?.json().catch(() => null);
  if (result?.ok) return wantsJson ? { sent: true } : back("sent");
  if (wantsJson) {
    throw createError({ status: 502, message: result?.error?.message ?? "We couldn't send it. Try again." });
  }
  return back("failed");
});
  • Akhiran .post.ts membuat file hanya menjawab POST.
  • Pemeriksaan Origin berjalan sebelum hal lain. readBody, yang dipakai readValidatedBody, juga mem-parsing body urlencoded, jadi tanpa pemeriksaan itu situs lain bisa mengirim formulir biasa ke route ini dengan cookie pengguna Anda terlampir.
  • getUserSession tidak melempar exception. Route sendiri yang memilih jawabannya: 401 untuk $fetch, redirect untuk kiriman biasa. requireUserSession akan mengirim halaman 401 ke pengguna tanpa JavaScript.
  • z.coerce.number() mengubah "4" dari urlencoded menjadi 4. .catch("unknown") menyimpan screen yang salah bentuk sebagai unknown alih-alih menolak seluruh jawaban.
  • back hanya menyimpan path dari Referer, jadi redirect tidak pernah bisa keluar dari situs Anda.

Komponennya

app/components/FeedbackForm.vue
<script setup lang="ts">
const MESSAGES: Record<string, string> = {
  "signed-out": "Your session ended. Sign in again, then send it.",
  invalid: "Pick a score from 1 to 5.",
  failed: "We couldn't send it. Try again in a moment.",
};
const SCORES = ["Broken", "Frustrating", "Fine", "Good", "Great"];

const route = useRoute();
const { loggedIn } = useUserSession();
const screen = computed(() => String(route.name ?? "unknown"));
const outcome = String(route.query.feedback ?? "");
const status = ref<"idle" | "sending" | "sent">(outcome === "sent" ? "sent" : "idle");
const error = ref(MESSAGES[outcome] ?? "");

async function send(event: SubmitEvent) {
  const body = Object.fromEntries(new FormData(event.currentTarget as HTMLFormElement));
  status.value = "sending";
  error.value = "";
  try {
    await $fetch("/api/feedback", { method: "POST", body });
    status.value = "sent";
  } catch (failure) {
    error.value = (failure as { data?: { message?: string } }).data?.message ?? "We couldn't send it. Try again in a moment.";
    status.value = "idle";
  }
}
</script>

<template>
  <template v-if="loggedIn">
    <p v-if="status === 'sent'" role="status">Thanks. We read every one.</p>
    <form v-else method="post" action="/api/feedback" :aria-busy="status === 'sending'" @submit.prevent="send">
      <fieldset>
        <legend>How is this screen working for you?</legend>
        <label v-for="(label, index) in SCORES" :key="label">
          <input type="radio" name="rating" :value="index + 1" required /> {{ label }}
        </label>
      </fieldset>
      <label for="feedback-comment">What would make it better?</label>
      <textarea id="feedback-comment" name="comment" rows="4" maxlength="4000" />
      <input type="hidden" name="screen" :value="screen" />
      <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
      <p v-if="error" role="alert">{{ error }}</p>
      <button :disabled="status === 'sending'">{{ status === "sending" ? "Sending" : "Send feedback" }}</button>
    </form>
  </template>
</template>
  • method dan action adalah atribut sungguhan, jadi sebelum hydration browser mengirim formulir ke route. Begitu Vue berjalan, @submit.prevent mengambil alih.
  • Body JSON memberi tahu route bahwa ia bisa menjawab dengan JSON. Body berasal dari FormData, jadi honeypot dan screen ikut terkirim tanpa kode tambahan.
  • Pada $fetch yang gagal, data.message dari error adalah pesan dari createError di route.
  • Setelah kiriman biasa, ?feedback= di URL mengatur state awal, jadi ucapan terima kasih atau error tampil di halaman yang dimuat ulang.
  • useUserSession menyembunyikan formulir dari pengunjung yang belum login. Ini hanya kemudahan, bukan pemeriksaan; route yang melakukan pemeriksaan.

Taruh <FeedbackForm /> di halaman mana pun di bagian aplikasi untuk pengguna login, seperti app/pages/projects/[id]/settings.vue. Nuxt meng-import-nya otomatis.

Honeypot, pemeriksaan manusia, dan batas

Input _gotcha sampai ke Ikiform bersama field lainnya, dan yang terisi disimpan sebagai spam. Pemeriksaan manusia Ikiform dilewati untuk kiriman dengan secret key, dan di sini sesi yang menjalankan tugas itu: route menolak siapa pun yang belum login. Yang tersisa adalah akun yang login mengirim terus-menerus, yang hanya bisa dihitung Ikiform berdasarkan alamat server Anda.

Versi sisi server mengirim dengan secret key, sehingga Ikiform melewati Allowed domains dan pemeriksaan manusianya. Semua kiriman lalu datang dari IP server Anda, dan batas Standard 5 per menit per pengunjung menghitung semua pengunjung Anda sebagai satu. Pilih rate limit Relaxed atau Custom untuk formulir yang Anda kirim lewat server, dan simpan IKIFORM_SECRET_KEY di environment variable khusus server.

Pilihan rendering

Formulir butuh server rendering untuk hasil tanpa JavaScript. Pada route dengan ssr: false di routeRules, halaman kosong sampai JavaScript berjalan, jadi kiriman biasa tidak pernah terjadi dan ?feedback= hanya dibaca di browser. Itu tidak masalah, hanya menghilangkan fallback-nya. Jangan taruh formulir di route prerender: true: HTML dibangun sekali, jadi useUserSession tidak melihat siapa pun dan formulir tidak muncul sampai hydration.

Deploy

HostYang harus dilakukan
VercelNitro mendeteksi Vercel, jadi tidak perlu preset. Tambahkan NUXT_IKIFORM_SECRET_KEY dan NUXT_SESSION_PASSWORD di Environment Variables lalu deploy ulang.
NetlifyJuga terdeteksi. Tambahkan kedua variabel di pengaturan environment situs dengan scope Functions; server route berjalan sebagai function.
CloudflareBuild dengan preset cloudflare_module untuk Workers, atau hubungkan repo ke Pages, yang terdeteksi otomatis. Simpan kedua nilai sebagai secret, dan taruh di .dev.vars untuk wrangler dev.

Di Cloudflare, nilai environment hanya ada selama request berjalan. Route membaca useRuntimeConfig(event) di dalam handler, memang di sanalah tempatnya.

Uji gratis di localhost

Tanpa paket, workspace menerima 50 kiriman uji per bulan dari localhost, dinilai dari header Origin. fetch dari route Nitro tidak punya browser di belakangnya dan tidak mengirim Origin kecuali Anda mengaturnya. Itulah sebabnya route meneruskan own.origin: di bawah nuxt dev nilainya http://localhost:3000 dan dihitung sebagai uji, dan di produksi nilainya domain Anda. Hilangkan itu dan setiap uji lokal kembali dengan 402 plan_required.

Pastikan berfungsi

  1. Jalankan nuxt dev, login, buka halaman pengaturan sebuah proyek, dan kirim skor 2 dengan komentar.
  2. Di Submissions, pastikan user_id dan email cocok dengan akun Anda, screen bernilai projects-id-settings, dan page berisi path asli.
  3. Matikan JavaScript dan kirim lagi. Halaman dimuat ulang dengan baris terima kasih dan ?feedback=sent di alamat.
  4. Kirim ke /api/feedback dengan curl tanpa Origin. Route menjawab 403 dan tidak ada yang sampai ke Ikiform.
  5. Hapus cookie sesi dan kirim dari halaman yang terbuka. Formulir menampilkan pesan login.

Hal yang sering menjebak

MasalahYang harus dilakukan
403 dari route Anda sendiri di produksiProxy mengubah host, sehingga getRequestURL tidak sesuai dengan Origin browser. Buat proxy mengirim X-Forwarded-Host, yang dibaca oleh xForwardedHost: true.
screen bernilai unknown di setiap jawabanNama route dengan akhiran locale, seperti dari @nuxtjs/i18n, mengandung karakter yang ditolak pola. Perluas regex-nya, atau hapus akhiran itu di komponen.
Secret key kosong di produksiNama variabel harus NUXT_ ditambah key dalam upper snake case, NUXT_IKIFORM_SECRET_KEY, dan key itu harus ada di runtimeConfig.
402 plan_required pada uji lokalKiriman keluar tanpa Origin. Pertahankan header Origin: own.origin dan buka aplikasi di localhost.
422 validation_failedKiriman tidak cocok dengan definisi field formulir. error.fields menyebutkan setiap field dan alasannya. Samakan name input dengan ID field.

Pertanyaan

Mentok di salah satu langkah, atau ingin panduan untuk situasi Anda? Email hello@ikiform.com.