Przeglądaj źródła

A better-looking default email (#371)

* Give every mail a card, a brand bar, a receipt and a quiet footer

* Let the theme take the rule under the letterhead away

* Colour bar switch on the theme, rule switch on the header block
Bernt Christian Egeland 3 tygodni temu
rodzic
commit
cc698675b0

+ 4 - 0
messages/de/settings.json

@@ -2149,6 +2149,8 @@
         "description": "Der letzte Tag, an dem der Einladungslink funktioniert."
         "description": "Der letzte Tag, an dem der Einladungslink funktioniert."
       }
       }
     },
     },
+    "headerRule": "Linie unter der Kopfzeile",
+    "headerRuleHint": "Eine dünne Linie zwischen Logo oder Werkstattname und dem Rest der E-Mail.",
     "optionalTag": "Nicht jede E-Mail hat einen Wert für dieses Tag. Bleibt es leer, werden die Worte darum herum bereinigt.",
     "optionalTag": "Nicht jede E-Mail hat einen Wert für dieses Tag. Bleibt es leer, werden die Worte darum herum bereinigt.",
     "summaryRows": {
     "summaryRows": {
       "reference": "Referenz",
       "reference": "Referenz",
@@ -2243,6 +2245,8 @@
       },
       },
       "shapes": "Formen",
       "shapes": "Formen",
       "buttonRadius": "Schaltflächenecken",
       "buttonRadius": "Schaltflächenecken",
+      "topBar": "Farbbalken oben",
+      "topBarHint": "Ein schmales Band in Ihrer Primärfarbe entlang der Oberkante der E-Mail.",
       "showLogo": "Logo anzeigen",
       "showLogo": "Logo anzeigen",
       "showLogoHint": "Ist das Logo aus, druckt der Kopfbereich stattdessen den Werkstattnamen."
       "showLogoHint": "Ist das Logo aus, druckt der Kopfbereich stattdessen den Werkstattnamen."
     },
     },

+ 4 - 0
messages/en/settings.json

@@ -2149,6 +2149,8 @@
         "description": "The last day the invitation link works."
         "description": "The last day the invitation link works."
       }
       }
     },
     },
+    "headerRule": "Rule under the header",
+    "headerRuleHint": "A thin line between the logo or workshop name and the rest of the email.",
     "optionalTag": "Not every email has a value for this tag. When it is blank, the words around it are tidied up.",
     "optionalTag": "Not every email has a value for this tag. When it is blank, the words around it are tidied up.",
     "summaryRows": {
     "summaryRows": {
       "reference": "Reference",
       "reference": "Reference",
@@ -2243,6 +2245,8 @@
       },
       },
       "shapes": "Shapes",
       "shapes": "Shapes",
       "buttonRadius": "Button corners",
       "buttonRadius": "Button corners",
+      "topBar": "Colour bar at the top",
+      "topBarHint": "A thin band in your primary colour along the top edge of the email.",
       "showLogo": "Show logo",
       "showLogo": "Show logo",
       "showLogoHint": "With the logo off, the header prints the workshop name instead."
       "showLogoHint": "With the logo off, the header prints the workshop name instead."
     },
     },

+ 4 - 0
messages/es/settings.json

@@ -2149,6 +2149,8 @@
         "description": "El último día en que funciona el enlace de invitación."
         "description": "El último día en que funciona el enlace de invitación."
       }
       }
     },
     },
+    "headerRule": "Línea bajo el encabezado",
+    "headerRuleHint": "Una línea fina entre el logotipo o el nombre del taller y el resto del correo.",
     "optionalTag": "No todos los correos tienen un valor para esta etiqueta. Cuando está vacía, las palabras que la rodean se ordenan.",
     "optionalTag": "No todos los correos tienen un valor para esta etiqueta. Cuando está vacía, las palabras que la rodean se ordenan.",
     "summaryRows": {
     "summaryRows": {
       "reference": "Referencia",
       "reference": "Referencia",
@@ -2243,6 +2245,8 @@
       },
       },
       "shapes": "Formas",
       "shapes": "Formas",
       "buttonRadius": "Esquinas del botón",
       "buttonRadius": "Esquinas del botón",
+      "topBar": "Barra de color arriba",
+      "topBarHint": "Una franja fina en tu color principal a lo largo del borde superior del correo.",
       "showLogo": "Mostrar logotipo",
       "showLogo": "Mostrar logotipo",
       "showLogoHint": "Con el logotipo desactivado, la cabecera imprime el nombre del taller en su lugar."
       "showLogoHint": "Con el logotipo desactivado, la cabecera imprime el nombre del taller en su lugar."
     },
     },

+ 4 - 0
messages/fr/settings.json

@@ -2149,6 +2149,8 @@
         "description": "Le dernier jour où le lien d'invitation fonctionne."
         "description": "Le dernier jour où le lien d'invitation fonctionne."
       }
       }
     },
     },
+    "headerRule": "Filet sous l'en-tête",
+    "headerRuleHint": "Un trait fin entre le logo ou le nom de l'atelier et le reste de l'e-mail.",
     "optionalTag": "Tous les e-mails n'ont pas de valeur pour cette balise. Quand elle est vide, les mots autour sont remis en ordre.",
     "optionalTag": "Tous les e-mails n'ont pas de valeur pour cette balise. Quand elle est vide, les mots autour sont remis en ordre.",
     "summaryRows": {
     "summaryRows": {
       "reference": "Référence",
       "reference": "Référence",
@@ -2243,6 +2245,8 @@
       },
       },
       "shapes": "Formes",
       "shapes": "Formes",
       "buttonRadius": "Angles du bouton",
       "buttonRadius": "Angles du bouton",
+      "topBar": "Barre de couleur en haut",
+      "topBarHint": "Une fine bande dans votre couleur principale le long du bord supérieur de l'e-mail.",
       "showLogo": "Afficher le logo",
       "showLogo": "Afficher le logo",
       "showLogoHint": "Logo désactivé, l'en-tête imprime le nom de l'atelier à la place."
       "showLogoHint": "Logo désactivé, l'en-tête imprime le nom de l'atelier à la place."
     },
     },

