Let campaign titles in any language produce a valid URL slug
Arabic, Persian, CJK, and other non-Latin titles were collapsing to an empty d-tag because slugifyCampaignIdentifier only kept [a-z0-9] after NFKD. NFKD doesn't transliterate Arabic to Latin, so a title like حملة لمساعدة الأطفال slugified to "" and the user hit the cryptic errorTitleInvalid message at submit time — after walking through the entire wizard. Route the title through the slugify package's charMap first (covers Arabic, Persian, Cyrillic, Greek, Georgian, Armenian, Vietnamese, common Latin diacritics, currency symbols, smart quotes). For inputs that still produce no ASCII characters — emoji-only titles, CJK, Thai, Tamil — buildCampaignSlug returns a random campaign-XXXXXX identifier so the user can still publish; the human-readable title lives in the title tag, not the URL. Also strip Unicode bidi controls and zero-width characters (RLM/LRM/FSI/PDI/ZWNJ/BOM) before they reach the title tag. RTL keyboards routinely insert these invisibly, and preserving them in display strings is a homograph/phishing vector. Surface validation under the title input itself rather than at submit: when the title transliterates cleanly, show the slug preview in a muted-tone code block; when it doesn't, show an amber notice explaining that a random URL identifier will be generated and that the title is preserved verbatim. Hidden in edit mode where the d-tag is locked.
This commit is contained in:
+87
-5
@@ -1,5 +1,6 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import slugify from 'slugify';
|
||||
|
||||
import { COUNTRIES } from '@/lib/countries';
|
||||
import { parseCountryIdentifier } from '@/lib/countryIdentifiers';
|
||||
@@ -281,17 +282,98 @@ export function encodeCampaignNaddr(campaign: ParsedCampaign, relays?: string[])
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip Unicode bidi controls, zero-width characters, and BOMs from a
|
||||
* user-supplied title before it lands in an event tag or feeds the slug
|
||||
* deriver. These code points are invisible in most rendering contexts
|
||||
* but survive copy-paste — they're routinely auto-inserted by RTL
|
||||
* keyboards (RLM/LRM/FSI/PDI), and they're a phishing vector when
|
||||
* preserved in display strings.
|
||||
*
|
||||
* - `\u200B-\u200F` zero-width space / joiner / non-joiner / LRM / RLM
|
||||
* - `\u202A-\u202E` LRE / RLE / PDF / LRO / RLO bidi embedding+override
|
||||
* - `\u2066-\u2069` LRI / RLI / FSI / PDI bidi isolates
|
||||
* - `\uFEFF` zero-width no-break space (BOM)
|
||||
*
|
||||
* Whitespace (including non-breaking variants) is preserved here —
|
||||
* trimming is the caller's job.
|
||||
*/
|
||||
export function sanitizeCampaignTitle(input: string): string {
|
||||
return input.replace(/[\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Slugifies a free-form string into a `d` tag value. Lowercase, ASCII-only,
|
||||
* hyphenated. Returns an empty string if nothing remains after stripping.
|
||||
*
|
||||
* Non-Latin scripts (Arabic, Cyrillic, Greek, Persian, Georgian, etc.) are
|
||||
* transliterated to ASCII via the `slugify` package's built-in charMap
|
||||
* before the strict-ASCII filter runs — so an Arabic title like `حملة`
|
||||
* becomes `hmlh` instead of collapsing to empty. Combining marks (diacritics
|
||||
* on Latin letters) are stripped via NFKD so `café` becomes `cafe`.
|
||||
*
|
||||
* The output is suitable for direct comparison against the strict d-tag
|
||||
* regex `/^[a-z0-9][a-z0-9-]{0,63}$/`; callers that need a guaranteed-
|
||||
* non-empty d-tag should use {@link buildCampaignSlug}, which adds a random
|
||||
* fallback for inputs that don't transliterate to any ASCII alphanumeric.
|
||||
*/
|
||||
export function slugifyCampaignIdentifier(input: string): string {
|
||||
return input
|
||||
.toLowerCase()
|
||||
// Drop bidi/zero-width controls first so they don't affect the slug
|
||||
// (RLM/LRM around a Latin title would otherwise survive into the
|
||||
// transliteration step as `\u200F` → no charMap entry → kept verbatim
|
||||
// → filtered, but only after pinning down the leading-hyphen position).
|
||||
const cleaned = sanitizeCampaignTitle(input);
|
||||
|
||||
// `slugify` runs its charMap (covers Arabic, Persian, Cyrillic, Greek,
|
||||
// Georgian, Armenian, Vietnamese, common Latin diacritics, currency
|
||||
// symbols, smart quotes, etc.) and lowercases. We follow up with our
|
||||
// own NFKD + combining-mark strip to catch any Latin diacritics that
|
||||
// slugify's map missed, then collapse to the strict d-tag charset.
|
||||
const transliterated = slugify(cleaned, {
|
||||
lower: true,
|
||||
// We strip everything outside [a-z0-9] ourselves below, so let
|
||||
// slugify keep punctuation as-is — its `strict` mode would drop
|
||||
// useful separators that we'd rather convert to hyphens.
|
||||
strict: false,
|
||||
trim: true,
|
||||
});
|
||||
|
||||
return transliterated
|
||||
.normalize('NFKD')
|
||||
// strip combining marks
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[\u0300-\u036f]/g, '') // strip combining marks
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 64);
|
||||
.slice(0, 64)
|
||||
// Re-trim trailing hyphens introduced by the 64-char truncation.
|
||||
.replace(/-+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a publishable d-tag from a campaign title.
|
||||
*
|
||||
* Returns a `{ slug, isFallback }` pair:
|
||||
* - `slug` — a valid d-tag matching `/^[a-z0-9][a-z0-9-]{0,63}$/`.
|
||||
* - `isFallback` — `true` when the title contained no ASCII-transliterable
|
||||
* characters (e.g. emoji-only, or scripts not covered by the
|
||||
* transliteration map), and the slug is a random 10-character
|
||||
* identifier of the form `campaign-XXXXXX`.
|
||||
*
|
||||
* The fallback exists so users typing titles in scripts like Chinese,
|
||||
* Japanese, Korean, Thai, Tamil, etc. can still publish a campaign —
|
||||
* the human-readable title lives in the `title` tag, so an opaque
|
||||
* d-tag has no user-facing cost beyond an uglier URL.
|
||||
*/
|
||||
export function buildCampaignSlug(input: string): { slug: string; isFallback: boolean } {
|
||||
const slug = slugifyCampaignIdentifier(input);
|
||||
if (slug && /^[a-z0-9][a-z0-9-]{0,63}$/.test(slug)) {
|
||||
return { slug, isFallback: false };
|
||||
}
|
||||
return { slug: `campaign-${randomHex(6)}`, isFallback: true };
|
||||
}
|
||||
|
||||
/** Cryptographically-random lowercase hex string of the given byte length. */
|
||||
function randomHex(bytes: number): string {
|
||||
const buf = new Uint8Array(bytes);
|
||||
crypto.getRandomValues(buf);
|
||||
return Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
@@ -560,6 +560,8 @@
|
||||
"errorTitleRequired": "العنوان مطلوب.",
|
||||
"errorTitleInvalid": "يجب أن يحتوي العنوان على حروف أو أرقام لإنشاء رابط الحملة.",
|
||||
"errorSlugInvalid": "يجب أن يكون المعرّف بحروف صغيرة وأرقام وشرطات.",
|
||||
"slugPreview": "سيستخدم رابط حملتك المعرّف",
|
||||
"slugFallbackNotice": "تعذّر تحويل عنوانك إلى معرّف للرابط، لذا سننشئ معرّفًا عشوائيًا. أمّا عنوانك فسيبقى تمامًا كما كتبته.",
|
||||
"errorHdUnavailable": "المحفظة المدمجة غير متاحة لهذا التسجيل.",
|
||||
"errorSpUnavailable": "عنوان الدفع الصامت غير متاح لهذا التسجيل.",
|
||||
"errorOnchainInvalid": "العنوان على السلسلة ليس عنوان bech32(m) شبكة رئيسية معروفًا (bc1q… / bc1p…).",
|
||||
|
||||
@@ -998,6 +998,8 @@
|
||||
"errorTitleRequired": "Title is required.",
|
||||
"errorTitleInvalid": "Title must include letters or numbers so a campaign URL can be created.",
|
||||
"errorSlugInvalid": "Identifier must be lowercase letters, numbers, and hyphens.",
|
||||
"slugPreview": "Your campaign URL will use the identifier",
|
||||
"slugFallbackNotice": "We couldn't turn your title into a URL identifier, so we'll generate a random one. Your title stays exactly as you typed it.",
|
||||
"errorHdUnavailable": "Built-in wallet is unavailable for this login.",
|
||||
"errorSpUnavailable": "Silent-payment address is unavailable for this login.",
|
||||
"errorOnchainInvalid": "The on-chain address is not a recognized mainnet bech32(m) address (bc1q… / bc1p…).",
|
||||
|
||||
@@ -572,6 +572,8 @@
|
||||
"errorTitleRequired": "El título es obligatorio.",
|
||||
"errorTitleInvalid": "El título debe incluir letras o números para crear una URL de campaña.",
|
||||
"errorSlugInvalid": "El identificador debe ser letras minúsculas, números y guiones.",
|
||||
"slugPreview": "La URL de tu campaña usará el identificador",
|
||||
"slugFallbackNotice": "No pudimos convertir tu título en un identificador de URL, así que generaremos uno aleatorio. Tu título se conserva exactamente como lo escribiste.",
|
||||
"errorHdUnavailable": "La cartera integrada no está disponible para este inicio de sesión.",
|
||||
"errorSpUnavailable": "La dirección de pago silencioso no está disponible para este inicio de sesión.",
|
||||
"errorOnchainInvalid": "La dirección on-chain no es una dirección bech32(m) mainnet reconocida (bc1q… / bc1p…).",
|
||||
|
||||
@@ -572,6 +572,8 @@
|
||||
"errorTitleRequired": "عنوان الزامی است.",
|
||||
"errorTitleInvalid": "عنوان باید شامل حروف یا اعداد باشد تا یک نشانی برای کمپین ساخته شود.",
|
||||
"errorSlugInvalid": "شناسه باید با حروف کوچک، اعداد و خط تیره باشد.",
|
||||
"slugPreview": "نشانی کمپین تو از این شناسه استفاده خواهد کرد",
|
||||
"slugFallbackNotice": "نتوانستیم عنوانت را به یک شناسهٔ نشانی تبدیل کنیم، پس یک شناسهٔ تصادفی میسازیم. عنوانت دقیقاً همانطور که نوشتی باقی میماند.",
|
||||
"errorHdUnavailable": "کیف پول داخلی برای این ورود در دسترس نیست.",
|
||||
"errorSpUnavailable": "نشانی پرداخت بیصدا برای این ورود در دسترس نیست.",
|
||||
"errorOnchainInvalid": "نشانی روی زنجیره، یک نشانی bech32(m) شبکهٔ اصلی شناختهشده نیست (bc1q… / bc1p…).",
|
||||
|
||||
@@ -994,6 +994,8 @@
|
||||
"errorTitleRequired": "Le titre est obligatoire.",
|
||||
"errorTitleInvalid": "Le titre doit contenir des lettres ou des chiffres pour qu'une URL de campagne puisse être créée.",
|
||||
"errorSlugInvalid": "L'identifiant doit être composé de lettres minuscules, de chiffres et de traits d'union.",
|
||||
"slugPreview": "L'URL de votre campagne utilisera l'identifiant",
|
||||
"slugFallbackNotice": "Nous n'avons pas pu transformer votre titre en identifiant d'URL, nous en générerons donc un aléatoire. Votre titre est conservé exactement tel que vous l'avez saisi.",
|
||||
"errorHdUnavailable": "Le portefeuille intégré n'est pas disponible pour ce mode de connexion.",
|
||||
"errorSpUnavailable": "L'adresse de paiement silencieux n'est pas disponible pour ce mode de connexion.",
|
||||
"errorOnchainInvalid": "L'adresse on-chain n'est pas une adresse bech32(m) mainnet reconnue (bc1q… / bc1p…).",
|
||||
|
||||
@@ -1004,6 +1004,8 @@
|
||||
"errorTitleRequired": "शीर्षक ज़रूरी है।",
|
||||
"errorTitleInvalid": "शीर्षक में अक्षर या अंक होने चाहिए ताकि कैंपेन URL बन सके।",
|
||||
"errorSlugInvalid": "पहचानकर्ता में छोटे अक्षर, अंक, और हाइफ़न ही होने चाहिए।",
|
||||
"slugPreview": "आपके कैंपेन के URL में यह पहचानकर्ता इस्तेमाल होगा",
|
||||
"slugFallbackNotice": "हम आपके शीर्षक से URL पहचानकर्ता नहीं बना सके, इसलिए हम एक रैंडम पहचानकर्ता बनाएँगे। आपका शीर्षक जैसा आपने टाइप किया है वैसा ही रहेगा।",
|
||||
"errorHdUnavailable": "इस लॉगिन के लिए बिल्ट-इन वॉलेट उपलब्ध नहीं है।",
|
||||
"errorSpUnavailable": "इस लॉगिन के लिए साइलेंट-पेमेंट एड्रेस उपलब्ध नहीं है।",
|
||||
"errorOnchainInvalid": "ऑन-चेन एड्रेस कोई पहचाना mainnet bech32(m) एड्रेस नहीं है (bc1q… / bc1p…)।",
|
||||
|
||||
@@ -1004,6 +1004,8 @@
|
||||
"errorTitleRequired": "Judul wajib diisi.",
|
||||
"errorTitleInvalid": "Judul harus mengandung huruf atau angka agar URL kampanye bisa dibuat.",
|
||||
"errorSlugInvalid": "Pengenal harus berupa huruf kecil, angka, dan tanda hubung.",
|
||||
"slugPreview": "URL kampanye Anda akan menggunakan pengenal ini",
|
||||
"slugFallbackNotice": "Kami tidak dapat mengubah judul Anda menjadi pengenal URL, jadi kami akan membuatkan pengenal acak. Judul Anda tetap persis seperti yang Anda tulis.",
|
||||
"errorHdUnavailable": "Dompet bawaan tidak tersedia untuk login ini.",
|
||||
"errorSpUnavailable": "Alamat silent-payment tidak tersedia untuk login ini.",
|
||||
"errorOnchainInvalid": "Alamat on-chain bukan alamat bech32(m) mainnet yang dikenal (bc1q… / bc1p…).",
|
||||
|
||||
@@ -572,6 +572,8 @@
|
||||
"errorTitleRequired": "ត្រូវការចំណងជើង។",
|
||||
"errorTitleInvalid": "ចំណងជើងត្រូវតែមានអក្សរ ឬលេខ ដើម្បីបង្កើត URL យុទ្ធនាការ។",
|
||||
"errorSlugInvalid": "កំណត់អត្តសញ្ញាណត្រូវតែជាអក្សរតូច លេខ និងសហសញ្ញា។",
|
||||
"slugPreview": "URL យុទ្ធនាការរបស់អ្នកនឹងប្រើកំណត់អត្តសញ្ញាណនេះ",
|
||||
"slugFallbackNotice": "យើងមិនអាចបំប្លែងចំណងជើងរបស់អ្នកទៅជាកំណត់អត្តសញ្ញាណ URL បានទេ ដូច្នេះយើងនឹងបង្កើតមួយដោយចៃដន្យ។ ចំណងជើងរបស់អ្នកនៅដដែលដូចអ្វីដែលអ្នកបានវាយបញ្ចូល។",
|
||||
"errorHdUnavailable": "កាបូបដែលភ្ជាប់មកមិនអាចប្រើបានសម្រាប់ការចូលនេះ។",
|
||||
"errorSpUnavailable": "អាសយដ្ឋានបង់ប្រាក់ស្ងាត់មិនអាចប្រើបានសម្រាប់ការចូលនេះ។",
|
||||
"errorOnchainInvalid": "អាសយដ្ឋាន on-chain មិនមែនជាអាសយដ្ឋាន bech32(m) mainnet ដែលត្រូវបានទទួលស្គាល់ទេ (bc1q… / bc1p…)។",
|
||||
|
||||
@@ -572,6 +572,8 @@
|
||||
"errorTitleRequired": "سرلیک اړین دی.",
|
||||
"errorTitleInvalid": "سرلیک باید توري یا عددونه ولري ترڅو د کمپاین لینک جوړ شي.",
|
||||
"errorSlugInvalid": "پېژندونکی باید واړه توري، عددونه، او ډشونه ولري.",
|
||||
"slugPreview": "ستاسو د کمپاین لینک به دا پېژندونکی وکاروي",
|
||||
"slugFallbackNotice": "ستاسو سرلیک مو د لینک پېژندونکي ته بدلولی نه شو، نو موږ به یو ناڅاپه پېژندونکی جوړ کړو. ستاسو سرلیک به همغه شان پاتې وي لکه څنګه چې مو لیکلی دی.",
|
||||
"errorHdUnavailable": "د دې ننوتلو لپاره داخلي پاکټ شتون نه لري.",
|
||||
"errorSpUnavailable": "د دې ننوتلو لپاره د چپ پیسو پته شتون نه لري.",
|
||||
"errorOnchainInvalid": "پر چین پته د پېژندل شوي mainnet bech32(m) پته نه ده (bc1q… / bc1p…).",
|
||||
|
||||
@@ -1004,6 +1004,8 @@
|
||||
"errorTitleRequired": "O título é obrigatório.",
|
||||
"errorTitleInvalid": "O título deve incluir letras ou números para que uma URL de campanha possa ser criada.",
|
||||
"errorSlugInvalid": "O identificador deve ser composto de letras minúsculas, números e hifens.",
|
||||
"slugPreview": "A URL da sua campanha usará o identificador",
|
||||
"slugFallbackNotice": "Não conseguimos transformar seu título em um identificador de URL, então geraremos um aleatório. Seu título permanece exatamente como você o digitou.",
|
||||
"errorHdUnavailable": "A carteira integrada não está disponível para este login.",
|
||||
"errorSpUnavailable": "O endereço de pagamento silencioso não está disponível para este login.",
|
||||
"errorOnchainInvalid": "O endereço on-chain não é um endereço bech32(m) mainnet reconhecido (bc1q… / bc1p…).",
|
||||
|
||||
@@ -1004,6 +1004,8 @@
|
||||
"errorTitleRequired": "Название обязательно.",
|
||||
"errorTitleInvalid": "Название должно содержать буквы или цифры, чтобы можно было создать URL кампании.",
|
||||
"errorSlugInvalid": "Идентификатор должен состоять из строчных букв, цифр и дефисов.",
|
||||
"slugPreview": "URL вашей кампании будет использовать идентификатор",
|
||||
"slugFallbackNotice": "Нам не удалось превратить ваше название в URL-идентификатор, поэтому мы создадим случайный. Название останется ровно таким, как вы его ввели.",
|
||||
"errorHdUnavailable": "Встроенный кошелёк недоступен для этого входа.",
|
||||
"errorSpUnavailable": "Адрес тихого платежа недоступен для этого входа.",
|
||||
"errorOnchainInvalid": "Адрес в блокчейне не является распознанным bech32(m) адресом mainnet (bc1q… / bc1p…).",
|
||||
|
||||
@@ -572,6 +572,8 @@
|
||||
"errorTitleRequired": "Musoro unodiwa.",
|
||||
"errorTitleInvalid": "Musoro unofanira kuva nemavara kana manhamba kuti URL yecampaign igadzirike.",
|
||||
"errorSlugInvalid": "Kupiwa zita kunofanira kunge kune mavara madiki, manhamba, uye dashes.",
|
||||
"slugPreview": "URL yecampaign yako ichashandisa kupiwa zita uku",
|
||||
"slugFallbackNotice": "Hatina kukwanisa kushandura musoro wako kuita kupiwa zita kweURL, saka tichagadzira imwe nemazvirokwazvo. Musoro wako unoramba wakangoita sezvawakaunyora.",
|
||||
"errorHdUnavailable": "Chikwama chakavakirwa hachiwanikwi pakupinda uku.",
|
||||
"errorSpUnavailable": "Kero yemubhadharo unyararo haiwanikwi pakupinda uku.",
|
||||
"errorOnchainInvalid": "Kero yepa-chain haisi kero yebech32(m) mainnet inozivikanwa (bc1q… / bc1p…).",
|
||||
|
||||
@@ -1003,6 +1003,8 @@
|
||||
"errorTitleRequired": "Kichwa kinahitajika.",
|
||||
"errorTitleInvalid": "Kichwa lazima kijumuishe herufi au nambari ili URL ya kampeni iweze kuundwa.",
|
||||
"errorSlugInvalid": "Kitambulisho lazima kiwe herufi ndogo, nambari, na vistari.",
|
||||
"slugPreview": "URL ya kampeni yako itatumia kitambulisho hiki",
|
||||
"slugFallbackNotice": "Hatukuweza kubadilisha kichwa chako kuwa kitambulisho cha URL, kwa hivyo tutatengeneza kimoja kibahatishi. Kichwa chako kitabaki kama ulivyokiandika.",
|
||||
"errorHdUnavailable": "Pochi iliyojengwa ndani haipatikani kwa kuingia hii.",
|
||||
"errorSpUnavailable": "Anwani ya malipo ya kimya haipatikani kwa kuingia hii.",
|
||||
"errorOnchainInvalid": "Anwani ya katika-mnyororo si anwani ya mtandao mkuu ya bech32(m) inayotambulika (bc1q… / bc1p…).",
|
||||
|
||||
@@ -1003,6 +1003,8 @@
|
||||
"errorTitleRequired": "Başlık zorunludur.",
|
||||
"errorTitleInvalid": "Kampanya URL'inin oluşturulabilmesi için başlığın harf veya rakam içermesi gerekir.",
|
||||
"errorSlugInvalid": "Tanımlayıcı yalnızca küçük harf, rakam ve tire içermelidir.",
|
||||
"slugPreview": "Kampanya URL'iniz şu tanımlayıcıyı kullanacak",
|
||||
"slugFallbackNotice": "Başlığınızı bir URL tanımlayıcısına dönüştüremedik, bu yüzden rastgele bir tane oluşturacağız. Başlığınız yazdığınız şekliyle aynen kalacak.",
|
||||
"errorHdUnavailable": "Bu giriş türü için yerleşik cüzdan kullanılamıyor.",
|
||||
"errorSpUnavailable": "Bu giriş türü için sessiz ödeme adresi kullanılamıyor.",
|
||||
"errorOnchainInvalid": "Zincir üstü adres tanınan bir mainnet bech32(m) adresi değil (bc1q… / bc1p…).",
|
||||
|
||||
@@ -572,6 +572,8 @@
|
||||
"errorTitleRequired": "標題為必填項。",
|
||||
"errorTitleInvalid": "標題必須包含字母或數字,才能建立活動 URL。",
|
||||
"errorSlugInvalid": "識別符號必須是小寫字母、數字和連字元。",
|
||||
"slugPreview": "你的活動 URL 將使用此識別符號",
|
||||
"slugFallbackNotice": "我們無法將你的標題轉換為 URL 識別符號,因此將自動產生一個隨機識別符號。你的標題會原樣保留。",
|
||||
"errorHdUnavailable": "此登入方式不支援內建錢包。",
|
||||
"errorSpUnavailable": "此登入方式不支援靜默支付地址。",
|
||||
"errorOnchainInvalid": "鏈上地址不是已識別的主網 bech32(m) 地址(bc1q… / bc1p…)。",
|
||||
|
||||
@@ -572,6 +572,8 @@
|
||||
"errorTitleRequired": "标题为必填项。",
|
||||
"errorTitleInvalid": "标题必须包含字母或数字,才能创建活动 URL。",
|
||||
"errorSlugInvalid": "标识符必须是小写字母、数字和连字符。",
|
||||
"slugPreview": "你的活动 URL 将使用此标识符",
|
||||
"slugFallbackNotice": "我们无法将你的标题转换为 URL 标识符,因此将自动生成一个随机标识符。你的标题会原样保留。",
|
||||
"errorHdUnavailable": "此登录方式不支持内置钱包。",
|
||||
"errorSpUnavailable": "此登录方式不支持静默支付地址。",
|
||||
"errorOnchainInvalid": "链上地址不是已识别的主网 bech32(m) 地址(bc1q… / bc1p…)。",
|
||||
|
||||
@@ -43,9 +43,11 @@ import { useToast } from '@/hooks/useToast';
|
||||
import { formatBTC, satsToUSD } from '@/lib/bitcoin';
|
||||
import {
|
||||
CAMPAIGN_KIND,
|
||||
buildCampaignSlug,
|
||||
encodeCampaignNaddr,
|
||||
parseCampaign,
|
||||
parseCampaignWallet,
|
||||
sanitizeCampaignTitle,
|
||||
slugifyCampaignIdentifier,
|
||||
} from '@/lib/campaign';
|
||||
import { getTodayDateInput } from '@/lib/dateInput';
|
||||
@@ -254,9 +256,19 @@ export function CreateCampaignPage() {
|
||||
const editCampaign = isEditMode ? editCampaignQuery.data : null;
|
||||
|
||||
// The slug is protocol plumbing: derive it from the title instead of asking
|
||||
// fundraisers to understand Nostr d-tags.
|
||||
const derivedIdentifier = useMemo(() => slugifyCampaignIdentifier(title), [title]);
|
||||
const activeIdentifier = editCampaign?.identifier ?? derivedIdentifier;
|
||||
// fundraisers to understand Nostr d-tags. `buildCampaignSlug` handles
|
||||
// non-Latin titles via transliteration (Arabic → ASCII, etc.) and falls
|
||||
// back to a random `campaign-XXXXXX` for scripts that can't be
|
||||
// transliterated — so users typing in any language can publish.
|
||||
const derivedSlug = useMemo(() => buildCampaignSlug(title), [title]);
|
||||
/**
|
||||
* Transliteration-only preview shown under the title field. Distinct
|
||||
* from {@link derivedSlug}: this never falls back to a random hex slug,
|
||||
* so the UI can tell the user "we couldn't read your title — your URL
|
||||
* will be a random identifier" when it returns empty.
|
||||
*/
|
||||
const previewSlug = useMemo(() => slugifyCampaignIdentifier(title), [title]);
|
||||
const activeIdentifier = editCampaign?.identifier ?? derivedSlug.slug;
|
||||
const minDeadline = useMemo(() => getTodayDateInput(), []);
|
||||
|
||||
// Live-parsed custom inputs, used to drive disclaimers and inline
|
||||
@@ -325,7 +337,7 @@ export function CreateCampaignPage() {
|
||||
if (editCampaign && editCampaign.pubkey !== user.pubkey) {
|
||||
throw new Error(t('campaignsCreate.errorEditNotOwner'));
|
||||
}
|
||||
const trimmedTitle = title.trim();
|
||||
const trimmedTitle = sanitizeCampaignTitle(title).trim();
|
||||
const slug = activeIdentifier;
|
||||
|
||||
if (!trimmedTitle) throw new Error(t('campaignsCreate.errorTitleRequired'));
|
||||
@@ -664,6 +676,14 @@ export function CreateCampaignPage() {
|
||||
placeholder={t('campaignsCreate.titlePlaceholder')}
|
||||
maxLength={200}
|
||||
required
|
||||
aria-invalid={title.trim().length > 0 && !derivedSlug.slug ? true : undefined}
|
||||
aria-describedby="campaign-title-help"
|
||||
/>
|
||||
<TitleSlugHint
|
||||
title={title}
|
||||
previewSlug={previewSlug}
|
||||
isFallback={derivedSlug.isFallback}
|
||||
isEditMode={isEditMode}
|
||||
/>
|
||||
</FormSection>
|
||||
);
|
||||
@@ -996,6 +1016,64 @@ export function CreateCampaignPage() {
|
||||
|
||||
// ─── Layout helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Inline help / validation text rendered under the title input. Tells the
|
||||
* user, before they reach the submit button:
|
||||
*
|
||||
* - **Empty title** — nothing rendered (the input's `required` attribute
|
||||
* handles the empty-state message at submit time; we don't want to
|
||||
* nag before the user starts typing).
|
||||
* - **Title fine, preview slug derivable** — green-tinted hint showing
|
||||
* the URL identifier that will be generated (`your-title-here`). Gives
|
||||
* instant feedback that what they typed worked.
|
||||
* - **Title typed, preview slug empty** — amber notice explaining that
|
||||
* the characters can't be turned into a URL identifier, so the
|
||||
* campaign will get a random one instead. This is the case for
|
||||
* emoji-only titles, or scripts not covered by transliteration (CJK,
|
||||
* Thai, Tamil, etc.). Publishing still succeeds — we want users to
|
||||
* understand the trade-off, not block them.
|
||||
*
|
||||
* Hidden entirely in edit mode, where the slug is locked to the existing
|
||||
* campaign's d-tag and the title can change without affecting the URL.
|
||||
*/
|
||||
function TitleSlugHint({
|
||||
title,
|
||||
previewSlug,
|
||||
isFallback,
|
||||
isEditMode,
|
||||
}: {
|
||||
title: string;
|
||||
previewSlug: string;
|
||||
isFallback: boolean;
|
||||
isEditMode: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isEditMode) return null;
|
||||
if (!title.trim()) return null;
|
||||
|
||||
if (previewSlug && !isFallback) {
|
||||
return (
|
||||
<p id="campaign-title-help" className="text-xs text-muted-foreground">
|
||||
{t('campaignsCreate.slugPreview')}{' '}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[0.7rem] text-foreground">
|
||||
{previewSlug}
|
||||
</code>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// previewSlug empty → the random-hex fallback will kick in at submit.
|
||||
return (
|
||||
<p
|
||||
id="campaign-title-help"
|
||||
className="text-xs text-amber-700 dark:text-amber-400"
|
||||
>
|
||||
{t('campaignsCreate.slugFallbackNotice')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wallet picker for the campaign form.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user