add: ability to create seller account

This commit is contained in:
trisua 2025-07-12 21:05:45 -04:00
parent e4468e4768
commit aea764948c
13 changed files with 356 additions and 0 deletions

View file

@ -58,6 +58,7 @@ pub const AUTH_BASE: &str = include_str!("./public/html/auth/base.lisp");
pub const AUTH_LOGIN: &str = include_str!("./public/html/auth/login.lisp");
pub const AUTH_REGISTER: &str = include_str!("./public/html/auth/register.lisp");
pub const AUTH_CONNECTION: &str = include_str!("./public/html/auth/connection.lisp");
pub const AUTH_SELLER_CONNECTION: &str = include_str!("./public/html/auth/seller_connection.lisp");
pub const PROFILE_BASE: &str = include_str!("./public/html/profile/base.lisp");
pub const PROFILE_POSTS: &str = include_str!("./public/html/profile/posts.lisp");
@ -139,6 +140,8 @@ pub const LITTLEWEB_SERVICE: &str = include_str!("./public/html/littleweb/servic
pub const LITTLEWEB_DOMAIN: &str = include_str!("./public/html/littleweb/domain.lisp");
pub const LITTLEWEB_BROWSER: &str = include_str!("./public/html/littleweb/browser.lisp");
pub const MARKETPLACE_SELLER: &str = include_str!("./public/html/marketplace/seller.lisp");
// langs
pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml");
@ -146,6 +149,7 @@ pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml");
pub const VENDOR_SPOTIFY_ICON: &str = include_str!("./public/images/vendor/spotify.svg");
pub const VENDOR_LAST_FM_ICON: &str = include_str!("./public/images/vendor/last-fm.svg");
pub const VENDOR_STRIPE_ICON: &str = include_str!("./public/images/vendor/stripe.svg");
pub const TETRATTO_BUNNY: &[u8] = include_bytes!("./public/images/tetratto_bunny.webp");
@ -343,6 +347,7 @@ pub(crate) fn lisp_plugins() -> HashMap<String, Box<dyn FnMut(Element) -> Elemen
pub(crate) async fn write_assets(config: &Config) -> PathBufD {
vendor_icon!("spotify", VENDOR_SPOTIFY_ICON, config.dirs.icons);
vendor_icon!("last_fm", VENDOR_LAST_FM_ICON, config.dirs.icons);
vendor_icon!("stripe", VENDOR_STRIPE_ICON, config.dirs.icons);
bin_icon!("tetratto_bunny.webp", TETRATTO_BUNNY, config.dirs.assets);
// ...
@ -364,6 +369,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
write_template!(html_path->"auth/login.html"(crate::assets::AUTH_LOGIN) --config=config --lisp plugins);
write_template!(html_path->"auth/register.html"(crate::assets::AUTH_REGISTER) --config=config --lisp plugins);
write_template!(html_path->"auth/connection.html"(crate::assets::AUTH_CONNECTION) --config=config --lisp plugins);
write_template!(html_path->"auth/seller_connection.html"(crate::assets::AUTH_SELLER_CONNECTION) --config=config --lisp plugins);
write_template!(html_path->"profile/base.html"(crate::assets::PROFILE_BASE) -d "profile" --config=config --lisp plugins);
write_template!(html_path->"profile/posts.html"(crate::assets::PROFILE_POSTS) --config=config --lisp plugins);
@ -440,6 +446,8 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
write_template!(html_path->"littleweb/domain.html"(crate::assets::LITTLEWEB_DOMAIN) --config=config --lisp plugins);
write_template!(html_path->"littleweb/browser.html"(crate::assets::LITTLEWEB_BROWSER) --config=config --lisp plugins);
write_template!(html_path->"marketplace/seller.html"(crate::assets::MARKETPLACE_SELLER) -d "marketplace" --config=config --lisp plugins);
html_path
}

View file

@ -285,3 +285,9 @@ version = "1.0.0"
"littleweb:action.edit_site_name" = "Edit site name"
"littleweb:action.rename" = "Rename"
"littleweb:action.add" = "Add"
"marketplace:label.products" = "Products"
"marketplace:label.status" = "Status"
"marketplace:action.get_started" = "Get started"
"marketplace:action.finsh_setting_up_account" = "Finish setting up my account"
"marketplace:action.open_seller_dashboard" = "Open seller dashboard"

View file

@ -583,6 +583,9 @@ input[type="checkbox"]:checked {
border-radius: 6px;
height: max-content;
font-weight: 600;
display: flex;
justify-content: center;
align-items: center;
}
.notification.tr {
@ -597,6 +600,11 @@ input[type="checkbox"]:checked {
padding: 0;
}
.notification .icon {
width: 100%;
height: 100%;
}
/* chip */
.chip {
background: var(--color-primary);

View file

@ -0,0 +1,25 @@
(text "{% extends \"auth/base.html\" %} {% block head %}")
(title
(text "Connection"))
(text "{% endblock %} {% block title %}Connection{% endblock %} {% block content %}")
(div
("class" "w-full flex-col gap-2")
("id" "status")
(b
(text "Working...")))
(text "{% if connection_type == \"refresh\" %}")
(script
("defer" "true")
(text "setTimeout(async () => {
trigger(\"seller::onboarding\");
}, 1000);"))
(text "{% elif connection_type == \"return\" %}")
(script
("defer" "true")
(text "setTimeout(async () => {
document.getElementById(\"status\").innerHTML =
`<b>Account updated.</b> You can now close this tab.`;
}, 1000);"))
(text "{%- endif %} {% endblock %}")

View file

@ -331,3 +331,17 @@
(span
(text "{{ text \"settings:tab.connections\" }}")))
(text "{%- endmacro %}")
(text "{% macro seller_settings_nav_options() -%}")
(a
("data-tab-button" "account")
("class" "active")
("href" "#/account")
(icon (text "smile"))
(span (str (text "settings:tab.account"))))
(a
("data-tab-button" "products")
("href" "#/products")
(icon (text "package"))
(span (str (text "marketplace:label.products"))))
(text "{%- endmacro %}")

View file

@ -0,0 +1,79 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Seller settings - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex-col gap-2")
; nav
(div
("class" "mobile_nav mobile")
; primary nav
(div
("class" "dropdown")
("style" "width: max-content")
(button
("class" "raised small")
("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown")
(icon (text "sliders-horizontal"))
(span ("class" "current_tab_text") (text "account")))
(div
("class" "inner left")
(text "{{ macros::seller_settings_nav_options() }}"))))
; nav desktop
(div
("class" "desktop pillmenu")
(text "{{ macros::seller_settings_nav_options() }}"))
; ...
(div
("class" "card w-full lowered flex flex-col gap-2")
("data-tab" "account")
(div
("class" "card-nest w-full")
(div
("class" "card small flex items-center gap-2")
(div
("class" "notification")
("style" "width: 46px")
(icon (text "stripe")))
(b (str (text "marketplace:label.status"))))
(div
("class" "card")
(text "{% if user.seller_data.account_id -%}")
(text "{% if user.seller_data.completed_onboarding -%}")
; completed onboarding + has stripe account linked
(button
("onclick" "trigger('seller::login')")
(icon (text "arrow-right"))
(str (text "marketplace:action.open_seller_dashboard")))
(text "{% else %}")
; not completed onboarding
(p (text "You've not finished setting up your Stripe account."))
(p (text "Please complete onboarding to accept payments."))
(button
("onclick" "trigger('seller::onboarding')")
(icon (text "arrow-right"))
(str (text "marketplace:action.finsh_setting_up_account")))
(text "{%- endif %}")
(text "{% else %}")
; doesn't have a stripe account linked
(button
("onclick" "trigger('seller::register')")
(icon (text "arrow-right"))
(str (text "marketplace:action.get_started")))
(text "{%- endif %}"))))
(div
("class" "card w-full lowered hidden flex flex-col gap-2")
("data-tab" "products")
(div
("class" "card w-full flex flex-wrap gap-2")
)))
(text "{% endblock %}")

View file

@ -0,0 +1 @@
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" x="0" y="0" viewBox="0 0 468 222.5" style="enable-background:new 0 0 468 222.5" xml:space="preserve"><style>.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#635bff}</style><path class="st0" d="M414 113.4c0-25.6-12.4-45.8-36.1-45.8-23.8 0-38.2 20.2-38.2 45.6 0 30.1 17 45.3 41.4 45.3 11.9 0 20.9-2.7 27.7-6.5v-20c-6.8 3.4-14.6 5.5-24.5 5.5-9.7 0-18.3-3.4-19.4-15.2h48.9c0-1.3.2-6.5.2-8.9zm-49.4-9.5c0-11.3 6.9-16 13.2-16 6.1 0 12.6 4.7 12.6 16h-25.8zM301.1 67.6c-9.8 0-16.1 4.6-19.6 7.8l-1.3-6.2h-22v116.6l25-5.3.1-28.3c3.6 2.6 8.9 6.3 17.7 6.3 17.9 0 34.2-14.4 34.2-46.1-.1-29-16.6-44.8-34.1-44.8zm-6 68.9c-5.9 0-9.4-2.1-11.8-4.7l-.1-37.1c2.6-2.9 6.2-4.9 11.9-4.9 9.1 0 15.4 10.2 15.4 23.3 0 13.4-6.2 23.4-15.4 23.4zM223.8 61.7l25.1-5.4V36l-25.1 5.3zM223.8 69.3h25.1v87.5h-25.1zM196.9 76.7l-1.6-7.4h-21.6v87.5h25V97.5c5.9-7.7 15.9-6.3 19-5.2v-23c-3.2-1.2-14.9-3.4-20.8 7.4zM146.9 47.6l-24.4 5.2-.1 80.1c0 14.8 11.1 25.7 25.9 25.7 8.2 0 14.2-1.5 17.5-3.3V135c-3.2 1.3-19 5.9-19-8.9V90.6h19V69.3h-19l.1-21.7zM79.3 94.7c0-3.9 3.2-5.4 8.5-5.4 7.6 0 17.2 2.3 24.8 6.4V72.2c-8.3-3.3-16.5-4.6-24.8-4.6C67.5 67.6 54 78.2 54 95.9c0 27.6 38 23.2 38 35.1 0 4.6-4 6.1-9.6 6.1-8.3 0-18.9-3.4-27.3-8v23.8c9.3 4 18.7 5.7 27.3 5.7 20.8 0 35.1-10.3 35.1-28.2-.1-29.8-38.2-24.5-38.2-35.7z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -1201,3 +1201,60 @@
]);
});
})();
(() => {
const self = reg_ns("seller");
self.define("register", async () => {
await trigger("atto::debounce", ["seller::register"]);
if (
!(await trigger("atto::confirm", [
"Are you sure you want to do this?",
]))
) {
return;
}
const res = await (
await fetch("/api/v1/service_hooks/stripe/seller/register", {
method: "POST",
})
).json();
trigger("atto::toast", [res.ok ? "success" : "error", res.message]);
self.onboarding();
});
self.define("onboarding", async () => {
await trigger("atto::debounce", ["seller::onboarding"]);
const res = await (
await fetch("/api/v1/service_hooks/stripe/seller/onboarding", {
method: "POST",
})
).json();
trigger("atto::toast", [res.ok ? "success" : "error", res.message]);
if (res.ok) {
window.location.href = res.payload;
}
});
self.define("login", async () => {
await trigger("atto::debounce", ["seller::login"]);
const res = await (
await fetch("/api/v1/service_hooks/stripe/seller/login", {
method: "POST",
})
).json();
trigger("atto::toast", [res.ok ? "success" : "error", res.message]);
if (res.ok) {
window.location.href = res.payload;
}
});
})();