+ 4 - 0
messages/it/settings.json

@@ -2149,6 +2149,8 @@
         "description": "L'ultimo giorno in cui il link di invito funziona."
         "description": "L'ultimo giorno in cui il link di invito funziona."
       }
       }
     },
     },
+    "headerRule": "Linea sotto l'intestazione",
+    "headerRuleHint": "Una linea sottile tra il logo o il nome dell'officina e il resto dell'email.",
     "optionalTag": "Non tutte le email hanno un valore per questo tag. Quando è vuoto, le parole intorno vengono sistemate.",
     "optionalTag": "Non tutte le email hanno un valore per questo tag. Quando è vuoto, le parole intorno vengono sistemate.",
     "summaryRows": {
     "summaryRows": {
       "reference": "Riferimento",
       "reference": "Riferimento",
@@ -2243,6 +2245,8 @@
       },
       },
       "shapes": "Forme",
       "shapes": "Forme",
       "buttonRadius": "Angoli del pulsante",
       "buttonRadius": "Angoli del pulsante",
+      "topBar": "Barra colorata in alto",
+      "topBarHint": "Una sottile fascia nel tuo colore principale lungo il bordo superiore dell'email.",
       "showLogo": "Mostra logo",
       "showLogo": "Mostra logo",
       "showLogoHint": "Con il logo disattivato, la testata stampa invece il nome dell'officina."
       "showLogoHint": "Con il logo disattivato, la testata stampa invece il nome dell'officina."
     },
     },

+ 4 - 0
messages/lt/settings.json

@@ -2149,6 +2149,8 @@
         "description": "Paskutinė diena, kai kvietimo nuoroda veikia."
         "description": "Paskutinė diena, kai kvietimo nuoroda veikia."
       }
       }
     },
     },
+    "headerRule": "Linija po antrašte",
+    "headerRuleHint": "Plona linija tarp logotipo ar dirbtuvių pavadinimo ir likusio laiško.",
     "optionalTag": "Ne kiekvienas laiškas turi šios žymės reikšmę. Kai ji tuščia, aplinkiniai žodžiai sutvarkomi.",
     "optionalTag": "Ne kiekvienas laiškas turi šios žymės reikšmę. Kai ji tuščia, aplinkiniai žodžiai sutvarkomi.",
     "summaryRows": {
     "summaryRows": {
       "reference": "Nuoroda",
       "reference": "Nuoroda",
@@ -2243,6 +2245,8 @@
       },
       },
       "shapes": "Formos",
       "shapes": "Formos",
       "buttonRadius": "Mygtuko kampai",
       "buttonRadius": "Mygtuko kampai",
+      "topBar": "Spalvota juosta viršuje",
+      "topBarHint": "Plona juosta jūsų pagrindine spalva išilgai viršutinio laiško krašto.",
       "showLogo": "Rodyti logotipą",
       "showLogo": "Rodyti logotipą",
       "showLogoHint": "Išjungus logotipą, antraštinėje dalyje vietoj jo spausdinamas dirbtuvių pavadinimas."
       "showLogoHint": "Išjungus logotipą, antraštinėje dalyje vietoj jo spausdinamas dirbtuvių pavadinimas."
     },
     },

+ 4 - 0
messages/nb/settings.json

@@ -2149,6 +2149,8 @@
         "description": "Siste dag invitasjonslenken virker."
         "description": "Siste dag invitasjonslenken virker."
       }
       }
     },
     },
+    "headerRule": "Strek under toppen",
+    "headerRuleHint": "En tynn strek mellom logoen eller verkstedsnavnet og resten av e-posten.",
     "optionalTag": "Ikke alle e-poster har en verdi for denne taggen. Når den er tom, ryddes ordene rundt den opp.",
     "optionalTag": "Ikke alle e-poster har en verdi for denne taggen. Når den er tom, ryddes ordene rundt den opp.",
     "summaryRows": {
     "summaryRows": {
       "reference": "Referanse",
       "reference": "Referanse",
@@ -2243,6 +2245,8 @@
       },
       },
       "shapes": "Former",
       "shapes": "Former",
       "buttonRadius": "Knappehjørner",
       "buttonRadius": "Knappehjørner",
+      "topBar": "Fargestripe øverst",
+      "topBarHint": "Et smalt bånd i primærfargen din langs toppen av e-posten.",
       "showLogo": "Vis logo",
       "showLogo": "Vis logo",
       "showLogoHint": "Med logoen av skriver toppteksten ut verkstedsnavnet i stedet."
       "showLogoHint": "Med logoen av skriver toppteksten ut verkstedsnavnet i stedet."
     },
     },

+ 4 - 0
messages/nl/settings.json

@@ -2149,6 +2149,8 @@
         "description": "De laatste dag waarop de uitnodigingslink werkt."
         "description": "De laatste dag waarop de uitnodigingslink werkt."
       }
       }
     },
     },
+    "headerRule": "Lijn onder de kop",
+    "headerRuleHint": "Een dunne lijn tussen het logo of de werkplaatsnaam en de rest van de e-mail.",
     "optionalTag": "Niet elke e-mail heeft een waarde voor deze tag. Als hij leeg is, worden de woorden eromheen opgeruimd.",
     "optionalTag": "Niet elke e-mail heeft een waarde voor deze tag. Als hij leeg is, worden de woorden eromheen opgeruimd.",
     "summaryRows": {
     "summaryRows": {
       "reference": "Referentie",
       "reference": "Referentie",
@@ -2243,6 +2245,8 @@
       },
       },
       "shapes": "Vormen",
       "shapes": "Vormen",
       "buttonRadius": "Knophoeken",
       "buttonRadius": "Knophoeken",
+      "topBar": "Kleurbalk bovenaan",
+      "topBarHint": "Een smalle band in uw primaire kleur langs de bovenrand van de e-mail.",
       "showLogo": "Logo tonen",
       "showLogo": "Logo tonen",
       "showLogoHint": "Met het logo uit drukt de koptekst in plaats daarvan de naam van de werkplaats af."
       "showLogoHint": "Met het logo uit drukt de koptekst in plaats daarvan de naam van de werkplaats af."
     },
     },

