Feedback pin system for site testers to use - Sharing in case anyone else needs it

I’ve used and tweaked this feedback pin system on my projects. Great for having users test and be able to just drop a pin wherever they have an issue/question - and then add annotation. Thought it might be helpful to other people here! I’ve modified it to show all feedback in Admin when I’ve needed it so I can check off the fixes/debugs/improvements one by one. Not a coder - so, I’m sure it’s not as well-written as it could be - but it works.

Feedback Pin System — Implementation Guide

Feedback pin system that lets logged-in users drop pins anywhere on a page, leave comments, and manage feedback. Admins can see all feedback site-wide; regular users see only their own.


1. Database Schema

Create a site_annotations table (Drizzle ORM + PostgreSQL):

import { pgTable, serial, varchar, text, integer, timestamp } from “drizzle-orm/pg-core”;

import { createInsertSchema } from “drizzle-zod”;

import { z } from “zod”;

export const siteAnnotations = pgTable(“site_annotations”, {

id: serial(“id”).primaryKey(),

userId: varchar(“user_id”).notNull(),

userName: text(“user_name”),

pagePath: text(“page_path”).notNull(),

elementSelector: text(“element_selector”),

xPercent: integer(“x_percent”).notNull(),

yPercent: integer(“y_percent”).notNull(),

comment: text(“comment”).notNull(),

status: text(“status”).default(“open”).notNull(),

createdAt: timestamp(“created_at”).defaultNow(),

});

export const insertSiteAnnotationSchema = createInsertSchema(siteAnnotations).omit({

id: true,

createdAt: true,

});

export type InsertSiteAnnotation = z.infer;

export type SiteAnnotation = typeof siteAnnotations.$inferSelect;

Field notes

  • xPercent and yPercent store absolute pixel coordinates (scrollX + clientX, scrollY + clientY) — not percentages despite the column names. This ensures pins stay exactly where they were placed regardless of viewport size.
  • pagePath stores the URL path (e.g. /, /admin/orders, /products/123). Annotations are scoped to the page they were created on — they only appear on that specific path and are never shown on other pages.
  • status is either “open” or “resolved”.

2. Storage Interface

Add these methods to your storage interface and implement them with Drizzle:

interface IStorage {

getAnnotations(): Promise<SiteAnnotation>;

getAnnotation(id: number): Promise<SiteAnnotation | undefined>;

getAnnotationsByPage(pagePath: string): Promise<SiteAnnotation>;

getAnnotationsByUser(userId: string): Promise<SiteAnnotation>;

createAnnotation(annotation: InsertSiteAnnotation): Promise;

updateAnnotationStatus(id: number, status: string): Promise<SiteAnnotation | undefined>;

deleteAnnotation(id: number): Promise;

}

Drizzle implementation

import { eq, desc } from “drizzle-orm”;

async getAnnotations() {

return db.select().from(siteAnnotations).orderBy(desc(siteAnnotations.createdAt));

}

async getAnnotation(id: number) {

const [result] = await db.select().from(siteAnnotations).where(eq(siteAnnotations.id, id));

return result;

}

async getAnnotationsByPage(pagePath: string) {

return db

.select()

.from(siteAnnotations)

.where(eq(siteAnnotations.pagePath, pagePath))

.orderBy(siteAnnotations.createdAt);

}

async getAnnotationsByUser(userId: string) {

return db

.select()

.from(siteAnnotations)

.where(eq(siteAnnotations.userId, userId))

.orderBy(desc(siteAnnotations.createdAt));

}

async createAnnotation(annotation: InsertSiteAnnotation) {

const [result] = await db.insert(siteAnnotations).values(annotation).returning();

return result;

}

async updateAnnotationStatus(id: number, status: string) {

const [result] = await db

.update(siteAnnotations)

.set({ status })

.where(eq(siteAnnotations.id, id))

.returning();

return result;

}

async deleteAnnotation(id: number) {

await db.delete(siteAnnotations).where(eq(siteAnnotations.id, id));

}


3. API Routes

Create these endpoints (e.g. server/routes/annotations.ts). All require authentication. The CSV export requires admin.

Method Endpoint Auth Description
GET /api/annotations User Returns all annotations (admin) or user’s own
GET /api/annotations/page?path=… User Returns annotations for a specific page path. Admins see all; users see only their own.
POST /api/annotations User Creates a new annotation. Server attaches userIdand userName from session. Body: { pagePath, xPercent, yPercent, comment }
PATCH /api/annotations/:id User Updates status. Body: { status }. Users can only update their own; admins can update any.
DELETE /api/annotations/:id User Deletes an annotation. Users can only delete their own; admins can delete any.
GET /api/annotations/export Admin Returns a CSV file of all annotations.

