@cbjk_iris/schema 0.0.0-stage → 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +52 -0
- package/dist/constants-C4TYxqGV.mjs +116 -0
- package/dist/defaults-BTUKcxLp.mjs +160 -0
- package/dist/defaults-NwFmMIfx.d.mts +1021 -0
- package/dist/defaults.d.mts +2 -0
- package/dist/defaults.mjs +3 -0
- package/dist/index.d.mts +1449 -0
- package/dist/index.mjs +587 -0
- package/dist/json-schema.d.mts +6 -0
- package/dist/json-schema.mjs +45 -0
- package/dist/page-DxWfFiUO.mjs +1035 -0
- package/json/home.schema.json +961 -0
- package/json/page.schema.json +972 -0
- package/json/product.schema.json +157 -0
- package/json/section.schema.json +944 -0
- package/json/service.schema.json +77 -0
- package/package.json +43 -4
- package/README.md +0 -3
|
@@ -0,0 +1,1035 @@
|
|
|
1
|
+
import { i as LEAD_FIELDS, o as SECTION_ICONS } from "./constants-C4TYxqGV.mjs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
//#region src/fields.ts
|
|
4
|
+
/** Mensagens padrão do zod em pt-BR, por parse ({ error: ptBRError }), sem mexer na config global. */
|
|
5
|
+
const ptBRError = z.locales.ptBR().localeError;
|
|
6
|
+
const slugSchema = z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { error: "use só letras minúsculas, números e hífens (ex.: capa-iphone-15)" }).max(80, { error: "no máximo 80 caracteres" });
|
|
7
|
+
const MAX_URL_LENGTH = 2048;
|
|
8
|
+
/**
|
|
9
|
+
* Caracteres que o navegador ou o new URL() removem ou reinterpretam, e que por isso não podem
|
|
10
|
+
* aparecer em link nem em caminho: controle (C0, DEL, C1), espaço, espaços e marcas invisíveis
|
|
11
|
+
* (NBSP, soft hyphen, ZWSP/ZWNJ/ZWJ, LRM/RLM, controles bidi, separadores de linha e parágrafo,
|
|
12
|
+
* word joiner e os invisíveis de U+2061 a U+206F, CGJ U+034F, ALM U+061C, MVS U+180E, BOM), a barra
|
|
13
|
+
* invertida (o navegador a trata como /) e os pontos unicode que viram "." no host.
|
|
14
|
+
* Ex.: "/\t/evil.com" vira "//evil.com" no navegador.
|
|
15
|
+
*/
|
|
16
|
+
const UNSAFE_CHARS = /[\u0000-\u0020\u007f-\u00a0\u00ad\u034f\u061c\u180e\u200b-\u200f\u2028\u2029\u202a-\u202e\u2060-\u206f\ufeff\\\u3002\uff0e\uff61]/;
|
|
17
|
+
/** true se o valor cru tem caractere que o navegador reinterpretaria (ver UNSAFE_CHARS). */
|
|
18
|
+
function hasUnsafeChars(value) {
|
|
19
|
+
return UNSAFE_CHARS.test(value);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Por que a URL foi recusada (ou null se passou): sem caractere inseguro no valor cru, só
|
|
23
|
+
* https, sem usuário e senha, sem porta explícita, até 2048 caracteres. Com hosts, o hostname
|
|
24
|
+
* precisa ser um deles ou subdomínio (host === d || host.endsWith("." + d)), comparado em
|
|
25
|
+
* minúsculas depois do new URL().
|
|
26
|
+
*/
|
|
27
|
+
function urlProblem(value, hosts) {
|
|
28
|
+
if (hasUnsafeChars(value)) return "não pode ter espaço, caractere invisível ou de controle, nem \\";
|
|
29
|
+
if (value.length > 2048) return `no máximo ${MAX_URL_LENGTH} caracteres`;
|
|
30
|
+
if (!value.startsWith("https://")) return "precisa começar com https://";
|
|
31
|
+
let url;
|
|
32
|
+
try {
|
|
33
|
+
url = new URL(value);
|
|
34
|
+
} catch {
|
|
35
|
+
return "não é uma URL válida";
|
|
36
|
+
}
|
|
37
|
+
if (url.protocol !== "https:" || !url.hostname) return "precisa começar com https://";
|
|
38
|
+
if (url.username || url.password) return "não pode ter usuário e senha na URL";
|
|
39
|
+
if (url.port) return "não pode ter porta na URL";
|
|
40
|
+
if (hosts && !hosts.some((d) => url.hostname === d || url.hostname.endsWith(`.${d}`))) return "host não permitido";
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const httpsPattern = /^https:\/\//;
|
|
44
|
+
/**
|
|
45
|
+
* Link https. Com `hosts`, só esses hosts e subdomínios. `message` troca a mensagem de erro
|
|
46
|
+
* (uma só para qualquer falha).
|
|
47
|
+
*/
|
|
48
|
+
function httpsUrlWith(message, hosts) {
|
|
49
|
+
return z.string().regex(httpsPattern, { error: message ?? "precisa começar com https://" }).max(MAX_URL_LENGTH, { error: message ?? `no máximo 2048 caracteres` }).superRefine((value, ctx) => {
|
|
50
|
+
const problem = urlProblem(value, hosts);
|
|
51
|
+
if (problem && value.startsWith("https://") && value.length <= 2048) ctx.addIssue({
|
|
52
|
+
code: "custom",
|
|
53
|
+
message: message ?? problem
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
const httpsUrl = httpsUrlWith();
|
|
58
|
+
const hasDotDot = (path) => path.split("/").includes("..");
|
|
59
|
+
const ENCODED_SEPARATORS = /%(?:2e|2f|5c)/i;
|
|
60
|
+
/** Caminho local seguro: começa com uma / só, sem .., sem separador codificado. */
|
|
61
|
+
function isLocalPath(v) {
|
|
62
|
+
return v.startsWith("/") && !v.startsWith("//") && !hasDotDot(v) && !ENCODED_SEPARATORS.test(v);
|
|
63
|
+
}
|
|
64
|
+
/** Imagem: caminho em public/ (começa com /, sem // e sem ..) ou URL https. */
|
|
65
|
+
const imageSrc = z.string().refine((v) => !hasUnsafeChars(v) && (isLocalPath(v) || urlProblem(v) === null), { error: "use um caminho em public/ começando com / (ex.: /products/foto.jpg) ou um link https://, sem espaço nem caractere invisível" });
|
|
66
|
+
const EMAIL_RE = z.regexes.email;
|
|
67
|
+
/**
|
|
68
|
+
* mailto: com um endereço só e, na query, só subject e body. Recusa cc, bcc, to e outros
|
|
69
|
+
* parâmetros (mandariam cópia para outro endereço) e %0d/%0a (injeção de cabeçalho).
|
|
70
|
+
*/
|
|
71
|
+
function isSafeMailto(v) {
|
|
72
|
+
if (!v.startsWith("mailto:") || /%0[ad]/i.test(v)) return false;
|
|
73
|
+
const [address, query, ...rest] = v.slice(7).split("?");
|
|
74
|
+
if (rest.length || !EMAIL_RE.test(address)) return false;
|
|
75
|
+
if (query === void 0) return true;
|
|
76
|
+
const keys = [...new URLSearchParams(query).keys()];
|
|
77
|
+
return keys.length > 0 && keys.every((k) => k === "subject" || k === "body");
|
|
78
|
+
}
|
|
79
|
+
/** Destino de link: rota do site (/sobre), âncora (#contato), https:, mailto: ou tel:. */
|
|
80
|
+
const siteHref = z.string().refine((v) => !hasUnsafeChars(v) && (isLocalPath(v) || /^#[\w-]*$/.test(v) || isSafeMailto(v) || /^tel:\+?[\d()-]+$/.test(v) || urlProblem(v) === null), { error: "use uma rota (/sobre), uma âncora (#contato) ou um link https://, mailto: ou tel:, sem espaço nem caractere invisível" });
|
|
81
|
+
/** Funções CSS permitidas em cssValue (cores e contas). Qualquer outra é recusada. */
|
|
82
|
+
const CSS_FUNCTIONS = /* @__PURE__ */ new Set([
|
|
83
|
+
"calc",
|
|
84
|
+
"min",
|
|
85
|
+
"max",
|
|
86
|
+
"clamp",
|
|
87
|
+
"var",
|
|
88
|
+
"rgb",
|
|
89
|
+
"rgba",
|
|
90
|
+
"hsl",
|
|
91
|
+
"hsla",
|
|
92
|
+
"hwb",
|
|
93
|
+
"lab",
|
|
94
|
+
"lch",
|
|
95
|
+
"oklab",
|
|
96
|
+
"oklch",
|
|
97
|
+
"color",
|
|
98
|
+
"color-mix"
|
|
99
|
+
]);
|
|
100
|
+
/** Por que o valor CSS foi recusado (ou null): parênteses, funções permitidas e var(--nome). */
|
|
101
|
+
function cssStructureProblem(v) {
|
|
102
|
+
if (v.length > 200) return "no máximo 200 caracteres";
|
|
103
|
+
let depth = 0;
|
|
104
|
+
for (const ch of v) {
|
|
105
|
+
if (ch === "(") depth++;
|
|
106
|
+
if (ch === ")" && --depth < 0) return "parênteses desbalanceados";
|
|
107
|
+
}
|
|
108
|
+
if (depth !== 0) return "parênteses desbalanceados";
|
|
109
|
+
for (const [, name] of v.matchAll(/(-?[a-z_][\w-]*)\s*\(/gi)) if (!CSS_FUNCTIONS.has(name.toLowerCase())) return `função ${name}() não é permitida (use calc, min, max, clamp, var, rgb, hsl, oklch, color-mix...)`;
|
|
110
|
+
for (const m of v.matchAll(/var\s*\(/gi)) if (!/^\s*--[\w-]+\s*[,)]/.test(v.slice(m.index + m[0].length))) return "var() precisa de uma variável --nome como primeiro argumento";
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Valor CSS que vai cru para o <style> do layout (cores, raio, tokens de forma). Estrutura
|
|
115
|
+
* permitida, não lista negra: até 200 caracteres; só os caracteres A-Z a-z 0-9, espaço e
|
|
116
|
+
* . , % # ( ) / + - * _ (sem colchete, que abriria um bloco que engole o resto da folha; sem
|
|
117
|
+
* ; : { } < > @ aspas, barra invertida e não-ASCII); sem início ou fim de comentário;
|
|
118
|
+
* parênteses balanceados; só as funções de CSS_FUNCTIONS; var() só com --nome.
|
|
119
|
+
*/
|
|
120
|
+
const cssValue = z.string().min(1).max(200, {
|
|
121
|
+
error: "valor CSS com no máximo 200 caracteres",
|
|
122
|
+
abort: true
|
|
123
|
+
}).regex(/^[A-Za-z0-9 .,%#()/+*_-]*$/, {
|
|
124
|
+
error: "valor CSS só pode ter letras sem acento, números, espaço e . , % # ( ) / + - * _",
|
|
125
|
+
abort: true
|
|
126
|
+
}).refine((v) => !/\/\*|\*\//.test(v), { error: "valor CSS não pode ter comentário" }).superRefine((v, ctx) => {
|
|
127
|
+
const problem = cssStructureProblem(v);
|
|
128
|
+
if (problem) ctx.addIssue({
|
|
129
|
+
code: "custom",
|
|
130
|
+
message: `valor CSS inválido: ${problem}`
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
/** Comprimento CSS simples (ex.: 320px, 20rem, 50vh). */
|
|
134
|
+
const cssLength = z.string().regex(/^\d+(?:\.\d+)?(?:px|rem|em|vh|svh|dvh)$/, { error: "use um comprimento simples, ex.: 320px" });
|
|
135
|
+
const LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
136
|
+
/**
|
|
137
|
+
* Sufixos de hospedagem pública e de registro de 2 níveis: como host inteiro, liberariam no
|
|
138
|
+
* /_next/image as imagens de qualquer cliente desse provedor. Subdomínio específico
|
|
139
|
+
* (meu-site.vercel.app, minha-loja.com.br) é aceito. Lista é dado, não contrato: PR ao arquiteto.
|
|
140
|
+
*/
|
|
141
|
+
const PUBLIC_SUFFIX_HOSTS = [
|
|
142
|
+
"github.io",
|
|
143
|
+
"vercel.app",
|
|
144
|
+
"netlify.app",
|
|
145
|
+
"pages.dev",
|
|
146
|
+
"workers.dev",
|
|
147
|
+
"amazonaws.com",
|
|
148
|
+
"s3.amazonaws.com",
|
|
149
|
+
"cloudfront.net",
|
|
150
|
+
"blob.core.windows.net",
|
|
151
|
+
"appspot.com",
|
|
152
|
+
"firebaseapp.com",
|
|
153
|
+
"web.app",
|
|
154
|
+
"herokuapp.com",
|
|
155
|
+
"onrender.com",
|
|
156
|
+
"fly.dev",
|
|
157
|
+
"azurewebsites.net",
|
|
158
|
+
"googleusercontent.com",
|
|
159
|
+
"storage.googleapis.com",
|
|
160
|
+
"r2.dev",
|
|
161
|
+
"ngrok.io",
|
|
162
|
+
"ngrok-free.app",
|
|
163
|
+
"trycloudflare.com",
|
|
164
|
+
"duckdns.org",
|
|
165
|
+
"no-ip.org",
|
|
166
|
+
"googleapis.com",
|
|
167
|
+
"surge.sh",
|
|
168
|
+
"glitch.me",
|
|
169
|
+
"repl.co",
|
|
170
|
+
"co.uk",
|
|
171
|
+
"org.uk",
|
|
172
|
+
"ac.uk",
|
|
173
|
+
"com.br",
|
|
174
|
+
"net.br",
|
|
175
|
+
"org.br",
|
|
176
|
+
"gov.br",
|
|
177
|
+
"com.au",
|
|
178
|
+
"net.au",
|
|
179
|
+
"co.jp",
|
|
180
|
+
"com.mx",
|
|
181
|
+
"com.ar",
|
|
182
|
+
"co.nz",
|
|
183
|
+
"co.za",
|
|
184
|
+
"com.cn",
|
|
185
|
+
"co.kr",
|
|
186
|
+
"co.in"
|
|
187
|
+
];
|
|
188
|
+
/**
|
|
189
|
+
* Rede interna, nomes reservados e DNS curinga (resolvem para qualquer IP, inclusive privado):
|
|
190
|
+
* recusados como sufixo ou host inteiro. Defesa em profundidade: a defesa principal é do Next,
|
|
191
|
+
* cujo otimizador recusa IP local por padrão (images.dangerouslyAllowLocalIP: false).
|
|
192
|
+
*/
|
|
193
|
+
const PRIVATE_HOST_SUFFIXES = [
|
|
194
|
+
"internal",
|
|
195
|
+
"local",
|
|
196
|
+
"localhost",
|
|
197
|
+
"home.arpa",
|
|
198
|
+
"arpa",
|
|
199
|
+
"lan",
|
|
200
|
+
"corp",
|
|
201
|
+
"intranet",
|
|
202
|
+
"private",
|
|
203
|
+
"home",
|
|
204
|
+
"test",
|
|
205
|
+
"example",
|
|
206
|
+
"invalid",
|
|
207
|
+
"onion",
|
|
208
|
+
"nip.io",
|
|
209
|
+
"sslip.io",
|
|
210
|
+
"xip.io",
|
|
211
|
+
"localtest.me",
|
|
212
|
+
"lvh.me",
|
|
213
|
+
"vcap.me",
|
|
214
|
+
"traefik.me"
|
|
215
|
+
];
|
|
216
|
+
/**
|
|
217
|
+
* Host exato para next/image (images.remoteHosts). Sem curinga (vira proxy aberto no
|
|
218
|
+
* /_next/image), sem protocolo, caminho, porta ou IP; só minúsculas e com pelo menos um ponto;
|
|
219
|
+
* nem sufixo de hospedagem pública, nem rede interna.
|
|
220
|
+
*/
|
|
221
|
+
const hostnameSchema = z.string().refine((v) => {
|
|
222
|
+
const labels = v.split(".");
|
|
223
|
+
return v.length <= 253 && labels.length >= 2 && labels.every((l) => LABEL_RE.test(l)) && /[a-z]/.test(labels[labels.length - 1]);
|
|
224
|
+
}, {
|
|
225
|
+
error: "host inválido: use só o nome exato, sem curinga, protocolo, caminho, porta ou IP (ex.: ik.imagekit.io)",
|
|
226
|
+
abort: true
|
|
227
|
+
}).refine((v) => !PUBLIC_SUFFIX_HOSTS.includes(v), {
|
|
228
|
+
error: (iss) => `"${String(iss.input)}" é um sufixo de hospedagem pública: liberaria as imagens de qualquer site desse provedor. Use o host do seu site (ex.: meu-site.${String(iss.input)})`,
|
|
229
|
+
abort: true
|
|
230
|
+
}).refine((v) => !PRIVATE_HOST_SUFFIXES.some((s) => v === s || v.endsWith(`.${s}`)), { error: (iss) => `"${String(iss.input)}" é de rede interna ou de DNS curinga (resolve para qualquer IP): use um host público` });
|
|
231
|
+
/** Lista sem repetição; a repetida aponta o índice no caminho do erro. */
|
|
232
|
+
function uniqueItems(what) {
|
|
233
|
+
return (list, ctx) => {
|
|
234
|
+
list.forEach((item, i) => {
|
|
235
|
+
if (list.indexOf(item) !== i) ctx.addIssue({
|
|
236
|
+
code: "custom",
|
|
237
|
+
message: `${what} repetido: ${String(item)}`,
|
|
238
|
+
path: [i]
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
//#endregion
|
|
244
|
+
//#region src/catalog.ts
|
|
245
|
+
/** Hosts aceitos por plataforma (dado, não contrato: acrescentar host é PR ao arquiteto). */
|
|
246
|
+
const PLATFORM_HOSTS = {
|
|
247
|
+
mercadoLivre: [
|
|
248
|
+
"mercadolivre.com.br",
|
|
249
|
+
"mercadolivre.com",
|
|
250
|
+
"mercadolibre.com"
|
|
251
|
+
],
|
|
252
|
+
shopee: [
|
|
253
|
+
"shopee.com.br",
|
|
254
|
+
"shopee.com",
|
|
255
|
+
"shope.ee"
|
|
256
|
+
],
|
|
257
|
+
amazon: [
|
|
258
|
+
"amazon.com.br",
|
|
259
|
+
"amazon.com",
|
|
260
|
+
"amzn.to",
|
|
261
|
+
"a.co"
|
|
262
|
+
]
|
|
263
|
+
};
|
|
264
|
+
const PLATFORM_MESSAGES = {
|
|
265
|
+
mercadoLivre: "mercadoLivreLink precisa ser um link https do Mercado Livre (ex.: https://produto.mercadolivre.com.br/...)",
|
|
266
|
+
shopee: "shopeeLink precisa ser um link https da Shopee (ex.: https://shopee.com.br/...)",
|
|
267
|
+
amazon: "amazonLink precisa ser um link https da Amazon (ex.: https://www.amazon.com.br/...)"
|
|
268
|
+
};
|
|
269
|
+
/** Link https de um marketplace: host da lista ou subdomínio, sem credenciais nem porta, até 2048. */
|
|
270
|
+
function platformUrl(platform) {
|
|
271
|
+
return httpsUrlWith(PLATFORM_MESSAGES[platform], PLATFORM_HOSTS[platform]);
|
|
272
|
+
}
|
|
273
|
+
const specSchema = z.strictObject({
|
|
274
|
+
label: z.string().min(1).meta({
|
|
275
|
+
label: "Nome",
|
|
276
|
+
example: "Material"
|
|
277
|
+
}),
|
|
278
|
+
value: z.string().min(1).meta({
|
|
279
|
+
label: "Valor",
|
|
280
|
+
example: "Silicone"
|
|
281
|
+
})
|
|
282
|
+
});
|
|
283
|
+
const installmentSchema = z.strictObject({
|
|
284
|
+
/** número de parcelas, ex.: 10 */
|
|
285
|
+
count: z.number().int().positive().meta({
|
|
286
|
+
label: "Parcelas",
|
|
287
|
+
input: "number"
|
|
288
|
+
}),
|
|
289
|
+
/** valor de cada parcela */
|
|
290
|
+
value: z.number().positive().meta({
|
|
291
|
+
label: "Valor da parcela",
|
|
292
|
+
input: "number"
|
|
293
|
+
}),
|
|
294
|
+
/** se as parcelas têm juros; false = "sem juros" */
|
|
295
|
+
hasInterest: z.boolean().optional().meta({
|
|
296
|
+
label: "Com juros",
|
|
297
|
+
input: "confirm"
|
|
298
|
+
})
|
|
299
|
+
});
|
|
300
|
+
const catalogItemBase = z.object({
|
|
301
|
+
slug: slugSchema.meta({
|
|
302
|
+
label: "Slug",
|
|
303
|
+
description: "Nome do arquivo e parte da URL",
|
|
304
|
+
example: "capa-iphone-15"
|
|
305
|
+
}),
|
|
306
|
+
name: z.string().min(1).meta({ label: "Nome" }),
|
|
307
|
+
/** frase curta exibida nos cards e no topo da página de detalhe */
|
|
308
|
+
shortDescription: z.string().min(1).meta({ label: "Descrição curta" }),
|
|
309
|
+
/** texto completo exibido na página de detalhe */
|
|
310
|
+
description: z.string().min(1).meta({
|
|
311
|
+
label: "Descrição completa",
|
|
312
|
+
input: "multiline"
|
|
313
|
+
}),
|
|
314
|
+
images: z.array(imageSrc).min(1).meta({
|
|
315
|
+
label: "Imagens",
|
|
316
|
+
input: "image",
|
|
317
|
+
description: "A primeira é a capa"
|
|
318
|
+
}),
|
|
319
|
+
specs: z.array(specSchema).optional().meta({ label: "Especificações" }),
|
|
320
|
+
/** posição na lista (crescente); sem order vai para o fim; empate por slug */
|
|
321
|
+
order: z.number().int().nonnegative().optional().meta({
|
|
322
|
+
label: "Ordem",
|
|
323
|
+
input: "number"
|
|
324
|
+
})
|
|
325
|
+
});
|
|
326
|
+
const productSchema = catalogItemBase.extend({
|
|
327
|
+
kind: z.literal("product"),
|
|
328
|
+
category: z.string().min(1).meta({ label: "Categoria" }),
|
|
329
|
+
/** preço à vista */
|
|
330
|
+
price: z.number().nonnegative().meta({
|
|
331
|
+
label: "Preço à vista",
|
|
332
|
+
input: "number"
|
|
333
|
+
}),
|
|
334
|
+
/** preço "de"; maior que price gera o selo de oferta */
|
|
335
|
+
originalPrice: z.number().positive().optional().meta({
|
|
336
|
+
label: "Preço original",
|
|
337
|
+
input: "number",
|
|
338
|
+
ask: "optional"
|
|
339
|
+
}),
|
|
340
|
+
installment: installmentSchema.optional().meta({ label: "Parcelamento" }),
|
|
341
|
+
/** 0 = sob encomenda */
|
|
342
|
+
stockQty: z.number().int().nonnegative().meta({
|
|
343
|
+
label: "Estoque",
|
|
344
|
+
input: "number",
|
|
345
|
+
description: "0 = sob encomenda"
|
|
346
|
+
}),
|
|
347
|
+
whatsappButton: z.boolean().default(true).meta({
|
|
348
|
+
label: "Botão do WhatsApp",
|
|
349
|
+
input: "confirm"
|
|
350
|
+
}),
|
|
351
|
+
mercadoLivreLink: platformUrl("mercadoLivre").optional().meta({
|
|
352
|
+
label: "Link do Mercado Livre",
|
|
353
|
+
input: "url",
|
|
354
|
+
ask: "optional"
|
|
355
|
+
}),
|
|
356
|
+
shopeeLink: platformUrl("shopee").optional().meta({
|
|
357
|
+
label: "Link da Shopee",
|
|
358
|
+
input: "url",
|
|
359
|
+
ask: "optional"
|
|
360
|
+
}),
|
|
361
|
+
amazonLink: platformUrl("amazon").optional().meta({
|
|
362
|
+
label: "Link da Amazon",
|
|
363
|
+
input: "url",
|
|
364
|
+
ask: "optional"
|
|
365
|
+
}),
|
|
366
|
+
externalLink: httpsUrlWith("externalLink precisa começar com https://").optional().meta({
|
|
367
|
+
label: "Link de outra loja",
|
|
368
|
+
input: "url",
|
|
369
|
+
ask: "optional"
|
|
370
|
+
})
|
|
371
|
+
}).strict();
|
|
372
|
+
const serviceSchema = catalogItemBase.extend({ kind: z.literal("service") }).strict();
|
|
373
|
+
/** content/products/<slug>.json: sem kind e sem slug (vêm do diretório e do nome do arquivo). */
|
|
374
|
+
const productContentSchema = productSchema.omit({
|
|
375
|
+
kind: true,
|
|
376
|
+
slug: true
|
|
377
|
+
});
|
|
378
|
+
/** content/services/<slug>.json */
|
|
379
|
+
const serviceContentSchema = serviceSchema.omit({
|
|
380
|
+
kind: true,
|
|
381
|
+
slug: true
|
|
382
|
+
});
|
|
383
|
+
//#endregion
|
|
384
|
+
//#region src/links.ts
|
|
385
|
+
const label = z.string().min(1).meta({ label: "Texto do botão" });
|
|
386
|
+
const siteLinkSchema = z.union([z.strictObject({
|
|
387
|
+
label,
|
|
388
|
+
href: siteHref.meta({
|
|
389
|
+
label: "Destino",
|
|
390
|
+
example: "/sobre",
|
|
391
|
+
input: "url"
|
|
392
|
+
})
|
|
393
|
+
}), z.strictObject({
|
|
394
|
+
label,
|
|
395
|
+
whatsapp: z.union([z.literal(true), z.string().min(1)]).meta({
|
|
396
|
+
label: "Mensagem do WhatsApp",
|
|
397
|
+
description: "true = mensagem geral do site; um texto = mensagem própria"
|
|
398
|
+
})
|
|
399
|
+
})]).meta({
|
|
400
|
+
label: "Botão",
|
|
401
|
+
input: "link"
|
|
402
|
+
});
|
|
403
|
+
/**
|
|
404
|
+
* Metadados de campo, via .meta() (por último na cadeia, depois de .optional() e .default()).
|
|
405
|
+
* Validados em runtime por defineSection: não há module augmentation do zod, para não mudar
|
|
406
|
+
* o zod de quem instala o pacote.
|
|
407
|
+
*/
|
|
408
|
+
const fieldMetaSchema = z.strictObject({
|
|
409
|
+
/** obrigatório quando o campo é perguntado */
|
|
410
|
+
label: z.string().min(1).optional(),
|
|
411
|
+
/** ajuda na CLI e no hover do JSON */
|
|
412
|
+
description: z.string().min(1).optional(),
|
|
413
|
+
/** placeholder da pergunta e exemplo no erro */
|
|
414
|
+
example: z.string().optional(),
|
|
415
|
+
/** tipo de pergunta; ausente = inferido do tipo zod (pela CLI) */
|
|
416
|
+
input: z.enum([
|
|
417
|
+
"text",
|
|
418
|
+
"multiline",
|
|
419
|
+
"url",
|
|
420
|
+
"image",
|
|
421
|
+
"number",
|
|
422
|
+
"select",
|
|
423
|
+
"multiselect",
|
|
424
|
+
"confirm",
|
|
425
|
+
"icon",
|
|
426
|
+
"link"
|
|
427
|
+
]).optional(),
|
|
428
|
+
/** se a CLI pergunta; ausente = "always" se obrigatório, "never" se opcional */
|
|
429
|
+
ask: z.enum([
|
|
430
|
+
"always",
|
|
431
|
+
"optional",
|
|
432
|
+
"never"
|
|
433
|
+
]).optional(),
|
|
434
|
+
/** ordem das perguntas; ausente = ordem do objeto */
|
|
435
|
+
order: z.number().optional()
|
|
436
|
+
});
|
|
437
|
+
/** Campos comuns das seções (menos o hero, que só tem id). */
|
|
438
|
+
const sectionBase = z.object({
|
|
439
|
+
id: z.string().regex(/^[a-z][a-z0-9-]*$/, { error: "use letras minúsculas, números e hífens (ex.: contato)" }).optional().meta({
|
|
440
|
+
label: "Âncora",
|
|
441
|
+
description: "Identificador para links do tipo #contato"
|
|
442
|
+
}),
|
|
443
|
+
title: z.string().min(1).optional().meta({
|
|
444
|
+
label: "Título",
|
|
445
|
+
ask: "optional"
|
|
446
|
+
}),
|
|
447
|
+
subtitle: z.string().min(1).optional().meta({
|
|
448
|
+
label: "Subtítulo",
|
|
449
|
+
ask: "optional"
|
|
450
|
+
})
|
|
451
|
+
});
|
|
452
|
+
/** Metadado do campo (o registrado na própria instância). */
|
|
453
|
+
function fieldMeta(schema) {
|
|
454
|
+
return z.globalRegistry.get(schema);
|
|
455
|
+
}
|
|
456
|
+
function isOptional(schema) {
|
|
457
|
+
return schema instanceof z.ZodOptional || schema instanceof z.ZodDefault || schema instanceof z.ZodPrefault;
|
|
458
|
+
}
|
|
459
|
+
/** Se a CLI pergunta o campo: o ask do metadado, ou "always" se obrigatório e "never" se opcional. */
|
|
460
|
+
function fieldAsk(schema) {
|
|
461
|
+
return fieldMeta(schema)?.ask ?? (isOptional(schema) ? "never" : "always");
|
|
462
|
+
}
|
|
463
|
+
function unwrap(schema) {
|
|
464
|
+
let s = schema;
|
|
465
|
+
while (s instanceof z.ZodOptional || s instanceof z.ZodDefault || s instanceof z.ZodPrefault || s instanceof z.ZodNullable) s = s.unwrap();
|
|
466
|
+
return s;
|
|
467
|
+
}
|
|
468
|
+
/** Problemas de metadado nos campos de um objeto, recursivo (listas, objetos e uniões). */
|
|
469
|
+
function fieldProblems(shape, prefix = "") {
|
|
470
|
+
return Object.entries(shape).flatMap(([key, field]) => {
|
|
471
|
+
if (field instanceof z.ZodLiteral) return [];
|
|
472
|
+
const path = prefix + key;
|
|
473
|
+
const problems = [];
|
|
474
|
+
const meta = fieldMeta(field);
|
|
475
|
+
if (meta && !fieldMetaSchema.safeParse(meta).success) problems.push(`${path}: meta inválido (${JSON.stringify(meta)})`);
|
|
476
|
+
const ask = fieldAsk(field);
|
|
477
|
+
if (ask !== "never" && !meta?.label) problems.push(`${path} é perguntado (ask "${ask}") e não tem label`);
|
|
478
|
+
return [...problems, ...nested(unwrap(field), path)];
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
function nested(schema, path) {
|
|
482
|
+
if (schema instanceof z.ZodObject) return fieldProblems(schema.shape, `${path}.`);
|
|
483
|
+
if (schema instanceof z.ZodArray) return nested(unwrap(schema.element), `${path}[]`);
|
|
484
|
+
if (schema instanceof z.ZodUnion) return schema.options.flatMap((o) => nested(unwrap(o), path));
|
|
485
|
+
return [];
|
|
486
|
+
}
|
|
487
|
+
/** Monta o schema final da seção: os campos mais o type literal, strict. */
|
|
488
|
+
function sectionSchemaOf(type, fields) {
|
|
489
|
+
return fields.extend({ type: z.literal(type) }).strict();
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Define uma seção. Falha na hora (na importação do pacote) se: o type não é kebab-case,
|
|
493
|
+
* um metadado é inválido, um campo perguntado não tem label, ou um preset não valida.
|
|
494
|
+
*/
|
|
495
|
+
function defineSection(def) {
|
|
496
|
+
const where = `defineSection("${def.type}")`;
|
|
497
|
+
if (!/^[a-z]+(?:-[a-z]+)*$/.test(def.type)) throw new Error(`${where}: o type precisa ser kebab-case`);
|
|
498
|
+
const problems = fieldProblems(def.fields.shape);
|
|
499
|
+
if (problems.length) throw new Error(`${where}: ${problems.join("; ")}`);
|
|
500
|
+
const schema = sectionSchemaOf(def.type, def.fields);
|
|
501
|
+
const presets = def.presets ?? [];
|
|
502
|
+
for (const preset of presets) {
|
|
503
|
+
const r = schema.safeParse({
|
|
504
|
+
...preset.value,
|
|
505
|
+
type: def.type
|
|
506
|
+
});
|
|
507
|
+
if (!r.success) throw new Error(`${where}: preset "${preset.id}" não valida: ${r.error.message}`);
|
|
508
|
+
}
|
|
509
|
+
return {
|
|
510
|
+
type: def.type,
|
|
511
|
+
module: def.module,
|
|
512
|
+
label: def.label,
|
|
513
|
+
description: def.description,
|
|
514
|
+
schema,
|
|
515
|
+
presets,
|
|
516
|
+
limits: def.limits ?? {}
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
//#endregion
|
|
520
|
+
//#region src/sections/benefits.ts
|
|
521
|
+
const benefits = defineSection({
|
|
522
|
+
type: "benefits",
|
|
523
|
+
module: "core",
|
|
524
|
+
label: "Benefícios",
|
|
525
|
+
description: "Lista de vantagens com ícone, título e texto curto.",
|
|
526
|
+
fields: sectionBase.extend({ items: z.array(z.strictObject({
|
|
527
|
+
icon: z.enum(SECTION_ICONS).meta({
|
|
528
|
+
label: "Ícone",
|
|
529
|
+
input: "icon"
|
|
530
|
+
}),
|
|
531
|
+
title: z.string().min(1).meta({ label: "Título" }),
|
|
532
|
+
text: z.string().min(1).meta({
|
|
533
|
+
label: "Texto",
|
|
534
|
+
input: "multiline"
|
|
535
|
+
})
|
|
536
|
+
})).min(1).meta({
|
|
537
|
+
label: "Benefícios",
|
|
538
|
+
ask: "always"
|
|
539
|
+
}) }),
|
|
540
|
+
presets: [{
|
|
541
|
+
id: "basico",
|
|
542
|
+
label: "3 benefícios de exemplo",
|
|
543
|
+
value: {
|
|
544
|
+
title: "Por que escolher a gente",
|
|
545
|
+
items: [
|
|
546
|
+
{
|
|
547
|
+
icon: "shield-check",
|
|
548
|
+
title: "Garantia",
|
|
549
|
+
text: "Garantia por escrito em todo atendimento."
|
|
550
|
+
},
|
|
551
|
+
{
|
|
552
|
+
icon: "clock",
|
|
553
|
+
title: "Rapidez",
|
|
554
|
+
text: "Resposta no mesmo dia pelo WhatsApp."
|
|
555
|
+
},
|
|
556
|
+
{
|
|
557
|
+
icon: "hand-coins",
|
|
558
|
+
title: "Preço justo",
|
|
559
|
+
text: "Orçamento claro antes de começar."
|
|
560
|
+
}
|
|
561
|
+
]
|
|
562
|
+
}
|
|
563
|
+
}]
|
|
564
|
+
});
|
|
565
|
+
//#endregion
|
|
566
|
+
//#region src/sections/contact.ts
|
|
567
|
+
const contact = defineSection({
|
|
568
|
+
type: "contact",
|
|
569
|
+
module: "core",
|
|
570
|
+
label: "Contato",
|
|
571
|
+
description: "Telefone, e-mail, endereço, horário e mapa, a partir de siteConfig.contact.",
|
|
572
|
+
fields: sectionBase.extend({
|
|
573
|
+
/** mostra o mapa de siteConfig.contact.mapEmbedUrl */
|
|
574
|
+
showMap: z.boolean().optional().meta({
|
|
575
|
+
label: "Mostrar o mapa",
|
|
576
|
+
input: "confirm"
|
|
577
|
+
}) })
|
|
578
|
+
});
|
|
579
|
+
//#endregion
|
|
580
|
+
//#region src/sections/cta-banner.ts
|
|
581
|
+
const ctaBanner = defineSection({
|
|
582
|
+
type: "cta-banner",
|
|
583
|
+
module: "core",
|
|
584
|
+
label: "Chamada para ação",
|
|
585
|
+
description: "Faixa de destaque com texto curto e um botão.",
|
|
586
|
+
fields: sectionBase.extend({
|
|
587
|
+
text: z.string().min(1).optional().meta({
|
|
588
|
+
label: "Texto",
|
|
589
|
+
input: "multiline",
|
|
590
|
+
ask: "optional"
|
|
591
|
+
}),
|
|
592
|
+
cta: siteLinkSchema.meta({
|
|
593
|
+
label: "Botão",
|
|
594
|
+
input: "link"
|
|
595
|
+
}),
|
|
596
|
+
/** cor de fundo: primária, cor de ação ou discreta */
|
|
597
|
+
tone: z.enum([
|
|
598
|
+
"primary",
|
|
599
|
+
"cta",
|
|
600
|
+
"muted"
|
|
601
|
+
]).optional().meta({
|
|
602
|
+
label: "Cor de fundo",
|
|
603
|
+
input: "select"
|
|
604
|
+
})
|
|
605
|
+
})
|
|
606
|
+
});
|
|
607
|
+
//#endregion
|
|
608
|
+
//#region src/sections/faq.ts
|
|
609
|
+
const faq = defineSection({
|
|
610
|
+
type: "faq",
|
|
611
|
+
module: "core",
|
|
612
|
+
label: "Perguntas frequentes",
|
|
613
|
+
description: "Perguntas e respostas, com dados estruturados para o Google.",
|
|
614
|
+
fields: sectionBase.extend({ items: z.array(z.strictObject({
|
|
615
|
+
question: z.string().min(1).meta({ label: "Pergunta" }),
|
|
616
|
+
answer: z.string().min(1).meta({
|
|
617
|
+
label: "Resposta",
|
|
618
|
+
input: "multiline"
|
|
619
|
+
})
|
|
620
|
+
})).min(1).meta({
|
|
621
|
+
label: "Perguntas",
|
|
622
|
+
ask: "always"
|
|
623
|
+
}) }),
|
|
624
|
+
presets: [{
|
|
625
|
+
id: "basico",
|
|
626
|
+
label: "3 perguntas de exemplo",
|
|
627
|
+
value: {
|
|
628
|
+
title: "Dúvidas frequentes",
|
|
629
|
+
items: [
|
|
630
|
+
{
|
|
631
|
+
question: "Como faço um orçamento?",
|
|
632
|
+
answer: "Fale com a gente pelo WhatsApp e responda algumas perguntas rápidas."
|
|
633
|
+
},
|
|
634
|
+
{
|
|
635
|
+
question: "Quais as formas de pagamento?",
|
|
636
|
+
answer: "Pix, débito e crédito."
|
|
637
|
+
},
|
|
638
|
+
{
|
|
639
|
+
question: "Vocês dão garantia?",
|
|
640
|
+
answer: "Sim, com termo por escrito."
|
|
641
|
+
}
|
|
642
|
+
]
|
|
643
|
+
}
|
|
644
|
+
}],
|
|
645
|
+
limits: { maxPerPage: 1 }
|
|
646
|
+
});
|
|
647
|
+
//#endregion
|
|
648
|
+
//#region src/sections/gallery.ts
|
|
649
|
+
const gallery = defineSection({
|
|
650
|
+
type: "gallery",
|
|
651
|
+
module: "gallery",
|
|
652
|
+
label: "Galeria de projetos",
|
|
653
|
+
description: "Fotos de public/ultimos_projetos, com link para a galeria completa.",
|
|
654
|
+
fields: sectionBase.extend({ limit: z.number().int().positive().optional().meta({
|
|
655
|
+
label: "Quantidade máxima de fotos",
|
|
656
|
+
input: "number"
|
|
657
|
+
}) })
|
|
658
|
+
});
|
|
659
|
+
//#endregion
|
|
660
|
+
//#region src/sections/hero.ts
|
|
661
|
+
const slide = z.strictObject({
|
|
662
|
+
image: imageSrc.meta({
|
|
663
|
+
label: "Imagem",
|
|
664
|
+
input: "image",
|
|
665
|
+
example: "/home/slide-1.jpg"
|
|
666
|
+
}),
|
|
667
|
+
alt: z.string().min(1).meta({
|
|
668
|
+
label: "Descrição da imagem",
|
|
669
|
+
description: "Texto alternativo, para leitores de tela"
|
|
670
|
+
}),
|
|
671
|
+
title: z.string().min(1).meta({ label: "Título" }),
|
|
672
|
+
subtitle: z.string().min(1).optional().meta({
|
|
673
|
+
label: "Subtítulo",
|
|
674
|
+
ask: "optional"
|
|
675
|
+
}),
|
|
676
|
+
/** botão principal do slide */
|
|
677
|
+
cta: siteLinkSchema.optional().meta({
|
|
678
|
+
label: "Botão principal",
|
|
679
|
+
input: "link"
|
|
680
|
+
}),
|
|
681
|
+
/** segundo botão, com estilo discreto */
|
|
682
|
+
secondaryCta: siteLinkSchema.optional().meta({
|
|
683
|
+
label: "Botão secundário",
|
|
684
|
+
input: "link"
|
|
685
|
+
}),
|
|
686
|
+
/** torna o slide inteiro clicável (útil para banners de promoção sem botão) */
|
|
687
|
+
href: siteHref.optional().meta({
|
|
688
|
+
label: "Link do slide inteiro",
|
|
689
|
+
input: "url"
|
|
690
|
+
}),
|
|
691
|
+
/** posição do texto */
|
|
692
|
+
align: z.enum([
|
|
693
|
+
"left",
|
|
694
|
+
"center",
|
|
695
|
+
"right"
|
|
696
|
+
]).optional().meta({
|
|
697
|
+
label: "Alinhamento do texto",
|
|
698
|
+
input: "select"
|
|
699
|
+
})
|
|
700
|
+
});
|
|
701
|
+
const hero = defineSection({
|
|
702
|
+
type: "hero",
|
|
703
|
+
module: "core",
|
|
704
|
+
label: "Banner principal",
|
|
705
|
+
description: "Carrossel de imagens grandes com título e botões.",
|
|
706
|
+
fields: sectionBase.pick({ id: true }).extend({
|
|
707
|
+
slides: z.array(slide).min(1).meta({
|
|
708
|
+
label: "Slides",
|
|
709
|
+
ask: "always"
|
|
710
|
+
}),
|
|
711
|
+
autoplay: z.boolean().optional().meta({
|
|
712
|
+
label: "Passar sozinho",
|
|
713
|
+
input: "confirm"
|
|
714
|
+
}),
|
|
715
|
+
/** ms entre slides */
|
|
716
|
+
interval: z.number().int().positive().optional().meta({
|
|
717
|
+
label: "Intervalo (ms)",
|
|
718
|
+
input: "number"
|
|
719
|
+
}),
|
|
720
|
+
height: z.enum([
|
|
721
|
+
"sm",
|
|
722
|
+
"md",
|
|
723
|
+
"lg",
|
|
724
|
+
"full"
|
|
725
|
+
]).optional().meta({
|
|
726
|
+
label: "Altura",
|
|
727
|
+
input: "select"
|
|
728
|
+
})
|
|
729
|
+
}),
|
|
730
|
+
presets: [{
|
|
731
|
+
id: "basico",
|
|
732
|
+
label: "Um slide com botão do WhatsApp",
|
|
733
|
+
value: {
|
|
734
|
+
height: "lg",
|
|
735
|
+
slides: [{
|
|
736
|
+
image: "/home/slide-1.jpg",
|
|
737
|
+
alt: "Foto principal da loja",
|
|
738
|
+
title: "Bem-vindo à nossa loja",
|
|
739
|
+
subtitle: "Atendimento rápido pelo WhatsApp.",
|
|
740
|
+
cta: {
|
|
741
|
+
label: "Fale com a gente",
|
|
742
|
+
whatsapp: true
|
|
743
|
+
}
|
|
744
|
+
}]
|
|
745
|
+
}
|
|
746
|
+
}],
|
|
747
|
+
limits: { maxPerPage: 1 }
|
|
748
|
+
});
|
|
749
|
+
//#endregion
|
|
750
|
+
//#region src/sections/lead-form.ts
|
|
751
|
+
/** Formulário de contato. O destino dos dados vem de siteConfig.leads. */
|
|
752
|
+
const leadForm = defineSection({
|
|
753
|
+
type: "lead-form",
|
|
754
|
+
module: "core",
|
|
755
|
+
label: "Formulário de contato",
|
|
756
|
+
description: "Formulário de orçamento; os dados vão para o WhatsApp ou para um serviço de formulários.",
|
|
757
|
+
fields: sectionBase.extend({
|
|
758
|
+
/** campos, na ordem; em branco = nome, telefone e mensagem */
|
|
759
|
+
fields: z.array(z.enum(LEAD_FIELDS)).superRefine(uniqueItems("campo")).optional().meta({
|
|
760
|
+
label: "Campos",
|
|
761
|
+
input: "multiselect"
|
|
762
|
+
}),
|
|
763
|
+
submitLabel: z.string().min(1).optional().meta({ label: "Texto do botão de envio" }),
|
|
764
|
+
/** texto de consentimento (LGPD) exibido com uma caixa de seleção obrigatória */
|
|
765
|
+
consentText: z.string().min(1).optional().meta({
|
|
766
|
+
label: "Texto de consentimento (LGPD)",
|
|
767
|
+
input: "multiline"
|
|
768
|
+
})
|
|
769
|
+
})
|
|
770
|
+
});
|
|
771
|
+
//#endregion
|
|
772
|
+
//#region src/sections/products.ts
|
|
773
|
+
const products = defineSection({
|
|
774
|
+
type: "products",
|
|
775
|
+
module: "products",
|
|
776
|
+
label: "Produtos em destaque",
|
|
777
|
+
description: "Lista de produtos do catálogo, com link para a página de produtos.",
|
|
778
|
+
fields: sectionBase.extend({
|
|
779
|
+
category: z.string().min(1).optional().meta({
|
|
780
|
+
label: "Categoria",
|
|
781
|
+
ask: "optional"
|
|
782
|
+
}),
|
|
783
|
+
limit: z.number().int().positive().optional().meta({
|
|
784
|
+
label: "Quantidade máxima",
|
|
785
|
+
input: "number"
|
|
786
|
+
})
|
|
787
|
+
})
|
|
788
|
+
});
|
|
789
|
+
//#endregion
|
|
790
|
+
//#region src/sections/services.ts
|
|
791
|
+
const services = defineSection({
|
|
792
|
+
type: "services",
|
|
793
|
+
module: "services",
|
|
794
|
+
label: "Serviços em destaque",
|
|
795
|
+
description: "Lista de serviços do catálogo, com link para a página de serviços.",
|
|
796
|
+
fields: sectionBase.extend({ limit: z.number().int().positive().optional().meta({
|
|
797
|
+
label: "Quantidade máxima",
|
|
798
|
+
input: "number"
|
|
799
|
+
}) })
|
|
800
|
+
});
|
|
801
|
+
//#endregion
|
|
802
|
+
//#region src/sections/stats.ts
|
|
803
|
+
const stats = defineSection({
|
|
804
|
+
type: "stats",
|
|
805
|
+
module: "core",
|
|
806
|
+
label: "Números",
|
|
807
|
+
description: "Números de destaque (anos de experiência, clientes atendidos...).",
|
|
808
|
+
fields: sectionBase.extend({ items: z.array(z.strictObject({
|
|
809
|
+
value: z.string().min(1).meta({
|
|
810
|
+
label: "Número",
|
|
811
|
+
example: "10+"
|
|
812
|
+
}),
|
|
813
|
+
label: z.string().min(1).meta({
|
|
814
|
+
label: "Legenda",
|
|
815
|
+
example: "anos de experiência"
|
|
816
|
+
})
|
|
817
|
+
})).min(1).meta({
|
|
818
|
+
label: "Números",
|
|
819
|
+
ask: "always"
|
|
820
|
+
}) }),
|
|
821
|
+
presets: [{
|
|
822
|
+
id: "basico",
|
|
823
|
+
label: "3 números de exemplo",
|
|
824
|
+
value: { items: [
|
|
825
|
+
{
|
|
826
|
+
value: "10+",
|
|
827
|
+
label: "anos de experiência"
|
|
828
|
+
},
|
|
829
|
+
{
|
|
830
|
+
value: "1.000+",
|
|
831
|
+
label: "clientes atendidos"
|
|
832
|
+
},
|
|
833
|
+
{
|
|
834
|
+
value: "4,9",
|
|
835
|
+
label: "nota média"
|
|
836
|
+
}
|
|
837
|
+
] }
|
|
838
|
+
}]
|
|
839
|
+
});
|
|
840
|
+
//#endregion
|
|
841
|
+
//#region src/sections/testimonials.ts
|
|
842
|
+
const testimonials = defineSection({
|
|
843
|
+
type: "testimonials",
|
|
844
|
+
module: "core",
|
|
845
|
+
label: "Depoimentos",
|
|
846
|
+
description: "Opiniões de clientes, com nota de 1 a 5.",
|
|
847
|
+
fields: sectionBase.extend({ items: z.array(z.strictObject({
|
|
848
|
+
name: z.string().min(1).meta({ label: "Nome" }),
|
|
849
|
+
text: z.string().min(1).meta({
|
|
850
|
+
label: "Depoimento",
|
|
851
|
+
input: "multiline"
|
|
852
|
+
}),
|
|
853
|
+
role: z.string().min(1).optional().meta({
|
|
854
|
+
label: "Descrição",
|
|
855
|
+
example: "Troca de tela",
|
|
856
|
+
ask: "optional"
|
|
857
|
+
}),
|
|
858
|
+
rating: z.literal([
|
|
859
|
+
1,
|
|
860
|
+
2,
|
|
861
|
+
3,
|
|
862
|
+
4,
|
|
863
|
+
5
|
|
864
|
+
]).optional().meta({
|
|
865
|
+
label: "Nota",
|
|
866
|
+
input: "select"
|
|
867
|
+
}),
|
|
868
|
+
image: imageSrc.optional().meta({
|
|
869
|
+
label: "Foto",
|
|
870
|
+
input: "image"
|
|
871
|
+
})
|
|
872
|
+
})).min(1).meta({
|
|
873
|
+
label: "Depoimentos",
|
|
874
|
+
ask: "always"
|
|
875
|
+
}) }),
|
|
876
|
+
presets: [{
|
|
877
|
+
id: "basico",
|
|
878
|
+
label: "2 depoimentos de exemplo",
|
|
879
|
+
value: {
|
|
880
|
+
title: "O que dizem nossos clientes",
|
|
881
|
+
items: [{
|
|
882
|
+
name: "Cliente 1",
|
|
883
|
+
rating: 5,
|
|
884
|
+
text: "Atendimento rápido e atencioso. Recomendo."
|
|
885
|
+
}, {
|
|
886
|
+
name: "Cliente 2",
|
|
887
|
+
rating: 5,
|
|
888
|
+
text: "Resolveram no mesmo dia, com garantia."
|
|
889
|
+
}]
|
|
890
|
+
}
|
|
891
|
+
}]
|
|
892
|
+
});
|
|
893
|
+
//#endregion
|
|
894
|
+
//#region src/sections/text-image.ts
|
|
895
|
+
const textImage = defineSection({
|
|
896
|
+
type: "text-image",
|
|
897
|
+
module: "core",
|
|
898
|
+
label: "Texto e imagem",
|
|
899
|
+
description: "Texto em parágrafos ao lado de uma imagem.",
|
|
900
|
+
fields: sectionBase.extend({
|
|
901
|
+
/** parágrafos separados por linha em branco */
|
|
902
|
+
text: z.string().min(1).meta({
|
|
903
|
+
label: "Texto",
|
|
904
|
+
input: "multiline",
|
|
905
|
+
description: "Parágrafos separados por linha em branco"
|
|
906
|
+
}),
|
|
907
|
+
image: imageSrc.meta({
|
|
908
|
+
label: "Imagem",
|
|
909
|
+
input: "image"
|
|
910
|
+
}),
|
|
911
|
+
imageAlt: z.string().min(1).meta({ label: "Descrição da imagem" }),
|
|
912
|
+
imageSide: z.enum(["left", "right"]).optional().meta({
|
|
913
|
+
label: "Lado da imagem",
|
|
914
|
+
input: "select"
|
|
915
|
+
}),
|
|
916
|
+
cta: siteLinkSchema.optional().meta({
|
|
917
|
+
label: "Botão",
|
|
918
|
+
input: "link"
|
|
919
|
+
})
|
|
920
|
+
})
|
|
921
|
+
});
|
|
922
|
+
//#endregion
|
|
923
|
+
//#region src/sections/widget.ts
|
|
924
|
+
/**
|
|
925
|
+
* Código de widget colado (avaliações do TripAdvisor, Reclame Aqui, Trustpilot, Elfsight...).
|
|
926
|
+
* Os <script> do código rodam no navegador: é conteúdo de confiança do dono do site.
|
|
927
|
+
*/
|
|
928
|
+
const widget = defineSection({
|
|
929
|
+
type: "widget",
|
|
930
|
+
module: "core",
|
|
931
|
+
label: "Widget externo",
|
|
932
|
+
description: "Código de widget colado de outra plataforma (avaliações, agenda...). Os scripts rodam no navegador.",
|
|
933
|
+
fields: sectionBase.extend({
|
|
934
|
+
html: z.string().min(1).meta({
|
|
935
|
+
label: "Código do widget",
|
|
936
|
+
input: "multiline"
|
|
937
|
+
}),
|
|
938
|
+
/** altura mínima reservada enquanto o widget carrega, ex.: "320px" */
|
|
939
|
+
minHeight: cssLength.optional().meta({
|
|
940
|
+
label: "Altura mínima",
|
|
941
|
+
example: "320px"
|
|
942
|
+
})
|
|
943
|
+
})
|
|
944
|
+
});
|
|
945
|
+
//#endregion
|
|
946
|
+
//#region src/sections/index.ts
|
|
947
|
+
/** Todas as definições, na ordem de hoje: as do núcleo e depois as dos módulos. */
|
|
948
|
+
const sectionDefinitions = [
|
|
949
|
+
hero,
|
|
950
|
+
benefits,
|
|
951
|
+
textImage,
|
|
952
|
+
ctaBanner,
|
|
953
|
+
testimonials,
|
|
954
|
+
stats,
|
|
955
|
+
faq,
|
|
956
|
+
contact,
|
|
957
|
+
leadForm,
|
|
958
|
+
widget,
|
|
959
|
+
products,
|
|
960
|
+
services,
|
|
961
|
+
gallery
|
|
962
|
+
];
|
|
963
|
+
const sectionSchema = z.discriminatedUnion("type", [
|
|
964
|
+
hero.schema,
|
|
965
|
+
benefits.schema,
|
|
966
|
+
textImage.schema,
|
|
967
|
+
ctaBanner.schema,
|
|
968
|
+
testimonials.schema,
|
|
969
|
+
stats.schema,
|
|
970
|
+
faq.schema,
|
|
971
|
+
contact.schema,
|
|
972
|
+
leadForm.schema,
|
|
973
|
+
widget.schema,
|
|
974
|
+
products.schema,
|
|
975
|
+
services.schema,
|
|
976
|
+
gallery.schema
|
|
977
|
+
]);
|
|
978
|
+
const SECTION_TYPES = sectionDefinitions.map((d) => d.type);
|
|
979
|
+
function getSectionDefinition(type) {
|
|
980
|
+
const def = sectionDefinitions.find((d) => d.type === type);
|
|
981
|
+
if (!def) throw new Error(`Seção desconhecida: "${type}". Tipos válidos: ${SECTION_TYPES.join(", ")}`);
|
|
982
|
+
return def;
|
|
983
|
+
}
|
|
984
|
+
/** Tipos de seção de um módulo ("core" = sempre presentes). */
|
|
985
|
+
function sectionsOfModule(module) {
|
|
986
|
+
return sectionDefinitions.filter((d) => d.module === module).map((d) => d.type);
|
|
987
|
+
}
|
|
988
|
+
//#endregion
|
|
989
|
+
//#region src/page.ts
|
|
990
|
+
/** Slugs que não podem ser página (rotas dos módulos e do Next), mesmo com o módulo desligado. */
|
|
991
|
+
const RESERVED_PAGE_SLUGS = [
|
|
992
|
+
"produtos",
|
|
993
|
+
"produto",
|
|
994
|
+
"servicos",
|
|
995
|
+
"servico",
|
|
996
|
+
"ultimos-projetos",
|
|
997
|
+
"api",
|
|
998
|
+
"_next",
|
|
999
|
+
"home"
|
|
1000
|
+
];
|
|
1001
|
+
const pageSlugSchema = slugSchema.refine((s) => !RESERVED_PAGE_SLUGS.includes(s), { error: (iss) => `"${String(iss.input)}" é reservado (${RESERVED_PAGE_SLUGS.join(", ")}): escolha outro slug` });
|
|
1002
|
+
function sectionList(where) {
|
|
1003
|
+
return z.array(sectionSchema).superRefine((sections, ctx) => {
|
|
1004
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1005
|
+
sections.forEach((section, i) => {
|
|
1006
|
+
const { limits, label } = getSectionDefinition(section.type);
|
|
1007
|
+
const n = (counts.get(section.type) ?? 0) + 1;
|
|
1008
|
+
counts.set(section.type, n);
|
|
1009
|
+
if (limits.maxPerPage !== void 0 && n > limits.maxPerPage) ctx.addIssue({
|
|
1010
|
+
code: "custom",
|
|
1011
|
+
path: [i],
|
|
1012
|
+
message: `a seção "${section.type}" (${label}) pode aparecer no máximo ${limits.maxPerPage} vez(es) por página`
|
|
1013
|
+
});
|
|
1014
|
+
if (limits.homeOnly && where === "page") ctx.addIssue({
|
|
1015
|
+
code: "custom",
|
|
1016
|
+
path: [i],
|
|
1017
|
+
message: `a seção "${section.type}" (${label}) só pode ficar na home`
|
|
1018
|
+
});
|
|
1019
|
+
});
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
const homeSchema = z.strictObject({ sections: sectionList("home").meta({ label: "Seções" }) });
|
|
1023
|
+
const pageSchema = z.strictObject({
|
|
1024
|
+
title: z.string().min(1).meta({ label: "Título da página" }),
|
|
1025
|
+
description: z.string().min(1).optional().meta({
|
|
1026
|
+
label: "Descrição (SEO)",
|
|
1027
|
+
ask: "optional"
|
|
1028
|
+
}),
|
|
1029
|
+
sections: sectionList("page").meta({
|
|
1030
|
+
label: "Seções",
|
|
1031
|
+
ask: "never"
|
|
1032
|
+
})
|
|
1033
|
+
});
|
|
1034
|
+
//#endregion
|
|
1035
|
+
export { slugSchema as A, serviceSchema as C, imageSrc as D, httpsUrl as E, urlProblem as M, ptBRError as O, serviceContentSchema as S, hostnameSchema as T, catalogItemBase as _, SECTION_TYPES as a, productContentSchema as b, sectionSchema as c, fieldAsk as d, fieldMeta as f, PLATFORM_HOSTS as g, siteLinkSchema as h, pageSlugSchema as i, uniqueItems as j, siteHref as k, sectionsOfModule as l, sectionBase as m, homeSchema as n, getSectionDefinition as o, fieldMetaSchema as p, pageSchema as r, sectionDefinitions as s, RESERVED_PAGE_SLUGS as t, defineSection as u, installmentSchema as v, cssValue as w, productSchema as x, platformUrl as y };
|