123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527 |
- import React from 'react'
- import Moment from 'react-moment';
- import NumberFormat from 'react-number-format';
- import marked from 'marked';
- import CopyToClipboard from 'react-copy-to-clipboard';
- import { Link } from 'react-router-dom';
- import {
- Button,
- Card,
- Container,
- Grid,
- Icon,
- Label,
- List,
- Message,
- Statistic,
- } from 'semantic-ui-react'
- import { formatBalance } from '@polkadot/util';
- import { Balance } from '@polkadot/types/interfaces';
- import { useMyAccount } from '@polkadot/joy-utils/MyAccountContext'
- import { Countdown } from '../elements'
- import { ApplicationStakeRequirement, RoleStakeRequirement } from '../StakeRequirement'
- import { GenericJoyStreamRoleSchema } from '@joystream/types/hiring/schemas/role.schema.typings'
- import { Opening } from "@joystream/types/hiring"
- import { MemberId } from '@joystream/types/members';
- import { OpeningStageClassification, OpeningState } from "../classifiers"
- import { OpeningMetadataProps } from "../OpeningMetadata"
- import {
- openingIcon,
- openingClass,
- openingDescription,
- } from '../openingStateMarkup'
- import { Loadable } from '@polkadot/joy-utils/index'
- type OpeningStage = OpeningMetadataProps & {
- stage: OpeningStageClassification
- }
- function classificationDescription(state: OpeningState): string {
- switch (state) {
- case OpeningState.AcceptingApplications:
- return "Started"
- case OpeningState.InReview:
- return "Review started"
- case OpeningState.Complete:
- case OpeningState.Cancelled:
- return "Ended"
- }
- return "Created"
- }
- export function OpeningHeader(props: OpeningStage) {
- let time = null
- if (props.stage.starting_time.getTime() > 0) {
- time = <Moment unix format="DD/MM/YYYY, hh:mm A">{props.stage.starting_time.getTime() / 1000}</Moment>
- }
- return (
- <Grid columns="equal">
- <Grid.Column className="status">
- <Label ribbon size="large">
- {openingIcon(props.stage.state)}
- {openingDescription(props.stage.state)}
- </Label>
- </Grid.Column>
- <Grid.Column className="meta" textAlign="right">
- <Label>
- <Icon name="history" /> {classificationDescription(props.stage.state)}
- {time}
- <Label.Detail>
- <Link to={`/explorer/query/${props.stage.starting_block_hash}`}>
- <Icon name="cube" />
- <NumberFormat value={props.stage.starting_block}
- displayType="text"
- thousandSeparator={true}
- />
- </Link>
- </Label.Detail>
- </Label>
- <a>
- <CopyToClipboard text={window.location.origin + "/#/working-groups/opportunities/" + props.meta.group + "/" + props.meta.id}>
- <Label>
- <Icon name="copy" /> Copy link
- </Label>
- </CopyToClipboard>
- </a>
- </Grid.Column>
- </Grid>
- )
- }
- type DefactoMinimumStake = {
- defactoMinimumStake: Balance
- }
- type MemberIdProps = {
- member_id?: MemberId
- }
- type OpeningBodyCTAProps = OpeningStakeAndApplicationStatus & OpeningStage & OpeningBodyProps & MemberIdProps
- function OpeningBodyCTAView(props: OpeningBodyCTAProps) {
- if (props.stage.state != OpeningState.AcceptingApplications || applicationImpossible(props.applications)) {
- return null
- }
- let message = (
- <Message positive>
- <Icon name="check circle" /> No stake required
- </Message>
- )
- if (hasAnyStake(props)) {
- const balance = !props.defactoMinimumStake.isZero() ? props.defactoMinimumStake : props.requiredApplicationStake.hard.add(props.requiredRoleStake.hard)
- const plural = (props.requiredApplicationStake.anyRequirement() && props.requiredRoleStake.anyRequirement()) ? "s totalling" : " of"
- message = (
- <Message warning icon>
- <Icon name="warning sign" />
- <Message.Content>
- Stake{plural} at least <strong>{formatBalance(balance)}</strong> required!
- </Message.Content>
- </Message>
- )
- }
- let applyButton = (
- <Link to={"/working-groups/opportunities/" + props.meta.group + "/" + props.meta.id + "/apply"}>
- <Button icon fluid positive size="huge">
- APPLY NOW
- <Icon name="angle right" />
- </Button>
- </Link>
- )
- const accountCtx = useMyAccount()
- if (!accountCtx.state.address) {
- applyButton = (
- <Message error icon>
- <Icon name="info circle" />
- <Message.Content>
- You will need an account to apply for this role. You can generate one in the <Link to="/accounts">Accounts</Link> section.
- </Message.Content>
- </Message>
- )
- message = <p></p>
- } else if (!props.member_id) {
- applyButton = (
- <Message error icon>
- <Icon name="info circle" />
- <Message.Content>
- You will need a membership to apply for this role. You can sign up in the <Link to="/members">Membership</Link> section.
- </Message.Content>
- </Message>
- )
- message = <p></p>
- }
- return (
- <Container>
- {applyButton}
- {message}
- </Container>
- )
- }
- export type StakeRequirementProps = DefactoMinimumStake & {
- requiredApplicationStake: ApplicationStakeRequirement
- requiredRoleStake: RoleStakeRequirement
- maxNumberOfApplications: number
- }
- function hasAnyStake(props: StakeRequirementProps): boolean {
- return props.requiredApplicationStake.anyRequirement() || props.requiredRoleStake.anyRequirement()
- }
- class messageState {
- positive: boolean = true
- warning: boolean = false
- negative: boolean = false
- setPositive(): void {
- this.positive = true
- this.warning = false
- this.negative = false
- }
- setWarning(): void {
- this.positive = false
- this.warning = true
- this.negative = false
- }
- setNegative(): void {
- this.positive = false
- this.warning = false
- this.negative = true
- }
- }
- export function OpeningBodyStakeRequirement(props: StakeRequirementProps) {
- if (!hasAnyStake(props)) {
- return null
- }
- const plural = (props.requiredApplicationStake.anyRequirement() && props.requiredRoleStake.anyRequirement()) ? "s" : null
- let title = <Message.Header color="orange" as='h5'><Icon name="shield" /> Stake{plural} required!</Message.Header>
- let explanation = null
- if (!props.defactoMinimumStake.isZero()) {
- title = <Message.Header color="orange" as='h5'><Icon name="shield" /> Increased stake{plural} required!</Message.Header>
- explanation = (
- <p>
- However, in order to be in the top {props.maxNumberOfApplications} candidates, you wil need to stake at least <strong>{formatBalance(props.defactoMinimumStake)} in total</strong>.
- </p>
- )
- }
- return (
- <Message className="stake-requirements" warning>
- {title}
- {props.requiredApplicationStake.describe()}
- {props.requiredRoleStake.describe()}
- {explanation}
- </Message>
- )
- }
- export type ApplicationCountProps = {
- numberOfApplications: number
- maxNumberOfApplications: number
- applied?: boolean
- }
- export type OpeningStakeAndApplicationStatus = StakeRequirementProps & ApplicationCountProps
- function applicationImpossible(props: OpeningStakeAndApplicationStatus): boolean {
- return props.maxNumberOfApplications > 0 &&
- (props.numberOfApplications >= props.maxNumberOfApplications) &&
- !hasAnyStake(props)
- }
- function applicationPossibleWithIncresedStake(props: OpeningStakeAndApplicationStatus): boolean {
- return props.maxNumberOfApplications > 0 &&
- (props.numberOfApplications >= props.maxNumberOfApplications) &&
- hasAnyStake(props)
- }
- export function ApplicationCount(props: ApplicationCountProps) {
- let max_applications = null
- if (props.maxNumberOfApplications > 0) {
- max_applications = (
- <span>
- /
- <NumberFormat value={props.maxNumberOfApplications}
- displayType="text"
- thousandSeparator={true}
- />
- </span>
- )
- }
- return (
- <span>
- <NumberFormat value={props.numberOfApplications + (props.applied ? 1 : 0)}
- displayType="text"
- thousandSeparator={true}
- />
- {max_applications}
- </span>
- )
- }
- export function OpeningBodyApplicationsStatus(props: OpeningStakeAndApplicationStatus) {
- const impossible = applicationImpossible(props)
- const msg = new messageState()
- if (impossible) {
- msg.setNegative()
- } else if (applicationPossibleWithIncresedStake(props)) {
- msg.setWarning()
- }
- let order = null
- if (hasAnyStake(props)) {
- order = ", ordered by total stake,"
- }
- let max_applications = null
- let message = <p>All applications{order} will be considered during the review period.</p>
- if (props.maxNumberOfApplications > 0) {
- max_applications = (
- <span>
- /
- <NumberFormat value={props.maxNumberOfApplications}
- displayType="text"
- thousandSeparator={true}
- />
- </span>
- )
- let disclaimer = null
- if (impossible) {
- disclaimer = "No futher applications will be considered."
- }
- message = (
- <p>Only the top {props.maxNumberOfApplications} applications{order} will be considered for this role. {disclaimer}</p>
- )
- }
- return (
- <Message positive={msg.positive} warning={msg.warning} negative={msg.negative}>
- <Statistic size="small">
- <Statistic.Value>
- <NumberFormat value={props.numberOfApplications + (props.applied ? 1 : 0)}
- displayType="text"
- thousandSeparator={true}
- />
- {max_applications}
- </Statistic.Value>
- <Statistic.Label>Applications</Statistic.Label>
- </Statistic>
- {message}
- </Message>
- )
- }
- export function OpeningBodyReviewInProgress(props: OpeningStageClassification) {
- let countdown = null
- let expected = null
- if (props.review_end_time && props.starting_time.getTime() > 0) {
- countdown = <Countdown end={props.review_end_time} />
- expected = (
- <span>
- (expected on
- <strong>
- <Moment format="MMM DD, YYYY HH:mm:ss" date={props.review_end_time} interval={0} />
- </strong>
- )
- </span>
- )
- }
- return (
- <Message info className="countdown">
- <h4>Review process has begun</h4>
- {countdown}
- <p>
- <span>Candidates will be selected by block
- <strong>
- <NumberFormat value={props.review_end_block}
- displayType="text"
- thousandSeparator={true}
- />
- </strong>
- </span>
- {expected}
- <span> at the latest.</span>
- </p>
- </Message>
- )
- }
- type BlockTimeProps = {
- block_time_in_seconds: number
- }
- function timeInHumanFormat(block_time_in_seconds: number, blocks: number) {
- const d1 = new Date()
- const d2 = new Date(d1.getTime())
- d2.setSeconds(d2.getSeconds() + (block_time_in_seconds * blocks))
- return <Moment duration={d1} date={d2} interval={0} />
- }
- export type OpeningBodyProps = DefactoMinimumStake & StakeRequirementProps & BlockTimeProps & OpeningMetadataProps & MemberIdProps & {
- opening: Opening
- text: GenericJoyStreamRoleSchema
- stage: OpeningStageClassification
- applications: OpeningStakeAndApplicationStatus
- }
- export function OpeningBody(props: OpeningBodyProps) {
- const jobDesc = marked(props.text.job.description || '')
- const blockNumber = <NumberFormat value={props.opening.max_review_period_length.toNumber()}
- displayType="text"
- thousandSeparator={true} />
- let stakeRequirements = null
- switch (props.stage.state) {
- case OpeningState.WaitingToBegin:
- case OpeningState.AcceptingApplications:
- stakeRequirements = <OpeningBodyStakeRequirement {...props.applications} defactoMinimumStake={props.defactoMinimumStake} />
- break
- case OpeningState.InReview:
- stakeRequirements = <OpeningBodyReviewInProgress {...props.stage} />
- break
- }
- return (
- <Grid columns="equal">
- <Grid.Column width={10} className="summary">
- <Card.Header>
- <OpeningReward text={props.text.reward} />
- </Card.Header>
- <h4 className="headline">{props.text.headline}</h4>
- <h5>Role description</h5>
- <div dangerouslySetInnerHTML={{ __html: jobDesc }} />
- <h5>Hiring process details</h5>
- <List>
- <List.Item>
- <List.Icon name="clock" />
- <List.Content>
- The maximum review period for this opening is <strong>{blockNumber} blocks</strong> (approximately <strong>{timeInHumanFormat(props.block_time_in_seconds, props.opening.max_review_period_length.toNumber())}</strong>).
- </List.Content>
- </List.Item>
- {props.text.process && props.text.process.details.map((detail, key) => (
- <List.Item key={key}>
- <List.Icon name="info circle" />
- <List.Content>{detail}</List.Content>
- </List.Item>
- ))}
- </List>
- </Grid.Column>
- <Grid.Column width={6} className="details">
- <OpeningBodyApplicationsStatus {...props.applications} />
- {stakeRequirements}
- <OpeningBodyCTAView {...props} {...props.applications} />
- </Grid.Column>
- </Grid>
- )
- }
- type OpeningRewardProps = {
- text: string
- }
- function OpeningReward(props: OpeningRewardProps) {
- return (
- <Statistic size="small">
- <Statistic.Label>Reward</Statistic.Label>
- <Statistic.Value>{props.text}</Statistic.Value>
- </Statistic>
- )
- }
- export type WorkingGroupOpening = OpeningStage & DefactoMinimumStake & OpeningMetadataProps & {
- opening: Opening
- applications: OpeningStakeAndApplicationStatus
- }
- type OpeningViewProps = WorkingGroupOpening & BlockTimeProps & MemberIdProps
- export const OpeningView = Loadable<OpeningViewProps>(
- ['opening', 'block_time_in_seconds'],
- props => {
- const hrt = props.opening.parse_human_readable_text()
- if (typeof hrt === "undefined" || typeof hrt === "string") {
- return null
- }
- const text = hrt as GenericJoyStreamRoleSchema
- return (
- <Container className={"opening " + openingClass(props.stage.state)}>
- <h2>{text.job.title}</h2>
- <Card fluid className="container">
- <Card.Content className="header">
- <OpeningHeader stage={props.stage} meta={props.meta} />
- </Card.Content>
- <Card.Content className="main">
- <OpeningBody
- {...props.applications}
- text={text}
- meta={props.meta}
- opening={props.opening}
- stage={props.stage}
- applications={props.applications}
- defactoMinimumStake={props.defactoMinimumStake}
- block_time_in_seconds={props.block_time_in_seconds}
- member_id={props.member_id}
- />
- </Card.Content>
- </Card>
- </Container>
- )
- }
- )
- export type OpeningsViewProps = MemberIdProps & {
- openings?: Array<WorkingGroupOpening>
- block_time_in_seconds?: number
- }
- export const OpeningsView = Loadable<OpeningsViewProps>(
- ['openings', 'block_time_in_seconds'],
- props => {
- return (
- <Container>
- {props.openings && props.openings.map((opening, key) => (
- <OpeningView key={key} {...opening} block_time_in_seconds={props.block_time_in_seconds as number} member_id={props.member_id} />
- ))}
- </Container>
- )
- }
- )
- export const OpeningError = () => {
- return (
- <Container>
- <Message error>
- Uh oh! Something went wrong
- </Message>
- </Container>
- )
- }
|