Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions .claude/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@
{
"name": "www",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["--filter", "www", "dev", "--port", "4699"],
"port": 4699,
"autoPort": false
"runtimeArgs": ["--filter", "www", "dev"],
"port": 5173
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
.container {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
gap: var(--ds-size-8);
align-items: start;
max-width: 100%;
margin-block: var(--ds-size-8);

@media (max-width: 900px) {
grid-template-columns: minmax(0, 1fr);
}
}

.form {
display: flex;
flex-direction: column;
gap: var(--ds-size-6);
}

.preview {
display: flex;
flex-direction: column;
gap: var(--ds-size-4);
padding: var(--ds-size-6);
border: 1px solid var(--ds-color-neutral-border-subtle);
border-radius: var(--ds-border-radius-lg);
background-color: var(--ds-color-neutral-background-tinted);

@media (min-width: 901px) {
position: sticky;
top: calc(var(--header-height) + var(--ds-size-4));
max-height: calc(100dvh - var(--header-height) - var(--ds-size-8));
}
}

.signature {
padding: var(--ds-size-5);
background-color: #fff;
border-radius: var(--ds-border-radius-md);
overflow: auto;
min-height: 0;
}

.actions {
display: flex;
flex-direction: column;
gap: var(--ds-size-2);
align-items: start;
}

.srOnly {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import {
Button,
Checkbox,
Fieldset,
Heading,
Paragraph,
Radio,
Textfield,
ValidationMessage,
} from '@digdir/designsystemet-react';
import { CheckmarkIcon, FilesIcon } from '@navikt/aksel-icons';
import { useEffect, useId, useMemo, useState } from 'react';
import classes from './email-signatur-generator.module.css';
import {
getLanguages,
getOffice,
type LanguageCode,
LOGO_PATH,
languages,
type OfficeId,
offices,
} from './signature-config';
import {
buildSignatureHtml,
buildSignatureText,
type SignatureData,
} from './signature-template';

type CopyState = 'idle' | 'copied' | 'error';

const copySignature = async (html: string, text: string) => {
if (typeof ClipboardItem !== 'undefined' && navigator.clipboard?.write) {
try {
await navigator.clipboard.write([
new ClipboardItem({
'text/html': new Blob([html], { type: 'text/html' }),
'text/plain': new Blob([text], { type: 'text/plain' }),
}),
]);
return;
} catch {}
}

const holder = document.createElement('div');
holder.setAttribute('contenteditable', 'true');
holder.innerHTML = html;
holder.style.position = 'fixed';
holder.style.left = '-9999px';
document.body.appendChild(holder);

try {
const range = document.createRange();
range.selectNodeContents(holder);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);

if (!document.execCommand('copy')) {
throw new Error('execCommand("copy") was rejected');
}
selection?.removeAllRanges();
} finally {
holder.remove();
}
};

export const EmailSignatureGenerator = () => {
const previewId = useId();

const [name, setName] = useState('');
const [role, setRole] = useState('');
const [phone, setPhone] = useState('');
const [office, setOffice] = useState<OfficeId>('leikanger');
const [selectedLanguages, setSelectedLanguages] = useState<LanguageCode[]>([
'nb',
]);
const [copyState, setCopyState] = useState<CopyState>('idle');
const [mounted, setMounted] = useState(false);

useEffect(() => setMounted(true), []);

const noLanguage = selectedLanguages.length === 0;

const input: SignatureData = useMemo(
() => ({
name,
role,
phone,
office: getOffice(office),
languages: getLanguages(selectedLanguages),
}),
[name, role, phone, office, selectedLanguages],
);

const previewHtml = useMemo(
() => buildSignatureHtml({ ...input, logoSrc: LOGO_PATH }),
[input],
);

const toggleLanguage = (code: LanguageCode, checked: boolean) => {
setSelectedLanguages((current) =>
checked
? [...current, code]
: current.filter((language) => language !== code),
);
};

const onCopy = async () => {
const html = buildSignatureHtml({
...input,
logoSrc: new URL(LOGO_PATH, window.location.origin).href,
});

try {
await copySignature(html, buildSignatureText(input));
setCopyState('copied');
} catch {
setCopyState('error');
}
};

useEffect(() => {
if (copyState !== 'copied') return;
const timer = window.setTimeout(() => setCopyState('idle'), 2500);
return () => window.clearTimeout(timer);
}, [copyState]);

if (!mounted) {
return <div className={classes.container} aria-hidden='true' />;
}

return (
<div className={classes.container}>
<form
className={classes.form}
onSubmit={(event) => event.preventDefault()}
>
<Textfield
label='Navn'
value={name}
autoComplete='name'
onChange={(event) => setName(event.target.value)}
/>
<Textfield
label='Rolle'
value={role}
autoComplete='organization-title'
onChange={(event) => setRole(event.target.value)}
/>
<Textfield
label='Telefonnummer'
type='tel'
value={phone}
autoComplete='tel'
onChange={(event) => setPhone(event.target.value)}
/>

<Fieldset>
<Fieldset.Legend>Kontorsted</Fieldset.Legend>
{offices.map((item) => (
<Radio
key={item.id}
name='office'
label={item.label}
description={item.address}
value={item.id}
checked={office === item.id}
onChange={() => setOffice(item.id)}
/>
))}
</Fieldset>

<Fieldset>
<Fieldset.Legend>Språk</Fieldset.Legend>
<Fieldset.Description>
Velg ett eller flere. Signaturen får én bolk per språk.
</Fieldset.Description>
{languages.map((language) => (
<Checkbox
key={language.code}
name='language'
label={language.label}
value={language.code}
checked={selectedLanguages.includes(language.code)}
onChange={(event) =>
toggleLanguage(language.code, event.target.checked)
}
/>
))}
{noLanguage && (
<ValidationMessage>Velg minst ett språk.</ValidationMessage>
)}
</Fieldset>
</form>

<section
className={classes.preview}
aria-labelledby={`${previewId}-heading`}
>
<Heading level={2} data-size='2xs' id={`${previewId}-heading`}>
Forhåndsvisning
</Heading>

<div
className={classes.signature}
// The markup is built by `signature-template.ts` from escaped input –
// rendering it here is what keeps preview and clipboard identical.
// biome-ignore lint/security/noDangerouslySetInnerHtml: see above
dangerouslySetInnerHTML={{ __html: previewHtml }}
/>

<div className={classes.actions}>
<Button type='button' onClick={onCopy} disabled={noLanguage}>
{copyState === 'copied' ? (
<CheckmarkIcon aria-hidden />
) : (
<FilesIcon aria-hidden />
)}
{copyState === 'copied' ? 'Kopiert!' : 'Kopier signatur'}
</Button>
{copyState === 'error' && (
<ValidationMessage>
Kopieringen feilet. Marker signaturen over og kopier den manuelt.
</ValidationMessage>
)}
</div>
<Paragraph data-size='sm' aria-live='polite' className={classes.srOnly}>
{copyState === 'copied' ? 'Signaturen er kopiert.' : ''}
</Paragraph>
</section>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
export type LanguageCode = 'nb' | 'nn' | 'en';
export type OfficeId = 'leikanger' | 'oslo' | 'bronnoysund';

export type Office = {
id: OfficeId;
/** Label shown in the radio group. */
label: string;
/** Visiting address, printed verbatim in the signature. */
address: string;
};

export type Language = {
code: LanguageCode;
/** Label shown in the checkbox group. */
label: string;
/** `lang` attribute on the language block. */
htmlLang: string;
greeting: string;
phoneLabel: string;
};

// TODO: bekreft besøksadressene før siden publiseres.
export const offices: Office[] = [
{
id: 'leikanger',
label: 'Leikanger',
address: 'Askedalen 4, 6863 Leikanger, NO',
},
{
id: 'oslo',
label: 'Oslo',
address: 'Lørenfaret 1 C, 0585 Oslo, NO',
},
{
id: 'bronnoysund',
label: 'Brønnøysund',
address: 'Havnegata 48, 8900 Brønnøysund, NO',
},
];

export const languages: Language[] = [
{
code: 'nb',
label: 'Bokmål',
htmlLang: 'nb-NO',
greeting: 'Vennlig hilsen',
phoneLabel: 'Mob',
},
{
code: 'nn',
label: 'Nynorsk',
htmlLang: 'nn-NO',
greeting: 'Venleg helsing',
phoneLabel: 'Mob',
},
{
code: 'en',
label: 'Engelsk',
htmlLang: 'en',
greeting: 'Kind regards',
phoneLabel: 'Phone',
},
];

export const getOffice = (id: OfficeId): Office =>
offices.find((office) => office.id === id) ?? offices[0];

/** Languages in the order they are declared above, not the order they were ticked. */
export const getLanguages = (codes: LanguageCode[]): Language[] =>
languages.filter((language) => codes.includes(language.code));

/** Printed once, under the logo – the same for every office and language. */
export const POSTAL_ADDRESS = 'Postboks 1382 Vika, 0114 Oslo, NO';

export const WEBSITE = { label: 'digdir.no', href: 'https://www.digdir.no' };

/** Path to the logo used in the signature, relative to the site root. */
export const LOGO_PATH = '/images/digdir-epost.png';
export const LOGO_ALT = 'Digdir';
export const LOGO_WIDTH = 130;
Loading
Loading