From aea764948c3e8bd4a9b4fe6b2c724b50dec55897 Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 12 Jul 2025 21:05:45 -0400 Subject: [PATCH] add: ability to create seller account --- crates/app/src/assets.rs | 8 ++ crates/app/src/langs/en-US.toml | 6 ++ crates/app/src/public/css/style.css | 8 ++ .../public/html/auth/seller_connection.lisp | 25 +++++ crates/app/src/public/html/macros.lisp | 14 +++ .../src/public/html/marketplace/seller.lisp | 79 +++++++++++++++ .../app/src/public/images/vendor/stripe.svg | 1 + crates/app/src/public/js/me.js | 57 +++++++++++ .../routes/api/v1/auth/connections/stripe.rs | 43 +++++++++ crates/app/src/routes/api/v1/mod.rs | 4 + crates/app/src/routes/pages/marketplace.rs | 95 +++++++++++++++++++ crates/app/src/routes/pages/mod.rs | 14 +++ crates/core/src/model/auth.rs | 2 + 13 files changed, 356 insertions(+) create mode 100644 crates/app/src/public/html/auth/seller_connection.lisp create mode 100644 crates/app/src/public/html/marketplace/seller.lisp create mode 100644 crates/app/src/public/images/vendor/stripe.svg create mode 100644 crates/app/src/routes/pages/marketplace.rs diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index e4088a1..ad0f49b 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -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 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 } diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index a852d5a..94fa6f8 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -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" diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index 24c41bd..c4c5185 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -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); diff --git a/crates/app/src/public/html/auth/seller_connection.lisp b/crates/app/src/public/html/auth/seller_connection.lisp new file mode 100644 index 0000000..43381da --- /dev/null +++ b/crates/app/src/public/html/auth/seller_connection.lisp @@ -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 = + `Account updated. You can now close this tab.`; + }, 1000);")) +(text "{%- endif %} {% endblock %}") diff --git a/crates/app/src/public/html/macros.lisp b/crates/app/src/public/html/macros.lisp index f9d8a1f..980ee7f 100644 --- a/crates/app/src/public/html/macros.lisp +++ b/crates/app/src/public/html/macros.lisp @@ -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 %}") diff --git a/crates/app/src/public/html/marketplace/seller.lisp b/crates/app/src/public/html/marketplace/seller.lisp new file mode 100644 index 0000000..0efa4f0 --- /dev/null +++ b/crates/app/src/public/html/marketplace/seller.lisp @@ -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 %}") diff --git a/crates/app/src/public/images/vendor/stripe.svg b/crates/app/src/public/images/vendor/stripe.svg new file mode 100644 index 0000000..415271d --- /dev/null +++ b/crates/app/src/public/images/vendor/stripe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js index 4fd2150..e7fa2d6 100644 --- a/crates/app/src/public/js/me.js +++ b/crates/app/src/public/js/me.js @@ -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; + } + }); +})(); diff --git a/crates/app/src/routes/api/v1/auth/connections/stripe.rs b/crates/app/src/routes/api/v1/auth/connections/stripe.rs index 8343b1b..33e60b4 100644 --- a/crates/app/src/routes/api/v1/auth/connections/stripe.rs +++ b/crates/app/src/routes/api/v1/auth/connections/stripe.rs @@ -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, +) -> 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()), + } +} diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 164b17f..ccc91c8 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -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( diff --git a/crates/app/src/routes/pages/marketplace.rs b/crates/app/src/routes/pages/marketplace.rs new file mode 100644 index 0000000..69a2b3d --- /dev/null +++ b/crates/app/src/routes/pages/marketplace.rs @@ -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, +) -> 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, +) -> 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, +) -> 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(), + )) +} diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index ed513f9..83e29ad 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -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 { diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index 1119d8b..4fb1882 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -334,6 +334,8 @@ pub struct UserSettings { pub struct StripeSellerData { #[serde(default)] pub account_id: Option, + #[serde(default)] + pub completed_onboarding: bool, } fn mime_avif() -> String {