+ 4 - 0
messages/pl/settings.json

@@ -2149,6 +2149,8 @@
         "description": "Ostatni dzień działania linku zaproszenia."
         "description": "Ostatni dzień działania linku zaproszenia."
       }
       }
     },
     },
+    "headerRule": "Linia pod nagłówkiem",
+    "headerRuleHint": "Cienka linia między logo lub nazwą warsztatu a resztą wiadomości.",
     "optionalTag": "Nie każdy e-mail ma wartość dla tego tagu. Gdy jest pusty, słowa wokół niego są porządkowane.",
     "optionalTag": "Nie każdy e-mail ma wartość dla tego tagu. Gdy jest pusty, słowa wokół niego są porządkowane.",
     "summaryRows": {
     "summaryRows": {
       "reference": "Numer",
       "reference": "Numer",
@@ -2243,6 +2245,8 @@
       },
       },
       "shapes": "Kształty",
       "shapes": "Kształty",
       "buttonRadius": "Rogi przycisku",
       "buttonRadius": "Rogi przycisku",
+      "topBar": "Kolorowy pasek u góry",
+      "topBarHint": "Wąski pasek w Twoim kolorze głównym wzdłuż górnej krawędzi wiadomości.",
       "showLogo": "Pokaż logo",
       "showLogo": "Pokaż logo",
       "showLogoHint": "Przy wyłączonym logo nagłówek drukuje zamiast niego nazwę warsztatu."
       "showLogoHint": "Przy wyłączonym logo nagłówek drukuje zamiast niego nazwę warsztatu."
     },
     },

+ 4 - 0
messages/pt-BR/settings.json

@@ -2149,6 +2149,8 @@
         "description": "O último dia em que o link do convite funciona."
         "description": "O último dia em que o link do convite funciona."
       }
       }
     },
     },
+    "headerRule": "Linha sob o cabeçalho",
+    "headerRuleHint": "Uma linha fina entre o logotipo ou o nome da oficina e o resto do e-mail.",
     "optionalTag": "Nem todo e-mail tem um valor para esta tag. Quando ela fica vazia, as palavras ao redor são ajustadas.",
     "optionalTag": "Nem todo e-mail tem um valor para esta tag. Quando ela fica vazia, as palavras ao redor são ajustadas.",
     "summaryRows": {
     "summaryRows": {
       "reference": "Referência",
       "reference": "Referência",
@@ -2243,6 +2245,8 @@
       },
       },
       "shapes": "Formas",
       "shapes": "Formas",
       "buttonRadius": "Cantos do botão",
       "buttonRadius": "Cantos do botão",
+      "topBar": "Barra colorida no topo",
+      "topBarHint": "Uma faixa fina na sua cor principal ao longo da borda superior do e-mail.",
       "showLogo": "Mostrar logotipo",
       "showLogo": "Mostrar logotipo",
       "showLogoHint": "Com o logotipo desativado, o cabeçalho imprime o nome da oficina no lugar."
       "showLogoHint": "Com o logotipo desativado, o cabeçalho imprime o nome da oficina no lugar."
     },
     },

+ 4 - 0
messages/ru/settings.json

@@ -2149,6 +2149,8 @@
         "description": "Последний день, когда работает ссылка приглашения."
         "description": "Последний день, когда работает ссылка приглашения."
       }
       }
     },
     },
+    "headerRule": "Линия под шапкой",
+    "headerRuleHint": "Тонкая линия между логотипом или названием мастерской и остальным письмом.",
     "optionalTag": "Не у каждого письма есть значение для этого тега. Когда он пуст, слова вокруг него приводятся в порядок.",
     "optionalTag": "Не у каждого письма есть значение для этого тега. Когда он пуст, слова вокруг него приводятся в порядок.",
     "summaryRows": {
     "summaryRows": {
       "reference": "Номер",
       "reference": "Номер",
@@ -2243,6 +2245,8 @@
       },
       },
       "shapes": "Формы",
       "shapes": "Формы",
       "buttonRadius": "Углы кнопки",
       "buttonRadius": "Углы кнопки",
+      "topBar": "Цветная полоса сверху",
+      "topBarHint": "Тонкая полоса в вашем основном цвете вдоль верхнего края письма.",
       "showLogo": "Показывать логотип",
       "showLogo": "Показывать логотип",
       "showLogoHint": "При выключенном логотипе шапка печатает вместо него название мастерской."
       "showLogoHint": "При выключенном логотипе шапка печатает вместо него название мастерской."
     },
     },

+ 4 - 0
messages/tr/settings.json

@@ -2149,6 +2149,8 @@
         "description": "Davet bağlantısının çalıştığı son gün."
         "description": "Davet bağlantısının çalıştığı son gün."
       }
       }
     },
     },
+    "headerRule": "Başlık altında çizgi",
+    "headerRuleHint": "Logo veya atölye adı ile e-postanın geri kalanı arasında ince bir çizgi.",
     "optionalTag": "Her e-postada bu etiket için bir değer yoktur. Boş olduğunda çevresindeki sözcükler düzenlenir.",
     "optionalTag": "Her e-postada bu etiket için bir değer yoktur. Boş olduğunda çevresindeki sözcükler düzenlenir.",
     "summaryRows": {
     "summaryRows": {
       "reference": "Referans",
       "reference": "Referans",
@@ -2243,6 +2245,8 @@
       },
       },
       "shapes": "Şekiller",
       "shapes": "Şekiller",
       "buttonRadius": "Düğme köşeleri",
       "buttonRadius": "Düğme köşeleri",
+      "topBar": "Üstte renkli şerit",
+      "topBarHint": "E-postanın üst kenarı boyunca ana renginizde ince bir şerit.",
       "showLogo": "Logoyu göster",
       "showLogo": "Logoyu göster",
       "showLogoHint": "Logo kapalıyken üst bilgi bunun yerine atölye adını yazdırır."
       "showLogoHint": "Logo kapalıyken üst bilgi bunun yerine atölye adını yazdırır."
     },
     },

+ 37 - 3
src/__tests__/features/email/email-template-engine.test.ts