View file

@ -332,6 +332,10 @@ pub async fn onboarding_account_link_request(
None => return Json(Error::NotAllowed.into()),
};
if user.seller_data.account_id.is_some() {
return Json(Error::NotAllowed.into());
}
let client = match data.3 {
Some(ref c) => c,
None => return Json(Error::Unknown.into()),
@ -379,6 +383,10 @@ pub async fn create_seller_account_request(
None => return Json(Error::NotAllowed.into()),
};
if user.seller_data.account_id.is_some() {
return Json(Error::NotAllowed.into());
}
let client = match data.3 {
Some(ref c) => c,
None => return Json(Error::Unknown.into()),
@ -420,3 +428,38 @@ pub async fn create_seller_account_request(
Err(e) => return Json(e.into()),
}
}
pub async fn login_link_request(
jar: CookieJar,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = &(data.read().await);
let user = match get_user_from_token!(jar, data.0) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
if user.seller_data.account_id.is_none() | !user.seller_data.completed_onboarding {
return Json(Error::NotAllowed.into());
}
let client = match data.3 {
Some(ref c) => c,
None => return Json(Error::Unknown.into()),
};
match stripe::LoginLink::create(
&client,
&stripe::AccountId::from_str(&user.seller_data.account_id.unwrap()).unwrap(),
&data.0.0.0.host,
)
.await
{
Ok(x) => Json(ApiReturn {
ok: true,
message: "Acceptable".to_string(),
payload: Some(x.url),
}),
Err(e) => Json(Error::MiscError(e.to_string()).into()),
}
}

View file

@ -520,6 +520,10 @@ pub fn routes() -> Router {
"/service_hooks/stripe/seller/onboarding",
post(auth::connections::stripe::onboarding_account_link_request),
)
.route(
"/service_hooks/stripe/seller/login",
post(auth::connections::stripe::login_link_request),
)
// channels
.route("/channels", post(channels::channels::create_request))
.route(

View file

@ -0,0 +1,95 @@
use super::render_error;
use crate::{assets::initial_context, get_lang, get_user_from_token, State};
use axum::{
response::{Html, IntoResponse},
Extension,
};
use axum_extra::extract::CookieJar;
use tetratto_core::model::Error;
/// `/settings/seller`
pub async fn seller_settings_request(
jar: CookieJar,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = data.read().await;
let user = match get_user_from_token!(jar, data.0) {
Some(ua) => ua,
None => {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &None).await,
));
}
};
let lang = get_lang!(jar, data.0);
let context = initial_context(&data.0.0.0, lang, &Some(user)).await;
// return
Ok(Html(
data.1.render("marketplace/seller.html", &context).unwrap(),
))
}
pub async fn connection_return_request(
jar: CookieJar,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = data.read().await;
let mut user = match get_user_from_token!(jar, data.0) {
Some(ua) => ua,
None => {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &None).await,
));
}
};
// update user
user.seller_data.completed_onboarding = true;
if let Err(e) = data
.0
.update_user_seller_data(user.id, user.seller_data.clone())
.await
{
return Err(Html(render_error(e, &jar, &data, &None).await));
}
// ...
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
context.insert("connection_type", "return");
// return
Ok(Html(
data.1
.render("auth/seller_connection.html", &context)
.unwrap(),
))
}
pub async fn connection_reload_request(
jar: CookieJar,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = data.read().await;
let user = match get_user_from_token!(jar, data.0) {
Some(ua) => ua,
None => {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &None).await,
));
}
};
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
context.insert("connection_type", "reload");
// return
Ok(Html(
data.1
.render("auth/seller_connection.html", &context)
.unwrap(),
))
}

