auction.ts

 import { isRecord } from "./record";


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


/**
 * Parses unknown data into an Auction. Will log an error and return undefined
 * if it is not a valid Auction.
 * @param val unknown data to parse into an Auction
 * @return Auction if val is a valid Auction and undefined otherwise
 */
export const parseAuction = (val: unknown): undefined | Auction => {
  if (!isRecord(val)) {
    console.error("not an auction", val)
    return undefined;
  }

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

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

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

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

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

  if (typeof val.endTime !== "number" || val.endTime < 0 || isNaN(val.endTime)) {
    console.error("not an auction: 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
  };
};

App.tsx

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


// Indicates which page to show. If it is the details page, the argument
// includes the specific auction to show the details of.
type Page = "list" | "new" | {kind: "details", index: number}; 

// RI: If page is "details", then index is a valid index into auctions array.
type AppState = {page: Page, auctions: ReadonlyArray<Auction> | undefined};

// Whether to show debugging information in the console.
const DEBUG: boolean = true;


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

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

  componentDidMount = (): void => {
    this.doRefreshAllClick();
  };

  render = (): JSX.Element => {
    if (this.state.auctions === undefined) {
      if (DEBUG) console.debug("rendering loading page");
      return <div>Loading...</div>;

    } else if (this.state.page === "list") {
      if (DEBUG) console.debug("rendering list page");
      return <AuctionList auctions={this.state.auctions}
                          onNewClick={this.doNewClick}
                          onAuctionClick={this.doAuctionClick}
                          onRefreshClick={this.doRefreshAllClick}/>;

    } else if (this.state.page === "new") {
      if (DEBUG) console.debug("rendering add page");
      return <NewAuction onStartClick={this.doStartClick}
                         onBackClick={this.doBackClick}/>;

    } else {  // details
      const index = this.state.page.index;
      const auction = this.state.auctions[index];
      if (DEBUG) console.debug(`rendering details page for "${auction.name}"`);
      return <AuctionDetails auction={auction}
                  onBidClick={(bidder, amt) => this.doBidClick(index, bidder, amt)}
                  onRefreshClick={() => this.doRefreshAuctionClick(index)}
                  onBackClick={this.doBackClick}/>;
    }
  };
  
  doRefreshAllClick = (): void => {
    fetch("/api/list").then(this.doListResp)
        .catch(() => this.doListError("failed to connect to server"));
  };

  doListResp = (resp: Response): void => {
    if (resp.status === 200) {
      resp.json().then(this.doListJson)
          .catch(() => this.doListError("200 response is not JSON"));
    } else if (resp.status === 400) {
      resp.text().then(this.doListError)
          .catch(() => this.doListError("400 response is not text"));
    } else {
      this.doListError(`bad status code from /api/list: ${resp.status}`);
    }
  };

  doListJson = (data: unknown): void => {
    if (!isRecord(data)) {
      console.error("bad data from /api/list: not a record", data);
      return;
    }

    if (!Array.isArray(data.auctions)) {
      console.error("bad data from /api/list: auctions is not an array", data);
      return;
    }

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

  doListError = (msg: string): void => {
    console.error(`Error fetching /api/list: ${msg}`);
  };

  doNewClick = (): void => {
    if (DEBUG) console.debug("set state to new");
    this.setState({page: "new"});
  };
  
  doAuctionClick = (index: number): void => {
    if (DEBUG) console.debug(`set state to details for auction ${index}`);
    this.setState({page: {kind: "details", index}});
  };
  
  doBackClick = (): void => {
    if (DEBUG) console.debug("set state to list");
    this.setState({page: "list"});
  };

  doStartClick = (info: NewAuctionInfo): void => {
    if (DEBUG) console.debug("add an auction and then show the full list");

    fetch("/api/add", {
        method: "POST", body: JSON.stringify(info),
        headers: {"Content-Type": "application/json"} })
      .then(this.doAddResp)
      .catch(() => this.doAddError("failed to connect to server"));
  };

  doAddResp = (resp: Response): void => {
    if (resp.status === 200) {
      resp.json().then(this.doAddJson)
          .catch(() => this.doAddError("200 response is not JSON"));
    } else if (resp.status === 400) {
      resp.text().then(this.doAddError)
          .catch(() => this.doAddError("400 response is not text"));
    } else {
      this.doAddError(`bad status code from /api/add: ${resp.status}`);
    }
  };

  doAddJson = (data: unknown): void => {
    if (!isRecord(data)) {
      console.error("bad data from /api/add: not a record", data);
      return;
    }

    // Not sure where this goes, so let's fetch again.
    const auction = parseAuction(data.auction);
    if (auction !== undefined) {
      this.setState({page: "list", auctions: undefined});  // show loading

      fetch("/api/list").then(this.doListResp)
          .catch(() => this.doListError("failed to connect to server"));
    }
  };

  doAddError = (msg: string): void => {
    console.error(`Error fetching /api/add: ${msg}`);
  };
  
  doBidClick = (index: number, bidder: string, amount: number): void => {
    if (DEBUG) console.debug("add an auction and then show the full list");

    const args = {index, bidder, amount};
    fetch("/api/bid", {
        method: "POST", body: JSON.stringify(args),
        headers: {"Content-Type": "application/json"} })
      .then((res) => this.doBidResp(index, res))
      .catch(() => this.doBidError("failed to connect to server"));
  };

  doBidResp = (index: number, res: Response): void => {
    if (res.status === 200) {
      res.json().then((data) => this.doBidJson(index, data))
          .catch(() => this.doBidError("200 response is not JSON"));
    } else if (res.status === 400) {
      res.text().then(this.doBidError)
          .catch(() => this.doBidError("400 response is not text"));
    } else {
      this.doBidError(`bad status code from /api/bid: ${res.status}`);
    }
  };

  doBidJson = (index: number, data: unknown): void => {
    if (this.state.auctions === undefined)
      throw new Error("impossible");

    if (!isRecord(data)) {
      console.error("bad data from /api/bid: not a record", data);
      return;
    }

    const auction = parseAuction(data.auction);
    if (auction !== undefined) {
      // Re-display the details page with the new auction.
      const auctions = this.state.auctions.slice(0, index)
          .concat([auction])
          .concat(this.state.auctions.slice(index+1));
      this.setState({auctions});
    }
  };

  doBidError = (msg: string): void => {
    console.error(`Error fetching /api/bid: ${msg}`);
  };

  doRefreshAuctionClick = (index: number): void => {
    if (DEBUG) console.debug("add an auction and then show the full list");

    const args = {index};
    fetch("/api/get", {
        method: "POST", body: JSON.stringify(args),
        headers: {"Content-Type": "application/json"} })
      .then((res) => this.doGetResp(index, res))
      .catch(() => this.doGetError("failed to connect to server"));
  };

  doGetResp = (index: number, res: Response): void => {
    if (res.status === 200) {
      res.json().then((data) => this.doGetJson(index, data))
          .catch(() => this.doGetError("200 res is not JSON"));
    } else if (res.status === 400) {
      res.text().then(this.doGetError)
          .catch(() => this.doGetError("400 response is not text"));
    } else {
      this.doGetError(`bad status code from /api/refersh: ${res.status}`);
    }
  };

  doGetJson = (index: number, data: unknown): void => {
    if (this.state.auctions === undefined)
      throw new Error("impossible");

    if (!isRecord(data)) {
      console.error("bad data from /api/refresh: not a record", data);
      return;
    }

    const auction = parseAuction(data.auction);
    if (auction !== undefined) {
      // Re-display the details page with the new auction.
      const auctions = this.state.auctions.slice(0, index)
          .concat([auction])
          .concat(this.state.auctions.slice(index+1));
      this.setState({auctions});
    }
  };

  doGetError = (msg: string): void => {
    console.error(`Error fetching /api/refresh: ${msg}`);
  };
}

AuctionList.tsx

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


type ListProps = {
  auctions: ReadonlyArray<Auction>,
  onNewClick: () => void,
  onRefreshClick: () => void,
  onAuctionClick: (index: number) => void
};

type ListState = {
  now: number,  // current time when rendering
};


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

  constructor(props: ListProps) {
    super(props);
    this.state = {now: Date.now()}
  }

  componentDidUpdate = (prevProps: ListProps): void => {
    if (prevProps !== this.props) {
      this.setState({now: Date.now()});  // Force a refresh
    }
  };

  render = (): JSX.Element => {
    return (
      <div>
        <h2>Current Auctions</h2>
        <ul>{this.renderAuctions()}</ul>
        <button type="button" onClick={this.doRefreshClick}>Refresh</button>
        <button type="button" onClick={this.doNewClick}>New Auction</button>
      </div>);
  };

  renderAuctions = (): JSX.Element[] => {
    const auctions: JSX.Element[] = [];
    // Inv: auctions = LI for each of auctions[0 .. i-1]
    for (let i = 0; i < this.props.auctions.length; i++) {
      const auction = this.props.auctions[i];
      const min = (auction.endTime - this.state.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.doAuctionClick(evt, i)}>{auction.name}</a>
          {desc}
        </li>);
    }
    return auctions;
  };

  doRefreshClick = (_evt: MouseEvent<HTMLButtonElement>): void => {
    this.props.onRefreshClick();
  };

  doNewClick = (_evt: MouseEvent<HTMLButtonElement>): void => {
    this.props.onNewClick();  // tell the parent to show the new auction page
  };

  doAuctionClick = (evt: MouseEvent<HTMLAnchorElement>, index: number): void => {
    evt.preventDefault();
    this.props.onAuctionClick(index);
  };
}

