add: products ui

This commit is contained in:
trisua 2025-08-08 02:17:06 -04:00
parent 8f76578f1b
commit fd529d3847
31 changed files with 1041 additions and 49 deletions

4
Cargo.lock generated
View file

@ -3318,7 +3318,7 @@ dependencies = [
[[package]] [[package]]
name = "tetratto" name = "tetratto"
version = "14.0.0" version = "15.0.0"
dependencies = [ dependencies = [
"ammonia", "ammonia",
"async-stripe", "async-stripe",
@ -3350,7 +3350,7 @@ dependencies = [
[[package]] [[package]]
name = "tetratto-core" name = "tetratto-core"
version = "14.0.0" version = "15.0.0"
dependencies = [ dependencies = [
"async-recursion", "async-recursion",
"base16ct", "base16ct",

View file

@ -1,6 +1,6 @@
[package] [package]
name = "tetratto" name = "tetratto"
version = "14.0.0" version = "15.0.0"
edition = "2024" edition = "2024"
authors.workspace = true authors.workspace = true
repository.workspace = true repository.workspace = true

View file

@ -69,6 +69,7 @@ pub const PROFILE_REPLIES: &str = include_str!("./public/html/profile/replies.li
pub const PROFILE_MEDIA: &str = include_str!("./public/html/profile/media.lisp"); pub const PROFILE_MEDIA: &str = include_str!("./public/html/profile/media.lisp");
pub const PROFILE_OUTBOX: &str = include_str!("./public/html/profile/outbox.lisp"); pub const PROFILE_OUTBOX: &str = include_str!("./public/html/profile/outbox.lisp");
pub const PROFILE_RESPONSES: &str = include_str!("./public/html/profile/responses.lisp"); pub const PROFILE_RESPONSES: &str = include_str!("./public/html/profile/responses.lisp");
pub const PROFILE_SHOP: &str = include_str!("./public/html/profile/shop.lisp");
pub const COMMUNITIES_LIST: &str = include_str!("./public/html/communities/list.lisp"); pub const COMMUNITIES_LIST: &str = include_str!("./public/html/communities/list.lisp");
pub const COMMUNITIES_BASE: &str = include_str!("./public/html/communities/base.lisp"); pub const COMMUNITIES_BASE: &str = include_str!("./public/html/communities/base.lisp");
@ -148,6 +149,9 @@ pub const MAIL_COMPOSE: &str = include_str!("./public/html/mail/compose.lisp");
pub const MAIL_LETTER: &str = include_str!("./public/html/mail/letter.lisp"); pub const MAIL_LETTER: &str = include_str!("./public/html/mail/letter.lisp");
pub const ECONOMY_WALLET: &str = include_str!("./public/html/economy/wallet.lisp"); pub const ECONOMY_WALLET: &str = include_str!("./public/html/economy/wallet.lisp");
pub const ECONOMY_PRODUCTS: &str = include_str!("./public/html/economy/products.lisp");
pub const ECONOMY_EDIT: &str = include_str!("./public/html/economy/edit.lisp");
pub const ECONOMY_PRODUCT: &str = include_str!("./public/html/economy/product.lisp");
// langs // langs
pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml"); pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml");
@ -310,6 +314,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
write_template!(html_path->"profile/media.html"(crate::assets::PROFILE_MEDIA) --config=config --lisp plugins); write_template!(html_path->"profile/media.html"(crate::assets::PROFILE_MEDIA) --config=config --lisp plugins);
write_template!(html_path->"profile/outbox.html"(crate::assets::PROFILE_OUTBOX) --config=config --lisp plugins); write_template!(html_path->"profile/outbox.html"(crate::assets::PROFILE_OUTBOX) --config=config --lisp plugins);
write_template!(html_path->"profile/responses.html"(crate::assets::PROFILE_RESPONSES) --config=config --lisp plugins); write_template!(html_path->"profile/responses.html"(crate::assets::PROFILE_RESPONSES) --config=config --lisp plugins);
write_template!(html_path->"profile/shop.html"(crate::assets::PROFILE_SHOP) --config=config --lisp plugins);
write_template!(html_path->"communities/list.html"(crate::assets::COMMUNITIES_LIST) -d "communities" --config=config --lisp plugins); write_template!(html_path->"communities/list.html"(crate::assets::COMMUNITIES_LIST) -d "communities" --config=config --lisp plugins);
write_template!(html_path->"communities/base.html"(crate::assets::COMMUNITIES_BASE) --config=config --lisp plugins); write_template!(html_path->"communities/base.html"(crate::assets::COMMUNITIES_BASE) --config=config --lisp plugins);
@ -382,6 +387,9 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
write_template!(html_path->"mail/letter.html"(crate::assets::MAIL_LETTER) --config=config --lisp plugins); write_template!(html_path->"mail/letter.html"(crate::assets::MAIL_LETTER) --config=config --lisp plugins);
write_template!(html_path->"economy/wallet.html"(crate::assets::ECONOMY_WALLET) -d "economy" --config=config --lisp plugins); write_template!(html_path->"economy/wallet.html"(crate::assets::ECONOMY_WALLET) -d "economy" --config=config --lisp plugins);
write_template!(html_path->"economy/products.html"(crate::assets::ECONOMY_PRODUCTS) --config=config --lisp plugins);
write_template!(html_path->"economy/edit.html"(crate::assets::ECONOMY_EDIT) --config=config --lisp plugins);
write_template!(html_path->"economy/product.html"(crate::assets::ECONOMY_PRODUCT) --config=config --lisp plugins);
html_path html_path
} }

View file

@ -88,6 +88,7 @@ version = "1.0.0"
"auth:label.replies" = "Replies" "auth:label.replies" = "Replies"
"auth:label.media" = "Media" "auth:label.media" = "Media"
"auth:label.outbox" = "Outbox" "auth:label.outbox" = "Outbox"
"auth:label.shop" = "Shop"
"auth:label.before_you_view" = "Before you view" "auth:label.before_you_view" = "Before you view"
"auth:label.private_profile" = "Private profile" "auth:label.private_profile" = "Private profile"
"auth:label.private_profile_message" = "This profile is private, meaning you can only view it if they follow you." "auth:label.private_profile_message" = "This profile is private, meaning you can only view it if they follow you."
@ -227,6 +228,7 @@ version = "1.0.0"
"requests:label.user_follow_request" = "User follow request" "requests:label.user_follow_request" = "User follow request"
"requests:action.view_profile" = "View profile" "requests:action.view_profile" = "View profile"
"requests:label.user_follow_request_message" = "Accepting this request will not allow them to see your profile. For that, you must follow them back." "requests:label.user_follow_request_message" = "Accepting this request will not allow them to see your profile. For that, you must follow them back."
"requests:label.coin_transfer_request" = "Coin transfer request"
"chats:label.my_chats" = "My chats" "chats:label.my_chats" = "My chats"
"chats:action.move" = "Move" "chats:action.move" = "Move"
@ -327,3 +329,17 @@ version = "1.0.0"
"mail:action.send_mail" = "Send mail" "mail:action.send_mail" = "Send mail"
"economy:label.recent_transfers" = "Recent transfers" "economy:label.recent_transfers" = "Recent transfers"
"economy:action.request" = "Request"
"economy:label.title" = "Title"
"economy:label.description" = "Description"
"economy:label.my_products" = "My products"
"economy:label.my_wallet" = "My wallet"
"economy:label.create_new" = "Create new product"
"economy:label.price" = "Price"
"economy:label.on_sale" = "On sale"
"economy:label.stock" = "Stock"
"economy:label.unlimited" = "Unlimited"
"economy:label.fulfillment_style" = "Fulfillment style"
"economy:label.use_automail" = "Use automail"
"economy:label.automail_message" = "Automail message"
"economy:action.buy" = "Buy"

View file

@ -483,6 +483,13 @@ select:focus {
border-color: var(--color-super-lowered); border-color: var(--color-super-lowered);
} }
input:disabled,
textarea:disabled,
select:disabled {
opacity: 50%;
cursor: not-allowed;
}
.poll_bar { .poll_bar {
background-color: var(--color-primary); background-color: var(--color-primary);
border-radius: var(--radius); border-radius: var(--radius);

View file

@ -2681,3 +2681,23 @@
(td (text "{{ ((post.likes + 1) / (post.dislikes + 1))|round(method=\"ceil\", precision=2) }}")) (td (text "{{ ((post.likes + 1) / (post.dislikes + 1))|round(method=\"ceil\", precision=2) }}"))
(td (span ("class" "date short") (text "{{ post.created }}")))) (td (span ("class" "date short") (text "{{ post.created }}"))))
(text "{%- endmacro %}") (text "{%- endmacro %}")
(text "{% macro product_listing_card(product, owner=false, edit=false) -%}")
(a
("class" "card button lowered w_full flex flex_col gap_2")
("href" "/product/{{ product.id }}{% if edit -%} /edit {%- endif %}")
(text "{% if owner -%}")
(text "{{ self::full_username(user=owner) }}")
(text "{%- endif %}")
(h3
("class" "flex gap_2 items_center {% if not product.on_sale -%} fade {%- endif %}")
("style" "height: 24px; text-decoration: {% if not product.on_sale -%} line-through {%- else -%} none {%- endif %}")
(icon (text "package"))
(text "{{ product.title }}"))
(h4
("class" "flex gap_2 items_center")
("style" "height: 18px")
(icon (text "badge-cent"))
(text "{{ product.price }}")))
(text "{%- endmacro %}")

View file

@ -0,0 +1,322 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Manage product - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"\") }}")
(main
("class" "flex flex_col gap_2")
(div
("class" "card_nest")
(div
("class" "card small flex gap_2 items_center")
(icon (text "pencil-line"))
(b
(str (text "economy:label.title"))))
(form
("class" "card flex flex_col gap_2")
("onsubmit" "update_title_from_form(event)")
(div
("class" "flex flex_col gap_1")
(label
("for" "title")
(str (text "economy:label.title")))
(input
("type" "text")
("name" "title")
("id" "title")
("placeholder" "title")
("required" "")
("minlength" "2")
("maxlength" "128")
("value" "{{ product.title }}")))
(button (str (text "general:action.save")))))
(div
("class" "card_nest")
(div
("class" "card small flex gap_2 items_center")
(icon (text "pencil-line"))
(b
(str (text "economy:label.description"))))
(form
("class" "card flex flex_col gap_2")
("onsubmit" "update_description_from_form(event)")
(div
("class" "flex flex_col gap_1")
(label
("for" "description")
(str (text "economy:label.description")))
(textarea
("name" "description")
("id" "description")
("placeholder" "description")
("required" "")
("minlength" "2")
("maxlength" "1024")
(text "{{ product.description }}")))
(button (str (text "general:action.save")))))
(div
("class" "card_nest")
(div
("class" "card small flex gap_2 items_center")
(icon (text "badge-cent"))
(b
(str (text "economy:label.price"))))
(form
("class" "card flex flex_col gap_2")
("onsubmit" "update_price_from_form(event)")
(label
("for" "on_sale")
("class" "flex items_center gap_2")
(input
("type" "checkbox")
("id" "on_sale")
("name" "on_sale")
("class" "w_content")
("checked" "{{ product.on_sale }}")
("oninput" "event.preventDefault(); update_on_sale_from_form(event.target.checked)"))
(span
(str (text "economy:label.on_sale"))))
(div
("class" "flex flex_col gap_1")
(label
("for" "title")
(str (text "economy:label.price")))
(input
("type" "number")
("name" "price")
("id" "price")
("placeholder" "price")
("required" "")
("min" "0")
("max" "1000000")
("value" "{{ product.price }}")))
(button (str (text "general:action.save")))))
(div
("class" "card_nest")
(div
("class" "card small flex gap_2 items_center")
(icon (text "weight"))
(b
(str (text "economy:label.stock"))))
(form
("class" "card flex flex_col gap_2")
("onsubmit" "update_stock_from_form(event)")
(label
("for" "unlimited")
("class" "flex items_center gap_2")
(input
("type" "checkbox")
("id" "unlimited")
("name" "unlimited")
("class" "w_content")
("checked" "{{ product.stock == -1 }}")
("oninput" "event.preventDefault(); event.target.checked ? document.getElementById('stock').value = -1 : document.getElementById('stock').value = 0"))
(span
(str (text "economy:label.unlimited"))))
(div
("class" "flex flex_col gap_1")
(label
("for" "title")
(str (text "economy:label.stock")))
(input
("type" "number")
("name" "stock")
("id" "stock")
("placeholder" "stock")
("required" "")
("min" "-1")
("max" "1000000")
("value" "{{ product.stock }}")))
(button (str (text "general:action.save")))))
(div
("class" "card_nest")
(div
("class" "card small flex gap_2 items_center")
(icon (text "package-check"))
(b
(str (text "economy:label.fulfillment_style"))))
(form
("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."))
(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")))))
(a
("class" "button secondary")
("href" "/product/{{ product.id }}")
(icon (text "arrow-left"))
(str (text "general:action.back"))))
(script
(text "async function update_title_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"products::update\"]);
fetch(\"/api/v1/products/{{ product.id }}/title\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
title: e.target.title.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
async function update_description_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"products::update\"]);
fetch(\"/api/v1/products/{{ product.id }}/description\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
description: e.target.description.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
async function update_on_sale_from_form(on_sale) {
await trigger(\"atto::debounce\", [\"products::update\"]);
fetch(\"/api/v1/products/{{ product.id }}/on_sale\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
on_sale,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
async function update_price_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"products::update\"]);
fetch(\"/api/v1/products/{{ product.id }}/price\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
price: e.target.price.valueAsNumber,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
async function update_stock_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"products::update\"]);
fetch(\"/api/v1/products/{{ product.id }}/stock\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
stock: e.target.stock.valueAsNumber,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
async function update_method_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"products::update\"]);
fetch(\"/api/v1/products/{{ product.id }}/method\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
method: e.target.use_automail.checked ? { AutoMail: e.target.automail_message.value } : \"ManualMail\",
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
globalThis.mirror_use_automail = () => {
const use_automail = document.getElementById(\"use_automail\").checked;
if (use_automail) {
document.getElementById(\"automail_message\").removeAttribute(\"disabled\");
} else {
document.getElementById(\"automail_message\").setAttribute(\"disabled\", \"true\");
}
}
setTimeout(() => {
mirror_use_automail();
}, 150);"))
(text "{% endblock %}")

View file

@ -0,0 +1,67 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "{{ product.title }} - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"\") }}")
(main
("class" "flex flex_col gap_2")
(div
("class" "card flex flex_col gap_2")
(h3
("style" "height: 32px")
(text "{{ product.title }}"))
(text "{{ components::full_username(user=owner) }}")
(text "{% if product.stock >= 0 -%}")
(span ("class" "red") (text "{{ product.stock }} remaining"))
(text "{%- endif %}")
(div
("class" "card lowered w_full no_p_margin")
(text "{{ product.description|markdown|safe }}"))
(div
("class" "flex gap_2 items_center")
(a
("class" "button camo lowered")
("href" "/wallet")
("target" "_blank")
(icon (text "badge-cent"))
(text "{{ product.price }}"))
(text "{% if user.id != product.owner -%}")
(button
("onclick" "purchase()")
("disabled" "{{ product.stock == 0 }}")
(icon (text "piggy-bank"))
(str (text "economy:action.buy")))
(text "{% else %}")
(a
("class" "button")
("href" "/product/{{ product.id }}/edit")
(icon (text "settings"))
(str (text "general:label.edit")))
(text "{%- endif %}"))))
(script
(text "async function purchase() {
await trigger(\"atto::debounce\", [\"products::buy\"]);
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this? Your new balance will be {{ user.coins - product.price }} coins.\",
]))
) {
return;
}
fetch(\"/api/v1/products/{{ product.id }}/buy\", {
method: \"POST\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}"))
(text "{% endblock %}")

View file

@ -0,0 +1,91 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "My products - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"products\") }}")
(main
("class" "flex flex_col gap_2")
; create new
(text "{{ components::supporter_ad(body=\"Become a supporter to create unlimited products!\") }}")
(div
("class" "card_nest")
(div
("class" "card small")
(b
(str (text "economy:label.create_new"))))
(form
("class" "card flex flex_col gap_2")
("onsubmit" "create_product_from_form(event)")
(div
("class" "flex flex_col gap_1")
(label
("for" "title")
(str (text "economy:label.title")))
(input
("type" "text")
("name" "title")
("id" "title")
("placeholder" "title")
("required" "")
("minlength" "2")
("maxlength" "128")))
(div
("class" "flex flex_col gap_1")
(label
("for" "description")
(str (text "economy:label.description")))
(textarea
("name" "description")
("id" "description")
("placeholder" "description")
("required" "")
("minlength" "2")
("maxlength" "1024")))
(button
(text "{{ text \"communities:action.create\" }}"))))
; product listing
(div
("class" "card_nest")
(div
("class" "card small flex items_center gap_2")
(icon (text "store"))
(str (text "economy:label.my_products")))
(div
("class" "card flex flex_col gap_2")
(text "{% for item in list %} {{ components::product_listing_card(product=item, edit=true) }} {% endfor %}")
(text "{{ components::pagination(page=page, items=list|length) }}"))))
(script
(text "async function create_product_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"products::create\"]);
fetch(\"/api/v1/products\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
title: e.target.title.value,
description: e.target.description.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
e.target.reset();
setTimeout(() => {
window.location.href = `/product/${res.payload}/edit`;
}, 100);
}
});
}"))
(text "{% endblock %}")

View file

@ -51,9 +51,10 @@
(td ("class" "w_content") (text "{{ components::full_username(user=transfer[3]) }}")) (td ("class" "w_content") (text "{{ components::full_username(user=transfer[3]) }}"))
(td ("class" "w_content") (text "{{ components::full_username(user=transfer[4]) }}")) (td ("class" "w_content") (text "{{ components::full_username(user=transfer[4]) }}"))
(td (td
("class" "flex items_center gap_1")
(text "{{ transfer[2] }}") (text "{{ transfer[2] }}")
(text "{% if transfer[6] -%}") (text "{% if transfer[6] -%}")
(span ("title" "Pending") (icon (text "clock"))) (span ("class" "flex items_center gap_1") ("title" "Pending") (icon (text "clock")))
(text "{%- endif %}")) (text "{%- endif %}"))
(td (td
(text "{% if transfer[5] -%}") (text "{% if transfer[5] -%}")

View file

@ -73,14 +73,20 @@
("class" "inner") ("class" "inner")
(a (a
("href" "/chats/0/0") ("href" "/chats/0/0")
("title" "Chats")
(icon (text "message-circle")) (icon (text "message-circle"))
(str (text "communities:label.chats"))) (str (text "communities:label.chats")))
(a (a
("href" "/mail") ("href" "/mail")
("title" "Mail")
(icon (text "mail")) (icon (text "mail"))
(str (text "general:link.mail"))) (str (text "general:link.mail")))
(a
("href" "/wallet")
(icon (text "piggy-bank"))
(str (text "economy:label.my_wallet")))
(a
("href" "/products")
(icon (text "store"))
(str (text "economy:label.my_products")))
(a (a
("href" "/journals/0/0") ("href" "/journals/0/0")
(icon (text "notebook")) (icon (text "notebook"))
@ -318,6 +324,13 @@
("class" "{% if selected == 'media' -%}active{%- endif %}") ("class" "{% if selected == 'media' -%}active{%- endif %}")
(str (text "auth:label.media"))) (str (text "auth:label.media")))
(text "{% if user and profile.settings.enable_shop -%}")
(a
("href" "/@{{ profile.username }}/shop")
("class" "{% if selected == 'shop' -%}active{%- endif %}")
(str (text "auth:label.shop")))
(text "{%- endif %}")
(text "{% if is_self or is_helper -%}") (text "{% if is_self or is_helper -%}")
(a (a
("href" "/@{{ profile.username }}/outbox") ("href" "/@{{ profile.username }}/outbox")

View file

@ -112,6 +112,7 @@
subject: e.target.subject.value.trim(), subject: e.target.subject.value.trim(),
receivers: RECEIVERS, receivers: RECEIVERS,
replying_to: SEARCH_PARAMS.get(\"replying_to\") || \"0\", replying_to: SEARCH_PARAMS.get(\"replying_to\") || \"0\",
transfer_id: SEARCH_PARAMS.get(\"transfer_id\") || \"0\",
}), }),
}) })
.then((res) => res.json()) .then((res) => res.json())

View file

@ -92,6 +92,37 @@
(text "{{ icon \"trash\" }}") (text "{{ icon \"trash\" }}")
(span (span
(text "{{ text \"general:action.delete\" }}")))))) (text "{{ text \"general:action.delete\" }}"))))))
(text "{% elif request.action_type == \"Transfer\" %}")
(div
("class" "card_nest")
(div
("class" "card small flex items_center gap_2")
(icon (text "piggy-bank"))
(span
(str (text "requests:label.coin_transfer_request"))))
(div
("class" "card flex flex_col gap_2")
(span (a ("href" "/api/v1/auth/user/find/{{ request.linked_asset }}") (text "Somebody")) (text " is asking for a transfer of ") (b (text "{{ request.data.Int32 }} coins")) (text "."))
(div
("class" "card flex flex_wrap w_full secondary gap_2")
(a
("href" "/api/v1/auth/user/find/{{ request.linked_asset }}")
("class" "button")
(text "{{ icon \"external-link\" }}")
(span
(text "{{ text \"requests:action.view_profile\" }}")))
(button
("class" "lowered green")
("onclick" "accept_transfer_request(event, '{{ request.id }}', '{{ request.linked_asset }}', {{ request.data.Int32 }})")
(text "{{ icon \"check\" }}")
(span
(text "{{ text \"general:action.accept\" }}")))
(button
("class" "lowered red")
("onclick" "remove_request('{{ request.id }}', '{{ request.linked_asset }}')")
(text "{{ icon \"trash\" }}")
(span
(text "{{ text \"general:action.delete\" }}"))))))
(text "{%- endif %} {% endfor %} {% for question in questions %}") (text "{%- endif %} {% endfor %} {% for question in questions %}")
(div (div
("class" "card_nest") ("class" "card_nest")
@ -138,7 +169,8 @@
(text "{{ components::pagination(page=page, items=requests|length, key=\"&id=\", value=profile.id) }}")) (text "{{ components::pagination(page=page, items=requests|length, key=\"&id=\", value=profile.id) }}"))
(script (script
(text "async function remove_request(id, linked_asset) { (text "async function remove_request(id, linked_asset, confirm = true) {
if (confirm) {
if ( if (
!(await trigger(\"atto::confirm\", [ !(await trigger(\"atto::confirm\", [
\"Are you sure you want to do this?\", \"Are you sure you want to do this?\",
@ -146,6 +178,7 @@
) { ) {
return; return;
} }
}
fetch(`/api/v1/requests/${id}/${linked_asset}`, { fetch(`/api/v1/requests/${id}/${linked_asset}`, {
method: \"DELETE\", method: \"DELETE\",
@ -275,6 +308,41 @@
} }
} }
}); });
};
globalThis.accept_transfer_request = async (e, id, receiver, amount) => {
await trigger(\"atto::debounce\", [\"economy::transfer\"]);
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(`/api/v1/transfers`, {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
receiver,
amount,
}),
})
.then((res) => res.json())
.then(async (res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
e.target.parentElement.parentElement.parentElement.parentElement.remove();
remove_request(id, receiver, false);
}
});
};")) };"))
(text "{% endblock %}") (text "{% endblock %}")

