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
};

App.tsx

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

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

type AppState = {page: Page, auctions: ReadonlyArray<Auction>};

// 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: []};
  }

  render = (): JSX.Element => {
    if (this.state.page === "list") {
      if (DEBUG) console.debug("rendering list page");
      return <AuctionList auctions={this.state.auctions}
                  onNew={this.doNewClick} onShow={this.doShowClick}/>;

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

    } else {  // details
      const auction = this.state.page.auction;
      if (DEBUG) console.debug(`rendering details page for "${auction.name}"`);
      return <AuctionDetails auction={auction} onChange={this.doAuctionChange}
                  onBack={this.doBackClick}/>;
    }
  };

  doNewClick = (): void => {
    if (DEBUG) console.debug("set state to new");
    this.setState({page: "new"});
  };
  
  doShowClick = (auction: Auction): void => {
    if (DEBUG) console.debug(`set state to details for "${auction.name}"`);
    this.setState({page: {kind: "details", auction}});
  };
  
  doStartClick = (auction: Auction): void => {
    if (DEBUG) console.debug("add an auction and then show the full list");
    const auctions = this.state.auctions.concat([auction]);
    this.setState({page: "list", auctions});
  };
  
  doBackClick = (): void => {
    if (DEBUG) console.debug("set state to list");
    this.setState({page: "list"});
  };

  doAuctionChange = (oldAuction: Auction, newAuction: Auction): void => {
    // Build a new list of auctions with the old auction replaced by new one.
    const auctions = [];
    for (const auction of this.state.auctions) {
      auctions.push(auction === oldAuction ? newAuction : auction);
    }

    // Re-display the details page with the new auction.
    this.setState({auctions, page: {kind: "details", auction: newAuction}});
  };
}

AuctionList.tsx

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


type ListProps = {
  auctions: ReadonlyArray<Auction>,
  onNew: () => void,
  onShow: (auction: Auction) => 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()}
  }

  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[] = [];
    for (const auction of this.props.auctions) {
      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, auction)}>{auction.name}</a>
          {desc}
        </li>);
    }
    return auctions;
  };

  doRefreshClick = (_evt: MouseEvent<HTMLButtonElement>): void => {
    // Update the time at which we are showing 
    this.setState({now: Date.now()});
  };

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

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

NewAuction.tsx

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


type NewAuctionProps = {
  onStart: (auction: Auction) => void,
  onBack: () => 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.doAddClick}>Add</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: ""});
  };

  doAddClick = (_: 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;
    }

    // We will act as if the seller bid just below the min bid. That requires
    // someone else to bid at least the min bid to win the auction.
    const maxBid = minBid - 1;
    const maxBidder = this.state.seller;

    // Record the time since epoch when this ends.
    const endTime = Date.now() + minutes * 60 * 1000;

    // Ask the app to start this auction (adding it to the list).
    this.props.onStart({
        name: this.state.name,
        description: this.state.description,
        seller: this.state.seller,
        endTime, maxBid, maxBidder });
  };
}

AuctionDetails.tsx

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


type DetailsProps = {
  auction: Auction,
  onChange: (oldAuction: Auction, newAuction: Auction) => void,
  onBack: () => 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.
      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: ""});
      }
    }
  };

  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;
    }

    const auction = this.props.auction;
    const newAuction: Auction = {
        name: auction.name,
        description: auction.description,
        seller: auction.seller,
        endTime: auction.endTime,
        maxBid: amount,
        maxBidder: this.state.bidder
      };
    this.props.onChange(auction, newAuction);
  };

  doRefreshClick = (_evt: MouseEvent<HTMLButtonElement>): void => {
    // Update the time at which we are showing 
    this.setState({now: Date.now(), error: ""});
  };

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

Full Code