diff options
author | Vincent Ambo <mail@tazj.in> | 2023-04-20T10·40+0300 |
---|---|---|
committer | tazjin <tazjin@tvl.su> | 2023-05-27T11·40+0000 |
commit | 99c78966372017cb3b7bf021f08520a040004b0c (patch) | |
tree | ba5d71df0dcd7a662d19525254648106e5da9e94 /corp/rih/src | |
parent | 3b33c19a9c43cf66c2d28ffa3d49bb6e8757d9b1 (diff) |
feat(corp/rih): implement initial frontend application r/6208
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
Diffstat (limited to 'corp/rih/src')
-rw-r--r-- | corp/rih/src/home.html | 178 | ||||
-rw-r--r-- | corp/rih/src/main.rs | 369 |
2 files changed, 547 insertions, 0 deletions
diff --git a/corp/rih/src/home.html b/corp/rih/src/home.html new file mode 100644 index 000000000000..2098e9a6dd6f --- /dev/null +++ b/corp/rih/src/home.html @@ -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> +} diff --git a/corp/rih/src/main.rs b/corp/rih/src/main.rs new file mode 100644 index 000000000000..e2f3480c37a8 --- /dev/null +++ b/corp/rih/src/main.rs @@ -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(); +} |