# feedback-tracker

A universal, embeddable feedback widget. One Web Component, four reference adapters, GDPR-friendly defaults. No framework, no build step, no vendor lock-in.

**Live demo:** <https://feedback.api.mpeters.dev/> · **CDN:** `https://feedback.api.mpeters.dev/feedback-tracker.js`

```html
<script type="module" src="https://feedback.api.mpeters.dev/feedback-tracker.js"></script>
<feedback-tracker
  adapter="pocketbase"
  endpoint="https://feedback.api.mpeters.dev"
  collection="feedback"
  collect-email
></feedback-tracker>
```

That's it. Floating button bottom-right, click → form, submit → the hosted PocketBase instance (or any backend you wire up via the [adapters](#bundled-adapters)).

## Why another one?

Existing widgets either lock you to one SaaS, require a backend you don't have, or aren't backend-agnostic. This one is a **Web Component** (works in any framework or none) with a **pluggable adapter system** so the backend is your choice. Three are bundled out of the box; writing a fourth is ~30 lines.

## Install

Three options, pick one:

**1. Use the hosted CDN** (zero setup — production deployment at `feedback.api.mpeters.dev`):

```html
<script type="module" src="https://feedback.api.mpeters.dev/feedback-tracker.js"></script>
```

**2. Self-host the static file**: copy `src/` to any static host and load it as an ES module:

```html
<script type="module" src="/path/to/feedback-tracker.js"></script>
```

**3. Via npm** (once published):

```bash
npm install feedback-tracker
```

```js
import 'feedback-tracker';
```

## Bundled adapters

| Adapter | Use when | Setup |
|---|---|---|
| `webhook` | You want to POST JSON anywhere — your own server, a Cloudflare Worker, a Discord/Slack webhook | `endpoint="..."` |
| `appwrite` | You want a real database with a free EU-hosted tier and an admin UI for free | `endpoint`, `project`, `database`, `collection` |
| `pocketbase` | You want a self-hosted single-binary backend on your homeserver | `endpoint`, `collection` |
| `web3forms` | You want feedback in your inbox in 60 seconds with no account | `access-key="..."` |

### Webhook (universal)

```html
<feedback-tracker
  adapter="webhook"
  endpoint="https://example.com/feedback"
></feedback-tracker>
```

POSTs JSON like:

```json
{
  "type": "idea",
  "message": "...",
  "email": "user@example.com",
  "meta": {
    "url": "https://yoursite.com/page",
    "userAgent": "...",
    "locale": "en-US",
    "timestamp": "2026-04-12T15:14:22.000Z"
  }
}
```

Discord and Slack webhooks need a slightly different shape — pass `data-format="discord"` or `data-format="slack"` and the adapter formats the body for you.

### Appwrite (GDPR-friendly default)

