Wix Stores takes a line item’s price from the product, and nothing in Velo’s cart or checkout APIs lets you set a price yourself, so a text box can’t change a unit price. The route that works is the one you already described: one product priced per character, added to the cart with the quantity set to the character count.
Set up the dashboard side first. Make a product called something like “Personalised garland (price per letter)”, priced at your per-letter price, with no options or variants. Add a custom text field to it called Wording, which is what carries the buyer’s word through to your order. Turn inventory tracking off on it, or a five-letter name takes five units off your stock. Note its slug, which is the end of its product page URL.
The counting rule goes in the public folder so the page and the backend use the same one. The page quotes from it and the backend charges from it, and two separate copies would eventually disagree.
// public/per-character.js
export const RULES = {
// Charge for spaces between words, or treat them as free gaps.
chargeSpaces: false,
// Shortest and longest wording you will make.
minCharacters: 1,
maxCharacters: 20,
// Characters you can actually make. Add to this if you do accents or symbols.
allowed: /^[A-Za-z0-9 &'.-]*$/
};
export function tidy(raw) {
return String(raw === undefined || raw === null ? '' : raw)
.replace(/\s+/g, ' ')
.trim();
}
export function priceWording(raw, rules = RULES) {
const wording = tidy(raw);
if (wording === '') {
return { ok: false, wording, characters: 0, reason: 'empty', message: 'Type the name or word you would like.' };
}
if (!rules.allowed.test(wording)) {
const offending = [...new Set([...wording])].filter((character) => !rules.allowed.test(character));
return {
ok: false,
wording,
characters: 0,
reason: 'unmakeable',
offending,
message: `I can't make ${offending.join(' ')} yet. Letters, numbers, & ' . and - are fine.`
};
}
const characters = rules.chargeSpaces ? wording.length : wording.replace(/ /g, '').length;
if (characters < rules.minCharacters) {
return { ok: false, wording, characters, reason: 'short', message: `Minimum order is ${rules.minCharacters} characters.` };
}
if (characters > rules.maxCharacters) {
return { ok: false, wording, characters, reason: 'long', message: `${rules.maxCharacters} characters is the longest I can make. That one is ${characters}.` };
}
return { ok: true, wording, characters, reason: null, message: '' };
}
The four settings at the top are the decisions you have to make. chargeSpaces: false means MR AND MRS is 8 characters rather than 10, so flip it if a gap is a piece you actually make. The allowed pattern is what you can actually make, so add accented letters or extra punctuation if you do. Anything outside it gets refused by name, so a buyer who pastes an emoji is told which character is the problem.
The backend does the adding:
// backend/garland-cart.web.js
import { Permissions, webMethod } from 'wix-web-module';
import wixData from 'wix-data';
import { currentCart } from 'wix-ecom-backend';
import { priceWording } from 'public/per-character';
// Wix Stores. The same id on every site.
const WIX_STORES_APP_ID = '215238eb-22a5-4c36-9e7b-e7c08025e04e';
// The end of your product's URL, and the title of its custom text field, exactly as you typed it.
const PRODUCT_SLUG = 'personalised-garland-price-per-letter';
const WORDING_FIELD = 'Wording';
async function perCharacterProduct() {
const { items } = await wixData
.query('Stores/Products')
.eq('slug', PRODUCT_SLUG)
.limit(1)
.find();
if (items.length === 0) {
throw new Error(`No product with the slug "${PRODUCT_SLUG}".`);
}
return items[0];
}
export const getPerCharacterPrice = webMethod(Permissions.Anyone, async () => {
const product = await perCharacterProduct();
return {
price: product.discountedPrice || product.price,
currency: product.currency
};
});
export const addWordingToCart = webMethod(Permissions.Anyone, async (typed) => {
// Counted again here. Whatever the browser sends can be edited on the way.
const priced = priceWording(typed);
if (!priced.ok) {
throw new Error(priced.message);
}
const product = await perCharacterProduct();
await currentCart.addToCurrentCart({
lineItems: [
{
catalogReference: {
appId: WIX_STORES_APP_ID,
catalogItemId: product._id,
options: {
customTextFields: { [WORDING_FIELD]: priced.wording }
}
},
quantity: priced.characters
}
]
});
return { wording: priced.wording, characters: priced.characters };
});
It counts a second time here on purpose, because the number the page sends is one the buyer’s browser can edit and it sets the price. customTextFields puts the typed word onto the cart line and onto your order, so you know what to make.
The page code, with your own element IDs swapped in:
import wixEcomFrontend from 'wix-ecom-frontend';
import { priceWording } from 'public/per-character';
import { addWordingToCart, getPerCharacterPrice } from 'backend/garland-cart.web';
$w.onReady(async () => {
const typed = $w('#wordingInput');
const addButton = $w('#addToCartButton');
const priceText = $w('#priceText');
const message = $w('#messageText');
addButton.disable();
priceText.text = '';
message.text = '';
let unitPrice;
let money;
try {
const { price, currency } = await getPerCharacterPrice();
unitPrice = price;
money = (amount) => new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(amount);
} catch (error) {
message.text = 'Pricing is unavailable right now.';
console.error(error);
return;
}
function show() {
const priced = priceWording(typed.value);
if (priced.ok) {
priceText.text = `${priced.characters} characters, ${money(priced.characters * unitPrice)}`;
message.text = '';
addButton.enable();
return;
}
priceText.text = '';
message.text = priced.reason === 'empty' ? '' : priced.message;
addButton.disable();
}
typed.onInput(show);
show();
addButton.onClick(async () => {
addButton.disable();
message.text = 'Adding...';
try {
const added = await addWordingToCart(typed.value);
message.text = `${added.wording} added, ${added.characters} characters.`;
await wixEcomFrontend.refreshCart();
await wixEcomFrontend.openSideCart();
} catch (error) {
message.text = error.message || 'That did not reach the cart.';
}
show();
});
});
The unit price comes off the product record, not out of a number typed into the page code, so changing it in the dashboard changes the page.
Two identical wordings merge into one cart line. A buyer who adds SARAH twice gets one line at quantity 10, not two garlands of five. The money is right and the cart reads oddly, which is the reason for putting “price per letter” in the product name.
The custom text field has its own maximum length, set in the dashboard, and that one does count spaces. Put it above your maxCharacters or a long wording gets cut short before your code ever sees it.
wix-stores-frontend’s cart module is deprecated now, so anything you find written against cart.addProducts() is aimed at the old one:
I’ve tested the counting and the cart payload the backend builds, but not against a live store. If it doesn’t work, check the element IDs and the field title first.