Opportunities.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526
  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. export type StakeRequirementProps = DefactoMinimumStake & {
  94. requiredApplicationStake: ApplicationStakeRequirement;
  95. requiredRoleStake: RoleStakeRequirement;
  96. maxNumberOfApplications: number;
  97. }
  98. function hasAnyStake (props: StakeRequirementProps): boolean {
  99. return props.requiredApplicationStake.anyRequirement() || props.requiredRoleStake.anyRequirement();
  100. }
  101. class MessageState {
  102. positive = true
  103. warning = false
  104. negative = false
  105. setPositive (): void {
  106. this.positive = true;
  107. this.warning = false;
  108. this.negative = false;
  109. }
  110. setWarning (): void {
  111. this.positive = false;
  112. this.warning = true;
  113. this.negative = false;
  114. }
  115. setNegative (): void {
  116. this.positive = false;
  117. this.warning = false;
  118. this.negative = true;
  119. }
  120. }
  121. export function OpeningBodyStakeRequirement (props: StakeRequirementProps) {
  122. if (!hasAnyStake(props)) {
  123. return null;
  124. }
  125. const plural = (props.requiredApplicationStake.anyRequirement() && props.requiredRoleStake.anyRequirement()) ? 's' : null;
  126. let title = <Message.Header color="orange" as='h5'><Icon name="shield" /> Stake{plural} required!</Message.Header>;
  127. let explanation = null;
  128. if (!props.defactoMinimumStake.isZero()) {
  129. title = <Message.Header color="orange" as='h5'><Icon name="shield" /> Increased stake{plural} required!</Message.Header>;
  130. explanation = (
  131. <p>
  132. 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>.
  133. </p>
  134. );
  135. }
  136. return (
  137. <Message className="stake-requirements" warning>
  138. {title}
  139. {props.requiredApplicationStake.describe()}
  140. {props.requiredRoleStake.describe()}
  141. {explanation}
  142. </Message>
  143. );
  144. }
  145. export type ApplicationCountProps = {
  146. numberOfApplications: number;
  147. maxNumberOfApplications: number;
  148. applied?: boolean;
  149. }
  150. export type OpeningStakeAndApplicationStatus = StakeRequirementProps & ApplicationCountProps
  151. function applicationImpossible (props: OpeningStakeAndApplicationStatus): boolean {
  152. return props.maxNumberOfApplications > 0 &&
  153. (props.numberOfApplications >= props.maxNumberOfApplications) &&
  154. !hasAnyStake(props);
  155. }
  156. function applicationPossibleWithIncresedStake (props: OpeningStakeAndApplicationStatus): boolean {
  157. return props.maxNumberOfApplications > 0 &&
  158. (props.numberOfApplications >= props.maxNumberOfApplications) &&
  159. hasAnyStake(props);
  160. }
  161. export function ApplicationCount (props: ApplicationCountProps) {
  162. let max_applications = null;
  163. if (props.maxNumberOfApplications > 0) {
  164. max_applications = (
  165. <span>
  166. /
  167. <NumberFormat value={props.maxNumberOfApplications}
  168. displayType="text"
  169. thousandSeparator={true}
  170. />
  171. </span>
  172. );
  173. }
  174. return (
  175. <span>
  176. <NumberFormat value={props.numberOfApplications + (props.applied ? 1 : 0)}
  177. displayType="text"
  178. thousandSeparator={true}
  179. />
  180. {max_applications}
  181. </span>
  182. );
  183. }
  184. type OpeningBodyCTAProps = OpeningStakeAndApplicationStatus & OpeningStage & OpeningBodyProps & MemberIdProps
  185. function OpeningBodyCTAView (props: OpeningBodyCTAProps) {
  186. if (props.stage.state !== OpeningState.AcceptingApplications || applicationImpossible(props.applications)) {
  187. return null;
  188. }
  189. let message = (
  190. <Message positive>
  191. <Icon name="check circle" /> No stake required
  192. </Message>
  193. );
  194. if (hasAnyStake(props)) {
  195. const balance = !props.defactoMinimumStake.isZero() ? props.defactoMinimumStake : props.requiredApplicationStake.hard.add(props.requiredRoleStake.hard);
  196. const plural = (props.requiredApplicationStake.anyRequirement() && props.requiredRoleStake.anyRequirement()) ? 's totalling' : ' of';
  197. message = (
  198. <Message warning icon>
  199. <Icon name="warning sign" />
  200. <Message.Content>
  201. Stake{plural} at least <strong>{formatBalance(balance)}</strong> required!
  202. </Message.Content>
  203. </Message>
  204. );
  205. }
  206. let applyButton = (
  207. <Link to={'/working-groups/opportunities/' + props.meta.group + '/' + props.meta.id + '/apply'}>
  208. <Button icon fluid positive size="huge">
  209. APPLY NOW
  210. <Icon name="angle right" />
  211. </Button>
  212. </Link>
  213. );
  214. const accountCtx = useMyAccount();
  215. if (!accountCtx.state.address) {
  216. applyButton = (
  217. <Message error icon>
  218. <Icon name="info circle" />
  219. <Message.Content>
  220. You will need an account to apply for this role. You can generate one in the <Link to="/accounts">Accounts</Link> section.
  221. </Message.Content>
  222. </Message>
  223. );
  224. message = <p></p>;
  225. } else if (!props.member_id) {
  226. applyButton = (
  227. <Message error icon>
  228. <Icon name="info circle" />
  229. <Message.Content>
  230. You will need a membership to apply for this role. You can sign up in the <Link to="/members">Membership</Link> section.
  231. </Message.Content>
  232. </Message>
  233. );
  234. message = <p></p>;
  235. }
  236. return (
  237. <Container>
  238. {applyButton}
  239. {message}
  240. </Container>
  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;
  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. };