Is there an app for charging per character?

What I’m trying to do
I sell personalised garlands. I want my buyer to type a name or word/s, and the price populates per character that they type e.g. SARAH = 5 characters, there the cart adds 5 items.

I can see in Shopify there are apps you can add to your site (e.g. Easify) but can’t find a Wix equivalent.

If anyone knows of an option, that would be great. Thank you

Hi Sarah!

I don’t know if there’s an app for Wix that can replicate the exact same functionality that you’re looking for. So can’t say for sure.

However, yes this can totally be implemented into your Wix site using custom code.

For example, if you want a specific kind of logic for your site (like the one you’re looking for) and there’s no way to do it natively, and there’s no app that offers something like this either, you can write code that will add the exact functionality that you’re looking for within your Wix eCommerce store.

This means your site continues to function exactly as it is, along with the newly added logic.

If you’re well versed with writing code, you can give it a shot yourself! Or you can reach out to us by clicking on the link in my profile if you’d like to get a quote for this functionality.

We’re Wix Certified Developers and specialize in creating custom coded solutions for Wix websites exactly like this!

Thanks Pratham, appreciate your reply. I will give that some investigation!

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.

Hi Sarah — I found a Wix app worth checking for the per-character pricing part: Kickflip product configurator.

Its pricing-equations guide explicitly describes using the character count of a text field in a price formula, including a choice to include or exclude spaces. For your example, that would be five chargeable characters multiplied by your per-letter rate.

I have not tested your exact garland workflow in Wix. The guide establishes character-based pricing, but I could not verify that it automatically changes the cart quantity to five or deducts stock for individual letters. Do you need the cart to contain five units for inventory purposes, or would one personalised garland with the price calculated from five letters work? That distinction is worth checking before setting it up.

The listing says free to install, with ongoing monthly and sales-based fees, so it is not a free-use recommendation.

Disclosure: I’m researching Wix store workflows at KurodaLab and am not affiliated with Kickflip. This is based on its documentation, not hands-on use of this configuration.