Skip to main content

Overview

PassTru’s template system allows complete customization of all attendee-facing pages: the Attendee Portal, Check-In Page, and Post-Check-In screen. Control branding, content, and layout without writing code.

Template Types

PassTru provides three distinct template types:

Attendee Portal

Personal portal page each attendee sees after logging in

Check-In Page

Public self-service check-in interface

Post Check-In

Success message shown after check-in completion

Accessing Branding Settings

1

Navigate to Event Portal

From your event list, click on an event to open its portal.
2

Open Branding Tab

Click Branding in the event portal sidebar.
3

Select Template

Choose which template to edit from the tabs: Portal, Check-In, Post Check-In, or Email.
4

Customize Content

Edit fields, upload images, and configure settings.
5

Save and Publish

Click Save Draft to save changes, or Publish to make them live.

Draft & Publish Workflow

PassTru uses a draft/publish system to safely preview changes:
src/pages/event/EventBranding.tsx
const handleSaveDraft = async () => {
  const { error } = await supabase.from("events").update({
    draft_branding_portal: portalData,
    draft_branding_checkin: checkinData,
    draft_branding_postcheckin: postCheckinData,
  }).eq("id", eventId);
};

const handlePublish = async () => {
  const { error } = await supabase.from("events").update({
    // Save drafts
    draft_branding_portal: portalData,
    draft_branding_checkin: checkinData,
    draft_branding_postcheckin: postCheckinData,
    // Copy to live
    branding_portal: portalData,
    branding_checkin: checkinData,
    branding_postcheckin: postCheckinData,
  }).eq("id", eventId);
};
Draft Mode: Changes saved as drafts are visible in the preview but not on public pages until published.

Variable System

Templates support dynamic variables that are replaced with actual data:

Event Variables

Available in all templates:
{{event_name}}    // "Annual Gala 2026"
{{event_date}}    // "2026-06-15"
{{event_venue}}   // "Grand Ballroom, KL Convention Centre"
{{org_name}}      // "Acme Events"

Attendee Variables

Available in Portal and Post Check-In templates:
{{attendee_name}}      // "John Doe"
{{attendee_email}}     // "[email protected]"
{{attendee_unique_id}} // "AB12CD34"
{{attendee_status}}    // "Checked In" or "Pending"

Custom Field Variables

Any custom attendee field becomes a variable:
{{Department}}    // If Department field is configured
{{Table}}         // If Table field is configured
{{Seat}}          // If Seat field is configured
{{Dietary}}       // If Dietary field is configured

Variable Replacement

Variables are replaced using a regex-based system:
src/components/branding/types.ts
export function replaceVars(text: string, vars: BrandingVariables): string {
  return text.replace(/\{\{(\w+)\}\}/g, (match, key) => vars[key] ?? match);
}

Attendee Portal Template

The most comprehensive template with multiple sections:

Available Fields

Portal Template Type

src/components/branding/types.ts
export interface PortalBranding {
  logo_url: string;
  headline: string;
  subtitle: string;
  banner_url: string;
  qr_section_label: string;
  section1_title: string;
  section1_body: string;
  pdf1_url: string;
  pdf1_label: string;
  pdf2_url: string;
  pdf2_label: string;
  section2_title: string;
  section2_body: string;
  button1_label: string;
  button1_url: string;
  button2_label: string;
  button2_url: string;
  footer: string;
}

Check-In Page Template

Simpler template for the public check-in interface:

Available Fields

  • Event Logo: PNG, max 1MB (100px h × 300px w)
  • Headline: Welcome message
  • Subtitle: Instructions or tagline
  • Event Banner: PNG, max 1MB (400px h × 800px w)
  • Body Text: Additional information or agenda
  • Event Poster: PNG, max 1MB (800px h × 400px w)
  • Footer: Footer text
The QR scanner and manual entry components are system-controlled and cannot be customized via templates.

Check-In Template Type

src/components/branding/types.ts
export interface CheckinBranding {
  logo_url: string;
  headline: string;
  subtitle: string;
  banner_url: string;
  body: string;
  poster_url: string;
  footer: string;
}

Post Check-In Template

Minimal template for the success message:

Available Fields

  • Headline: Success message
  • Body Text: Additional information
  • Visible Fields: Checkboxes to select which attendee fields to display

Field Visibility

Control which attendee information is shown after check-in:
src/components/branding/BrandingForm.tsx
const allFields = [
  "Name", 
  "Email", 
  "Unique ID",
  ...attendeeFields.filter(f => !["Name", "Email"].includes(f))
];

const toggleField = (field: string) => {
  const next = visibleFields.includes(field)
    ? visibleFields.filter((f) => f !== field)
    : [...visibleFields, field];
  update("visible_fields", next);
};

