feat(corp/rih): implement initial frontend application

This doesn't actually submit anything to the (not-yet-existing)
backend, but will help the designers figure out what we're actually
looking for here.

Change-Id: I680d88151fb0706953f18eb6256da6f205da7ffb
Reviewed-on: https://cl.tvl.fyi/c/depot/+/8489
Reviewed-by: tazjin <tazjin@tvl.su>
Tested-by: BuildkiteCI
This commit is contained in:
Vincent Ambo 2023-04-20 13:40:40 +03:00 committed by tazjin
parent 3b33c19a9c
commit 99c7896637
10 changed files with 2057 additions and 0 deletions

3
corp/rih/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
result
target
dist

1337
corp/rih/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

36
corp/rih/Cargo.toml Normal file
View file

@ -0,0 +1,36 @@
[package]
version = "0.1.0"
name = "rih"
authors = [ "Vincent Ambo <tazjin@tvl.su>" ]
license = "Proprietary"
edition = "2021"
[dependencies]
fuzzy-matcher = "0.3.7"
gloo = "0.8"
rust_iso3166 = "0.1.10"
serde_json = "1.0"
serde_urlencoded = "*" # pinned by yew
yew = { version = "0.20", features = ["csr"] }
yew-router = "0.17"
rand = "0.8"
getrandom = { version = "0.2", features = ["js"] }
# needs to be in sync with nixpkgs
wasm-bindgen = "= 0.2.84"
[dependencies.serde]
version = "*" # pinned by yew
features = [ "derive" ]
[dependencies.web-sys]
version = "*" # pinned by yew
features = [ "HtmlDetailsElement" ]
[profile.release]
lto = true
opt-level = 'z'
codegen-units = 1
[package.metadata.wasm-pack.profile.release]
wasm-opt = ['-Os']

3
corp/rih/README.md Normal file
View file

@ -0,0 +1,3 @@
Implementation of russiaishiring.com.
This is a corporate TVL project, see `//corp/LICENSE`.

52
corp/rih/default.nix Normal file
View file

@ -0,0 +1,52 @@
{ lib, pkgs, ... }:
let
wasmRust = pkgs.rust-bin.stable.latest.default.override {
targets = [ "wasm32-unknown-unknown" ];
};
cargoToml = with builtins; fromTOML (readFile ./Cargo.toml);
wasmBindgenMatch =
cargoToml.dependencies.wasm-bindgen == "= ${pkgs.wasm-bindgen-cli.version}";
assertWasmBindgen = assert (lib.assertMsg wasmBindgenMatch ''
Due to instability in the Rust WASM ecosystem, the trunk build
tool enforces that the Cargo-dependency version of `wasm-bindgen`
MUST match the version of the CLI supplied in the environment.
This can get out of sync when nixpkgs is updated. To resolve it,
wasm-bindgen must be bumped in the Cargo.toml file and cargo needs
to be run to resolve the dependencies.
Versions of `wasm-bindgen` in Cargo.toml:
Expected: '= ${pkgs.wasm-bindgen-cli.version}'
Actual: '${cargoToml.dependencies.wasm-bindgen}'
''); pkgs.wasm-bindgen-cli;
deps = with pkgs; [
binaryen
sass
wasmRust
trunk
assertWasmBindgen
];
in
pkgs.rustPlatform.buildRustPackage rec {
pname = "rih-frontend";
version = "canon";
src = lib.cleanSource ./.;
cargoLock.lockFile = ./Cargo.lock;
buildPhase = ''
export PATH=${lib.makeBinPath deps}:$PATH
mkdir home
export HOME=$PWD/.home
env
trunk build --release -d $out
'';
dontInstall = true;
}

62
corp/rih/index.css Normal file
View file

