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

View file

@ -1,6 +1,6 @@
[package]
name = "tetratto"
version = "14.0.0"
version = "15.0.0"
edition = "2024"
authors.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_OUTBOX: &str = include_str!("./public/html/profile/outbox.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_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 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
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/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/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/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->"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
}

View file

@ -88,6 +88,7 @@ version = "1.0.0"
"auth:label.replies" = "Replies"
"auth:label.media" = "Media"
"auth:label.outbox" = "Outbox"
"auth:label.shop" = "Shop"
"auth:label.before_you_view" = "Before you view"
"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."
@ -227,6 +228,7 @@ version = "1.0.0"
"requests:label.user_follow_request" = "User follow request"
"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.coin_transfer_request" = "Coin transfer request"
"chats:label.my_chats" = "My chats"
"chats:action.move" = "Move"
@ -327,3 +329,17 @@ version = "1.0.0"
"mail:action.send_mail" = "Send mail"
"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);
}
input:disabled,
textarea:disabled,
select:disabled {
opacity: 50%;
cursor: not-allowed;
}
.poll_bar {
background-color: var(--color-primary);
border-radius: var(--radius);

View file

@ -2681,3 +2681,23 @@
(td (text "{{ ((post.likes + 1) / (post.dislikes + 1))|round(method=\"ceil\", precision=2) }}"))
(td (span ("class" "date short") (text "{{ post.created }}"))))
(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[4]) }}"))
(td
("class" "flex items_center gap_1")
(text "{{ transfer[2] }}")
(text "{% if transfer[6] -%}")
(span ("title" "Pending") (icon (text "clock")))
(span ("class" "flex items_center gap_1") ("title" "Pending") (icon (text "clock")))
(text "{%- endif %}"))
(td
(text "{% if transfer[5] -%}")

View file

@ -73,14 +73,20 @@
("class" "inner")
(a
("href" "/chats/0/0")
("title" "Chats")
(icon (text "message-circle"))
(str (text "communities:label.chats")))
(a
("href" "/mail")
("title" "Mail")
(icon (text "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
("href" "/journals/0/0")
(icon (text "notebook"))
@ -318,6 +324,13 @@
("class" "{% if selected == 'media' -%}active{%- endif %}")
(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 -%}")
(a
("href" "/@{{ profile.username }}/outbox")

View file

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

View file

@ -92,6 +92,37 @@
(text "{{ icon \"trash\" }}")
(span
(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 %}")
(div
("class" "card_nest")
@ -138,13 +169,15 @@
(text "{{ components::pagination(page=page, items=requests|length, key=\"&id=\", value=profile.id) }}"))
(script
(text "async function remove_request(id, linked_asset) {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you want to do this?\",
]))
) {
return;
(text "async function remove_request(id, linked_asset, confirm = true) {
if (confirm) {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you want to do this?\",
]))
) {
return;
}
}
fetch(`/api/v1/requests/${id}/${linked_asset}`, {
@ -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 %}")

View file

@ -256,6 +256,13 @@
(icon (text "mail-plus"))
(span
(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 -%}")
(a
("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) => {
await trigger(\"atto::debounce\", [
\"users::follow\",

View file

@ -1958,6 +1958,23 @@
settings.forum_signature,
\"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\"],
[
[\"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");
}
for (const element of document.querySelectorAll('[disabled="false"]')) {
element.removeAttribute("disabled");
}
for (const element of document.querySelectorAll('[selected="true"]')) {
element.parentElement.value = element.value;
}

View file

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

View file

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

View file

@ -149,6 +149,15 @@ pub async fn update_price_request(
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 {
Ok(_) => Json(ApiReturn {
ok: true,

View file

@ -2,7 +2,9 @@ use crate::{get_user_from_token, State, cookie::CookieJar};
use axum::{response::IntoResponse, Extension, Json};
use tetratto_core::model::{
economy::{CoinTransfer, CoinTransferMethod},
oauth, ApiReturn, Error,
oauth,
requests::{ActionData, ActionRequest, ActionType},
ApiReturn, Error,
};
use super::CreateCoinTransfer;
@ -21,7 +23,10 @@ pub async fn create_request(
.create_transfer(
&mut CoinTransfer::new(
user.id,
req.receiver,
match req.receiver.parse() {
Ok(x) => x,
Err(_) => return Json(Error::Unknown.into()),
},
req.amount,
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()),
}
}
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(),
))
}
/// `/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}/following", get(profile::following_request))
.route("/@{username}/followers", get(profile::followers_request))
.route("/@{username}/shop", get(profile::shop_request))
// communities
.route("/communities", get(communities::list_request))
.route("/communities/search", get(communities::search_request))
@ -162,6 +163,9 @@ pub fn routes() -> Router {
.route("/mail/letter/{id}", get(mail::letter_request))
// economy
.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 {

View file

@ -307,20 +307,20 @@ pub async fn posts_request(
)
.await
{
Ok(p) => Some(data.0.posts_muted_phrase_filter(
Ok(p) => data.0.posts_muted_phrase_filter(
&p,
if let Some(ref ua) = user {
Some(&ua.settings.muted)
} else {
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)),
}
} else {
None
Vec::new()
};
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()))
}
/// `/@{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`
pub async fn outbox_request(
jar: CookieJar,

View file

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

View file

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

View file

@ -1,5 +1,6 @@
use std::collections::HashMap;
use crate::model::auth::Notification;
use crate::model::{auth::User, mail::Letter, permissions::SecondaryPermission, Error, Result};
use crate::{auto_method, DataManager};
use oiseau::{cache::Cache, execute, get, params, query_rows, PostgresRow};
@ -160,6 +161,9 @@ impl DataManager {
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 {
Ok(c) => c,
@ -185,6 +189,20 @@ impl DataManager {
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)
}

View file

@ -150,7 +150,7 @@ impl DataManager {
match product.method {
ProductFulfillmentMethod::AutoMail(message) => {
// 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.0.0.system_user,
@ -167,6 +167,16 @@ impl DataManager {
// mark transfer as pending and create it
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
self.create_letter(Letter::new(
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.
***
<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
),
0,

View file

@ -1,8 +1,9 @@
use std::collections::HashMap;
use crate::model::auth::User;
use crate::model::economy::{CoinTransferMethod, Product};
use crate::model::{Error, Result, economy::CoinTransfer};
use crate::model::{
Error, Result,
economy::{CoinTransferMethod, Product, CoinTransfer},
auth::{Notification, User},
};
use crate::{auto_method, DataManager};
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(receiver.id, receiver.coins).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(())
}
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.
#[serde(default)]
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 {

View file

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