forked from Qortal/q-shop
chore: v1.1.2 – Service product type + free pricing; docs; version bump
This commit is contained in:
23
docs/RELEASE_NOTES_v1.1.2.md
Normal file
23
docs/RELEASE_NOTES_v1.1.2.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Q‑Shop v1.1.2 — Release Notes
|
||||
|
||||
Release date: 2025‑08‑23
|
||||
|
||||
## Summary
|
||||
Adds a new Service product type and full support for free (0‑price) items, including end‑to‑end 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/0‑price 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 Q‑Mail notification is sent to the seller.
|
||||
- Validation updated to allow 0; negative prices remain blocked.
|
||||
- Minor UI/logic tweaks
|
||||
- Total and per‑item 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/`.
|
||||
|
||||
13
docs/USER_ANNOUNCEMENT_v1.1.2.md
Normal file
13
docs/USER_ANNOUNCEMENT_v1.1.2.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Q‑Shop v1.1.2 — What’s 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; you’ll still get an order + Q‑Mail 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.
|
||||
|
||||
That’s it — easy service listings and truly free items!
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "q-shop",
|
||||
"private": true,
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user