App.tsx

import React, { Component } from 'react';
import { AuctionList } from './AuctionList';
import { AuctionDetails } from './AuctionDetails';
import { NewAuction } from './NewAuction';
import { Auction } from './auction';

type Page = "list" | "add" | {kind: "details", auction: Auction}; 
type AppState = {page: Page};

// Top-level component that displays the appropriate page.
export class App extends Component<{}, AppState> {

  constructor(props: {}) {
    super(props);
    this.state = {page: "list"};
  }

  render = (): JSX.Element => {
    if (this.state.page === "list") {
      return <AuctionList onAdd={this.handleAdd}
                          onShow={this.handleShow}/>;
    } else if (this.state.page === "add") {
      return <NewAuction onShow={this.handleShow}
                         onBack={this.handleBack}/>;
    } else {  // details
      return <AuctionDetails auction={this.state.page.auction}
                 onShow={this.handleShow} onBack={this.handleBack}/>;
    }
  };

  handleAdd = (): void => {
    this.setState({page: "add"});
  };
  
  handleBack = (): void => {
    this.setState({page: "list"});
  };
  
  handleShow = (auction: Auction): void => {
    this.setState({page: {kind: "details", auction}});
  };
}

AuctionList.tsx

import React, { Component, MouseEvent } from 'react';
import { Auction, parseAuction } from './auction';


type ListProps = {
  onAdd: () => void,
  onShow: (auction: Auction) => void
};

type ListState = {
  auctions: undefined | ReadonlyArray<Auction>;
};


// Shows the list of all the auctions.
export class AuctionList extends Component<ListProps, ListState> {

  constructor(props: ListProps) {
    super(props);
    this.state = {auctions: undefined};
  }

  componentDidMount = () => {
    fetch("/api/list")
        .then(this.handleList)
        .catch(this.handleServerError);
  };

  render = (): JSX.Element => {
    if (this.state.auctions === undefined) {
      return <p>Loading...</p>;
    } else {
      const now = Date.now();
      const auctions: JSX.Element[] = [];
      for (const auction of this.state.auctions) {
        const min = (auction.endTime - now) / 60 / 1000;
        const desc = (min < 0) ? "" :
            <span> – {Math.round(min)} minutes remaining</span>;
        auctions.push(
          <li key={auction.name}>
            <a href="#" onClick={(evt) => this.handleShow(evt, auction)}>{auction.name}</a>
            {desc}
          </li>);
      }

      return (
        <div>
          <h2>Current Auctions</h2>
          <ul>{auctions}</ul>
          <button type="button" onClick={this.props.onAdd}>Add</button>
        </div>);
    }
  };

  handleShow = (evt: MouseEvent<HTMLAnchorElement>, auction: Auction) => {
    evt.preventDefault();
    this.props.onShow(auction);
  };

  // Called with the response from a request to /list
  handleList = (res: Response) => {
    if (res.status === 200) {
      res.json().then(this.handleListJson).catch(this.handleServerError);
    } else {
      this.handleServerError(res);
    }
  };

  // Called with the JSON of the response from /list
  handleListJson = (vals: any) => {
    if (typeof vals !== "object" || vals === null || !('auctions' in vals) ||
        !Array.isArray(vals.auctions)) {
      console.error("bad data from /list: no auctions", vals)
      return;
    }

    const auctions: Auction[] = [];
    for (const val of vals.auctions) {
      const auction = parseAuction(val);
      if (auction !== undefined) {
        auctions.push(auction);
      }
    }
    this.setState({auctions: auctions});
  };

  // Called when we fail to communicate correctly with the server.
  handleServerError = (_: Response) => {
    // TODO: show the error to the user, with more information
    console.error(`unknown error talking to server`);
  };
}

AuctionDtails.tsx

import React, { Component, ChangeEvent, MouseEvent } from 'react';
import { Auction, parseAuction } from './auction';


type DetailsProps = {
  auction: Auction,
  onShow: (auction: Auction) => void,
  onBack: () => void
};

type DetailsState = {
  bidder: string,
  amount: number
};


// Shows an individual auction and allows bidding (if ongoing).
export class AuctionDetails extends Component<DetailsProps, DetailsState> {

  constructor(props: DetailsProps) {
    super(props);
    this.state = {bidder: "", amount: this.props.auction.maxBid+1};
  }