Important: page-scoped queries

The GET /api/annotations/page endpoint is the primary fetch used by the widget. It must filter by pagePath so that pins only appear on the page where they were created. Never return all annotations for rendering — always filter by the current page path.

router.get(“/annotations/page”, requireAuth, async (req, res) => {

const pagePath = req.query.path as string;

if (!pagePath) return res.status(400).json({ error: “path query parameter required” });

const allForPage = await storage.getAnnotationsByPage(pagePath);

// Admins see all pins on the page; regular users see only their own

const isAdmin = req.user.role === “admin”;

const annotations = isAdmin

? allForPage

: allForPage.filter((a) => a.userId === req.user.id);

res.json(annotations);

});

Important: ownership checks on mutations

Users must only be able to update or delete their own annotations. Always verify ownership server-side:

router.delete(“/annotations/:id”, requireAuth, async (req, res) => {

const annotation = await storage.getAnnotation(Number(req.params.id));

if (!annotation) return res.status(404).json({ error: “Not found” });

const isAdmin = req.user.role === “admin”;

if (annotation.userId !== req.user.id && !isAdmin) {

return res.status(403).json({ error: “Forbidden” });

}

await storage.deleteAnnotation(annotation.id);

res.json({ success: true });

});

Register routes in your main routes file:

import { registerAnnotationRoutes } from “./routes/annotations”;

registerAnnotationRoutes(app, requireAdmin, requireAuth);


4. Frontend Component: AnnotationWidget

A single React component mounted globally in App.tsx so it appears on every page. It manages several modes/states.

Dependencies

  • lucide-react icons: MessageSquarePlus, X, Send, MapPin, CheckCircle2, Trash2, Download, Sparkles, MousePointerClick, MessageCircle, ArrowRight
  • @tanstack/react-query for data fetching
  • Your auth context (needs a user object with id, role, and name or email)

Core Behavior

A. Floating Action Button (FAB)

  • Fixed position bottom-right corner, z-index 10001
  • Shows a MessageSquarePlus icon (amber-800 background)
  • Pulse/glow animation on first visit per session (use a sessionStorage key to track dismissal)
  • On hover, shows a tooltip explaining the feature
  • Click toggles “feedback mode” on/off

B. Feedback Mode (active = true)

  • Shows control buttons: “View All / My Feedback” (opens sidebar list) and “+ Add Feedback” (starts pin placement)
  • Renders an absolute-positioned overlay spanning the full scroll height of the page
  • Overlay z-index: 9998, pointer-events: none
  • Pins are fetched only for the current page path via GET /api/annotations/page?path=. Pins from other pages are never loaded or rendered.
  • Existing pins render as colored circles with MapPin icons:
    • Amber = open status
    • Green = resolved status
  • Clicking a pin toggles its popup card showing the comment, author, date, and action buttons (Resolve/Reopen, Delete)

C. Pin Placement Mode (placing = true)

  • Top banner: “Click anywhere on the page to place a feedback pin” with a Cancel button (z-index 10000)
  • Cursor changes to crosshair (document.body.style.cursor = “crosshair”)
  • Click handler captures absolute coordinates: x = e.clientX + window.scrollX, y = e.clientY + window.scrollY
  • After clicking, shows a PendingPinCard at the clicked position with:
    • A blue pin marker
    • A textarea for the comment
    • Cancel and Save buttons
    • Cmd/Ctrl+Enter keyboard shortcut to submit
  • On save, the annotation is created via POST /api/annotations with the current page path and absolute coordinates
  • On cancel, the pending pin disappears immediately with no data saved

D. Feedback List Sidebar (showList = true)

  • Fixed right sidebar (z-index 10000), 384px wide
  • Shows all annotations (admin) or user’s own
  • Each item shows: page path, status badge, comment text, author, date
  • Action buttons per item: Resolve/Reopen toggle, Delete
  • Admin gets a CSV export download link

E. Onboarding Helper

  • 2-step tutorial card that appears on first activation
  • Step 1: How to use (3 numbered steps with icons)
  • Step 2: Example feedback prompts (customize for your product)
  • Pagination dots and Next/Done buttons
  • Appears at bottom-right above the FAB

5. Pin Positioning — Making Pins Stay Where They Are Placed

This is the most critical part of the system. Pins must appear at the exact spot the user clicked, regardless of scrolling, viewport resizing, or page revisits.

Coordinate system

