add: ProfileStyle products

This commit is contained in:
trisua 2025-08-08 23:44:45 -04:00
parent 077e9252e3
commit 95cb889080
19 changed files with 525 additions and 54 deletions

View file

@ -208,6 +208,7 @@ version = "1.0.0"
"settings:tab.billing" = "Billing"
"settings:tab.uploads" = "Uploads"
"settings:tab.invites" = "Invites"
"setttings:label.applied_configurations" = "Applied configurations"
"mod_panel:label.open_reported_content" = "Open reported content"
"mod_panel:label.manage_profile" = "Manage profile"
@ -345,3 +346,6 @@ version = "1.0.0"
"economy:label.automail_message" = "Automail message"
"economy:action.buy" = "Buy"
"economy:label.already_purchased" = "Already purchased"
"economy:label.snippet_data" = "Snippet data"
"economy:action.apply" = "Apply"
"economy:action.unapply" = "Unapply"

View file

@ -2491,6 +2491,8 @@
(text "Create infinite Littleweb sites"))
(li
(text "Create infinite Littleweb domains"))
(li
(text "Create and sell CSS snippet products"))
(text "{% if config.security.enable_invite_codes -%}")
(li

View file

@ -150,35 +150,60 @@
(icon (text "package-check"))
(b
(str (text "economy:label.fulfillment_style"))))
(form
(div
("class" "card flex flex_col gap_2")
("onsubmit" "update_method_from_form(event)")
(p (text "If you choose to send an automated mail letter upon purchase, users will automatically receive the message you supply below."))
(p (text "If you disable automail, you'll be required to manually mail users who have purchased your product before the transfer is finalized."))
(select
("id" "fulfillment_style_select")
("onchange" "mirror_fulfillment_style_select(true)")
(option ("value" "mail") (text "Mail") ("selected" "{{ not product.method == \"ProfileStyle\" }}"))
(option ("value" "snippet") (text "CSS Snippet") ("selected" "{{ product.method == \"ProfileStyle\" }}")))
(form
("class" "flex flex_col gap_2 hidden")
("id" "mail_fulfillment")
("onsubmit" "update_method_from_form(event)")
(p (text "If you choose to send an automated mail letter upon purchase, users will automatically receive the message you supply below."))
(p (text "If you disable automail, you'll be required to manually mail users who have purchased your product before the transfer is finalized."))
(text "{% set is_automail = product.method != \"ManualMail\" and product.method != \"ProfileStyle\" %}")
(label
("for" "use_automail")
("class" "flex items_center gap_2")
(input
("type" "checkbox")
("id" "use_automail")
("name" "use_automail")
("class" "w_content")
("oninput" "mirror_use_automail()")
("checked" "{% if product.method != \"ManualMail\" -%} true {%- else -%} false {%- endif %}"))
(span
(str (text "economy:label.use_automail"))))
(div
("class" "flex flex_col gap_1")
(label
("for" "automail_message")
(str (text "economy:label.automail_message")))
(textarea
("name" "automail_message")
("id" "automail_message")
("placeholder" "automail_message")
(text "{% if product.method != \"ManualMail\" -%} {{ product.method.AutoMail }} {%- endif %}")))
(button (str (text "general:action.save")))))
("for" "use_automail")
("class" "flex items_center gap_2")
(input
("type" "checkbox")
("id" "use_automail")
("name" "use_automail")
("class" "w_content")
("oninput" "mirror_use_automail()")
("checked" "{% if is_automail -%} true {%- else -%} false {%- endif %}"))
(span
(str (text "economy:label.use_automail"))))
(div
("class" "flex flex_col gap_1")
(label
("for" "automail_message")
(str (text "economy:label.automail_message")))
(textarea
("name" "automail_message")
("id" "automail_message")
("placeholder" "automail_message")
(text "{% if is_automail -%} {{ product.method.AutoMail }} {%- endif %}")))
(button (str (text "general:action.save"))))
(form
("class" "flex flex_col gap_2 hidden")
("id" "snippet_fulfillment")
("onsubmit" "update_data_from_form(event)")
(text "{{ components::supporter_ad(body=\"Become a supporter to create snippets!\") }}")
(div
("class" "flex flex_col gap_1")
(label
("for" "data")
(str (text "economy:label.snippet_data")))
(textarea
("name" "data")
("id" "data")
("placeholder" "data")
(text "{{ product.data }}")))
(button (str (text "general:action.save"))))))
(div
("class" "flex gap_2")
@ -347,6 +372,28 @@
});
}
async function update_data_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"products::update\"]);
fetch(\"/api/v1/products/{{ product.id }}/data\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
data: e.target.data.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
async function delete_product() {
if (
!(await trigger(\"atto::confirm\", [
@ -378,7 +425,46 @@
}
}
globalThis.mirror_fulfillment_style_select = (send = false) => {
const selected = document.getElementById(\"fulfillment_style_select\").selectedOptions[0].value;
if (selected === \"mail\") {
document.getElementById(\"mail_fulfillment\").classList.remove(\"hidden\");
document.getElementById(\"snippet_fulfillment\").classList.add(\"hidden\");
if (send) {
update_method_from_form({
preventDefault: () => {},
target: document.getElementById(\"mail_fulfillment\"),
});
}
} else {
document.getElementById(\"mail_fulfillment\").classList.add(\"hidden\");
document.getElementById(\"snippet_fulfillment\").classList.remove(\"hidden\");
if (send) {
fetch(\"/api/v1/products/{{ product.id }}/method\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
method: \"ProfileStyle\",
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
}
}
setTimeout(() => {
mirror_use_automail();
mirror_fulfillment_style_select();
}, 150);"))
(text "{% endblock %}")

View file

@ -19,26 +19,44 @@
("class" "card lowered w_full no_p_margin")
(text "{{ product.description|markdown|safe }}"))
(text "{% if already_purchased -%}")
(span
("class" "green flex items_center gap_2")
(icon (text "circle-check"))
(str (text "economy:label.already_purchased")))
(text "{%- endif %}")
(div
("class" "flex gap_2 items_center")
(text "{% if user.id != product.owner -%}")
(text "{% if not already_purchased -%}")
; price
(a
("class" "button camo lowered")
("href" "/wallet")
("target" "_blank")
(icon (text "badge-cent"))
(text "{{ product.price }}"))
(text "{% if user.id != product.owner -%}")
(text "{% if not already_purchased -%}")
; buy button
(button
("onclick" "purchase()")
("disabled" "{{ product.stock == 0 }}")
(icon (text "piggy-bank"))
(str (text "economy:action.buy")))
(text "{% else %}")
(span
("class" "green flex items_center gap_2")
(icon (text "circle-check"))
(str (text "economy:label.already_purchased")))
; profile style snippets
(text "{% if product.method == \"ProfileStyle\" -%} {% if not product.id in applied_configurations_mapped -%}")
(button
("onclick" "apply()")
(icon (text "check"))
(str (text "economy:action.apply")))
(text "{% else %}")
(button
("onclick" "remove()")
(icon (text "x"))
(str (text "economy:action.unapply")))
(text "{%- endif %} {%- endif %}")
; ...
(text "{%- endif %}")
(text "{% else %}")
(a
@ -69,6 +87,59 @@
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
window.location.reload();
}
});
}
async function apply() {
await trigger(\"atto::debounce\", [\"user::update\"]);
fetch(\"/api/v1/auth/user/{{ user.id }}/applied_configuration\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
\"type\": \"StyleSnippet\",
\"id\": \"{{ product.id }}\",
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
window.location.reload();
}
});
}
async function remove() {
await trigger(\"atto::debounce\", [\"user::update\"]);
fetch(\"/api/v1/auth/user/{{ user.id }}/applied_configuration\", {
method: \"DELETE\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
\"id\": \"{{ product.id }}\",
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
window.location.reload();
}
});
}"))
(text "{% endblock %}")

View file

@ -468,6 +468,12 @@
("class" "rhs w_full flex flex_col gap_4")
(text "{% block content %}{% endblock %}")))))
(text "{% if not use_user_theme -%}")
(text "{% for cnf in applied_configurations -%}")
(text "{{ cnf|safe }}")
(text "{%- endfor %}")
(text "{%- endif %}")
(text "{% if not is_self and profile.settings.warning -%}")
(script
(text "setTimeout(() => {

View file

@ -1162,6 +1162,26 @@
("class" "fade")
(text "This represents the site theme shown to users viewing
your profile.")))))
(text "{% if profile.applied_configurations|length > 0 -%}")
(div
("class" "card_nest")
("ui_ident" "applied_configurations")
(div
("class" "card small flex items_center gap_2")
(icon (text "cog"))
(str (text "setttings:label.applied_configurations")))
(div
("class" "card")
(p (text "Products that you have purchased and applied to your profile are displayed below. Snippets are always synced to the product, meaning the owner could update it at any time."))
(ul
(text "{% for cnf in profile.applied_configurations -%}")
(li
(text "{{ cnf[0] }} ")
(a
("href" "/product/{{ cnf[1] }}")
(text "{{ cnf[1] }}")))
(text "{%- endfor %}"))))
(text "{%- endif %}")
(button
("onclick" "save_settings()")
("id" "save_button")
@ -1742,6 +1762,7 @@
\"import_export\",
\"theme_preference\",
\"profile_theme\",
\"applied_configurations\",
]);
ui.generate_settings_ui(

View file

@ -3,10 +3,11 @@ use crate::{
get_user_from_token,
model::{ApiReturn, Error},
routes::api::v1::{
AppendAssociations, AwardAchievement, DeleteUser, DisableTotp, RefreshGrantToken,
UpdateSecondaryUserRole, UpdateUserAwaitingPurchase, UpdateUserBanExpire,
UpdateUserBanReason, UpdateUserInviteCode, UpdateUserIsDeactivated, UpdateUserIsVerified,
UpdateUserPassword, UpdateUserRole, UpdateUserUsername,
AddAppliedConfiguration, AppendAssociations, AwardAchievement, DeleteUser, DisableTotp,
RefreshGrantToken, RemoveAppliedConfiguration, UpdateSecondaryUserRole,
UpdateUserAwaitingPurchase, UpdateUserBanExpire, UpdateUserBanReason, UpdateUserInviteCode,
UpdateUserIsDeactivated, UpdateUserIsVerified, UpdateUserPassword, UpdateUserRole,
UpdateUserUsername,
},
State,
};
@ -24,6 +25,7 @@ use tetratto_core::{
cache::Cache,
model::{
auth::{AchievementName, InviteCode, Token, UserSettings, SELF_SERVE_ACHIEVEMENTS},
economy::CoinTransferMethod,
moderation::AuditLogEntry,
oauth,
permissions::FinePermission,
@ -180,6 +182,106 @@ pub async fn update_user_settings_request(
}
}
/// Add the given applied configuration.
pub async fn add_applied_configuration_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
Json(req): Json<AddAppliedConfiguration>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProfile) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
if user.id != id && !user.permissions.check(FinePermission::MANAGE_USERS) {
return Json(Error::NotAllowed.into());
}
let product_id: usize = match req.id.parse() {
Ok(x) => x,
Err(e) => return Json(Error::MiscError(e.to_string()).into()),
};
let product = match data.get_product_by_id(product_id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
if data
.get_transfer_by_sender_method(user.id, CoinTransferMethod::Purchase(product.id))
.await
.is_err()
{
return Json(Error::NotAllowed.into());
}
// update
user.applied_configurations.push((req.r#type, product.id));
// ...
match data
.update_user_applied_configurations(id, user.applied_configurations)
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "Applied configurations updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
/// Remove the given applied configuration.
pub async fn remove_applied_configuration_request(
jar: CookieJar,
Path(id): Path<usize>,
Extension(data): Extension<State>,
Json(req): Json<RemoveAppliedConfiguration>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProfile) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
if user.id != id && !user.permissions.check(FinePermission::MANAGE_USERS) {
return Json(Error::NotAllowed.into());
}
let product_id: usize = match req.id.parse() {
Ok(x) => x,
Err(e) => return Json(Error::MiscError(e.to_string()).into()),
};
// update
user.applied_configurations.remove(
match user
.applied_configurations
.iter()
.position(|x| x.1 == product_id)
{
Some(x) => x,
None => return Json(Error::GeneralNotFound("configuration".to_string()).into()),
},
);
// ...
match data
.update_user_applied_configurations(id, user.applied_configurations)
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "Applied configurations updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
/// Append associations to the current user.
pub async fn append_associations_request(
jar: CookieJar,

View file

@ -25,7 +25,7 @@ use axum::{
use serde::Deserialize;
use tetratto_core::model::{
apps::{AppDataSelectMode, AppDataSelectQuery, AppQuota, DeveloperPassStorageQuota},
auth::AchievementName,
auth::{AchievementName, AppliedConfigType},
communities::{
CommunityContext, CommunityJoinAccess, CommunityReadAccess, CommunityWriteAccess,
PollOption, PostContext,
@ -333,6 +333,14 @@ pub fn routes() -> Router {
"/auth/user/{id}/settings",
post(auth::profile::update_user_settings_request),
)
.route(
"/auth/user/{id}/applied_configuration",
post(auth::profile::add_applied_configuration_request),
)
.route(
"/auth/user/{id}/applied_configuration",
delete(auth::profile::remove_applied_configuration_request),
)
.route(
"/auth/user/{id}/role",
post(auth::profile::update_user_role_request),
@ -728,6 +736,7 @@ pub fn routes() -> Router {
"/products/{id}/description",
post(products::update_description_request),
)
.route("/products/{id}/data", post(products::update_data_request))
.route(
"/products/{id}/on_sale",
post(products::update_on_sale_request),
@ -1274,6 +1283,11 @@ pub struct UpdateProductDescription {
pub description: String,
}
#[derive(Deserialize)]
pub struct UpdateProductData {
pub data: String,
}
#[derive(Deserialize)]
pub struct UpdateProductOnSale {
pub on_sale: bool,
@ -1298,3 +1312,14 @@ pub struct UpdateProductMethod {
pub struct UpdateProductStock {
pub stock: i32,
}
#[derive(Deserialize)]
pub struct AddAppliedConfiguration {
pub r#type: AppliedConfigType,
pub id: String,
}
#[derive(Deserialize)]
pub struct RemoveAppliedConfiguration {
pub id: String,
}

View file

@ -1,9 +1,15 @@
use crate::{get_user_from_token, State, cookie::CookieJar};
use axum::{extract::Path, response::IntoResponse, Extension, Json};
use tetratto_core::model::{economy::Product, oauth, ApiReturn, Error};
use tetratto_core::model::{
economy::{Product, ProductFulfillmentMethod},
oauth,
permissions::FinePermission,
ApiReturn, Error,
};
use super::{
CreateProduct, UpdateProductDescription, UpdateProductMethod, UpdateProductOnSale,
UpdateProductPrice, UpdateProductSingleUse, UpdateProductStock, UpdateProductTitle,
CreateProduct, UpdateProductData, UpdateProductDescription, UpdateProductMethod,
UpdateProductOnSale, UpdateProductPrice, UpdateProductSingleUse, UpdateProductStock,
UpdateProductTitle,
};
pub async fn create_request(
@ -112,6 +118,33 @@ pub async fn update_description_request(
}
}
pub async fn update_data_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(mut req): Json<UpdateProductData>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
req.data = req.data.trim().to_string();
if req.data.len() > 16384 {
return Json(Error::DataTooLong("data".to_string()).into());
}
match data.update_product_data(id, &user, &req.data).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Product updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}
pub async fn update_on_sale_request(
jar: CookieJar,
Extension(data): Extension<State>,
@ -205,6 +238,12 @@ pub async fn update_method_request(
None => return Json(Error::NotAllowed.into()),
};
if req.method == ProductFulfillmentMethod::ProfileStyle
&& !user.permissions.check(FinePermission::SUPPORTER)
{
return Json(Error::RequiresSupporter.into());
}
match data.update_product_method(id, &user, req.method).await {
Ok(_) => Json(ApiReturn {
ok: true,

View file

@ -147,12 +147,19 @@ pub async fn product_request(
false
};
let applied_configurations_mapped: Vec<usize> =
user.applied_configurations.iter().map(|x| x.1).collect();
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
context.insert("product", &product);
context.insert("owner", &owner);
context.insert("already_purchased", &already_purchased);
context.insert(
"applied_configurations_mapped",
&applied_configurations_mapped,
);
// return
Ok(Html(

View file

@ -232,6 +232,7 @@ pub fn profile_context(
user: &Option<User>,
profile: &User,
communities: &Vec<Community>,
applied_configurations: Vec<String>,
is_self: bool,
is_following: bool,
is_following_you: bool,
@ -244,6 +245,7 @@ pub fn profile_context(
context.insert("is_following_you", &is_following_you);
context.insert("is_blocking", &is_blocking);
context.insert("warning_hash", &hash(profile.settings.warning.clone()));
context.insert("applied_configurations", &applied_configurations);
context.insert(
"is_supporter",
@ -376,6 +378,10 @@ pub async fn posts_request(
&user,
&other_user,
&communities,
match data.0.get_applied_configurations(&other_user).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
},
is_self,
is_following,
is_following_you,
@ -492,6 +498,10 @@ pub async fn replies_request(
&user,
&other_user,
&communities,
match data.0.get_applied_configurations(&other_user).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
},
is_self,
is_following,
is_following_you,
@ -604,6 +614,10 @@ pub async fn media_request(
&user,
&other_user,
&communities,
match data.0.get_applied_configurations(&other_user).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
},
is_self,
is_following,
is_following_you,
@ -690,9 +704,13 @@ pub async fn shop_request(
context.insert("page", &props.page);
profile_context(
&mut context,
&Some(user),
&Some(user.clone()),
&other_user,
&communities,
match data.0.get_applied_configurations(&other_user).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
},
is_self,
is_following,
is_following_you,
@ -784,9 +802,13 @@ pub async fn outbox_request(
context.insert("page", &props.page);
profile_context(
&mut context,
&Some(user),
&Some(user.clone()),
&other_user,
&communities,
match data.0.get_applied_configurations(&other_user).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
},
is_self,
is_following,
is_following_you,
@ -896,6 +918,10 @@ pub async fn following_request(
&user,
&other_user,
&communities,
match data.0.get_applied_configurations(&other_user).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
},
is_self,
is_following,
is_following_you,
@ -1005,6 +1031,10 @@ pub async fn followers_request(
&user,
&other_user,
&communities,
match data.0.get_applied_configurations(&other_user).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
},
is_self,
is_following,
is_following_you,