POLLNOW is a full-stack awards & voting platform built on top of Next.js (App Router), designed for creating, managing, and analyzing structured events with multi-category voting.
The application combines:
- A public-facing voting experience (anonymous, device-bound, anti-duplicate),
- A multi-tenant user dashboard for event owners,
- A real-time collaborative event editing system with granular permission control,
- A moderation-oriented admin panel,
- A subscription system based on Stripe, and
- Supporting modules for support tickets, notifications (in-app + email), analytics, and AI-powered features.
The goal of this project is not just to "make something work", but to explore how a modern SaaS-style system can be built with:
- React Server Components + Server Actions
- Prisma with a non-trivial relational schema
- NextAuth with custom flows (email verification, Google, password reset)
- Stripe billing & webhooks
- Production-oriented patterns (middleware, modular server actions, strict validation, API rate limiting).
The application revolves around events (award ceremonies, competitions, polls) and their voting lifecycle.
-
User
- Owns events
- Has a subscription status (free / premium tiers)
- Can authenticate via credentials or Google
- Receives notifications and support messages
- Can like and vote on public events
- Holds two email-preference flags (
emailNotifications,emailCollaborations) that gate transactional email delivery and support one-click unsubscribe
-
Event
- Represents a specific awards ceremony / poll session
- Controls:
- Visibility (public list vs direct link access)
- Voting mode (anonymous vs identified)
- Gala date and result visibility
- Status (
DRAFT,PENDING,APPROVED,DENIED) - Tags (searchable, normalized to lowercase, pill-based input with autocomplete)
- Tracks community engagement via likes (
EventLike) and ratings (EventVote, upvote/downvote)
-
Participant
- A nominee / candidate that can be reused across polls in the same event
-
Poll
- A category inside an event (e.g. "Best Movie", "Best Streamer")
- Associated with:
- Order (for linear voting flow)
- Options
- Optional max options / selection rules
- Minimum 2 nominees required before a category can be saved
-
Option
- Link between a
Participantand aPoll - Defines which participant is part of which category
- Maintains its own order inside a poll
- Link between a
-
Vote
- A vote for a specific
Optionin aPoll - Contains:
- Timestamps
- Optional
userIdwhen the voter's identity is known - An associated voter hash for anonymous / device-bound tracking
- A vote for a specific
-
EventLike
- Heart/like reaction tied to a
Userand anEvent - Unique per user per event (toggle behaviour)
- Heart/like reaction tied to a
-
EventVote
- Community rating tied to a
Userand anEvent valuefield:1(upvote) or-1(downvote)- Unique per user per event; same value toggles it off
- Community rating tied to a
-
SupportChat / SupportMessage
- Used for in-app support ticketing between users and admins
-
Notification
- Server-side generated events rendered in the dashboard
-
Billing / Tokens
- Subscription & Stripe metadata
- Verification tokens
- Password reset tokens
All of this is expressed in prisma/schema.prisma and evolved via many migrations under prisma/migrations.
POLLNOW is structured around Next.js App Router and uses a combination of:
- Server Components for data-fetching routes,
- Client Components for interactive UI,
- Server Actions for mutations and business logic,
- API Routes for Stripe webhooks, voting, and engagement endpoints.
High-level view:
Client (React/Tailwind) β‘οΈ Next.js App Router
β‘οΈ Server Components & Actions
β‘οΈ Prisma (PostgreSQL)
β‘οΈ Pusher (real-time WebSocket layer)
β‘οΈ External services (Stripe, Resend, Gemini AI, Pollinations AI)
-
Server Actions in
src/app/lib/*-actions.ts(e.g.dashboard-actions,event-actions,stats-actions) encapsulate business logic instead of pushing everything into API routes. -
NextAuth is configured in
src/auth.config.tsandsrc/auth.ts, using Prisma as the adapter and Postgres as the storage. -
Stripe integration is handled via:
- Server-side actions in
stripe-actions.ts - Webhook route in
app/api/webhooks/stripe/route.ts
- Server-side actions in
-
Middleware (
src/middleware.ts) is used to:- Protect admin routes
- Enforce auth in certain sections
- Assign a
voter_idcookie to every visitor for anonymous vote tracking
-
Rate Limiting (
src/lib/rate-limit.ts) is applied to all API routes using a sliding-window in-memory strategy (see Β§10 below).
Located mainly in:
src/auth.config.tssrc/auth.tssrc/app/lib/auth-actions.tssrc/app/api/auth/[...nextauth]/route.ts
Capabilities:
-
Email/password login with bcrypt-hashed passwords.
-
Google OAuth login (
authenticateGoogle). -
Email verification flow:
- Verification tokens (
lib/tokens.ts) - Verification page under
app/auth/new-verification/page.tsx.
- Verification tokens (
-
Password reset support via tokens (
reset-password.ts, tokens model). -
Session-based role handling (
USER,MODERATOR,ADMIN).
Authentication is consumed in server components via auth() calls and used to gate entire routes (dashboard, admin, etc.).
Key paths & modules:
src/app/dashboard/page.tsxsrc/app/dashboard/event/[id]/page.tsxsrc/app/lib/dashboard-actions.tssrc/app/lib/event-actions.tssrc/components/dashboard/*
The dashboard provides:
-
Events tab
- Create event (
CreateEventButton+dashboard-actions.ts)- Tag input uses the pill-based
TagsInputcomponent with live autocomplete from the API
- Tag input uses the pill-based
- List events (
DashboardEventCard) - Per-event link into
/dashboard/event/[id]
- Create event (
-
Event detail page
-
EventTabswraps:EventSettingsβ configuration, gala date, visibility, anonymous voting, and editable tags viaTagsInputParticipantListβ add/edit/remove participants; Enterprise/Unlimited users can bulk-import via CSVPollListβ categories with:- Drag & drop reordering
- Paginated participant selector (10 per page)
- "Select All" / "Remove All" across all pages
- Minimum 2 nominees enforced before saving
EventStatisticsβ aggregated stats, breakdown by category, premium gating, and engagement KPIs (likes, upvotes/downvotes, net score)
-
-
Notifications & Support
-
DashboardTabsalso exposes:- Notifications tab (
NotificationsTab,user-notification-actions.ts) - Support tab (
SupportTab, support ticket list +CreateTicketButton)
- Notifications tab (
-
All writes are performed via server actions invoked from forms and interactive clients.
Public event access is organized under:
src/app/e/[slug]/page.tsxβ Voting entry for a specific eventsrc/app/e/[slug]/completed/page.tsxβ Post-voting "thank you" pagesrc/app/e/[slug]/results/page.tsxβ Results page (time-gated, shows likes/votes in header)src/app/polls/*β Public explore & listing pagessrc/app/api/polls/*β Voting & result APIs
Voting UX:
-
User lands on
/e/[slug](public events are freely accessible; private events require a?key=param). -
A linear voting flow guides them through each poll (category) in order.
-
Votes are validated and stored via:
public-actions.tsevent-actions.tsstats-actions.ts
-
When finished, the user is redirected to a completion page.
-
Results may be:
- Hidden until a gala date,
- Partially visible (e.g. aggregated only),
- Or fully visible if configuration & time allow.
-
The "Volver al Lobby" button on the results page correctly preserves the
?key=parameter for private events.
Core implementation lives in:
- Prisma models (
Vote,Event, etc.) stats-actions.tspublic-actions.ts&pollsAPI routes
Mechanisms:
-
Each anonymous visitor is assigned a voter hash:
- Derived from device/session information.
- Stored in the DB to prevent re-votes per poll/event.
-
HttpOnly cookies + hashes are used to:
- Avoid exposing identifiers to the client.
- Distinguish "already voted" states.
-
If a user is authenticated, their
userIdmay be attached to the vote (depending on event configuration). -
Events have an
isAnonymousVotingflag:- When
true, identities are hidden even from premium analytics. - When
false, premium tiers (or admins) can see who voted for what (when allowed).
- When
-
Unauthenticated users can vote on public events β their votes are stored and shown as "AnΓ³nimos" in statistics.
src/app/lib/stats-actions.ts exposes a high-level getEventStats(eventId) function that:
-
Fetches polls, options, and votes for the event.
-
Computes:
-
totalVotes -
totalPolls -
votesByPoll(for bar charts) -
Per-poll breakdown:
- Options with their
votesCount - List of voters (if allowed)
- Options with their
-
-
Builds an
activityTimelinefrom recent votes grouped by date. -
Reads
event.isAnonymousVotingto ensure privacy is respected in the UI. -
Fetches community engagement data:
likeCount,upvotes,downvotes,voteScore.
The client-side visualization is handled by:
src/components/dashboard/EventStatistics.tsx
Features include:
-
Voting KPIs: total votes, active categories, participation status.
-
Engagement KPIs (second row): likes received, upvotes/downvotes breakdown, net score (colour-coded green/red/grey).
-
Progress-bar style charts for vote distribution.
-
Scrollable list of polls with per-category modals.
-
Conditional UI:
- Free plan: blurred/gated UI + mock stats (with realistic mock engagement data).
- Premium: real numbers.
- Premium+ or Admin: voter identities (if event is not anonymous).
src/app/polls/page.tsx and its client components provide a fully-featured public event discovery experience:
- Search bar β debounced full-text search across titles and descriptions.
- Sort filters (chip buttons):
- Recientes, Populares (likes), Mejor valorados, Peor valorados, MΓ‘s antiguos.
- Random event button β fetches a random approved public event via
/api/events/randomand navigates to it instantly, with a dice icon and spin animation while loading. - Clickable tags β each tag pill on an event card navigates to
?tag=TAGto filter by that tag. - Active tag chip β shows the current tag filter with an Γ to clear it.
- Pagination β 6 events per page with smart ellipsis page numbers, Previous/Next controls, and auto-scroll to top on page change.
- Per-card engagement actions (authenticated users):
- β€οΈ Like button with live count and optimistic update.
- π Upvote / π Downvote buttons with coloured net score (
+Ngreen,-Nred). - All actions use
stopPropagationso they don't trigger card navigation.
- Animated transitions β
AnimatePresencewithmode="wait"ensures correct entry/exit animations when switching between result set and empty state.
Tags on events are standardized across the platform:
- Always stored as lowercase, diacritics removed (slug-safe).
- Maximum 5 tags per event, each up to 20 characters.
- Input uses
src/components/ui/TagsInput.tsx:- Pill display with animated add/remove.
- Live autocomplete popup from
/api/tags?q=showing usage counts. - Enter, comma, or Backspace to add/remove.
- Single hidden
<input name="tags">for form compatibility.
- Tags are editable both at event creation (
CreateEventButton) and in event settings (EventSettings). /api/tagsonly returns tags from public events, rate-limited to 60 req/min.
Admin routes are located under:
src/app/admin/*
Modules:
admin/page.tsxβ Admin dashboard root.admin/eventsβ Event list & review.admin/reviewsβ Content moderation for event reviews.admin/usersβ User list and user detail view.admin/notificationsβ Admin notifications UI.admin/chatsβ Support chats view.
Supporting business logic:
src/app/lib/admin-actions.tsβ Approvals, rejections, user updates, etc.src/app/api/admin/events/batch/route.tsβ Bulk event status updates / deletions (ADMIN + MODERATOR only, rate-limited).src/app/api/admin/users/batch/route.tsβ Bulk user role / ban / plan changes, including Enterprise assignment (ADMIN only, rate-limited).
The user list table displays a plan badge (Free / Premium / Plus / Unlimited / Enterprise) derived from stripePriceId, replacing the generic subscriptionStatus text. The bulk-action toolbar includes an "β Enterprise" button for batch-assigning the Enterprise plan.
Admins have elevated visibility and control:
- Can inspect any event and its stats.
- May override limitations imposed on regular users.
- Serve as moderators for reports and abuse.
Event owners can invite collaborators and manage their access with granular, real-time permissions.
Key files:
src/app/api/collaborators/[eventId]/route.tsβ GET (fetch team), PATCH (update permissions), DELETE (remove collaborator)src/app/api/collaborators/invite/route.tsβ Send collaboration invitationsrc/app/api/collaborators/respond/route.tsβ Accept / reject invitationsrc/components/dashboard/TeamTab.tsxβ Owner-side team management panelsrc/components/dashboard/CollaboratorCard.tsxβ Per-collaborator permission editorsrc/lib/pusher.tsβ WebSocket channel definitions and event names
- Each event can have multiple
EventCollaboratorrecords (plan-limited: Premium=1, Plus=5, Unlimited=15, Enterprise=30). - Each collaborator entry holds 6 nullable boolean permission fields:
canEditSettingsβ Edit event name, description, date, privacycanRegenerateKeyβ Rotate the private access keycanDeleteEventβ Permanently delete the eventcanManageNomineesβ Create and edit participantscanManagePollsβ Create and edit voting categoriescanViewStatsβ View event statistics and results
null= inherit from event defaults (set separately per event).- Event defaults ship with
canManageNominees,canManagePolls, andcanViewStatsenabled by default.
Effective permission = individual override ?? event-level default:
null (inherit) β resolves to event default
true / false β explicit override (wins over default)
When an owner toggles a permission back to the value that matches the event default, it automatically resets to null (inherit), keeping the model clean.
All permission and membership changes broadcast over the private private-event-{id} channel:
| Pusher Event | Trigger |
|---|---|
invitation-sent |
A new invitation is created |
collaborator-joined |
A user accepts an invitation |
collaborator-left |
A collaborator is removed |
permissions-updated |
Global defaults or individual overrides change |
data-changed |
Participants, polls, or event settings change |
- Owner side (
TeamTab): updates collaborator list and permission toggles in real time without a full page reload. - Collaborator side (
EventTabs): listens forpermissions-updatedand callsrouter.refresh()β Next.js re-fetches the server component with updated permissions, instantly showing or hiding action buttons, forms, and entire sections.
- Owner sends invitation β
CollaboratorInvitationrecord created, notification stored, collaboration invite email sent via Resend (styled amber/gold template, only if the recipient hasemailCollaborations = true), Pusher event fires. - Invited user sees the pending invitation in their dashboard notifications tab.
- On accept β
EventCollaboratorcreated with all permissionsnull(inheriting event defaults); Pusher firescollaborator-joined. - On reject β invitation marked
REJECTED; owner can re-invite later. - If a collaborator is later removed (DELETE), the
EventCollaboratorrecord is deleted but theCollaboratorInvitationremains. The invite route checks the actual collaborator table before blocking re-invites, so removed users can be re-invited without conflict.
- Owners and admins always have full access to an event page.
- Non-collaborators hitting
/dashboard/event/[id]receive a styled "Sin acceso a este evento" page instead of a generic 404.
Support system:
src/app/dashboard/support/*src/app/admin/chats/*src/app/lib/support-actions.tssrc/app/api/support/messages/[chatId]/route.ts
Users can open support chats; admins reply via the admin interface. Messages are delivered in real time via Pusher (private-chat-{chatId} channel, event new-message): sendSupportMessage triggers the event server-side after saving to the DB, and ChatInterface subscribes on mount β replacing the previous polling approach. Optimistic messages are automatically swapped for the server-confirmed ones when the Pusher event arrives. A single re-sync fetch runs when the user returns to a previously hidden tab.
Notifications:
src/app/lib/user-notification-actions.ts- Rendered in the dashboard
Notificationstab. - Allow marking single notifications as read or all at once.
- Two types:
SYSTEM(event approvals/rejections) andCOLLABORATION(invitations).
Email notifications (src/lib/mail.ts):
In addition to in-app notifications, users receive transactional emails via Resend for:
| Trigger | Template | Gated by preference |
|---|---|---|
| Account registration | Verification link (indigo theme) | No β always sent |
| Password reset | Reset link (neutral dark theme) | No β always sent |
| Event approved by admin | System notification (indigo theme) | emailNotifications |
| Event rejected by admin | System notification with rejection reason | emailNotifications |
| Collaboration invitation received | Special amber/gold template with event name | emailCollaborations |
All notification emails are dispatched fire-and-forget (.catch()) so a delivery failure never interrupts the main flow.
Deliverability hardening:
Every email includes a text plain-text alternative (HTML-only emails are penalised by spam filters). Non-auth emails carry a List-Unsubscribe / List-Unsubscribe-Post header pointing to a signed unsubscribe URL, and use specific subject lines derived from the event title to avoid generic spam triggers.
Email preferences & unsubscribe (src/lib/unsubscribe.ts, src/app/unsubscribe/page.tsx):
- Users control both toggles from
/dashboard?tab=profileβ Notificaciones por correo section. - Every notification/collaboration email includes an "Unsubscribe" footer link.
- The link encodes a signed, stateless token:
base64url(userId:type:HMAC-SHA256)usingNEXTAUTH_SECRET. No extra DB table required. GET /unsubscribe?token=...verifies the token, sets the relevant preference tofalse, and renders a confirmation page with a link back to the profile to re-enable.
All API routes are protected by a sliding-window in-memory rate limiter (src/lib/rate-limit.ts). The store is cleaned automatically every 5 minutes to prevent unbounded growth.
| Route | Limit | Key |
|---|---|---|
POST /api/polls |
10 / min | IP |
GET /api/polls/[id] |
60 / min | IP |
GET /api/polls/[id]/results |
60 / min | IP |
POST /api/polls/[id]/vote |
15 / min | IP |
POST /api/events/[id]/like |
15 / min | userId |
POST /api/events/[id]/vote |
20 / min | userId |
GET /api/events/random |
30 / min | IP |
GET /api/tags |
60 / min | IP |
POST /api/generate-image |
5 / min (auth) Β· 2 / min (anon) | userId / IP |
POST /api/chat |
15 / min | IP |
GET /api/support/messages/[chatId] |
30 / min | userId |
POST /api/admin/events/batch |
30 / min | userId |
POST /api/admin/users/batch |
30 / min | userId |
POST /api/webhooks/stripe |
β | Stripe signature (exempt) |
GET /api/collaborators/[eventId] |
60 / min | IP |
PATCH /api/collaborators/[eventId] |
30 / min | IP |
DELETE /api/collaborators/[eventId] |
20 / min | IP |
POST /api/collaborators/invite |
10 / min | IP |
All rate-limited endpoints return 429 Too Many Requests with a Retry-After header on violation.
Note: This implementation is in-memory and works well for single-instance deployments. For multi-instance or edge deployments (Vercel, etc.), replacing with
@upstash/ratelimit+ Redis is recommended.
Billing logic is spread across:
src/app/lib/plans.tsβ Plan metadata (slug, features, pricing tiers).src/app/lib/stripe-actions.tsβ Checkout, portal, and subscription-related actions.src/app/api/webhooks/stripe/route.tsβ Stripe webhook handler.src/app/premium/page.tsxβ Pricing / upsell page.src/components/premium/*βPricingSection,CheckoutButton,ManageButton.
User subscription data is persisted in the User model:
subscriptionStatusstripeCustomerIdstripeSubscriptionIdstripePriceIdsubscriptionEndDatecancelAtPeriodEnd
The dashboard and event statistics use these fields to conditionally enable premium features.
| Plan | Events | Categories/event | Nominees/event | Collaborators/event | Price | Assignment |
|---|---|---|---|---|---|---|
| Free | 1 | 5 | 12 | 0 | β | Default |
| Premium | 5 | 10 | 30 | 1 | β¬2.99/mo | Stripe |
| Plus | 10 | 15 | 50 | 5 | β¬8.99/mo | Stripe |
| Unlimited | 20 | 30 | 100 | 15 | β¬12.99/mo | Stripe |
| Enterprise | 150 | 50 | 1000 | 30 | Custom | Manual (admin) |
The Enterprise plan (priceId: "enterprise") is not linked to Stripe β it is assigned manually by an admin via UserActions. It supports two modes:
- Lifetime β no
subscriptionEndDateset; the cron job never touches it. - Fixed term β
subscriptionEndDateis set; the cron expires it to Free automatically.
getPlanFromUser() in src/lib/plans.ts correctly handles both cases in real time without a DB call.
Core stack:
- Framework: Next.js (App Router, RSC, Server Actions)
- Language: TypeScript
- Frontend: React, Tailwind CSS, Framer Motion
- Backend: Node.js (via Next.js runtime)
- ORM: Prisma
- Database: PostgreSQL
- Auth: NextAuth + @auth/prisma-adapter
- Payments: Stripe
- Mail: Resend (email verification, transactional emails, notification emails)
- Real-time: Pusher (WebSocket channels for collaborative editing, permission sync, and support chat)
- AI (Chat): Google Gemini (
gemini-2.5-flash-lite) via@google/generative-ai - AI (Images): Pollinations AI (parallel free models: klein Β· flux Β· zimage, fallback to p-image)
- Guided tours: Shepherd.js (step-by-step interactive onboarding)
- 3D / Visuals:
@react-three/fiber,@react-three/drei,@react-three/postprocessing - Validation: Zod
- Utilities: date-fns, clsx, use-debounce, canvas-confetti, bcryptjs, ldrs
Some key packages used throughout the project:
@auth/prisma-adapter
@google/generative-ai
@hello-pangea/dnd
@pmndrs/assets
@pmndrs/branding
@prisma/client
@react-spring/web
@react-three/drei
@react-three/fiber
@react-three/postprocessing
bcryptjs
canvas-confetti
clsx
date-fns
framer-motion
ldrs
lucide-react
resend
use-debounce
zodThe project defines a set of scripts for development, database workflows, and quality checks (from package.json):
devβ Development serverprodevβ Development with production-like settingsbuildβ Next.js production buildstartβ Start the production serverlintβ Run ESLint
Database-related scripts:
db:resetβ Reset and reseed the databasedb:pushβprisma db pushdb:seedβ Executeprisma/seed.tsdb:studioβ Launch Prisma Studiodb:migrateβ Apply migrations
These scripts are used throughout the development workflow to iterate on both schema and application behavior.
.
βββ prisma/
β βββ migrations/ # Full migration history
β βββ schema.prisma # Main Prisma schema
β βββ seed.ts # Seed script
βββ public/ # Static assets
βββ src/
β βββ app/
β β βββ admin/ # Admin dashboard
β β βββ api/ # API routes (auth, polls, events, support, webhooks, AI)
β β βββ auth/ # Email verification flow
β β βββ dashboard/ # User dashboard & event management
β β βββ unsubscribe/ # Email unsubscribe confirmation page
β β βββ e/[slug]/ # Public voting flow for events
β β βββ polls/ # Public poll discovery & results (with filters + pagination)
β β βββ login/ # Login page
β β βββ logout/ # Logged-in guard explanation page
β β βββ register/ # Registration page
β β βββ premium/ # Pricing / subscriptions
β β βββ legal/ # Legal pages (terms, privacy, cookies)
β β βββ about/ # About page
β β βββ maintenance/ # Maintenance / holding page
β β βββ page.tsx # Landing page
β βββ components/
β β βββ dashboard/ # Dashboard components (tabs, forms, stats, lists)
β β βββ admin/ # Admin-only UI
β β βββ polls/ # Public poll UI (cards, filters, explore)
β β βββ premium/ # Billing & pricing components
β β βββ home/ # Landing, hero, 3D award mockup
β β βββ ui/ # Shared UI primitives (TagsInput, etc.)
β β βββ shared UI # Navbar, forms, confetti, etc.
β βββ lib/
β β βββ prisma.ts # Prisma client singleton
β β βββ config.ts # App configuration
β β βββ plans.ts # Plan definitions
β β βββ tokens.ts # Token generation helpers
β β βββ mail.ts # Email sending helpers (all transactional templates)
β β βββ unsubscribe.ts # HMAC-signed unsubscribe token generation & verification
β β βββ pusher.ts # Pusher server/client + channel helpers + event names
β β βββ validations.ts # Zod schemas
β β βββ countResults.ts # Result aggregation helpers
β β βββ rate-limit.ts # Sliding-window in-memory rate limiter
β β βββ *_actions.ts # Server Actions for each domain area
β β βββ stripe-actions.ts# Stripe integration helpers
β βββ middleware.ts # Route guarding, voter_id cookie, cross-cutting concerns
β βββ types/next-auth.d.ts # NextAuth type augmentation
βββ reset-password.ts # Standalone entry for password reset
src/__test__/results.test.tscovers result aggregation logic.- ESLint is configured via
eslint.config.mjs. - TypeScript is used across the entire codebase (
strict-oriented setup). - The codebase is organized to keep UI, server actions, and business logic cleanly separated, making it easier to extend or refactor.
This project served as a deep-dive into:
-
Designing a non-trivial relational schema (with many migrations and iterative improvements).
-
Structuring a large Next.js App Router application with:
- Multiple segments (public, dashboard, admin)
- Mixed Server/Client components
- Server Actions as the main mutation layer.
-
Implementing secure anonymous voting with:
- Cookie-based identity
- Hashing
- Duplicate prevention.
-
Adding real subscription tiers using Stripe and handling webhooks safely.
-
Building a real-world level admin panel, support system, and notification layer.
-
Integrating AI features: Gemini-powered chat assistant and Pollinations AI image generation with multi-model fallback.
-
Designing a community engagement layer: event likes, upvote/downvote ratings, tag-based discovery, and sort/filter exploration.
-
Protecting a public API surface with rate limiting across all endpoints.
-
Building a real-time collaborative editing system with Pusher: granular permission inheritance, bidirectional live sync between event owner and collaborators, and instant UI updates without page reloads.
-
Designing a transactional email system with per-user opt-out preferences, stateless HMAC-signed unsubscribe tokens, and deliverability hardening (plain-text alternatives, specific subject lines,
List-Unsubscribeheaders). -
Polishing UX with motion, dark theme, and consistent component patterns.
This repository is intended as a complete, production-style reference for a modern SaaS-like voting platform, showcasing how all these pieces can work together coherently in a single codebase.
Last update: 17/5/2026 β v2.6 (Real-time support chat via Pusher, gala date timezone fix)
The support chat (ChatInterface) previously used a 4-second polling interval to fetch new messages. This has been replaced with a Pusher WebSocket subscription:
sendSupportMessagenow triggers anew-messageevent onprivate-chat-{chatId}after saving to the DB, carrying the full message payload.ChatInterfacesubscribes to the private channel on mount and unbinds on unmount; nosetIntervalis used.- The Pusher auth endpoint (
/api/pusher/auth) now authorisesprivate-chat-*channels for the chat owner and admin/mod roles. - Optimistic messages are replaced by the incoming Pusher event (matched by
senderId), so no post-send re-fetch is needed. - A single re-sync fetch still runs when the user switches back to a hidden tab, as a safety net for any events missed during a connection drop.
EventSettings now converts the datetime-local input value to a UTC ISO string (Date.toISOString()) before passing it to updateEvent. Previously, the raw local-time string (e.g. "2026-05-17T20:00") was sent to the server, which interpreted it as UTC β causing a 2-hour offset for users in Spain (UTC+2).
A new top-tier plan (slug: "enterprise", priceId: "enterprise") has been added to src/lib/plans.ts. Unlike all other tiers it is not Stripe-managed β admins assign it manually.
Limits: 150 events, 50 categories/event, 1 000 nominees/event, 30 collaborators/event, no ads.
Two operating modes:
- Lifetime β
subscriptionEndDateleft empty. The daily expiry cron never touches it (subscriptionEndDate IS NULLis excluded from the batch query). - Fixed-term β
subscriptionEndDateset to a future date. When the date passes, the cron downgrades the user to Free automatically;getPlanFromUser()also enforces it in real time without waiting for the cron.
Admin panel changes:
UserActionsplan selector: new "β Enterprise" option. Selecting it clearssubscriptionEndDate(defaults to lifetime) and setssubscriptionStatus: "active". A contextual hint explains the lifetime/expirable logic.AdminUsersTableClient"Plan" column: replaced the rawsubscriptionStatustext with styled plan badges β Enterprise (amber), Unlimited (indigo), Plus (blue), Premium (violet), Free (gray). A new "β Enterprise" batch-action button is available in the toolbar.batch/route.ts:"enterprise"added to the plan slug β price ID map.
Dashboard profile (SubscriptionCard):
- Enterprise users see a fully differentiated card: amber border + glow, gold gradient plan name, Crown badge labelled "Vitalicio" or "Activo", a 4-cell limits grid (events / categories / nominees / collaborators), and no Stripe upgrade or manage buttons.
Event dashboard header:
- The plan badge in the event breadcrumb uses
plan.slugfor comparisons (instead ofplan.name.toUpperCase()) and adds "β Plan Enterprise" with amber styling.
Enterprise and Unlimited users can import large batches of nominees and categories from CSV files directly in the event dashboard.
Server actions (src/app/lib/csv-actions.ts):
bulkCreateParticipants(eventId, rows[])β validates each row (required name, max 80 chars), checks plan limits per row, creates records, callsrevalidatePathand Pusher once at the end. Returns{ created, errors[] }with per-row reasons.bulkCreatePolls(eventId, rows[])β same pattern for categories: validates title (required, max 100 chars),votingType(must beSINGLE,MULTIPLE, orLIMITED_MULTIPLE),maxOptionsβ₯ 2 forLIMITED_MULTIPLE. Assignsordersequentially from the current last poll.
UI (both ParticipantList and PollList):
- An amber "CSV" button appears in the header for Enterprise and Unlimited users only (next to the existing "Nuevo"/"Nueva" button).
- Clicking it opens a 3-phase modal:
- File selection β format documentation, downloadable example CSV, file picker (
.csvonly). - Preview & validation β summary of valid vs. invalid rows, validation-error list (with row number and reason), preview table of the first 5 valid rows, "Import N items" button (disabled if 0 valid rows).
- Result β shows how many items were created and lists any rows that failed server-side (limit reached, DB error, etc.) with per-row reasons.
- File selection β format documentation, downloadable example CSV, file picker (
- Backdrop click and the Γ button respect the importing state (disabled while a request is in flight).
- On close after a successful import,
router.refresh()is called to reload the server component data.
Example CSV files (downloadable from the modal):
public/csv/ejemplo_nominados.csvβ columns:nombre,imagen_urlpublic/csv/ejemplo_categorias.csvβ columns:titulo,descripcion,tipo_votacion,max_opciones
A new section has been added below the "Empresas que confΓan en nosotros" logo grid in LandingClient.tsx:
- Heading: "ΒΏTe gustarΓa colaborar con nosotros?" (same typographic scale as the final CTA section).
- Button: "Soluciones para Empresas" with an orange β amber gradient (
from-orange-500 to-amber-500),shadow-orange-900/30depth, and hover scale β links tohttps://pollnow.es/empresas(opens in a new tab).
The "Volver al Lobby del Evento" button in CompletedView previously always linked to /e/[slug] without preserving the access key, causing private events to show an "Invalid Access Key" error.
Fix: completed/page.tsx now reads searchParams.key and also queries isPublic + accessKey from the DB. If the event is private, the resolved key is passed down as the accessKey prop to CompletedView, which appends ?key={accessKey} to the lobby link.
A floating help button (bottom-right, authenticated users only) provides three entry points:
- Tour por la web β 7-step Shepherd.js walkthrough of the main dashboard sections (tabs, events, notifications, profile). Works whether the user is already on
/dashboardor navigating from elsewhere, using a custom browser event (pollnow:tour) to avoid soft-navigation issues. - Tutorial visual (
/help/create-event) β static step-by-step guide with a videogame-themed example event ("Premios Videojuegos del AΓ±o"), visual mockups for each creation step, and 6 real-world event type ideas. - Tour guiado β interactive Shepherd.js walkthrough that walks the user through the create-event form in real time (opens modal, highlights name/description/submit). Locked if the user has reached their event quota.
Implementation details:
HelpButtonWrapper(server component) checks auth + plan quota; passescanCreateMoreto the clientHelpButton.DashboardGuidedTour(headless client component insideDashboardTabs) handles both URL-param (?tour=web/create) and custom-event triggers.- All tour target elements are decorated with
tour-*CSS classes anddata-tour-tabattributes.
Free models (klein, flux, zimage) now run in parallel via a custom Promise-based race (not Promise.race, to handle the "all failed" case). The first successful response wins; p-image is used as a paid fallback only if all free models fail. Generation loading animation replaced with animated gradient blobs.
/legal/terms, /legal/privacy, and /legal/cookies fully rewritten to comply with LSSI-CE, LOPDGDD, and GDPR:
- Terms: 15 sections covering AI policy, age minimum 14, force majeure, ODR link.
- Privacy: subprocessors table (Neon, Vercel, Pusher, Resend, Stripe, Pollinations), 72h breach notification, AEPD complaint section, data retention periods.
- Cookies: complete cookie tables with all fields, browser-specific management links.
New ADMIN-only section in the admin panel:
- Rich email composer with 5 visual templates (indigo, amber, emerald, rose, slate).
- Live HTML preview in an iframe using the same
buildBroadcastEmailHtml()function used server-side. - Recipient filters: all users, premium only, free only, or custom user search with autocomplete.
- Confirmation modal before sending; results banner with sent/failed counts.
- Sends in chunks of 100 via
resend.batch.send(); logs toModerationLogwithactionType: "BROADCAST_EMAIL". - Rate-limited to 5 req/min.
prisma.config.ts updated to use DATABASE_URL_UNPOOLED for the datasource block. Neon's pooled connection (PgBouncer) does not support pg_advisory_lock, which caused prisma migrate deploy to time out. The direct connection bypasses the pooler and resolves the issue.
New ADMIN-only section in the admin panel with three independent tools:
Welcome bonus
- Toggle to enable/disable a global welcome bonus applied to every new user at registration.
- Configurable plan (
premium,plus,unlimited) and duration (days). - Applied automatically in
auth-actions.tsafterprisma.user.createβ checksPromotionConfigsingleton and callsapplyWelcomeBonus()if active. - Sets
subscriptionEndDate,subscriptionStatus: "active",welcomeBonusApplied: trueon the new user row.
Subscription expiry cron
- New API route
GET /api/cron/expire-subscriptionsauthenticated viaAuthorization: Bearer CRON_SECRET. - Runs daily at 04:00 UTC via Vercel Cron Jobs (
vercel.json"crons"array). - Bulk-updates users where
stripeSubscriptionIdis null,subscriptionStatus = "active", andsubscriptionEndDate < now()β resets them to free tier. getPlanFromUserinsrc/lib/plans.tsalso checks expiry in real time (no DB call β uses the already-loaded user object) so plan downgrades are instant even between cron runs.
Raffles
- Full CRUD for raffles: title, description, deadline, participation condition (
all_users/registered_before_deadline), optional max participants, optional counter display. - Status lifecycle:
ACTIVEβWINNER_SELECTEDβCLOSED. - Winner selection: random pick from eligible users or manual override from the eligible-users list.
- Each raffle can optionally show a custom message in the global announcement bar.
New CRON_SECRET env var required in Vercel to authenticate cron requests.
- Singleton
AnnouncementBarDB record (id"global"), managed from the promotions panel. - Configurable text (up to 300 chars), optional link + link label, active/inactive toggle.
- Rendered via
AnnouncementBarWrapper(server component, ISR cached 60 s with tag"announcement") above the Navbar in the root layout. AnnouncementBarClientshows a red scrolling marquee; users can dismiss it (stored inlocalStoragekeyed by record ID). Cache invalidated instantly viarevalidateTag("announcement")on any admin update.
New public marketing page targeting corporate clients:
- Hero section with orange/red gradient, tagline, and two CTAs (contact + live demo).
- UseCases β 6 illustrated business scenarios (team awards, hackathons, employee recognition, etc.).
- HowItWorks β 3-step visual guide.
- WhatsIncluded β feature checklist with Lucide icons.
- Pricing β single 399 β¬ one-time corporate licence card with mailto CTA.
- PrivateNegotiation β prompt for large orgs to reach out for custom pricing.
- FAQ β animated accordion with common enterprise questions.
- All CTAs use
mailto:contacto@pollnow.eswith pre-filled subject/body.
New NEXT_PUBLIC_DEMO_EVENT_URL env var points the demo CTA to a live event URL.
Made with
β₯οΈ by Rayelus
Pollnow Β© 2026 by Raimundo Palma MΓ©ndez is licensed under CC BY-SA 4.0![]()
- Presentation: POLLNOW - PresentaciΓ³n Proyecto Intermodular 2ΒΊDAW (2025/2026)
- Official Document: POLLNOW - Proyecto Final 2ΒΊDAW