  componentDidUpdate = (_: DetailsProps): void => {
    if (this.state.amount <= this.props.auction.maxBid) {
      this.setState({amount: this.props.auction.maxBid+1})
    }
  };

  render = (): JSX.Element => {
    const auction = this.props.auction;
    const now = Date.now()
    if (auction.endTime <= now) {
      return (
        <div>
          <h2>{auction.name}</h2>
          <p>{auction.description}</p>
          <p>Winning Bid: {auction.maxBid} (by {auction.maxBidder})</p>
        </div>);
    } else {
      const min = Math.round((auction.endTime - now) / 60 / 100) / 10;
      return (
        <div>
          <h2>{auction.name}</h2>
          <p>{auction.description}</p>
          <p><i>Bidding ends in {min} minutes...</i></p>
          <p>Current Bid: {auction.maxBid}</p>
          <div>
            <label htmlFor="bidder">Name:</label>
            <input type="text" id="bidder" value={this.state.bidder} 
                onChange={this.handleBidderChange}></input>
          </div>
          <div>
            <label htmlFor="amount">Amount:</label>
            <input type="number" min={auction.maxBid + 1}
                id="amount" value={this.state.amount} 
                onChange={this.handleAmountChange}></input>
          </div>
          <button type="button" onClick={this.handleBid}>Bid</button>
          <button type="button" onClick={this.props.onBack}>Done</button>
        </div>);
    }
  };

  handleBidderChange = (evt: ChangeEvent<HTMLInputElement>): void => {
    this.setState({bidder: evt.target.value});
  };

  handleAmountChange = (evt: ChangeEvent<HTMLInputElement>): void => {
    this.setState({amount: parseInt(evt.target.value)});
  };

  handleBid = (_: MouseEvent<HTMLButtonElement>): void => {
    if (this.state.bidder.length > 0) {
      const url = "/api/bid" +
          "?name=" + encodeURIComponent(this.props.auction.name) +
          "&bidder=" + encodeURIComponent(this.state.bidder) +
          "&amount=" + encodeURIComponent(this.state.amount);
      fetch(url, {method: "POST"})
        .then(this.handleBidResponse)
        .catch(this.handleServerError);
    }
  };

  handleBidResponse = (res: Response) => {
    if (res.status === 200) {
      res.json().then(this.handleBidJson).catch(this.handleServerError);
    } else {
      this.handleServerError(res);
    }
  };

  handleBidJson = (val: any) => {
    if (typeof val !== "object" || val === null) {
      console.error("bad data from /bid: not a record", val)
      return;
    }

    const auction = parseAuction(val);
    if (auction !== undefined) {
      this.props.onShow(auction);
    }
  };

  // Called when we fail to communicate correctly with the server.
  handleServerError = (_: Response) => {
    // TODO: show the error to the user, with more information
    console.error(`unknown error talking to server`);
  };
}

NewAuction.tsx

import React, { Component, ChangeEvent, MouseEvent } from 'react';
import { Auction, parseAuction } from './auction';


type NewAuctionProps = {
  onShow: (auction: Auction) => void,
  onBack: () => void
};

type NewAuctionState = {
  name: string,
  description: string,
  seller: string,
  minutes: number,
  minBid: number
};


// Allows the user to create a new auction.
export class NewAuction extends Component<NewAuctionProps, NewAuctionState> {

  constructor(props: NewAuctionProps) {
    super(props);
    this.state = {name: "", description: "", seller: "", minutes: 60, minBid: 1};
  }

  render = (): JSX.Element => {
    return (
      <div>
        <h2>New Auction</h2>
        <div>
          <label htmlFor="name">Item Name:</label>
          <input id="name" type="text" value={this.state.name}
              onChange={this.handleNameChange}></input>
        </div>
        <div>
          <label htmlFor="description">Description:</label>
          <input id="description" type="text" value={this.state.description}
              onChange={this.handleDescChange}></input>
        </div>
        <div>
          <label htmlFor="seller">Seller Name:</label>
          <input id="seller" type="text" value={this.state.seller}
              onChange={this.handleSellerChange}></input>
        </div>
        <div>
          <label htmlFor="minutes">Minutes:</label>
          <input id="minutes" type="number" min="1" value={this.state.minutes}
              onChange={this.handleMinutesChange}></input>
        </div>
        <div>
          <label htmlFor="minBid">Minimum Bid:</label>
          <input id="minBid" type="number" min="1" value={this.state.minBid}
              onChange={this.handleMinBidChange}></input>
        </div>
        <button type="button" onClick={this.handleAdd}>Add</button>
      </div>);
  };