NewAuction.tsx

import React, { Component, ChangeEvent, MouseEvent } from 'react';


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

type NewAuctionProps = {
  onStartClick: (info: NewAuctionInfo) => void,
  onBackClick: () => void
};

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


// 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", error: ""};
  }

  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.doNameChange}></input>
        </div>
        <div>
          <label htmlFor="description">Description:</label>
          <input id="description" type="text" value={this.state.description}
              onChange={this.doDescChange}></input>
        </div>
        <div>
          <label htmlFor="seller">Seller Name:</label>
          <input id="seller" type="text" value={this.state.seller}
              onChange={this.doSellerChange}></input>
        </div>
        <div>
          <label htmlFor="minutes">Minutes:</label>
          <input id="minutes" type="number" min="1" value={this.state.minutes}
              onChange={this.doMinutesChange}></input>
        </div>
        <div>
          <label htmlFor="minBid">Minimum Bid:</label>
          <input id="minBid" type="number" min="1" value={this.state.minBid}
              onChange={this.doMinBidChange}></input>
        </div>
        <button type="button" onClick={this.doStartClick}>Start</button>
        <button type="button" onClick={this.doBackClick}>Back</button>
        {this.renderError()}
      </div>);
  };

  renderError = (): JSX.Element => {
    if (this.state.error.length === 0) {
      return <div></div>;
    } else {
      const style = {width: '300px', backgroundColor: 'rgb(246,194,192)',
          border: '1px solid rgb(137,66,61)', borderRadius: '5px', padding: '5px' };
      return (<div style={{marginTop: '15px'}}>
          <span style={style}><b>Error</b>: {this.state.error}</span>
        </div>);
    }
  };

  doNameChange = (evt: ChangeEvent<HTMLInputElement>): void => {
    this.setState({name: evt.target.value, error: ""});
  };

  doDescChange = (evt: ChangeEvent<HTMLInputElement>): void => {
    this.setState({description: evt.target.value, error: ""});
  };

  doSellerChange = (evt: ChangeEvent<HTMLInputElement>): void => {
    this.setState({seller: evt.target.value, error: ""});
  };

  doMinutesChange = (evt: ChangeEvent<HTMLInputElement>): void => {
    this.setState({minutes: evt.target.value, error: ""});
  };

  doMinBidChange = (evt: ChangeEvent<HTMLInputElement>): void => {
    this.setState({minBid: evt.target.value, error: ""});
  };

  doStartClick = (_: MouseEvent<HTMLButtonElement>): void => {
    // Verify that the user entered all required information.
    if (this.state.name.trim().length === 0 ||
        this.state.description.trim().length === 0 ||
        this.state.seller.trim().length === 0 ||
        this.state.minutes.trim().length === 0 ||
        this.state.minBid.trim().length === 0) {
      this.setState({error: "a required field is missing."});
      return;
    }

    // Verify that minutes is a number.
    const minutes = parseFloat(this.state.minutes);
    if (isNaN(minutes) || minutes < 1 || Math.floor(minutes) !== minutes) {
      this.setState({error: "minutes is not a positive integer"});
      return;
    }

    // Ignore this request if the minutes or minBid are not numbers.
    const minBid = parseFloat(this.state.minBid);
    if (isNaN(minBid) || minBid < 1 || Math.floor(minBid) !== minBid) {
      this.setState({error: "min bid is not a positive integer"});
      return;
    }

    // Ask the app to start this auction (adding it to the list).
    this.props.onStartClick({
        name: this.state.name,
        description: this.state.description,
        seller: this.state.seller,
        minBid: minBid,
        minutes: minutes });
  };

  doBackClick = (_: MouseEvent<HTMLButtonElement>): void => {
    this.props.onBackClick();  // tell the parent this was clicked
  };
}

