@mrfylke/contact-form
v0.1.31
Published
A configurable contact form package for public transportation organizations.
Readme
@mrfylke/contact-form
A configurable contact form package for public transportation organizations.
Installation
npm install @mrfylke/contact-formPeer dependencies
npm install react react-dom next @atb-as/theme @internationalized/date react-aria-componentsQuick start
1. Create your configuration
import { ContactFormConfig } from '@mrfylke/contact-form';
export const contactFormConfig: ContactFormConfig = {
theme: {
light: { /* colors, spacer, border */ },
dark: { /* same structure */ },
},
api: {
onSubmit,
},
};2. Wrap your app
import { ContactFormRoot } from '@mrfylke/contact-form';
import { contactFormConfig } from './config/contact-form-config';
export function RootLayout({ children }) {
const acceptLanguage = (await headers()).get('accept-language');
return (
<ContactFormRoot config={contactFormConfig} acceptLanguageHeader={acceptLanguage ?? undefined}>
{children}
</ContactFormRoot>
);
}3. Render form pages
import { RefundContent, ContactPageLayout } from '@mrfylke/contact-form';
export default function ContactPage() {
return (
<ContactPageLayout title="Contact Us">
<RefundContent />
</ContactPageLayout>
);
}API configuration
The package is decoupled from your backend. By default, it POSTs to
endpoints.submit (/api/contact/submit) with { ...state, $slug: slug }
in the body — no onSubmit needed:
api: {
endpoints: {
submit: '/api/contact/submit', // Optional, this is the default
},
}Pair that with createSubmitRouteHandler for the route itself — it reads
$slug from the body and forwards the rest to your connector at that path:
// app/api/contact/submit/route.ts
import { createSubmitRouteHandler } from '@mrfylke/contact-form/server';
export const POST = createSubmitRouteHandler({
request: (path, init) => fetch(`${process.env.CONNECTOR_URL}${path}`, init),
});Overriding the submit callback
For full control over routing, provide your own onSubmit callback instead —
it receives the form slug and form state:
import type { FormSlug } from '@mrfylke/contact-form';
const onSubmit = async (
slug: FormSlug,
state: Record<string, unknown>,
): Promise<Response> => {
return fetch('/api/contact/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...state, formType: slug }),
});
};See FormSlug for the full set of possible values.
Routing submissions to different endpoints
const onSubmit = async (slug: FormSlug, state) => {
const endpointMap: Record<FormSlug, string> = {
'ticket-control': '/api/contact/ticket-control',
refund: '/api/contact/refund',
'means-of-transport': '/api/contact/means-of-transport',
ticketing: '/api/contact/ticketing',
'journey-info': '/api/contact/journey-info',
'lost-property': '/api/contact/lost-property',
};
return fetch(endpointMap[slug], {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(state),
});
};Payload types
Import these in your API handlers to type-check incoming data:
import type {
TicketControlSubmitPayload,
RefundSubmitPayload,
MeansOfTransportSubmitPayload,
TicketingSubmitPayload,
JourneyInfoSubmitPayload,
} from '@mrfylke/contact-form';Attachments are base64-encoded strings within the payload.
Lines endpoint
The package includes a ready-made route handler for fetching transit lines from Entur.
Option 1 – ready-made handler (recommended)
// app/api/contact/lines/route.ts
import { createLinesRouteHandler } from '@mrfylke/contact-form/server';
export const GET = createLinesRouteHandler();
// Or with explicit authority:
// export const GET = createLinesRouteHandler({ getAuthorityId: () => 'ATB:Authority:2' });Set ENTUR_AUTHORITY_ID in your .env and point api.endpoints.lines at the mounted path.
Option 2 – call getLines() directly
import { getLines } from '@mrfylke/contact-form/server';
export async function GET() {
const lines = await getLines(); // reads ENTUR_AUTHORITY_ID from env
return Response.json(lines);
}Server exports: createLinesRouteHandler · getLines · getEnturAuthorityId · EnturAuthority
ContactFormRoot props
| Prop | Type | Description |
|---|---|---|
| config | ContactFormConfig | Required. Theme, icons, API, org, features. |
| acceptLanguageHeader | string | Raw Accept-Language header from the server. |
| initialLanguage | Language | Client-side initial language when no header is available. |
| forceTheme | 'light' \| 'dark' | Forces a theme variant; disables the dark-mode toggle. |
| translationsOverride | ContactFormTranslationsOverride | Merges additional translation overrides on top of config.translations. |
Container and layout
import { ContactFormRoot, ContactFormContainer, ContactPageLayout } from '@mrfylke/contact-form';
<ContactFormRoot config={contactFormConfig}>
<ContactFormContainer header={<MyHeader />} footer={<MyFooter />}>
<ContactPageLayout title="Contact" backLink={backLink}>
{content}
</ContactPageLayout>
</ContactFormContainer>
</ContactFormRoot>Header and footer are your app's responsibility. Their components can use useContactFormConfig(), useTheme(), and useTranslation() from the package.
Layout config
layout: {
basePath: '/contact',
backLinkDefault: { href: '/', label: PageText.Contact.contactPageLayout.homeLink },
successPath: '/contact/thanks', // default: basePath + '/success'
errorPath: '/contact/oops', // default: basePath + '/error'
successContent: <CustomThanksMessage />,
errorContent: <CustomErrorMessage />,
footerUrls: {
privacy: 'https://example.com/privacy',
accessibility: 'https://example.com/accessibility',
help: '/contact',
tickets: 'https://example.com/tickets',
homePage: { href: 'https://example.com', name: 'example.com' },
},
}Render SuccessContent/ErrorContent (or your overrides) without ContactPageLayout — they are standalone result pages.
Deep-linking
Form type selections are reflected in the URL as a ?type= query parameter. This allows direct links to specific form states.
Reading on load: on mount each form section reads ?type= and selects the matching radio if it is in the enabled set.
Writing on change: selecting a radio updates ?type= via replaceState (no page reload).
Bypassing agreement checkboxes: for sections with an initial agreement step (refund), append &agreed=true to pre-tick the checkbox and jump straight to the form.
Available values per section:
| Section | ?type= values |
|---|---|
| Means of transport | driver · transportation · delay · stop · serviceOffering · injury |
| Ticket control | feeComplaint · feedback · postponePayment |
| Refund | refundOfTicket · refundAndTravelGuarantee · residualValueOnTravelCard |
| Ticketing | priceAndTicketTypes · app · webshop · travelCard |
Examples:
/contact/refund?type=refundAndTravelGuarantee&agreed=true— opens travel guarantee form with agreement pre-accepted/contact/ticket-control?type=feeComplaint— opens fee complaint form directly
Only values present in formSchemaOverrides.enabledFormTypes / enabledFormCategories are accepted; invalid values are ignored.
Translations
Translations are required — the package ships no built-in copy. The key structure follows planner-web as source of truth.
import {
PageText,
ComponentText,
CommonText,
ServerText,
} from '@mrfylke/contact-form/translations';
const contactFormConfig: ContactFormConfig = {
translations: { pages: PageText, components: ComponentText, common: CommonText, server: ServerText },
};To override specific keys, merge with the defaults:
const customTranslations = {
pages: {
Contact: {
contactPageLayout: {
title: { no: 'Kontakt oss', en: 'Contact us', nn: 'Kontakt oss' },
},
},
},
components: ComponentText,
common: CommonText,
server: ServerText,
};Theme
Using @atb-as/theme (recommended)
import { createThemesFor, ThemeVariant } from '@atb-as/theme';
import { adaptAtbTheme } from '@mrfylke/contact-form/config';
const theme = adaptAtbTheme(createThemesFor(ThemeVariant.FRAM));Manual theme
type Theme = {
light: ThemeColors;
dark: ThemeColors;
};
type ThemeColors = {
color: {
transport: TransportColors;
status: StatusColors;
background: BackgroundColors;
interactive: { primary: InteractiveColor; secondary: InteractiveColor; destructive: InteractiveColor };
};
spacer: { small: string; medium: string; large: string; xLarge: string };
border: { radius: { small: string; regular: string; circle: string }; width: { slim: string; medium: string } };
};Use themeOverrides for small tweaks without rebuilding the full theme:
themeOverrides: { light: { spacer: { medium: '1.25rem' } } }Icon set
Provide a React component for each key. Each component accepts size, className, role, alt, and style props.
Transportation: transportation/BusFill · transportation/TrainFill · transportation/TramFill · transportation/WalkFill · transportation/BicycleFill · transportation/PlaneFill · transportation/BoatFill · transportation/FerryFill · transportation/MetroFill · transportation/UnknownFill
Actions: actions/Close · actions/Clear · actions/Add
Status: status/CheckmarkFill · status/ErrorFill
Overriding standard behavior
| Feature | Default | Config key |
|---|---|---|
| Pages/tabs | All 7 tabs, all internal | pagesOverrides |
| Ferry transport mode | Included | features.includeFerry |
| Form schemas | All types/categories per page | formSchemaOverrides |
| Header links | Single optional home link | layout.headerLinksOverride |
| Footer links | Standard sections | layout.footerLinksOverride |
| Translations | No defaults — must be provided | translations |
| Theme | Must be provided | theme + themeOverrides |
Pages
pagesOverrides: {
hidePageIds: ['lost-property'],
externalUrlByPageId: { refund: 'https://example.com/refund-form' },
}Page IDs: 'ticket-control' · 'refund' · 'means-of-transport' · 'ticketing' · 'lost-property' · 'group-travel' · 'journey-info'
Ferry flag
features: { includeFerry: false } // removes ferry from transport mode dropdowns and line queriesForm schema overrides
formSchemaOverrides: {
ticketControl: { enabledFormTypes: ['feeComplaint', 'feedback'] },
meansOfTransport: { enabledFormTypes: ['driver', 'delay', 'transportation'] },
refund: { enabledFormCategories: ['refundOfTicket', 'refundAndTravelGuarantee'] },
ticketing: { enabledFormCategories: ['app', 'webshop'] },
}Can also be loaded from a JSON/YAML file:
import schemaOverrides from './config/contact-form-schema.json';
// ...
formSchemaOverrides: schemaOverrides,Header links override
layout: {
headerLinksOverride: [
{ href: 'https://example.com', label: { no: 'Hjem', en: 'Home', nn: 'Heim' } },
],
}Empty array removes all header links.
Footer links override
layout: {
footerLinksOverride: [
{ href: 'https://example.com/tickets', label: { no: 'Billetter', en: 'Tickets', nn: 'Billettar' }, section: 'general' },
{ href: 'https://example.com/privacy', label: { no: 'Personvern', en: 'Privacy', nn: 'Personvern' }, section: 'bottom' },
],
}Links with section: 'bottom' replace the standard privacy/accessibility row.
Styling
import '@mrfylke/contact-form/lib/styles.css';Examples
See the apps/next-demo directory for a complete implementation, including commented examples of all override options in apps/next-demo/src/config/contact-form-config.tsx.
Troubleshooting
useContactFormConfig must be used within ContactFormRoot — wrap your app with <ContactFormRoot>.
Icons not showing — verify all required icon keys are provided and check the browser console for warnings.
Theme not applying — ensure <ContactFormRoot> wraps everything and your theme object matches the expected structure.
License
EUPL-1.2