@ -0,0 +1,62 @@
.b-section-divider {
width: 100%;
height: 3rem;
background-color: rgba(0, 0, 0, .1);
border: solid rgba(0, 0, 0, .15);
border-width: 1px 0;
box-shadow: inset 0 .5em 1.5em rgba(0, 0, 0, .1), inset 0 .125em .5em rgba(0, 0, 0, .15);
}
/* .btn-light, */
/* .btn-light:hover, */
/* .btn-light:focus { */
/* color: #333; */
/* text-shadow: none; /\* Prevent inheritance from `body` *\/ */
/* } */
/* body { */
/* text-shadow: 0 .05rem .1rem rgba(0, 0, 0, .5); */
/* box-shadow: inset 0 0 5rem rgba(0, 0, 0, .5); */
/* } */
/* .cover-container { */
/* max-width: 42em; */
/* } */
/* .nav-masthead .nav-link { */
/* color: rgba(255, 255, 255, .5); */
/* border-bottom: .25rem solid transparent; */
/* } */
/* .nav-masthead .nav-link:hover, */
/* .nav-masthead .nav-link:focus { */
/* border-bottom-color: rgba(255, 255, 255, .25); */
/* } */
/* .nav-masthead .nav-link + .nav-link { */
/* margin-left: 1rem; */
/* } */
/* .nav-masthead .active { */
/* color: #fff; */
/* border-bottom-color: #fff; */
/* } */
/* body { */
/* font-family: Arial, sans-serif; */
/* line-height: 1.6; */
/* margin: 20px; */
/* color: #333; */
/* } */
/* header { */
/* text-align: center; */
/* margin-bottom: 30px; */
/* } */
/* h1 { */
/* color: #0039a6; */
/* } */
/* .intro */

17
corp/rih/index.html Normal file
View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="auto">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#712cf9">
<title>Russia is Hiring</title>
<link data-trunk rel="inline" href="index.css">
<link data-trunk rel="copy-file" href="rih-logo.png">
<!-- Bootstrap -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-KK94CHFLLe+nY2dmCWGMq91rCGa5gtU4mk92HdvYe+M/SXH301p5ILy+dN9+nJOZ" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ENjdO4Dr2bkBIFxQpeoTz1HIcje39Wm4jDKdf19U8gI4ddQ3GYNS7NTKfAdVQSZe" crossorigin="anonymous"></script>
</head>
<body>
</body>
</html>

BIN
corp/rih/rih-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

178
corp/rih/src/home.html Normal file
View file

