/* [referral-brief-v1] Platform-owned locale copy for the referral-partner landing page (/r/), v7 — recommendation-first order: hero (label, name, about line, consented links — no CTA row, no chips, no disclosure block), the consent-gated statement/basis/fits voice blocks, vorteil, the single conditions section, the person-first transparency card, and the referral form strings — the § 5a UWG disclosure is the closing footnote under the submit button (warpgogol-m000200); plus the neutral mode-D variant and the visitor auto-reply. The disclosure sentences are legal text — the platform owns the sentence structure so the same disclosure shape ships on every site; site-varying phrases (reward amount, scope/exclusion lines, AGB anchor map) arrive via ReferralLandingFacts resolved from partners.yaml `landing`, and every number traces to ReferralServiceFacts derived from PBP entities (DNA-103). DE is the legally binding version; UK is a synced reference translation. Do not interpolate anything beyond the declared facts — the copy is fixed contract text. Do not add storage/cookie strings — the page must be able to claim zero storage truthfully. Do not hardcode site names, prices, verticals, or document section numbers — they are site-authored facts or PBP-derived. No "verified"/"geprüft"/"zertifiziert"/"garantiert" claims about the recommendation, no SEO/ROI promises. REVIEW-CODE-2026-10-11-01 fixes: uk phrase-guard patterns + explicit-delimiter uk negation (JS \b is ASCII-only), lang-aware sentence splitting, thousands-separator-safe money parsing, confirms aria-label, photo leading-slash contract, sectionNavItems statement/passtnicht anchors. RFC-1273: v5 recommendation-first copy — leistungSummary (≤3 PBP-interpolated lines) + leistungMoreLink replace the 8-item leistung; freedomLine for the raised verify section; refWithLabel becomes (displayName | null) => string; transparency code label demoted to "(technisch)"; secondaryNavLabel removed (verify is a section now). fo-review F6/F7: referralNameVisible/referralVorteilVisible/referralVerifyVisible — the section-visibility predicates shared by the component and the route handler's nav. RFC-1277: v6 copy — brief v3 phase 1 removes the five generic sections (leistung/preis/zusagen/passtnicht/steps) in favour of one "conditions" block (studio paragraph + interpolated price line + three AGB-anchored commitments + not-for line + full-standard link); transparency keeps exactly five rows; ctaPreis removed (single CTA); new keys: conditions*, fitsHeading, statementDisclosure, clientStatusLine, recommenderInstagramLabel, agbBindingNote (uk-only non-empty); referralSentenceDate fixes the uk "р.." double period; display-aware voice predicates (statement/basis/fits/confirms/heroAbout/links/photo). RFC-1277: review fixes: JSON-twin honors display.*; drop orphaned landing: copy facts; heroAbout requires statement gate; CSS class + dead ghost CTA; v6 wording in validators/docs; REVIEW_TOKENS word boundaries; schema-gate tests (81+73 green) warpgogol-m000200: confirms block retired — the platform-fixed "what this recommendation means / does not mean" check/cross columns, the `confirms` copy object, the `referralConfirmsVisible` predicate, and the `display.confirms` switch are removed; voice order becomes statement → basis → fits → vorteil. Website/instagram labels are short fixed strings; scope headings shortened. REVIEW-CODE-2026, RFC-1198, RFC-1222, RFC-1249, RFC-1258, RFC-1278, warpgogol-m000193 */ import type { ReferralLandingConfig, ReferralPartner } from "./partners.ts"; import type { ReferralAgbAnchorKey, ReferralAgbAnchors, ReferralServiceFacts, } from "./service-facts.ts"; export type ReferralCompensation = "referral-fee" | "none" | "steward"; /** RFC-1222: disclosure-structure version stamped onto the submit payload. Manual constant — bump only when the disclosure sentence structure changes, never auto-incremented. */ export const REFERRAL_DISCLOSURE_VERSION = "v2"; /** RFC-1222: the § 5a Abs. 4 UWG binary — "is there a commercial interest". `referral-fee` and `steward` are paid; `none` is unpaid. The finer distinction stays in `compensation.mode`; this answers only the binary. */ export type ReferralDisclosureKind = "paid" | "unpaid"; export function referralDisclosureKind(compensation: ReferralCompensation): ReferralDisclosureKind { return compensation === "none" ? "unpaid" : "paid"; } /** Localized long date for receipt rows ("Erfasst", "Stand"). */ export function referralLocalDate(isoDate: string, lang: string): string { const date = new Date(`${isoDate}T00:00:00Z`); if (Number.isNaN(date.getTime())) return isoDate; return new Intl.DateTimeFormat(lang === "uk" ? "uk-UA" : "de-DE", { dateStyle: "long", timeZone: "UTC", }).format(date); } /** RFC-1277 (brief v3 2.2): sentence-safe date — some locales end the long form with their own period (uk-UA: "13 квітня 2027 р."), so templates that append a sentence period would double it ("р.."). Strips the terminal dot for mid- and end-of-sentence interpolation. */ export function referralSentenceDate(isoDate: string, lang: string): string { return referralLocalDate(isoDate, lang).replace(/\.$/, ""); } /** Localized money phrase — "200 €" (integer amounts stay fraction-free). */ export function referralMoney(amount: number, currency: string | undefined, lang: string): string { const formatted = new Intl.NumberFormat(lang === "uk" ? "uk-UA" : "de-DE", { style: "currency", currency: currency ?? "EUR", maximumFractionDigits: Number.isInteger(amount) ? 0 : 2, minimumFractionDigits: Number.isInteger(amount) ? 0 : 2, }).format(amount); return formatted; } /** Referred-customer benefit derived from the site's PBP partner-program policy (`referredCustomerBenefit` + `terms.referralFreePeriodCondition`). All fields already localized/formatted by the caller. */ export interface ReferralBenefitFacts { /** Localized free-period phrase (e.g. "1 Monat"). */ readonly freePeriod?: string; /** Whether the program promises a priority start slot. */ readonly priorityStart?: boolean; /** Free-period condition token (e.g. "active-in-second-month") — known tokens map to a localized clause, unknown tokens are omitted. */ readonly freePeriodCondition?: string; } /** Site-varying landing phrases, already resolved for one language. Every field is optional — absent facts degrade to truthful generic wording. */ export interface ReferralLandingFacts { /** Reward display phrase per compensation variant (e.g. "70 €"). */ readonly reward?: { readonly "referral-fee"?: string; readonly steward?: string }; /** Steward role term (e.g. "Markt-Steward"). Fallback: "Partner". */ readonly stewardRole?: string; /** Referred-customer benefit from the PBP partner-program policy. */ readonly benefit?: ReferralBenefitFacts; /** PBP-derived service facts (prices, commitments, qualifying period, AGB version). */ readonly service?: ReferralServiceFacts; /** Site-authored AGB section anchors for this language. */ readonly anchors?: ReferralAgbAnchors; } /** * Resolves the site-authored `landing` block (lang → phrase maps) into the * per-language facts the copy module consumes. Missing languages fall back * to `fallbackLang`, then to the first authored phrase. */ export function resolveReferralLandingFacts( config: ReferralLandingConfig | null | undefined, lang: string, fallbackLang = "de", ): ReferralLandingFacts { if (!config) return {}; const pick = (map: Record | undefined): string | undefined => map?.[lang] ?? map?.[fallbackLang] ?? Object.values(map ?? {})[0]; const referralFee = pick(config.rewardPhrases?.["referral-fee"]); const steward = pick(config.rewardPhrases?.steward); const anchorsRaw = config.agbAnchors?.[lang] ?? config.agbAnchors?.[fallbackLang]; return { stewardRole: pick(config.stewardRole), ...(referralFee || steward ? { reward: { "referral-fee": referralFee, steward } } : {}), ...(anchorsRaw ? { anchors: anchorsRaw as ReferralAgbAnchors } : {}), }; } /** One left-column commitment row — anchored to the owning AGB section. */ export interface ReferralCommitmentRow { readonly text: string; readonly anchorKey: ReferralAgbAnchorKey; } /** The disclosure split: bold label + calm sentence(s), mode-aware. */ export interface ReferralDisclosureLine { readonly label: string; readonly text: string; /** The referral code embedded verbatim in `text` (anonymous phrasing only) — the renderer wraps it in so the slug reads as a token, not a name. Absent when a consented display name carries the sentence. */ readonly code?: string; } export interface ReferralTransparencyCopy { heading: string; /** warpgogol-m000200: exactly four rows — recommendedBy + yourPrice + ruleset + code (the kind row is gone; the compensation disclosure lives in the closing footnote under the form). */ labels: { code: string; recommendedBy: string; yourPrice: string; ruleset: string; }; yourPrice: string; /** "AGB, § 3 Abs. 5 und § 12 (Fassung 4.2)" — the version arrives from the AGB document metadata, never hardcoded. */ rulesetLabel: (version: string | undefined) => string; closing: string; } export interface ReferralFormCopy { heading: string; /** Prefix the enhanced client's mailto fallback puts before the code. */ refPrefix: string; /** Visible label of the textarea on the native error re-render. */ messageLabel: string; /** Textarea placeholder — must ask for a reachable contact inside the text. */ messageHelp: string; submitLabel: string; sendingLabel: string; /** Live checklist labels (enhanced path) — the contact item mirrors the same requirement `contactRequired` states on the native error doc. */ checklistTitle: string; checklistReadyLabel: string; checklistLengthLabel: string; checklistContactLabel: string; privacyNote: string; privacyLinkLabel: string; successMessage: string; errorSummary: string; /** Per-field errors on the native re-rendered error document — the one-field form keeps only the message plus the extracted-contact hint. */ contactRequired: string; messageRequired: string; } /** Visitor auto-reply — the same response promise as the success message, the visitor's own message copy, and the referral code when one applied. Compensation is never restated differently than on the page. */ export interface ReferralAutoReplyCopy { subject: string; body: (args: { message: string; code?: string }) => string; } /** Mode-D neutral document — no referral mention anywhere. */ export interface ReferralNeutralCopy { title: string; lead: string; /** Form privacy sentence — deliberately free of any referral-code mention. */ privacyNote: string; } /** RFC-1222: retired-partner status page copy — never carries the name. */ export interface ReferralRetiredCopy { title: string; status: string; home: string; } export interface ReferralLandingCopy { /** Header CTA → #referral-form. */ headerCta: string; heroLabel: string; /** Interpolates {displayName, siteName} — mode A only. */ h1Named: (displayName: string) => string; /** Modes B/C — name-free. */ h1Anonymous: string; lead: string; /** The disclosure line — label + text resolved per compensation and identity. warpgogol-m000200: renders as the closing footnote under the submit button, no longer as the hero block. */ disclosure: ( compensation: ReferralCompensation, displayName: string | null, slug: string, ) => ReferralDisclosureLine; vorteilHeading: string; vorteilText: string | null; /** RFC-1277 (brief v3 2.5): the single generic-terms section replacing the five former blocks (leistung/preis/zusagen/passtnicht/steps) — the studio paragraph is the former hero `lead`, prices/commitments stay PBP- and AGB-anchored. */ conditionsHeading: string; /** One interpolated price line: activation + monthly (+ yearly option), UStG note, cancellation term. Missing facts drop their clause. */ conditionsPriceLine: string; /** Exactly three anchored commitments: launch window, handover, response. */ conditionsCommitments: readonly ReferralCommitmentRow[]; /** The single not-a-fit line — anchored to the AGB exclusions section. */ conditionsNotFor: { text: string; anchorKey: ReferralAgbAnchorKey }; b2bLine: string; b2bAnchorKey: ReferralAgbAnchorKey; /** RFC-1278: "(AGB)" — the conditions-commitment anchor link label; a law document abbreviation, identical in de/uk, but never a markup literal. */ agbLinkLabel: string; quoteAriaLabel: string; /** RFC-1258: the recommender's voice section headings. */ statementHeading: string; basisHeading: string; /** RFC-1277 (brief v3 3.3): heading of the standalone fits block — first-person voice like the statement heading. */ fitsHeading: string; /** RFC-1277 (brief v3 3.2): the basis-block client-status line — renders for both answers ("… ist (k)ein Kunde von …"), absent data = no line. */ clientStatusLine: (displayName: string, isClient: boolean) => string; /** RFC-1258: dated wave-capacity line — pre-rendered from the PBP capacity fact; null when the fact is absent (the landing then shows the static waveSlots range line only). Freshness is the validator's job. */ capacityText: string | null; /** RFC-1258: expiry line — "Empfehlung gültig bis …". */ validUntilLine: (isoDate: string) => string; /** RFC-1258: headings of the recommender's own fit lists (scope data). */ scopeFitsHeading: string; scopeDoesNotFitHeading: string; /** aria-label of the hero nav wrapping the recommender's consented external links (website/instagram). warpgogol-m000200: own key — the former verify-heading reuse is gone with the verify section. */ recommenderLinksLabel: string; /** RFC-1258: label of the consent-gated link to the recommender's website. warpgogol-m000200: plain short label — the nav's aria-label already names the recommender. */ recommenderWebsiteLabel: string; /** RFC-1277: label of the consent-gated Instagram link (hero). */ recommenderInstagramLabel: string; /** RFC-1277 (brief v3 2.8): the note under the transparency card naming the binding AGB language version — non-empty only on locales whose AGB is a reference translation (UK: "Обов'язковою є німецька версія AGB."). DE stays empty — the German page IS the binding version. */ agbBindingNote: string; transparency: ReferralTransparencyCopy; form: ReferralFormCopy; autoReply: ReferralAutoReplyCopy; neutral: ReferralNeutralCopy; retired: ReferralRetiredCopy; } // --------------------------------------------------------------------------- // Fact interpolation helpers — every placeholder resolves from facts, never // from literals. Missing facts degrade to truthful phrasing. // --------------------------------------------------------------------------- function whoPhrase( displayName: string | null, slug: string, lang: "de" | "uk", capitalized: boolean, ): string { if (displayName) return displayName; if (lang === "uk") { return capitalized ? `Володар коду рекомендації ${slug}` : `володар коду рекомендації ${slug}`; } return capitalized ? `Der Inhaber des Empfehlungscodes ${slug}` : `der Inhaber des Empfehlungscodes ${slug}`; } function rewardPhrase(facts: ReferralLandingFacts, compensation: ReferralCompensation): string { return ( (compensation === "steward" ? facts.reward?.steward : facts.reward?.["referral-fee"]) ?? "" ); } function qualifyingDaysText(facts: ReferralServiceFacts | undefined, lang: "de" | "uk"): string { const days = facts?.qualifyingDays; if (days === undefined) { return lang === "uk" ? "протягом передбаченого строку активності та оплати" : "für die vereinbarte Qualifizierungszeit aktiv und bezahlt"; } return lang === "uk" ? `протягом ${days} днів активною та оплаченою` : `${days} Tage aktiv und bezahlt`; } function disclosureDe( facts: ReferralLandingFacts, compensation: ReferralCompensation, displayName: string | null, slug: string, siteName: string, ): ReferralDisclosureLine { switch (compensation) { case "referral-fee": { const who = whoPhrase(displayName, slug, "de", false); const reward = rewardPhrase(facts, compensation) || "eine Vergütung"; return { label: "Vergütete Empfehlung.", text: `Wird aus dieser Empfehlung ein Kunde und bleibt dessen Subscription ${qualifyingDaysText(facts.service, "de")}, erhält ${who} ${reward} von ${siteName}. Ihr Listenpreis ändert sich dadurch nicht.`, ...(displayName ? {} : { code: slug }), }; } case "none": return { label: "Empfehlung ohne Geldvergütung.", text: `${whoPhrase(displayName, slug, "de", true)} erhält für diese Empfehlung keine Zahlung von ${siteName}.`, ...(displayName ? {} : { code: slug }), }; case "steward": { const who = whoPhrase(displayName, slug, "de", true); const extra = facts.reward?.steward ? `, unter anderem ${facts.reward.steward}` : ""; return { label: "Vergütete Empfehlung.", text: `${who} ist als ${facts.stewardRole ?? "Partner"} für ${siteName} tätig und erhält dafür eine Vergütung${extra}. Ihr Listenpreis ändert sich dadurch nicht.`, ...(displayName ? {} : { code: slug }), }; } } } function disclosureUk( facts: ReferralLandingFacts, compensation: ReferralCompensation, displayName: string | null, slug: string, siteName: string, ): ReferralDisclosureLine { switch (compensation) { case "referral-fee": { const who = whoPhrase(displayName, slug, "uk", false); const reward = rewardPhrase(facts, compensation) || "винагороду"; return { label: "Рекомендація з винагородою.", text: `Якщо з цієї рекомендації з'явиться Клієнт і його підписка залишатиметься ${qualifyingDaysText(facts.service, "uk")}, ${who} отримає ${reward} від ${siteName}. Ваша ціна за прайсом через це не змінюється.`, ...(displayName ? {} : { code: slug }), }; } case "none": return { label: "Рекомендація без грошової винагороди.", text: `${whoPhrase(displayName, slug, "uk", true)} не отримує жодної виплати від ${siteName} за цю рекомендацію.`, ...(displayName ? {} : { code: slug }), }; case "steward": { const who = whoPhrase(displayName, slug, "uk", true); const extra = facts.reward?.steward ? `, зокрема ${facts.reward.steward}` : ""; return { label: "Рекомендація з винагородою.", text: `${who} працює для ${siteName} як ${facts.stewardRole ?? "партнер"} і отримує за це винагороду${extra}. Ваша ціна за прайсом через це не змінюється.`, ...(displayName ? {} : { code: slug }), }; } } } /** RFC-1277 (brief v3 2.5): the one-line price clause — activation + monthly (+ yearly alternative), UStG note, cancellation term. Every amount is PBP-derived; a missing fact drops its clause instead of inventing a number. */ function conditionsPriceLineDe(facts: ReferralLandingFacts): string { const s = facts.service; const cur = s?.priceCurrency; const activation = s?.activationPrice !== undefined ? referralMoney(s.activationPrice, cur, "de") : undefined; const monthly = s?.monthlyPrice !== undefined ? referralMoney(s.monthlyPrice, cur, "de") : undefined; const yearly = s?.yearlyPrice !== undefined ? referralMoney(s.yearlyPrice, cur, "de") : undefined; const price = activation && monthly ? `${activation} Aktivierung + ${monthly} im Monat${yearly ? ` (oder ${yearly} im Jahr)` : ""}` : monthly ? `${monthly} im Monat` : "Preise nach dem veröffentlichten Standard"; const cancel = s?.cancellationDays !== undefined ? ` Kündigung: ${s.cancellationDays} Tage zum Monatsende.` : ""; return `${price}, Endpreise ohne Umsatzsteuer (§ 19 UStG).${cancel}`; } function conditionsPriceLineUk(facts: ReferralLandingFacts): string { const s = facts.service; const cur = s?.priceCurrency; const activation = s?.activationPrice !== undefined ? referralMoney(s.activationPrice, cur, "uk") : undefined; const monthly = s?.monthlyPrice !== undefined ? referralMoney(s.monthlyPrice, cur, "uk") : undefined; const yearly = s?.yearlyPrice !== undefined ? referralMoney(s.yearlyPrice, cur, "uk") : undefined; const price = activation && monthly ? `${activation} активація + ${monthly} на місяць${yearly ? ` (або ${yearly} на рік)` : ""}` : monthly ? `${monthly} на місяць` : "ціни за опублікованим стандартом"; const cancel = s?.cancellationDays !== undefined ? ` Розірвання: за ${s.cancellationDays} днів до кінця місяця.` : ""; return `${price}, без ПДВ (§ 19 UStG).${cancel}`; } function vorteilDe(facts: ReferralLandingFacts, siteName: string): string | null { const benefit = facts.benefit; if (!benefit || (!benefit.freePeriod && !benefit.priorityStart)) return null; const parts: string[] = []; if (benefit.priorityStart) { const wave = facts.service?.waveSlots && `${facts.service.waveSlots.min}–${facts.service.waveSlots.max}`; parts.push( wave ? `starten Sie mit Priorität in der nächsten Welle (${siteName} nimmt ${wave} Websites pro Monat auf)` : `starten Sie mit Priorität in der nächsten Welle`, ); } if (benefit.freePeriod) { const condition = benefit.freePeriodCondition === "active-in-second-month" ? ", sofern Sie im 2. Monat aktiv bleiben" : ""; parts.push(`erhalten ${benefit.freePeriod} Subscription kostenlos${condition}`); } return `Als empfohlener Betrieb ${parts.join(" und ")}.`; } function vorteilUk(facts: ReferralLandingFacts, siteName: string): string | null { const benefit = facts.benefit; if (!benefit || (!benefit.freePeriod && !benefit.priorityStart)) return null; const parts: string[] = []; if (benefit.priorityStart) { const wave = facts.service?.waveSlots && `${facts.service.waveSlots.min}–${facts.service.waveSlots.max}`; parts.push( wave ? `стартуєте з пріоритетом у наступній хвилі (${siteName} бере ${wave} вебсайти на місяць)` : `стартуєте з пріоритетом у наступній хвилі`, ); } if (benefit.freePeriod) { const condition = benefit.freePeriodCondition === "active-in-second-month" ? ", якщо Ви залишаєтесь активними у 2-му місяці" : ""; parts.push(`отримуєте ${benefit.freePeriod} підписки безкоштовно${condition}`); } return `Як рекомендований бізнес Ви ${parts.join(" і ")}.`; } function deCopy(siteName: string, facts: ReferralLandingFacts): ReferralLandingCopy { const s = facts.service; const launchDays = s?.launchWorkdays; const response = s?.responseHours; const handover = s?.handoverHours; // REVIEW-CODE-2026-10-08-02: no invented digits — an absent support-response // policy degrades to a numberless sentence, never a guessed "24". const responseSentence = response !== undefined ? `Wir antworten innerhalb von ${response} Stunden an Werktagen per E-Mail.` : "Wir antworten an Werktagen per E-Mail."; const launchDelayClause = s?.launchRemedy === "continued-performance" ? "; bei Verzug durch uns arbeiten wir kostenlos weiter bis zum Launch" : ""; return { headerCta: "Situation beschreiben", heroLabel: "EMPFEHLUNG", h1Named: (name) => `${name} empfiehlt Ihnen ${siteName}`, h1Anonymous: `Man hat Ihnen ${siteName} empfohlen`, lead: `${siteName} baut Websites für kleine Unternehmen, Handwerksbetriebe und Selbständige: auf Ihrer eigenen Domain, mit schriftlichen Bedingungen und einem festen Ausstieg.`, disclosure: (compensation, displayName, slug) => disclosureDe(facts, compensation, displayName, slug, siteName), vorteilHeading: "Ihr Empfehlungsvorteil", vorteilText: vorteilDe(facts, siteName), // RFC-1277 (brief v3 2.5): the single conditions section — the studio // paragraph (former hero lead) renders in the component, the price line // and commitments stay PBP/AGB-anchored; the launch delay clause keeps // its home in the launch commitment row. conditionsHeading: "Die Konditionen kurz", conditionsPriceLine: conditionsPriceLineDe(facts), conditionsCommitments: [ { text: launchDays !== undefined ? `Launch in ${launchDays} Werktagen ab Projektstart${launchDelayClause}` : `Launch ab Projektstart${launchDelayClause}`, anchorKey: "launchWorkdays", }, { text: handover !== undefined ? `vollständige Übergabe in ${handover} Stunden nach Vertragsende` : "vollständige Übergabe nach Vertragsende", anchorKey: "handoverHours", }, { text: response !== undefined ? `Antwort in ${response} Stunden an Werktagen` : "Antwort an Werktagen", anchorKey: "responseHours", }, ], conditionsNotFor: { text: "Nicht für Onlineshops mit Warenpositionen und Lagerbeständen, Portale und SEO-Garantien", anchorKey: "unsupportedProjects", }, b2bLine: "Dieses Angebot gilt für Unternehmer, Selbständige und Freiberufler (§ 14 BGB), nicht für Verbraucher.", b2bAnchorKey: "b2b", agbLinkLabel: "(AGB)", quoteAriaLabel: "Wörtliche Empfehlung", statementHeading: "Warum ich das empfehle", basisHeading: "Was ich selbst geprüft habe", fitsHeading: "Wem ich das empfehle — und wem nicht", clientStatusLine: (displayName, isClient) => isClient ? `${displayName} ist Kunde von ${siteName}.` : `${displayName} ist kein Kunde von ${siteName}.`, capacityText: s?.capacity ? `Nächste Welle: ${s.capacity.month}, ${s.capacity.free} von ${s.capacity.total} Plätzen frei.` : null, validUntilLine: (isoDate) => `Diese Empfehlung gilt bis ${referralSentenceDate(isoDate, "de")}.`, scopeFitsHeading: "Wem das passt", scopeDoesNotFitHeading: "Wem das nicht passt", recommenderLinksLabel: "Die Empfehlungsgeberin prüfen", recommenderWebsiteLabel: "Website", recommenderInstagramLabel: "Instagram", agbBindingNote: "", transparency: { heading: "Transparenz", labels: { code: "Empfehlungscode (technisch)", recommendedBy: "Empfohlen von", yourPrice: "Ihr Preis", ruleset: "Regelwerk", }, yourPrice: "Listenpreis unverändert; Vorteil siehe Bedingungen", rulesetLabel: (version) => version ? `AGB, § 3 Abs. 5 und § 12 (Fassung ${version})` : "AGB, § 3 Abs. 5 und § 12", closing: "Die Empfehlung verpflichtet Sie zu nichts.", }, form: { heading: "Situation beschreiben", refPrefix: "Empfohlen von: ", messageLabel: "Nachricht", messageHelp: "Was haben Sie heute, was fehlt — und wie erreichen wir Sie (E-Mail oder Telefon)?", submitLabel: `Prüfen lassen, ob ${siteName} passt`, sendingLabel: "Wird gesendet …", checklistTitle: "Zum Senden fehlt:", checklistReadyLabel: "Alles bereit — senden Sie Ihre Anfrage", checklistLengthLabel: "Mindestens 24 Zeichen geschrieben", checklistContactLabel: "E-Mail-Adresse oder Telefonnummer im Text hinterlassen", privacyNote: "Wir nutzen auf dieser Seite keine Cookies und speichern nichts auf Ihrem Gerät. Der Empfehlungscode wird nur mit Ihrer Anfrage übermittelt.", privacyLinkLabel: "Datenschutzerklärung", successMessage: `Danke. Ihre Anfrage ist angekommen. ${responseSentence} Schauen Sie bei Bedarf im Spam-Ordner nach.`, errorSummary: "Einige Angaben fehlen oder sind ungültig.", contactRequired: "Bitte hinterlassen Sie eine E-Mail-Adresse oder Telefonnummer in der Nachricht.", messageRequired: "Bitte schreiben Sie eine Nachricht.", }, autoReply: { subject: `Ihre Anfrage bei ${siteName}`, body: ({ message, code }) => [ `Danke für Ihre Anfrage bei ${siteName}.`, "", "Ihre Nachricht (Kopie):", message, "", ...(code ? [`Empfehlungscode: ${code}`, ""] : []), `${responseSentence} Schauen Sie bei Bedarf im Spam-Ordner nach.`, ].join("\n"), }, neutral: { title: `Ihre Anfrage an ${siteName}`, lead: response !== undefined ? `Beschreiben Sie kurz Ihre Situation — wir antworten innerhalb von ${response} Stunden an Werktagen.` : "Beschreiben Sie kurz Ihre Situation — wir antworten an Werktagen.", privacyNote: "Wir nutzen auf dieser Seite keine Cookies und speichern nichts auf Ihrem Gerät.", }, retired: { title: "Diese Empfehlung ist nicht mehr aktiv", status: `${siteName} können Sie weiterhin unabhängig prüfen.`, home: "Zur Startseite", }, }; } function ukCopy(siteName: string, facts: ReferralLandingFacts): ReferralLandingCopy { const s = facts.service; const launchDays = s?.launchWorkdays; const response = s?.responseHours; const handover = s?.handoverHours; const responseSentence = response !== undefined ? `Ми відповідаємо протягом ${response} годин у робочі дні електронною поштою.` : "Ми відповідаємо в робочі дні електронною поштою."; const launchDelayClause = s?.launchRemedy === "continued-performance" ? "; при зволіканні з нашого боку працюємо безкоштовно до запуску" : ""; return { headerCta: "Описати ситуацію", heroLabel: "РЕКОМЕНДАЦІЯ", h1Named: (name) => `${name} рекомендує Вам ${siteName}`, h1Anonymous: `Вам рекомендували ${siteName}`, lead: `${siteName} робить вебсайти для малого бізнесу, ремісничих підприємств та самозайнятих: на Вашому власному домені, з письмовими умовами та передбаченим виходом.`, disclosure: (compensation, displayName, slug) => disclosureUk(facts, compensation, displayName, slug, siteName), vorteilHeading: "Ваша перевага за рекомендацією", vorteilText: vorteilUk(facts, siteName), // RFC-1277 (brief v3 2.5): одна секція «Умови коротко» замість п'яти // колишніх блоків — абзац про студію (колишній hero lead) рендерить // компонент; ціни й зобов'язання лишаються PBP/AGB-заякореними. conditionsHeading: "Умови коротко", conditionsPriceLine: conditionsPriceLineUk(facts), conditionsCommitments: [ { text: launchDays !== undefined ? `запуск протягом ${launchDays} робочих днів від старту проєкту${launchDelayClause}` : `запуск від старту проєкту${launchDelayClause}`, anchorKey: "launchWorkdays", }, { text: handover !== undefined ? `повна передача протягом ${handover} годин після завершення договору` : "повна передача після завершення договору", anchorKey: "handoverHours", }, { text: response !== undefined ? `відповідь протягом ${response} годин у робочі дні` : "відповідь у робочі дні", anchorKey: "responseHours", }, ], conditionsNotFor: { text: "Не підходить: інтернет-магазини з товарними позиціями та залишками, портали, гарантії SEO", anchorKey: "unsupportedProjects", }, b2bLine: "Ця пропозиція діє для підприємців, самозайнятих і вільних професій (§ 14 BGB), не для споживачів.", b2bAnchorKey: "b2b", agbLinkLabel: "(AGB)", quoteAriaLabel: "Дослівна рекомендація", statementHeading: "Чому я це рекомендую", basisHeading: "Що я перевірила особисто", fitsHeading: "Кому я це раджу — і кому ні", clientStatusLine: (displayName, isClient) => isClient ? `${displayName} — клієнт ${siteName}.` : `${displayName} не є клієнтом ${siteName}.`, capacityText: s?.capacity ? `Наступна хвиля: ${s.capacity.month}, ${s.capacity.free} з ${s.capacity.total} місць вільно.` : null, validUntilLine: (isoDate) => `Ця рекомендація діє до ${referralSentenceDate(isoDate, "uk")}.`, scopeFitsHeading: "Кому це підходить", scopeDoesNotFitHeading: "Кому це не підходить", recommenderLinksLabel: "Перевірити авторку рекомендації", recommenderWebsiteLabel: "Сайт", recommenderInstagramLabel: "Instagram", agbBindingNote: "Обов'язковою є німецька версія AGB.", transparency: { heading: "Прозорість", labels: { code: "Код рекомендації (технічний)", recommendedBy: "Рекомендує", yourPrice: "Ваша ціна", ruleset: "Регламент", }, yourPrice: "Ціна за прайсом незмінна; перевагу див. в умовах", rulesetLabel: (version) => version ? `AGB, § 3 абз. 5 і § 12 (версія ${version})` : "AGB, § 3 абз. 5 і § 12", closing: "Рекомендація Вас ні до чого не зобов'язує.", }, form: { heading: "Опишіть Вашу ситуацію", refPrefix: "Рекомендація від: ", messageLabel: "Повідомлення", messageHelp: "Що у Вас є сьогодні, чого бракує — і як нам з Вами зв'язатися (електронна пошта або телефон)?", submitLabel: `Перевірити, чи підходить ${siteName}`, sendingLabel: "Надсилаємо …", checklistTitle: "Для відправки не вистачає:", checklistReadyLabel: "Усе готово — надсилайте запит", checklistLengthLabel: "Мінімум 24 символи написано", checklistContactLabel: "Електронну пошту або телефон у тексті повідомлення", privacyNote: "Ми не використовуємо на цій сторінці cookie і нічого не зберігаємо на Вашому пристрої. Код рекомендації передається лише разом із Вашим запитом.", privacyLinkLabel: "Політика конфіденційності", successMessage: `Дякуємо. Ваш запит надійшов. ${responseSentence} За потреби перевірте папку «Спам».`, errorSummary: "Деякі поля не заповнені або містять помилки.", contactRequired: "Будь ласка, залиште в повідомленні адресу електронної пошти або номер телефону.", messageRequired: "Будь ласка, напишіть повідомлення.", }, autoReply: { subject: `Ваш запит до ${siteName}`, body: ({ message, code }) => [ `Дякуємо за Ваш запит до ${siteName}.`, "", "Ваше повідомлення (копія):", message, "", ...(code ? [`Код рекомендації: ${code}`, ""] : []), `${responseSentence} За потреби перевірте папку «Спам».`, ].join("\n"), }, neutral: { title: `Ваш запит до ${siteName}`, lead: response !== undefined ? `Коротко опишіть Вашу ситуацію — ми відповідаємо протягом ${response} годин у робочі дні.` : "Коротко опишіть Вашу ситуацію — ми відповідаємо в робочі дні.", privacyNote: "Ми не використовуємо на цій сторінці cookie і нічого не зберігаємо на Вашому пристрої.", }, retired: { title: "Ця рекомендація більше не активна", status: `${siteName} Ви можете надалі перевіряти самостійно.`, home: "На головну", }, }; } /** * Returns the landing copy for `lang`, with `siteName` (the site's public * business name from the PBP profile) and the resolved `facts` (partners.yaml * `landing` phrases + PBP-derived service facts) interpolated wherever they * apply. The brand and economics are never hardcoded — this module serves * every workshop site. */ export function referralLandingCopy( lang: string, siteName: string, facts: ReferralLandingFacts = {}, ): ReferralLandingCopy { return lang === "uk" ? ukCopy(siteName, facts) : deCopy(siteName, facts); } /** RFC-1273: public-name consent gate — every identity-bearing render site (h1, role, statement attribution, website/photo, the transparency recommendedBy row, the name-aware form label) reads this predicate. */ export function referralNameVisible(partner: ReferralPartner): boolean { return partner.status === "active" && !!partner.displayName; } /** The vorteil section renders only when at least one of its facts exists — an empty heading would disclose nothing. */ export function referralVorteilVisible( partner: ReferralPartner, copy: ReferralLandingCopy, ): boolean { return ( copy.vorteilText !== null || copy.capacityText !== null || (partner.status === "active" && partner.validUntil !== undefined) ); } /** RFC-1277: the recommender's voice block — data + recorded consent + confirmation date + active status, AND the operator's display switch. Every gated block reads through this chain so a `display` flag can only hide, never reveal. */ export function referralStatementVisible(partner: ReferralPartner): boolean { return ( partner.status === "active" && !!partner.statement && !!partner.statementConsent && !!partner.statementConfirmedAt && partner.display?.statement !== false ); } /** brief 3.2 — the basis block renders when the statement does AND at least one item exists (a basis fact or the answered isClient status). */ export function referralBasisVisible(partner: ReferralPartner): boolean { return ( referralStatementVisible(partner) && partner.display?.basis !== false && ((partner.basis?.length ?? 0) > 0 || partner.isClient !== undefined) ); } /** brief 3.3 — her own fit view (scope.fits/doesNotFit). */ export function referralFitsVisible(partner: ReferralPartner): boolean { return referralStatementVisible(partner) && partner.display?.fits !== false && !!partner.scope; } /** brief 1.2 — the relation phrase under the hero role (voice data — her authored words publish only under the full statement bar: data + consent + confirmation + display.statement, plus its own display.heroAbout switch). */ export function referralHeroAboutVisible(partner: ReferralPartner): boolean { return ( referralStatementVisible(partner) && !!partner.relationToClientWorld && partner.display?.heroAbout !== false ); } /** brief 3.5 — the consented verification links (website/instagram) in the hero. Per-link consent is checked separately at the render site. */ export function referralLinksVisible(partner: ReferralPartner): boolean { return referralNameVisible(partner) && partner.display?.links !== false; } /** brief 3.5 — the consent-gated portrait. */ export function referralPhotoVisible(partner: ReferralPartner): boolean { return ( referralNameVisible(partner) && !!partner.photo && !!partner.photoConsent && partner.display?.photo !== false ); }