add: products ui
This commit is contained in:
parent
8f76578f1b
commit
fd529d3847
31 changed files with 1041 additions and 49 deletions
4
Cargo.lock
generated
4
Cargo.lock
generated
|
@ -3318,7 +3318,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tetratto"
|
||||
version = "14.0.0"
|
||||
version = "15.0.0"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"async-stripe",
|
||||
|
@ -3350,7 +3350,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tetratto-core"
|
||||
version = "14.0.0"
|
||||
version = "15.0.0"
|
||||
dependencies = [
|
||||
"async-recursion",
|
||||
"base16ct",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "tetratto"
|
||||
version = "14.0.0"
|
||||
version = "15.0.0"
|
||||
edition = "2024"
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 %}")
|
||||
|
|
322
crates/app/src/public/html/economy/edit.lisp
Normal file
322
crates/app/src/public/html/economy/edit.lisp
Normal 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 %}")
|
67
crates/app/src/public/html/economy/product.lisp
Normal file
67
crates/app/src/public/html/economy/product.lisp
Normal 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 %}")
|
91
crates/app/src/public/html/economy/products.lisp
Normal file
91
crates/app/src/public/html/economy/products.lisp
Normal 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 %}")
|
|
@ -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] -%}")
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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,7 +169,8 @@
|
|||
(text "{{ components::pagination(page=page, items=requests|length, key=\"&id=\", value=profile.id) }}"))
|
||||
|
||||
(script
|
||||
(text "async function remove_request(id, linked_asset) {
|
||||
(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?\",
|
||||
|
@ -146,6 +178,7 @@
|
|||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
fetch(`/api/v1/requests/${id}/${linked_asset}`, {
|
||||
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 %}")
|
||||
|
|
|
@ -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\",
|
||||
|
|
|
@ -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\"],
|
||||
|
|
17
crates/app/src/public/html/profile/shop.lisp
Normal file
17
crates/app/src/public/html/profile/shop.lisp
Normal 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 %}")
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
))
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -401,6 +401,7 @@ fn default_banned_usernames() -> Vec<String> {
|
|||
"mail".to_string(),
|
||||
"product".to_string(),
|
||||
"wallet".to_string(),
|
||||
"products".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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:{}");
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue