diff --git a/Cargo.lock b/Cargo.lock index 75260ea..56b0651 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 4fb69e9..bbbe59c 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto" -version = "14.0.0" +version = "15.0.0" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index d39bf2e..29ff4e6 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -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 } diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 0c778cd..73507f4 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -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" diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index 1e9afe0..a3145db 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -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); diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 900116a..3753516 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -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 %}") diff --git a/crates/app/src/public/html/economy/edit.lisp b/crates/app/src/public/html/economy/edit.lisp new file mode 100644 index 0000000..30accd2 --- /dev/null +++ b/crates/app/src/public/html/economy/edit.lisp @@ -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 %}") diff --git a/crates/app/src/public/html/economy/product.lisp b/crates/app/src/public/html/economy/product.lisp new file mode 100644 index 0000000..bcceb2b --- /dev/null +++ b/crates/app/src/public/html/economy/product.lisp @@ -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 %}") diff --git a/crates/app/src/public/html/economy/products.lisp b/crates/app/src/public/html/economy/products.lisp new file mode 100644 index 0000000..c6f734a --- /dev/null +++ b/crates/app/src/public/html/economy/products.lisp @@ -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 %}") diff --git a/crates/app/src/public/html/economy/wallet.lisp b/crates/app/src/public/html/economy/wallet.lisp index e390502..ddeb428 100644 --- a/crates/app/src/public/html/economy/wallet.lisp +++ b/crates/app/src/public/html/economy/wallet.lisp @@ -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] -%}") diff --git a/crates/app/src/public/html/macros.lisp b/crates/app/src/public/html/macros.lisp index cd35481..ab7c3af 100644 --- a/crates/app/src/public/html/macros.lisp +++ b/crates/app/src/public/html/macros.lisp @@ -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") diff --git a/crates/app/src/public/html/mail/compose.lisp b/crates/app/src/public/html/mail/compose.lisp index 5b9f401..430a8ff 100644 --- a/crates/app/src/public/html/mail/compose.lisp +++ b/crates/app/src/public/html/mail/compose.lisp @@ -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()) diff --git a/crates/app/src/public/html/misc/requests.lisp b/crates/app/src/public/html/misc/requests.lisp index 2735e0e..73d03ef 100644 --- a/crates/app/src/public/html/misc/requests.lisp +++ b/crates/app/src/public/html/misc/requests.lisp @@ -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 %}") diff --git a/crates/app/src/public/html/profile/base.lisp b/crates/app/src/public/html/profile/base.lisp index e730aa6..aab9c70 100644 --- a/crates/app/src/public/html/profile/base.lisp +++ b/crates/app/src/public/html/profile/base.lisp @@ -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\", diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 3f5901a..0356283 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -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\"], diff --git a/crates/app/src/public/html/profile/shop.lisp b/crates/app/src/public/html/profile/shop.lisp new file mode 100644 index 0000000..744d2f1 --- /dev/null +++ b/crates/app/src/public/html/profile/shop.lisp @@ -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 %}") diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index 8791aeb..ac6dda8 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -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; } diff --git a/crates/app/src/routes/api/v1/letters.rs b/crates/app/src/routes/api/v1/letters.rs index 0d00749..c2a525b 100644 --- a/crates/app/src/routes/api/v1/letters.rs +++ b/crates/app/src/routes/api/v1/letters.rs @@ -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, diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 9ce76ef..f725f39 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -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, } diff --git a/crates/app/src/routes/api/v1/products.rs b/crates/app/src/routes/api/v1/products.rs index d377de8..df73b86 100644 --- a/crates/app/src/routes/api/v1/products.rs +++ b/crates/app/src/routes/api/v1/products.rs @@ -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, diff --git a/crates/app/src/routes/api/v1/transfers.rs b/crates/app/src/routes/api/v1/transfers.rs index 5de916f..be26656 100644 --- a/crates/app/src/routes/api/v1/transfers.rs +++ b/crates/app/src/routes/api/v1/transfers.rs @@ -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, + Json(req): Json, +) -> 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()), + } +} diff --git a/crates/app/src/routes/pages/economy.rs b/crates/app/src/routes/pages/economy.rs index ea6f6f6..36c9cbe 100644 --- a/crates/app/src/routes/pages/economy.rs +++ b/crates/app/src/routes/pages/economy.rs @@ -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, + Query(props): Query, +) -> 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, + Path(id): Path, +) -> 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, + Path(id): Path, +) -> 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(), + )) +} diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index 3cdae39..be8c3df 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -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 { diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs index 9f5b16d..3f58805 100644 --- a/crates/app/src/routes/pages/profile.rs +++ b/crates/app/src/routes/pages/profile.rs @@ -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, + Query(props): Query, + Extension(data): Extension, +) -> 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, diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 6d4a438..1904994 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -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 diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index fb04c7b..814fcb1 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -401,6 +401,7 @@ fn default_banned_usernames() -> Vec { "mail".to_string(), "product".to_string(), "wallet".to_string(), + "products".to_string(), ] } diff --git a/crates/core/src/database/letters.rs b/crates/core/src/database/letters.rs index 80f973e..4375ebb 100644 --- a/crates/core/src/database/letters.rs +++ b/crates/core/src/database/letters.rs @@ -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) } diff --git a/crates/core/src/database/products.rs b/crates/core/src/database/products.rs index 82240da..714531d 100644 --- a/crates/core/src/database/products.rs +++ b/crates/core/src/database/products.rs @@ -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. *** -Fulfill purchase", +Fulfill purchase", product.id, product.title, customer.id, transfer.id ), 0, diff --git a/crates/core/src/database/transfers.rs b/crates/core/src/database/transfers.rs index fb4ed6c..833539e 100644 --- a/crates/core/src/database/transfers.rs +++ b/crates/core/src/database/transfers.rs @@ -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:{}"); } diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index 680d059..0217a02 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -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 { diff --git a/crates/core/src/model/requests.rs b/crates/core/src/model/requests.rs index 1b4f320..3a32f38 100644 --- a/crates/core/src/model/requests.rs +++ b/crates/core/src/model/requests.rs @@ -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,