View file

@ -5,6 +5,7 @@ pub mod developer;
pub mod forge;
pub mod journals;
pub mod littleweb;
pub mod marketplace;
pub mod misc;
pub mod mod_panel;
pub mod profile;
@ -76,6 +77,14 @@ pub fn routes() -> Router {
"/auth/connections_link/app/{id}",
get(developer::connection_callback_request),
)
.route(
"/auth/connections_link/seller/reload",
get(marketplace::connection_reload_request),
)
.route(
"/auth/connections_link/seller/return",
get(marketplace::connection_return_request),
)
// profile
.route("/settings", get(profile::settings_request))
.route("/@{username}", get(profile::posts_request))
@ -146,6 +155,11 @@ pub fn routes() -> Router {
.route("/domains/{id}", get(littleweb::domain_request))
.route("/net", get(littleweb::browser_home_request))
.route("/net/{*uri}", get(littleweb::browser_request))
// marketplace
.route(
"/settings/seller",
get(marketplace::seller_settings_request),
)
}
pub fn lw_routes() -> Router {

View file

@ -334,6 +334,8 @@ pub struct UserSettings {
pub struct StripeSellerData {
#[serde(default)]
pub account_id: Option<String>,
#[serde(default)]
pub completed_onboarding: bool,
}
fn mime_avif() -> String {