A vitrine ordenada por preço mostra 1200 logo antes de 89, e não é bug do tema. O valor mora na wp_postmeta, cuja coluna meta_value é longtext, e texto ordena caractere por caractere: “1200” vem antes de “89” pelo mesmo motivo que “ab” vem antes de “b”. A correção é uma palavra: troque meta_value por meta_value_num no orderby.
A consulta que ordena certo
<?php
$q = new WP_Query( [
'post_type' => 'produto',
'posts_per_page' => 12,
'meta_key' => 'preco',
'orderby' => 'meta_value_num',
'order' => 'ASC',
] );
O meta_key é obrigatório aqui, senão o WordPress não sabe qual linha da postmeta juntar. Por baixo, o WP_Query 6.9 monta o ORDER BY como meta_value+0, o que joga a string para número no próprio MySQL 8. Efeito colateral: esse meta_key gera um INNER JOIN, então produto sem o campo preco some da lista.
Trazendo quem não tem o campo
<?php
$q = new WP_Query( [
'post_type' => 'produto',
'posts_per_page' => 12,
'meta_query' => [
'relation' => 'OR',
'com_preco' => [
'key' => 'preco',
'type' => 'DECIMAL(10,2)',
'compare' => 'EXISTS',
],
'sem_preco' => [
'key' => 'preco',
'compare' => 'NOT EXISTS',
],
],
'orderby' => [ 'com_preco' => 'ASC' ],
] );
Dando nome à cláusula, o orderby aceita esse nome direto. E aqui mora a pegadinha que me pegou: ordenando por cláusula nomeada, o WordPress usa CAST(meta_value AS ...) com o type que você declarou — e NUMERIC vira SIGNED lá dentro, que corta os centavos. Com preço, peça DECIMAL(10,2); o 19,90 e o 19,99 dependem disso.
Quem cai no ramo NOT EXISTS entra com valor nulo e, em ASC, o MySQL 8 coloca nulo na frente de tudo. Se esses tiverem que ficar no fim da vitrine, ordene DESC ou grave zero no campo na hora de salvar o produto. E confira o valor cru na tabela antes de caçar plugin: campo gravado com R$ na frente ou vírgula no lugar do ponto continua saindo torto, porque a conversão para no primeiro caractere que não é número.