Landing: Implement registration success/failure flows
Change-Id: I0b48cfb0c0b35bfe7c71b13f8953951821fb3958
diff --git a/apps/landing/.gitignore b/apps/landing/.gitignore
index c75eecc..132a090 100644
--- a/apps/landing/.gitignore
+++ b/apps/landing/.gitignore
@@ -1 +1,2 @@
-/public
+public/
+resources/
\ No newline at end of file
diff --git a/apps/landing/app.cue b/apps/landing/app.cue
new file mode 100644
index 0000000..1d6e418
--- /dev/null
+++ b/apps/landing/app.cue
@@ -0,0 +1,8 @@
+app: {
+ type: "hugo:latest"
+ ingress: {
+ network: "Private"
+ subdomain: "landing"
+ auth: enabled: false
+ }
+}
diff --git a/apps/landing/hugo.toml b/apps/landing/hugo.toml
index b7cb8bc..a7a29d1 100644
--- a/apps/landing/hugo.toml
+++ b/apps/landing/hugo.toml
@@ -7,3 +7,6 @@
Content-Security-Policy = 'connect-src app.v1.dodo.cloud'
Referrer-Policy = 'strict-origin-when-cross-origin'
X-Content-Type-Options = 'nosniff'
+
+[params]
+ apiBaseURL = "https://app.v1.dodo.cloud"
diff --git a/apps/landing/layouts/_default/baseof.html b/apps/landing/layouts/_default/baseof.html
index 153bd09..dd82ad4 100644
--- a/apps/landing/layouts/_default/baseof.html
+++ b/apps/landing/layouts/_default/baseof.html
@@ -3,9 +3,12 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
- <link rel="stylesheet" href="/styles/style.css?v=0.0.2">
+ <link rel="stylesheet" href="/styles/style.css?v=0.0.6">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/hack-font/3.3.0/web/hack.min.css">
<title>dodo</title>
+ <script>
+ apiBaseURL = "{{ .Site.Params.apiBaseURL }}";
+ </script>
</head>
<body>
<div class="container">
diff --git a/apps/landing/layouts/_default/list.html b/apps/landing/layouts/_default/list.html
index 471e61a..c92d5c5 100644
--- a/apps/landing/layouts/_default/list.html
+++ b/apps/landing/layouts/_default/list.html
@@ -30,5 +30,6 @@
<div class="footer-form">
{{ partial "register-form.html" . }}
</div>
-<script src="/js/main.js?v=0.0.2"></script>
+<script src="/js/main.js?v=0.0.5"></script>
+<script src="/js/register.js?v=0.0.3"></script>
{{ end }}
diff --git a/apps/landing/layouts/partials/register-form.html b/apps/landing/layouts/partials/register-form.html
index 3f35896..03e08c4 100644
--- a/apps/landing/layouts/partials/register-form.html
+++ b/apps/landing/layouts/partials/register-form.html
@@ -1,24 +1,24 @@
-<div class="form-container-footer">
- <form id="register-form" method="POST" action="/register" class="form-group-footer" onsubmit="return register()">
- <label>
- domain
- <select id="network" name="domain">
+<div id="form-container" class="form-container-footer">
+ <form id="register-form" method="POST" action="/register" class="form-group-footer" onsubmit="return register()">
+ <h3 id="error-message"></h3>
+ <div class="reg-inputs">
+ <select id="network" name="domain" required>
+ <option value="" disabled selected>domain</option>
<option value="dodoapp.xyz">dodoapp.xyz</option>
</select>
- </label>
- <label>
- subdomain
- <input id="subdomain" type="text" name="subdomain" />
- </label>
- <label>
- application type
- <select id="app-type" name="app-type">
+ <input id="subdomain" type="text" name="subdomain" placeholder="subdomain" required>
+ <select id="app-type" name="app-type" required>
+ <option value="" disabled selected>application type</option>
</select>
- </label>
- <label>
- ssh public key
- <textarea id="public-key" name="public-key" rows="2" required></textarea>
- </label>
- <button type="submit">create first app</button>
- </form>
+ </div>
+ <textarea id="public-key" name="public-key" rows="2" placeholder="ssh public key"></textarea>
+ <button id="create-app-button" type="submit">
+ <svg id="spinner" class="animated-spinner" fill="none" height="18" width="18" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" style="display: none;">
+ <g>
+ <circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="4" />
+ </g>
+ </svg>
+ create first app
+ </button>
+ </form>
</div>
diff --git a/apps/landing/layouts/register/single.html b/apps/landing/layouts/register/single.html
index f576e41..7d872a5 100644
--- a/apps/landing/layouts/register/single.html
+++ b/apps/landing/layouts/register/single.html
@@ -4,4 +4,5 @@
{{ partial "register-form.html" . }}
</div>
</div>
+<script src="/js/register.js?v=0.0.3"></script>
{{ end }}
diff --git a/apps/landing/static/js/main.js b/apps/landing/static/js/main.js
index 24e651e..a068560 100644
--- a/apps/landing/static/js/main.js
+++ b/apps/landing/static/js/main.js
@@ -139,55 +139,3 @@
fact.addEventListener("mouseover", () => handleMouseover(facts[index].params.image, index, facts[index].params.title));
});
});
-
-async function loadPublicData() {
- let networkSelect = document.querySelector("select#network");
- if (networkSelect === undefined) {
- return;
- }
- networkSelect.innerHTML = "";
- let appTypeSelect = document.querySelector("select#app-type");
- if (appTypeSelect === undefined) {
- return;
- }
- appTypeSelect.innerHTML = "";
- let resp = await fetch("https://app.v1.dodo.cloud/api/public-data");
- if (!resp.ok) {
- return;
- }
- let data = await resp.json();
- data.networks.forEach((network) => {
- let opt = document.createElement("option");
- opt.setAttribute("value", network.domain);
- opt.innerHTML = network.domain;
- networkSelect.appendChild(opt);
- });
- data.types.forEach((t) => {
- let opt = document.createElement("option");
- opt.setAttribute("value", t);
- opt.innerHTML = t;
- appTypeSelect.appendChild(opt);
- });
-}
-
-function register() {
- var data = {
- type: document.getElementById("app-type").value,
- adminPublicKey: document.getElementById("public-key").value,
- network: document.getElementById("network").value,
- subdomain: document.getElementById("subdomain").value,
- };
- fetch("https://app.v1.dodo.cloud/api/apps", {
- method: "POST",
- body: JSON.stringify(data),
- }).then((resp) => {
- resp.json().then((r) => {
- console.log(r);
- });
- });
- return false;
-}
-
-document.addEventListener("DOMContentLoaded", () => {
- loadPublicData();
-});
diff --git a/apps/landing/static/js/register.js b/apps/landing/static/js/register.js
new file mode 100644
index 0000000..ca66f0b
--- /dev/null
+++ b/apps/landing/static/js/register.js
@@ -0,0 +1,128 @@
+async function loadPublicData() {
+ let networkSelect = document.querySelector("select#network");
+ if (networkSelect === undefined) {
+ return;
+ }
+ let appTypeSelect = document.querySelector("select#app-type");
+ if (appTypeSelect === undefined) {
+ return;
+ }
+ networkSelect.innerHTML = `<option value="" disabled selected>domain</option>`;
+ appTypeSelect.innerHTML = `<option value="" disabled selected>application type</option>`;
+ let resp = await fetch(`${apiBaseURL}/api/public-data`);
+ if (!resp.ok) {
+ return;
+ }
+ let data = await resp.json();
+ data.networks.forEach((network) => {
+ let opt = document.createElement("option");
+ opt.setAttribute("value", network.domain);
+ opt.innerHTML = network.domain;
+ networkSelect.appendChild(opt);
+ });
+ data.types.forEach((t) => {
+ let opt = document.createElement("option");
+ opt.setAttribute("value", t);
+ opt.innerHTML = t;
+ appTypeSelect.appendChild(opt);
+ });
+}
+
+function errorRender(error) {
+ const errorMsg = document.getElementById("error-message");
+ errorMsg.innerHTML = error;
+ errorMsg.style.display = "block";
+}
+
+function triggerForm(status, errDisplay, buttonTxt, spinnerStatus) {
+ const form = document.getElementById('register-form');
+ const elements = form.querySelectorAll('input, select, textarea, button');
+ elements.forEach(element => {
+ element.disabled = status;
+ });
+ const errorMsg = document.getElementById("error-message");
+ errorMsg.style.display = errDisplay;
+ const button = document.getElementById("create-app-button");
+ button.removeChild(button.lastChild);
+ button.appendChild(document.createTextNode(buttonTxt));
+ const spinner = document.getElementById("spinner");
+ spinner.style.display = spinnerStatus;
+}
+
+async function register(event) {
+ event.preventDefault();
+ const data = {
+ type: document.getElementById("app-type").value,
+ adminPublicKey: document.getElementById("public-key").value,
+ network: document.getElementById("network").value,
+ subdomain: document.getElementById("subdomain").value,
+ };
+ triggerForm(true, "none", "\u00A0\u00A0\creating first app", "inline-block");
+ fetch(`${apiBaseURL}/api/apps`, {
+ method: "POST",
+ body: JSON.stringify(data)
+ })
+ .then(response => {
+ if (!response.ok) {
+ errorRender("Internal error, try again");
+ triggerForm(false, "block", "create first app", "none");
+ }
+ return response.json();
+ })
+ .then(result => {
+ const domain = document.getElementById("network").value;
+ const subdomain = document.getElementById("subdomain").value;
+ const appLink = `https://${subdomain}.${domain}`;
+ const appStatusLink = `https://status.${subdomain}.${domain}`;
+
+ const successHTML = `
+ <div class="registration-outcome">
+ <h3>Application has been successfully deployed, use information below to access it:</h3>
+ <button onclick="window.open('${appLink}', '_blank')">Application address: ${appLink}</button>
+ <br>
+ <button onclick="window.open('${appStatusLink}', '_blank')">Status page address: ${appStatusLink}</button>
+ <br>
+ <button class="pass" onclick="copyPassword('${result.password}')" id="copy-button">
+ Status page password: <strong>${result.password}</strong>
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 256 256">
+ <path fill="currentColor" d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z" />
+ </svg>
+ </button>
+ <div id="tooltip" class="tooltip">Password copied to clipboard</div>
+ </div>`;
+ document.getElementById("form-container").innerHTML = successHTML;
+ })
+ .catch(error => {
+ errorRender(`Failed to deploy application. Error: '${error.message}'`);
+ triggerForm(false, "block", "create first app", "none");
+ })
+ .finally(() => {
+ document.getElementById("spinner").style.display = "none";
+ });
+ return;
+}
+
+function copyPassword(password) {
+ navigator.clipboard.writeText(password).then(() => {
+ const button = document.getElementById("copy-button");
+ const tooltip = document.getElementById("tooltip");
+ const rect = button.getBoundingClientRect();
+ const tooltipWidth = tooltip.offsetWidth;
+ tooltip.style.top = `${rect.top - 30 + window.scrollY}px`;
+ tooltip.style.left = `${rect.left + (rect.width / 2) - (tooltipWidth / 2) + window.scrollX}px`;
+ tooltip.style.opacity = "1";
+ tooltip.style.visibility = "visible";
+ setTimeout(() => {
+ tooltip.style.opacity = "0";
+ tooltip.style.visibility = "hidden";
+ }, 1000);
+ });
+}
+
+document.addEventListener("DOMContentLoaded", () => {
+ loadPublicData();
+ const registerForm = document.getElementById("register-form");
+ if (registerForm) {
+ registerForm.onsubmit = register;
+ }
+});
diff --git a/apps/landing/static/styles/style.css b/apps/landing/static/styles/style.css
index 6537d58..8cd8e34 100644
--- a/apps/landing/static/styles/style.css
+++ b/apps/landing/static/styles/style.css
@@ -18,7 +18,9 @@
font-size: var(--fontSize);
}
-input, textarea, select {
+input,
+textarea,
+select {
font-family: Hack, monospace;
font-size: var(--fontSize);
}
@@ -291,6 +293,8 @@
padding: 20px;
display: flex;
justify-content: center;
+ flex-direction: column;
+ align-items: center;
}
.form-container-footer {
@@ -309,14 +313,12 @@
.form-group-footer {
display: flex;
justify-content: space-between;
- align-items: center;
+ align-items: flex-start;
flex-direction: column;
}
.form-group-footer label {
display: block;
- margin-bottom: 5px;
- padding-bottom: 10px;
color: var(--formText);
margin-right: auto;
width: 100%;
@@ -325,8 +327,7 @@
.form-group-footer input,
.form-group-footer textarea {
width: 100%;
- padding: 10px;
- margin-top: 5px;
+ padding: 10px 10px 10px 1rem;
border: 1px solid var(--formText);
box-sizing: border-box;
color: var(--formText);
@@ -334,8 +335,32 @@
resize: vertical;
}
+.form-group-footer textarea {
+ margin-top: 10px;
+}
+
+.form-group-footer select {
+ padding: 10px 2.5rem 10px 1rem;
+ padding-inline-start: 1rem;
+ padding-inline-end: 2.5rem;
+ border: 1px solid var(--formText);
+ border-radius: 0;
+ outline: 0;
+ box-shadow: none;
+ background-color: var(--formBg);
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ box-sizing: border-box;
+ background-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='24'%20height='24'%20viewBox='0%200%2024%2024'%20fill='none'%20stroke='rgb(136%2C%20145%2C%20164)'%20stroke-width='2'%20stroke-linecap='round'%20stroke-linejoin='round'%3E%3Cpolyline%20points='6%209%2012%2015%2018%209'%20/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: center right 0.75rem;
+ background-size: 1rem auto;
+}
+
.form-group-footer input:focus,
-.form-group-footer textarea:focus {
+.form-group-footer textarea:focus,
+.form-group-footer select:focus {
outline: none !important;
border: 1px solid var(--button);
}
@@ -355,6 +380,44 @@
background-color: #d4888d;
}
+input::placeholder,
+textarea::placeholder {
+ color: rgba(214, 214, 214, 0.6);
+ opacity: 1;
+}
+
+select option[disabled] {
+ color: rgba(214, 214, 214, 0.6);
+}
+
+select {
+ color: rgb(214, 214, 214);
+}
+
+select:invalid {
+ color: rgba(214, 214, 214, 0.6);
+}
+
+select:not(:invalid) {
+ color: rgb(214, 214, 214);
+}
+
+select option:not(:disabled) {
+ color: rgb(214, 214, 214);
+}
+
+input:disabled,
+select:disabled,
+textarea:disabled {
+ color: rgba(214, 214, 214, 0.6);
+ cursor: not-allowed;
+}
+
+button:disabled {
+ cursor: not-allowed;
+ opacity: 0.6;
+}
+
/* FOOTER FORM END */
/* APPS START */
@@ -457,3 +520,117 @@
}
/* ABOUT END */
+
+/* SECCESFULL REGISTRATION START */
+
+.registration-outcome {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+}
+
+.registration-outcome button {
+ font-size: 16px;
+ cursor: pointer;
+ background-color: var(--bg);
+ border-radius: 0;
+ border: 0;
+ height: 30px;
+ font-family: Hack, monospace;
+ color: #3a3a3a;
+ display: flex;
+ align-items: center;
+ justify-content: start;
+}
+
+.registration-outcome h3 {
+ margin: 0;
+ margin-bottom: 15px;
+}
+
+.registration-outcome .pass {
+ display: flex;
+}
+
+.registration-outcome svg {
+ color: var(--button);
+ margin-left: 5px;
+}
+
+.tooltip {
+ position: absolute;
+ background-color: #333;
+ color: #fff;
+ padding: 5px 10px;
+ border-radius: 4px;
+ white-space: nowrap;
+ opacity: 0;
+ visibility: hidden;
+ transition: opacity 0.3s;
+ font-size: 14px;
+}
+
+/* SECCESFULL REGISTRATION END */
+
+.reg-inputs {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ gap: 10px;
+ margin-bottom: 10px;
+ width: 100%;
+}
+
+@media (max-width: 768px) {
+ .reg-inputs {
+ grid-template-columns: 1fr;
+ width: 100%;
+ }
+}
+
+#create-app-button {
+ display: flex;
+ align-items: center;
+ margin-top: 5px;
+}
+
+#error-message {
+ margin: 0 0 10px 0;
+ color: var(--logo);
+ display: none;
+}
+
+.animated-spinner g {
+ animation: rotate 2s linear infinite;
+ transform-origin: center center;
+}
+
+.animated-spinner circle {
+ stroke-dasharray: 75, 100;
+ stroke-dashoffset: -5;
+ animation: dash 1.5s ease-in-out infinite;
+ stroke-linecap: round;
+}
+
+@keyframes rotate {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes dash {
+ 0% {
+ stroke-dasharray: 1, 100;
+ stroke-dashoffset: 0;
+ }
+ 50% {
+ stroke-dasharray: 44.5, 100;
+ stroke-dashoffset: -17.5;
+ }
+ 100% {
+ stroke-dasharray: 44.5, 100;
+ stroke-dashoffset: -62;
+ }
+}