@ -0,0 +1,178 @@
html! {
<main>
<div class="container px-4 pt-5 my-5 text-center">
<div class="row">
<div class="col-7 ms-auto">
<h1 class="display-5 fw-bold text-body-emphasis">{"Russia is Hiring"}</h1>
<p class="lead my-4">
{"Are you an IT-specialist on the hunt for a job? Well, "}
<a href="https://archive.is/SAONj" class="text-black">{"times are tough"}</a>
{" in Western countries at the moment. Meanwhile tech is booming in Russia, and national support programs make life as an IT-specialist very comfortable. Why not look East?"}
</p>
<p class="lead mb-4">{"We can help you find an employer in Russia, sort out the formalities and get you started. Sign up and tell us a bit about your profile, or read on below about the benefits of life in Russia."}</p>
<div class="d-grid gap-2 d-sm-flex justify-content-sm-center">
<button type="button" class="btn btn-primary btn-lg px-4 gap-3">{"Sign up"}</button>
</div>
</div>
<div class="col-2 me-auto">
<img src="/rih-logo.png" height="400px" />
</div>
</div>
</div>
<div class="b-section-divider"></div>
<div class="container px-5 py-5">
<h2 class="pb-2 border-bottom">{"Life in Russia"}</h2>
<div class="row row-cols-1 row-cols-md-2 align-items-md-center g-5 py-5">
<div class="col d-flex flex-column align-items-start gap-2">
<h3 class="fw-bold">{"Moscow is very cool and good indeed"}</h3>
<p class="text-body-secondary">{"Paragraph of text beneath the heading to explain the heading. We'll add onto it with another sentence and probably just keep going until we run out of words."}</p>
<a href="#sign-up" class="btn btn-primary btn-lg">{"Primary button"}</a>
</div>
<div class="col">
<div class="row row-cols-1 row-cols-sm-2 g-4">
<div class="col d-flex flex-column gap-2">
<div class="feature-icon-small d-inline-flex align-items-center justify-content-center text-bg-primary bg-gradient fs-4 rounded-3">
</div>
<h4 class="fw-semibold mb-0">{"Look they have banyas"}</h4>
<p class="text-body-secondary">{"Paragraph of text beneath the heading to explain the heading."}</p>
</div>
<div class="col d-flex flex-column gap-2">
<div class="feature-icon-small d-inline-flex align-items-center justify-content-center text-bg-primary bg-gradient fs-4 rounded-3">
</div>
<h4 class="fw-semibold mb-0">{"Wow such cultural diversity"}</h4>
<p class="text-body-secondary">{"Paragraph of text beneath the heading to explain the heading."}</p>
</div>
<div class="col d-flex flex-column gap-2">
<div class="feature-icon-small d-inline-flex align-items-center justify-content-center text-bg-primary bg-gradient fs-4 rounded-3">
</div>
<h4 class="fw-semibold mb-0">{"Many nice landscapes indeed"}</h4>
<p class="text-body-secondary">{"Paragraph of text beneath the heading to explain the heading."}</p>
</div>
<div class="col d-flex flex-column gap-2">
<div class="feature-icon-small d-inline-flex align-items-center justify-content-center text-bg-primary bg-gradient fs-4 rounded-3">
</div>
<h4 class="fw-semibold mb-0">{"And such low taxes!"}</h4>
<p class="text-body-secondary">{"Paragraph of text beneath the heading to explain the heading."}</p>
</div>
</div>
</div>
</div>
</div>
<div class="b-section-divider"></div>
<div class="container px-4 py-5">
<div class="row">
<div class="mx-auto col-7">
<a id="sign-up"/>
<h2 class="pb-2 border-bottom">{"Finding Work in Russia"}</h2>
<p>
{"Usually landing the most interesting jobs requires you to have a well-developed network of contacts, but this is tough when you set your eyes on a new country. Luckily we at "}
<a class="text-black" href={VISTA_URL}>{"Vista Immigration"}</a>
{" have contacts with many tech companies in Russia, large and small, and can help you with this!"}</p>
<p>{"Tell us a bit about yourself, the technologies you'd like to work with, and your situation in regards to relocating to Russia. We will then match up your profile with companies that match your interests, and establish contact between you and a potential employer if there is a good fit. No generic recruiter spam, guaranteed - we'd rather not send you anything, than send you something irrelevant!"}</p>
<p>
{"If you get hired, our experts can assist you with legal and other support for your move. Добро пожаловать в Россию!"}
</p>
</div>
</div>
<div class="row my-3">
<div class="col-7 mx-auto">
<p>{"Let's get started with a few basic personal details ..."}</p>
</div>
<div class="mx-auto col-6 border rounded-3 shadow">
<form class="m-3">
<div class="mb-3">
<label for="name" class="form-label">{"What's your name?"}</label>
<input type="text" class="form-control" id="name"
oninput={link.callback(|event| input_message(event, Msg::SetName))} />
</div>
<div class="mb-3">
<label for="email" class="form-label">{"What's your email address?"}</label>
<input type="email" class="form-control" id="email" aria-describedby="emailHelp"
oninput={link.callback(|event| input_message(event, Msg::SetEmail))}/>
<div id="emailHelp" class="form-text">{"No newsletters, no spam - we will only reach out if there's a match!"}</div>
</div>
<div class="mb-3">
<label id="citizenship" class="form-label">{"What citizenship do you hold?"}</label>
{citizenship_input(self, link)}
<div id="citizenshipHelp" class="form-text">{"We need to know this to estimate immigration-related bureaucracy. If you hold more than one citizenship, pick the one with which you'd want to receive a work visa."}</div>
</div>
<div class="mb-3">
<label for="personalDetails" class="form-label">{"Other relevant information:"}</label>
<textarea class="form-control" id="personalDetails" rows=3
aria-describedby="personalDetailsHelp"
oninput={link.callback(|event| textarea_message(event, Msg::SetPersonalDetails))} >
</textarea>
<div id="personalDetailsHelp" class="form-text">{"Any specific places where you'd like to live? Would you be moving with family? Any other assistance required?"}</div>
</div>
<hr />
<p>{"Now lets have a look at what you'd like to work with!"}</p>
<div class="mb-3">
<label for="job" class="form-label">{"What job(s) are you looking for?"}</label>
<input
type="text" class="form-control" id="job"
placeholder="Backend/frontend engineer, Test automation, DevOps/SRE, UI/UX ..."
oninput={link.callback(|event| input_message(event, Msg::SetPosition))} />
</div>
<div class="mb-3">
<label for="technologies" class="form-label">{"Which technologies do you want to work with?"}</label>
<div>{render_technologies(link, &self.record.technologies)}</div>
<input type="text" class="form-control" id="technologies"
aria-describedby="technologiesHelp"
onkeypress={link.callback(add_tech)}/>
<div id="technologiesHelp" class="form-text">{"Press enter after each technology."}</div>
</div>
<div class="mb-3">
<label for="jobDetails" class="form-label">{"What's your work background?"}</label>
<textarea class="form-control" id="workBackground" rows=3
aria-describedby="workBackgroundHelp"
oninput={link.callback(|event| textarea_message(event, Msg::SetWorkBackground))} >
</textarea>
<div id="workBackgroundHelp" class="form-text">{"Tell us about your work experience, and/or leave links to your CV on your site, LinkedIn or wherever."}</div>
</div>
<div class="mb-3">
<label for="jobDetails" class="form-label">{"Other job details:"}</label>
<textarea class="form-control" id="jobDetails" rows=3
aria-describedby="jobDetailsHelp"
oninput={link.callback(|event| textarea_message(event, Msg::SetJobDetails))}>
</textarea>
<div id="jobDetailsHelp" class="form-text">{"Tell us a bit about what you're looking for in a job and in an employer."}</div>
</div>
</form>
</div>
</div>
</div>
<div class="b-section-divider"></div>
<footer class="mt-auto text-center">
<div class="py-3">
<p>
{"By "}
<a href={VISTA_URL} class="text-black">{"Vista Immigration"}</a>
{", with help from "}
<a href="https://tvl.su/" class="text-black">{"TVL"}</a>
{"."}
</p>
</div>
</footer>
</main>
}