  handleNameChange = (evt: ChangeEvent<HTMLInputElement>): void => {
    this.setState({name: evt.target.value});
  };

  handleDescChange = (evt: ChangeEvent<HTMLInputElement>): void => {
    this.setState({description: evt.target.value});
  };

  handleSellerChange = (evt: ChangeEvent<HTMLInputElement>): void => {
    this.setState({seller: evt.target.value});
  };

  handleMinutesChange = (evt: ChangeEvent<HTMLInputElement>): void => {
    this.setState({minutes: parseInt(evt.target.value)});
  };

  handleMinBidChange = (evt: ChangeEvent<HTMLInputElement>): void => {
    this.setState({minBid: parseInt(evt.target.value)});
  };

  handleAdd = (_: MouseEvent<HTMLButtonElement>): void => {
    if (this.state.name.length > 0 && this.state.description.length > 0 && 
        this.state.seller.length > 0) {
      const url = "/api/add" +
          "?name=" + encodeURIComponent(this.state.name) +
          "&description=" + encodeURIComponent(this.state.description) +
          "&seller=" + encodeURIComponent(this.state.seller) +
          "&minutes=" + encodeURIComponent(''+this.state.minutes) +
          "&minBid=" + encodeURIComponent(''+this.state.minBid);
      fetch(url, {method: "POST"})
        .then(this.handleAddResponse)
        .catch(this.handleServerError);
    }
  };

  handleAddResponse = (res: Response) => {
    if (res.status === 200) {
      res.json().then(this.handleAddJson).catch(this.handleServerError);
    } else {
      this.handleServerError(res);
    }
  };

  handleAddJson = (val: any) => {
    if (typeof val !== "object" || val === null) {
      console.error("bad data from /bid: not a record", val)
      return;
    }

    const auction = parseAuction(val);
    if (auction !== undefined) {
      this.props.onShow(val);
    }
  };

  // Called when we fail to communicate correctly with the server.
  handleServerError = (_: Response) => {
    // TODO: show the error to the user, with more information
    console.error(`unknown error talking to server`);
  };
}

auction.ts

// Description of an individual auction
// RI: minBid, maxBid >= 0
export type Auction = {
  name: string,
  description: string,
  seller: string,
  endTime: number,  // ms since epoch
  maxBid: number,
  maxBidder: string
};

// Parses unkonwn data into an Auction. Will log an error and return undefined
// if it is not a valid Auction.
export function parseAuction(val: any): undefined | Auction {
  if (typeof val !== "object" || val === null) {
    console.error("not an auction", val)
    return undefined;
  }

  if (!('name' in val) || typeof val.name !== "string") {
    console.error("not an object: missing or invalid 'name'", val)
    return undefined;
  }

  if (!('description' in val) || typeof val.description !== "string") {
    console.error("not an object: missing or invalid 'description'", val)
    return undefined;
  }

  if (!('seller' in val) || typeof val.seller !== "string") {
    console.error("not an object: missing or invalid 'seller'", val)
    return undefined;
  }

  if (!('maxBid' in val) || typeof val.maxBid !== "number" ||
      val.maxBid < 0 || isNaN(val.maxBid)) {
    console.error("not an object: missing or invalid 'maxBid'", val)
    return undefined;
  }

  if (!('maxBidder' in val) || typeof val.maxBidder !== "string") {
    console.error("not an object: missing or invalid 'maxBidder'", val)
    return undefined;
  }

  if (!('endTime' in val) || typeof val.endTime !== "number" ||
      val.endTime < 0 || isNaN(val.endTime)) {
    console.error("not an object: missing or invalid 'endTime'", val)
    return undefined;
  }

  return {
    name: val.name,
    description: val.description,
    seller: val.seller,
    maxBid: val.maxBid,
    maxBidder: val.maxBidder,
    endTime: val.endTime
  };
}

Full Code