AuctionDetails.tsx

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


type DetailsProps = {
  auction: Auction,
  onBidClick: (bidder: string, amount: number) => void,
  onRefreshClick: () => void,
  onBackClick: () => void,
};

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


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

  constructor(props: DetailsProps) {
    super(props);

    const amount = this.props.auction.maxBid + 1;  // min bid is 1 more
    this.state = {now: Date.now(), bidder: "", amount: '' + amount, error: ""};
  }

  componentDidUpdate = (prevProps: DetailsProps): void => {
    if (prevProps !== this.props) {
      // If the user's bid is too small, update it to be valid. In any case,
      // we need to update the time.
      const amount = parseFloat(this.state.amount);
      if (!isNaN(amount) && amount < this.props.auction.maxBid + 1) {
        this.setState({amount: '' + (this.props.auction.maxBid + 1),
                       now: Date.now(), error: ""});
      } else {
        this.setState({now: Date.now(), error: ""});
      }
    }
  };

  render = (): JSX.Element => {
    const auction = this.props.auction;
    if (auction.endTime <= this.state.now) {
      return this.renderCompleted();
    } else {
      return this.renderOngoing();
    }
  };

  renderCompleted = (): JSX.Element => {
    const auction = this.props.auction;
    return (
      <div>
        <h2>{auction.name}</h2>
        <p>{auction.description}</p>
        <p>Winning Bid: {auction.maxBid} (by {auction.maxBidder})</p>
      </div>);
  };

  renderOngoing = (): JSX.Element => {
    const auction = this.props.auction;
    const min = Math.round((auction.endTime - this.state.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} by {auction.maxBidder}</p>
        <div>
          <label htmlFor="bidder">Name:</label>
          <input type="text" id="bidder" value={this.state.bidder} 
              onChange={this.doBidderChange}></input>
        </div>
        <div>
          <label htmlFor="amount">Amount:</label>
          <input type="number" min={auction.maxBid + 1}
              id="amount" value={this.state.amount} 
              onChange={this.doAmountChange}></input>
        </div>
        <button type="button" onClick={this.doBidClick}>Bid</button>
        <button type="button" onClick={this.doRefreshClick}>Refresh</button>
        <button type="button" onClick={this.doDoneClick}>Done</button>
        {this.renderError()}
      </div>);
  };

  renderError = (): JSX.Element => {
    if (this.state.error.length === 0) {
      return <div></div>;
    } else {
      const style = {width: '300px', backgroundColor: 'rgb(246,194,192)',
          border: '1px solid rgb(137,66,61)', borderRadius: '5px', padding: '5px' };
      return (<div style={{marginTop: '15px'}}>
          <span style={style}><b>Error</b>: {this.state.error}</span>
        </div>);
    }
  };

  doBidderChange = (evt: ChangeEvent<HTMLInputElement>): void => {
    this.setState({bidder: evt.target.value, error: ""});
  };

  doAmountChange = (evt: ChangeEvent<HTMLInputElement>): void => {
    this.setState({amount: evt.target.value, error: ""});
  };

  doBidClick = (_: MouseEvent<HTMLButtonElement>): void => {
    // Verify that the user entered all required information.
    if (this.state.bidder.trim().length === 0 ||
        this.state.amount.trim().length === 0) {
      this.setState({error: "a required field is missing."});
      return;
    }

    // Verify that amount is a positive integer.
    const amount = parseFloat(this.state.amount);
    if (isNaN(amount) || Math.floor(amount) !== amount) {
      this.setState({error: "amount is not an integer"});
      return;
    }

    // Verify that amount is bigger than the current bid.
    if (amount < this.props.auction.maxBid + 1) {
      this.setState({error: "amount is not bigger than current bid"});
      return;
    }

    this.props.onBidClick(this.state.bidder, amount);
  };

  doRefreshClick = (_evt: MouseEvent<HTMLButtonElement>): void => {
    this.props.onRefreshClick();  // tell the parent to redraw this
  };

  doDoneClick = (_: MouseEvent<HTMLButtonElement>): void => {
    this.props.onBackClick();  // tell the parent to show the full list again
  };
}

Full Code