@aglyn/aglyn 1.0.0-beta.287 → 1.0.0-beta.288

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/aglyn",
3
- "version": "1.0.0-beta.287",
3
+ "version": "1.0.0-beta.288",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -37,16 +37,16 @@
37
37
  "./package.json": "./package.json"
38
38
  },
39
39
  "dependencies": {
40
- "@aglyn/shared-data-enums": "1.0.0-beta.287",
41
- "@aglyn/shared-data-mdi": "1.0.0-beta.287",
42
- "@aglyn/shared-data-types": "1.0.0-beta.287",
43
- "@aglyn/shared-util-email": "1.0.0-beta.287",
44
- "@aglyn/shared-util-first-touch": "1.0.0-beta.287",
45
- "@aglyn/shared-util-http": "1.0.0-beta.287",
46
- "@aglyn/shared-util-logger": "1.0.0-beta.287",
47
- "@aglyn/shared-util-timestamp": "1.0.0-beta.287",
48
- "@aglyn/shared-util-tools": "1.0.0-beta.287",
49
- "@aglyn/shared-util-vendor": "1.0.0-beta.287",
40
+ "@aglyn/shared-data-enums": "1.0.0-beta.288",
41
+ "@aglyn/shared-data-mdi": "1.0.0-beta.288",
42
+ "@aglyn/shared-data-types": "1.0.0-beta.288",
43
+ "@aglyn/shared-util-email": "1.0.0-beta.288",
44
+ "@aglyn/shared-util-first-touch": "1.0.0-beta.288",
45
+ "@aglyn/shared-util-http": "1.0.0-beta.288",
46
+ "@aglyn/shared-util-logger": "1.0.0-beta.288",
47
+ "@aglyn/shared-util-timestamp": "1.0.0-beta.288",
48
+ "@aglyn/shared-util-tools": "1.0.0-beta.288",
49
+ "@aglyn/shared-util-vendor": "1.0.0-beta.288",
50
50
  "@data-driven-forms/react-form-renderer": "^4.2.0",
51
51
  "@msgpack/msgpack": "^3.1.3",
52
52
  "@types/unist": "^3.0.3",
@@ -38,8 +38,12 @@ export declare const BUSINESS_TONES: readonly ["friendly", "professional", "play
38
38
  export type BusinessTone = (typeof BUSINESS_TONES)[number];
39
39
  /** Each tone as the owner picks it and as a writer is told it. */
40
40
  export declare const BUSINESS_TONE_LABELS: Readonly<Record<BusinessTone, string>>;
41
- /** The fields the profile document stores itself. */
42
- export declare const BUSINESS_PROFILE_FIELDS: readonly ["whatYouDo", "services", "serviceArea", "audience", "tone", "toneNotes"];
41
+ /**
42
+ * The fields the profile document stores itself. `founded` is the year the
43
+ * business started (AGL-4092): the guided start asks it, and every job that
44
+ * writes about the business reads it here rather than marking a gap for it.
45
+ */
46
+ export declare const BUSINESS_PROFILE_FIELDS: readonly ["whatYouDo", "services", "serviceArea", "audience", "tone", "toneNotes", "founded"];
43
47
  export type BusinessProfileField = (typeof BUSINESS_PROFILE_FIELDS)[number];
44
48
  /** Each field's ceiling, in characters; `services` is per item. */
45
49
  export declare const BUSINESS_PROFILE_MAX_CHARS: Readonly<Record<Exclude<BusinessProfileField, 'tone'>, number>>;
@@ -53,16 +57,24 @@ export interface BusinessProfileDoc {
53
57
  audience?: string;
54
58
  tone?: BusinessTone | null;
55
59
  toneNotes?: string;
60
+ /** The year the business started, four digits: `2019`. */
61
+ founded?: string;
56
62
  /** Who wrote each stored value; a value without one is the owner's. */
57
63
  sources?: Partial<Record<BusinessProfileField, BusinessProfileSource>>;
58
64
  updatedAt?: unknown;
59
65
  updatedBy?: string | null;
60
66
  }
61
67
  /** The values a write carries, before they are sanitized. */
62
- export type BusinessProfileValues = Partial<Pick<BusinessProfileDoc, 'whatYouDo' | 'services' | 'serviceArea' | 'audience' | 'tone' | 'toneNotes'>>;
68
+ export type BusinessProfileValues = Partial<Pick<BusinessProfileDoc, 'whatYouDo' | 'services' | 'serviceArea' | 'audience' | 'tone' | 'toneNotes' | 'founded'>>;
63
69
  export declare const text: (value: unknown, max: number) => string;
64
70
  /** One field's stored value, sanitized; empty is `''`, `[]` or `null`. */
65
71
  export declare function fieldValue(doc: BusinessProfileValues | null | undefined, field: BusinessProfileField): unknown;
72
+ /**
73
+ * What is wrong with a year founded as the owner typed it (AGL-4092), or
74
+ * `null` when nothing is: empty, or four digits no later than this year —
75
+ * the guided start's own rule.
76
+ */
77
+ export declare function businessProfileFoundedProblem(value: unknown, now?: Date): string | null;
66
78
  export declare const isEmpty: (value: unknown) => boolean;
67
79
  /** The source a stored, non-empty value carries; the owner's when none is recorded. */
68
80
  export declare function businessProfileSourceOf(doc: BusinessProfileDoc | null | undefined, field: BusinessProfileField): BusinessProfileSource | null;
@@ -42,20 +42,26 @@ export const BUSINESS_TONES = [
42
42
  premium: 'Premium and refined',
43
43
  plain: 'Plain and direct'
44
44
  };
45
- /** The fields the profile document stores itself. */ export const BUSINESS_PROFILE_FIELDS = [
45
+ /**
46
+ * The fields the profile document stores itself. `founded` is the year the
47
+ * business started (AGL-4092): the guided start asks it, and every job that
48
+ * writes about the business reads it here rather than marking a gap for it.
49
+ */ export const BUSINESS_PROFILE_FIELDS = [
46
50
  'whatYouDo',
47
51
  'services',
48
52
  'serviceArea',
49
53
  'audience',
50
54
  'tone',
51
- 'toneNotes'
55
+ 'toneNotes',
56
+ 'founded'
52
57
  ];
53
58
  /** Each field's ceiling, in characters; `services` is per item. */ export const BUSINESS_PROFILE_MAX_CHARS = {
54
59
  whatYouDo: 200,
55
60
  services: 60,
56
61
  serviceArea: 120,
57
62
  audience: 160,
58
- toneNotes: 200
63
+ toneNotes: 200,
64
+ /** A year, four digits, as the guided start asks it. */ founded: 4
59
65
  };
60
66
  /** At most this many services are kept. */ export const BUSINESS_PROFILE_MAX_SERVICES = 12;
61
67
  export const text = (value, max)=>typeof value === 'string' ? value.replace(/\s+/g, ' ').trim().slice(0, max) : '';
@@ -84,6 +90,16 @@ const services = (value)=>{
84
90
  return text(doc == null ? void 0 : doc[field], BUSINESS_PROFILE_MAX_CHARS[field]);
85
91
  }
86
92
  }
93
+ /**
94
+ * What is wrong with a year founded as the owner typed it (AGL-4092), or
95
+ * `null` when nothing is: empty, or four digits no later than this year —
96
+ * the guided start's own rule.
97
+ */ export function businessProfileFoundedProblem(value, now = new Date()) {
98
+ const year = typeof value === 'string' ? value.trim() : '';
99
+ if (!year) return null;
100
+ const latest = now.getFullYear();
101
+ return /^\d{4}$/.test(year) && Number(year) >= 1000 && Number(year) <= latest ? null : `Enter the year as four digits, like ${latest - 10}.`;
102
+ }
87
103
  export const isEmpty = (value)=>value === null || value === '' || Array.isArray(value) && value.length === 0;
88
104
  /** The source a stored, non-empty value carries; the owner's when none is recorded. */ export function businessProfileSourceOf(doc, field) {
89
105
  var _doc_sources;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/business-profile-values.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The pure part of the business profile: its fields, limits, sources and the\n * write rules (what the owner typed is theirs; a changed field becomes the\n * owner's). It imports nothing, so the native apps replay it from\n * `function-cases.generated.json` (AGL-3671); `business-profile.ts` re-exports\n * all of it, so every existing import is unchanged.\n */\n\n/** The collection a profile document lives in, under a host or an org. */\nexport const BUSINESS_PROFILE_SUBCOLLECTION = 'businessProfile'\n/** The site's profile document id. */\nexport const BUSINESS_PROFILE_SITE_DOC = 'profile'\n/** The workspace defaults document id. */\nexport const BUSINESS_PROFILE_WORKSPACE_DOC = 'defaults'\n\n/** Who wrote a stored value: the owner, the guided start's answers, or a generator. */\nexport type BusinessProfileSource = 'owner' | 'start' | 'ai'\n\n/**\n * Where a RESOLVED value came from: a stored source, the site's own settings\n * (`seo.entity`, `business`, the display name), or the workspace defaults.\n */\nexport type BusinessProfileOrigin = BusinessProfileSource | 'site' | 'workspace'\n\n/** How much a source's words weigh: a higher rank is never replaced by a lower one. */\nconst SOURCE_RANK: Readonly<Record<BusinessProfileSource, number>> = {\n ai: 1,\n start: 2,\n owner: 3,\n}\n\nexport const BUSINESS_TONES = ['friendly', 'professional', 'playful', 'premium', 'plain'] as const\nexport type BusinessTone = (typeof BUSINESS_TONES)[number]\n\n/** Each tone as the owner picks it and as a writer is told it. */\nexport const BUSINESS_TONE_LABELS: Readonly<Record<BusinessTone, string>> = {\n friendly: 'Friendly and warm',\n professional: 'Professional and clear',\n playful: 'Playful and upbeat',\n premium: 'Premium and refined',\n plain: 'Plain and direct',\n}\n\n/** The fields the profile document stores itself. */\nexport const BUSINESS_PROFILE_FIELDS = [\n 'whatYouDo',\n 'services',\n 'serviceArea',\n 'audience',\n 'tone',\n 'toneNotes',\n] as const\nexport type BusinessProfileField = (typeof BUSINESS_PROFILE_FIELDS)[number]\n\n/** Each field's ceiling, in characters; `services` is per item. */\nexport const BUSINESS_PROFILE_MAX_CHARS: Readonly<Record<Exclude<BusinessProfileField, 'tone'>, number>> = {\n whatYouDo: 200,\n services: 60,\n serviceArea: 120,\n audience: 160,\n toneNotes: 200,\n}\n\n/** At most this many services are kept. */\nexport const BUSINESS_PROFILE_MAX_SERVICES = 12\n\n/** The stored document, at either level. */\nexport interface BusinessProfileDoc {\n whatYouDo?: string\n services?: string[]\n serviceArea?: string\n audience?: string\n tone?: BusinessTone | null\n toneNotes?: string\n /** Who wrote each stored value; a value without one is the owner's. */\n sources?: Partial<Record<BusinessProfileField, BusinessProfileSource>>\n updatedAt?: unknown\n updatedBy?: string | null\n}\n\n/** The values a write carries, before they are sanitized. */\nexport type BusinessProfileValues = Partial<\n Pick<BusinessProfileDoc, 'whatYouDo' | 'services' | 'serviceArea' | 'audience' | 'tone' | 'toneNotes'>\n>\n\nexport const text = (value: unknown, max: number): string =>\n typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim().slice(0, max) : ''\n\nconst tone = (value: unknown): BusinessTone | null =>\n (BUSINESS_TONES as readonly unknown[]).includes(value) ? (value as BusinessTone) : null\n\nconst services = (value: unknown): string[] => {\n const items = Array.isArray(value) ? value : typeof value === 'string' ? value.split(/[,\\n]/) : []\n const seen = new Set<string>()\n const kept: string[] = []\n for (const item of items) {\n const name = text(item, BUSINESS_PROFILE_MAX_CHARS.services)\n const key = name.toLowerCase()\n if (!name || seen.has(key)) continue\n seen.add(key)\n kept.push(name)\n if (kept.length >= BUSINESS_PROFILE_MAX_SERVICES) break\n }\n return kept\n}\n\n/** One field's stored value, sanitized; empty is `''`, `[]` or `null`. */\nexport function fieldValue(doc: BusinessProfileValues | null | undefined, field: BusinessProfileField): unknown {\n switch (field) {\n case 'services':\n return services(doc?.services)\n case 'tone':\n return tone(doc?.tone)\n default:\n return text(doc?.[field], BUSINESS_PROFILE_MAX_CHARS[field])\n }\n}\n\nexport const isEmpty = (value: unknown): boolean =>\n value === null || value === '' || (Array.isArray(value) && value.length === 0)\n\n/** The source a stored, non-empty value carries; the owner's when none is recorded. */\nexport function businessProfileSourceOf(\n doc: BusinessProfileDoc | null | undefined,\n field: BusinessProfileField,\n): BusinessProfileSource | null {\n if (isEmpty(fieldValue(doc, field))) return null\n const source = doc?.sources?.[field]\n return source && source in SOURCE_RANK ? source : 'owner'\n}\n\n/** Every value a write may carry, sanitized; an absent field stays absent. */\nexport function sanitizeBusinessProfileValues(values: BusinessProfileValues): BusinessProfileValues {\n const clean: BusinessProfileValues = {}\n for (const field of BUSINESS_PROFILE_FIELDS) {\n if (!(field in values)) continue\n ;(clean as Record<string, unknown>)[field] = fieldValue(values, field)\n }\n return clean\n}\n\n/**\n * The document a generator's or the guided start's values make of the stored\n * one: each value fills a field that is empty or was written by a source that\n * weighs less, and nothing else. `null` when nothing would change, so a caller\n * writes only when there is something to write.\n */\nexport function mergeBusinessProfilePrefill(\n current: BusinessProfileDoc | null | undefined,\n values: BusinessProfileValues,\n source: Exclude<BusinessProfileSource, 'owner'>,\n): BusinessProfileDoc | null {\n const clean = sanitizeBusinessProfileValues(values)\n const next: BusinessProfileDoc = { ...(current ?? {}), sources: { ...(current?.sources ?? {}) } }\n let changed = false\n for (const field of BUSINESS_PROFILE_FIELDS) {\n if (!(field in clean)) continue\n const value = (clean as Record<string, unknown>)[field]\n if (isEmpty(value)) continue\n // An explicit source counts even on an empty value: a field the owner\n // cleared stays cleared rather than being refilled behind them.\n const recorded = current?.sources?.[field]\n const held = recorded && recorded in SOURCE_RANK ? recorded : businessProfileSourceOf(current, field)\n if (held && SOURCE_RANK[held] >= SOURCE_RANK[source]) continue\n if (JSON.stringify(fieldValue(current, field)) === JSON.stringify(value)) continue\n ;(next as Record<string, unknown>)[field] = value\n next.sources![field] = source\n changed = true\n }\n return changed ? next : null\n}\n\n/**\n * The document the owner's save makes: every field they changed becomes\n * theirs, and a field they left as it was keeps the source it had — so saving\n * the form after correcting one line does not claim every suggestion on it.\n */\nexport function businessProfileOwnerWrite(\n current: BusinessProfileDoc | null | undefined,\n edited: BusinessProfileValues,\n): Pick<BusinessProfileDoc, BusinessProfileField | 'sources'> {\n const clean = sanitizeBusinessProfileValues(edited)\n const next: Record<string, unknown> = {}\n const sources: Partial<Record<BusinessProfileField, BusinessProfileSource>> = {}\n for (const field of BUSINESS_PROFILE_FIELDS) {\n const before = fieldValue(current, field)\n const after = field in clean ? (clean as Record<string, unknown>)[field] : before\n next[field] = after\n const same = JSON.stringify(before) === JSON.stringify(after)\n if (isEmpty(after)) {\n // A suggestion the owner deleted is their decision, and recorded as\n // one, so the next prefill does not put it back.\n if (!same) sources[field] = 'owner'\n else if (current?.sources?.[field] === 'owner') sources[field] = 'owner'\n continue\n }\n sources[field] = same ? (businessProfileSourceOf(current, field) ?? 'owner') : 'owner'\n }\n return { ...(next as Pick<BusinessProfileDoc, BusinessProfileField>), sources }\n}\n"],"names":["BUSINESS_PROFILE_SUBCOLLECTION","BUSINESS_PROFILE_SITE_DOC","BUSINESS_PROFILE_WORKSPACE_DOC","SOURCE_RANK","ai","start","owner","BUSINESS_TONES","BUSINESS_TONE_LABELS","friendly","professional","playful","premium","plain","BUSINESS_PROFILE_FIELDS","BUSINESS_PROFILE_MAX_CHARS","whatYouDo","services","serviceArea","audience","toneNotes","BUSINESS_PROFILE_MAX_SERVICES","text","value","max","replace","trim","slice","tone","includes","items","Array","isArray","split","seen","Set","kept","item","name","key","toLowerCase","has","add","push","length","fieldValue","doc","field","isEmpty","businessProfileSourceOf","source","sources","sanitizeBusinessProfileValues","values","clean","mergeBusinessProfilePrefill","current","next","changed","recorded","held","JSON","stringify","businessProfileOwnerWrite","edited","before","after","same"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;CAMC,GAED,wEAAwE,GACxE,OAAO,MAAMA,iCAAiC,kBAAiB;AAC/D,oCAAoC,GACpC,OAAO,MAAMC,4BAA4B,UAAS;AAClD,wCAAwC,GACxC,OAAO,MAAMC,iCAAiC,WAAU;AAWxD,qFAAqF,GACrF,MAAMC,cAA+D;IACnEC,IAAI;IACJC,OAAO;IACPC,OAAO;AACT;AAEA,OAAO,MAAMC,iBAAiB;IAAC;IAAY;IAAgB;IAAW;IAAW;CAAQ,CAAS;AAGlG,gEAAgE,GAChE,OAAO,MAAMC,uBAA+D;IAC1EC,UAAU;IACVC,cAAc;IACdC,SAAS;IACTC,SAAS;IACTC,OAAO;AACT,EAAC;AAED,mDAAmD,GACnD,OAAO,MAAMC,0BAA0B;IACrC;IACA;IACA;IACA;IACA;IACA;CACD,CAAS;AAGV,iEAAiE,GACjE,OAAO,MAAMC,6BAA8F;IACzGC,WAAW;IACXC,UAAU;IACVC,aAAa;IACbC,UAAU;IACVC,WAAW;AACb,EAAC;AAED,yCAAyC,GACzC,OAAO,MAAMC,gCAAgC,GAAE;AAqB/C,OAAO,MAAMC,OAAO,CAACC,OAAgBC,MACnC,OAAOD,UAAU,WAAWA,MAAME,OAAO,CAAC,QAAQ,KAAKC,IAAI,GAAGC,KAAK,CAAC,GAAGH,OAAO,GAAE;AAElF,MAAMI,OAAO,CAACL,QACZ,AAAChB,eAAsCsB,QAAQ,CAACN,SAAUA,QAAyB;AAErF,MAAMN,WAAW,CAACM;IAChB,MAAMO,QAAQC,MAAMC,OAAO,CAACT,SAASA,QAAQ,OAAOA,UAAU,WAAWA,MAAMU,KAAK,CAAC,WAAW,EAAE;IAClG,MAAMC,OAAO,IAAIC;IACjB,MAAMC,OAAiB,EAAE;IACzB,KAAK,MAAMC,QAAQP,MAAO;QACxB,MAAMQ,OAAOhB,KAAKe,MAAMtB,2BAA2BE,QAAQ;QAC3D,MAAMsB,MAAMD,KAAKE,WAAW;QAC5B,IAAI,CAACF,QAAQJ,KAAKO,GAAG,CAACF,MAAM;QAC5BL,KAAKQ,GAAG,CAACH;QACTH,KAAKO,IAAI,CAACL;QACV,IAAIF,KAAKQ,MAAM,IAAIvB,+BAA+B;IACpD;IACA,OAAOe;AACT;AAEA,wEAAwE,GACxE,OAAO,SAASS,WAAWC,GAA6C,EAAEC,KAA2B;IACnG,OAAQA;QACN,KAAK;YACH,OAAO9B,SAAS6B,uBAAAA,IAAK7B,QAAQ;QAC/B,KAAK;YACH,OAAOW,KAAKkB,uBAAAA,IAAKlB,IAAI;QACvB;YACE,OAAON,KAAKwB,uBAAAA,GAAK,CAACC,MAAM,EAAEhC,0BAA0B,CAACgC,MAAM;IAC/D;AACF;AAEA,OAAO,MAAMC,UAAU,CAACzB,QACtBA,UAAU,QAAQA,UAAU,MAAOQ,MAAMC,OAAO,CAACT,UAAUA,MAAMqB,MAAM,KAAK,EAAE;AAEhF,qFAAqF,GACrF,OAAO,SAASK,wBACdH,GAA0C,EAC1CC,KAA2B;QAGZD;IADf,IAAIE,QAAQH,WAAWC,KAAKC,SAAS,OAAO;IAC5C,MAAMG,SAASJ,wBAAAA,eAAAA,IAAKK,OAAO,qBAAZL,YAAc,CAACC,MAAM;IACpC,OAAOG,UAAUA,UAAU/C,cAAc+C,SAAS;AACpD;AAEA,4EAA4E,GAC5E,OAAO,SAASE,8BAA8BC,MAA6B;IACzE,MAAMC,QAA+B,CAAC;IACtC,KAAK,MAAMP,SAASjC,wBAAyB;QAC3C,IAAI,CAAEiC,CAAAA,SAASM,MAAK,GAAI;QACtBC,KAAiC,CAACP,MAAM,GAAGF,WAAWQ,QAAQN;IAClE;IACA,OAAOO;AACT;AAEA;;;;;CAKC,GACD,OAAO,SAASC,4BACdC,OAA8C,EAC9CH,MAA6B,EAC7BH,MAA+C;;IAE/C,MAAMI,QAAQF,8BAA8BC;IAC5C,MAAMI,OAA2B,aAAMD,kBAAAA,UAAW,CAAC;QAAIL,SAAS,qBAAMK,2BAAAA,QAASL,OAAO,mBAAI,CAAC;;IAC3F,IAAIO,UAAU;IACd,KAAK,MAAMX,SAASjC,wBAAyB;YAM1B0C;QALjB,IAAI,CAAET,CAAAA,SAASO,KAAI,GAAI;QACvB,MAAM/B,QAAQ,AAAC+B,KAAiC,CAACP,MAAM;QACvD,IAAIC,QAAQzB,QAAQ;QACpB,sEAAsE;QACtE,gEAAgE;QAChE,MAAMoC,WAAWH,4BAAAA,mBAAAA,QAASL,OAAO,qBAAhBK,gBAAkB,CAACT,MAAM;QAC1C,MAAMa,OAAOD,YAAYA,YAAYxD,cAAcwD,WAAWV,wBAAwBO,SAAST;QAC/F,IAAIa,QAAQzD,WAAW,CAACyD,KAAK,IAAIzD,WAAW,CAAC+C,OAAO,EAAE;QACtD,IAAIW,KAAKC,SAAS,CAACjB,WAAWW,SAAST,YAAYc,KAAKC,SAAS,CAACvC,QAAQ;QACxEkC,IAAgC,CAACV,MAAM,GAAGxB;QAC5CkC,KAAKN,OAAO,AAAC,CAACJ,MAAM,GAAGG;QACvBQ,UAAU;IACZ;IACA,OAAOA,UAAUD,OAAO;AAC1B;AAEA;;;;CAIC,GACD,OAAO,SAASM,0BACdP,OAA8C,EAC9CQ,MAA6B;IAE7B,MAAMV,QAAQF,8BAA8BY;IAC5C,MAAMP,OAAgC,CAAC;IACvC,MAAMN,UAAwE,CAAC;IAC/E,KAAK,MAAMJ,SAASjC,wBAAyB;YAYlBmC;QAXzB,MAAMgB,SAASpB,WAAWW,SAAST;QACnC,MAAMmB,QAAQnB,SAASO,QAAQ,AAACA,KAAiC,CAACP,MAAM,GAAGkB;QAC3ER,IAAI,CAACV,MAAM,GAAGmB;QACd,MAAMC,OAAON,KAAKC,SAAS,CAACG,YAAYJ,KAAKC,SAAS,CAACI;QACvD,IAAIlB,QAAQkB,QAAQ;gBAITV;YAHT,oEAAoE;YACpE,iDAAiD;YACjD,IAAI,CAACW,MAAMhB,OAAO,CAACJ,MAAM,GAAG;iBACvB,IAAIS,CAAAA,4BAAAA,mBAAAA,QAASL,OAAO,qBAAhBK,gBAAkB,CAACT,MAAM,MAAK,SAASI,OAAO,CAACJ,MAAM,GAAG;YACjE;QACF;QACAI,OAAO,CAACJ,MAAM,GAAGoB,QAAQlB,2BAAAA,wBAAwBO,SAAST,kBAAjCE,2BAA2C,UAAW;IACjF;IACA,OAAO,aAAMQ;QAAyDN;;AACxE"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/business-profile-values.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The pure part of the business profile: its fields, limits, sources and the\n * write rules (what the owner typed is theirs; a changed field becomes the\n * owner's). It imports nothing, so the native apps replay it from\n * `function-cases.generated.json` (AGL-3671); `business-profile.ts` re-exports\n * all of it, so every existing import is unchanged.\n */\n\n/** The collection a profile document lives in, under a host or an org. */\nexport const BUSINESS_PROFILE_SUBCOLLECTION = 'businessProfile'\n/** The site's profile document id. */\nexport const BUSINESS_PROFILE_SITE_DOC = 'profile'\n/** The workspace defaults document id. */\nexport const BUSINESS_PROFILE_WORKSPACE_DOC = 'defaults'\n\n/** Who wrote a stored value: the owner, the guided start's answers, or a generator. */\nexport type BusinessProfileSource = 'owner' | 'start' | 'ai'\n\n/**\n * Where a RESOLVED value came from: a stored source, the site's own settings\n * (`seo.entity`, `business`, the display name), or the workspace defaults.\n */\nexport type BusinessProfileOrigin = BusinessProfileSource | 'site' | 'workspace'\n\n/** How much a source's words weigh: a higher rank is never replaced by a lower one. */\nconst SOURCE_RANK: Readonly<Record<BusinessProfileSource, number>> = {\n ai: 1,\n start: 2,\n owner: 3,\n}\n\nexport const BUSINESS_TONES = ['friendly', 'professional', 'playful', 'premium', 'plain'] as const\nexport type BusinessTone = (typeof BUSINESS_TONES)[number]\n\n/** Each tone as the owner picks it and as a writer is told it. */\nexport const BUSINESS_TONE_LABELS: Readonly<Record<BusinessTone, string>> = {\n friendly: 'Friendly and warm',\n professional: 'Professional and clear',\n playful: 'Playful and upbeat',\n premium: 'Premium and refined',\n plain: 'Plain and direct',\n}\n\n/**\n * The fields the profile document stores itself. `founded` is the year the\n * business started (AGL-4092): the guided start asks it, and every job that\n * writes about the business reads it here rather than marking a gap for it.\n */\nexport const BUSINESS_PROFILE_FIELDS = [\n 'whatYouDo',\n 'services',\n 'serviceArea',\n 'audience',\n 'tone',\n 'toneNotes',\n 'founded',\n] as const\nexport type BusinessProfileField = (typeof BUSINESS_PROFILE_FIELDS)[number]\n\n/** Each field's ceiling, in characters; `services` is per item. */\nexport const BUSINESS_PROFILE_MAX_CHARS: Readonly<Record<Exclude<BusinessProfileField, 'tone'>, number>> = {\n whatYouDo: 200,\n services: 60,\n serviceArea: 120,\n audience: 160,\n toneNotes: 200,\n /** A year, four digits, as the guided start asks it. */\n founded: 4,\n}\n\n/** At most this many services are kept. */\nexport const BUSINESS_PROFILE_MAX_SERVICES = 12\n\n/** The stored document, at either level. */\nexport interface BusinessProfileDoc {\n whatYouDo?: string\n services?: string[]\n serviceArea?: string\n audience?: string\n tone?: BusinessTone | null\n toneNotes?: string\n /** The year the business started, four digits: `2019`. */\n founded?: string\n /** Who wrote each stored value; a value without one is the owner's. */\n sources?: Partial<Record<BusinessProfileField, BusinessProfileSource>>\n updatedAt?: unknown\n updatedBy?: string | null\n}\n\n/** The values a write carries, before they are sanitized. */\nexport type BusinessProfileValues = Partial<\n Pick<BusinessProfileDoc, 'whatYouDo' | 'services' | 'serviceArea' | 'audience' | 'tone' | 'toneNotes' | 'founded'>\n>\n\nexport const text = (value: unknown, max: number): string =>\n typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim().slice(0, max) : ''\n\nconst tone = (value: unknown): BusinessTone | null =>\n (BUSINESS_TONES as readonly unknown[]).includes(value) ? (value as BusinessTone) : null\n\nconst services = (value: unknown): string[] => {\n const items = Array.isArray(value) ? value : typeof value === 'string' ? value.split(/[,\\n]/) : []\n const seen = new Set<string>()\n const kept: string[] = []\n for (const item of items) {\n const name = text(item, BUSINESS_PROFILE_MAX_CHARS.services)\n const key = name.toLowerCase()\n if (!name || seen.has(key)) continue\n seen.add(key)\n kept.push(name)\n if (kept.length >= BUSINESS_PROFILE_MAX_SERVICES) break\n }\n return kept\n}\n\n/** One field's stored value, sanitized; empty is `''`, `[]` or `null`. */\nexport function fieldValue(doc: BusinessProfileValues | null | undefined, field: BusinessProfileField): unknown {\n switch (field) {\n case 'services':\n return services(doc?.services)\n case 'tone':\n return tone(doc?.tone)\n default:\n return text(doc?.[field], BUSINESS_PROFILE_MAX_CHARS[field])\n }\n}\n\n/**\n * What is wrong with a year founded as the owner typed it (AGL-4092), or\n * `null` when nothing is: empty, or four digits no later than this year —\n * the guided start's own rule.\n */\nexport function businessProfileFoundedProblem(value: unknown, now: Date = new Date()): string | null {\n const year = typeof value === 'string' ? value.trim() : ''\n if (!year) return null\n const latest = now.getFullYear()\n return /^\\d{4}$/.test(year) && Number(year) >= 1000 && Number(year) <= latest\n ? null\n : `Enter the year as four digits, like ${latest - 10}.`\n}\n\nexport const isEmpty = (value: unknown): boolean =>\n value === null || value === '' || (Array.isArray(value) && value.length === 0)\n\n/** The source a stored, non-empty value carries; the owner's when none is recorded. */\nexport function businessProfileSourceOf(\n doc: BusinessProfileDoc | null | undefined,\n field: BusinessProfileField,\n): BusinessProfileSource | null {\n if (isEmpty(fieldValue(doc, field))) return null\n const source = doc?.sources?.[field]\n return source && source in SOURCE_RANK ? source : 'owner'\n}\n\n/** Every value a write may carry, sanitized; an absent field stays absent. */\nexport function sanitizeBusinessProfileValues(values: BusinessProfileValues): BusinessProfileValues {\n const clean: BusinessProfileValues = {}\n for (const field of BUSINESS_PROFILE_FIELDS) {\n if (!(field in values)) continue\n ;(clean as Record<string, unknown>)[field] = fieldValue(values, field)\n }\n return clean\n}\n\n/**\n * The document a generator's or the guided start's values make of the stored\n * one: each value fills a field that is empty or was written by a source that\n * weighs less, and nothing else. `null` when nothing would change, so a caller\n * writes only when there is something to write.\n */\nexport function mergeBusinessProfilePrefill(\n current: BusinessProfileDoc | null | undefined,\n values: BusinessProfileValues,\n source: Exclude<BusinessProfileSource, 'owner'>,\n): BusinessProfileDoc | null {\n const clean = sanitizeBusinessProfileValues(values)\n const next: BusinessProfileDoc = { ...(current ?? {}), sources: { ...(current?.sources ?? {}) } }\n let changed = false\n for (const field of BUSINESS_PROFILE_FIELDS) {\n if (!(field in clean)) continue\n const value = (clean as Record<string, unknown>)[field]\n if (isEmpty(value)) continue\n // An explicit source counts even on an empty value: a field the owner\n // cleared stays cleared rather than being refilled behind them.\n const recorded = current?.sources?.[field]\n const held = recorded && recorded in SOURCE_RANK ? recorded : businessProfileSourceOf(current, field)\n if (held && SOURCE_RANK[held] >= SOURCE_RANK[source]) continue\n if (JSON.stringify(fieldValue(current, field)) === JSON.stringify(value)) continue\n ;(next as Record<string, unknown>)[field] = value\n next.sources![field] = source\n changed = true\n }\n return changed ? next : null\n}\n\n/**\n * The document the owner's save makes: every field they changed becomes\n * theirs, and a field they left as it was keeps the source it had — so saving\n * the form after correcting one line does not claim every suggestion on it.\n */\nexport function businessProfileOwnerWrite(\n current: BusinessProfileDoc | null | undefined,\n edited: BusinessProfileValues,\n): Pick<BusinessProfileDoc, BusinessProfileField | 'sources'> {\n const clean = sanitizeBusinessProfileValues(edited)\n const next: Record<string, unknown> = {}\n const sources: Partial<Record<BusinessProfileField, BusinessProfileSource>> = {}\n for (const field of BUSINESS_PROFILE_FIELDS) {\n const before = fieldValue(current, field)\n const after = field in clean ? (clean as Record<string, unknown>)[field] : before\n next[field] = after\n const same = JSON.stringify(before) === JSON.stringify(after)\n if (isEmpty(after)) {\n // A suggestion the owner deleted is their decision, and recorded as\n // one, so the next prefill does not put it back.\n if (!same) sources[field] = 'owner'\n else if (current?.sources?.[field] === 'owner') sources[field] = 'owner'\n continue\n }\n sources[field] = same ? (businessProfileSourceOf(current, field) ?? 'owner') : 'owner'\n }\n return { ...(next as Pick<BusinessProfileDoc, BusinessProfileField>), sources }\n}\n"],"names":["BUSINESS_PROFILE_SUBCOLLECTION","BUSINESS_PROFILE_SITE_DOC","BUSINESS_PROFILE_WORKSPACE_DOC","SOURCE_RANK","ai","start","owner","BUSINESS_TONES","BUSINESS_TONE_LABELS","friendly","professional","playful","premium","plain","BUSINESS_PROFILE_FIELDS","BUSINESS_PROFILE_MAX_CHARS","whatYouDo","services","serviceArea","audience","toneNotes","founded","BUSINESS_PROFILE_MAX_SERVICES","text","value","max","replace","trim","slice","tone","includes","items","Array","isArray","split","seen","Set","kept","item","name","key","toLowerCase","has","add","push","length","fieldValue","doc","field","businessProfileFoundedProblem","now","Date","year","latest","getFullYear","test","Number","isEmpty","businessProfileSourceOf","source","sources","sanitizeBusinessProfileValues","values","clean","mergeBusinessProfilePrefill","current","next","changed","recorded","held","JSON","stringify","businessProfileOwnerWrite","edited","before","after","same"],"mappings":"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"}
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { type HostSeoEntity } from './content-authors';
18
18
  import { type BusinessProfileDoc, type BusinessProfileOrigin, type BusinessTone } from './business-profile-values';
19
- export { BUSINESS_PROFILE_FIELDS, BUSINESS_PROFILE_MAX_CHARS, BUSINESS_PROFILE_MAX_SERVICES, BUSINESS_PROFILE_SITE_DOC, BUSINESS_PROFILE_SUBCOLLECTION, BUSINESS_PROFILE_WORKSPACE_DOC, BUSINESS_TONE_LABELS, BUSINESS_TONES, businessProfileOwnerWrite, businessProfileSourceOf, mergeBusinessProfilePrefill, sanitizeBusinessProfileValues, } from './business-profile-values';
19
+ export { BUSINESS_PROFILE_FIELDS, BUSINESS_PROFILE_MAX_CHARS, BUSINESS_PROFILE_MAX_SERVICES, BUSINESS_PROFILE_SITE_DOC, BUSINESS_PROFILE_SUBCOLLECTION, BUSINESS_PROFILE_WORKSPACE_DOC, BUSINESS_TONE_LABELS, BUSINESS_TONES, businessProfileFoundedProblem, businessProfileOwnerWrite, businessProfileSourceOf, mergeBusinessProfilePrefill, sanitizeBusinessProfileValues, } from './business-profile-values';
20
20
  export type { BusinessProfileDoc, BusinessProfileField, BusinessProfileOrigin, BusinessProfileSource, BusinessProfileValues, BusinessTone, } from './business-profile-values';
21
21
  /** The tones as a plain list, in the order the owner picks them, for the native apps' pickers. */
22
22
  export declare const BUSINESS_TONE_ORDER: readonly string[];
@@ -37,8 +37,8 @@ export declare const BUSINESS_PROFILE_FIELD_ORDER: readonly string[];
37
37
  * here would make two places to keep in step, and the copy would drift.
38
38
  *
39
39
  * So the profile document holds only what has no home yet — the services, the
40
- * audience, the tone of voice — plus a line about the business and the area it
41
- * serves for a site whose SEO entity does not say. {@link resolveBusinessProfile}
40
+ * audience, the tone of voice, the year it was founded — plus a line about the
41
+ * business and the area it serves for a site whose SEO entity does not say. {@link resolveBusinessProfile}
42
42
  * reads the two together into one picture, field by field, and says where
43
43
  * each value came from.
44
44
  *
@@ -88,6 +88,8 @@ export interface ResolvedBusinessProfile {
88
88
  audience: ResolvedBusinessField<string> | null;
89
89
  tone: ResolvedBusinessField<BusinessTone> | null;
90
90
  toneNotes: ResolvedBusinessField<string> | null;
91
+ /** The year the business started (AGL-4092). */
92
+ founded: ResolvedBusinessField<string> | null;
91
93
  /** The schema.org business type the SEO entity names, e.g. `Plumber`. */
92
94
  businessType: string | null;
93
95
  /** Real contact details, each only where the owner entered it in the site's settings. */
@@ -17,7 +17,7 @@
17
17
  import { normalizeAreaServed } from "./local-business.js";
18
18
  import { PLATFORM_BRAND_NAME } from "./platform-brand.js";
19
19
  import { BUSINESS_PROFILE_FIELDS, BUSINESS_PROFILE_MAX_CHARS, BUSINESS_TONES, businessProfileSourceOf, fieldValue, isEmpty, text } from "./business-profile-values.js";
20
- export { BUSINESS_PROFILE_FIELDS, BUSINESS_PROFILE_MAX_CHARS, BUSINESS_PROFILE_MAX_SERVICES, BUSINESS_PROFILE_SITE_DOC, BUSINESS_PROFILE_SUBCOLLECTION, BUSINESS_PROFILE_WORKSPACE_DOC, BUSINESS_TONE_LABELS, BUSINESS_TONES, businessProfileOwnerWrite, businessProfileSourceOf, mergeBusinessProfilePrefill, sanitizeBusinessProfileValues } from "./business-profile-values.js";
20
+ export { BUSINESS_PROFILE_FIELDS, BUSINESS_PROFILE_MAX_CHARS, BUSINESS_PROFILE_MAX_SERVICES, BUSINESS_PROFILE_SITE_DOC, BUSINESS_PROFILE_SUBCOLLECTION, BUSINESS_PROFILE_WORKSPACE_DOC, BUSINESS_TONE_LABELS, BUSINESS_TONES, businessProfileFoundedProblem, businessProfileOwnerWrite, businessProfileSourceOf, mergeBusinessProfilePrefill, sanitizeBusinessProfileValues } from "./business-profile-values.js";
21
21
  /** The tones as a plain list, in the order the owner picks them, for the native apps' pickers. */ export const BUSINESS_TONE_ORDER = BUSINESS_TONES;
22
22
  /** The stored fields as a plain list, for the native apps' writers. */ export const BUSINESS_PROFILE_FIELD_ORDER = BUSINESS_PROFILE_FIELDS;
23
23
  /** The postal address as one line: the SEO entity's parts, else the contact card's block. */ function addressLine(host) {
@@ -87,6 +87,7 @@ export { BUSINESS_PROFILE_FIELDS, BUSINESS_PROFILE_MAX_CHARS, BUSINESS_PROFILE_M
87
87
  audience: resolve('audience'),
88
88
  tone: resolve('tone'),
89
89
  toneNotes: resolve('toneNotes'),
90
+ founded: resolve('founded'),
90
91
  businessType: text(entity == null ? void 0 : entity.businessType, 80) || null,
91
92
  contact: {
92
93
  email: text(entity == null ? void 0 : entity.email, 320) || text(host == null ? void 0 : (_host_business = host.business) == null ? void 0 : _host_business.supportEmail, 320) || null,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/business-profile.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { siteProfileUrls, type HostSeoEntity } from './content-authors'\nimport { normalizeAreaServed } from './local-business'\nimport { PLATFORM_BRAND_NAME } from './platform-brand'\nimport {\n BUSINESS_PROFILE_FIELDS,\n BUSINESS_PROFILE_MAX_CHARS,\n BUSINESS_TONES,\n businessProfileSourceOf,\n fieldValue,\n isEmpty,\n text,\n type BusinessProfileDoc,\n type BusinessProfileField,\n type BusinessProfileOrigin,\n type BusinessProfileSource,\n type BusinessProfileValues,\n type BusinessTone,\n} from './business-profile-values'\n\nexport {\n BUSINESS_PROFILE_FIELDS,\n BUSINESS_PROFILE_MAX_CHARS,\n BUSINESS_PROFILE_MAX_SERVICES,\n BUSINESS_PROFILE_SITE_DOC,\n BUSINESS_PROFILE_SUBCOLLECTION,\n BUSINESS_PROFILE_WORKSPACE_DOC,\n BUSINESS_TONE_LABELS,\n BUSINESS_TONES,\n businessProfileOwnerWrite,\n businessProfileSourceOf,\n mergeBusinessProfilePrefill,\n sanitizeBusinessProfileValues,\n} from './business-profile-values'\nexport type {\n BusinessProfileDoc,\n BusinessProfileField,\n BusinessProfileOrigin,\n BusinessProfileSource,\n BusinessProfileValues,\n BusinessTone,\n} from './business-profile-values'\n\n/** The tones as a plain list, in the order the owner picks them, for the native apps' pickers. */\nexport const BUSINESS_TONE_ORDER: readonly string[] = BUSINESS_TONES\n\n/** The stored fields as a plain list, for the native apps' writers. */\nexport const BUSINESS_PROFILE_FIELD_ORDER: readonly string[] = BUSINESS_PROFILE_FIELDS\n\n/**\n * A site's BUSINESS PROFILE (AGL-3661): what the business is, as everything\n * that writes about it needs to know — the site's own pages, the structured\n * data, and any generator a plugin brings.\n *\n * ── Most of it is already stored, so this stores only the rest ─────────────\n *\n * The site's name, its one-line description, its contact details, its hours,\n * the areas it serves and its social profiles all have a home on the host\n * document already: `seo.entity` (the publisher entity the JSON-LD is built\n * from, edited in Setup → SEO) and `business` (the contact card the `host.*`\n * tokens and the footer read, edited in Setup → Basic details). Copying them\n * here would make two places to keep in step, and the copy would drift.\n *\n * So the profile document holds only what has no home yet — the services, the\n * audience, the tone of voice — plus a line about the business and the area it\n * serves for a site whose SEO entity does not say. {@link resolveBusinessProfile}\n * reads the two together into one picture, field by field, and says where\n * each value came from.\n *\n * ── Where it is stored ────────────────────────────────────────────────────\n *\n * `hosts/{hostId}/businessProfile/profile` for a site and\n * `orgs/{orgId}/businessProfile/defaults` for the workspace, whose values a\n * site inherits where it has none of its own. A subcollection document rather\n * than a key on the host: the host document's `business` map is replaced\n * whole by the contact card's save, and a key beside it would be one more\n * thing a partial write could blank.\n *\n * ── Whose words win ───────────────────────────────────────────────────────\n *\n * Every stored value carries its source. What the owner typed is never\n * replaced by anything else; what the guided start was told replaces only\n * what a generator guessed; and a generator's guess fills only what is empty.\n * {@link mergeBusinessProfilePrefill} is the one place that rule is applied.\n */\n\n\n/** The host fields the profile reads. A closed surface, as the host tokens' is. */\nexport interface BusinessProfileHost {\n displayName?: string\n logoUrl?: string\n seo?: { description?: string; entity?: HostSeoEntity | null } | null\n business?: {\n supportEmail?: string\n address?: string\n socialLinks?: Array<{ label?: string; url?: string } | null> | null\n } | null\n}\n\nexport interface ResolvedBusinessField<T> {\n value: T\n origin: BusinessProfileOrigin\n}\n\n/** The picture every reader works from: each field's value and where it came from. */\nexport interface ResolvedBusinessProfile {\n name: ResolvedBusinessField<string> | null\n whatYouDo: ResolvedBusinessField<string> | null\n services: ResolvedBusinessField<string[]> | null\n serviceArea: ResolvedBusinessField<string> | null\n audience: ResolvedBusinessField<string> | null\n tone: ResolvedBusinessField<BusinessTone> | null\n toneNotes: ResolvedBusinessField<string> | null\n /** The schema.org business type the SEO entity names, e.g. `Plumber`. */\n businessType: string | null\n /** Real contact details, each only where the owner entered it in the site's settings. */\n contact: {\n email: string | null\n phone: string | null\n address: string | null\n hours: string | null\n }\n /** The site's social and other profiles, https only. */\n profiles: string[]\n logo: string | null\n}\n\n\n/** The postal address as one line: the SEO entity's parts, else the contact card's block. */\nfunction addressLine(host: BusinessProfileHost | null | undefined): string {\n const parts = host?.seo?.entity?.address\n const line = [\n parts?.streetAddress,\n parts?.addressLocality,\n parts?.addressRegion,\n parts?.postalCode,\n parts?.addressCountry,\n ]\n .map((part) => text(part, 200))\n .filter(Boolean)\n .join(', ')\n return line || text(host?.business?.address, 400)\n}\n\n/**\n * The profile as every reader sees it, field by field (AGL-3661):\n *\n * 1. what the OWNER typed into the site's profile;\n * 2. what the site's own settings say (the SEO entity, the contact card, the\n * display name), which the owner typed too, elsewhere;\n * 3. what the guided start or a generator stored on the site's profile;\n * 4. the workspace defaults.\n *\n * Contact details, the logo and the profiles come from the site's settings\n * only: nothing but the owner's own entry may say how to reach the business.\n */\nexport function resolveBusinessProfile(input: {\n host: BusinessProfileHost | null | undefined\n site?: BusinessProfileDoc | null\n workspace?: BusinessProfileDoc | null\n}): ResolvedBusinessProfile {\n const { host, site, workspace } = input\n const entity = host?.seo?.entity ?? null\n const fromSite: Partial<Record<BusinessProfileField, unknown>> = {\n whatYouDo: text(entity?.description, BUSINESS_PROFILE_MAX_CHARS.whatYouDo),\n serviceArea: normalizeAreaServed(entity?.areaServed).join(', ').slice(0, BUSINESS_PROFILE_MAX_CHARS.serviceArea),\n }\n\n const resolve = <T>(field: BusinessProfileField): ResolvedBusinessField<T> | null => {\n const stored = businessProfileSourceOf(site, field)\n if (stored === 'owner') return { value: fieldValue(site, field) as T, origin: 'owner' }\n const own = fromSite[field]\n if (own !== undefined && !isEmpty(own)) return { value: own as T, origin: 'site' }\n if (stored) return { value: fieldValue(site, field) as T, origin: stored }\n if (businessProfileSourceOf(workspace, field)) {\n return { value: fieldValue(workspace, field) as T, origin: 'workspace' }\n }\n return null\n }\n\n const name = text(entity?.name, 200) || text(host?.displayName, 200)\n return {\n name: name ? { value: name, origin: 'site' } : null,\n whatYouDo: resolve<string>('whatYouDo'),\n services: resolve<string[]>('services'),\n serviceArea: resolve<string>('serviceArea'),\n audience: resolve<string>('audience'),\n tone: resolve<BusinessTone>('tone'),\n toneNotes: resolve<string>('toneNotes'),\n businessType: text(entity?.businessType, 80) || null,\n contact: {\n email: text(entity?.email, 320) || text(host?.business?.supportEmail, 320) || null,\n phone: text(entity?.telephone, 64) || null,\n address: addressLine(host) || null,\n hours: text(entity?.openingHours, 400) || null,\n },\n profiles: siteProfileUrls(host as Parameters<typeof siteProfileUrls>[0]),\n logo: text(host?.logoUrl, 800) || text(entity?.logo, 800) || null,\n }\n}\n\n/** Each origin as the profile screen labels it. */\nexport const BUSINESS_PROFILE_ORIGIN_LABELS: Readonly<Record<BusinessProfileOrigin, string>> = {\n owner: 'You',\n site: 'Site settings',\n start: 'Your answers when the site was started',\n ai: `Suggested by ${PLATFORM_BRAND_NAME} AI`,\n workspace: 'Workspace default',\n}\n"],"names":["siteProfileUrls","normalizeAreaServed","PLATFORM_BRAND_NAME","BUSINESS_PROFILE_FIELDS","BUSINESS_PROFILE_MAX_CHARS","BUSINESS_TONES","businessProfileSourceOf","fieldValue","isEmpty","text","BUSINESS_PROFILE_MAX_SERVICES","BUSINESS_PROFILE_SITE_DOC","BUSINESS_PROFILE_SUBCOLLECTION","BUSINESS_PROFILE_WORKSPACE_DOC","BUSINESS_TONE_LABELS","businessProfileOwnerWrite","mergeBusinessProfilePrefill","sanitizeBusinessProfileValues","BUSINESS_TONE_ORDER","BUSINESS_PROFILE_FIELD_ORDER","addressLine","host","parts","seo","entity","address","line","streetAddress","addressLocality","addressRegion","postalCode","addressCountry","map","part","filter","Boolean","join","business","resolveBusinessProfile","input","site","workspace","fromSite","whatYouDo","description","serviceArea","areaServed","slice","resolve","field","stored","value","origin","own","undefined","name","displayName","services","audience","tone","toneNotes","businessType","contact","email","supportEmail","phone","telephone","hours","openingHours","profiles","logo","logoUrl","BUSINESS_PROFILE_ORIGIN_LABELS","owner","start","ai"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/business-profile.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { siteProfileUrls, type HostSeoEntity } from './content-authors'\nimport { normalizeAreaServed } from './local-business'\nimport { PLATFORM_BRAND_NAME } from './platform-brand'\nimport {\n BUSINESS_PROFILE_FIELDS,\n BUSINESS_PROFILE_MAX_CHARS,\n BUSINESS_TONES,\n businessProfileSourceOf,\n fieldValue,\n isEmpty,\n text,\n type BusinessProfileDoc,\n type BusinessProfileField,\n type BusinessProfileOrigin,\n type BusinessProfileSource,\n type BusinessProfileValues,\n type BusinessTone,\n} from './business-profile-values'\n\nexport {\n BUSINESS_PROFILE_FIELDS,\n BUSINESS_PROFILE_MAX_CHARS,\n BUSINESS_PROFILE_MAX_SERVICES,\n BUSINESS_PROFILE_SITE_DOC,\n BUSINESS_PROFILE_SUBCOLLECTION,\n BUSINESS_PROFILE_WORKSPACE_DOC,\n BUSINESS_TONE_LABELS,\n BUSINESS_TONES,\n businessProfileFoundedProblem,\n businessProfileOwnerWrite,\n businessProfileSourceOf,\n mergeBusinessProfilePrefill,\n sanitizeBusinessProfileValues,\n} from './business-profile-values'\nexport type {\n BusinessProfileDoc,\n BusinessProfileField,\n BusinessProfileOrigin,\n BusinessProfileSource,\n BusinessProfileValues,\n BusinessTone,\n} from './business-profile-values'\n\n/** The tones as a plain list, in the order the owner picks them, for the native apps' pickers. */\nexport const BUSINESS_TONE_ORDER: readonly string[] = BUSINESS_TONES\n\n/** The stored fields as a plain list, for the native apps' writers. */\nexport const BUSINESS_PROFILE_FIELD_ORDER: readonly string[] = BUSINESS_PROFILE_FIELDS\n\n/**\n * A site's BUSINESS PROFILE (AGL-3661): what the business is, as everything\n * that writes about it needs to know — the site's own pages, the structured\n * data, and any generator a plugin brings.\n *\n * ── Most of it is already stored, so this stores only the rest ─────────────\n *\n * The site's name, its one-line description, its contact details, its hours,\n * the areas it serves and its social profiles all have a home on the host\n * document already: `seo.entity` (the publisher entity the JSON-LD is built\n * from, edited in Setup → SEO) and `business` (the contact card the `host.*`\n * tokens and the footer read, edited in Setup → Basic details). Copying them\n * here would make two places to keep in step, and the copy would drift.\n *\n * So the profile document holds only what has no home yet — the services, the\n * audience, the tone of voice, the year it was founded — plus a line about the\n * business and the area it serves for a site whose SEO entity does not say. {@link resolveBusinessProfile}\n * reads the two together into one picture, field by field, and says where\n * each value came from.\n *\n * ── Where it is stored ────────────────────────────────────────────────────\n *\n * `hosts/{hostId}/businessProfile/profile` for a site and\n * `orgs/{orgId}/businessProfile/defaults` for the workspace, whose values a\n * site inherits where it has none of its own. A subcollection document rather\n * than a key on the host: the host document's `business` map is replaced\n * whole by the contact card's save, and a key beside it would be one more\n * thing a partial write could blank.\n *\n * ── Whose words win ───────────────────────────────────────────────────────\n *\n * Every stored value carries its source. What the owner typed is never\n * replaced by anything else; what the guided start was told replaces only\n * what a generator guessed; and a generator's guess fills only what is empty.\n * {@link mergeBusinessProfilePrefill} is the one place that rule is applied.\n */\n\n\n/** The host fields the profile reads. A closed surface, as the host tokens' is. */\nexport interface BusinessProfileHost {\n displayName?: string\n logoUrl?: string\n seo?: { description?: string; entity?: HostSeoEntity | null } | null\n business?: {\n supportEmail?: string\n address?: string\n socialLinks?: Array<{ label?: string; url?: string } | null> | null\n } | null\n}\n\nexport interface ResolvedBusinessField<T> {\n value: T\n origin: BusinessProfileOrigin\n}\n\n/** The picture every reader works from: each field's value and where it came from. */\nexport interface ResolvedBusinessProfile {\n name: ResolvedBusinessField<string> | null\n whatYouDo: ResolvedBusinessField<string> | null\n services: ResolvedBusinessField<string[]> | null\n serviceArea: ResolvedBusinessField<string> | null\n audience: ResolvedBusinessField<string> | null\n tone: ResolvedBusinessField<BusinessTone> | null\n toneNotes: ResolvedBusinessField<string> | null\n /** The year the business started (AGL-4092). */\n founded: ResolvedBusinessField<string> | null\n /** The schema.org business type the SEO entity names, e.g. `Plumber`. */\n businessType: string | null\n /** Real contact details, each only where the owner entered it in the site's settings. */\n contact: {\n email: string | null\n phone: string | null\n address: string | null\n hours: string | null\n }\n /** The site's social and other profiles, https only. */\n profiles: string[]\n logo: string | null\n}\n\n\n/** The postal address as one line: the SEO entity's parts, else the contact card's block. */\nfunction addressLine(host: BusinessProfileHost | null | undefined): string {\n const parts = host?.seo?.entity?.address\n const line = [\n parts?.streetAddress,\n parts?.addressLocality,\n parts?.addressRegion,\n parts?.postalCode,\n parts?.addressCountry,\n ]\n .map((part) => text(part, 200))\n .filter(Boolean)\n .join(', ')\n return line || text(host?.business?.address, 400)\n}\n\n/**\n * The profile as every reader sees it, field by field (AGL-3661):\n *\n * 1. what the OWNER typed into the site's profile;\n * 2. what the site's own settings say (the SEO entity, the contact card, the\n * display name), which the owner typed too, elsewhere;\n * 3. what the guided start or a generator stored on the site's profile;\n * 4. the workspace defaults.\n *\n * Contact details, the logo and the profiles come from the site's settings\n * only: nothing but the owner's own entry may say how to reach the business.\n */\nexport function resolveBusinessProfile(input: {\n host: BusinessProfileHost | null | undefined\n site?: BusinessProfileDoc | null\n workspace?: BusinessProfileDoc | null\n}): ResolvedBusinessProfile {\n const { host, site, workspace } = input\n const entity = host?.seo?.entity ?? null\n const fromSite: Partial<Record<BusinessProfileField, unknown>> = {\n whatYouDo: text(entity?.description, BUSINESS_PROFILE_MAX_CHARS.whatYouDo),\n serviceArea: normalizeAreaServed(entity?.areaServed).join(', ').slice(0, BUSINESS_PROFILE_MAX_CHARS.serviceArea),\n }\n\n const resolve = <T>(field: BusinessProfileField): ResolvedBusinessField<T> | null => {\n const stored = businessProfileSourceOf(site, field)\n if (stored === 'owner') return { value: fieldValue(site, field) as T, origin: 'owner' }\n const own = fromSite[field]\n if (own !== undefined && !isEmpty(own)) return { value: own as T, origin: 'site' }\n if (stored) return { value: fieldValue(site, field) as T, origin: stored }\n if (businessProfileSourceOf(workspace, field)) {\n return { value: fieldValue(workspace, field) as T, origin: 'workspace' }\n }\n return null\n }\n\n const name = text(entity?.name, 200) || text(host?.displayName, 200)\n return {\n name: name ? { value: name, origin: 'site' } : null,\n whatYouDo: resolve<string>('whatYouDo'),\n services: resolve<string[]>('services'),\n serviceArea: resolve<string>('serviceArea'),\n audience: resolve<string>('audience'),\n tone: resolve<BusinessTone>('tone'),\n toneNotes: resolve<string>('toneNotes'),\n founded: resolve<string>('founded'),\n businessType: text(entity?.businessType, 80) || null,\n contact: {\n email: text(entity?.email, 320) || text(host?.business?.supportEmail, 320) || null,\n phone: text(entity?.telephone, 64) || null,\n address: addressLine(host) || null,\n hours: text(entity?.openingHours, 400) || null,\n },\n profiles: siteProfileUrls(host as Parameters<typeof siteProfileUrls>[0]),\n logo: text(host?.logoUrl, 800) || text(entity?.logo, 800) || null,\n }\n}\n\n/** Each origin as the profile screen labels it. */\nexport const BUSINESS_PROFILE_ORIGIN_LABELS: Readonly<Record<BusinessProfileOrigin, string>> = {\n owner: 'You',\n site: 'Site settings',\n start: 'Your answers when the site was started',\n ai: `Suggested by ${PLATFORM_BRAND_NAME} AI`,\n workspace: 'Workspace default',\n}\n"],"names":["siteProfileUrls","normalizeAreaServed","PLATFORM_BRAND_NAME","BUSINESS_PROFILE_FIELDS","BUSINESS_PROFILE_MAX_CHARS","BUSINESS_TONES","businessProfileSourceOf","fieldValue","isEmpty","text","BUSINESS_PROFILE_MAX_SERVICES","BUSINESS_PROFILE_SITE_DOC","BUSINESS_PROFILE_SUBCOLLECTION","BUSINESS_PROFILE_WORKSPACE_DOC","BUSINESS_TONE_LABELS","businessProfileFoundedProblem","businessProfileOwnerWrite","mergeBusinessProfilePrefill","sanitizeBusinessProfileValues","BUSINESS_TONE_ORDER","BUSINESS_PROFILE_FIELD_ORDER","addressLine","host","parts","seo","entity","address","line","streetAddress","addressLocality","addressRegion","postalCode","addressCountry","map","part","filter","Boolean","join","business","resolveBusinessProfile","input","site","workspace","fromSite","whatYouDo","description","serviceArea","areaServed","slice","resolve","field","stored","value","origin","own","undefined","name","displayName","services","audience","tone","toneNotes","founded","businessType","contact","email","supportEmail","phone","telephone","hours","openingHours","profiles","logo","logoUrl","BUSINESS_PROFILE_ORIGIN_LABELS","owner","start","ai"],"mappings":"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"}
@@ -590,7 +590,7 @@ export const DOCS_SECTION_INDEX = [
590
590
  title: 'Business profile',
591
591
  heading: '',
592
592
  anchor: '',
593
- text: 'Business profile\nEvery AI job for a site starts from what the site already knows about the business: its name, what it does, its services, the area it serves, who it is for, its tone of voice, and the contact details you entered. Aglyn AI writes for this business instead of guessing, and it never makes up a way to reach you.'
593
+ text: 'Business profile\nEvery AI job for a site starts from what the site already knows about the business: its name, what it does, its services, the area it serves, who it is for, its tone of voice, the year it was founded, and the contact details you entered. Aglyn AI writes for this business instead of guessing, and it never makes up a way to reach you.'
594
594
  },
595
595
  {
596
596
  path: '/ai/business-profile',
@@ -604,21 +604,21 @@ export const DOCS_SECTION_INDEX = [
604
604
  title: 'Business profile',
605
605
  heading: 'The Business profile card',
606
606
  anchor: '#the-business-profile-card',
607
- text: 'The Business profile card holds what Aglyn AI writes for:\n- What the business does, in a sentence or two;\n- Services, one per line, up to 12;\n- Who it is for and Area served;\n- Tone of voice: Friendly and warm, Professional and clear, Playful and upbeat, Premium and refined, or Plain and direct, and Notes on tone for anything else.\nAglyn AI reads it on every job for the site and never replaces what you write here.'
607
+ text: 'The Business profile card holds what Aglyn AI writes for:\n- What the business does, in a sentence or two;\n- Services, one per line, up to 12;\n- Who it is for and Area served;\n- Tone of voice: Friendly and warm, Professional and clear, Playful and upbeat, Premium and refined, or Plain and direct, and Notes on tone for anything else;\n- Year founded, as four digits, such as 2015. An About page or a "Since" line uses it instead of leaving a gap where the year would go.\nAglyn AI reads it on every job for the site and never replaces what you write here.'
608
608
  },
609
609
  {
610
610
  path: '/ai/business-profile',
611
611
  title: 'Business profile',
612
612
  heading: 'Where the values come from',
613
613
  anchor: '#where-the-values-come-from',
614
- text: 'When a site is started with Aglyn AI, the profile is filled from your answers to the guided start, and from what the plan wrote about the business. Each field says where its value came from:\n- Your answers when the site was started, or Suggested by Aglyn AI: a suggestion. Edit it and it becomes yours.\n- An empty field that Aglyn AI still has a value for says so: the description in your SEO settings, or the workspace\'s default.\nWhat you type is never replaced. A later job can fill an empty field, but it never overwrites one you wrote, and a field you cleared stays clear.\nWorkspace defaults {#workspace-defaults}\nSettings → Profile has a Business profile defaults card. A site uses those values wherever its own profile is empty, so a workspace that runs many sites for one business writes them once. Only managers can change them.'
614
+ text: 'When a site is started with Aglyn AI, the profile is filled from your answers to the guided start, including the Year founded from More details, and from what the plan wrote about the business. Each field says where its value came from:\n- Your answers when the site was started, or Suggested by Aglyn AI: a suggestion. Edit it and it becomes yours.\n- An empty field that Aglyn AI still has a value for says so: the description in your SEO settings, or the workspace\'s default.\nWhat you type is never replaced. A later job can fill an empty field, but it never overwrites one you wrote, and a field you cleared stays clear.\nWorkspace defaults {#workspace-defaults}\nSettings → Profile has a Business profile defaults card. A site uses those values wherever its own profile is empty, so a workspace that runs many sites for one business writes them once. Only managers can change them. The year founded is set on each site\'s own card.'
615
615
  },
616
616
  {
617
617
  path: '/ai/business-profile',
618
618
  title: 'Business profile',
619
619
  heading: 'Contact details are never invented',
620
620
  anchor: '#contact-details-are-never-invented',
621
- text: 'The name, business type, email, phone, address, opening hours and social profiles come only from Setup → Basic details and Setup → SEO, exactly as entered. A detail you have not entered reads Not entered, and Aglyn AI writes around it, or leaves a bracketed placeholder such as [phone number] for you to fill, instead of making one up. The same goes for prices, reviews and testimonials.'
621
+ text: 'The name, business type, email, phone, address, opening hours and social profiles come only from Setup → Basic details and Setup → SEO, exactly as entered. A detail you have not entered reads Not entered, and Aglyn AI writes the sentence without it instead of making one up or leaving a gap for you to fill. The same goes for prices, reviews and testimonials.'
622
622
  },
623
623
  {
624
624
  path: '/ai/business-profile',
@@ -884,7 +884,7 @@ export const DOCS_SECTION_INDEX = [
884
884
  title: 'Generate a form from a description',
885
885
  heading: 'What a form cannot collect',
886
886
  anchor: '#what-a-form-cannot-collect',
887
- text: 'A form collects text, choices and ratings. When your brief asks for something else, such as a file or photo upload, a signature or a payment, the form leaves it out and the job says so beside Open draft. For a roofing quote request that asks for photos of the damage, for example, the form asks for a written description of the damage instead. The job also lists any fact the brief did not give that the form marks in square brackets, such as a label naming your studio, for you to fill in before the form goes on a page.'
887
+ text: 'A form collects text, choices and ratings. When your brief asks for something else, such as a file or photo upload, a signature or a payment, the form leaves it out and the job says so beside Open draft. For a roofing quote request that asks for photos of the damage, for example, the form asks for a written description of the damage instead. A fact the brief did not give is left out of the form. If the form still shows one in square brackets, such as a label naming your studio, the job lists it for you to fill in before the form goes on a page.'
888
888
  },
889
889
  {
890
890
  path: '/ai/generate-a-form',
@@ -3019,7 +3019,7 @@ export const DOCS_SECTION_INDEX = [
3019
3019
  title: 'Manage your domains',
3020
3020
  heading: '',
3021
3021
  anchor: '',
3022
- text: 'Manage your domains\nYour workspace\'s Domains page lists every domain it holds through Aglyn: the ones connected to a site, the ones that aren\'t connected yet, and the ones on their way. Open it from your workspace\'s menu. Workspace owners and admins can open it.\nPlan availability Starter and above for buying, transferring in and connecting domains. On a plan without domains, the page still lists the domains you hold, and you can still turn auto-renew on or off, edit their contacts and DNS records, or move them to another registrar.'
3022
+ text: 'Manage your domains\nYour workspace\'s Domains page lists every domain it holds through Aglyn: the ones connected to a site, the ones that aren\'t connected yet, and the ones on their way. Open it from your workspace\'s menu. Opening it takes the Manage the workspace\'s domains permission, which workspace owners and admins have.\nPlan availability Starter and above for buying, transferring in and connecting domains. On a plan without domains, the page still lists the domains you hold, and you can still turn auto-renew on or off, edit their contacts and DNS records, or move them to another registrar.'
3023
3023
  },
3024
3024
  {
3025
3025
  path: '/building-sites/custom-domains/manage-domains',
@@ -3572,7 +3572,7 @@ export const DOCS_SECTION_INDEX = [
3572
3572
  title: 'Generate a page from a prompt',
3573
3573
  heading: 'How the page is built',
3574
3574
  anchor: '#how-the-page-is-built',
3575
- text: 'The job first builds anything the plan creates, one at a time: a layout, a form or a reusable component, each as a draft, the same way a layout, form or component job builds one. Then it builds the page one section at a time, top to bottom, in the background, so you can leave the Pages list while it works. Each section is checked against the building rules as part of the whole page: one top-level heading and headings in order, your theme\'s colors, spacing and type, and your components placed as instances instead of copied.\n- Pictures are image slots with a description of what belongs there, for you to fill from the media library.\n- Facts the brief does not give, such as a price, a phone number or an address, appear in square brackets for you to fill in, instead of being invented. AI jobs lists them beside the draft, including the ones a component on the page shows until you set your own, named once for that component however often the page places it.'
3575
+ text: 'The job first builds anything the plan creates, one at a time: a layout, a form or a reusable component, each as a draft, the same way a layout, form or component job builds one. Then it builds the page one section at a time, top to bottom, in the background, so you can leave the Pages list while it works. Each section is checked against the building rules as part of the whole page: one top-level heading and headings in order, your theme\'s colors, spacing and type, and your components placed as instances instead of copied.\n- Pictures are image slots with a description of what belongs there, for you to fill from the media library.\n- Facts the brief does not give, such as a price, a phone number or an address, are left out, never invented. If the draft still shows one in square brackets, AI jobs lists it beside the draft, including the ones a component on the page shows until you set your own, named once for that component however often the page places it.'
3576
3576
  },
3577
3577
  {
3578
3578
  path: '/building-sites/screens-and-layouts/generate-a-page',
@@ -3656,7 +3656,7 @@ export const DOCS_SECTION_INDEX = [
3656
3656
  title: 'Layouts',
3657
3657
  heading: 'Generate a layout with Aglyn AI',
3658
3658
  anchor: '#generate-a-layout-with-aglyn-ai',
3659
- text: 'An AI build job can make a layout from a brief: the header, navigation and footer you have in mind. The job proposes a plan first, and builds once you confirm it: a header that links your pages, the slot each page renders in, and a footer. When your site already has a navigation or menu component, the layout places it rather than building another. The layout follows the building rules, so it carries no page title of its own and takes its colors and type from your theme.\nOpen Layouts for the site and choose Create with AI, beside Templates and Create New Layout. Write what the header, navigation and footer should hold, then choose Plan the layout. The window shows the plan when it is ready, with Confirm plan; Open AI jobs follows the job in the Assist panel instead.\nA link in the layout goes only to a page that does what its words say, and one on a colored band, such as a footer in your primary color, uses the band\'s text color. Where the brief leaves out a fact, such as your address or opening hours, the footer marks it in square brackets, and AI jobs lists those gaps beside the draft.\nThe layout arrives as a new draft in Layouts, and Open draft on the job opens it in the Besigner. No page uses it until you assign it, so nothing on the live site changes. It counts against your shared-layout allowance like a layout you create yourself, and when the site has none to spare the job says so before it starts, not after it has spent anything.'
3659
+ text: 'An AI build job can make a layout from a brief: the header, navigation and footer you have in mind. The job proposes a plan first, and builds once you confirm it: a header that links your pages, the slot each page renders in, and a footer. When your site already has a navigation or menu component, the layout places it rather than building another. The layout follows the building rules, so it carries no page title of its own and takes its colors and type from your theme.\nOpen Layouts for the site and choose Create with AI, beside Templates and Create New Layout. Write what the header, navigation and footer should hold, then choose Plan the layout. The window shows the plan when it is ready, with Confirm plan; Open AI jobs follows the job in the Assist panel instead.\nA link in the layout goes only to a page that does what its words say, and one on a colored band, such as a footer in your primary color, uses the band\'s text color. Where the brief leaves out a fact, such as your address or opening hours, the footer leaves it out. If the draft still shows one in square brackets, AI jobs lists it beside the draft.\nThe layout arrives as a new draft in Layouts, and Open draft on the job opens it in the Besigner. No page uses it until you assign it, so nothing on the live site changes. It counts against your shared-layout allowance like a layout you create yourself, and when the site has none to spare the job says so before it starts, not after it has spent anything.'
3660
3660
  },
3661
3661
  {
3662
3662
  path: '/building-sites/screens-and-layouts/layouts',
@@ -5168,7 +5168,7 @@ export const DOCS_SECTION_INDEX = [
5168
5168
  title: 'Fulfillment, returns and webhooks',
5169
5169
  heading: 'Order webhooks',
5170
5170
  anchor: '#order-webhooks',
5171
- text: 'An order webhook posts each order event to an address you choose, as it happens: a warehouse system, an ERP, a spreadsheet script. Add them under Notifications → Order webhooks in the Products hub. Managing webhooks needs the admin role and organization-wide membership, the same as refunds.\nAdd endpoint asks for:\n- Endpoint URL — an https:// address on a public server. Redirects are not followed, so give the final address.\n- Events — any of the events below.\nWhen you add an endpoint, its signing secret is shown once. Copy it into your endpoint\'s settings; Roll secret makes a new one if it is lost. A store can keep 10 endpoints. Pause stops deliveries to one without deleting it, and Send test event posts a webhook.test event now.\n| Event | When |\n| order.paid | An order was paid: online, buy-now, a payment link, a POS sale or a subscription renewal. |\n| order.fulfilled | A shipment was recorded, once per shipment. |\n| order.delivered | The order was marked delivered. |\n| order.refunded | Money went back to the buyer, once per refund, including a refund made in the Stripe Dashboard. |\n| order.cancelled | The order was canceled. |\n| return.requested | A buyer asked for a return, or you opened one. |\n| return.approved | You approved a return, or opened one yourself. |\n| return.declined | You declined a return request. |\n| return.received | The returned items arrived. |\n| return.refunded | A return was refunded. |\nWhen an endpoint is down\nAn endpoint has 8 seconds to answer each event. One that doesn\'t is sent the event again, on a schedule that lasts about a day, and the site\'s admins and editors are notified in the console if the last try fails.\nWhoever builds your endpoint needs Order webhooks for developers: what each event carries and how to check that it came from your'
5171
+ text: 'An order webhook posts each order event to an address you choose, as it happens: a warehouse system, an ERP, a spreadsheet script. Add them under Notifications → Order webhooks in the Products hub. Managing webhooks needs the Manage order webhooks permission. By default, as with refunds, that\'s an admin of the whole workspace who is also the site\'s admin.\nAdd endpoint asks for:\n- Endpoint URL — an https:// address on a public server. Redirects are not followed, so give the final address.\n- Events — any of the events below.\nWhen you add an endpoint, its signing secret is shown once. Copy it into your endpoint\'s settings; Roll secret makes a new one if it is lost. A store can keep 10 endpoints. Pause stops deliveries to one without deleting it, and Send test event posts a webhook.test event now.\n| Event | When |\n| order.paid | An order was paid: online, buy-now, a payment link, a POS sale or a subscription renewal. |\n| order.fulfilled | A shipment was recorded, once per shipment. |\n| order.delivered | The order was marked delivered. |\n| order.refunded | Money went back to the buyer, once per refund, including a refund made in the Stripe Dashboard. |\n| order.cancelled | The order was canceled. |\n| return.requested | A buyer asked for a return, or you opened one. |\n| return.approved | You approved a return, or opened one yourself. |\n| return.declined | You declined a return request. |\n| return.received | The returned items arrived. |\n| return.refunded | A return was refunded. |\nWhen an endpoint is down\nAn endpoint has 8 seconds to answer each event. One that doesn\'t is sent the event again, on a schedule that lasts about a day, and the site\'s admins and editors are notified in the console if the last try fails.\nWhoever builds your endpoint needs Order webhooks for developer'
5172
5172
  },
5173
5173
  {
5174
5174
  path: '/commerce-and-bookings/commerce/orders-and-returns',
@@ -5217,7 +5217,7 @@ export const DOCS_SECTION_INDEX = [
5217
5217
  title: 'Commerce',
5218
5218
  heading: 'Orders',
5219
5219
  anchor: '#orders',
5220
- text: 'Every paid checkout becomes an order with a sequential number, line-item snapshots, totals, and a timeline:\n- Statuses: pending → paid → fulfilled (or partially) → delivered, with cancel/refund exits guarded by a status machine. The seven of them, and the labels the console shows, are in Statuses and channels.\n- Fulfill with tracking, in one shipment or several, print packing slips and invoices, and add internal notes — see Fulfillment, returns and webhooks.\n- Ship with the tools you already use: ShipStation imports your orders and sends each shipment back, ShippingEasy receives each order as it is paid and sends each label back, and Pirate Ship, Shippo or EasyPost work through a spreadsheet out and a file of tracking numbers back.\n- Returns: buyers ask from their account or the order-status page, and you approve, receive with restock and refund — see Returns. Order webhooks send each order event to your own systems — see Order webhooks.\n- Refunds (full or partial) go through Stripe and reverse the platform fee. Refunding needs the admin role and organization-wide membership — a workspace owner or admin, or a member given access to every site. A collaborator invited to this one site is an admin of the site, which is enough to run the till and fulfill orders but not to send money back out of the business.\n- Chargebacks are shown apart from refunds. When a shopper disputes a charge with their bank, the order gets a Chargeback open badge and the list warns you with the date Stripe needs your evidence by — answer it in the Stripe dashboard, because an unanswered dispute is decided for the shopper. If the dispute is lost the money is reversed, the order reads Charged back rather than Refunded, and the buyer loses the downloads, membership and verified-purchase review a refun'
5220
+ text: 'Every paid checkout becomes an order with a sequential number, line-item snapshots, totals, and a timeline:\n- Statuses: pending → paid → fulfilled (or partially) → delivered, with cancel/refund exits guarded by a status machine. The seven of them, and the labels the console shows, are in Statuses and channels.\n- Fulfill with tracking, in one shipment or several, print packing slips and invoices, and add internal notes — see Fulfillment, returns and webhooks.\n- Ship with the tools you already use: ShipStation imports your orders and sends each shipment back, ShippingEasy receives each order as it is paid and sends each label back, and Pirate Ship, Shippo or EasyPost work through a spreadsheet out and a file of tracking numbers back.\n- Returns: buyers ask from their account or the order-status page, and you approve, receive with restock and refund — see Returns. Order webhooks send each order event to your own systems — see Order webhooks.\n- Refunds (full or partial) go through Stripe and reverse the platform fee. Refunding needs the Refund orders permission. By default, that\'s an admin of the site who is also a member of the whole workspace: a workspace owner or admin, or a member given access to every site. A collaborator invited to this one site as its admin can run the till and fulfill orders, but can\'t send money back out of the business unless the site grants them Refund orders.\n- Chargebacks are shown apart from refunds. When a shopper disputes a charge with their bank, the order gets a Chargeback open badge and the list warns you with the date Stripe needs your evidence by — answer it in the Stripe dashboard, because an unanswered dispute is decided for the shopper. If the dispute is lost the money is reversed, the order reads Charged back rather than Refunded, an'
5221
5221
  },
5222
5222
  {
5223
5223
  path: '/commerce-and-bookings/commerce/overview',
@@ -5511,7 +5511,7 @@ export const DOCS_SECTION_INDEX = [
5511
5511
  title: 'Running the register',
5512
5512
  heading: 'Staff PINs',
5513
5513
  anchor: '#staff-pins',
5514
- text: 'A shared tablet stays signed in; each person switches in with their own PIN instead of signing out.\n- Set your 4–6 digit PIN on the Staff PINs card on Products → Point of sale. A PIN that is one digit repeated or a straight run like 1234 is refused.\n- At the register, tap Switch cashier, pick your name and enter your PIN. Every sale, shift and return you ring is recorded under you until someone else switches in.\n- A PIN never grants more than your role: the register checks your role and your Manage POS permission each time it is used, so removing someone\'s access takes effect on their next tap.\n- Five wrong PINs in a row lock that person out for 15 minutes. A workspace admin can reset or remove anyone\'s PIN, which also lifts a lockout.\n- Lock the register after (minutes idle) in Admin → Plugins → Commerce locks the register after that long without a tap, so the next person has to enter their PIN. Tap Lock to lock it yourself.\nPINs are stored only as salted hashes and nobody can read them back, not even a workspace admin.'
5514
+ text: 'A shared tablet stays signed in; each person switches in with their own PIN instead of signing out.\n- Set your 4–6 digit PIN on the Staff PINs card on Products → Point of sale. A PIN that is one digit repeated or a straight run like 1234 is refused.\n- At the register, tap Switch cashier, pick your name and enter your PIN. Every sale, shift and return you ring is recorded under you until someone else switches in.\n- A PIN never grants more than your role: the register checks your role and your Manage POS permission each time it is used, so removing someone\'s access takes effect on their next tap.\n- Five wrong PINs in a row lock that person out for 15 minutes. Anyone with Approve refunds over the register\'s limit can reset or remove anyone\'s PIN, which also lifts a lockout.\n- Lock the register after (minutes idle) in Admin → Plugins → Commerce locks the register after that long without a tap, so the next person has to enter their PIN. Tap Lock to lock it yourself.\nPINs are stored only as salted hashes and nobody can read them back, not even a workspace admin.'
5515
5515
  },
5516
5516
  {
5517
5517
  path: '/commerce-and-bookings/commerce/pos-operations',
@@ -5525,7 +5525,7 @@ export const DOCS_SECTION_INDEX = [
5525
5525
  title: 'Running the register',
5526
5526
  heading: 'Returns and exchanges',
5527
5527
  anchor: '#returns-and-exchanges',
5528
- text: 'Tap Return on the register, then type the order number, scan the barcode at the foot of the receipt, or enter the customer\'s email.\n1. Pick the items coming back, how many of each, and the reason.\n2. Leave Put the items back in stock on to restock them at the register\'s location.\n3. Refund. The money goes back to how the customer paid:\n- Card — refunded through Stripe to the same card.\n- Cash — paid out of the drawer; the register tells you how much to hand over, and the open shift counts it.\n- Gift card — added back to the same gift card. A voided or frozen gift card cannot take a refund.\n- Room charge — taken back off the stay\'s folio.\nA sale paid more than one way is refunded to cards first, then gift cards and room charges, and to cash last. Turn on Split by hand to choose the amounts yourself; a payment can never take back more than it paid.\nEach unit\'s refund is its price less its share of any discount, plus its share of the tax, so returning items one at a time refunds exactly what returning them together would.\nEvery register return is also listed under Returns, beside the returns customers ask for online, already marked refunded. Items a customer has already asked to return online are held for that return: the register shows them as In an online return and will not refund them a second time.\nExchanges: after the return, tap Ring up the exchange to start a new sale for the same customer.\nRefund limits and manager approval\nCashier refund limit ($) in Admin → Plugins → Commerce is the largest return a cashier may refund without a manager. It is $0 by default, so every register refund needs a manager until you raise it. Above the limit the register asks a workspace admin to enter their PIN to approve that one refund. Workspace admins have no limit.'
5528
+ text: 'Tap Return on the register, then type the order number, scan the barcode at the foot of the receipt, or enter the customer\'s email.\n1. Pick the items coming back, how many of each, and the reason.\n2. Leave Put the items back in stock on to restock them at the register\'s location.\n3. Refund. The money goes back to how the customer paid:\n- Card — refunded through Stripe to the same card.\n- Cash — paid out of the drawer; the register tells you how much to hand over, and the open shift counts it.\n- Gift card — added back to the same gift card. A voided or frozen gift card cannot take a refund.\n- Room charge — taken back off the stay\'s folio.\nA sale paid more than one way is refunded to cards first, then gift cards and room charges, and to cash last. Turn on Split by hand to choose the amounts yourself; a payment can never take back more than it paid.\nEach unit\'s refund is its price less its share of any discount, plus its share of the tax, so returning items one at a time refunds exactly what returning them together would.\nEvery register return is also listed under Returns, beside the returns customers ask for online, already marked refunded. Items a customer has already asked to return online are held for that return: the register shows them as In an online return and will not refund them a second time.\nExchanges: after the return, tap Ring up the exchange to start a new sale for the same customer.\nRefund limits and manager approval\nCashier refund limit ($) in Admin → Plugins → Commerce is the largest return a cashier may refund without a manager. It is $0 by default, so every register refund needs a manager until you raise it. Above the limit the register asks someone with Approve refunds over the register\'s limit to enter their PIN to approve that one refund, and anyone '
5529
5529
  },
5530
5530
  {
5531
5531
  path: '/commerce-and-bookings/commerce/pos-operations',
@@ -11244,14 +11244,14 @@ export const DOCS_SECTION_INDEX = [
11244
11244
  title: 'Funnels',
11245
11245
  heading: 'Create a funnel',
11246
11246
  anchor: '#create',
11247
- text: '1. Open your site\'s Analytics page and find Funnels.\n2. Select New funnel, name it, and add steps with the pickers.\n3. Save. Saving needs a site admin or editor.\nCreate with AI {#create-with-ai}\nWhen Aglyn AI is available to your workspace, Create with AI turns a description — "people who read a blog post, then booked a consultation" — into a draft funnel. The draft uses only the pages, forms, services, products and bars or popups your site has; a step the model suggests that your site does not have is left out and listed. Nothing is saved until you review the draft and select Save. It uses AI credits like other generation.\nDraft funnels {#drafts}\nWhen Aglyn AI builds part of your site from one request, it can set a funnel up too. That funnel arrives as a draft, marked Draft on the Funnels card, and changes nothing on your live site: it does not turn recording on, it has no results, Insights does not read it, and it cannot be followed up on. Its steps use only pages your site already publishes, and forms, services, products or bars and popups your site has — including ones the same request made.\nSelect the draft to review its steps, edit it if you like (it stays a draft), then select Activate. Activating checks every step against your site again, makes the funnel live and turns recording on if it was off, so it counts visits from then on. Activating needs a site admin or editor. A draft counts towards the 20 funnels a site keeps.'
11247
+ text: '1. Open your site\'s Analytics page and find Funnels.\n2. Select New funnel, name it, and add steps with the pickers.\n3. Save. Saving needs the Manage funnels permission, which a site\'s admins and editors have.\nCreate with AI {#create-with-ai}\nWhen Aglyn AI is available to your workspace, Create with AI turns a description — "people who read a blog post, then booked a consultation" — into a draft funnel. The draft uses only the pages, forms, services, products and bars or popups your site has; a step the model suggests that your site does not have is left out and listed. Nothing is saved until you review the draft and select Save. It uses AI credits like other generation.\nDraft funnels {#drafts}\nWhen Aglyn AI builds part of your site from one request, it can set a funnel up too. That funnel arrives as a draft, marked Draft on the Funnels card, and changes nothing on your live site: it does not turn recording on, it has no results, Insights does not read it, and it cannot be followed up on. Its steps use only pages your site already publishes, and forms, services, products or bars and popups your site has — including ones the same request made.\nSelect the draft to review its steps, edit it if you like (it stays a draft), then select Activate. Activating checks every step against your site again, makes the funnel live and turns recording on if it was off, so it counts visits from then on. Activating needs Manage funnels. A draft counts towards the 20 funnels a site keeps.'
11248
11248
  },
11249
11249
  {
11250
11250
  path: '/marketing-and-automation/analytics/funnels',
11251
11251
  title: 'Funnels',
11252
11252
  heading: 'Act on a drop-off',
11253
11253
  anchor: '#act-on-drop-off',
11254
- text: 'Below each step after the first, Act on this drop-off drafts an automation for the people who reached the step before it and did not go on. Pick:\n- After — how long to wait for them to go on: 1 hour, 1 day, 3 days or 7 days.\n- Then — send them a follow-up email, or create a CRM task for your team.\nThe automation is saved switched off on your site\'s Automation page. Edit its words there, then switch it on. It starts on the trigger Left a funnel, with conditions for this funnel, this step and this wait, and it can use the funnel\'s name, the step\'s name, the next step\'s name and the person\'s email.\nWho it reaches:\n- Only people who submitted a form on your site. Anonymous visitors are never followed up.\n- Only once per person, for each funnel, step and wait, and only if they had not reached the next step in any of their recorded visits.\n- A follow-up email is a mailing, not a reply: it carries its unsubscribe link and is never sent to someone who unsubscribed or whose address is suppressed.\n- People who dropped off in the last wait period before you created the automation are included; earlier ones are not.\nAglyn checks for drop-offs every 15 minutes, so a follow-up can arrive up to a quarter of an hour after its wait. Drafting needs a site admin or editor and a plan that includes automations. Add an Email from the site step after the step you follow up on to see in the funnel how many people the follow-up brought back.'
11254
+ text: 'Below each step after the first, Act on this drop-off drafts an automation for the people who reached the step before it and did not go on. Pick:\n- After — how long to wait for them to go on: 1 hour, 1 day, 3 days or 7 days.\n- Then — send them a follow-up email, or create a CRM task for your team.\nThe automation is saved switched off on your site\'s Automation page. Edit its words there, then switch it on. It starts on the trigger Left a funnel, with conditions for this funnel, this step and this wait, and it can use the funnel\'s name, the step\'s name, the next step\'s name and the person\'s email.\nWho it reaches:\n- Only people who submitted a form on your site. Anonymous visitors are never followed up.\n- Only once per person, for each funnel, step and wait, and only if they had not reached the next step in any of their recorded visits.\n- A follow-up email is a mailing, not a reply: it carries its unsubscribe link and is never sent to someone who unsubscribed or whose address is suppressed.\n- People who dropped off in the last wait period before you created the automation are included; earlier ones are not.\nAglyn checks for drop-offs every 15 minutes, so a follow-up can arrive up to a quarter of an hour after its wait. Drafting needs Manage funnels and a plan that includes automations. Add an Email from the site step after the step you follow up on to see in the funnel how many people the follow-up brought back.'
11255
11255
  },
11256
11256
  {
11257
11257
  path: '/marketing-and-automation/analytics/funnels',
@@ -12014,14 +12014,14 @@ export const DOCS_SECTION_INDEX = [
12014
12014
  title: 'Org automations',
12015
12015
  heading: 'Create one',
12016
12016
  anchor: '#create-one',
12017
- text: '1. At the organization level, open Automation → Org automations and choose Add org automation.\n2. Name it and pick the trigger event. Optionally add a filter or conditions — the same ones the actions builder offers.\n3. Choose where it runs on: Every site — including sites you add later — or Chosen sites, up to 30 of them.\n4. Add the steps, then save.\nTo start from a description instead, choose Create with AI in the card\'s header: Aglyn AI drafts one and opens it in the editor, switched off, for you to place on sites and save. See Automations with AI.\nOnly an organization owner, admin or editor creates and edits org automations. An organization holds up to 100 of them; deleting one frees its place.\nTriggers\nOrg automations start on what the server records about a person: Form submitted, New lead, Contact created, Contact changed stage, New booking, Member signed up, Member signed in, Deal moved, Deal won, Deal lost and CRM task completed. What each event carries for filters and conditions is listed under the filter field as you pick it.\nPage views and on-page events (clicks, scroll depth, exit intent) are not offered: they belong to one site\'s pages. Build those as actions on the site.\nSteps\nSend an email, Notify site admins, Enroll in a list, Assign to a campaign, Write to a dataset, Update a dataset record, the five CRM steps, Fire a custom event, Wait, Wait for something to happen and End the flow here.\nWhat is left out belongs to one site, so an org automation cannot run it:\n- Run a workflow — a workflow calls its own site\'s functions and variables;\n- Send a webhook — a webhook holds its own site\'s address and secret;\n- every in-page step — popups, drawers, menus, show and hide, CSS classes, custom HTML and JavaScript, analytics events, redirects and site alerts.\nTo '
12017
+ text: '1. At the organization level, open Automation → Org automations and choose Add org automation.\n2. Name it and pick the trigger event. Optionally add a filter or conditions — the same ones the actions builder offers.\n3. Choose where it runs on: Every site — including sites you add later — or Chosen sites, up to 30 of them.\n4. Add the steps, then save.\nTo start from a description instead, choose Create with AI in the card\'s header: Aglyn AI drafts one and opens it in the editor, switched off, for you to place on sites and save. See Automations with AI.\nCreating and editing org automations takes the Manage org automations permission, which a workspace\'s owners, admins and editors have. An organization holds up to 100 of them; deleting one frees its place.\nTriggers\nOrg automations start on what the server records about a person: Form submitted, New lead, Contact created, Contact changed stage, New booking, Member signed up, Member signed in, Deal moved, Deal won, Deal lost and CRM task completed. What each event carries for filters and conditions is listed under the filter field as you pick it.\nPage views and on-page events (clicks, scroll depth, exit intent) are not offered: they belong to one site\'s pages. Build those as actions on the site.\nSteps\nSend an email, Notify site admins, Enroll in a list, Assign to a campaign, Write to a dataset, Update a dataset record, the five CRM steps, Fire a custom event, Wait, Wait for something to happen and End the flow here.\nWhat is left out belongs to one site, so an org automation cannot run it:\n- Run a workflow — a workflow calls its own site\'s functions and variables;\n- Send a webhook — a webhook holds its own site\'s address and secret;\n- every in-page step — popups, drawers, menus, show and hide, CSS classes, custom HTML and Java'
12018
12018
  },
12019
12019
  {
12020
12020
  path: '/marketing-and-automation/workflows-and-actions/org-automations',
12021
12021
  title: 'Org automations',
12022
12022
  heading: 'Pause it on one site',
12023
12023
  anchor: '#pause-it-on-one-site',
12024
- text: 'Each site\'s Automation → Actions section shows Org automations on this site: the ones placed on it, whether each runs here, and Pause here / Resume here. A site\'s admins and editors can pause an org automation on their own site — only their own; the organization\'s editors can pause it on any of its sites from the organization hub, with Pause on… and the Paused on chips.\nPausing stops it on that site, including for anyone waiting inside it there, and leaves every other site running it.'
12024
+ text: 'Each site\'s Automation → Actions section shows Org automations on this site: the ones placed on it, whether each runs here, and Pause here / Resume here. Anyone with Pause org automations on a site (a site\'s admins and editors, by default) can pause an org automation on that site — only there; anyone with Manage org automations can pause it on any of the organization\'s sites from the organization hub, with Pause on… and the Paused on chips.\nPausing stops it on that site, including for anyone waiting inside it there, and leaves every other site running it.'
12025
12025
  },
12026
12026
  {
12027
12027
  path: '/marketing-and-automation/workflows-and-actions/org-automations',