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,