Hey there! π Tap & Tell is a modern, NFC-powered digital guestbook that transforms how guests leave their mark at events. Guests tap their phone on an NFC tag (or scan a QR code), and a beautiful multi-step wizard guides them through leaving their name, photo, and a personal message. No app install required! π±β¨
Perfect for weddings π, birthday parties π, corporate events π’, or any gathering where you want to capture memories digitally!
Note
π€ AI-Aided Development (AIAD)
This project openly uses AI-assisted development (e.g. Claude Code) to accelerate workflows, improve code quality, and gain more development momentum. All AI-generated code is reviewed and approved by humans β this is not a vibe-coding project, but a deliberate effort to build a useful product while exploring the boundaries, benefits, and trade-offs of AI-aided development.
Glad you asked! Here's the good stuff:
- π± NFC & QR Code Entry β Guests tap an NFC tag or scan a QR code to open the guestbook instantly β no app download needed!
- π§ Multi-Step Wizard β A beautiful 4-step form guides guests through leaving their entry (Basics β Favorites β Fun Facts β Message)
- πΈ Photo Upload with Compression β Guests snap a selfie or upload a photo, automatically compressed client-side for fast uploads
- π¨ Polaroid-Style Cards β Entries are displayed as gorgeous polaroid-style cards with handwritten fonts
- π Dark Mode β Full light/dark/system theme support with zero flash of unstyled content (FOUC)
- π Multilingual β English and German out of the box with
@nuxtjs/i18n - π₯οΈ Slideshow Mode β Full-screen auto-advancing slideshow, perfect for displaying on a TV at your event
- π PDF Export β Download your entire guestbook as a beautifully formatted PDF
- π Admin Dashboard β Password-protected admin panel for entry moderation (approve, reject, delete)
- π Entry Moderation β Three-state system: pending β approved / rejected β keep your guestbook clean!
- π Offline Support β Entries are queued in IndexedDB when offline and synced when back online
- π± PWA Ready β Install as a Progressive Web App on any device
- π³ Docker Support β Ready-to-use Dockerfile and docker-compose for easy self-hosting
The magic is simple β here's the flow:
1. π² Guest taps NFC tag or scans QR code
β
2. π Browser opens Tap & Tell (no app install!)
β
3. π§ 4-step wizard collects:
Step 1: Name + Photo (required)
Step 2: Favorites β color, food, movie, song, video (optional)
Step 3: Fun Facts β superpowers, hidden talents, preferences (optional)
Step 4: Personal Message (required)
β
4. πΎ Entry saved with photo compression
β
5. π Entry appears in the guestbook!
Note
π Steps 1 (Basics) and 4 (Message) are required. Steps 2 (Favorites) and 3 (Fun Facts) are completely optional β guests can skip them!
Ready to set up your own digital guestbook? Let's go! π
- Node.js 18+ installed
- pnpm package manager (
npm install -g pnpm)
# 1. Clone the repo
git clone https://github.com/Disane87/tap-and-tell.git
cd tap-and-tell
# 2. Install dependencies
pnpm install
# 3. Start the dev server
pnpm devThat's it! Open http://localhost:3000 and you're running! π
Create a .env file in the project root:
# PostgreSQL connection string
POSTGRES_URL=postgresql://user:password@localhost:5432/tapandtell
# JWT signing secret (CHANGE THIS in production!)
JWT_SECRET=your-jwt-secret-here
# CSRF token signing secret (CHANGE THIS in production!)
CSRF_SECRET=your-csrf-secret-here
# Master encryption key for photo encryption (64 hex chars, REQUIRED in production!)
ENCRYPTION_MASTER_KEY=
# Storage directory for entries and photos
DATA_DIR=.dataCaution
JWT_SECRET, CSRF_SECRET, and ENCRYPTION_MASTER_KEY in production!
Prefer containers? We've got you covered!
The docker-compose.prod.yml is a self-contained stack (app + PostgreSQL) ready for Portainer or any Docker host.
1. Generate secrets
All secrets must be set before first start. Generate them with openssl:
# JWT signing secret (64-char hex)
openssl rand -hex 32
# CSRF token secret (64-char hex)
openssl rand -hex 32
# Photo encryption master key (64-char hex)
openssl rand -hex 32
# PostgreSQL password (64-char hex)
openssl rand -hex 32
# API token secret (base64)
openssl rand -base64 322. Configure environment variables
Set the generated values in docker-compose.prod.yml or pass them as environment variables:
| Variable | Format | Description |
|---|---|---|
POSTGRES_PASSWORD |
64-char hex | PostgreSQL password (same in postgres and app services) |
JWT_SECRET |
64-char hex | JWT signing key for authentication |
CSRF_SECRET |
64-char hex | CSRF double-submit cookie secret |
ENCRYPTION_MASTER_KEY |
64-char hex | AES-256-GCM photo encryption key |
TOKEN_SECRET |
base64 string | API token signing secret |
DB_SSL |
"false" |
Set to "false" for Docker-to-Docker connections (no SSL). Omit for external DBs (Neon, Supabase) where SSL is required. |
Caution
Never commit secrets to version control. Use environment variables, Docker secrets, or Portainer's environment variable UI instead.
3. Deploy
# Direct Docker Compose
docker compose -f docker-compose.prod.yml up -dOr in Portainer: Stacks β Add Stack β paste the compose file β set environment variables in the UI.
docker compose up -d# Build the image
docker build -t tap-and-tell .
# Run the container
docker run -d \
-p 3000:3000 \
-e POSTGRES_URL=postgresql://user:password@host:5432/tapandtell \
-e DB_SSL=false \
-e JWT_SECRET=$(openssl rand -hex 32) \
-e CSRF_SECRET=$(openssl rand -hex 32) \
-e ENCRYPTION_MASTER_KEY=$(openssl rand -hex 32) \
-e TOKEN_SECRET=$(openssl rand -base64 32) \
-v tap-and-tell-data:/app/data \
tap-and-tellImportant
π Mount a volume to /app/data to persist your guestbook entries and photos across container restarts!
Here's a tour of everything Tap & Tell offers:
The main entry point for guests! Features:
- π Swipeable Carousel β Intro slide followed by existing entry slides
- π Bottom Sheet Wizard β The 4-step form slides up from the bottom
- β¨οΈ Keyboard & Swipe Navigation β Navigate entries with arrow keys or swipe gestures
- π± NFC Context Detection β Personalized welcome when entering via NFC tag
- π΅ Pagination Dots β Visual indicators for carousel position
Browse all approved entries in a beautiful grid:
- π Search by Name β Debounced search (300ms) for instant filtering
- π Sort Options β Newest first or oldest first
- π PDF Export β Download the entire guestbook as a formatted PDF
- π₯οΈ Slideshow Link β Quick access to slideshow mode
- π Detail View β Click any card to see the full entry in a bottom sheet
Perfect for displaying on a TV at your event!
βΆοΈ Auto-Advancing β Configurable interval (3β30 seconds, default 8)- βΈοΈ Play/Pause Controls β Take control when you want
- π₯οΈ Fullscreen Mode β True fullscreen for maximum impact
- β¨οΈ Keyboard Controls β Arrow keys, Space, P (pause), F (fullscreen), ESC (exit)
- π» Auto-Hide Controls β Controls fade away during playback
Manage your guestbook with a password-protected admin panel:
- π Status Tabs β Filter by All, Pending, Approved, Rejected
- β Bulk Actions β Approve or reject multiple entries at once
- ποΈ Individual Management β Delete or change status of single entries
- π’ Entry Counts β See counts per status at a glance
- πͺ Secure Logout β Token-based session management
Generate QR codes for your event:
- π― Custom Event Name β Embed your event name in the URL
- π₯ Download Options β Export as PNG or SVG
- π Copy URL β Quick copy to clipboard
- π NFC-Compatible URLs β Generates
?source=nfc&event=YourEventlinks
Let's peek under the hood! Here's how Tap & Tell is built:
| Layer | Technology |
|---|---|
| Framework | Nuxt 4.3 (SSR disabled β client-side SPA) |
| UI Library | Vue 3.5 with Composition API |
| Styling | Tailwind CSS v4 via @tailwindcss/vite |
| Components | shadcn-vue (headless UI) |
| Language | TypeScript 5.9 |
| Icons | Lucide Vue Next |
| Database | PostgreSQL 16+ with Row-Level Security (RLS) |
| ORM | Drizzle ORM |
| Auth | JWT (jose) + 2FA (TOTP / Email OTP) |
| Encryption | AES-256-GCM per-tenant photo encryption |
| i18n | @nuxtjs/i18n (EN + DE) |
| PDF Generation | jsPDF |
| QR Codes | qrcode |
| Utilities | VueUse |
| Toasts | vue-sonner |
| PWA | @vite-pwa/nuxt |
| Package Manager | pnpm |
| Deployment | Docker (self-hosted) |
tap-and-tell/
βββ app/ # π₯οΈ Nuxt client application
β βββ pages/ # Route pages
β βββ components/ # Vue components
β β βββ form/ # Wizard form steps
β β βββ ui/ # shadcn-vue base components
β βββ composables/ # Vue composables (state & logic)
β βββ types/ # TypeScript type definitions
β βββ plugins/ # Nuxt client plugins
β βββ layouts/ # Page layouts
β βββ lib/ # Utility functions
β βββ assets/ # Static assets (CSS, images)
β
βββ server/ # βοΈ Nitro server
β βββ routes/api/ # API endpoints
β β βββ g/ # Public guest endpoints (flat routes)
β β βββ auth/ # Authentication + 2FA
β β βββ tenants/ # Tenant/guestbook management
β β βββ photos/ # Photo serving (encrypted)
β βββ database/ # Schema + migrations (Drizzle ORM)
β βββ utils/ # Server utilities (crypto, auth, storage)
β βββ plugins/ # Server startup plugins
β
βββ i18n/ # π Internationalization
β βββ locales/ # EN + DE translation files
β
βββ public/ # π Static public assets
β βββ icons/ # PWA icons
β
βββ plans/ # π Development plan documents
βββ nuxt.config.ts # βοΈ Nuxt configuration
βββ package.json # π¦ Dependencies
βββ tsconfig.json # π§ TypeScript config
Tap & Tell uses PostgreSQL 16+ with Row-Level Security (RLS) for multi-tenant data isolation. Photos are stored on disk with AES-256-GCM per-tenant encryption.
PostgreSQL (via Drizzle ORM)
βββ users, sessions, user_two_factor # Auth & 2FA
βββ tenants, tenant_members # Multi-tenancy
βββ guestbooks, entries # Core data (RLS-protected)
βββ audit_logs, api_apps, api_tokens # Security & API access
.data/photos/
βββ [guestbookId]/[entryId].[ext] # AES-256-GCM encrypted photos
βββ ...
Note
Photo storage is configurable via STORAGE_DRIVER (local, vercel-blob, or s3) and DATA_DIR (default: .data/).
All API endpoints at a glance. Authenticated endpoints use HTTP-only JWT cookies with CSRF protection.
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/g/[id]/info |
Guestbook info (name, settings, type) |
GET |
/api/g/[id]/entries |
Approved entries for a guestbook |
POST |
/api/g/[id]/entries |
Create a new guest entry (rate-limited) |
GET |
/api/photos/[tenantId]/[filename] |
Serve encrypted photo |
GET |
/api/health |
Health check endpoint |
GET |
/api/og |
Locale-aware OG image (?lang=de|en) |
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/auth/login |
Login with email/password, set JWT cookies |
POST |
/api/auth/register |
Register a new account |
POST |
/api/auth/logout |
Clear auth cookies |
POST |
/api/auth/refresh |
Refresh access token |
GET |
/api/auth/me |
Get current user profile |
PUT |
/api/auth/me |
Update name and/or email |
DELETE |
/api/auth/me |
Delete account (requires password) |
PUT |
/api/auth/password |
Change password |
GET |
/api/auth/csrf |
Get CSRF token |
POST |
/api/auth/avatar |
Upload avatar (multipart, max 5 MB) |
DELETE |
/api/auth/avatar |
Delete avatar |
GET |
/api/auth/avatar/[userId] |
Serve avatar image (public) |
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/auth/2fa/setup |
Start 2FA setup (returns QR code) |
POST |
/api/auth/2fa/verify-setup |
Verify TOTP code to activate 2FA |
GET |
/api/auth/2fa/status |
Check if 2FA is enabled |
POST |
/api/auth/2fa/verify |
Verify 2FA code during login |
POST |
/api/auth/2fa/disable |
Disable 2FA |
POST |
/api/auth/2fa/resend |
Resend email OTP code |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/tenants |
List user's tenants |
POST |
/api/tenants |
Create a new tenant |
GET |
/api/tenants/[uuid] |
Get tenant details |
PUT |
/api/tenants/[uuid] |
Update tenant settings |
DELETE |
/api/tenants/[uuid] |
Delete tenant |
POST |
/api/tenants/[uuid]/rotate-key |
Rotate encryption key |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/tenants/[uuid]/guestbooks |
List guestbooks with entry counts |
POST |
/api/tenants/[uuid]/guestbooks |
Create a new guestbook |
GET |
/api/tenants/[uuid]/guestbooks/[gbUuid] |
Get guestbook details |
PUT |
/api/tenants/[uuid]/guestbooks/[gbUuid] |
Update guestbook settings |
DELETE |
/api/tenants/[uuid]/guestbooks/[gbUuid] |
Delete guestbook (cascades entries) |
POST |
/api/tenants/[uuid]/guestbooks/[gbUuid]/header |
Upload header image |
DELETE |
/api/tenants/[uuid]/guestbooks/[gbUuid]/header |
Delete header image |
POST |
/api/tenants/[uuid]/guestbooks/[gbUuid]/background |
Upload background image |
DELETE |
/api/tenants/[uuid]/guestbooks/[gbUuid]/background |
Delete background image |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/tenants/[uuid]/guestbooks/[gbUuid]/entries |
All entries (admin view) |
PATCH |
/api/tenants/[uuid]/guestbooks/[gbUuid]/entries/[id] |
Update entry status |
DELETE |
/api/tenants/[uuid]/guestbooks/[gbUuid]/entries/[id] |
Delete an entry |
POST |
/api/tenants/[uuid]/guestbooks/[gbUuid]/entries/bulk |
Bulk status update |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/tenants/[uuid]/members |
List team members |
POST |
/api/tenants/[uuid]/members/invite |
Invite team member |
GET |
/api/tenants/[uuid]/members/invites |
List pending invites |
DELETE |
/api/tenants/[uuid]/members/invites/[id] |
Cancel invite |
DELETE |
/api/tenants/[uuid]/members/[userId] |
Remove team member |
GET |
/api/invites/[token] |
Get invite details (public) |
POST |
/api/invites/accept |
Accept team invite (public) |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/tenants/[uuid]/apps |
List API apps |
POST |
/api/tenants/[uuid]/apps |
Create API app |
GET |
/api/tenants/[uuid]/apps/[appId] |
Get API app details |
PUT |
/api/tenants/[uuid]/apps/[appId] |
Update API app |
DELETE |
/api/tenants/[uuid]/apps/[appId] |
Delete API app |
GET |
/api/tenants/[uuid]/apps/[appId]/tokens |
List tokens |
POST |
/api/tenants/[uuid]/apps/[appId]/tokens |
Create token |
DELETE |
/api/tenants/[uuid]/apps/[appId]/tokens/[tokenId] |
Revoke token |
GET |
/api/scopes |
List available API scopes |
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/analytics/events |
Track analytics event |
GET |
/api/tenants/[uuid]/analytics/overview |
Dashboard overview |
GET |
/api/tenants/[uuid]/analytics/traffic |
Traffic analytics |
GET |
/api/tenants/[uuid]/analytics/sources |
Traffic sources |
GET |
/api/tenants/[uuid]/analytics/devices |
Device breakdown |
GET |
/api/tenants/[uuid]/analytics/funnel |
Conversion funnel |
# Login
curl -X POST /api/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "you@example.com", "password": "your-password"}'
# β Sets access_token and refresh_token cookies
# Create a guest entry (public, no auth)
curl -X POST /api/g/your-guestbook-id/entries \
-H "Content-Type: application/json" \
-d '{
"name": "Jane Doe",
"message": "What an amazing event!",
"photo": "data:image/jpeg;base64,..."
}'The brain of Tap & Tell lives in these composables β each one handles a specific concern:
| Composable | What It Does |
|---|---|
useAuth() |
π JWT cookie-based authentication (login, register, logout, profile) |
useGuests() |
π CRUD operations for guestbook entries. Module-level shared state across the app |
useGuestForm() |
π§ 4-step wizard state management with per-step validation |
useGuestbook() |
π Public guestbook operations using flat /api/g/[id] endpoints |
useTenantAdmin() |
π Admin entry operations (fetch all, delete, update status, bulk) |
useTheme() |
π Light/dark/system theme with localStorage persistence & FOUC prevention |
useNfc() |
π± Detects NFC context from URL query params (?source=nfc&event=...) |
useSlideshow() |
π₯οΈ Auto-advancing slideshow with play/pause/fullscreen controls |
useEntryFilters() |
π Debounced search & sort for the guestbook page |
usePdfExport() |
π Multi-page PDF generation with photos, favorites, and fun facts |
useImageCompression() |
πΈ Client-side image compression (max 1920px, target 500KB) |
useOfflineQueue() |
π IndexedDB-based offline entry queuing with auto-sync |
Tap & Tell features a 3-layer theme initialization to prevent any flash of unstyled content (FOUC):
Layer 1: Inline <script> in <head>
ββ Runs BEFORE first paint
ββ Reads localStorage, applies `dark` class to <html>
ββ Zero visual flash! β‘
Layer 2: Client Plugin (theme.client.ts)
ββ Syncs reactive Vue state with DOM
ββ Listens for system preference changes
Layer 3: <ClientOnly> Wrapper
ββ ThemeToggle component only renders on client
ββ Prevents SSR hydration mismatches
Toggle between Light βοΈ, Dark π, and System π» modes with a single click.
All user-facing text is translatable β no hardcoded strings anywhere!
| Feature | Details |
|---|---|
| Languages | π¬π§ English (default) + π©πͺ Deutsch |
| Strategy | No URL prefix, browser detection |
| Persistence | Cookie i18n_locale |
| Module | @nuxtjs/i18n |
Translation files live in i18n/locales/ covering all scopes: form, guestbook, admin, navigation, slideshow, toasts, and more.
Setting up NFC tags or QR codes for your event is easy!
- Get writable NFC tags (NTAG215 or similar)
- Use any NFC writer app to write the URL:
https://your-domain.com/?source=nfc&event=YourEventName - Place tags at your event venue β guests tap and they're in! π²
- Go to
/admin/qrin your admin panel - Enter your event name
- Download as PNG or SVG
- Print and display at your venue! π¨οΈ
Tip
π‘ Pro tip: Use both NFC tags AND QR codes! NFC for quick access, QR as a fallback for phones without NFC support.
Here's what a guest entry looks like under the hood:
interface GuestEntry {
id: string // UUID
name: string // Guest's name
message: string // Personal message
photoUrl?: string // Photo path (e.g., /api/photos/{id}.jpg)
answers?: GuestAnswers // Optional form answers
createdAt: string // ISO 8601 timestamp
status?: EntryStatus // 'pending' | 'approved' | 'rejected'
rejectionReason?: string // Why entry was rejected
}
interface GuestAnswers {
// π¨ Favorites
favoriteColor?: string
favoriteFood?: string
favoriteMovie?: string
favoriteSong?: { title: string; artist?: string; url?: string }
favoriteVideo?: { title: string; url?: string }
// π Fun Facts
superpower?: string
hiddenTalent?: string
desertIslandItems?: string
coffeeOrTea?: 'coffee' | 'tea'
nightOwlOrEarlyBird?: 'night_owl' | 'early_bird'
beachOrMountains?: 'beach' | 'mountains'
// π Our Story
howWeMet?: string
bestMemory?: string
}Client-side image compression is applied automatically before upload:
| Setting | Value |
|---|---|
| Max dimension | 1920px |
| Target file size | 500KB |
| Initial JPEG quality | 0.8 |
| Minimum JPEG quality | 0.3 (adaptive) |
Tap & Tell is a fully-configured Progressive Web App:
| Setting | Value |
|---|---|
| Display mode | Standalone |
| Orientation | Portrait |
| Theme color | Dark |
| Icon | SVG (any size, maskable) |
| Font caching | Google Fonts (1-year CacheFirst) |
| Offline | Navigate fallback to / |
Entries are validated server-side with these constraints:
| Field | Constraint |
|---|---|
name |
Required, 1β100 characters |
message |
Required, 1β1000 characters |
photo |
Optional, max 7MB (base64) |
| Property | Value |
|---|---|
| Algorithm | HS256 (JWT via jose) |
| Access Token | 15 minutes, HTTP-only cookie |
| Refresh Token | 7 days, HTTP-only cookie, stored in DB |
| CSRF | Double-submit cookie pattern |
| 2FA | TOTP (RFC 6238) + Email OTP |
Want to contribute or customize? Here's how to get the development environment running:
pnpm install # Install dependencies
pnpm dev # Start development server (https://localhost:3000)
pnpm build # Build for production
pnpm preview # Preview production build locally
pnpm exec nuxi typecheck # Run TypeScript type checkingHere are the "why"s behind the design:
| Decision | Reasoning |
|---|---|
| SSR Disabled | Client-side SPA avoids hydration mismatches with localStorage, NFC APIs, and browser-only features |
| Module-Level State | Composables use module-level ref() instead of useState() to prevent SSR payload conflicts |
| PostgreSQL + RLS | Multi-tenant isolation via Row-Level Security, per-tenant encryption for photos |
| JWT Cookies | HTTP-only access (15min) + refresh (7d) tokens with CSRF protection |
| Client-Side Compression | Reduces upload size and server load β images compressed before sending |
| IndexedDB Offline Queue | Entries are never lost, even without internet β syncs automatically when back online |
| 3-Layer Theme Init | Prevents FOUC completely β no flash between page load and theme application |
Want to make Tap & Tell even better? That's awesome! π
Here's how to get started:
- π΄ Fork the repository
- πΏ Create a feature branch (
git checkout -b feature/amazing-feature) - π» Make your changes
- β
Build to verify (
pnpm build) - π Commit with conventional commits (
feat: add amazing feature) - π Push and open a Pull Request
- π€ Code comments & JSDoc in English
- π All user-facing text must use i18n translation keys
- π¨ Styling with Tailwind CSS utility classes
- π§© UI components follow shadcn-vue conventions
- βΏ Accessibility (a11y) best practices
- π TypeScript β avoid
anytype - π Security β no hardcoded secrets, validate at boundaries
We use Conventional Commits:
feat: add new feature
fix: resolve a bug
docs: update documentation
refactor: restructure code
style: formatting changes
test: add or update tests
chore: maintenance tasks
Caution
Before going to production, make sure to:
- π Set a secure
JWT_SECRET(not the default) - π Set a secure
CSRF_SECRET(not the default) - π Generate a 64-character hex
ENCRYPTION_MASTER_KEYfor photo encryption - π All admin features require 2FA (TOTP or Email OTP)
Here are some creative ways to use Tap & Tell:
- π Weddings β Let guests leave their wishes and photos for the couple
- π Birthday Parties β Collect fun facts and memories from attendees
- π’ Corporate Events β Gather feedback and networking connections
- π Graduations β Classmates share their favorite memories
- π Holiday Parties β Guests share their holiday traditions and wishes
- π Housewarming β Visitors leave advice and well-wishes for the new home
- πΈ Concerts & Festivals β Fans share their experience and favorite moments
Thanks for checking out Tap & Tell! If you find it useful, give it a β on GitHub β it really helps! π
Got a bug to report? Have an idea for a new feature? Open an issue and let's make this better together! π
Made with β€οΈ using Nuxt, Vue, and Tailwind CSS