about summary refs log tree commit diff
path: root/corp/rih/frontend/src
diff options
context:
space:
mode:
authorVincent Ambo <mail@tazj.in>2023-06-06T11·26+0300
committertazjin <tazjin@tvl.su>2023-06-06T11·43+0000
commit5dee4780dab5e22eb2929a3b4287c5a781fc20f9 (patch)
treeefdbcdd87bef1bd6dcca3010c0b7115bb443b6de /corp/rih/frontend/src
parent6fa6f3a7f4a72a74ce495609646d2dae3789cde3 (diff)
chore(corp/rih): move frontend to a separate folder r/6237
Change-Id: Ic7467f459015c39c73f87c61a048319eaf1243be
Reviewed-on: https://cl.tvl.fyi/c/depot/+/8714
Tested-by: BuildkiteCI
Reviewed-by: tazjin <tazjin@tvl.su>
Diffstat (limited to 'corp/rih/frontend/src')
-rw-r--r--corp/rih/frontend/src/home.html206
-rw-r--r--corp/rih/frontend/src/main.rs505
-rw-r--r--corp/rih/frontend/src/privacy-policy.md100
3 files changed, 811 insertions, 0 deletions
diff --git a/corp/rih/frontend/src/home.html b/corp/rih/frontend/src/home.html
new file mode 100644
index 000000000000..697daf5148f3
--- /dev/null
+++ b/corp/rih/frontend/src/home.html
@@ -0,0 +1,206 @@
+html! {
+<main>
+  <script>
+    {r#"function captchaOnload(sitekey, callback) {
+      if (window.smartCaptcha) {
+        const container = document.getElementById('captcha-container');
+        const widgetId = window.smartCaptcha.render(container, {
+            sitekey: sitekey,
+            hl: 'en',
+            callback: callback,
+        });
+      }
+    }"#}
+  </script>
+
+  <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 you telling us a bit about what kind of job you would like!"}</p>
+      </div>
+
+      if !self.submitted {
+      <div class="mx-auto col-6 border rounded-3 shadow">
+        <form class="m-3">
+
+          <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>
+
+          <hr/>
+          <p>{"Now we also need some personal details about you:"}</p>
+
+          <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>
+
+          <div id="captcha-container" class="smart-captcha mb-3" style="height: 100px" />
+
+          <button type="submit" class="btn btn-primary"
+                  disabled={!(self.record.is_complete() && self.captcha_token.is_some())}
+                  onclick={link.callback(|_| Msg::Submit)}>
+            {"Submit"}
+          </button>
+          <p class="pt-2"><i>{"This page is still under construction! Please reach out at contact@ if you have any questions."}</i></p>
+        </form>
+      </div>
+      } else {
+      <div class="mx-auto col-6 border rounded-3 shadow">
+        <p>{"Thank you for submitting your data! We will reach out to confirm your email address, and further if any matches are found. You can contact us at contact@russiaishiring.com with any questions you might have."}</p>
+      </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/frontend/src/main.rs b/corp/rih/frontend/src/main.rs
new file mode 100644
index 000000000000..9b7e38422ac2
--- /dev/null
+++ b/corp/rih/frontend/src/main.rs
@@ -0,0 +1,505 @@
+use fuzzy_matcher::skim::SkimMatcherV2;
+use fuzzy_matcher::FuzzyMatcher;
+use gloo::console;
+use gloo::history::{BrowserHistory, History};
+use gloo::net::http;
+use gloo::storage::{LocalStorage, Storage};
+use gloo::utils::format::JsValueSerdeExt;
+use rand::seq::IteratorRandom;
+use rand::thread_rng;
+use serde::{Deserialize, Serialize};
+use static_markdown::markdown;
+use std::collections::BTreeSet;
+use wasm_bindgen::closure::Closure;
+use wasm_bindgen::{JsCast, JsValue};
+use web_sys::{HtmlInputElement, HtmlTextAreaElement, KeyboardEvent};
+use yew::html::Scope;
+use yew::prelude::*;
+use yew_router::prelude::*;
+
+/// Form submission is protected with a captcha. The development
+/// version of the captcha does not do domain checking and works on
+/// `localhost` as well.
+#[cfg(debug_assertions)]
+const CAPTCHA_KEY: &'static str = "ysc1_K7iOi3FSmsyO8pZGu8Im2iQClCtPsVx7jSRyhyCV435a732c";
+
+#[cfg(not(debug_assertions))]
+const CAPTCHA_KEY: &'static str = "ysc1_a3LVlaDRDMwU8CLSZ0WKENTI2exyOxz5J2c6x28P5339d410";
+
+// Form data is submitted to different endpoints in dev/prod.
+#[cfg(debug_assertions)]
+const SUBMIT_URL: &'static str = "http://localhost:9090/submit";
+
+#[cfg(not(debug_assertions))]
+const SUBMIT_URL: &'static str = "https://api.russiaishiring.com/submit";
+
+/// 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/";
+
+#[derive(Debug, Clone, Copy, PartialEq, Routable)]
+enum Route {
+    #[at("/")]
+    Home,
+    #[at("/privacy-policy")]
+    PrivacyPolicy,
+    #[not_found]
+    #[at("/404")]
+    NotFound,
+}
+
+/// Represents a single record as filled in by a user. This is the
+/// primary data structure we want to populate and persist somewhere.
+#[derive(Clone, 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,
+}
+
+impl Record {
+    fn is_complete(&self) -> bool {
+        !self.name.is_empty()
+            && !self.email.is_empty()
+            && !self.citizenship.is_empty()
+            && !self.position.is_empty()
+            && !self.technologies.is_empty()
+    }
+}
+
+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,
+
+    // History handler.
+    history: BrowserHistory,
+
+    // Captcha token, if the captcha has been solved.
+    captcha_token: Option<String>,
+
+    // Captcha callback closure which needs to be kept alive for the
+    // lifecycle of the app.
+    captcha_callback: Closure<dyn FnMut(String)>,
+
+    // Has data been submitted already by this user?
+    submitted: bool,
+}
+
+#[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),
+
+    CaptchaSolved(String),
+    Submit,
+}
+
+/// 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>
+    }
+}
+
+/// Submit the collected data to the backend service.
+async fn submit_data(captcha_token: &str, record: &Record) -> bool {
+    let response = http::Request::get(SUBMIT_URL)
+        .method(http::Method::POST)
+        .json(&serde_json::json!({
+            "captcha_token": captcha_token,
+            "record": record,
+        }))
+        .expect("serialising a serde_json::Value can not fail")
+        .send()
+        .await
+        .unwrap();
+
+    // currently there is nothing we can actually do with the response
+    // here, but we should add some way to communicate back some
+    // server errors etc., even if the whole thing should be as
+    // forgiving as possible.
+    response.ok()
+}
+
+/// Handle the submit event, if all data was successfully collected.
+fn handle_submit(app: &App, link: Scope<App>) -> Msg {
+    let token = app.captcha_token.as_ref().unwrap().clone();
+    let record = app.record.clone();
+
+    wasm_bindgen_futures::spawn_local(async move {
+        if !submit_data(&token, &record).await {
+            console::warn!("failed to submit data for some reason");
+        } else {
+            console::log!("submitted data successfully");
+        }
+    });
+
+    Msg::NoOp
+}
+
+impl Component for App {
+    type Message = Msg;
+    type Properties = ();
+
+    fn create(ctx: &Context<Self>) -> Self {
+        App {
+            record: LocalStorage::get("record").unwrap_or_default(),
+            citizenship_focus: false,
+            citizenship_query: String::default(),
+            history: BrowserHistory::default(),
+            captcha_token: None,
+            captcha_callback: {
+                let link = ctx.link().clone();
+                Closure::wrap(Box::new(move |val| {
+                    link.send_message(Msg::CaptchaSolved(val));
+                }))
+            },
+            submitted: false,
+        }
+    }
+
+    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)
+            }
+
+            Msg::CaptchaSolved(token) => {
+                self.captcha_token = Some(token);
+                (false, true)
+            }
+
+            Msg::Submit => {
+                if self.record.is_complete() && self.captcha_token.is_some() {
+                    self.submitted = true;
+                    handle_submit(self, ctx.link().clone());
+                    (false, true)
+                } else {
+                    console::warn!("submitted data, but form or captcha was not ready");
+                    (false, 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();
+        let location = self.history.location();
+        let route = Route::recognize(location.path()).unwrap_or(Route::NotFound);
+
+        match route {
+            Route::Home => include!("home.html"),
+            Route::PrivacyPolicy => html! {
+                <main>{include!("privacy-policy.md")}</main>
+            },
+            Route::NotFound => todo!(),
+        }
+    }
+
+    fn rendered(&mut self, _: &Context<Self>, first_render: bool) {
+        if first_render {
+            let func =
+                js_sys::Function::new_with_args("key, callback", "captchaOnload(key, callback)");
+            let _ = func.call2(
+                &JsValue::NULL,
+                &JsValue::from_str(CAPTCHA_KEY),
+                &self.captcha_callback.as_ref().unchecked_ref(),
+            );
+        }
+    }
+}
+
+fn main() {
+    yew::Renderer::<App>::new().render();
+}
diff --git a/corp/rih/frontend/src/privacy-policy.md b/corp/rih/frontend/src/privacy-policy.md
new file mode 100644
index 000000000000..843aac4c0a90
--- /dev/null
+++ b/corp/rih/frontend/src/privacy-policy.md
@@ -0,0 +1,100 @@
+markdown!(r#"
+Privacy Policy
+==============
+
+Effective as of: 2023-05-26
+
+This policy outlines who, how and what happens with personal
+information entered on russiaishiring.com.
+
+0. Parties
+
+    Entities involved in the collection, storage and processing of
+    data are the following, all registered in Russia:
+
+   - [VFBS LLC](https://vista-immigration.ru/) (Vista Foreign Business
+     Support), INN 7709963942, г. Москва, муниципальный округ
+     Таганский вн.тер.г., ул. Таганская, д. 17-23
+
+     Contact: tbis@vfbs.ru
+
+   - [TVL LLC](https://tvl.su/), INN 9703038861, г. Москва, вн.тер.г.
+     муниципальный округ Беговой, ул Правды, д. 24, стр. 2, помещ. 3П
+
+     Contact: contact@tvl.su
+
+   - [Yandex LLC](https://yandex.com/company/), INN 1027700229193,
+     119021, город Москва, ул. Льва Толстого, д.16
+
+     Contacts: https://yandex.com/company/contacts/moscow/
+
+   In the following text, VFBS LLC will be referred to as "we".
+
+1. Information we collect
+
+   We collect information used for the purpose of matching a potential
+   job candidate with a vacancy. This includes:
+
+   - Name
+   - Contact e-mail address
+   - Work background
+   - Citizenship
+   - Language skills
+
+   We collect other **optional** information, such as data about the
+   work background or expectations from a new job, as deemed relevant
+   and supplied by the user.
+
+   As part of our technical processes we also store the IP address
+   from which data was submitted.
+
+2. Data storage
+
+   We store all collected data in services in Yandex Cloud, with the
+   help of our technical services provider TVL LLC.
+
+   Stored data is subject to the [Yandex privacy
+   policy](https://yandex.ru/legal/confidential/).
+
+   Collected data is technically accessible by employees of TVL LLC
+   and VFBC LLC.
+
+3. Data processing
+
+   Data is processed by automatic systems in Yandex Cloud. During
+   processing, no access is granted to any parties not mentioned
+   above.
+
+4. Data sharing with third-parties
+
+   As part of the operation of the service, data may be passed on to
+   companies looking to hire candidates based on the data set.
+
+   This is never done without the user's consent. If you submit data
+   to us and a suitable job opportunity is found, we will reach out to
+   you and confirm the further process as well as any data sharing.
+
+5. User rights
+
+   Users that have submitted data to the service always have the right
+   to request its removal. Please contact us at
+   privacy@russiaishiring.com for help with data removal.
+
+   Note that we may require you to verify your identity through the
+   e-mail address you supplied to us when submitting data.
+
+6. Tracking technologies, cookies, advertising
+
+   We do not have any tracking technologies or advertising on
+   russiaishiring.com. We do not use cookies. Only data explicitly
+   entered by the user in the form is submitted to us.
+
+   The site uses local storage on the user's machine to store form
+   data between visits to the site.
+
+7. Retention period
+
+   We will retain individual data no longer than 2 years from the date
+   of entering the data. If updated data is received for a user, this
+   period is reset.
+"#)