Skip to content

The venue editor as a draggable canvas inside the admin panel

9/22/2024Mobile DevelopmentEvent Ticketing Platform12 min read

The brief in one paragraph

A multi-venue event-ticketing platform needs a way for venue managers to lay out their own venues without filing a ticket. The venue manager opens an admin panel, drops a section, drags it into place, adds seats inside it, resizes the section if needed, saves. The admin tool is the only path. There is no Adobe Illustrator, no Figma file, no "send your floor plan to support." The team had to decide what the editor looked like, what shape its data took, and how every other consumer of the layout — pricing, availability, ticket validation — would query the result.

The shipped answer is a draggable canvas inside the admin panel. Sections and seats are positioned freely on a 2D grid. Every interaction — drag, drop, resize, rotate — writes its result back as plain Prisma rows. There is no intermediate file, no export step, no compile pass. The customer-facing seating chart renders from the same rows.

The constraints that shaped the technical decisions

Three constraints lined up on the same side and pushed the team toward the canvas.

First, the editor had to be usable by venue staff who are not designers. The skill ceiling is "I can drag a rectangle." Anything that required selecting fill colours, choosing line weights, or remembering vector-tool keyboard shortcuts was out. The editor had to feel like Google Slides, not Illustrator.

Second, the same layout had to feed three downstream systems. The pricing engine queries which seats are in which section. The availability engine queries which seats are unsold. The validator at the door queries which seat a QR points to. Three consumers, three queries, all of them straightforward SQL when the layout is in rows and all of them painful when the layout is XML attributes inside a binary asset.

Third, the team wanted "add a new venue" to be a one-evening task for a venue manager, not a one-week task for an engineer. That ruled out any path where adding a venue meant generating a file, committing it to a repo, or running a deploy.

These three constraints push the same direction: the editor is a canvas, the data is rows, the deploy is none.

The architecture, explained against the actual repo layout

The schema carries layout coordinates on the parent (Section) and the child (Seat). Each row carries its own position, rotation, shape, and size:

model Section {
  id            String  @id @default(cuid())
  venueSlug     String
  name          String
  standing      Boolean @default(false)
  positionX     Float
  positionY     Float
  rotation      Float   @default(0)
  shape         String  @default("rectangle")
  sizeX         Float
  sizeY         Float
  disabled      Boolean @default(false)
  startingPrice Int
  seats         Seat[]
  venue         Venue   @relation(fields: [venueSlug], references: [slug])
}

model Seat {
  id        String     @id @default(cuid())
  seatId    String
  sectionId String
  name      String
  status    SeatStatus @default(AVAILABLE)
  positionX Float
  positionY Float
  rotation  Float      @default(0)
  shape     String     @default("rectangle")
  sizeX     Float
  sizeY     Float
  section   Section    @relation(fields: [sectionId], references: [id])
}

The admin canvas is a single React component that walks the section list and renders each one as a draggable <div> with a CSS transform. The same component, with drag disabled, is the customer-facing chart:

function VenueCanvas({ sections, editable, onPatch }: Props) {
  return (
    <div className="venue-canvas">
      {sections.map((s) => (
        <Rnd
          key={s.id}
          disableDragging={!editable}
          enableResizing={editable}
          position={{ x: s.positionX, y: s.positionY }}
          size={{ width: s.sizeX, height: s.sizeY }}
          onDragStop={(_, d) => onPatch(s.id, { positionX: d.x, positionY: d.y })}
          onResizeStop={(_, __, ref, ___, position) =>
            onPatch(s.id, {
              sizeX: ref.offsetWidth,
              sizeY: ref.offsetHeight,
              positionX: position.x,
              positionY: position.y,
            })
          }
          style={{ transform: `rotate(${s.rotation}deg)` }}
        >
          <SectionLabel section={s} />
        </Rnd>
      ))}
    </div>
  );
}

On drag stop, the new positionX / positionY go to /api/private/venues/[slug]/sections/[id]. The endpoint validates the input, runs the integrity scan, and persists. The customer-side render is unaware that an edit happened — it just queries the latest rows when it next loads.

The admin's per-section editor (when the venue manager dives into a single section to lay out its seats) follows the same pattern at a different scale. The section becomes the canvas; each seat is the draggable child:

function SectionEditor({ section, seats, onPatchSeat }: Props) {
  return (
    <div
      className="section-canvas"
      style={{ width: section.sizeX, height: section.sizeY }}
    >
      {seats.map((seat) => (
        <DraggableSeat
          key={seat.id}
          seat={seat}
          bounds="parent"
          onPosition={(x, y) => onPatchSeat(seat.id, { positionX: x, positionY: y })}
        />
      ))}
      <SeatPaletteToolbar onAddRow={(count) => /* bulk-insert seats */} />
    </div>
  );
}

The bounds="parent" is the small detail that prevents the seat from being dragged outside the section's box. It is enforced visually by the library and structurally by the integrity scan that runs on save.

The hardest sub-problem and how it resolved

The hardest sub-problem was section resizing. A section has child seats. The seats are positioned in absolute coordinates relative to the section's top-left corner. When the venue manager grabs the right edge of the section and drags it inward, three things have to be true at the same time:

  1. The section's sizeX updates to the new width.
  2. The seats that now fall outside the section's bounds need to either move, be flagged as out-of-bounds, or block the resize.
  3. The visual feedback during drag has to communicate which choice is being made.

