Opportunities.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527
  1. import React from 'react'
  2. import Moment from 'react-moment';
  3. import NumberFormat from 'react-number-format';
  4. import marked from 'marked';
  5. import CopyToClipboard from 'react-copy-to-clipboard';
  6. import { Link } from 'react-router-dom';
  7. import {
  8. Button,
  9. Card,
  10. Container,
  11. Grid,
  12. Icon,
  13. Label,
  14. List,
  15. Message,
  16. Statistic,
  17. } from 'semantic-ui-react'
  18. import { formatBalance } from '@polkadot/util';
  19. import { Balance } from '@polkadot/types/interfaces';
  20. import { useMyAccount } from '@polkadot/joy-utils/MyAccountContext'
  21. import { Countdown } from '../elements'
  22. import { ApplicationStakeRequirement, RoleStakeRequirement } from '../StakeRequirement'
  23. import { GenericJoyStreamRoleSchema } from '@joystream/types/hiring/schemas/role.schema.typings'
  24. import { Opening } from "@joystream/types/hiring"
  25. import { MemberId } from '@joystream/types/members';
  26. import { OpeningStageClassification, OpeningState } from "../classifiers"
  27. import { OpeningMetadataProps } from "../OpeningMetadata"
  28. import {
  29. openingIcon,
  30. openingClass,
  31. openingDescription,
  32. } from '../openingStateMarkup'
  33. import { Loadable } from '@polkadot/joy-utils/index'
  34. type OpeningStage = OpeningMetadataProps & {
  35. stage: OpeningStageClassification
  36. }
  37. function classificationDescription(state: OpeningState): string {
  38. switch (state) {
  39. case OpeningState.AcceptingApplications:
  40. return "Started"
  41. case OpeningState.InReview:
  42. return "Review started"
  43. case OpeningState.Complete:
  44. case OpeningState.Cancelled:
  45. return "Ended"
  46. }
  47. return "Created"
  48. }
  49. export function OpeningHeader(props: OpeningStage) {
  50. let time = null
  51. if (props.stage.starting_time.getTime() > 0) {
  52. time = <Moment unix format="DD/MM/YYYY, hh:mm A">{props.stage.starting_time.getTime() / 1000}</Moment>
  53. }
  54. return (
  55. <Grid columns="equal">
  56. <Grid.Column className="status">
  57. <Label ribbon size="large">
  58. {openingIcon(props.stage.state)}
  59. {openingDescription(props.stage.state)}
  60. </Label>
  61. </Grid.Column>
  62. <Grid.Column className="meta" textAlign="right">
  63. <Label>
  64. <Icon name="history" /> {classificationDescription(props.stage.state)}&nbsp;
  65. {time}
  66. <Label.Detail>
  67. <Link to={`/explorer/query/${props.stage.starting_block_hash}`}>
  68. <Icon name="cube" />
  69. <NumberFormat value={props.stage.starting_block}
  70. displayType="text"
  71. thousandSeparator={true}
  72. />
  73. </Link>
  74. </Label.Detail>
  75. </Label>
  76. <a>
  77. <CopyToClipboard text={window.location.origin + "/#/working-groups/opportunities/" + props.meta.group + "/" + props.meta.id}>
  78. <Label>
  79. <Icon name="copy" /> Copy link
  80. </Label>
  81. </CopyToClipboard>
  82. </a>
  83. </Grid.Column>
  84. </Grid>
  85. )
  86. }
  87. type DefactoMinimumStake = {
  88. defactoMinimumStake: Balance
  89. }
  90. type MemberIdProps = {
  91. member_id?: MemberId
  92. }
  93. type OpeningBodyCTAProps = OpeningStakeAndApplicationStatus & OpeningStage & OpeningBodyProps & MemberIdProps
  94. function OpeningBodyCTAView(props: OpeningBodyCTAProps) {
  95. if (props.stage.state != OpeningState.AcceptingApplications || applicationImpossible(props.applications)) {
  96. return null
  97. }
  98. let message = (
  99. <Message positive>
  100. <Icon name="check circle" /> No stake required
  101. </Message>
  102. )
  103. if (hasAnyStake(props)) {
  104. const balance = !props.defactoMinimumStake.isZero() ? props.defactoMinimumStake : props.requiredApplicationStake.hard.add(props.requiredRoleStake.hard)
  105. const plural = (props.requiredApplicationStake.anyRequirement() && props.requiredRoleStake.anyRequirement()) ? "s totalling" : " of"
  106. message = (
  107. <Message warning icon>
  108. <Icon name="warning sign" />
  109. <Message.Content>
  110. Stake{plural} at least <strong>{formatBalance(balance)}</strong> required!
  111. </Message.Content>
  112. </Message>
  113. )
  114. }
  115. let applyButton = (
  116. <Link to={"/working-groups/opportunities/" + props.meta.group + "/" + props.meta.id + "/apply"}>
  117. <Button icon fluid positive size="huge">
  118. APPLY NOW
  119. <Icon name="angle right" />
  120. </Button>
  121. </Link>
  122. )
  123. const accountCtx = useMyAccount()
  124. if (!accountCtx.state.address) {
  125. applyButton = (
  126. <Message error icon>
  127. <Icon name="info circle" />
  128. <Message.Content>
  129. You will need an account to apply for this role. You can generate one in the <Link to="/accounts">Accounts</Link> section.
  130. </Message.Content>
  131. </Message>
  132. )
  133. message = <p></p>
  134. } else if (!props.member_id) {
  135. applyButton = (
  136. <Message error icon>
  137. <Icon name="info circle" />
  138. <Message.Content>
  139. You will need a membership to apply for this role. You can sign up in the <Link to="/members">Membership</Link> section.
  140. </Message.Content>
  141. </Message>
  142. )
  143. message = <p></p>
  144. }
  145. return (
  146. <Container>
  147. {applyButton}
  148. {message}
  149. </Container>
  150. )
  151. }
  152. export type StakeRequirementProps = DefactoMinimumStake & {
  153. requiredApplicationStake: ApplicationStakeRequirement
  154. requiredRoleStake: RoleStakeRequirement
  155. maxNumberOfApplications: number
  156. }
  157. function hasAnyStake(props: StakeRequirementProps): boolean {
  158. return props.requiredApplicationStake.anyRequirement() || props.requiredRoleStake.anyRequirement()
  159. }
  160. class messageState {
  161. positive: boolean = true
  162. warning: boolean = false
  163. negative: boolean = false
  164. setPositive(): void {
  165. this.positive = true
  166. this.warning = false
  167. this.negative = false
  168. }
  169. setWarning(): void {
  170. this.positive = false
  171. this.warning = true
  172. this.negative = false
  173. }
  174. setNegative(): void {
  175. this.positive = false
  176. this.warning = false
  177. this.negative = true
  178. }
  179. }
  180. export function OpeningBodyStakeRequirement(props: StakeRequirementProps) {
  181. if (!hasAnyStake(props)) {
  182. return null
  183. }
  184. const plural = (props.requiredApplicationStake.anyRequirement() && props.requiredRoleStake.anyRequirement()) ? "s" : null
  185. let title = <Message.Header color="orange" as='h5'><Icon name="shield" /> Stake{plural} required!</Message.Header>
  186. let explanation = null
  187. if (!props.defactoMinimumStake.isZero()) {
  188. title = <Message.Header color="orange" as='h5'><Icon name="shield" /> Increased stake{plural} required!</Message.Header>
  189. explanation = (
  190. <p>
  191. 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>.
  192. </p>
  193. )
  194. }
  195. return (
  196. <Message className="stake-requirements" warning>
  197. {title}
  198. {props.requiredApplicationStake.describe()}
  199. {props.requiredRoleStake.describe()}
  200. {explanation}
  201. </Message>
  202. )
  203. }
  204. export type ApplicationCountProps = {
  205. numberOfApplications: number
  206. maxNumberOfApplications: number
  207. applied?: boolean
  208. }
  209. export type OpeningStakeAndApplicationStatus = StakeRequirementProps & ApplicationCountProps
  210. function applicationImpossible(props: OpeningStakeAndApplicationStatus): boolean {
  211. return props.maxNumberOfApplications > 0 &&
  212. (props.numberOfApplications >= props.maxNumberOfApplications) &&
  213. !hasAnyStake(props)
  214. }
  215. function applicationPossibleWithIncresedStake(props: OpeningStakeAndApplicationStatus): boolean {
  216. return props.maxNumberOfApplications > 0 &&
  217. (props.numberOfApplications >= props.maxNumberOfApplications) &&
  218. hasAnyStake(props)
  219. }
  220. export function ApplicationCount(props: ApplicationCountProps) {
  221. let max_applications = null
  222. if (props.maxNumberOfApplications > 0) {
  223. max_applications = (
  224. <span>
  225. /
  226. <NumberFormat value={props.maxNumberOfApplications}
  227. displayType="text"
  228. thousandSeparator={true}
  229. />
  230. </span>
  231. )
  232. }
  233. return (
  234. <span>
  235. <NumberFormat value={props.numberOfApplications + (props.applied ? 1 : 0)}
  236. displayType="text"
  237. thousandSeparator={true}
  238. />
  239. {max_applications}
  240. </span>
  241. )
  242. }
  243. export function OpeningBodyApplicationsStatus(props: OpeningStakeAndApplicationStatus) {
  244. const impossible = applicationImpossible(props)
  245. const msg = new messageState()
  246. if (impossible) {
  247. msg.setNegative()
  248. } else if (applicationPossibleWithIncresedStake(props)) {
  249. msg.setWarning()
  250. }
  251. let order = null
  252. if (hasAnyStake(props)) {
  253. order = ", ordered by total stake,"
  254. }
  255. let max_applications = null
  256. let message = <p>All applications{order} will be considered during the review period.</p>
  257. if (props.maxNumberOfApplications > 0) {
  258. max_applications = (
  259. <span>
  260. /
  261. <NumberFormat value={props.maxNumberOfApplications}
  262. displayType="text"
  263. thousandSeparator={true}
  264. />
  265. </span>
  266. )
  267. let disclaimer = null
  268. if (impossible) {
  269. disclaimer = "No futher applications will be considered."
  270. }
  271. message = (
  272. <p>Only the top {props.maxNumberOfApplications} applications{order} will be considered for this role. {disclaimer}</p>
  273. )
  274. }
  275. return (
  276. <Message positive={msg.positive} warning={msg.warning} negative={msg.negative}>
  277. <Statistic size="small">
  278. <Statistic.Value>
  279. <NumberFormat value={props.numberOfApplications + (props.applied ? 1 : 0)}
  280. displayType="text"
  281. thousandSeparator={true}
  282. />
  283. {max_applications}
  284. </Statistic.Value>
  285. <Statistic.Label>Applications</Statistic.Label>
  286. </Statistic>
  287. {message}
  288. </Message>
  289. )
  290. }
  291. export function OpeningBodyReviewInProgress(props: OpeningStageClassification) {
  292. let countdown = null
  293. let expected = null
  294. if (props.review_end_time && props.starting_time.getTime() > 0) {
  295. countdown = <Countdown end={props.review_end_time} />
  296. expected = (
  297. <span>
  298. &nbsp;(expected on&nbsp;
  299. <strong>
  300. <Moment format="MMM DD, YYYY HH:mm:ss" date={props.review_end_time} interval={0} />
  301. </strong>
  302. )
  303. </span>
  304. )
  305. }
  306. return (
  307. <Message info className="countdown">
  308. <h4>Review process has begun</h4>
  309. {countdown}
  310. <p>
  311. <span>Candidates will be selected by block&nbsp;
  312. <strong>
  313. <NumberFormat value={props.review_end_block}
  314. displayType="text"
  315. thousandSeparator={true}
  316. />
  317. </strong>
  318. </span>
  319. {expected}
  320. <span> at the latest.</span>
  321. </p>
  322. </Message>
  323. )
  324. }
  325. type BlockTimeProps = {
  326. block_time_in_seconds: number
  327. }
  328. function timeInHumanFormat(block_time_in_seconds: number, blocks: number) {
  329. const d1 = new Date()
  330. const d2 = new Date(d1.getTime())
  331. d2.setSeconds(d2.getSeconds() + (block_time_in_seconds * blocks))
  332. return <Moment duration={d1} date={d2} interval={0} />
  333. }
  334. export type OpeningBodyProps = DefactoMinimumStake & StakeRequirementProps & BlockTimeProps & OpeningMetadataProps & MemberIdProps & {
  335. opening: Opening
  336. text: GenericJoyStreamRoleSchema
  337. stage: OpeningStageClassification
  338. applications: OpeningStakeAndApplicationStatus
  339. }
  340. export function OpeningBody(props: OpeningBodyProps) {
  341. const jobDesc = marked(props.text.job.description || '')
  342. const blockNumber = <NumberFormat value={props.opening.max_review_period_length.toNumber()}
  343. displayType="text"
  344. thousandSeparator={true} />
  345. let stakeRequirements = null
  346. switch (props.stage.state) {
  347. case OpeningState.WaitingToBegin:
  348. case OpeningState.AcceptingApplications:
  349. stakeRequirements = <OpeningBodyStakeRequirement {...props.applications} defactoMinimumStake={props.defactoMinimumStake} />
  350. break
  351. case OpeningState.InReview:
  352. stakeRequirements = <OpeningBodyReviewInProgress {...props.stage} />
  353. break
  354. }
  355. return (
  356. <Grid columns="equal">
  357. <Grid.Column width={10} className="summary">
  358. <Card.Header>
  359. <OpeningReward text={props.text.reward} />
  360. </Card.Header>
  361. <h4 className="headline">{props.text.headline}</h4>
  362. <h5>Role description</h5>
  363. <div dangerouslySetInnerHTML={{ __html: jobDesc }} />
  364. <h5>Hiring process details</h5>
  365. <List>
  366. <List.Item>
  367. <List.Icon name="clock" />
  368. <List.Content>
  369. 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>).
  370. </List.Content>
  371. </List.Item>
  372. {props.text.process && props.text.process.details.map((detail, key) => (
  373. <List.Item key={key}>
  374. <List.Icon name="info circle" />
  375. <List.Content>{detail}</List.Content>
  376. </List.Item>
  377. ))}
  378. </List>
  379. </Grid.Column>
  380. <Grid.Column width={6} className="details">
  381. <OpeningBodyApplicationsStatus {...props.applications} />
  382. {stakeRequirements}
  383. <OpeningBodyCTAView {...props} {...props.applications} />
  384. </Grid.Column>
  385. </Grid>
  386. )
  387. }
  388. type OpeningRewardProps = {
  389. text: string
  390. }
  391. function OpeningReward(props: OpeningRewardProps) {
  392. return (
  393. <Statistic size="small">
  394. <Statistic.Label>Reward</Statistic.Label>
  395. <Statistic.Value>{props.text}</Statistic.Value>
  396. </Statistic>
  397. )
  398. }
  399. export type WorkingGroupOpening = OpeningStage & DefactoMinimumStake & OpeningMetadataProps & {
  400. opening: Opening
  401. applications: OpeningStakeAndApplicationStatus
  402. }
  403. type OpeningViewProps = WorkingGroupOpening & BlockTimeProps & MemberIdProps
  404. export const OpeningView = Loadable<OpeningViewProps>(
  405. ['opening', 'block_time_in_seconds'],
  406. props => {
  407. const hrt = props.opening.parse_human_readable_text()
  408. if (typeof hrt === "undefined" || typeof hrt === "string") {
  409. return null
  410. }
  411. const text = hrt as GenericJoyStreamRoleSchema
  412. return (
  413. <Container className={"opening " + openingClass(props.stage.state)}>
  414. <h2>{text.job.title}</h2>
  415. <Card fluid className="container">
  416. <Card.Content className="header">
  417. <OpeningHeader stage={props.stage} meta={props.meta} />
  418. </Card.Content>
  419. <Card.Content className="main">
  420. <OpeningBody
  421. {...props.applications}
  422. text={text}
  423. meta={props.meta}
  424. opening={props.opening}
  425. stage={props.stage}
  426. applications={props.applications}
  427. defactoMinimumStake={props.defactoMinimumStake}
  428. block_time_in_seconds={props.block_time_in_seconds}
  429. member_id={props.member_id}
  430. />
  431. </Card.Content>
  432. </Card>
  433. </Container>
  434. )
  435. }
  436. )
  437. export type OpeningsViewProps = MemberIdProps & {
  438. openings?: Array<WorkingGroupOpening>
  439. block_time_in_seconds?: number
  440. }
  441. export const OpeningsView = Loadable<OpeningsViewProps>(
  442. ['openings', 'block_time_in_seconds'],
  443. props => {
  444. return (
  445. <Container>
  446. {props.openings && props.openings.map((opening, key) => (
  447. <OpeningView key={key} {...opening} block_time_in_seconds={props.block_time_in_seconds as number} member_id={props.member_id} />
  448. ))}
  449. </Container>
  450. )
  451. }
  452. )
  453. export const OpeningError = () => {
  454. return (
  455. <Container>
  456. <Message error>
  457. Uh oh! Something went wrong
  458. </Message>
  459. </Container>
  460. )
  461. }