1. Create a project on [Appwrite Cloud](https://cloud.appwrite.io) and pick the Frankfurt region
2. Create a database and a collection called `feedback` with these attributes:
   - `type` (string, required)
   - `message` (string, required, max 5000)
   - `email` (string, optional)
   - `url` (string)
   - `userAgent` (string)
   - `locale` (string)
3. In the collection's **Settings → Permissions**, add `Create` permission for `Any`
4. Embed the widget:

```html
<feedback-tracker
  adapter="appwrite"
  endpoint="https://fra.cloud.appwrite.io/v1"
  project="YOUR_PROJECT_ID"
  database="YOUR_DB_ID"
  collection="feedback"
></feedback-tracker>
```

The Appwrite Console doubles as your admin UI for free.

### PocketBase (self-host or hosted)

[PocketBase](https://pocketbase.io) is a single Go binary (~30 MB) with SQLite, an admin UI, file storage, and rule-based permissions. The hosted instance at `https://feedback.api.mpeters.dev` is a PocketBase deployment configured exactly like the steps below — see [Self-hosting](#self-hosting-caddy--pocketbase) for how it's set up.

To run your own:

1. Download and run PocketBase:
   ```bash
   ./pocketbase serve
   ```
2. Open the admin UI at `http://127.0.0.1:8090/_/` and create an admin account.
3. Create a new collection called `feedback` with fields:
   - `type` (Plain text, required)
   - `message` (Plain text, required, max length 5000)
   - `email` (Email, optional)
   - `url` (URL)
   - `userAgent` (Plain text)
   - `locale` (Plain text)
   - `screenshot` (File, optional, max 5 MB, MIME types `image/png`, `image/jpeg`, `image/webp`) — required if you want to receive screenshots
4. In the collection's **API Rules** tab, set the **Create rule** to an empty string (just save it blank). Leave List/View/Update/Delete rules unset so the public can write but not read.
5. Embed the widget:

```html
<feedback-tracker
  adapter="pocketbase"
  endpoint="https://pb.example.com"
  collection="feedback"
></feedback-tracker>
```

The PocketBase admin UI is your triage dashboard. Pair with [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) to expose it from a homeserver without opening ports — or follow the [Caddy recipe](#self-hosting-caddy--pocketbase) below for a public droplet.

### Web3Forms (zero-setup)

1. Go to [web3forms.com](https://web3forms.com), enter your email, get an access key
2. Embed:

```html
<feedback-tracker
  adapter="web3forms"
  access-key="YOUR_ACCESS_KEY"
></feedback-tracker>
```

Submissions arrive in the email you registered. 250/month free.

## Configuration

| Attribute | Description |
|---|---|
| `adapter` | Which adapter to use (`webhook`, `appwrite`, `pocketbase`, `web3forms`, or any registered) |
| `accent` | CSS color for the button and accents (default `#6366f1`) |
| `label` | Trigger button text (default `Feedback`) |
| `collect-email` | Show an email field |
| `position` | `bottom-right` (default) or `bottom-left` |
| `hidden-trigger` | Hide the floating button (control programmatically) |

Adapter-specific attributes (`endpoint`, `project`, etc.) are passed through to the adapter's config object. Anything starting with `data-` is also passed through.

## Programmatic control

```js
import { FeedbackTracker, registerAdapter } from 'feedback-tracker';

// Register a custom adapter
registerAdapter('mybackend', {
  async submit(payload, config) {
    const res = await fetch(config.endpoint, {
      method: 'POST',
      body: JSON.stringify(payload),
    });
    return res.ok ? { ok: true } : { ok: false, error: await res.text() };
  },
});
```

```html
<feedback-tracker adapter="mybackend" endpoint="..."></feedback-tracker>
```

## Events

The widget dispatches `feedback-sent` and `feedback-error` custom events that bubble through the shadow root:

```js
document.querySelector('feedback-tracker').addEventListener('feedback-sent', (e) => {
  console.log('sent', e.detail.payload, e.detail.result);
});
```

## Spam protection

The widget ships with a honeypot field (catches naive bots for free). For real spam protection, put a [Cloudflare Turnstile](https://www.cloudflare.com/application-services/products/turnstile/) verifier in front of your endpoint — it's free, unlimited, and invisible. The webhook adapter is the natural place to add it: send the Turnstile token in the payload and verify server-side before storing.

## React / Next.js

A thin React wrapper ships at `feedback-tracker/react`. It registers the Web Component on mount (SSR-safe — registration happens in `useEffect`) and forwards camelCase props as kebab-case attributes:

```tsx
'use client';
import { FeedbackTracker } from 'feedback-tracker/react';

export default function Page() {
  return (
    <FeedbackTracker
      adapter="pocketbase"
      endpoint="https://feedback.api.mpeters.dev"
      collection="feedback"
      collectEmail
      accent="#6366f1"
      label="Send feedback"
      onSent={(d) => console.log('sent', d.payload, d.result)}
      onError={(d) => console.warn('failed', d.error)}
    />
  );
}
```

TypeScript types ship with the package (`src/react/index.d.ts`). All adapter-specific props (`endpoint`, `collection`, `project`, `database`, `bucket`, `accessKey`) and widget options (`accent`, `label`, `position`, `collectEmail`, `hiddenTrigger`, `noScreenshot`, `dataFormat`) are typed. `onSent` / `onError` receive the same `detail` payload as the underlying `feedback-sent` / `feedback-error` custom events.

To register a custom adapter from React code:

```ts
import { registerAdapter } from 'feedback-tracker/react';

await registerAdapter('mybackend', {
  async submit(payload, config) { /* ... */ },
});
```

## Demo

**Live:** <https://feedback.api.mpeters.dev/> — a Next.js static export using the React wrapper, deployed to the same droplet that hosts the widget files. Click the floating button in the bottom-right to submit to the hosted PocketBase instance.

**Local development:**

```bash
cd demo-next
npm install
npm run dev
# open http://localhost:3000
```

**Build the static export:**

```bash
npm run demo:build      # → demo-next/out/
npm run demo:preview    # → http://localhost:3000 (built output)
```

Edit `demo-next/app/page.tsx` to change adapters, endpoints, or layout. The demo imports the React wrapper from the workspace via a `file:..` dependency, so changes to `src/react/index.jsx` are picked up after `npm install`.

## Self-hosting (Caddy + PocketBase)

This is the exact recipe used to deploy the hosted instance at `https://feedback.api.mpeters.dev` on a $4/mo DigitalOcean droplet (Ubuntu, 1 vCPU, 512 MB). Replace the domain with your own.

**1. DNS.** Point an `A` record at your droplet's IP:

```
feedback.example.com    A    YOUR.DROPLET.IP    60
```

**2. Install Caddy** (terminates TLS, serves static files, reverse-proxies to PocketBase):

```bash
apt install -y debian-keyring debian-archive-keyring apt-transport-https curl gnupg
curl -fsSL https://dl.cloudsmith.io/public/caddy/stable/gpg.key \
  | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -fsSL https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt \
  > /etc/apt/sources.list.d/caddy-stable.list
apt update && apt install -y caddy
```

**3. Install PocketBase** as a systemd service:

```bash
PB_VER=$(curl -fsSL https://api.github.com/repos/pocketbase/pocketbase/releases/latest \
  | grep tag_name | head -1 | sed 's/.*"v\([^"]*\)".*/\1/')
curl -fsSL -o /tmp/pb.zip \
  "https://github.com/pocketbase/pocketbase/releases/download/v${PB_VER}/pocketbase_${PB_VER}_linux_amd64.zip"
apt install -y unzip
unzip -o /tmp/pb.zip pocketbase -d /usr/local/bin/
chmod +x /usr/local/bin/pocketbase
useradd --system --home /var/lib/pocketbase --shell /usr/sbin/nologin pocketbase
mkdir -p /var/lib/pocketbase && chown -R pocketbase:pocketbase /var/lib/pocketbase

cat > /etc/systemd/system/pocketbase.service <<'EOF'
[Unit]
Description=PocketBase
After=network.target

[Service]
Type=simple
User=pocketbase
Group=pocketbase
ExecStart=/usr/local/bin/pocketbase serve --http=127.0.0.1:8090 --dir=/var/lib/pocketbase/pb_data --publicDir=/var/lib/pocketbase/pb_public
Restart=always
RestartSec=5
LimitNOFILE=4096

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable --now pocketbase
```

**4. Deploy the widget files:**

```bash
mkdir -p /var/www/feedback-tracker
# from your dev machine:
rsync -az src/ root@YOUR.DROPLET.IP:/var/www/feedback-tracker/
rsync -az README.md LICENSE package.json root@YOUR.DROPLET.IP:/var/www/feedback-tracker/
rsync -az demo/index.html root@YOUR.DROPLET.IP:/var/www/feedback-tracker/index.html
```

**5. Configure Caddy** (`/etc/caddy/Caddyfile`):

```caddy
feedback.example.com {
    encode gzip zstd

    @cors_preflight method OPTIONS
    handle @cors_preflight {
        header Access-Control-Allow-Origin "*"
        header Access-Control-Allow-Methods "GET, POST, OPTIONS"
        header Access-Control-Allow-Headers "Content-Type, Authorization"
        header Access-Control-Max-Age "86400"
        respond 204
    }

    handle /api/* {
        reverse_proxy 127.0.0.1:8090
        header Access-Control-Allow-Origin "*"
    }

    handle /_/* {
        reverse_proxy 127.0.0.1:8090
    }

    handle {
        root * /var/www/feedback-tracker
        header {
            Access-Control-Allow-Origin "*"
            Cache-Control "public, max-age=300"
            ?X-Content-Type-Options "nosniff"
        }
        @js path *.js
        header @js Content-Type "application/javascript; charset=utf-8"
        file_server
    }
}
```

```bash
systemctl reload caddy
```

Caddy provisions a Let's Encrypt cert automatically on first request.

**6. Bootstrap the PocketBase superuser & feedback collection:**

```bash
sudo -u pocketbase /usr/local/bin/pocketbase superuser create \
  admin@example.com 'GENERATE-A-STRONG-PASSWORD' \
  --dir=/var/lib/pocketbase/pb_data
```

Then log into `https://feedback.example.com/_/` and create the `feedback` collection per the [PocketBase adapter section](#pocketbase-self-host-or-hosted) above. Or do it via API:

```bash
TOKEN=$(curl -sS -X POST https://feedback.example.com/api/collections/_superusers/auth-with-password \
  -H "Content-Type: application/json" \
  -d '{"identity":"admin@example.com","password":"YOUR_PASSWORD"}' | jq -r .token)

curl -sS -X POST https://feedback.example.com/api/collections \
  -H "Authorization: $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "name": "feedback",
    "type": "base",
    "fields": [
      {"name": "type",       "type": "text",  "required": true,  "max": 50},
      {"name": "message",    "type": "text",  "required": true,  "max": 5000},
      {"name": "email",      "type": "email", "required": false},
      {"name": "url",        "type": "url",   "required": false},
      {"name": "userAgent",  "type": "text",  "required": false, "max": 500},
      {"name": "locale",     "type": "text",  "required": false, "max": 20},
      {"name": "screenshot", "type": "file",  "required": false, "maxSelect": 1, "maxSize": 5242880,
        "mimeTypes": ["image/png","image/jpeg","image/webp"]}
    ],
    "createRule": "",
    "listRule": null, "viewRule": null, "updateRule": null, "deleteRule": null
  }'
```

**7. Embed the widget elsewhere:**

```html
<script type="module" src="https://feedback.example.com/feedback-tracker.js"></script>
<feedback-tracker
  adapter="pocketbase"
  endpoint="https://feedback.example.com"
  collection="feedback"
  collect-email
></feedback-tracker>
```

**Updating after code changes:**

```bash
rsync -az src/ root@YOUR.DROPLET.IP:/var/www/feedback-tracker/
```

No reload needed — Caddy serves new files immediately (the `Cache-Control: max-age=300` means embedders may take up to 5 minutes to pick up changes).

**Resource use** (observed on the live droplet): Caddy ~17 MB RAM, PocketBase ~10 MB RAM. Comfortably fits a 512 MB droplet.

## Adapter interface

To write your own adapter:

```js
export const myAdapter = {
  async submit(payload, config) {
    // payload = { type, message, email?, meta }
    // config  = attributes from the <feedback-tracker> tag
    // return  = { ok: true, id?: string } | { ok: false, error: string }
  },
};
```

Then register it before the element is connected:

```js
import { registerAdapter } from 'feedback-tracker';
import { myAdapter } from './my-adapter.js';
registerAdapter('mine', myAdapter);
```

## Roadmap

- Built-in Cloudflare Turnstile support
- More adapters: Supabase, GitHub Issues (via Worker proxy), Brevo (via Worker proxy)
- i18n
- Custom field schemas

Recently shipped: Lucide icon set, screenshot capture + redaction, hosted PocketBase deployment, React wrapper, Next.js static-export demo.

## License

MIT