@@ -239,12 +239,44 @@ describe('renderEmailHtml', () => {
     expect(out).toContain('#d97706')
     expect(out).toContain('#d97706')
   })
   })
 
 
+  it('draws the rule under the letterhead unless the header block says not to', () => {
+    const ruled = 'border-bottom:1px solid #e6e8ec;"><span'
+    expect(renderEmailHtml(spec)).toContain(ruled)
+    const template = preset('invoice_sent')
+    const bare = renderEmailHtml(
+      buildEmailSpec(
+        {
+          ...template,
+          blocks: template.blocks.map((b) => (b.type === 'header' ? { ...b, rule: false } : b)),
+        },
+        invoiceInput
+      )
+    )
+    expect(bare).not.toContain(ruled)
+    expect(bare).toContain('Bergen Bil')
+  })
+
+  it('draws the colour bar along the top unless the theme says not to', () => {
+    expect(renderEmailHtml(spec)).toContain(
+      'height:5px;line-height:5px;font-size:0;border-radius:10px 10px 0 0;'
+    )
+    const bare = renderEmailHtml({ ...spec, theme: { ...spec.theme, topBar: false } })
+    expect(bare).not.toContain('height:5px;line-height:5px')
+    expect(bare).toContain('border-radius:10px;padding:32px 36px 24px 36px;')
+  })
+
   it('marks every row with its block only when asked, for the designer', () => {
   it('marks every row with its block only when asked, for the designer', () => {
     const sent = renderEmailHtml(spec)
     const sent = renderEmailHtml(spec)
     const marked = renderEmailHtml(spec, { marked: true })
     const marked = renderEmailHtml(spec, { marked: true })
     expect(sent).not.toContain('data-block')
     expect(sent).not.toContain('data-block')
     expect(marked).toContain('<style')
     expect(marked).toContain('<style')
-    for (const block of spec.blocks) expect(marked).toContain(`data-block="${block.id}"`)
+    // The rule before a closing footer folds into the card's edge, so it is
+    // the one block that has no row of its own.
+    const closing = spec.blocks[spec.blocks.length - 1]?.type === 'contact_footer'
+    const drawn = spec.blocks.filter(
+      (block, i) => !(closing && i === spec.blocks.length - 2 && block.type === 'divider')
+    )
+    for (const block of drawn) expect(marked).toContain(`data-block="${block.id}"`)
   })
   })
 
 
   it('keeps a typed line break as a break', () => {
   it('keeps a typed line break as a break', () => {
@@ -486,8 +518,10 @@ describe('email logo', () => {
     const html = renderEmailHtml(
     const html = renderEmailHtml(
       buildEmailSpec(template, { ...invoiceInput, logoUrl: 'https://x/l.png' })
       buildEmailSpec(template, { ...invoiceInput, logoUrl: 'https://x/l.png' })
     )
     )
-    expect(html).toContain('<td align="center" style="padding:0 0 20px 0;text-align:center;">')
-    expect(html).toContain('<td align="right" style="padding:6px 0 20px 0;text-align:right;">')
+    expect(html).toContain(
+      '<td align="center" style="padding:0 0 20px 0;text-align:center;border-bottom:1px solid #e6e8ec;">'
+    )
+    expect(html).toContain('<td align="right" style="padding:6px 0 24px 0;text-align:right;">')
   })
   })
 
 
   it('draws the logo at the width the theme asks for', () => {
   it('draws the logo at the width the theme asks for', () => {

+ 16 - 0
src/features/email/Components/EmailDesignerInspector.tsx

@@ -289,6 +289,14 @@ function BlockFields({
               value={block.align ?? 'left'}
               value={block.align ?? 'left'}
               onChange={(align) => onBlock(block.id, { align })}
               onChange={(align) => onBlock(block.id, { align })}
             />
             />
+            <Row label={t('headerRule')}>
+              <Toggle
+                label={t('headerRule')}
+                on={block.rule !== false}
+                onChange={(rule) => onBlock(block.id, { rule })}
+              />
+            </Row>
+            <Note>{t('headerRuleHint')}</Note>
           </Group>
           </Group>
           <Note>{t('blockNotes.header')}</Note>
           <Note>{t('blockNotes.header')}</Note>
         </>
         </>
@@ -462,6 +470,14 @@ function ThemeFields({
           suffix="px"
           suffix="px"
           onChange={(buttonRadius) => onTheme({ buttonRadius })}
           onChange={(buttonRadius) => onTheme({ buttonRadius })}
         />
         />
+        <Row label={t('theme.topBar')}>
+          <Toggle
+            label={t('theme.topBar')}
+            on={theme.topBar !== false}
+            onChange={(topBar) => onTheme({ topBar })}
+          />
+        </Row>
+        <Note>{t('theme.topBarHint')}</Note>
       </Group>
       </Group>
     </>
     </>
   )
   )

+ 34 - 1
src/features/email/Lib/buildEmailSpec.ts

@@ -28,7 +28,14 @@ export interface SpecText {
 }
 }
 
 
 type SpecBlockBody =
 type SpecBlockBody =
-  | { type: 'header'; workshopName: string; logoUrl?: string; logoWidth: number; align: BlockAlign }
+  | {
+      type: 'header'
+      workshopName: string
+      logoUrl?: string
+      logoWidth: number
+      align: BlockAlign
+      rule: boolean
+    }
   | ({ type: 'heading' } & SpecText)
   | ({ type: 'heading' } & SpecText)
   | ({ type: 'paragraph' } & SpecText)
   | ({ type: 'paragraph' } & SpecText)
   | ({ type: 'callout' } & SpecText)
   | ({ type: 'callout' } & SpecText)
@@ -47,6 +54,12 @@ export interface EmailSpec {
   subject: string
   subject: string
   theme: EmailTheme
   theme: EmailTheme
   blocks: SpecBlock[]
   blocks: SpecBlock[]
+  /**
+   * The line an inbox shows after the subject: the first words of the mail,
+   * so the list says "Your invoice is ready" rather than the workshop's name
+   * a second time.
+   */
+  preheader?: string
 }
 }
 
 
 export interface EmailSpecInput {
 export interface EmailSpecInput {
@@ -97,6 +110,7 @@ function resolveBlock(
         logoUrl: template.theme.showLogo ? input.logoUrl : undefined,
         logoUrl: template.theme.showLogo ? input.logoUrl : undefined,
         logoWidth: template.theme.logoWidth,
         logoWidth: template.theme.logoWidth,
         align: block.align ?? 'left',
         align: block.align ?? 'left',
+        rule: block.rule !== false,
       }
       }
 
 
     case 'heading': {
     case 'heading': {
@@ -189,7 +203,26 @@ export function buildEmailSpec(template: EmailTemplate, input: EmailSpecInput):
     subject: fillTags(template.subject, input.values, kindSpec(template.kind).tags).trim(),
     subject: fillTags(template.subject, input.values, kindSpec(template.kind).tags).trim(),
     theme: template.theme,
     theme: template.theme,
     blocks: collapseSpacing(blocks),
     blocks: collapseSpacing(blocks),
+    preheader: preheaderOf(blocks),
+  }
+}
+
+const PREHEADER_LENGTH = 140
+
+/**
+ * The first paragraph or callout, on one line and cut to what an inbox
+ * shows. The heading is skipped: it usually repeats the subject.
+ */
+function preheaderOf(blocks: SpecBlock[]): string | undefined {
+  for (const block of blocks) {
+    if (block.type !== 'paragraph' && block.type !== 'callout') continue
+    const line = block.text.replace(/\s+/g, ' ').trim()
+    if (!line) continue
+    return line.length > PREHEADER_LENGTH
+      ? `${line.slice(0, PREHEADER_LENGTH - 1).trimEnd()}…`
+      : line
   }
   }
+  return undefined
 }
 }
 
 
 /**
 /**

+ 8 - 0
src/features/email/Lib/emailTemplate.ts

@@ -63,6 +63,11 @@ export interface EmailBlock {
   height?: number
   height?: number
   /** document_summary: which rows to show. Absent shows every row there is. */
   /** document_summary: which rows to show. Absent shows every row there is. */
   rows?: SummaryRowKey[]
   rows?: SummaryRowKey[]
+  /**
+   * header: the rule under the letterhead. Absent counts as on, so a
+   * template saved before the rule existed keeps the look every mail has.
+   */
+  rule?: boolean
 }
 }
 
 
 /**
 /**
@@ -101,6 +106,8 @@ export interface EmailTheme {
   logoWidth: number
   logoWidth: number
   /** Rounded corners on the button; Outlook squares them off regardless. */
   /** Rounded corners on the button; Outlook squares them off regardless. */
   buttonRadius: number
   buttonRadius: number
+  /** The bar in the primary colour along the top of the card. Absent counts as on. */
+  topBar?: boolean
 }
 }
 
 
 export const DEFAULT_EMAIL_THEME: EmailTheme = {
 export const DEFAULT_EMAIL_THEME: EmailTheme = {
@@ -114,6 +121,7 @@ export const DEFAULT_EMAIL_THEME: EmailTheme = {
   logoUrl: '',
   logoUrl: '',
   logoWidth: 140,
   logoWidth: 140,
   buttonRadius: 6,
   buttonRadius: 6,
+  topBar: true,
 }
 }
 
 
 export const EMAIL_LOGO_MIN_WIDTH = 60
 export const EMAIL_LOGO_MIN_WIDTH = 60

+ 118 - 30
src/features/email/Render/renderEmailHtml.ts

@@ -21,6 +21,11 @@ export { escapeHtml }
  * rule written on the element it applies to, a 600px column, no external
  * rule written on the element it applies to, a 600px column, no external
  * stylesheet, no web font, no flexbox, no grid, no position.
  * stylesheet, no web font, no flexbox, no grid, no position.
  *
  *
+ * The shape is a card: a bar in the workshop's colour, a white sheet with
+ * the words on it, and the workshop's contact lines quietly underneath. The
+ * card is the renderer's, not a block, so every template a workshop makes
+ * sits on the same paper and only decides what is written on it.
+ *
  * The workshop never types HTML, so nothing here has to survive a paste from
  * The workshop never types HTML, so nothing here has to survive a paste from
  * Word. Every string that came from a person is escaped on the way in, and
  * Word. Every string that came from a person is escaped on the way in, and
  * the theme is checked again here even though the schema already did: this
  * the theme is checked again here even though the schema already did: this
@@ -29,6 +34,9 @@ export { escapeHtml }
 
 
 const WIDTH = 600
 const WIDTH = 600
 
 
+/** The hairline everything inside the card is ruled with. */
+const RULE = '#e6e8ec'
+
 const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i
 const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i
 
 
 /** A colour that is a colour, or the default; never anything that could close an attribute. */
 /** A colour that is a colour, or the default; never anything that could close an attribute. */
@@ -50,6 +58,7 @@ function safeTheme(theme: EmailTheme) {
     panel: color(theme.panelColor, d.panelColor),
     panel: color(theme.panelColor, d.panelColor),
     font: EMAIL_FONTS[theme.fontFamily] ?? EMAIL_FONTS[d.fontFamily],
     font: EMAIL_FONTS[theme.fontFamily] ?? EMAIL_FONTS[d.fontFamily],
     radius,
     radius,
+    topBar: theme.topBar !== false,
   }
   }
 }
 }
 
 
