add: products ui

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

View file

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

View file

@ -0,0 +1,322 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Manage product - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"\") }}")
(main
("class" "flex flex_col gap_2")
(div
("class" "card_nest")
(div
("class" "card small flex gap_2 items_center")
(icon (text "pencil-line"))
(b
(str (text "economy:label.title"))))
(form
("class" "card flex flex_col gap_2")
("onsubmit" "update_title_from_form(event)")
(div
("class" "flex flex_col gap_1")
(label
("for" "title")
(str (text "economy:label.title")))
(input
("type" "text")
("name" "title")
("id" "title")
("placeholder" "title")
("required" "")
("minlength" "2")
("maxlength" "128")
("value" "{{ product.title }}")))
(button (str (text "general:action.save")))))
(div
("class" "card_nest")
(div
("class" "card small flex gap_2 items_center")
(icon (text "pencil-line"))
(b
(str (text "economy:label.description"))))
(form
("class" "card flex flex_col gap_2")
("onsubmit" "update_description_from_form(event)")
(div
("class" "flex flex_col gap_1")
(label
("for" "description")
(str (text "economy:label.description")))
(textarea
("name" "description")
("id" "description")
("placeholder" "description")
("required" "")
("minlength" "2")
("maxlength" "1024")
(text "{{ product.description }}")))
(button (str (text "general:action.save")))))
(div
("class" "card_nest")
(div
("class" "card small flex gap_2 items_center")
(icon (text "badge-cent"))
(b
(str (text "economy:label.price"))))
(form
("class" "card flex flex_col gap_2")
("onsubmit" "update_price_from_form(event)")
(label
("for" "on_sale")
("class" "flex items_center gap_2")
(input
("type" "checkbox")
("id" "on_sale")
("name" "on_sale")
("class" "w_content")
("checked" "{{ product.on_sale }}")
("oninput" "event.preventDefault(); update_on_sale_from_form(event.target.checked)"))
(span
(str (text "economy:label.on_sale"))))
(div
("class" "flex flex_col gap_1")
(label
("for" "title")
(str (text "economy:label.price")))
(input
("type" "number")
("name" "price")
("id" "price")
("placeholder" "price")
("required" "")
("min" "0")
("max" "1000000")
("value" "{{ product.price }}")))
(button (str (text "general:action.save")))))
(div
("class" "card_nest")
(div
("class" "card small flex gap_2 items_center")
(icon (text "weight"))
(b
(str (text "economy:label.stock"))))
(form
("class" "card flex flex_col gap_2")
("onsubmit" "update_stock_from_form(event)")
(label
("for" "unlimited")
("class" "flex items_center gap_2")
(input
("type" "checkbox")
("id" "unlimited")
("name" "unlimited")
("class" "w_content")
("checked" "{{ product.stock == -1 }}")
("oninput" "event.preventDefault(); event.target.checked ? document.getElementById('stock').value = -1 : document.getElementById('stock').value = 0"))
(span
(str (text "economy:label.unlimited"))))
(div
("class" "flex flex_col gap_1")
(label
("for" "title")
(str (text "economy:label.stock")))
(input
("type" "number")
("name" "stock")
("id" "stock")
("placeholder" "stock")
("required" "")
("min" "-1")
("max" "1000000")
("value" "{{ product.stock }}")))
(button (str (text "general:action.save")))))
(div
("class" "card_nest")
(div
("class" "card small flex gap_2 items_center")
(icon (text "package-check"))
(b
(str (text "economy:label.fulfillment_style"))))
(form
("class" "card flex flex_col gap_2")
("onsubmit" "update_method_from_form(event)")
(p (text "If you choose to send an automated mail letter upon purchase, users will automatically receive the message you supply below."))
(p (text "If you disable automail, you'll be required to manually mail users who have purchased your product before the transfer is finalized."))
(label
("for" "use_automail")
("class" "flex items_center gap_2")
(input
("type" "checkbox")
("id" "use_automail")
("name" "use_automail")
("class" "w_content")
("oninput" "mirror_use_automail()")
("checked" "{% if product.method != \"ManualMail\" -%} true {%- else -%} false {%- endif %}"))
(span
(str (text "economy:label.use_automail"))))
(div
("class" "flex flex_col gap_1")
(label
("for" "automail_message")
(str (text "economy:label.automail_message")))
(textarea
("name" "automail_message")
("id" "automail_message")
("placeholder" "automail_message")
(text "{% if product.method != \"ManualMail\" -%} {{ product.method.AutoMail }} {%- endif %}")))
(button (str (text "general:action.save")))))
(a
("class" "button secondary")
("href" "/product/{{ product.id }}")
(icon (text "arrow-left"))
(str (text "general:action.back"))))
(script
(text "async function update_title_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"products::update\"]);
fetch(\"/api/v1/products/{{ product.id }}/title\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
title: e.target.title.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
async function update_description_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"products::update\"]);
fetch(\"/api/v1/products/{{ product.id }}/description\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
description: e.target.description.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
async function update_on_sale_from_form(on_sale) {
await trigger(\"atto::debounce\", [\"products::update\"]);
fetch(\"/api/v1/products/{{ product.id }}/on_sale\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
on_sale,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
async function update_price_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"products::update\"]);
fetch(\"/api/v1/products/{{ product.id }}/price\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
price: e.target.price.valueAsNumber,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
async function update_stock_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"products::update\"]);
fetch(\"/api/v1/products/{{ product.id }}/stock\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
stock: e.target.stock.valueAsNumber,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
async function update_method_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"products::update\"]);
fetch(\"/api/v1/products/{{ product.id }}/method\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
method: e.target.use_automail.checked ? { AutoMail: e.target.automail_message.value } : \"ManualMail\",
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
globalThis.mirror_use_automail = () => {
const use_automail = document.getElementById(\"use_automail\").checked;
if (use_automail) {
document.getElementById(\"automail_message\").removeAttribute(\"disabled\");
} else {
document.getElementById(\"automail_message\").setAttribute(\"disabled\", \"true\");
}
}
setTimeout(() => {
mirror_use_automail();
}, 150);"))
(text "{% endblock %}")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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