chore: v1.1.2 – Service product type + free pricing; docs; version bump

This commit is contained in:
q-shop-release-bot
2025-08-23 15:15:16 -04:00
parent d9c2db33a1
commit f80cef5426
7 changed files with 90 additions and 50 deletions

View File

@@ -0,0 +1,23 @@
# QShop v1.1.2 — Release Notes
Release date: 20250823
## Summary
Adds a new Service product type and full support for free (0price) items, including endtoend checkout without sending a payment transaction.
## Changes
- Product types
- New: Service — intended for paid services (no goods delivered).
- Checkout logic treats Service like Digital (no shipping section when the cart contains only Digital/Service items).
- Free/0price items
- Product creation/editing accepts 0 as a valid price (QORT and supported coins).
- Cart/Checkout displays 0 amounts and correctly computes totals.
- When the order total is 0, payment is skipped; the order is still created and a QMail notification is sent to the seller.
- Validation updated to allow 0; negative prices remain blocked.
- Minor UI/logic tweaks
- Total and peritem price UI now renders when the amount is 0.
## Notes
- Backward compatible: existing products are unaffected.
- Build: `npm ci && npm run build`. Output in `dist/`.

View File

@@ -0,0 +1,13 @@
# QShop v1.1.2 — Whats New
Two highly requested updates:
- Service product type: Create service listings (no shipping). If the cart has only Digital/Service items, checkout skips delivery details.
- Free items (0 price): Set price to 0 in QORT (and/or other supported coins). Buyers can place the order without sending a payment; youll still get an order + QMail notification.
How to use
- When adding a product, choose Type → Service or Digital as needed.
- Enter 0 to make an item free. Negative values are not allowed.
Thats it — easy service listings and truly free items!

View File