@@ -74,6 +83,9 @@ function wordsHtml(
   return richToHtml(words.rich, { font: t.font, linkColor: t.primary, ...look })
   return richToHtml(words.rich, { font: t.font, linkColor: t.primary, ...look })
 }
 }
 
 
+/** The summary rows that carry money, drawn heavier than the rest. */
+const EMPHASISED_ROWS = new Set(['total', 'balance'])
+
 function blockHtml(block: SpecBlock, t: SafeTheme, marked: boolean): string {
 function blockHtml(block: SpecBlock, t: SafeTheme, marked: boolean): string {
   const base = `font-family:${t.font};`
   const base = `font-family:${t.font};`
   const row = (cell: string) => tableRow(cell, marked ? block.id : undefined)
   const row = (cell: string) => tableRow(cell, marked ? block.id : undefined)
@@ -87,31 +99,37 @@ function blockHtml(block: SpecBlock, t: SafeTheme, marked: boolean): string {
       const logo = block.logoUrl
       const logo = block.logoUrl
         ? `<img src="${escapeHtml(block.logoUrl)}" alt="${escapeHtml(block.workshopName)}" width="${width}" style="display:inline-block;border:0;max-width:${width}px;height:auto;vertical-align:middle;" />`
         ? `<img src="${escapeHtml(block.logoUrl)}" alt="${escapeHtml(block.workshopName)}" width="${width}" style="display:inline-block;border:0;max-width:${width}px;height:auto;vertical-align:middle;" />`
         : block.workshopName
         : block.workshopName
-          ? `<span style="${base}font-size:20px;font-weight:700;color:${t.text};">${escapeHtml(block.workshopName)}</span>`
+          ? `<span style="${base}font-size:19px;font-weight:700;letter-spacing:-0.2px;color:${t.text};">${escapeHtml(block.workshopName)}</span>`
           : ''
           : ''
       if (!logo) return ''
       if (!logo) return ''
       // The td's align attribute is what Outlook reads; the style is for
       // The td's align attribute is what Outlook reads; the style is for
       // everyone else. The image is inline so the alignment applies to it.
       // everyone else. The image is inline so the alignment applies to it.
-      return row(
-        `<td align="${block.align}" style="padding:0 0 20px 0;text-align:${block.align};">${logo}</td>`
+      // A rule under the letterhead, and room after it, so the mark and the
+      // heading are not two bold lines fighting over the top of the card.
+      // The block can take the rule away; the room stays.
+      const rule = block.rule ? `border-bottom:1px solid ${RULE};` : ''
+      return (
+        row(
+          `<td align="${block.align}" style="padding:0 0 20px 0;text-align:${block.align};${rule}">${logo}</td>`
+        ) + `<tr><td style="height:24px;line-height:24px;font-size:0;">&nbsp;</td></tr>`
       )
       )
     }
     }
 
 
     case 'heading':
     case 'heading':
       return row(
       return row(
-        `<td style="${base}font-size:20px;font-weight:700;color:${t.text};padding:0 0 12px 0;">${wordsHtml(block, t, { color: t.text, fontSize: 20, lineHeight: 1.3 })}</td>`
+        `<td style="${base}font-size:22px;line-height:1.3;font-weight:700;letter-spacing:-0.2px;color:${t.text};padding:0 0 14px 0;">${wordsHtml(block, t, { color: t.text, fontSize: 22, lineHeight: 1.3 })}</td>`
       )
       )
 
 
     case 'paragraph':
     case 'paragraph':
       return row(
       return row(
-        `<td style="${base}font-size:15px;line-height:1.5;color:${t.text};padding:0 0 14px 0;">${wordsHtml(block, t, { color: t.text, fontSize: 15, lineHeight: 1.5 })}</td>`
+        `<td style="${base}font-size:15px;line-height:1.6;color:${t.text};padding:0 0 16px 0;">${wordsHtml(block, t, { color: t.text, fontSize: 15, lineHeight: 1.6 })}</td>`
       )
       )
 
 
     case 'callout':
     case 'callout':
       return row(
       return row(
-        `<td style="padding:0 0 18px 0;">` +
-          `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:${t.panel};border-left:4px solid ${t.primary};border-radius:4px;">` +
-          `<tr><td style="${base}font-size:15px;line-height:1.5;color:${t.text};padding:14px 16px;">${wordsHtml(block, t, { color: t.text, fontSize: 15, lineHeight: 1.5 })}</td></tr>` +
+        `<td style="padding:2px 0 20px 0;">` +
+          `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" bgcolor="${t.background}" style="background:${t.background};border-left:4px solid ${t.primary};border-radius:6px;">` +
+          `<tr><td style="${base}font-size:15px;line-height:1.6;color:${t.text};padding:16px 18px;">${wordsHtml(block, t, { color: t.text, fontSize: 15, lineHeight: 1.6 })}</td></tr>` +
           `</table></td>`
           `</table></td>`
       )
       )
 
 
@@ -121,51 +139,62 @@ function blockHtml(block: SpecBlock, t: SafeTheme, marked: boolean): string {
       // A table around the anchor, because Outlook ignores padding on one.
       // A table around the anchor, because Outlook ignores padding on one.
       const side = block.align === 'center' ? 'center' : block.align === 'right' ? 'right' : 'left'
       const side = block.align === 'center' ? 'center' : block.align === 'right' ? 'right' : 'left'
       return row(
       return row(
-        `<td align="${side}" style="padding:6px 0 20px 0;text-align:${side};">` +
+        `<td align="${side}" style="padding:6px 0 24px 0;text-align:${side};">` +
           `<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="${side}" style="display:inline-table;"><tr>` +
           `<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="${side}" style="display:inline-table;"><tr>` +
           `<td align="center" bgcolor="${t.primary}" style="border-radius:${t.radius}px;">` +
           `<td align="center" bgcolor="${t.primary}" style="border-radius:${t.radius}px;">` +
-          `<a href="${escapeHtml(href)}" style="${base}display:inline-block;padding:12px 22px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:${t.radius}px;">${escapeHtml(block.label)}</a>` +
+          `<a href="${escapeHtml(href)}" style="${base}display:inline-block;padding:14px 28px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:${t.radius}px;">${escapeHtml(block.label)}</a>` +
           `</td></tr></table></td>`
           `</td></tr></table></td>`
       )
       )
     }
     }
 
 
     case 'document_summary': {
     case 'document_summary': {
+      // A receipt: labels down the left, figures on the right, a rule
+      // between lines, and the money set heavier so it is what the eye
+      // lands on. The balance is in the workshop's colour, because it is
+      // the one number the customer has to act on.
+      const last = block.rows.length - 1
       const rows = block.rows
       const rows = block.rows
-        .map(
-          (line) =>
+        .map((line, i) => {
+          const money = EMPHASISED_ROWS.has(line.key)
+          const rule = i < last ? `border-bottom:1px solid ${RULE};` : ''
+          const valueColor = line.key === 'balance' ? t.primary : t.text
+          const valueSize = money ? 16 : 14
+          const weight = money ? 700 : 600
+          return (
             `<tr>` +
             `<tr>` +
-            `<td style="${base}font-size:13px;color:${t.muted};padding:4px 16px 4px 0;white-space:nowrap;">${escapeHtml(line.label)}</td>` +
-            `<td style="${base}font-size:14px;font-weight:600;color:${t.text};padding:4px 0;">${escapeHtml(line.value)}</td>` +
+            `<td style="${base}font-size:13px;color:${t.muted};padding:10px 16px 10px 0;white-space:nowrap;${rule}">${escapeHtml(line.label)}</td>` +
+            `<td align="right" style="${base}font-size:${valueSize}px;font-weight:${weight};color:${valueColor};padding:10px 0;text-align:right;${rule}">${escapeHtml(line.value)}</td>` +
             `</tr>`
             `</tr>`
-        )
+          )
+        })
         .join('')
         .join('')
       return row(
       return row(
-        `<td style="padding:0 0 18px 0;">` +
-          `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:${t.panel};border:1px solid #e5e7eb;border-radius:6px;">` +
-          `<tr><td style="padding:14px 16px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0">${rows}</table></td></tr>` +
+        `<td style="padding:2px 0 22px 0;">` +
+          `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" bgcolor="${t.background}" style="background:${t.background};border-radius:8px;">` +
+          `<tr><td style="padding:6px 20px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">${rows}</table></td></tr>` +
           `</table></td>`
           `</table></td>`
       )
       )
     }
     }
 
 
     case 'attachment_note':
     case 'attachment_note':
       return row(
       return row(
-        `<td style="${base}font-size:13px;line-height:1.5;color:${t.muted};padding:0 0 14px 0;">${wordsHtml(block, t, { color: t.muted, fontSize: 13, lineHeight: 1.5 })}</td>`
+        `<td style="${base}font-size:13px;line-height:1.5;color:${t.muted};padding:0 0 16px 0;">${wordsHtml(block, t, { color: t.muted, fontSize: 13, lineHeight: 1.5 })}</td>`
       )
       )
 
 
     case 'image': {
     case 'image': {
-      const img = `<img src="${escapeHtml(block.src)}" alt="${escapeHtml(block.alt)}" width="${block.width}" style="display:inline-block;border:0;max-width:100%;width:${block.width}px;height:auto;vertical-align:middle;" />`
+      const img = `<img src="${escapeHtml(block.src)}" alt="${escapeHtml(block.alt)}" width="${block.width}" style="display:inline-block;border:0;max-width:100%;width:${block.width}px;height:auto;vertical-align:middle;border-radius:6px;" />`
       const href = block.href ? safeHref(block.href) : null
       const href = block.href ? safeHref(block.href) : null
       const inner = href
       const inner = href
         ? `<a href="${escapeHtml(href)}" style="text-decoration:none;">${img}</a>`
         ? `<a href="${escapeHtml(href)}" style="text-decoration:none;">${img}</a>`
         : img
         : img
       return row(
       return row(
-        `<td align="${block.align}" style="padding:0 0 18px 0;text-align:${block.align};">${inner}</td>`
+        `<td align="${block.align}" style="padding:0 0 20px 0;text-align:${block.align};">${inner}</td>`
       )
       )
     }
     }
 
 
     case 'divider':
     case 'divider':
       return row(
       return row(
-        `<td style="padding:4px 0 18px 0;"><div style="height:1px;line-height:1px;font-size:0;background:#e5e7eb;">&nbsp;</div></td>`
+        `<td style="padding:6px 0 22px 0;"><div style="height:1px;line-height:1px;font-size:0;background:${RULE};">&nbsp;</div></td>`
       )
       )
 
 
     case 'spacer':
     case 'spacer':