Post Check-In Template Type

src/components/branding/types.ts
export interface PostCheckinBranding {
  headline: string;
  body: string;
  visible_fields: string[];
}

Image Upload System

All images are uploaded to Supabase Storage:

Upload Process

src/components/branding/BrandingForm.tsx
const handleFileUpload = async (field: string, file: File) => {
  // Validate PNG format and size
  const err = await validatePng(file, 1); // 1MB limit
  if (err) {
    toast.error(err);
    return;
  }
  
  // Upload to storage
  const path = `${eventId}/${Date.now()}-${file.name}`;
  const { error } = await supabase.storage
    .from("branding-assets")
    .upload(path, file);
    
  // Get public URL
  const { data: urlData } = supabase.storage
    .from("branding-assets")
    .getPublicUrl(path);
    
  update(field, urlData.publicUrl);
};

Image Validation

Images are validated using magic byte detection:
async function validatePng(file: File, maxSizeMb: number): Promise<string | null> {
  if (file.type !== "image/png") return "Only PNG files are allowed";
  if (file.size > maxSizeMb * 1024 * 1024) return `File must be under ${maxSizeMb}MB`;
  
  // PNG magic bytes: 137 80 78 71 13 10 26 10
  const header = await readFileHeader(file, 8);
  const pngMagic = [137, 80, 78, 71, 13, 10, 26, 10];
  if (pngMagic.some((b, i) => header[i] !== b)) {
    return "File is not a valid PNG image";
  }
  return null;
}
Format Restrictions:
  • Images: PNG only, max 1MB
  • PDFs: PDF only, max 5MB
  • Files validated by magic bytes, not just extension

PDF Upload System

PDF downloads for attendee portals:

Upload & Configuration

<PdfUploadField
  label="PDF 1"
  urlValue={data.pdf1_url}
  labelValue={data.pdf1_label}
  onUpload={(file) => handleFileUpload("pdf1_url", file, "pdf")}
  onClearUrl={() => update("pdf1_url", "")}
  onLabelChange={(v) => update("pdf1_label", v)}
/>

PDF Validation

async function validatePdf(file: File): Promise<string | null> {
  if (file.type !== "application/pdf") return "Only PDF files are allowed";
  if (file.size > 5 * 1024 * 1024) return "File must be under 5MB";
  
  // PDF magic bytes: %PDF (0x25 0x50 0x44 0x46)
  const header = await readFileHeader(file, 4);
  const pdfMagic = [0x25, 0x50, 0x44, 0x46];
  if (pdfMagic.some((b, i) => header[i] !== b)) {
    return "File is not a valid PDF document";
  }
  return null;
}

Storage Location

All branding assets are stored in the branding-assets bucket:
branding-assets/
  ├── {event-id}/
  │   ├── {timestamp}-logo.png
  │   ├── {timestamp}-banner.png
  │   ├── {timestamp}-poster.png
  │   └── {timestamp}-document.pdf
  └── general/
      └── {timestamp}-image.png

Template Rendering

Templates are rendered by specialized components:
src/components/branding/TemplateRenderer.tsx
<TemplateRenderer
  pageType="portal"
  branding={event.branding_portal}
  variables={{
    attendee_name: attendee.name,
    attendee_email: attendee.email,
    attendee_unique_id: attendee.unique_id,
    event_name: event.name,
    event_date: event.date,
    event_venue: event.venue,
  }}
/>

Live Preview

The branding editor includes a live preview panel:
src/pages/event/EventBranding.tsx
<div className="grid gap-6 lg:grid-cols-2">
  <Card>
    <CardHeader>
      <CardTitle>Attendee Portal Content</CardTitle>
    </CardHeader>
    <CardContent>
      <BrandingForm
        pageType="portal"
        data={portalData}
        onChange={setPortalData}
      />
    </CardContent>
  </Card>
  
  <BrandingPreview
    pageType="portal"
    branding={portalData}
    orgSlug={orgSlug}
    eventSlug={eventSlug}
  />
</div>

Default Templates

New events start with sensible defaults:
src/components/branding/types.ts
export const DEFAULT_PORTAL_BRANDING: PortalBranding = {
  logo_url: "",
  headline: "Welcome, {{attendee_name}}!",
  subtitle: "{{event_name}}",
  banner_url: "",
  qr_section_label: "Your Check-In QR Code",
  section1_title: "",
  section1_body: "",
  pdf1_url: "",
  pdf1_label: "",
  pdf2_url: "",
  pdf2_label: "",
  section2_title: "",
  section2_body: "",
  button1_label: "",
  button1_url: "",
  button2_label: "",
  button2_url: "",
  footer: "",
};

Build docs developers (and LLMs) love