369
corp/rih/src/main.rs Normal file
View file

@ -0,0 +1,369 @@
use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher;
use gloo::console;
use gloo::storage::{LocalStorage, Storage};
use rand::seq::IteratorRandom;
use rand::thread_rng;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use wasm_bindgen::closure::Closure;
use wasm_bindgen::JsCast;
use web_sys::{HtmlInputElement, HtmlTextAreaElement, KeyboardEvent};
use yew::html::Scope;
use yew::prelude::*;
/// This code ends up being compiled for the native and for the
/// webassembly architectures during the build & test process.
/// However, the `rust_iso3166` crate exposes a different API (!)
/// based on the platform.
///
/// This trait acts as a platform-independent wrapper for the crate.
///
/// Upstream issue: https://github.com/rust-iso/rust_iso3166/issues/7
trait CountryCodeAccess {
fn country_alpha2(&self) -> String;
fn country_alpha3(&self) -> String;
fn country_name(&self) -> String;
}
#[cfg(target_arch = "wasm32")]
impl CountryCodeAccess for rust_iso3166::CountryCode {
fn country_alpha2(&self) -> String {
self.alpha2()
}
fn country_alpha3(&self) -> String {
self.alpha3()
}
fn country_name(&self) -> String {
self.name()
}
}
#[cfg(not(target_arch = "wasm32"))]
impl CountryCodeAccess for rust_iso3166::CountryCode {
fn country_alpha2(&self) -> String {
self.alpha2.to_string()
}
fn country_alpha3(&self) -> String {
self.alpha3.to_string()
}
fn country_name(&self) -> String {
self.name.to_string()
}
}
const VISTA_URL: &'static str = "https://vista-immigration.ru/";
/// Represents a single record as filled in by a user. This is the
/// primary data structure we want to populate and persist somewhere.
#[derive(Default, Debug, Deserialize, Serialize)]
struct Record {
// Personal information
name: String,
email: String,
citizenship: String, // TODO
personal_details: String,
// Job information
position: String,
technologies: BTreeSet<String>,
job_details: String,
work_background: String,
}
#[derive(Default)]
struct App {
// The record being populated.
record: Record,
// Is the citizenship input focused?
citizenship_focus: bool,
// Current query in the citizenship field.
citizenship_query: String,
}
#[derive(Clone, Debug)]
enum Msg {
NoOp,
AddTechnology(String),
RemoveTechnology(String),
FocusCitizenship,
BlurCitizenship,
QueryCitizenship(String),
SetCitizenship(String),
SetName(String),
SetEmail(String),
SetPersonalDetails(String),
SetPosition(String),
SetJobDetails(String),
SetWorkBackground(String),
}
/// Callback handler for adding a technology.
fn add_tech(e: KeyboardEvent) -> Msg {
if e.key_code() != 13 {
return Msg::NoOp;
}
let input = e.target_unchecked_into::<HtmlInputElement>();
let tech = input.value();
input.set_value("");
Msg::AddTechnology(tech)
}
fn select_country_enter(event: KeyboardEvent) -> Msg {
if event.key_code() != 13 {
return Msg::NoOp;
}
let input = event.target_unchecked_into::<HtmlInputElement>();
if let Some(country) = fuzzy_country_matches(&input.value()).next() {
input.set_value(&country.country_name());
return Msg::SetCitizenship(country.country_name());
}
Msg::NoOp
}
fn fuzzy_country_matches(query: &str) -> Box<dyn Iterator<Item = rust_iso3166::CountryCode> + '_> {
if query.is_empty() {
let rng = &mut thread_rng();
return Box::new(
rust_iso3166::ALL
.iter()
.choose_multiple(rng, 5)
.into_iter()
.map(Clone::clone),
);
}
let matcher = SkimMatcherV2::default();
let query = query.to_lowercase();
let query_len = query.len();
let mut results: Vec<_> = rust_iso3166::ALL
.iter()
.filter_map(|code| {
let mut score = None;
// Prioritize exact matches for country codes if query length <= 3
if query_len <= 3 {
if code.country_alpha2().eq_ignore_ascii_case(&query)
|| code.country_alpha3().eq_ignore_ascii_case(&query)
{
score = Some(100);
}
}
// If no exact match, do a fuzzy match
if score.is_none() {
score = matcher.fuzzy_match(&code.country_name().to_lowercase(), &query);
}
score.map(|score| (score, code))
})
.collect();
// Sort by score in descending order
results.sort_by(|a, b| b.0.cmp(&a.0));
// Get iterator over the best matches
Box::new(results.into_iter().map(|(_score, code)| *code))
}
// Callback for an input element's value being mapped straight into a
// message.
fn input_message(e: InputEvent, msg: fn(String) -> Msg) -> Msg {
let input = e.target_unchecked_into::<HtmlInputElement>();
msg(input.value())
}
// Callback for a text area's value being mapped straight into a
// message.
fn textarea_message(e: InputEvent, msg: fn(String) -> Msg) -> Msg {
let textarea = e.target_unchecked_into::<HtmlTextAreaElement>();
msg(textarea.value())
}
fn schedule_blur(event: FocusEvent, link: Scope<App>) -> Msg {
let input = event.target_unchecked_into::<HtmlInputElement>();
let closure = Closure::once_into_js(Box::new(move || {
if let Some(app) = link.get_component() {
input.set_value(&app.record.citizenship);
}
link.send_message(Msg::BlurCitizenship);
}) as Box<dyn FnOnce()>);
let window = web_sys::window().expect("no global `window` exists");
let _ =
window.set_timeout_with_callback_and_timeout_and_arguments_0(closure.unchecked_ref(), 100);
Msg::NoOp
}
/// Creates an input field for citizenship selection with suggestions.
fn citizenship_input(app: &App, link: &Scope<App>) -> Html {
let dropdown_classes = if app.citizenship_focus {
"dropdown-menu show"
} else {
"dropdown-menu"
};
let choices = fuzzy_country_matches(&app.citizenship_query).map(|country| {
let msg = Msg::SetCitizenship(country.country_name());
html! {
<li><a class="dropdown-item" onclick={link.callback(move |_| msg.clone())}>{country.country_name()}</a></li>
}
});
let blur_link = link.clone();
html! {
<div class="dropdown">
<input type="text" class="form-control" id="citizenship" aria-describedby="citizenshipHelp"
autocomplete="off"
oninput={link.callback(|event| input_message(event, Msg::QueryCitizenship))}
onkeypress={link.callback(select_country_enter)}
onfocus={link.callback(|_| Msg::FocusCitizenship)}
onblur={link.callback(move |event| schedule_blur(event, blur_link.clone()))} />
<ul class={dropdown_classes} style="position: absolute; inset: 0px auto auto 0px; margin: 0px; transform: translate(0px, 40px);" data-popper-placement="bottom-start">
{ choices.collect::<Html>() }
</ul>
</div>
}
}
/// Creates a list of technologies which can be deleted again by clicking them.
fn render_technologies(link: &Scope<App>, technologies: &BTreeSet<String>) -> Html {
if technologies.is_empty() {
return html! {};
}
let items = technologies.iter().map(|tech| {
let msg: Msg = Msg::RemoveTechnology(tech.to_string());
html! {
<>
<span class="btn btn-secondary btn-sm"
onclick={link.callback(move |_| msg.clone())}>
{tech}
<span class="mx-auto text-center text-black">{" x"}</span>
</span>{" "}
</>
}
});
html! {
<div class="p-1">
{ items.collect::<Html>() }
</div>
}
}
impl Component for App {
type Message = Msg;
type Properties = ();
fn create(_ctx: &Context<Self>) -> Self {
let mut new = Self::default();
if let Ok(record) = LocalStorage::get("record") {
new.record = record;
}
new
}
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
console::log!("handling ", format!("{:?}", msg));
let (state_change, view_change) = match msg {
Msg::NoOp => (false, false),
Msg::AddTechnology(tech) => {
console::log!("adding technology", &tech);
self.record.technologies.insert(tech);
(true, true)
}
Msg::RemoveTechnology(tech) => {
console::log!("removing technology ", &tech);
self.record.technologies.remove(&tech);
(true, true)
}
Msg::QueryCitizenship(query) => {
self.citizenship_query = query;
(false, true)
}
Msg::FocusCitizenship => {
self.citizenship_focus = true;
(false, true)
}
Msg::BlurCitizenship => {
self.citizenship_focus = false;
(false, true)
}
Msg::SetCitizenship(country) => {
self.record.citizenship = country;
(true, false)
}
Msg::SetName(name) => {
self.record.name = name;
(true, false)
}
Msg::SetEmail(email) => {
self.record.email = email;
(true, false)
}
Msg::SetPersonalDetails(details) => {
self.record.personal_details = details;
(true, false)
}
Msg::SetPosition(position) => {
self.record.position = position;
(true, false)
}
Msg::SetJobDetails(details) => {
self.record.job_details = details;
(true, false)
}
Msg::SetWorkBackground(background) => {
self.record.work_background = background;
(true, false)
}
};
if state_change {
if let Err(err) = LocalStorage::set("record", &self.record) {
console::warn!(
"failed to persist record in local storage: ",
err.to_string()
);
}
}
view_change
}
fn view(&self, ctx: &Context<Self>) -> Html {
let link = ctx.link();
include!("home.html")
}
}
fn main() {
yew::Renderer::<App>::new().render();
}