├── 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 |
--------------------------------------------------------------------------------