The first instinct was to clip the seats automatically. Resize the section, any seat that lands outside gets moved to the nearest edge. The implementation took an afternoon and produced a series of unhappy demos: venue managers expected their carefully placed seats to stay where they were, and the auto-move turned a routine resize into a layout-destroying event.

The second instinct was to block the resize. Pinned seats cannot be moved by the section resize. If the resize would put a seat outside, the resize stops at the edge of the rightmost seat. This was correct but felt like the tool was fighting the user. The venue manager's question was "why won't this section get smaller" and the answer was "because of a seat you forgot about three weeks ago."

The shipped answer was to allow the resize freely, mark the out-of-bounds seats with a yellow highlight, and surface a banner: "3 seats are outside the section. Move them inside or delete them before saving." The save endpoint rejects the layout if the integrity scan finds out-of-bounds seats. The venue manager either moves the seats (a drag away), accepts the seats outside the visible section (rare; the schema allows it but the customer chart hides them), or undoes the resize.

// services/VenueIntegrityService.ts
function isInsideBounds(seat: Seat, section: Section) {
  return (
    seat.positionX >= 0 &&
    seat.positionX + seat.sizeX <= section.sizeX &&
    seat.positionY >= 0 &&
    seat.positionY + seat.sizeY <= section.sizeY
  );
}

async function checkSection(sectionId: string) {
  const section = await prisma.section.findUnique({
    where: { id: sectionId },
    include: { seats: true },
  });
  const outOfBounds = section.seats.filter((seat) => !isInsideBounds(seat, section));
  return { outOfBounds };
}

The integrity check is the structural backstop. The UI banner is the friendly affordance. Both surface the same truth: the layout is incomplete, and saving it would create a venue the customer cannot fully use.

What shipped and what did not

What shipped: the section canvas, the per-section seat editor, drag-and-drop on both, the rotation handle, the section-resize-with-bounds-check, the disabled flag, the per-section starting price. Venue managers add new venues without engineering involvement. The admin panel is the only path from "empty database" to "venue ready to sell."

What did not ship: a programmatic SVG export. The print artifact a venue manager occasionally needs is a high-resolution screenshot of the canvas, not a generated vector file. Adding the export is its own project because CSS transforms don't map one-to-one to SVG attributes; the team accepted the screenshot path because the demand is monthly, not daily.

What also did not ship: polygon-shaped sections. The schema has shape: string with rectangle (default) and curve (for the curved-balcony case) as the two values. A real concert venue with a horseshoe balcony or a triangle-shaped pit needs more, and the team did not ship the polygon editor. The schema can carry the data — shape: "polygon" with a serialised points array — but the editor UI to draw a polygon and edit its vertices is its own product. For the venues that need it, the team has manually populated shape: "polygon" with hand-edited JSON; the customer-facing chart renders correctly. The admin UI for it is on the backlog.

CTA — what a reader with a similar brief should ask first

If you are about to build a layout editor for a multi-tenant product, ask: who is the user, and what is their skill ceiling? If the answer is "designers with vector tools," your editor can lean on those tools. If the answer is "the customer's staff, on a laptop, in twenty minutes between shows," the editor has to feel like a drag-and-drop slide tool, not a CAD program.

The second question is: who else queries the layout? If the only consumer is the rendered chart, you can ship an asset-based path with fewer moving parts. If pricing, availability, and validation all need to query the layout, you have to model it as data from the first migration. Retrofitting a data layer onto an asset-based system is a multi-quarter project.

Trade-off

The canvas pays an upfront cost in geometry. The team has to implement hit detection, integrity scans, and the rotation transform in application code rather than getting them from a vector tool. The first feature — drag sections around the canvas — takes longer than it would with an off-the-shelf SVG editor.

What you buy with that cost is every later feature. Pricing per section is a column. Availability per seat is a join. Adding a new venue is an admin-panel action, not a deploy. The cost is paid once. The benefit accrues every time someone needs to query the layout for anything other than rendering it.

Business impact

The unit of growth for a multi-venue platform is "venues onboarded per quarter." The canvas-based editor moves that metric from "limited by engineering capacity" to "limited by sales capacity." A sales rep can demo a custom layout in a discovery call by drawing it live. A new venue manager can lay out their room without a kick-off meeting. The support team can answer "why is this seat showing as taken" by pointing at one row in one table.

For the platform that needs to scale across venues without scaling engineering hours per venue, this is the unlock. The choice that looks like it costs more on day one is the one that costs nothing on day three hundred.

What to do next

If you are building a layout editor and considering the asset-vs-data choice, the cheapest experiment is to model one section as data and render it as a CSS-transform overlay alongside your existing chart. The moment the overlay matches the original, the original can be retired.

If you are starting fresh, model the layout as data from the first migration. The geometry helpers are a hundred lines of TypeScript you write once and reuse across every consumer. The artifact to copy: a Section model with position and rotation, a Seat model that references it, a draggable React component that calls a PATCH endpoint on drag-stop, an integrity scan that flags out-of-bounds seats, and an admin banner that surfaces incomplete venues so the manager can fix them before saving. Everything else — the customer-side chart, the print-screenshot path, the seat palette — is shape on top of that spine.

Related Articles

Same Category

Comments (0)

Newsletter

Stay updated! Get all the latest and greatest posts delivered straight to your inbox