View file

@ -256,6 +256,13 @@
(icon (text "mail-plus")) (icon (text "mail-plus"))
(span (span
(str (text "mail:action.send_mail")))) (str (text "mail:action.send_mail"))))
(text "{%- endif %} {% if not profile.settings.no_transfers -%}")
(button
("onclick" "request_transfer()")
("class" "lowered")
(icon (text "badge-cent"))
(span
(str (text "economy:action.request"))))
(text "{%- endif %} {% if is_helper -%}") (text "{%- endif %} {% if is_helper -%}")
(a (a
("href" "/mod_panel/profile/{{ profile.id }}") ("href" "/mod_panel/profile/{{ profile.id }}")
@ -289,6 +296,41 @@
}); });
}; };
globalThis.request_transfer = async () => {
await trigger(\"atto::debounce\", [\"economy::transfer\"]);
const amount = Number.parseInt((await trigger(\"atto::prompt\", [\"Request amount:\"])) || \"\");
if (amount === 0) {
return;
}
if (
!(await trigger(\"atto::confirm\", [
`Are you sure you would like to request ${amount} coins from {{ profile.username }}?`,
]))
) {
return;
}
fetch(`/api/v1/transfers/ask`, {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
receiver: \"{{ profile.id }}\",
amount,
}),
})
.then((res) => res.json())
.then(async (res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
globalThis.toggle_follow_user = async (e) => { globalThis.toggle_follow_user = async (e) => {
await trigger(\"atto::debounce\", [ await trigger(\"atto::debounce\", [
\"users::follow\", \"users::follow\",

View file

@ -1958,6 +1958,23 @@
settings.forum_signature, settings.forum_signature,
\"textarea\", \"textarea\",
], ],
[[], \"Economy\", \"title\"],
[
[
\"enable_shop\",
\"Show shop tab on my profile\",
],
\"{{ profile.settings.enable_shop }}\",
\"checkbox\",
],
[
[
\"no_transfers\",
\"Disable transfer requests\",
],
\"{{ profile.settings.no_transfers }}\",
\"checkbox\",
],
[[], \"Misc\", \"title\"], [[], \"Misc\", \"title\"],
[ [
[\"hide_dislikes\", \"Hide post dislikes\"], [\"hide_dislikes\", \"Hide post dislikes\"],

View file

@ -0,0 +1,17 @@
(text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
(div
("style" "display: contents")
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings, allow_anonymous=profile.settings.allow_anonymous_questions) }}"))
(text "{%- endif %}")
(text "{{ macros::profile_nav(selected=\"shop\") }}")
(div
("class" "card_nest")
(div
("class" "card small flex gap_2 items_center")
(icon (text "store"))
(str (text "auth:label.shop")))
(div
("class" "card w_full flex flex_col gap_2")
(text "{% for item in list %} {{ components::product_listing_card(product=item) }} {% endfor %}")
(text "{{ components::pagination(page=page, items=list|length) }}")))
(text "{% endblock %}")

View file

@ -85,6 +85,10 @@ media_theme_pref();
element.removeAttribute("checked"); element.removeAttribute("checked");
} }
for (const element of document.querySelectorAll('[disabled="false"]')) {
element.removeAttribute("disabled");
}
for (const element of document.querySelectorAll('[selected="true"]')) { for (const element of document.querySelectorAll('[selected="true"]')) {
element.parentElement.value = element.value; element.parentElement.value = element.value;
} }

View file

@ -3,7 +3,7 @@ use axum::{
response::IntoResponse, response::IntoResponse,
Extension, Json, Extension, Json,
}; };
use tetratto_core::model::{auth::Notification, mail::Letter, oauth, ApiReturn, Error}; use tetratto_core::model::{mail::Letter, oauth, ApiReturn, Error};
use crate::{cookie::CookieJar, get_user_from_token, routes::pages::PaginatedQuery, State}; use crate::{cookie::CookieJar, get_user_from_token, routes::pages::PaginatedQuery, State};
use super::CreateLetter; use super::CreateLetter;
@ -170,30 +170,19 @@ pub async fn create_request(
.await .await
{ {
Ok(l) => { Ok(l) => {
// send notifications // check if we're fulfilling a coin transfer
for x in &l.receivers { if !props.transfer_id.is_empty() && props.transfer_id != "0" {
if let Err(e) = data if let Err(e) = data
.create_notification(Notification::new( .apply_transfer(match props.transfer_id.parse() {
"You've got mail!".to_string(), Ok(x) => x,
format!( Err(_) => return Json(Error::Unknown.into()),
"[@{}](/api/v1/auth/user/find/{}) has sent you a [letter](/mail/letter/{}).", })
user.username, user.id, l.id
),
*x,
))
.await .await
{ {
return Json(e.into()); return Json(e.into());
} }
} }
// check if we're fulfilling a coin transfer
if props.transfer_id != 0 {
if let Err(e) = data.apply_transfer(props.transfer_id).await {
return Json(e.into());
}
}
// ... // ...
Json(ApiReturn { Json(ApiReturn {
ok: true, ok: true,

View file

@ -710,6 +710,7 @@ pub fn routes() -> Router {
.route("/letters/received", get(letters::list_received_request)) .route("/letters/received", get(letters::list_received_request))
// transfers // transfers
.route("/transfers", post(transfers::create_request)) .route("/transfers", post(transfers::create_request))
.route("/transfers/ask", post(transfers::ask_request))
// products // products
.route("/products", post(products::create_request)) .route("/products", post(products::create_request))
.route("/products/{id}", delete(products::delete_request)) .route("/products/{id}", delete(products::delete_request))
@ -1236,12 +1237,12 @@ pub struct CreateLetter {
pub content: String, pub content: String,
pub replying_to: String, pub replying_to: String,
#[serde(default)] #[serde(default)]
pub transfer_id: usize, pub transfer_id: String,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct CreateCoinTransfer { pub struct CreateCoinTransfer {
pub receiver: usize, pub receiver: String,
pub amount: i32, pub amount: i32,
} }

View file

@ -149,6 +149,15 @@ pub async fn update_price_request(
None => return Json(Error::NotAllowed.into()), None => return Json(Error::NotAllowed.into()),
}; };
if req.price < 25 {
return Json(
Error::MiscError(
"Price is too low, please a price of use 25 coins or more".to_string(),
)
.into(),
);
}
match data.update_product_price(id, &user, req.price).await { match data.update_product_price(id, &user, req.price).await {
Ok(_) => Json(ApiReturn { Ok(_) => Json(ApiReturn {
ok: true, ok: true,

View file

@ -2,7 +2,9 @@ use crate::{get_user_from_token, State, cookie::CookieJar};
use axum::{response::IntoResponse, Extension, Json}; use axum::{response::IntoResponse, Extension, Json};
use tetratto_core::model::{ use tetratto_core::model::{
economy::{CoinTransfer, CoinTransferMethod}, economy::{CoinTransfer, CoinTransferMethod},
oauth, ApiReturn, Error, oauth,
requests::{ActionData, ActionRequest, ActionType},
ApiReturn, Error,
}; };
use super::CreateCoinTransfer; use super::CreateCoinTransfer;
@ -21,7 +23,10 @@ pub async fn create_request(
.create_transfer( .create_transfer(
&mut CoinTransfer::new( &mut CoinTransfer::new(
user.id, user.id,
req.receiver, match req.receiver.parse() {
Ok(x) => x,
Err(_) => return Json(Error::Unknown.into()),
},
req.amount, req.amount,
CoinTransferMethod::Transfer, // this endpoint is ONLY for regular transfers; products export a buy endpoint for the other method CoinTransferMethod::Transfer, // this endpoint is ONLY for regular transfers; products export a buy endpoint for the other method
), ),
@ -37,3 +42,35 @@ pub async fn create_request(
Err(e) => Json(e.into()), Err(e) => Json(e.into()),
} }
} }
pub async fn ask_request(
jar: CookieJar,
Extension(data): Extension<State>,
Json(req): Json<CreateCoinTransfer>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserSendCoins) {
Some(ua) => ua,
None => return Json(Error::NotAllowed.into()),
};
match data
.create_request(ActionRequest::new(
match req.receiver.parse() {
Ok(x) => x,
Err(_) => return Json(Error::Unknown.into()),
},
ActionType::Transfer,
user.id,
Some(ActionData::Int32(req.amount)),
))
.await
{
Ok(_) => Json(ApiReturn {
ok: true,
message: "Asked user for transfer".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
}
}

View file

@ -43,3 +43,109 @@ pub async fn wallet_request(
data.1.render("economy/wallet.html", &context).unwrap(), data.1.render("economy/wallet.html", &context).unwrap(),
)) ))
} }
/// `/products`
pub async fn products_request(
jar: CookieJar,
Extension(data): Extension<State>,
Query(props): Query<PaginatedQuery>,
) -> 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 list = match data.0.get_products_by_user(user.id, 12, props.page).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
context.insert("list", &list);
context.insert("page", &props.page);
// return
Ok(Html(
data.1.render("economy/products.html", &context).unwrap(),
))
}
/// `/product/{id}/edit`
pub async fn edit_product_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
) -> 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 product = match data.0.get_product_by_id(id).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
if user.id != product.owner {
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("product", &product);
// return
Ok(Html(data.1.render("economy/edit.html", &context).unwrap()))
}
/// `/product/{id}`
pub async fn product_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
) -> 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 product = match data.0.get_product_by_id(id).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
let owner = match data.0.get_user_by_id(product.owner).await {
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
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);
// return
Ok(Html(
data.1.render("economy/product.html", &context).unwrap(),
))
}

View file

@ -85,6 +85,7 @@ pub fn routes() -> Router {
.route("/@{username}/replies", get(profile::replies_request)) .route("/@{username}/replies", get(profile::replies_request))
.route("/@{username}/following", get(profile::following_request)) .route("/@{username}/following", get(profile::following_request))
.route("/@{username}/followers", get(profile::followers_request)) .route("/@{username}/followers", get(profile::followers_request))
.route("/@{username}/shop", get(profile::shop_request))
// communities // communities
.route("/communities", get(communities::list_request)) .route("/communities", get(communities::list_request))
.route("/communities/search", get(communities::search_request)) .route("/communities/search", get(communities::search_request))
@ -162,6 +163,9 @@ pub fn routes() -> Router {
.route("/mail/letter/{id}", get(mail::letter_request)) .route("/mail/letter/{id}", get(mail::letter_request))
// economy // economy
.route("/wallet", get(economy::wallet_request)) .route("/wallet", get(economy::wallet_request))
.route("/products", get(economy::products_request))
.route("/product/{id}/edit", get(economy::edit_product_request))
.route("/product/{id}", get(economy::product_request))
} }
pub fn lw_routes() -> Router { pub fn lw_routes() -> Router {

View file

@ -307,20 +307,20 @@ pub async fn posts_request(
) )
.await .await
{ {
Ok(p) => Some(data.0.posts_muted_phrase_filter( Ok(p) => data.0.posts_muted_phrase_filter(
&p, &p,
if let Some(ref ua) = user { if let Some(ref ua) = user {
Some(&ua.settings.muted) Some(&ua.settings.muted)
} else { } else {
None None
}, },
)), ),
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
}, },
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
} }
} else { } else {
None Vec::new()
}; };
let communities = match data.0.get_memberships_by_owner(other_user.id).await { let communities = match data.0.get_memberships_by_owner(other_user.id).await {
@ -614,6 +614,95 @@ pub async fn media_request(
Ok(Html(data.1.render("profile/media.html", &context).unwrap())) Ok(Html(data.1.render("profile/media.html", &context).unwrap()))
} }
/// `/@{username}/shop`
pub async fn shop_request(
jar: CookieJar,
Path(username): Path<String>,
Query(props): Query<PaginatedQuery>,
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 other_user = match data.0.get_user_by_username(&username).await {
Ok(ua) => ua,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
if !other_user.settings.enable_shop {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &Some(user)).await,
));
}
check_user_blocked_or_private!(Some(user.clone()), other_user, data, jar);
// fetch data
let list = match data
.0
.get_products_by_user(other_user.id, 12, props.page)
.await
{
Ok(x) => x,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
let communities = match data.0.get_memberships_by_owner(other_user.id).await {
Ok(m) => match data.0.fill_communities(m).await {
Ok(m) => m,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
},
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
// init context
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &Some(user.clone())).await;
let is_self = user.id == other_user.id;
let is_following = data
.0
.get_userfollow_by_initiator_receiver(user.id, other_user.id)
.await
.is_ok();
let is_following_you = data
.0
.get_userfollow_by_receiver_initiator(user.id, other_user.id)
.await
.is_ok();
let is_blocking = data
.0
.get_userblock_by_initiator_receiver(user.id, other_user.id)
.await
.is_ok();
context.insert("list", &list);
context.insert("page", &props.page);
profile_context(
&mut context,
&Some(user),
&other_user,
&communities,
is_self,
is_following,
is_following_you,
is_blocking,
);
// return
Ok(Html(data.1.render("profile/shop.html", &context).unwrap()))
}
/// `/@{username}/outbox` /// `/@{username}/outbox`
pub async fn outbox_request( pub async fn outbox_request(
jar: CookieJar, jar: CookieJar,

View file

@ -1,7 +1,7 @@
[package] [package]
name = "tetratto-core" name = "tetratto-core"
description = "The core behind Tetratto" description = "The core behind Tetratto"
version = "14.0.0" version = "15.0.0"
edition = "2024" edition = "2024"
authors.workspace = true authors.workspace = true
repository.workspace = true repository.workspace = true

View file

@ -401,6 +401,7 @@ fn default_banned_usernames() -> Vec<String> {
"mail".to_string(), "mail".to_string(),
"product".to_string(), "product".to_string(),
"wallet".to_string(), "wallet".to_string(),
"products".to_string(),
] ]
} }

View file

@ -1,5 +1,6 @@
use std::collections::HashMap; use std::collections::HashMap;
use crate::model::auth::Notification;
use crate::model::{auth::User, mail::Letter, permissions::SecondaryPermission, Error, Result}; use crate::model::{auth::User, mail::Letter, permissions::SecondaryPermission, Error, Result};
use crate::{auto_method, DataManager}; use crate::{auto_method, DataManager};
use oiseau::{cache::Cache, execute, get, params, query_rows, PostgresRow}; use oiseau::{cache::Cache, execute, get, params, query_rows, PostgresRow};
@ -160,6 +161,9 @@ impl DataManager {
return Err(Error::DataTooLong("receivers".to_string())); return Err(Error::DataTooLong("receivers".to_string()));
} }
// get sender
let sender = self.get_user_by_id(data.owner).await?;
// ... // ...
let conn = match self.0.connect().await { let conn = match self.0.connect().await {
Ok(c) => c, Ok(c) => c,
@ -185,6 +189,20 @@ impl DataManager {
return Err(Error::DatabaseError(e.to_string())); return Err(Error::DatabaseError(e.to_string()));
} }
// send notifications
for x in &data.receivers {
self.create_notification(Notification::new(
"You've got mail!".to_string(),
format!(
"[@{}](/api/v1/auth/user/find/{}) has sent you a [letter](/mail/letter/{}).",
sender.username, sender.id, data.id
),
*x,
))
.await?;
}
// ...
Ok(data) Ok(data)
} }

View file

@ -150,7 +150,7 @@ impl DataManager {
match product.method { match product.method {
ProductFulfillmentMethod::AutoMail(message) => { ProductFulfillmentMethod::AutoMail(message) => {
// we're basically done, transfer coins and send mail // we're basically done, transfer coins and send mail
self.create_transfer(&mut transfer, false).await?; self.create_transfer(&mut transfer, true).await?;
self.create_letter(Letter::new( self.create_letter(Letter::new(
self.0.0.system_user, self.0.0.system_user,
@ -167,6 +167,16 @@ impl DataManager {
// mark transfer as pending and create it // mark transfer as pending and create it
self.create_transfer(&mut transfer, false).await?; self.create_transfer(&mut transfer, false).await?;
// tell the customer to wait
self.create_letter(Letter::new(
self.0.0.system_user,
vec![customer.id],
format!("Thank you for purchasing \"{}\"", product.title),
"This product uses manual mail, meaning you won't be charged until the product owner sends you a letter about the product. You'll see a pending transfer in your wallet.".to_string(),
0,
))
.await?;
// tell product owner they have a new pending purchase // tell product owner they have a new pending purchase
self.create_letter(Letter::new( self.create_letter(Letter::new(
self.0.0.system_user, self.0.0.system_user,
@ -178,7 +188,7 @@ impl DataManager {
If your product is a purchase of goods or services, please be sure to fulfill this purchase either in the letter or elsewhere. The customer may request support if you fail to do so. If your product is a purchase of goods or services, please be sure to fulfill this purchase either in the letter or elsewhere. The customer may request support if you fail to do so.
*** ***
<a class=\"button\" href=\"/mail/compose?receivers=id:{}&title=Product%20fulfillment&transfer_id={}\">Fulfill purchase</a>", <a class=\"button\" href=\"/mail/compose?receivers=id:{}&subject=Product%20fulfillment&transfer_id={}\">Fulfill purchase</a>",
product.id, product.title, customer.id, transfer.id product.id, product.title, customer.id, transfer.id
), ),
0, 0,

View file

@ -1,8 +1,9 @@
use std::collections::HashMap; use std::collections::HashMap;
use crate::model::{
use crate::model::auth::User; Error, Result,
use crate::model::economy::{CoinTransferMethod, Product}; economy::{CoinTransferMethod, Product, CoinTransfer},
use crate::model::{Error, Result, economy::CoinTransfer}; auth::{Notification, User},
};
use crate::{auto_method, DataManager}; use crate::{auto_method, DataManager};
use oiseau::{cache::Cache, execute, get, params, query_rows, PostgresRow}; use oiseau::{cache::Cache, execute, get, params, query_rows, PostgresRow};
@ -168,8 +169,35 @@ impl DataManager {
self.update_user_coins(sender.id, sender.coins).await?; self.update_user_coins(sender.id, sender.coins).await?;
self.update_user_coins(receiver.id, receiver.coins).await?; self.update_user_coins(receiver.id, receiver.coins).await?;
self.update_transfer_is_pending(id, 0).await?; self.update_transfer_is_pending(id, 0).await?;
self.create_notification(Notification::new(
"Purchase fulfilled!".to_string(),
format!(
"You've just successfully fulfilled a purchase for a [product](/product/{}).",
match transfer.method {
CoinTransferMethod::Purchase(x) => x,
_ => 0,
}
),
receiver.id,
))
.await?;
self.create_notification(Notification::new(
"Purchase fulfilled!".to_string(),
format!(
"Your purchase for a [product](/product/{}) has been fulfilled.",
match transfer.method {
CoinTransferMethod::Purchase(x) => x,
_ => 0,
}
),
sender.id,
))
.await?;
Ok(()) Ok(())
} }
auto_method!(update_transfer_is_pending(i32) -> "UPDATE products SET is_pending = $1 WHERE id = $2" --cache-key-tmpl="atto.transfer:{}"); auto_method!(update_transfer_is_pending(i32) -> "UPDATE transfers SET is_pending = $1 WHERE id = $2" --cache-key-tmpl="atto.transfer:{}");
} }

View file

@ -350,6 +350,12 @@ pub struct UserSettings {
/// The signature automatically attached to new forum posts. /// The signature automatically attached to new forum posts.
#[serde(default)] #[serde(default)]
pub forum_signature: String, pub forum_signature: String,
/// If coin transfer requests are disabled.
#[serde(default)]
pub no_transfers: bool,
/// If your profile has the "Shop" tab enabled.
#[serde(default)]
pub enable_shop: bool,
} }
fn mime_avif() -> String { fn mime_avif() -> String {

View file

@ -1,7 +1,7 @@
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp};
#[derive(Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum ActionData { pub enum ActionData {
String(String), String(String),
Int32(i32), Int32(i32),
@ -46,7 +46,7 @@ impl Default for ActionData {
} }
} }
#[derive(Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum ActionType { pub enum ActionType {
/// A request to join a community. /// A request to join a community.
/// ///
@ -66,7 +66,7 @@ pub enum ActionType {
Transfer, Transfer,
} }
#[derive(Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct ActionRequest { pub struct ActionRequest {
pub id: usize, pub id: usize,
pub created: usize, pub created: usize,