Uygulama formları tek şemayla iki tarafta doğrulanır: aynı model tarayıcıda alan hatalarını gösterir, sunucuda gövdeyi temizleyip doğrular. Şema sunucu-güvenli @tecof/app-ui/schema girişinden gelir (React/DOM yok), form bileşenleri istemci girişinden.
1. Şemayı tanımlayın#
// "use client" YOK: bu dosya hem istemcide hem sunucuda içe aktarılır.
import { createModel } from "@tecof/app-ui/schema";
export type Settings = { companyName: string; email: string; vkn: string; mode: string; apiKey: string };
export const settingsModel = createModel<Settings>((v, ctx) => ({
companyName: v.text({ required: true, max: 120 }),
email: v.email({ required: true }),
vkn: v.vknOrTckn(),
mode: v.select(["draft", "live"], { required: true }),
apiKey: v.secret({ required: !ctx.secrets.apiKey }), // kayıtlıysa boş = değiştirme
}));Kurallar (v)#
| Kural | Açıklama |
|---|---|
text({ required, min, max, pattern, trim }) | Metin; trim: false sunucuda kırpmaz (SMS/şablon gövdesi) |
email() | E-posta |
httpsUrl({ hosts }) | Yalnız https, izin listeli host’lar |
trMobile() · e164Phone() | Türkiye cep telefonu · E.164 |
vkn() · tckn() · vknOrTckn() | Vergi / kimlik numarası (sağlama hanesiyle) |
iban() | TR IBAN (mod-97) |
number({ min, max, integer }) | Sayı |
boolean({ mustBeTrue }) | Onay kutusu |
select(values) · multiSelect(values, { min, max }) | Seçim |
secret({ required, min, max }) | Vault’a gidecek sır |
custom(check, message, { required, priority }) | Özel kural; check(value, formData) |
Mesajlar tr/en hazırdır (createRules("en")); özel mesaj için required: "…".
Koşullu zorunluluk#
Her kural requiredIf: (form) => boolean alır; boş değerde de çalışır ve alanın zorunlu yıldızı güncel form değerine göre çıkar. Koşulu belirleyen alana .proxy(["bağımlı-alan"]) ekleyin ki değişince bağımlı alan yeniden denetlensin:
mode: v.select(["draft", "live"], { required: true }).proxy(["webhookUrl"]),
webhookUrl: v.httpsUrl({ hosts: ["hooks.ornek.com"], requiredIf: (d) => d.mode === "live" }),Çapraz alan kuralı için v.custom((value: unknown, data?: Settings) => …, "mesaj", { priority: true }) kullanın. Ham schema-typed MixedType<A, B>() yazmayın (A değer, B form verisi tipidir ve kolay karışır); gerekiyorsa mixedOf<Değer, FormVerisi>().
2. İstemci formu#
"use client";
import { useState } from "react";
import { useSessionFetch } from "@tecof/app-bridge/react";
import { AppForm, FormActions, Section, SecretField, SelectField, TextField, useAppToast, type SecretState } from "@tecof/app-ui";
import { readFieldErrors } from "@tecof/app-ui/schema";
import { settingsModel, type Settings } from "@/lib/settings-model";
export function SettingsForm({ initial, apiKey }: { initial: Settings; apiKey: SecretState }) {
const [value, setValue] = useState(initial);
const [saved, setSaved] = useState(initial);
const fetch = useSessionFetch(); // 60 sn'lik oturum token'ını ekler
const toast = useAppToast();
return (
<AppForm
model={settingsModel}
modelContext={{ secrets: { apiKey: apiKey.set } }}
formValue={value}
initialValue={saved}
onChange={setValue}
onSubmit={async (data) => {
const res = await fetch("/api/settings", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(data),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) return { errors: readFieldErrors(json), message: json.message };
setSaved({ ...data, apiKey: "" });
setValue({ ...data, apiKey: "" });
toast.success("Ayarlar kaydedildi");
}}
>
<Section title="Bağlantı" description="Sağlayıcı hesabınız" padded={false}>
<TextField name="companyName" label="Firma adı" />
<TextField name="email" label="E-posta" type="email" />
<TextField name="vkn" label="VKN / TCKN" inputMode="numeric" />
<SelectField name="mode" label="Mod" options={[{ label: "Taslak", value: "draft" }, { label: "Canlı", value: "live" }]} />
<SecretField name="apiKey" label="API anahtarı" stored={apiKey} />
</Section>
<FormActions />
</AppForm>
);
}Hazır alanlar: TextField, TextareaField, NumberField, SelectField (8+ seçenekte aranabilir), ToggleField, CheckboxGroupField, RadioField, PasswordField, SecretField ve genel Field (accepter={DatePicker} vb.). Girdi öznitelikleri (inputMode, autoComplete, maxLength, pattern, autoFocus, enterKeyHint, data-*) doğrudan <input>’a geçer. Zorunlu işareti şemadan gelir. Çekmece/modal içinde layout="stack".
AppFormgönderimde istemci doğrulaması yapar; hata varsaonCheckFail(errors, "client")çağrılır (odaklanmadan önce — katlanmış satırı ya da sekmeyi burada açın), uyarı toast’ı gösterilir ve ilk hatalı alana odaklanılır. Sunucu{ errors }dönerseonCheckFail(errors, "server").- Form içinden durum:
useAppForm()→{ errors, saving, dirty, submit, reset, … }. FormActionsvarsayılan olarak Kaydet + geri al’dır (mobilde değişiklik varken alta yapışır). Gönderme formları içinsubmitLabel="Gönder" submittingLabel="Gönderiliyor…" submitIcon={<Send className="size-4" />} hideCancel; formun ortasında tek düğme için<SubmitButton>. Kendi gönder düğmenizi ya daonSubmitCapturegeçici çözümünü yazmayın.
3. Sunucu ucu#
import { resolveLocale, validateOnServer } from "@tecof/app-ui/schema";
import { settingsModel } from "@/lib/settings-model";
import { tecof, withSession } from "@/lib/tecof";
export async function PUT(request: Request) {
return withSession(request, async ({ installationId, locale }) => {
const client = tecof.client(installationId);
const hasKey = (await client.secrets.list()).some((s) => s.key === "apiKey");
const r = validateOnServer(settingsModel, await request.json().catch(() => null), {
locale: resolveLocale(locale),
context: { secrets: { apiKey: hasKey } },
});
if (!r.ok) return r.response(); // 422 { error: "validation_failed", message, fields }
const { apiKey, ...settings } = r.value;
if (apiKey) await client.secrets.set("apiKey", apiKey);
await client.settings.set(settings, { merge: true });
return Response.json({ ok: true });
});
}r.valueyalnız şema alanlarını içerir: fazlası atılır, metinler kırpılır (trim: falsehariç), sayı ve boolean’lar çevrilir.- Mağaza ve kurulum kimliği yalnız doğrulanmış oturum token’ından alınır; gövde, başlık ya da sorgudan asla.
withSessionşablondaki yardımcıdır (tecof.sessionFromRequest(request)üzerine kurulu).
SecretField ve Vault#
Sunucu sırrı istemciye hiç göndermez; yalnız durumunu gönderir: { set, last4?, display?, updatedAt? } (display, sunucunun ürettiği maske; ör. "850*****03"). Boş gönderim mevcut sırrı korur; yeni değer client.secrets.set(key, value) ile Vault’a yazılır. Ayrıntı: Ayarlar, Vault ve metafield’lar.
Türkiye’ye özgü yardımcılar#
import { parseTrMobile, normalizeTrMobile, maskTrMobile, normalizeIban, isVkn, isTckn } from "@tecof/app-ui/schema";
parseTrMobile("+90 (0532) 111 22 33"); // { ok: true, national: "5321112233", e164: "+905321112233" }
parseTrMobile("0212 111 22 33"); // { ok: false, reason: "not-mobile" }
normalizeTrMobile("0532 111 22 33"); // "+905321112233"
maskTrMobile("05321112233"); // "0532 *** ** 33" — günlükte yalnız buparseTrMobile gerekçeli sonuç döner (missing · invalid · not-tr · not-mobile); harf içeren değeri reddeder, +90 / 0090 / 90 / 0 öneklerini, boşluk, tire, nokta ve parantezi kabul eder. Diğer saf yardımcılar: isVkn, isTckn, isTrIban, normalizeIban, isE164Phone, isHttpsUrl(url, hosts), maskSecret, formatDateTime, formatMoney.
Test#
Şemayı istemci ve sunucuda aynı mesajı verdiğinden emin olacak şekilde test edin; route handler’larda token yokken 401, alan hatasında 422 ve Vault’a sır yazımını doğrulayın. Uygulama şablonu testleri Node’un yerleşik test çalıştırıcısıyla koşar (node --test).