@@ -174,17 +203,31 @@ function blockHtml(block: SpecBlock, t: SafeTheme, marked: boolean): string {
       )
       )
 
 
     case 'contact_footer':
     case 'contact_footer':
-      return row(
-        `<td style="${base}font-size:13px;line-height:1.5;color:${t.muted};padding:8px 0 0 0;">` +
-          block.lines.map((line) => paragraphHtml(line)).join('<br />') +
-          `</td>`
-      )
+      return row(contactFooterCell(block.lines, t, 'left', '8px 0 0 0'))
 
 
     default:
     default:
       return ''
       return ''
   }
   }
 }
 }
 
 
+/**
+ * The workshop's contact lines. The name is set a shade darker than the
+ * rest so the footer still says who wrote, without shouting.
+ */
+function contactFooterCell(
+  lines: string[],
+  t: SafeTheme,
+  align: 'left' | 'center',
+  padding: string
+): string {
+  const [name, ...rest] = lines
+  const nameHtml = name
+    ? `<span style="font-weight:600;color:${t.text};">${paragraphHtml(name)}</span>`
+    : ''
+  const body = [nameHtml, ...rest.map((line) => paragraphHtml(line))].filter(Boolean).join('<br />')
+  return `<td align="${align}" style="font-family:${t.font};font-size:12.5px;line-height:1.6;color:${t.muted};padding:${padding};text-align:${align};">${body}</td>`
+}
+
 /** A row of the mail; in a preview it also says which block it is. */
 /** A row of the mail; in a preview it also says which block it is. */
 function tableRow(cell: string, blockId?: string): string {
 function tableRow(cell: string, blockId?: string): string {
   const mark = blockId ? ` data-block="${escapeHtml(blockId)}"` : ''
   const mark = blockId ? ` data-block="${escapeHtml(blockId)}"` : ''
@@ -204,6 +247,18 @@ const PREVIEW_STYLE =
   '[data-block].is-selected>td{outline-color:#2563eb}' +
   '[data-block].is-selected>td{outline-color:#2563eb}' +
   '</style>'
   '</style>'
 
 
+/**
+ * The line an inbox shows after the subject. Hidden in the mail itself;
+ * without it the client shows whatever text comes first, which is the
+ * workshop's name again.
+ */
+function preheaderHtml(text: string | undefined): string {
+  if (!text) return ''
+  // The filler keeps the client from pulling the body text in after it.
+  const filler = '&zwnj;&nbsp;'.repeat(40)
+  return `<div style="display:none;max-height:0;overflow:hidden;font-size:1px;line-height:1px;color:transparent;opacity:0;mso-hide:all;">${escapeHtml(text)}${filler}</div>`
+}
+
 export interface RenderHtmlOptions {
 export interface RenderHtmlOptions {
   /**
   /**
    * Mark every row with the id of the block it came from, for the designer's
    * Mark every row with the id of the block it came from, for the designer's
@@ -215,17 +270,50 @@ export interface RenderHtmlOptions {
 export function renderEmailHtml(spec: EmailSpec, options: RenderHtmlOptions = {}): string {
 export function renderEmailHtml(spec: EmailSpec, options: RenderHtmlOptions = {}): string {
   const marked = options.marked === true
   const marked = options.marked === true
   const t = safeTheme(spec.theme)
   const t = safeTheme(spec.theme)
-  const body = spec.blocks.map((block) => blockHtml(block, t, marked)).join('')
+
+  // The contact lines go under the card when they close the mail, which is
+  // where a signature belongs; placed anywhere else they stay a block among
+  // blocks. A rule left standing at the card's foot when the footer moves
+  // out would underline nothing, so it goes with it.
+  let inside = spec.blocks
+  let footer: Extract<SpecBlock, { type: 'contact_footer' }> | null = null
+  const lastBlock = inside[inside.length - 1]
+  if (lastBlock?.type === 'contact_footer') {
+    footer = lastBlock
+    inside = inside.slice(0, -1)
+    while (inside.length && ['divider', 'spacer'].includes(inside[inside.length - 1].type)) {
+      inside = inside.slice(0, -1)
+    }
+  }
+
+  const body = inside.map((block) => blockHtml(block, t, marked)).join('')
+  const footerRow = footer
+    ? tableRow(
+        contactFooterCell(footer.lines, t, 'center', '22px 12px 0 12px'),
+        marked ? footer.id : undefined
+      )
+    : ''
 
 
   return (
   return (
     `<!doctype html><html><head><meta charset="utf-8" />` +
     `<!doctype html><html><head><meta charset="utf-8" />` +
     `<meta name="viewport" content="width=device-width,initial-scale=1" />` +
     `<meta name="viewport" content="width=device-width,initial-scale=1" />` +
     `<title>${escapeHtml(spec.subject)}</title>${marked ? PREVIEW_STYLE : ''}</head>` +
     `<title>${escapeHtml(spec.subject)}</title>${marked ? PREVIEW_STYLE : ''}</head>` +
     `<body style="margin:0;padding:0;background:${t.background};">` +
     `<body style="margin:0;padding:0;background:${t.background};">` +
-    `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:${t.background};">` +
-    `<tr><td align="center" style="padding:24px 12px;">` +
+    preheaderHtml(spec.preheader) +
+    `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" bgcolor="${t.background}" style="background:${t.background};">` +
+    `<tr><td align="center" style="padding:32px 16px 40px 16px;">` +
     `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="${WIDTH}" style="width:100%;max-width:${WIDTH}px;">` +
     `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="${WIDTH}" style="width:100%;max-width:${WIDTH}px;">` +
+    // The bar: the workshop's colour, five pixels of it, along the top of
+    // the card. Enough to be the brand at a glance, not enough to compete
+    // with a logo underneath. Without it the card is edged all the way round.
+    (t.topBar
+      ? `<tr><td bgcolor="${t.primary}" style="background:${t.primary};height:5px;line-height:5px;font-size:0;border-radius:10px 10px 0 0;">&nbsp;</td></tr>` +
+        `<tr><td bgcolor="${t.panel}" style="background:${t.panel};border:1px solid ${RULE};border-top:0;border-radius:0 0 10px 10px;padding:32px 36px 24px 36px;">`
+      : `<tr><td bgcolor="${t.panel}" style="background:${t.panel};border:1px solid ${RULE};border-radius:10px;padding:32px 36px 24px 36px;">`) +
+    `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">` +
     body +
     body +
+    `</table></td></tr>` +
+    footerRow +
     `</table></td></tr></table></body></html>`
     `</table></td></tr></table></body></html>`
   )
   )
 }
 }

+ 2 - 0
src/features/email/Schema/emailTemplateSchema.ts

@@ -50,6 +50,7 @@ export const emailBlockSchema = z.object({
   width: z.number().int().min(EMAIL_IMAGE_MIN_WIDTH).max(EMAIL_IMAGE_MAX_WIDTH).optional(),
   width: z.number().int().min(EMAIL_IMAGE_MIN_WIDTH).max(EMAIL_IMAGE_MAX_WIDTH).optional(),
   height: z.number().int().min(0).max(120).optional(),
   height: z.number().int().min(0).max(120).optional(),
   rows: z.array(z.enum(SUMMARY_ROWS)).max(SUMMARY_ROWS.length).optional(),
   rows: z.array(z.enum(SUMMARY_ROWS)).max(SUMMARY_ROWS.length).optional(),
+  rule: z.boolean().optional(),
 })
 })
 
 
 export const emailThemeSchema = z.object({
 export const emailThemeSchema = z.object({
@@ -66,6 +67,7 @@ export const emailThemeSchema = z.object({
     .refine((value) => value === '' || isEmailLogoUrl(value), 'Not an email logo'),
     .refine((value) => value === '' || isEmailLogoUrl(value), 'Not an email logo'),
   logoWidth: z.number().int().min(EMAIL_LOGO_MIN_WIDTH).max(EMAIL_LOGO_MAX_WIDTH),
   logoWidth: z.number().int().min(EMAIL_LOGO_MIN_WIDTH).max(EMAIL_LOGO_MAX_WIDTH),
   buttonRadius: z.number().int().min(0).max(32),
   buttonRadius: z.number().int().min(0).max(32),
+  topBar: z.boolean().optional(),
 })
 })
 
 
 export const emailKindSchema = z.enum(EMAIL_KINDS)
 export const emailKindSchema = z.enum(EMAIL_KINDS)