Pins use absolute pixel coordinates relative to the full document (not the viewport):

// On click — capture absolute position

const x = Math.round(e.clientX + window.scrollX);

const y = Math.round(e.clientY + window.scrollY);

These values are stored in the database as integers (xPercent / yPercent columns).

Rendering pins in the correct position

Pins are rendered inside an absolute-positioned overlay that matches the full document scroll height. This overlay is the coordinate space that makes stored positions line up:

function PinOverlay({ annotations, onPinClick, selectedId }) {

const [docHeight, setDocHeight] = useState(document.documentElement.scrollHeight);

useEffect(() => {

const ro = new ResizeObserver(() => {

setDocHeight(document.documentElement.scrollHeight);

});

ro.observe(document.documentElement);

return () => ro.disconnect();

}, );

return (

<div

style={{

position: “absolute”,

top: 0,

left: 0,

width: “100%”,

height: ${docHeight}px,

pointerEvents: “none”,

zIndex: 9998,

}}

{annotations.map((ann) => (

<div

key={ann.id}

onClick={() => onPinClick(ann.id)}

style={{

position: “absolute”,

left: ${ann.xPercent}px,

top: ${ann.yPercent}px,

pointerEvents: “auto”,

cursor: “pointer”,

transform: “translate(-50%, -100%)”,

}}

<MapPin

size={28}

fill={ann.status === “resolved” ? “#22c55e” : “#f59e0b”}

color=“white”

/>

))}

);

}

Why absolute pixels (not percentages)

  • Percentage-based positioning breaks when the page layout changes (e.g. sidebar toggled, content loaded dynamically, different screen sizes).
  • Absolute pixel coordinates are stable as long as the page content above the pin remains the same height.
  • The ResizeObserver on document.documentElement keeps the overlay height in sync if content changes dynamically.

Tracking document height changes

Use a ResizeObserver on document.documentElement to update the overlay height whenever the page content grows or shrinks:

useEffect(() => {

const ro = new ResizeObserver(() => {

setDocHeight(document.documentElement.scrollHeight);

});

ro.observe(document.documentElement);

return () => ro.disconnect();

}, );


6. Data Lifecycle — When Pins Persist and When They Don’t

Pins persist when:

  • The user clicks Save after placing a pin and entering a comment. Only then is a POST /api/annotations call made and the pin stored in the database.
  • The page is revisited — pins are fetched from the API on each page load, filtered by the current page path.

Pins do NOT persist when:

  • The user is in pin placement mode but cancels before saving. No API call is made. The pending pin UI is discarded immediately.
  • The user starts typing a comment but navigates away or closes the card without saving. Nothing is stored.
  • The user deletes a pin (via the popup card or sidebar). A DELETE /api/annotations/:id call removes it permanently.
  • The user marks a pin as “resolved”. The pin remains in the database but changes color (amber to green). It can be reopened or deleted later.

Scoped visibility:

  • Pins are scoped to the page path they were created on. A pin placed on /products/123 will never appear on /products/456 or /dashboard.
  • The widget fetches annotations using GET /api/annotations/page?path= — only annotations matching the exact current path are returned.
  • Regular users see only their own pins. Admins see all pins on the current page.
  • The sidebar list shows all of a user’s pins across all pages (or all pins for admins), but the on-page pin rendering is always filtered by current path.

React Query cache invalidation:

After any mutation (create, update status, delete), invalidate the annotation queries so the UI stays in sync:

const queryClient = useQueryClient();

const createMutation = useMutation({

mutationFn: (data) => fetch(“/api/annotations”, { method: “POST”, body: JSON.stringify(data) }),

onSuccess: () => {

queryClient.invalidateQueries({ queryKey: [“annotations”] });

},

});

const deleteMutation = useMutation({

mutationFn: (id) => fetch(/api/annotations/${id}, { method: “DELETE” }),

onSuccess: () => {

queryClient.invalidateQueries({ queryKey: [“annotations”] });

},

});


7. Page Path Detection

Standard routing (React Router, Wouter, etc.)

import { useSyncExternalStore } from “react”;

function getPathname(): string {

return window.location.pathname;

}

function subscribeToPathname(callback: () => void) {

window.addEventListener(“popstate”, callback);

return () => window.removeEventListener(“popstate”, callback);

}

function useCurrentPagePath(): string {

return useSyncExternalStore(subscribeToPathname, getPathname, getPathname);

}

Hash-based routing (/#/path)

import { useSyncExternalStore } from “react”;

function getHashPath(): string {

const hash = window.location.hash;

if (!hash || hash === “#” || hash === “#/”) return “/”;

const path = hash.startsWith(“#/”) ? hash.slice(1) : hash.startsWith(“#”) ? “/” + hash.slice(1) : hash;

return path.replace(//+$/, “”) || “/”;

}

function subscribeToHash(callback: () => void) {

window.addEventListener(“hashchange”, callback);

return () => window.removeEventListener(“hashchange”, callback);

}

function useCurrentPagePath(): string {

return useSyncExternalStore(subscribeToHash, getHashPath, getHashPath);

}


8. Viewport Clamping

Pin popups and pending pin cards can overflow the viewport edges. Use a useClampToViewport hook that adjusts the card’s position after mount:

function useClampToViewport(ref: React.RefObject) {

useEffect(() => {

if (!ref.current) return;

requestAnimationFrame(() => {

const el = ref.current;

if (!el) return;

const rect = el.getBoundingClientRect();

let tx = 0;

let ty = 0;

if (rect.right > window.innerWidth) tx = window.innerWidth - rect.right - 8;

if (rect.left < 0) tx = -rect.left + 8;

if (rect.bottom > window.innerHeight) ty = window.innerHeight - rect.bottom - 8;

if (rect.top < 0) ty = -rect.top + 8;

if (tx !== 0 || ty !== 0) {

el.style.transform = translate(${tx}px, ${ty}px);

}

});

}, [ref]);

}


9. z-index Layers

z-index Element
9998 Pin overlay (pointer-events: none; individual pins are pointer-events: auto)
9999 Pin popups and pending pin cards
10000 Placement mode banner, feedback list sidebar
10001 FAB button
10002 Onboarding helper card

10. CSS Animations

@keyframes fadeSlideUp {

from { opacity: 0; transform: translateY(12px); }

to { opacity: 1; transform: translateY(0); }

}

@keyframes glowPulse {

0%, 100% { opacity: 0.6; transform: scale(1); }

50% { opacity: 1; transform: scale(1.15); }

}

@keyframes buttonGlow {

0%, 100% {

box-shadow: 0 0 16px 6px rgba(253, 224, 71, 0.5),

0 0 32px 12px rgba(253, 224, 71, 0.25);

}

50% {

box-shadow: 0 0 24px 10px rgba(253, 224, 71, 0.7),

0 0 48px 20px rgba(253, 224, 71, 0.35);

}

}


11. Mount the Widget

In your main App.tsx, render the widget inside your app layout — after all routes, but inside your auth and query providers:

import { AnnotationWidget } from “@/components/AnnotationWidget”;

function App() {

return (

{/* your routes */}

);

}


12. Authentication

The widget needs a user object with at minimum: id, name (or email), and role.

If you’re on Replit, use Replit Auth (OpenID Connect with PKCE). It gives every Replit user a login without any custom forms. To distinguish admins, either:

  • Set an ADMIN_USER_IDS environment variable with a comma-separated list of user IDs, and check against it in your middleware.
  • Or add a role column to your users table that defaults to “user” and can be manually set to “admin”.

The widget checks user?.role === “admin” for admin features (seeing all pins, CSV export). Adapt this check to match however your app determines admin status.


13. Customization Checklist

Item What to change
Colors The system uses amber tones (amber-800 primary, amber-500 for open pins, green-500 for resolved). Adjust to match your brand.
Onboarding text The helper card should have example feedback prompts relevant to your product.
Session storage key The HELPER_SEEN_KEY constant controls the pulse animation and helper card. Use a unique name for your app (e.g. “myapp-feedback-helper-seen”).
Auth hook The widget uses const { user } = useAuth(). Point this at your auth provider.
Routing Use the standard or hash-based useCurrentPagePath hook depending on your router (see Section 7).
API base URL If your API is not at /api, update the fetch URLs in the widget.

14. Files to Create/Modify

File Action
shared/schema.ts (or your schema file) Add siteAnnotations table, insert schema, types
server/storage.ts (or your storage layer) Add 7 annotation methods to interface + implementation
server/routes/annotations.ts Create new file with 6 endpoints
server/routes.ts Register annotation routes
client/src/components/AnnotationWidget.tsx Create new file (~650 lines) with the full widget
client/src/App.tsx Import and mount
1 Like

So, is this essential the Replit suggestion button but without the forced Replit advertising?

I really appreciate the share. The idea and concept was cool. After seeing how users who have no affiliation to Replit,sign up for my products, and get emailed from replit marketing department I realized I have to be even more careful with production apps, and apps I intend to release for monetizing.

Keep the suggestions and ideas coming!