├── public ├── images │ └── favicon.png ├── javascripts │ └── hello.js └── stylesheets │ └── main.css ├── project ├── build.properties └── plugins.sbt ├── app ├── views │ ├── index.scala.html │ └── main.scala.html ├── Filters.scala ├── com │ └── semisafe │ │ └── ticketoverlords │ │ ├── OptimisticFuture.scala │ │ ├── SlickMapping.scala │ │ ├── Order.scala │ │ ├── TicketIssuer.scala │ │ ├── TicketIssuerWorker.scala │ │ ├── Event.scala │ │ └── TicketBlock.scala ├── controllers │ ├── Tickets.scala │ ├── Application.scala │ ├── responses │ │ ├── CSRFFilterError.scala │ │ └── EndpointResponse.scala │ ├── TicketBlocks.scala │ ├── Events.scala │ └── Orders.scala └── assets │ └── js │ ├── index.coffee │ ├── event_list.coffee │ └── event_list_entry.coffee ├── .gitignore ├── conf ├── evolutions │ └── default │ │ ├── 2.sql │ │ └── 1.sql ├── application-logger.xml ├── routes └── application.conf ├── README.md ├── LICENSE └── test ├── IntegrationSpec.scala └── ApplicationSpec.scala /public/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cameronhotchkies/ticket-overlords/HEAD/public/images/favicon.png -------------------------------------------------------------------------------- /public/javascripts/hello.js: -------------------------------------------------------------------------------- 1 | if (window.console) { 2 | console.log("Welcome to your Play application's JavaScript!"); 3 | } -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | #Activator-generated Properties 2 | #Mon Mar 30 16:37:47 PDT 2015 3 | template.uuid=ba2ec772-a214-4c55-ba57-cae483baac1f 4 | sbt.version=0.13.8 5 | -------------------------------------------------------------------------------- /app/views/index.scala.html: -------------------------------------------------------------------------------- 1 | @() 2 | 3 | @* This view uses a module named "index" for RequireJS *@ 4 | @main("Ticket Overlords", "index") { 5 |
6 | } 7 | -------------------------------------------------------------------------------- /app/Filters.scala: -------------------------------------------------------------------------------- 1 | import play.api.http.HttpFilters 2 | import play.filters.csrf.CSRFFilter 3 | import javax.inject.Inject 4 | 5 | class Filters @Inject() (csrfFilter: CSRFFilter) extends HttpFilters { 6 | def filters = Seq(csrfFilter) 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | logs 2 | project/project 3 | project/target 4 | target 5 | tmp 6 | .history 7 | dist 8 | /.idea 9 | /*.iml 10 | /out 11 | /.idea_modules 12 | /.classpath 13 | /.project 14 | /RUNNING_PID 15 | /.settings 16 | *.class 17 | bin 18 | .cache 19 | activator 20 | activator-launch-1.3.2.jar 21 | activator.bat 22 | db 23 | .cache-main 24 | .cache-tests 25 | -------------------------------------------------------------------------------- /conf/evolutions/default/2.sql: -------------------------------------------------------------------------------- 1 | # --- !Ups 2 | 3 | CREATE TABLE orders ( 4 | id INTEGER AUTO_INCREMENT PRIMARY KEY, 5 | ticket_block_id INTEGER, 6 | customer_name VARCHAR, 7 | customer_email VARCHAR, 8 | ticket_quantity INTEGER, 9 | timestamp DATETIME, 10 | FOREIGN KEY (ticket_block_id) REFERENCES ticket_blocks(id) 11 | ); 12 | 13 | # --- !Downs 14 | 15 | DROP TABLE IF EXISTS orders; -------------------------------------------------------------------------------- /app/com/semisafe/ticketoverlords/OptimisticFuture.scala: -------------------------------------------------------------------------------- 1 | package com.semisafe.ticketoverlords 2 | 3 | import scala.concurrent.Future 4 | import play.api.libs.concurrent.Execution.Implicits._ 5 | 6 | object OptimisticFuture { 7 | def sequence[A](source: Seq[Future[A]]): Future[Seq[A]] = { 8 | 9 | val optioned = source.map { f => 10 | f.map(Option.apply).recover { 11 | case _ => None: Option[A] 12 | } 13 | } 14 | 15 | Future.sequence(optioned).map(_.flatten) 16 | } 17 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Ticket Overlords 2 | ================ 3 | 4 | This is a sample web application for an online ticket store 5 | named *Ticket Overlords* as part of the http://semisafe.com 6 | series of tutorials aimed at teaching the basics of creating 7 | an application with the Play framework. 8 | 9 | If you are having problems with any of the code in the tutorial, 10 | or you just want to chat, there is a Gitter chatroom available 11 | relating to the blog posts for this project at 12 | 13 | https://gitter.im/HandsomeCam/ticket-overlords 14 | -------------------------------------------------------------------------------- /app/controllers/Tickets.scala: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import play.api.mvc._ 4 | import play.api.libs.json.Json 5 | 6 | object Tickets extends Controller { 7 | 8 | case class AvailabilityResponse(result: String, ticketQuantity: Option[Long]) 9 | object AvailabilityResponse { 10 | implicit val responseFormat = Json.format[AvailabilityResponse] 11 | } 12 | def ticketsAvailable = Action { request => 13 | val availableTickets = 1000 14 | val response = AvailabilityResponse("ok", Option(availableTickets)) 15 | Ok(Json.toJson(response)) 16 | } 17 | } -------------------------------------------------------------------------------- /app/controllers/Application.scala: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import play.api._ 4 | import play.api.mvc._ 5 | import play.api.routing.JavaScriptReverseRouter 6 | 7 | object Application extends Controller { 8 | 9 | def index = Action { 10 | Ok(views.html.index()) 11 | } 12 | 13 | def jsRoutes = Action { implicit request => 14 | Ok( 15 | JavaScriptReverseRouter("jsRoutes")( 16 | routes.javascript.Events.list, 17 | routes.javascript.Events.ticketBlocksForEvent, 18 | routes.javascript.Orders.create 19 | ) 20 | ) 21 | } 22 | 23 | } -------------------------------------------------------------------------------- /app/controllers/responses/CSRFFilterError.scala: -------------------------------------------------------------------------------- 1 | package controllers.responses 2 | 3 | import play.api.mvc._ 4 | import play.api.mvc.Results.Forbidden 5 | import play.api.http.Status 6 | import play.api.libs.json.Json 7 | import scala.concurrent.Future 8 | import play.filters.csrf.CSRF.ErrorHandler 9 | 10 | class CSRFFilterError extends ErrorHandler { 11 | def handle(req: RequestHeader, msg: String): Future[Result] = { 12 | val response = ErrorResponse(Status.FORBIDDEN, msg) 13 | val result = Forbidden(Json.toJson(response)) 14 | 15 | Future.successful(result) 16 | } 17 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This software is licensed under the Apache 2 license, quoted below. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use this project except in compliance with 4 | the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. 5 | 6 | Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an 7 | "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific 8 | language governing permissions and limitations under the License. -------------------------------------------------------------------------------- /app/com/semisafe/ticketoverlords/SlickMapping.scala: -------------------------------------------------------------------------------- 1 | package com.semisafe.ticketoverlords 2 | 3 | import org.joda.time.DateTime 4 | import java.sql.Timestamp 5 | import play.api.db.slick.DatabaseConfigProvider 6 | import slick.driver.JdbcProfile 7 | import play.api.Play.current 8 | 9 | object SlickMapping { 10 | protected val dbConfig = DatabaseConfigProvider.get[JdbcProfile](current) 11 | import dbConfig._ 12 | import dbConfig.driver.api._ 13 | 14 | implicit val jodaDateTimeMapping = { 15 | MappedColumnType.base[DateTime, Timestamp]( 16 | dt => new Timestamp(dt.getMillis), 17 | ts => new DateTime(ts)) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /test/IntegrationSpec.scala: -------------------------------------------------------------------------------- 1 | import org.specs2.mutable._ 2 | import org.specs2.runner._ 3 | import org.junit.runner._ 4 | 5 | import play.api.test._ 6 | import play.api.test.Helpers._ 7 | 8 | /** 9 | * add your integration spec here. 10 | * An integration test will fire up a whole play application in a real (or headless) browser 11 | */ 12 | @RunWith(classOf[JUnitRunner]) 13 | class IntegrationSpec extends Specification { 14 | 15 | "Application" should { 16 | 17 | "work from within a browser" in new WithBrowser { 18 | 19 | browser.goTo("http://localhost:" + port) 20 | 21 | browser.pageSource must contain("Your new application is ready.") 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | resolvers += "Typesafe repository" at "https://repo.typesafe.com/typesafe/releases/" 2 | 3 | // The Play plugin 4 | addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.4.0") 5 | 6 | // web plugins 7 | 8 | addSbtPlugin("com.typesafe.sbt" % "sbt-coffeescript" % "1.0.0") 9 | 10 | addSbtPlugin("com.typesafe.sbt" % "sbt-less" % "1.0.0") 11 | 12 | addSbtPlugin("com.typesafe.sbt" % "sbt-jshint" % "1.0.1") 13 | 14 | addSbtPlugin("com.typesafe.sbt" % "sbt-rjs" % "1.0.1") 15 | 16 | addSbtPlugin("com.typesafe.sbt" % "sbt-digest" % "1.0.0") 17 | 18 | addSbtPlugin("com.typesafe.sbt" % "sbt-mocha" % "1.1.0") 19 | 20 | addSbtPlugin("com.typesafe.sbteclipse" % "sbteclipse-plugin" % "4.0.0") -------------------------------------------------------------------------------- /app/views/main.scala.html: -------------------------------------------------------------------------------- 1 | @(title: String, requireJsModule: String)(content: Html) 2 | 3 | 4 | 5 | 6 | 7 | @title 8 | 9 | 10 | 11 | @helper.requireJs(core = routes.Assets.at("lib/requirejs/require.js").url, 12 | module = routes.Assets.at(s"js/$requireJsModule").url) 13 | 14 | 15 | @content 16 | 17 | 18 | -------------------------------------------------------------------------------- /conf/evolutions/default/1.sql: -------------------------------------------------------------------------------- 1 | # --- !Ups 2 | 3 | CREATE TABLE events ( 4 | id INTEGER AUTO_INCREMENT PRIMARY KEY, 5 | name VARCHAR, 6 | start DATETIME, 7 | end DATETIME, 8 | address VARCHAR, 9 | city VARCHAR, 10 | state VARCHAR, 11 | country CHAR(2) 12 | ); 13 | 14 | CREATE TABLE ticket_blocks ( 15 | id INTEGER AUTO_INCREMENT PRIMARY KEY, 16 | event_id INTEGER, 17 | name VARCHAR, 18 | product_code VARCHAR(40), 19 | price DECIMAL, 20 | initial_size INTEGER, 21 | sale_start DATETIME, 22 | sale_end DATETIME, 23 | FOREIGN KEY (event_id) REFERENCES events(id) 24 | ); 25 | 26 | # --- !Downs 27 | 28 | DROP TABLE IF EXISTS ticket_blocks; 29 | DROP TABLE IF EXISTS events; 30 | -------------------------------------------------------------------------------- /conf/application-logger.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | logs/ticket-overlords.log 4 | 5 | 6 | %date %level [%thread] %logger{10} [%file:%line] %msg %n 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | %gray([%-20file:%-3line]) %highlight(%-5level) %boldBlue(%logger{15}) :: %msg %n 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /test/ApplicationSpec.scala: -------------------------------------------------------------------------------- 1 | import org.specs2.mutable._ 2 | import org.specs2.runner._ 3 | import org.junit.runner._ 4 | 5 | import play.api.test._ 6 | import play.api.test.Helpers._ 7 | 8 | /** 9 | * Add your spec here. 10 | * You can mock out a whole application including requests, plugins etc. 11 | * For more information, consult the wiki. 12 | */ 13 | @RunWith(classOf[JUnitRunner]) 14 | class ApplicationSpec extends Specification { 15 | 16 | "Application" should { 17 | 18 | "send 404 on a bad request" in new WithApplication{ 19 | route(FakeRequest(GET, "/boum")) must beNone 20 | } 21 | 22 | "render the index page" in new WithApplication{ 23 | val home = route(FakeRequest(GET, "/")).get 24 | 25 | status(home) must equalTo(OK) 26 | contentType(home) must beSome.which(_ == "text/html") 27 | contentAsString(home) must contain ("Your new application is ready.") 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/assets/js/index.coffee: -------------------------------------------------------------------------------- 1 | require.config 2 | shim: 3 | react: 4 | exports: 'React' 5 | paths: 6 | react: '../lib/react/react' 7 | jquery: '../lib/jquery/jquery' 8 | 9 | # Define the requirements for this code block 10 | require [ 11 | 'react' 12 | 'event_list' 13 | ], (React, EventList) -> 14 | 15 | # Local reference to the React.DOM version of a div 16 | { div } = React.DOM 17 | 18 | # Define the master view class 19 | MasterView = React.createClass 20 | # Define the html / children components 21 | render: -> 22 | eventList = React.createFactory EventList 23 | div { key: 'top' }, 24 | eventList { key: 'events' }, null 25 | 26 | # Instantiate a MasterView element 27 | masterView = React.createElement MasterView, null 28 | 29 | # Render the MasterView element inside the "mount" div in the template 30 | rendered = React.render masterView, document.getElementById('mount') 31 | -------------------------------------------------------------------------------- /app/controllers/responses/EndpointResponse.scala: -------------------------------------------------------------------------------- 1 | package controllers.responses 2 | 3 | import play.api.libs.json.{ Json, Format, JsValue, JsNull, Writes } 4 | 5 | case class ErrorResult(status: Int, message: String) 6 | 7 | object ErrorResult { 8 | implicit val format: Format[ErrorResult] = Json.format[ErrorResult] 9 | } 10 | 11 | case class EndpointResponse( 12 | result: String, 13 | response: JsValue, 14 | error: Option[ErrorResult]) 15 | 16 | object EndpointResponse { 17 | implicit val format: Format[EndpointResponse] = Json.format[EndpointResponse] 18 | } 19 | 20 | object ErrorResponse { 21 | 22 | val INVALID_JSON = 1000 23 | val NOT_ENOUGH_TICKETS = 1001 24 | val TICKET_BLOCK_UNAVAILABLE = 1002 25 | 26 | def apply(status: Int, message: String) = { 27 | EndpointResponse("ko", JsNull, Option(ErrorResult(status, message))) 28 | } 29 | } 30 | 31 | object SuccessResponse { 32 | def apply[A](successResponse: A)(implicit w: Writes[A]) = { 33 | EndpointResponse("ok", Json.toJson(successResponse), None) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /public/stylesheets/main.css: -------------------------------------------------------------------------------- 1 | @import url(http://fonts.googleapis.com/css?family=Open+Sans:300); 2 | 3 | body { 4 | background: #1e5ea8; 5 | font-family: 'Open Sans', sans-serif; 6 | } 7 | 8 | div.eventEntries { 9 | display: table; 10 | background: #ffffff; 11 | padding: 10px; 12 | border: 4px solid #1e5ea8; 13 | border-radius: 15px; 14 | } 15 | 16 | div.eventEntries > div { 17 | display: table-row-group; 18 | border: 2px solid #838383; 19 | padding: 4px; 20 | } 21 | 22 | .eventEntry { 23 | display: table-row; 24 | border: 2px solid #838383; 25 | } 26 | 27 | .eventEntry > span { 28 | display: table-cell; 29 | padding: .25em; 30 | } 31 | 32 | .orderPanel { 33 | padding: 10px; 34 | border: 4px solid #1e5ea8; 35 | border-radius: 15px; 36 | background-color: white; 37 | color: black; 38 | } 39 | 40 | .orderPanel label, .orderPanel input, .orderpanel div { 41 | padding: .25em; 42 | margin: .25em; 43 | } 44 | 45 | button { 46 | height: 2em; 47 | border-radius: 15px; 48 | background-color: white; 49 | color: #ffffff; 50 | background-color: #1e5ea8; 51 | } 52 | 53 | input { 54 | border-radius: 5px; 55 | } -------------------------------------------------------------------------------- /conf/routes: -------------------------------------------------------------------------------- 1 | # Routes 2 | # This file defines all application routes (Higher priority routes first) 3 | # ~~~~ 4 | 5 | # Home page 6 | GET / controllers.Application.index 7 | 8 | # Map static resources from the /public folder to the /assets URL path 9 | GET /assets/*file controllers.Assets.at(path="/public", file) 10 | 11 | # API route for available tickets 12 | GET /tickets/available/ controllers.Tickets.ticketsAvailable 13 | 14 | # Javascript Router 15 | GET /jsr/ controllers.Application.jsRoutes 16 | 17 | # Event Resource 18 | POST /events/ controllers.Events.create 19 | GET /events/ controllers.Events.list 20 | GET /events/:eventID/ controllers.Events.getByID(eventID: Long) 21 | GET /events/:eventID/tickets/blocks/ controllers.Events.ticketBlocksForEvent(eventID: Long) 22 | 23 | # Ticket Block Resource 24 | POST /tickets/blocks/ controllers.TicketBlocks.create 25 | GET /tickets/blocks/ controllers.TicketBlocks.list 26 | GET /tickets/blocks/:blockID/ controllers.TicketBlocks.getByID(blockID: Long) 27 | 28 | # Order Resource 29 | POST /orders/ controllers.Orders.create 30 | GET /orders/ controllers.Orders.list 31 | GET /orders/:orderID/ controllers.Orders.getByID(orderID: Long) 32 | -------------------------------------------------------------------------------- /app/assets/js/event_list.coffee: -------------------------------------------------------------------------------- 1 | define [ 2 | 'react' 3 | 'jquery' 4 | 'event_list_entry' 5 | ], (React, jQuery, EventListEntry) -> 6 | 7 | EventList = React.createClass 8 | getInitialState: -> 9 | hasLoaded: false 10 | events: [] 11 | 12 | componentDidMount: -> 13 | eventListApi = jsRoutes.controllers.Events.list() 14 | eventListApi.ajax() 15 | .done (result) => 16 | if @isMounted() 17 | @setState 18 | events: result.response 19 | hasLoaded: true 20 | .fail (jqXHR, textStatus, errorThrown) => 21 | resultCode = jqXHR.status 22 | if @isMounted() 23 | @setState 24 | events: [] 25 | hasLoaded: true 26 | 27 | render: -> 28 | { div } = React.DOM 29 | if @state.hasLoaded 30 | eventListEntry = React.createFactory EventListEntry 31 | 32 | entries = @state.events.map (event) -> 33 | eventListEntry 34 | key: event.id 35 | event: event 36 | 37 | div { 38 | key: 'el' 39 | className: 'eventEntries' 40 | }, entries 41 | else 42 | div {} 43 | 44 | EventList 45 | -------------------------------------------------------------------------------- /app/controllers/TicketBlocks.scala: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import play.api.mvc._ 4 | import play.api.libs.json.Json 5 | 6 | import scala.concurrent.Future 7 | import play.api.libs.concurrent.Execution.Implicits._ 8 | 9 | import com.semisafe.ticketoverlords.TicketBlock 10 | import controllers.responses._ 11 | 12 | object TicketBlocks extends Controller { 13 | def list = Action.async { request => 14 | val ticketBlocks: Future[Seq[TicketBlock]] = TicketBlock.list 15 | 16 | ticketBlocks.map { tbs => 17 | Ok(Json.toJson(SuccessResponse(tbs))) 18 | } 19 | } 20 | 21 | def getByID(ticketBlockID: Long) = Action.async { request => 22 | val ticketBlockFuture: Future[Option[TicketBlock]] = TicketBlock.getByID(ticketBlockID) 23 | 24 | ticketBlockFuture.map { ticketBlock => 25 | ticketBlock.fold { 26 | NotFound(Json.toJson(ErrorResponse(NOT_FOUND, "No ticket block found"))) 27 | } { tb => 28 | Ok(Json.toJson(SuccessResponse(tb))) 29 | } 30 | } 31 | } 32 | 33 | def create = Action.async(parse.json) { request => 34 | val incomingBody = request.body.validate[TicketBlock] 35 | 36 | incomingBody.fold(error => { 37 | val errorMessage = s"Invalid JSON: ${error}" 38 | val response = ErrorResponse(ErrorResponse.INVALID_JSON, errorMessage) 39 | Future.successful(BadRequest(Json.toJson(response))) 40 | }, { ticketBlock => 41 | // save ticket block and get a copy back 42 | val createdBlock: Future[TicketBlock] = TicketBlock.create(ticketBlock) 43 | 44 | createdBlock.map { cb => 45 | Created(Json.toJson(SuccessResponse(cb))) 46 | } 47 | }) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /conf/application.conf: -------------------------------------------------------------------------------- 1 | # This is the main configuration file for the application. 2 | # ~~~~~ 3 | 4 | # Secret key 5 | # ~~~~~ 6 | # The secret key is used to secure cryptographic functions. 7 | # 8 | # This must be changed for production, but we recommend not changing it in this file. 9 | # 10 | # See http://www.playframework.com/documentation/latest/ApplicationSecret for more details. 11 | play.crypto.secret="VRZV/ppo 39 | ((Order.apply _).tupled, Order.unapply) 40 | } 41 | 42 | val table = TableQuery[OrdersTable] 43 | 44 | def list: Future[Seq[Order]] = { 45 | db.run(table.result) 46 | } 47 | 48 | def getByID(orderID: Long): Future[Option[Order]] = { 49 | db.run { 50 | table.filter { f => 51 | f.id === orderID 52 | }.result.headOption 53 | } 54 | } 55 | 56 | def create(newOrder: Order): Future[Order] = { 57 | val nowStamp = new DateTime() 58 | val withTimestamp = newOrder.copy(timestamp = Option(nowStamp)) 59 | 60 | val insertion = (table returning table.map(_.id)) += withTimestamp 61 | 62 | db.run(insertion).map { resultID => 63 | withTimestamp.copy(id = Option(resultID)) 64 | } 65 | } 66 | } -------------------------------------------------------------------------------- /app/com/semisafe/ticketoverlords/TicketIssuer.scala: -------------------------------------------------------------------------------- 1 | package com.semisafe.ticketoverlords 2 | 3 | import akka.actor.Actor 4 | import akka.actor.Status.{ Failure => ActorFailure } 5 | import play.api.libs.concurrent.Execution.Implicits._ 6 | import scala.concurrent.Future 7 | import akka.actor.{ ActorRef, Props } 8 | import play.api.libs.concurrent.Akka 9 | import play.api.Play.current 10 | 11 | case class AvailabilityCheck(ticketBlockID: Long) 12 | 13 | case class InsufficientTicketsAvailable( 14 | ticketBlockID: Long, 15 | ticketsAvailable: Int) extends Throwable 16 | 17 | case class TicketBlockUnavailable( 18 | ticketBlockID: Long) extends Throwable 19 | 20 | case class TicketBlockCreated(ticketBlock: TicketBlock) 21 | 22 | class TicketIssuer extends Actor { 23 | 24 | var workers = Map[Long, ActorRef]() 25 | 26 | def createWorker(ticketBlockID: Long) { 27 | if (!workers.contains(ticketBlockID)) { 28 | val worker = context.actorOf( 29 | Props(classOf[TicketIssuerWorker], ticketBlockID), 30 | name = ticketBlockID.toString) 31 | workers = workers + (ticketBlockID -> worker) 32 | } 33 | } 34 | 35 | override def preStart = { 36 | val ticketBlocksResult = TicketBlock.list 37 | 38 | for { 39 | ticketBlocks <- ticketBlocksResult 40 | block <- ticketBlocks 41 | id <- block.id 42 | } createWorker(id) 43 | } 44 | 45 | def placeOrder(order: Order) { 46 | val workerRef = workers.get(order.ticketBlockID) 47 | 48 | workerRef.fold { 49 | sender ! ActorFailure(TicketBlockUnavailable(order.ticketBlockID)) 50 | } { worker => 51 | worker forward order 52 | } 53 | } 54 | 55 | def receive = { 56 | case order: Order => placeOrder(order) 57 | case a: AvailabilityCheck => checkAvailability(a) 58 | case TicketBlockCreated(t) => t.id.foreach(createWorker) 59 | } 60 | 61 | def checkAvailability(message: AvailabilityCheck) = { 62 | val workerRef = workers.get(message.ticketBlockID) 63 | 64 | workerRef.fold { 65 | sender ! ActorFailure(TicketBlockUnavailable(message.ticketBlockID)) 66 | } { worker => 67 | worker forward message 68 | } 69 | } 70 | } 71 | 72 | object TicketIssuer { 73 | 74 | def props = Props[TicketIssuer] 75 | 76 | private val reference = Akka.system.actorOf( 77 | TicketIssuer.props, 78 | name = "ticketIssuer") 79 | 80 | def getSelection = Akka.system.actorSelection("/user/ticketIssuer") 81 | } 82 | 83 | -------------------------------------------------------------------------------- /app/controllers/Events.scala: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import play.api.mvc._ 4 | import play.api.libs.json.Json 5 | import scala.concurrent.Future 6 | import play.api.libs.concurrent.Execution.Implicits._ 7 | import akka.util.Timeout 8 | import play.api.Play.current 9 | import scala.concurrent.duration._ 10 | 11 | import com.semisafe.ticketoverlords.Event 12 | import com.semisafe.ticketoverlords.TicketBlock 13 | import controllers.responses._ 14 | 15 | object Events extends Controller { 16 | def list = Action.async { request => 17 | val eventFuture: Future[Seq[Event]] = Event.list 18 | 19 | val response = eventFuture.map { events => 20 | Ok(Json.toJson(SuccessResponse(events))) 21 | } 22 | 23 | response 24 | } 25 | 26 | def getByID(eventID: Long) = Action.async { request => 27 | val eventFuture: Future[Option[Event]] = Event.getByID(eventID) 28 | 29 | eventFuture.map { event => 30 | event.fold { 31 | NotFound(Json.toJson(ErrorResponse(NOT_FOUND, "No event found"))) 32 | } { e => 33 | Ok(Json.toJson(SuccessResponse(e))) 34 | } 35 | } 36 | } 37 | 38 | def create = Action.async(parse.json) { request => 39 | val incomingBody = request.body.validate[Event] 40 | 41 | incomingBody.fold(error => { 42 | val errorMessage = s"Invalid JSON: ${error}" 43 | val response = ErrorResponse(ErrorResponse.INVALID_JSON, errorMessage) 44 | Future.successful(BadRequest(Json.toJson(response))) 45 | }, { event => 46 | // save event and get a copy back 47 | val createdEventFuture: Future[Event] = Event.create(event) 48 | 49 | createdEventFuture.map { createdEvent => 50 | Created(Json.toJson(SuccessResponse(createdEvent))) 51 | } 52 | 53 | }) 54 | } 55 | 56 | def ticketBlocksForEvent(eventID: Long) = Action.async { request => 57 | val eventFuture = Event.getByID(eventID) 58 | 59 | eventFuture.flatMap { event => 60 | event.fold { 61 | Future.successful( 62 | NotFound(Json.toJson(ErrorResponse(NOT_FOUND, "No event found")))) 63 | } { e => 64 | val timeoutKey = "ticketoverlords.timeouts.ticket_availability_ms" 65 | val configuredTimeout = current.configuration.getInt(timeoutKey) 66 | val resolvedTimeout = configuredTimeout.getOrElse(400) 67 | implicit val timeout = Timeout(resolvedTimeout.milliseconds) 68 | 69 | val ticketBlocks: Future[Seq[TicketBlock]] = e.ticketBlocksWithAvailability 70 | ticketBlocks.map { tb => 71 | Ok(Json.toJson(SuccessResponse(tb))) 72 | } 73 | } 74 | } 75 | } 76 | } 77 | 78 | -------------------------------------------------------------------------------- /app/com/semisafe/ticketoverlords/TicketIssuerWorker.scala: -------------------------------------------------------------------------------- 1 | package com.semisafe.ticketoverlords 2 | 3 | import akka.actor.Actor 4 | import akka.actor.Status.{ Failure => ActorFailure } 5 | import play.api.libs.concurrent.Execution.Implicits._ 6 | 7 | class OrderRoutingException(message: String) extends Exception(message) 8 | 9 | class TicketIssuerWorker(ticketBlockID: Long) extends Actor { 10 | 11 | override def preStart = { 12 | val availabilityFuture = TicketBlock.availability(ticketBlockID) 13 | 14 | availabilityFuture.onSuccess { 15 | case result => self ! AddTickets(result) 16 | } 17 | } 18 | 19 | def validateRouting(requestedID: Long) = { 20 | if (ticketBlockID != requestedID) { 21 | 22 | val msg = s"IssuerWorker #${ticketBlockID} recieved " + 23 | s"an order for Ticket Block ${requestedID}" 24 | 25 | sender ! ActorFailure(new OrderRoutingException(msg)) 26 | false 27 | } else { 28 | true 29 | } 30 | } 31 | 32 | case class AddTickets(quantity: Int) 33 | 34 | def initializing: Actor.Receive = { 35 | case AddTickets(availability) => { 36 | context.become(normalOperation(availability)) 37 | } 38 | case order: Order => { 39 | if (validateRouting(order.ticketBlockID)) { 40 | val failureResponse = TicketBlockUnavailable( 41 | order.ticketBlockID) 42 | 43 | sender ! ActorFailure(failureResponse) 44 | } 45 | } 46 | case AvailabilityCheck(ticketBlockID) => { 47 | val failureResponse = TicketBlockUnavailable(ticketBlockID) 48 | sender ! ActorFailure(failureResponse) 49 | } 50 | } 51 | 52 | def normalOperation(availability: Int): Actor.Receive = { 53 | case AddTickets(newQuantity) => { 54 | context.become(normalOperation(availability + newQuantity)) 55 | } 56 | case order: Order => placeOrder(order, availability) 57 | case _: AvailabilityCheck => sender ! availability 58 | } 59 | 60 | def soldOut: Actor.Receive = { 61 | case AddTickets(availability) => { 62 | context.become(normalOperation(availability)) 63 | } 64 | case order: Order => { 65 | if (validateRouting(order.ticketBlockID)) { 66 | val failureResponse = InsufficientTicketsAvailable( 67 | order.ticketBlockID, 0) 68 | 69 | sender ! ActorFailure(failureResponse) 70 | } 71 | } 72 | case _: AvailabilityCheck => sender ! 0 73 | } 74 | 75 | // This replaces the previous definition of receive 76 | def receive = initializing 77 | 78 | def placeOrder(order: Order, availability: Int) { 79 | val origin = sender 80 | 81 | if (validateRouting(order.ticketBlockID)) { 82 | if (availability >= order.ticketQuantity) { 83 | val newAvailability = availability - order.ticketQuantity 84 | context.become(normalOperation(newAvailability)) 85 | 86 | val createdOrder = Order.create(order) 87 | 88 | createdOrder.map(origin ! _) 89 | } else { 90 | val failureResponse = InsufficientTicketsAvailable( 91 | order.ticketBlockID, 92 | availability) 93 | 94 | origin ! ActorFailure(failureResponse) 95 | } 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /app/com/semisafe/ticketoverlords/Event.scala: -------------------------------------------------------------------------------- 1 | package com.semisafe.ticketoverlords 2 | 3 | import org.joda.time.DateTime 4 | import play.api.libs.json.{ Json, Format } 5 | 6 | import play.api.db.slick.DatabaseConfigProvider 7 | import slick.driver.JdbcProfile 8 | import play.api.Play.current 9 | import play.api.db.DBApi 10 | import scala.concurrent.Future 11 | import play.api.libs.concurrent.Execution.Implicits._ 12 | import akka.util.Timeout 13 | import akka.pattern.ask 14 | 15 | import SlickMapping.jodaDateTimeMapping 16 | 17 | case class Event( 18 | id: Option[Long], 19 | name: String, 20 | start: DateTime, 21 | end: DateTime, 22 | address: String, 23 | city: String, 24 | state: String, 25 | country: String) { 26 | 27 | def ticketBlocksWithAvailability(implicit timeout: Timeout): Future[Seq[TicketBlock]] = { 28 | this.id.fold { 29 | Future.successful(Nil: Seq[TicketBlock]) 30 | } { eid => 31 | 32 | val basicBlocks = TicketBlock.listForEvent(eid) 33 | val issuer = TicketIssuer.getSelection 34 | val blocksWithAvailability: Future[Seq[TicketBlock]] = 35 | basicBlocks.flatMap { blocks => 36 | val updatedBlocks: Seq[Future[TicketBlock]] = for { 37 | block <- blocks 38 | blockID <- block.id 39 | availabilityRaw = issuer ? AvailabilityCheck(blockID) 40 | 41 | availability = availabilityRaw.mapTo[Int] 42 | 43 | updatedBlock = availability.map { a => 44 | block.copy(availability = Option(a)) 45 | } 46 | } yield updatedBlock 47 | 48 | // Transform Seq[Future[...]] to Future[Seq[...]] while filtering 49 | // any failures 50 | OptimisticFuture.sequence(updatedBlocks) 51 | } 52 | 53 | blocksWithAvailability 54 | } 55 | } 56 | } 57 | 58 | object Event { 59 | implicit val format: Format[Event] = Json.format[Event] 60 | 61 | protected val dbConfig = DatabaseConfigProvider.get[JdbcProfile](current) 62 | import dbConfig._ 63 | import dbConfig.driver.api._ 64 | 65 | class EventsTable(tag: Tag) extends Table[Event](tag, "EVENTS") { 66 | 67 | def id = column[Long]("ID", O.PrimaryKey, O.AutoInc) 68 | def name = column[String]("NAME") 69 | def start = column[DateTime]("START") 70 | def end = column[DateTime]("END") 71 | def address = column[String]("ADDRESS") 72 | def city = column[String]("CITY") 73 | def state = column[String]("STATE") 74 | def country = column[String]("COUNTRY") 75 | 76 | def * = (id.?, name, start, end, address, city, state, country) <> 77 | ((Event.apply _).tupled, Event.unapply) 78 | } 79 | 80 | val table = TableQuery[EventsTable] 81 | 82 | def list: Future[Seq[Event]] = { 83 | val eventList = table.result 84 | db.run(eventList) 85 | } 86 | 87 | def getByID(eventID: Long): Future[Option[Event]] = { 88 | val eventByID = table.filter { f => 89 | f.id === eventID 90 | }.result.headOption 91 | 92 | db.run(eventByID) 93 | } 94 | 95 | def create(newEvent: Event): Future[Event] = { 96 | val insertion = (table returning table.map(_.id)) += newEvent 97 | 98 | val insertedIDFuture = db.run(insertion) 99 | 100 | val createdCopy: Future[Event] = insertedIDFuture.map { resultID => 101 | newEvent.copy(id = Option(resultID)) 102 | } 103 | 104 | createdCopy 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /app/controllers/Orders.scala: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import play.api.mvc._ 4 | import play.api.libs.json.Json 5 | import scala.concurrent.Future 6 | import play.api.libs.concurrent.Execution.Implicits._ 7 | 8 | import com.semisafe.ticketoverlords.{ Order, TicketBlock } 9 | import controllers.responses._ 10 | 11 | import play.api.libs.concurrent.Akka 12 | import play.api.Play.current 13 | import com.semisafe.ticketoverlords.TicketIssuer 14 | import akka.actor.Props 15 | 16 | import akka.pattern.ask 17 | import akka.util.Timeout 18 | import scala.concurrent.duration._ 19 | 20 | import com.semisafe.ticketoverlords.InsufficientTicketsAvailable 21 | import com.semisafe.ticketoverlords.TicketBlockUnavailable 22 | import play.api.Logger 23 | 24 | import play.filters.csrf._ 25 | 26 | object Orders extends Controller { 27 | 28 | def list = Action.async { request => 29 | val orders = Order.list 30 | orders.map { o => 31 | Ok(Json.toJson(SuccessResponse(o))) 32 | } 33 | } 34 | 35 | def getByID(orderID: Long) = Action.async { request => 36 | val orderFuture = Order.getByID(orderID) 37 | 38 | orderFuture.map { order => 39 | order.fold { 40 | NotFound(Json.toJson(ErrorResponse(NOT_FOUND, "No order found"))) 41 | } { o => 42 | Ok(Json.toJson(SuccessResponse(o))) 43 | } 44 | } 45 | } 46 | 47 | def create = CSRFCheck { 48 | Action.async(parse.json) { request => 49 | val incomingBody = request.body.validate[Order] 50 | 51 | incomingBody.fold(error => { 52 | val errorMessage = s"Invalid JSON: ${error}" 53 | val response = ErrorResponse(ErrorResponse.INVALID_JSON, errorMessage) 54 | Future.successful(BadRequest(Json.toJson(response))) 55 | }, { order => 56 | val timeoutKey = "ticketoverlords.timeouts.issuer" 57 | val configuredTimeout = current.configuration.getInt(timeoutKey) 58 | val resolvedTimeout = configuredTimeout.getOrElse(5) 59 | implicit val timeout = Timeout(resolvedTimeout.seconds) 60 | 61 | val issuer = TicketIssuer.getSelection 62 | val orderFuture = (issuer ? order).mapTo[Order] 63 | 64 | // Convert successful future to Json 65 | orderFuture.map { createdOrder => 66 | Ok(Json.toJson(SuccessResponse(createdOrder))) 67 | }.recover({ 68 | case ita: InsufficientTicketsAvailable => { 69 | val responseMessage = 70 | "There are not enough tickets remaining to complete this order." + 71 | s" Quantity Remaining: ${ita.ticketsAvailable}" 72 | 73 | val response = ErrorResponse( 74 | ErrorResponse.NOT_ENOUGH_TICKETS, 75 | responseMessage) 76 | 77 | BadRequest(Json.toJson(response)) 78 | } 79 | case tba: TicketBlockUnavailable => { 80 | val responseMessage = 81 | s"Ticket Block ${order.ticketBlockID} is not available." 82 | val response = ErrorResponse( 83 | ErrorResponse.TICKET_BLOCK_UNAVAILABLE, 84 | responseMessage) 85 | 86 | BadRequest(Json.toJson(response)) 87 | } 88 | case unexpected => { 89 | Logger.error( 90 | s"Unexpected error while placing an order: ${unexpected.toString}") 91 | val response = ErrorResponse( 92 | INTERNAL_SERVER_ERROR, 93 | "An unexpected error occurred") 94 | 95 | InternalServerError(Json.toJson(response)) 96 | } 97 | }) 98 | }) 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /app/com/semisafe/ticketoverlords/TicketBlock.scala: -------------------------------------------------------------------------------- 1 | package com.semisafe.ticketoverlords 2 | 3 | import org.joda.time.DateTime 4 | import play.api.libs.json.{ Json, Format } 5 | 6 | import play.api.db.slick.DatabaseConfigProvider 7 | import slick.driver.JdbcProfile 8 | import play.api.Play.current 9 | import play.api.db.DBApi 10 | import SlickMapping.jodaDateTimeMapping 11 | import scala.concurrent.Future 12 | import play.api.libs.concurrent.Execution.Implicits._ 13 | import play.api.Logger 14 | 15 | import akka.actor.ActorSelection 16 | import play.libs.Akka 17 | 18 | case class TicketBlock( 19 | id: Option[Long], 20 | eventID: Long, 21 | name: String, 22 | productCode: String, 23 | price: BigDecimal, 24 | initialSize: Int, 25 | saleStart: DateTime, 26 | saleEnd: DateTime, 27 | availability: Option[Int] = None) 28 | 29 | object TicketBlock { 30 | implicit val format: Format[TicketBlock] = Json.format[TicketBlock] 31 | 32 | protected val dbConfig = DatabaseConfigProvider.get[JdbcProfile](current) 33 | import dbConfig._ 34 | import dbConfig.driver.api._ 35 | 36 | class TicketBlocksTable(tag: Tag) 37 | extends Table[TicketBlock](tag, "TICKET_BLOCKS") { 38 | 39 | def id = column[Long]("ID", O.PrimaryKey, O.AutoInc) 40 | def eventID = column[Long]("EVENT_ID") 41 | def name = column[String]("NAME") 42 | def productCode = column[String]("PRODUCT_CODE") 43 | def price = column[BigDecimal]("PRICE") 44 | def initialSize = column[Int]("INITIAL_SIZE") 45 | def saleStart = column[DateTime]("SALE_START") 46 | def saleEnd = column[DateTime]("SALE_END") 47 | 48 | def event = foreignKey("TB_EVENT", eventID, Event.table)(_.id) 49 | 50 | def * = (id.?, eventID, name, productCode, price, initialSize, 51 | saleStart, saleEnd) <> 52 | ( 53 | (TicketBlock.apply(_: Option[Long], _: Long, _: String, _: String, 54 | _: BigDecimal, _: Int, _: DateTime, _: DateTime, 55 | None)).tupled, { tb: TicketBlock => 56 | TicketBlock.unapply(tb).map { 57 | case (a, b, c, d, e, f, g, h, _) => (a, b, c, d, e, f, g, h) 58 | } 59 | }) 60 | } 61 | 62 | val table = TableQuery[TicketBlocksTable] 63 | 64 | def list: Future[Seq[TicketBlock]] = { 65 | val blockList = table.result 66 | db.run(blockList) 67 | } 68 | 69 | def listForEvent(eventID: Long): Future[Seq[TicketBlock]] = { 70 | val blockList = table.filter { tb => 71 | tb.eventID === eventID 72 | }.result 73 | db.run(blockList) 74 | } 75 | 76 | def getByID(blockID: Long): Future[Option[TicketBlock]] = { 77 | val blockByID = table.filter { f => 78 | f.id === blockID 79 | }.result.headOption 80 | db.run(blockByID) 81 | } 82 | 83 | def create(newTicketBlock: TicketBlock): Future[TicketBlock] = { 84 | val insertion = (table returning table.map(_.id)) += newTicketBlock 85 | db.run(insertion).map { resultID => 86 | val createdBlock = newTicketBlock.copy(id = Option(resultID)) 87 | 88 | val issuer = TicketIssuer.getSelection 89 | issuer ! TicketBlockCreated(createdBlock) 90 | 91 | createdBlock 92 | } 93 | } 94 | 95 | def availability(ticketBlockID: Long): Future[Int] = { 96 | db.run { 97 | val query = sql""" 98 | select INITIAL_SIZE - COALESCE(SUM(TICKET_QUANTITY), 0) 99 | from TICKET_BLOCKS tb 100 | left join ORDERS o on o.TICKET_BLOCK_ID=tb.ID 101 | where tb.ID=${ticketBlockID} 102 | group by INITIAL_SIZE; 103 | """.as[Int] 104 | 105 | query.headOption 106 | }.map { _.getOrElse(0) } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /app/assets/js/event_list_entry.coffee: -------------------------------------------------------------------------------- 1 | define [ 2 | 'react' 3 | ], (React) -> 4 | EventListEntry = React.createClass 5 | getInitialState: -> 6 | expanded: false 7 | 8 | gatherTicketBlocks: -> 9 | ticketBlocksApi = jsRoutes.controllers.Events.ticketBlocksForEvent( 10 | @props.event.id 11 | ) 12 | ticketBlocksApi.ajax() 13 | .done (result) => 14 | if @isMounted() 15 | availBlocks = (tb for tb in \ 16 | result.response when tb.availability > 0) 17 | @setState 18 | ticketBlocks: availBlocks 19 | 20 | .fail (jqXHR, textStatus, errorThrown) => 21 | resultCode = jqXHR.status 22 | if @isMounted() 23 | @setState 24 | ticketBlocks: null 25 | 26 | toggleExpanded: -> 27 | if @state.ticketBlocks == undefined 28 | @gatherTicketBlocks() 29 | @setState 30 | ticketBlocks: null 31 | 32 | @setState 33 | expanded: !@state.expanded 34 | 35 | getCookie: (name) -> 36 | document.cookie.split(';').filter( (c) -> 37 | c.trim().startsWith("#{ name }=") 38 | ).map( (c) -> 39 | c.split('=')[1] 40 | ) 41 | 42 | placeOrder: -> 43 | ticketBlockID = @refs.selectedTicketBlock.getDOMNode().value 44 | ticketQuantity = @refs.ticketQuantity.getDOMNode().value 45 | customerName = @refs.customerName.getDOMNode().value 46 | customerEmail = @refs.customerEmail.getDOMNode().value 47 | 48 | # This is pretty lame validation, but better than nothing 49 | if customerName.length == 0 50 | alert "Your name is required" 51 | return 52 | 53 | if customerEmail.length == 0 54 | alert "Your email is required" 55 | return 56 | 57 | order = 58 | ticketBlockID: Number(ticketBlockID) 59 | customerName: customerName 60 | customerEmail: customerEmail 61 | ticketQuantity: Number(ticketQuantity) 62 | 63 | csrfToken = @getCookie("CSRF-Token") 64 | 65 | ticketBlocksApi = jsRoutes.controllers.Orders.create() 66 | ticketBlocksApi.ajax( 67 | data: JSON.stringify order 68 | contentType: 'application/json' 69 | headers: 70 | "X-CSRF-Token": csrfToken 71 | ) 72 | .done (result) => 73 | if @isMounted() 74 | alert "Order placed. REF #{result.response.id}" 75 | @setState 76 | expanded: false 77 | 78 | .fail (jqXHR, textStatus, errorThrown) => 79 | resultCode = jqXHR.status 80 | result = jqXHR.responseJSON 81 | if @isMounted() 82 | alert "Error placing the order: #{result.error.message}" 83 | 84 | renderEntryBlocks: -> 85 | { div, span, option, label, select, input, button } = React.DOM 86 | eid = @props.event.id 87 | if @state.ticketBlocks? 88 | if @state.ticketBlocks.length > 0 89 | options = @state.ticketBlocks.map (tb) -> 90 | priceFormat = parseFloat(Math.round(tb.price * 100) / 100).toFixed(2) 91 | option { 92 | key: tb.id 93 | ref: "selectedTicketBlock" 94 | value: tb.id 95 | }, "#{ tb.name } - $#{ priceFormat }" 96 | 97 | blockChoice = select { 98 | key: 'tbo' 99 | id: "tbo#{eid}" 100 | }, options 101 | 102 | div { key: 'opnl' }, [ 103 | div { key: 'q'}, [ 104 | label { 105 | key: 'lt' 106 | htmlFor: "tbo#{eid}" 107 | }, "Tickets:" 108 | blockChoice 109 | 110 | label { 111 | key: 'lq' 112 | htmlFor: "qty#{eid}" 113 | }, "Quantity:" 114 | input { 115 | key: 'qty' 116 | ref: "ticketQuantity" 117 | id: "qty#{eid}" 118 | type: "number" 119 | max: 9999 120 | min: 1 121 | defaultValue: 1 122 | } 123 | ], 124 | 125 | div { key: 'n' }, [ 126 | label { 127 | key: 'ln' 128 | htmlFor: "name#{eid}" 129 | }, "Name:" 130 | input { 131 | key: 'name' 132 | ref: "customerName" 133 | id: "name#{eid}" 134 | } 135 | 136 | label { 137 | key: 'le' 138 | htmlFor: "email#{eid}" 139 | }, "Email:" 140 | input { 141 | key: 'email' 142 | ref: "customerEmail" 143 | id: "email#{eid}" 144 | } 145 | button { 146 | key: 'o' 147 | onClick: @placeOrder 148 | }, "Place Order" 149 | ] 150 | ] 151 | else 152 | div { key: 'so' }, "No tickets available" 153 | else 154 | null 155 | 156 | render: -> 157 | { div, span, button } = React.DOM 158 | if @props.event? 159 | eid = @props.event.id 160 | 161 | eventDate = new Date(@props.event.start) 162 | readableDate = eventDate.toDateString() 163 | 164 | orderText = if @state.expanded then "Cancel" else "Order" 165 | orderButton = button { 166 | key: 'o' 167 | onClick: @toggleExpanded 168 | }, orderText 169 | 170 | baseRow = div { 171 | key: "er-#{ eid }" 172 | className: "eventEntry" 173 | }, [ 174 | span { key: 'evn' }, @props.event.name 175 | span { key: 'evc' }, @props.event.city 176 | span { key: 'evd' }, readableDate 177 | span { key: 'order' }, orderButton 178 | ] 179 | 180 | contents = [baseRow] 181 | 182 | if @state.expanded 183 | contents.push @renderEntryBlocks() 184 | 185 | div {}, contents 186 | else 187 | null 188 | 189 | EventListEntry 190 | --------------------------------------------------------------------------------