@@ -1,7 +1,7 @@
{
"name": "q-shop",
"private": true,
"version": "1.1.1",
"version": "1.1.2",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -246,7 +246,7 @@ export const Cart = () => {
setState(selectedOption?.text || null);
};
// Check to see if any of the products in the cart are digital and that there are none which are physical
// Check to see if any of the products in the cart are non-shipping (digital/service) and that there are none which are physical
const isDigitalOrder = useMemo(() => {
if (!localCart) return false;
return Object.keys(localCart.orders).every(key => {
@@ -258,7 +258,7 @@ export const Cart = () => {
product = catalogueHashMap[catalogueId]?.products[productId];
}
if (!product) return false;
return product.type === "digital";
return product.type === "digital" || product.type === "service";
});
}, [localCart]);
@@ -336,7 +336,7 @@ export const Cart = () => {
} else if(price && exchangeRate && coinToUse !== CoinFilter.qort){
price = +price * exchangeRate
}
if (!price) {
if (price === undefined || price === null || Number.isNaN(price as any)) {
dispatch(
setNotification({
alertType: "error",
@@ -371,6 +371,7 @@ export const Cart = () => {
);
return;
}
// Fetch seller address/publicKey for encryption and identifiers
let res = await qortalRequest({
action: "GET_NAME_DATA",
name: storeOwner,
@@ -390,37 +391,40 @@ export const Cart = () => {
return;
}
// Prepare payment fields and optionally send payment when total > 0
let responseSendCoin: any = null
let signature: string | null = null
let txId: string | null = null
const totalToPayNum = Number(priceToPay)
if(coinToUse === CoinFilter.qort){
responseSendCoin = await qortalRequest({
action: "SEND_COIN",
coin: "QORT",
destinationAddress: address,
amount: priceToPay,
});
signature = responseSendCoin?.signature ?? responseSendCoin?.data?.signature ?? null;
} else {
const coinTicker = String(coinToUse).toUpperCase();
if (!storeToUse?.foreignCoins?.[coinTicker]) throw new Error(`Store has not set a ${coinTicker} address`);
const dest = storeToUse?.foreignCoins?.[coinTicker].trim();
const sendParams: any = {
action: 'SEND_COIN',
coin: coinTicker,
destinationAddress: dest,
amount: priceToPay,
};
if (coinTicker !== 'QORT' && coinTicker !== 'ARRR' && customFee) {
sendParams.fee = customFee;
}
responseSendCoin = await qortalRequest(sendParams);
if (coinTicker === 'QORT') {
if (totalToPayNum > 0) {
if(coinToUse === CoinFilter.qort){
responseSendCoin = await qortalRequest({
action: "SEND_COIN",
coin: "QORT",
destinationAddress: address,
amount: priceToPay,
});
signature = responseSendCoin?.signature ?? responseSendCoin?.data?.signature ?? null;
} else {
txId = (responseSendCoin && (responseSendCoin.txId || responseSendCoin.transactionId || (responseSendCoin.data && responseSendCoin.data.txId))) || null;
const coinTicker = String(coinToUse).toUpperCase();
if (!storeToUse?.foreignCoins?.[coinTicker]) throw new Error(`Store has not set a ${coinTicker} address`);
const dest = storeToUse?.foreignCoins?.[coinTicker].trim();
const sendParams: any = {
action: 'SEND_COIN',
coin: coinTicker,
destinationAddress: dest,
amount: priceToPay,
};
if (coinTicker !== 'QORT' && coinTicker !== 'ARRR' && customFee) {
sendParams.fee = customFee;
}
responseSendCoin = await qortalRequest(sendParams);
if (coinTicker === 'QORT') {
signature = responseSendCoin?.signature ?? responseSendCoin?.data?.signature ?? null;
} else {
txId = (responseSendCoin && (responseSendCoin.txId || responseSendCoin.transactionId || (responseSendCoin.data && responseSendCoin.data.txId))) || null;
}
}
}
@@ -448,9 +452,9 @@ export const Cart = () => {
total: priceToPay,
currency: coinToUse,
transactionSignature: signature || "",
addressUsed: coinToUse !== CoinFilter.qort ? (storeToUse?.foreignCoins?.[coinToUse]?.trim() || "") : "",
arrrAddressUsed: coinToUse === CoinFilter.arrr ? storeToUse?.foreignCoins?.ARRR.trim() : "",
txId: coinToUse !== CoinFilter.qort ? (txId || "") : ""
addressUsed: totalToPayNum > 0 && coinToUse !== CoinFilter.qort ? (storeToUse?.foreignCoins?.[coinToUse]?.trim() || "") : "",
arrrAddressUsed: totalToPayNum > 0 && coinToUse === CoinFilter.arrr ? storeToUse?.foreignCoins?.ARRR.trim() : "",
txId: totalToPayNum > 0 && coinToUse !== CoinFilter.qort ? (txId || "") : ""
},
communicationMethod: ["Q-Mail"],
};
@@ -475,9 +479,9 @@ export const Cart = () => {
total: priceToPay,
currency: coinToUse,
transactionSignature: signature || "",
addressUsed: coinToUse !== CoinFilter.qort ? (storeToUse?.foreignCoins?.[coinToUse]?.trim() || "") : "",
arrrAddressUsed: coinToUse === CoinFilter.arrr ? storeToUse?.foreignCoins?.ARRR.trim() : "",
txId: coinToUse !== CoinFilter.qort ? (txId || "") : ""
addressUsed: totalToPayNum > 0 && coinToUse !== CoinFilter.qort ? (storeToUse?.foreignCoins?.[coinToUse]?.trim() || "") : "",
arrrAddressUsed: totalToPayNum > 0 && coinToUse === CoinFilter.arrr ? storeToUse?.foreignCoins?.ARRR.trim() : "",
txId: totalToPayNum > 0 && coinToUse !== CoinFilter.qort ? (txId || "") : ""
},
communicationMethod: ["Q-Mail"],
};
@@ -497,9 +501,8 @@ export const Cart = () => {
};
const mailId = mailUid();
let identifier = `qortal_qmail_${storeOwner?.slice(0, 20)}_${address.slice(
-6
)}_mail_${mailId}`;
const addressSuffix = (address && typeof address === 'string' && address.length >= 6) ? address.slice(-6) : 'FREE';
let identifier = `qortal_qmail_${storeOwner?.slice(0, 20)}_${addressSuffix}_mail_${mailId}`;
// HTML with the order details being sent to seller by Q-Mail
const htmlContent = `
@@ -1114,12 +1117,12 @@ export const Cart = () => {
{price}
</span>
</ProductPriceFont>
{price && (
<ProductPriceFont>
Total Price:
<span>
{coinToUse === CoinFilter.qort && (
<QortalSVG
{(price !== undefined && price !== null) && (
<ProductPriceFont>
Total Price:
<span>
{coinToUse === CoinFilter.qort && (
<QortalSVG
color={theme.palette.text.primary}
height={"20"}
width={"20"}

View File

@@ -312,6 +312,7 @@ export const ProductForm: React.FC<ProductFormProps> = ({
>
<CustomMenuItem value="digital">Digital</CustomMenuItem>
<CustomMenuItem value="physical">Physical</CustomMenuItem>
<CustomMenuItem value="service">Service</CustomMenuItem>
</CustomSelect>
</FormControl>
</Box>

View File

@@ -203,7 +203,7 @@ export const ProductManager = () => {
const priceInQort = product?.price?.find(
(item: Price) => item?.currency === "qort"
)?.value;
if (!priceInQort)
if (priceInQort === undefined || priceInQort === null || Number.isNaN(priceInQort as any))
throw new Error("Cannot find price for one of your products");
const lastCatalogueInList = listOfCataloguesToPublish.at(-1);
if (
@@ -301,10 +301,10 @@ export const ProductManager = () => {
const priceInQort = product?.price?.find(
(item: Price) => item?.currency === "qort"
)?.value;
if (!priceInQort)
if (priceInQort === undefined || priceInQort === null || Number.isNaN(priceInQort as any))
throw new Error("Cannot find price for one of your products");
if (priceInQort <= 0)
throw new Error("Price cannot be less than or equal to 0");
if ((priceInQort as number) < 0)
throw new Error("Price cannot be negative");
dataContainerToPublish.products[product.id] = {
created: product.created,
priceQort: priceInQort,

View File

@@ -226,7 +226,7 @@ export const ShowOrder: FC<ShowOrderProps> = ({
}
};
// Check to see if any of the products in the order are digital and that there are none which are physical. Hide delivery details in the order if that's the case.
// Check to see if any of the products in the order are non-shipping (digital/service) and that there are none which are physical. Hide delivery details if that's the case.
const isDigitalOrder = useMemo(() => {
if (order && order?.details) {
if (!order) return false;
@@ -235,7 +235,7 @@ export const ShowOrder: FC<ShowOrderProps> = ({
.every(key => {
const product = order?.details?.[key]?.product;
if (!product) return false;
return product?.type === "digital";
return product?.type === "digital" || product?.type === "service";
});
} else {
return false;