From 8a4dbc2c6ec11f65e8f06ac18ae1eaa71f74e1c4 Mon Sep 17 00:00:00 2001 From: Jonas Braathen Date: Sun, 6 Sep 2026 03:26:25 +0200 Subject: [PATCH] Restyle to better match brand style guidelines and neuf.no --- assets/key-pig.svg | 47 ++++++++++ src/Navigation.tsx | 14 ++- src/components/BulletList.tsx | 44 ++++++++++ src/components/Button.tsx | 77 ++++++++++++----- src/components/FormErrors.tsx | 2 +- src/components/FormInput.tsx | 15 +--- src/components/KeyPig.tsx | 31 +++++++ src/components/ListRow.tsx | 18 ++-- src/components/ListSection.tsx | 17 ++-- src/components/PrivacyButton.tsx | 6 +- src/components/RadioButton.tsx | 73 ++++++++++++---- src/components/TextField.tsx | 60 ++++++++++--- src/components/Toast.tsx | 6 +- src/modules/about/About.tsx | 46 +++++----- src/modules/events/EventList.test.tsx | 10 +-- src/modules/events/EventList.tsx | 45 +++------- src/modules/login/Login.test.tsx | 3 +- src/modules/login/Login.tsx | 99 ++++++++++++---------- src/modules/membership/Membership.test.tsx | 15 +++- src/modules/membership/Membership.tsx | 10 +-- src/modules/membership/MembershipInfo.tsx | 51 ++++++----- src/modules/membership/Proof.test.tsx | 18 ++-- src/modules/membership/Proof.tsx | 49 ++++++----- src/modules/membership/UserDelete.tsx | 58 ++++--------- src/modules/membership/useProof.tsx | 12 +-- src/modules/register/UserRegister.tsx | 19 ++--- src/modules/settings/NotificationRow.tsx | 6 +- src/modules/settings/Settings.tsx | 10 +-- src/theme.ts | 63 +++++++------- 29 files changed, 561 insertions(+), 363 deletions(-) create mode 100644 assets/key-pig.svg create mode 100644 src/components/BulletList.tsx create mode 100644 src/components/KeyPig.tsx diff --git a/assets/key-pig.svg b/assets/key-pig.svg new file mode 100644 index 0000000..a75c298 --- /dev/null +++ b/assets/key-pig.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/Navigation.tsx b/src/Navigation.tsx index cd0c6ab..63db071 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -1,5 +1,5 @@ import { BottomTabNavigationOptions, createBottomTabNavigator } from '@react-navigation/bottom-tabs'; -import { NavigationContainer } from '@react-navigation/native'; +import { DefaultTheme, NavigationContainer } from '@react-navigation/native'; import { createNativeStackNavigator, NativeStackNavigationOptions } from '@react-navigation/native-stack'; import React from 'react'; @@ -22,10 +22,10 @@ import IconPerson from '@assets/icons/person.svg'; // TODO: Change this // also TODO: make sure battery indicator etc. is visible const defaultNavigationStyle = { - headerTintColor: theme.colors.neufWhite, + headerTintColor: theme.colors.white, headerTitleAlign: 'center', headerStyle: { - backgroundColor: theme.colors.neufBlue, + backgroundColor: theme.colors.chateauBlue, }, } as const; @@ -42,6 +42,12 @@ const EventStackScreen = () => ( ); +// Scene background for screens that don't paint their own +const navigationTheme = { + ...DefaultTheme, + colors: { ...DefaultTheme.colors, background: theme.background.backgroundColor }, +}; + type HeaderRightType = NativeStackNavigationOptions['headerRight']; const SettingsHeaderRight: HeaderRightType = ({ tintColor }) => ; @@ -84,7 +90,7 @@ const EventIcon: TabBarIconType = ({ color, size }) => ; const AboutIcon: TabBarIconType = ({ color, size }) => ; const Navigation = () => ( - + ( + + {items.map((item) => ( + + + {item} + + ))} + +); + +// Metrics from the web's rich text lists at a 16px body size +const styles = StyleSheet.create({ + item: { + flexDirection: 'row', + alignItems: 'flex-start', + paddingLeft: 6, + marginBottom: 12, + }, + bullet: { + width: 12, + height: 12, + borderRadius: 6, + backgroundColor: theme.colors.goldenOrange, + marginTop: 6, + marginRight: 14, + }, + text: { + ...theme.typography.body, + flex: 1, + }, +}); + +export default BulletList; diff --git a/src/components/Button.tsx b/src/components/Button.tsx index fc39d81..69235cb 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -1,57 +1,90 @@ import React from 'react'; -import { Pressable, PressableProps, StyleProp, StyleSheet, ViewStyle } from 'react-native'; +import { Pressable, PressableProps, StyleProp, StyleSheet, Text, TextStyle, ViewStyle } from 'react-native'; import theme from '@/theme'; +export type ButtonVariant = keyof typeof theme.buttons; + export type ButtonProps = Omit & { + /** primary is the orange call to action, secondary the beige one, as on neuf.no */ + variant?: ButtonVariant; style?: StyleProp; + /** Applies when children is a string */ + textStyle?: StyleProp; + icon?: React.ReactNode; fullWidth?: boolean; small?: boolean; children?: React.ReactNode; }; -const Button = ({ style, fullWidth, small, disabled, children, ...rest }: ButtonProps) => ( - [ - styles.base, - small && styles.small, - fullWidth && styles.fullWidth, - style, - pressed && styles.pressed, - disabled && styles.disabled, - ]} - {...rest} - > - {children} - -); +const Button = ({ + variant = 'secondary', + style, + textStyle, + icon, + fullWidth, + small, + disabled, + children, + ...rest +}: ButtonProps) => { + const colors = theme.buttons[variant]; + + return ( + [ + styles.base, + { backgroundColor: colors.background }, + small && styles.small, + fullWidth && styles.fullWidth, + style, + pressed && styles.pressed, + disabled && styles.disabled, + ]} + {...rest} + > + {icon} + {typeof children === 'string' ? ( + {children} + ) : ( + children + )} + + ); +}; const styles = StyleSheet.create({ base: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', + gap: 8, paddingVertical: 12, paddingHorizontal: 20, - borderRadius: 24, - backgroundColor: theme.colors.neufBeige, + borderRadius: 999, }, small: { paddingVertical: 6, paddingHorizontal: 14, - borderRadius: 20, }, fullWidth: { alignSelf: 'stretch', }, pressed: { - opacity: 0.7, + opacity: 0.8, }, disabled: { opacity: 0.6, }, + text: { + fontSize: 16, + fontWeight: '500', + }, + textSmall: { + fontSize: 14, + }, }); export default Button; diff --git a/src/components/FormErrors.tsx b/src/components/FormErrors.tsx index aac6e2b..5ecfc04 100644 --- a/src/components/FormErrors.tsx +++ b/src/components/FormErrors.tsx @@ -32,7 +32,7 @@ const FormErrors = ({ errors }: { errors: Record | Error }) => { - {errorFormatted} + {errorFormatted} ); diff --git a/src/components/FormInput.tsx b/src/components/FormInput.tsx index 4f7adf5..3459d7d 100644 --- a/src/components/FormInput.tsx +++ b/src/components/FormInput.tsx @@ -35,12 +35,12 @@ const FormInput = ({ <> {label}} + label={label} accessibilityLabel={label} returnKeyType={returnKeyType} onChangeText={onChange} onBlur={onBlur} - fieldStyle={[styles.field, fieldError ? styles.fieldWithError : undefined]} + fieldStyle={fieldError ? styles.fieldWithError : undefined} value={value} {...rest} /> @@ -54,21 +54,14 @@ const FormInput = ({ }; const styles = StyleSheet.create({ - field: { - borderColor: theme.colors.neufPurple, - borderWidth: 1, - borderRadius: 4, - backgroundColor: 'white', - padding: 4, - marginVertical: 4, - }, fieldWithError: { borderColor: theme.colors.danger, - borderWidth: 2, }, errorMessage: { + ...theme.typography.caption, color: theme.colors.danger, marginBottom: 4, + marginLeft: 16, }, }); diff --git a/src/components/KeyPig.tsx b/src/components/KeyPig.tsx new file mode 100644 index 0000000..7aca498 --- /dev/null +++ b/src/components/KeyPig.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; + +import theme from '@/theme'; +import KeyPigSvg from '@assets/key-pig.svg'; + +// The web's phone-size pig, keeping the artwork's 179:244 ratio +const WIDTH = 128; +const HEIGHT = Math.round((WIDTH * 244) / 179); + +type KeyPigProps = { + color?: string; + style?: StyleProp; +}; + +const KeyPig = ({ color = theme.colors.neufPink, style }: KeyPigProps) => ( + + + +); + +const styles = StyleSheet.create({ + // Web: translate(1vw, 20%) from the corner + pig: { + position: 'absolute', + right: -4, + bottom: -Math.round(HEIGHT * 0.2), + }, +}); + +export default KeyPig; diff --git a/src/components/ListRow.tsx b/src/components/ListRow.tsx index b361ada..f13f0ea 100644 --- a/src/components/ListRow.tsx +++ b/src/components/ListRow.tsx @@ -1,3 +1,4 @@ +import Icon from '@react-native-vector-icons/feather/static'; import React from 'react'; import { AccessibilityRole, Pressable, StyleSheet, Text, View } from 'react-native'; @@ -8,20 +9,20 @@ export type ListRowProps = { onPress?: () => void; /** Right-hand element, e.g. a Switch */ accessory?: React.ReactNode; - /** Disclosure glyph, for rows that lead somewhere */ - chevron?: boolean; + /** The program list's arrow, for rows that lead somewhere */ + arrow?: boolean; destructive?: boolean; accessibilityRole?: AccessibilityRole; testID?: string; }; /** One row in a ListSection: a label with an optional accessory, pressable when given onPress */ -const ListRow = ({ label, onPress, accessory, chevron, destructive, accessibilityRole, testID }: ListRowProps) => { +const ListRow = ({ label, onPress, accessory, arrow, destructive, accessibilityRole, testID }: ListRowProps) => { const content = ( <> {label} {accessory} - {chevron && ›} + {arrow && } ); @@ -53,24 +54,17 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', paddingVertical: 14, paddingHorizontal: 16, - backgroundColor: theme.colors.neufWhite, }, pressed: { opacity: 0.7, }, label: { + ...theme.typography.body, flex: 1, - fontSize: 16, }, destructive: { color: theme.colors.danger, }, - chevron: { - color: theme.colors.lightText, - fontSize: 22, - lineHeight: 22, - marginLeft: 8, - }, }); export default ListRow; diff --git a/src/components/ListSection.tsx b/src/components/ListSection.tsx index 5528cad..c4fd405 100644 --- a/src/components/ListSection.tsx +++ b/src/components/ListSection.tsx @@ -8,7 +8,7 @@ type ListSectionProps = { children: React.ReactNode; }; -/** Grouped rows on a white card with hairline separators, like a settings screen */ +/** Grouped rows on a card with separators, like a settings screen */ const ListSection = ({ title, children }: ListSectionProps) => { const rows = React.Children.toArray(children); @@ -33,23 +33,22 @@ const styles = StyleSheet.create({ marginTop: 24, }, title: { - color: theme.colors.lightText, - fontSize: 13, - textTransform: 'uppercase', - letterSpacing: 0.5, + ...theme.typography.suphead, + color: theme.colors.chateauBlue05, marginHorizontal: 32, marginBottom: 8, }, + // Rounded like buttons: the rows are actions, not a content surface card: { - backgroundColor: theme.colors.neufWhite, + backgroundColor: theme.colors.betongGrayDarkened, borderRadius: 12, marginHorizontal: 16, overflow: 'hidden', }, + // The web's subtle border separator: { - height: StyleSheet.hairlineWidth, - backgroundColor: theme.colors.gray1, - marginLeft: 16, + height: 1, + backgroundColor: theme.colors.chateauBlue05, }, }); diff --git a/src/components/PrivacyButton.tsx b/src/components/PrivacyButton.tsx index b14a29a..05e63aa 100644 --- a/src/components/PrivacyButton.tsx +++ b/src/components/PrivacyButton.tsx @@ -5,7 +5,7 @@ import theme from '@/theme'; import { openPrivacyPolicy } from '@/utils'; const PrivacyButton = () => ( - + Personvern ); @@ -15,8 +15,8 @@ const styles = StyleSheet.create({ padding: 16, }, linkText: { - color: theme.colors.neufPurple, - fontSize: 16, + ...theme.typography.body, + color: theme.colors.deepBrick, textAlign: 'center', textDecorationLine: 'underline', }, diff --git a/src/components/RadioButton.tsx b/src/components/RadioButton.tsx index 232b60e..4b5002f 100644 --- a/src/components/RadioButton.tsx +++ b/src/components/RadioButton.tsx @@ -1,11 +1,14 @@ +import Icon from '@react-native-vector-icons/feather/static'; import React, { useState } from 'react'; -import { Text, Pressable, StyleSheet } from 'react-native'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; import theme from '@/theme'; -interface RadioOption { +export interface RadioOption { label: string; value: string; + /** Secondary text on the right, e.g. a price */ + detail?: string; } interface RadioButtonProps { @@ -14,6 +17,7 @@ interface RadioButtonProps { defaultValue: RadioOption; } +/** Pill togglers as on neuf.no: outlined when idle, filled with a ticked dot when selected */ export default function RadioButton({ data, onSelect, defaultValue }: RadioButtonProps) { const [userOption, setUserOption] = useState(defaultValue.value); @@ -23,35 +27,72 @@ export default function RadioButton({ data, onSelect, defaultValue }: RadioButto }; return ( - <> + {data.map((item) => { + const selected = item.value === userOption; return ( selectHandler(item.value)} + style={({ pressed }) => [styles.option, selected && styles.optionSelected, pressed && styles.pressed]} > - {item.label} + {item.label} + {item.detail && {item.detail}} + + {selected && } + ); })} - + ); } const styles = StyleSheet.create({ - box: { - padding: 12, - margin: 4, - borderRadius: 12, + group: { + gap: 8, }, - selected: { - backgroundColor: theme.colors.neufOrange, + option: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 10, + paddingLeft: 16, + paddingRight: 10, + borderRadius: 999, + borderWidth: 2, + borderColor: theme.colors.chateauBlue, }, - unselected: { - backgroundColor: theme.colors.gray1, + optionSelected: { + backgroundColor: theme.colors.chateauBlue, }, - text: { - textAlign: 'center', + pressed: { + opacity: 0.8, + }, + label: { + flex: 1, + color: theme.colors.chateauBlue, + fontSize: 16, + fontWeight: '500', + }, + detail: { + color: theme.colors.chateauBlue, + fontSize: 16, + marginRight: 12, + }, + textSelected: { + color: theme.colors.white, + }, + dot: { + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: theme.colors.white, + borderWidth: 2, + borderColor: theme.colors.chateauBlue, + alignItems: 'center', + justifyContent: 'center', }, }); diff --git a/src/components/TextField.tsx b/src/components/TextField.tsx index 1ace19f..18e443f 100644 --- a/src/components/TextField.tsx +++ b/src/components/TextField.tsx @@ -1,30 +1,70 @@ -import React from 'react'; +import React, { useState } from 'react'; import { StyleProp, StyleSheet, Text, TextInput, TextInputProps, TextStyle, View, ViewStyle } from 'react-native'; +import theme from '@/theme'; + export type TextFieldProps = TextInputProps & { label?: React.ReactNode; fieldStyle?: StyleProp; }; -const TextField = React.forwardRef(({ label, fieldStyle, style, ...rest }, ref) => ( - - {label != null && - (typeof label === 'string' || typeof label === 'number' ? {label} : label)} - - -)); +// The text colour at 60%, as on neuf.no +const PLACEHOLDER_COLOR = `${theme.colors.chateauBlue}99`; + +/** Labelled input styled like neuf.no: a white pill with a blue border that turns orange on focus */ +const TextField = React.forwardRef( + ({ label, fieldStyle, style, onFocus, onBlur, placeholderTextColor, ...rest }, ref) => { + const [focused, setFocused] = useState(false); + + return ( + + {label != null && + (typeof label === 'string' || typeof label === 'number' ? {label} : label)} + { + setFocused(true); + onFocus?.(event); + }} + onBlur={(event) => { + setFocused(false); + onBlur?.(event); + }} + {...rest} + /> + + ); + }, +); TextField.displayName = 'TextField'; const styles = StyleSheet.create({ container: { - marginVertical: 4, + marginVertical: 6, }, label: { - marginBottom: 2, + ...theme.typography.caption, + color: theme.colors.chateauBlue, + fontWeight: '500', + marginBottom: 4, + marginLeft: 16, }, input: { + backgroundColor: theme.colors.white, + color: theme.colors.chateauBlue, fontSize: 16, + fontWeight: '500', + borderWidth: 2, + borderColor: theme.colors.chateauBlue, + borderRadius: 999, + paddingVertical: 10, + paddingHorizontal: 16, + }, + inputFocused: { + borderColor: theme.colors.goldenOrange, }, }); diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx index 9b7f294..b09b572 100644 --- a/src/components/Toast.tsx +++ b/src/components/Toast.tsx @@ -64,7 +64,7 @@ const styles = StyleSheet.create({ position: 'absolute', left: 16, right: 16, - backgroundColor: theme.colors.neufBlack, + backgroundColor: theme.colors.black, borderRadius: 8, paddingVertical: 12, paddingHorizontal: 16, @@ -76,9 +76,9 @@ const styles = StyleSheet.create({ bottom: 48, }, message: { - color: theme.colors.neufWhite, + ...theme.typography.body, + color: theme.colors.white, textAlign: 'center', - fontSize: 15, }, }); diff --git a/src/modules/about/About.tsx b/src/modules/about/About.tsx index db28153..d0427f6 100644 --- a/src/modules/about/About.tsx +++ b/src/modules/about/About.tsx @@ -94,9 +94,9 @@ const openMap = () => { /** FIXME: Values here are pretty hardcoded. Should fetch config from a JSON-file or API */ const About = () => ( - + - Det Norske Studentersamfund + Det Norske Studentersamfund Linking.openURL(withAppSource('https://neuf.no/'))}> @@ -121,36 +121,37 @@ const About = () => ( Det Norske Studentersamfund{'\n'}Slemdalsveien 15, 0369 Oslo - Åpningstider Ta kontakt - @@ -186,16 +187,19 @@ const About = () => ( export default About; const styles = StyleSheet.create({ + title: { + ...theme.typography.h2, + textAlign: 'center', + marginTop: 8, + }, header: { - fontSize: 20, - fontWeight: 'bold', + ...theme.typography.h3, textAlign: 'center', }, paragraph: { - fontSize: 16, - color: theme.colors.neufBlue, + ...theme.typography.body, + color: theme.colors.chateauBlue, paddingBottom: 10, - lineHeight: 25, }, featuredImage: { marginTop: 10, @@ -210,10 +214,6 @@ const styles = StyleSheet.create({ width: Dimensions.get('window').width * 0.8, // 80% of screen width alignSelf: 'center', }, - icon: { - fontSize: 16, - marginRight: 4, - }, largeIcon: { fontSize: 40, }, diff --git a/src/modules/events/EventList.test.tsx b/src/modules/events/EventList.test.tsx index 5ed7bcc..214894d 100644 --- a/src/modules/events/EventList.test.tsx +++ b/src/modules/events/EventList.test.tsx @@ -49,7 +49,7 @@ it('formats the next occurrence as a Norwegian date with the venue', async () => const { findByText } = await renderWithProviders(); - expect(await findByText('lø. 1. aug. 2026 kl. 19:00 i Storsalen')).toBeTruthy(); + expect(await findByText('lørdag 1. aug. 2026 kl. 19:00 i Storsalen')).toBeTruthy(); }); it('formats weekday and month abbreviations across the year', async () => { @@ -61,8 +61,8 @@ it('formats weekday and month abbreviations across the year', async () => { const { findByText } = await renderWithProviders(); - expect(await findByText('on. 30. des. 2026 kl. 18:00 i Storsalen')).toBeTruthy(); - expect(await findByText('ma. 17. mai 2027 kl. 13:00 i Storsalen')).toBeTruthy(); + expect(await findByText('onsdag 30. des. 2026 kl. 18:00 i Storsalen')).toBeTruthy(); + expect(await findByText('mandag 17. mai 2027 kl. 13:00 i Storsalen')).toBeTruthy(); }); it('renders venue-local (Oslo) time whatever the API offset and device timezone', async () => { @@ -72,7 +72,7 @@ it('renders venue-local (Oslo) time whatever the API offset and device timezone' const { findByText } = await renderWithProviders(); - expect(await findByText('lø. 1. aug. 2026 kl. 19:00 i Storsalen')).toBeTruthy(); + expect(await findByText('lørdag 1. aug. 2026 kl. 19:00 i Storsalen')).toBeTruthy(); }); it('prefixes the time with Neste: when the event has more occurrences', async () => { @@ -80,7 +80,7 @@ it('prefixes the time with Neste: when the event has more occurrences', async () const { findByText } = await renderWithProviders(); - expect(await findByText('Neste: lø. 1. aug. 2026 kl. 19:00 i Storsalen')).toBeTruthy(); + expect(await findByText('Neste: lørdag 1. aug. 2026 kl. 19:00 i Storsalen')).toBeTruthy(); expect(await findByText('Se flere tider (2)')).toBeTruthy(); }); diff --git a/src/modules/events/EventList.tsx b/src/modules/events/EventList.tsx index 488824e..230161f 100644 --- a/src/modules/events/EventList.tsx +++ b/src/modules/events/EventList.tsx @@ -3,7 +3,7 @@ import Icon from '@react-native-vector-icons/feather/static'; import { format, parseISO } from 'date-fns'; import { nb } from 'date-fns/locale'; import React from 'react'; -import { StyleSheet, View, Platform, FlatList, TouchableOpacity, Text, Linking } from 'react-native'; +import { StyleSheet, View, FlatList, TouchableOpacity, Text, Linking } from 'react-native'; import Button from '@/components/Button'; import theme from '@/theme'; @@ -15,7 +15,7 @@ import useEventList from './useEventList'; // Venue-local (Europe/Oslo) wall-clock time regardless of device timezone, // e.g. "lø. 1. aug. 2026 kl. 19:00" const formatTime = (time: string) => { - return format(new TZDate(parseISO(time), 'Europe/Oslo'), "EEEEEE. d. MMM y 'kl.' HH:mm", { locale: nb }); + return format(new TZDate(parseISO(time), 'Europe/Oslo'), "EEEE d. MMM y 'kl.' HH:mm", { locale: nb }); }; const Header = () => ; @@ -51,7 +51,7 @@ const EventList = () => { )} - + @@ -64,9 +64,7 @@ const EventList = () => { Kunne ikke hente programmet... - + ); @@ -79,7 +77,6 @@ const EventList = () => { keyExtractor={(item: EventType) => item.id} refreshing={refreshing} onRefresh={handleRefresh} - style={[theme.card, styles.list]} ListHeaderComponent={Header} /> ); @@ -88,7 +85,6 @@ export default EventList; const styles = StyleSheet.create({ listItem: { - backgroundColor: theme.colors.betongGray, paddingTop: 10, paddingBottom: 12, paddingLeft: 12, @@ -100,41 +96,26 @@ const styles = StyleSheet.create({ borderBottomWidth: 0, }, listItemTitle: { - fontSize: 16, - fontWeight: 'bold', + ...theme.text, + ...theme.typography.body, + fontWeight: '600', }, listItemSubtitle: { - fontSize: 16, + ...theme.text, + ...theme.typography.body, }, listItemTime: { - fontSize: 14, - color: theme.colors.lightText, - }, - sectionHeader: { - backgroundColor: theme.colors.neufWhite, - ...Platform.select({ - ios: { marginBottom: -8 }, - }), - paddingLeft: 8, - paddingTop: 12, - }, - sectionTitle: { - fontSize: 18, - fontWeight: 'bold', + ...theme.text, + ...theme.typography.caption, }, loadingText: { + ...theme.text, + ...theme.typography.body, textAlign: 'center', paddingBottom: 8, }, - list: { - padding: 0, - marginTop: 0, - marginBottom: 0, - backgroundColor: 'transparent', - }, listHeader: { paddingTop: 8, - backgroundColor: theme.colors.neufGray, }, arrow: { flex: 1, diff --git a/src/modules/login/Login.test.tsx b/src/modules/login/Login.test.tsx index c02fdb9..246dd70 100644 --- a/src/modules/login/Login.test.tsx +++ b/src/modules/login/Login.test.tsx @@ -28,12 +28,13 @@ beforeEach(() => { }); it('renders the login form', async () => { - const { getByText } = await renderWithProviders(); + const { getByText, getByTestId } = await renderWithProviders(); expect(getByText('E-post / Brukernavn')).toBeTruthy(); expect(getByText('Passord')).toBeTruthy(); expect(getByText('Logg inn')).toBeTruthy(); expect(getByText('Glemt passord?')).toBeTruthy(); + expect(getByTestId('key-pig')).toBeTruthy(); }); it('moves focus to the password field when the username is submitted from the keyboard', async () => { diff --git a/src/modules/login/Login.tsx b/src/modules/login/Login.tsx index b01d666..51381ea 100644 --- a/src/modules/login/Login.tsx +++ b/src/modules/login/Login.tsx @@ -1,9 +1,10 @@ import React from 'react'; -import { ActivityIndicator, Linking, StyleSheet, View, Text, ScrollView } from 'react-native'; +import { ActivityIndicator, Linking, StyleSheet, View, ScrollView } from 'react-native'; import Button from '@/components/Button'; import FormErrors from '@/components/FormErrors'; import FormInput from '@/components/FormInput'; +import KeyPig from '@/components/KeyPig'; import theme from '@/theme'; import { withAppSource } from '@/utils'; @@ -13,56 +14,62 @@ const Login = () => { const { isLoggingIn, isSubmitted, errors, loginError, control, setFocus, onSubmit } = useLogin(); return ( - - - {errors && } - setFocus('password')} - /> - - - {loginError && isSubmitted && } - {isLoggingIn && ( - - )} - - - + + + + + {errors && } + setFocus('password')} + /> + + + {loginError && isSubmitted && } + {isLoggingIn && ( + + )} + + + + ); }; export default Login; const styles = StyleSheet.create({ + screen: { + flex: 1, + }, loginButton: { marginVertical: 16, }, diff --git a/src/modules/membership/Membership.test.tsx b/src/modules/membership/Membership.test.tsx index 1aeb7eb..55c5656 100644 --- a/src/modules/membership/Membership.test.tsx +++ b/src/modules/membership/Membership.test.tsx @@ -22,6 +22,10 @@ it('shows the membership pitch when logged out', async () => { expect(await findByText('Bli medlem')).toBeTruthy(); expect(await findByText('Logg inn')).toBeTruthy(); + expect(await findByText('Medlemsfordeler')).toBeTruthy(); + expect(await findByText('Stemmerett i alle saker på medlemsmøter')).toBeTruthy(); + // Store policies want the privacy policy reachable without logging in + expect(await findByText('Personvern')).toBeTruthy(); }); it('lets a logged in non-member pick a membership type and shows its price', async () => { @@ -32,21 +36,24 @@ it('lets a logged in non-member pick a membership type and shows its price', asy initialUser: nonMember, }); - // All three membership types are offered + // All three membership types are offered, each with its price expect(await findByText('Velg medlemskapstype')).toBeTruthy(); expect(getByText('Student')).toBeTruthy(); + expect(getByText('150 kr')).toBeTruthy(); expect(getByText('Ordinær')).toBeTruthy(); + expect(getByText('300 kr')).toBeTruthy(); expect(getByText('Støtte')).toBeTruthy(); + expect(getByText('500 kr')).toBeTruthy(); // 'Ordinær' (slug: regular) is preselected, so the purchase button shows its price - expect(await findByText('Kjøp medlemskap (300 NOK)')).toBeTruthy(); + expect(await findByText('Kjøp medlemskap (300 kr)')).toBeTruthy(); // Selecting another type updates the price await fireEvent.press(getByText('Student')); - expect(await findByText('Kjøp medlemskap (150 NOK)')).toBeTruthy(); + expect(await findByText('Kjøp medlemskap (150 kr)')).toBeTruthy(); await fireEvent.press(getByText('Støtte')); - expect(await findByText('Kjøp medlemskap (500 NOK)')).toBeTruthy(); + expect(await findByText('Kjøp medlemskap (500 kr)')).toBeTruthy(); }); it('shows the membership proof when logged in', async () => { diff --git a/src/modules/membership/Membership.tsx b/src/modules/membership/Membership.tsx index 8cbcda4..a4771f1 100644 --- a/src/modules/membership/Membership.tsx +++ b/src/modules/membership/Membership.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { View, ActivityIndicator } from 'react-native'; -import PrivacyButton from '@/components/PrivacyButton'; import theme from '@/theme'; import MembershipInfo from './MembershipInfo'; @@ -16,7 +15,7 @@ const Membership = () => { if (isPending) { return ( - + ); } @@ -25,11 +24,6 @@ const Membership = () => { return ; } - return ( - <> - - - - ); + return ; }; export default Membership; diff --git a/src/modules/membership/MembershipInfo.tsx b/src/modules/membership/MembershipInfo.tsx index f8028b9..d8d2d4b 100644 --- a/src/modules/membership/MembershipInfo.tsx +++ b/src/modules/membership/MembershipInfo.tsx @@ -1,37 +1,47 @@ import React from 'react'; import { ScrollView, Text, View, StyleSheet } from 'react-native'; +import BulletList from '@/components/BulletList'; import Button from '@/components/Button'; +import PrivacyButton from '@/components/PrivacyButton'; import theme from '@/theme'; +const BENEFITS = [ + 'Rabatter på utvalgte arrangementer', + 'Tidlig innslipp under utvalgte arrangementer', + 'Earlybird-billetter til utvalgte arrangementer', + 'Medlemspriser hos Studentersamfundet i Trondhjem og Studentersamfunnet i Bergen', + 'Stemmerett i alle saker på medlemsmøter', + 'Mulighet til å stille til verv i styret og som direktør', +]; + interface MembershipInfoProps { onRegister: () => void; onLogin: () => void; } const MembershipInfo = ({ onRegister, onLogin }: MembershipInfoProps) => ( - + Hei 👋 - Her kan du bli medlem i Det Norske Studentersamfund - Chateau Neuf og ha medlemsbeviset ditt på telefonen + Her kan du bli medlem i Det Norske Studentersamfund. Appen fungerer som medlemsbevis. - - Hva får jeg? - - - Minst 25% rabatt på de fleste arrangement{'\n'}- Gratis inngang på enkelte arrangement{'\n'}- Store rabatter i - barene{'\n'}- Egne medlemsarrangement og -tilbud{'\n'}- Mulighet til å delta i landets eldste studentdemokrati - + Medlemsfordeler + Som medlem i Det Norske Studentersamfund får man: + Allerede medlem? - + ); @@ -39,32 +49,31 @@ export default MembershipInfo; const styles = StyleSheet.create({ paragraph: { + ...theme.typography.body, textAlign: 'center', - fontSize: 16, paddingBottom: 10, - lineHeight: 25, + }, + intro: { + ...theme.typography.body, + marginBottom: 12, }, button: { marginTop: 6, marginBottom: 16, }, em: { - fontSize: 16, + ...theme.typography.body, fontStyle: 'italic', textAlign: 'center', marginVertical: 10, }, headerTitle: { + ...theme.typography.h2, textAlign: 'center', - fontWeight: 'bold', - fontSize: 20, paddingVertical: 10, - lineHeight: 25, }, header: { - fontSize: 16, - fontWeight: 'bold', - lineHeight: 25, - paddingLeft: 2, + ...theme.typography.h3, + marginBottom: 4, }, }); diff --git a/src/modules/membership/Proof.test.tsx b/src/modules/membership/Proof.test.tsx index bb96ae3..793141e 100644 --- a/src/modules/membership/Proof.test.tsx +++ b/src/modules/membership/Proof.test.tsx @@ -58,7 +58,7 @@ it('disables the purchase button without showing a price while membership types await act(async () => { resolveMembershipTypes(testMembershipTypes); }); - expect(await findByText('Kjøp medlemskap (300 NOK)')).toBeTruthy(); + expect(await findByText('Kjøp medlemskap (300 kr)')).toBeTruthy(); }); it('shows an error with retry instead of a price when membership types fail to load', async () => { @@ -73,8 +73,8 @@ it('shows an error with retry instead of a price when membership types fail to l // Retrying hits the recovered API and gets the real price await fireEvent.press(getByText('Prøv igjen')); - expect(await findByText('Kjøp medlemskap (300 NOK)')).toBeTruthy(); - expect(getByRole('button', { name: 'Kjøp medlemskap (300 NOK)' })).toBeEnabled(); + expect(await findByText('Kjøp medlemskap (300 kr)')).toBeTruthy(); + expect(getByRole('button', { name: 'Kjøp medlemskap (300 kr)' })).toBeEnabled(); }); it('purchases the preselected membership through the stripe payment sheet', async () => { @@ -83,7 +83,7 @@ it('purchases the preselected membership through the stripe payment sheet', asyn // 'Ordinær' (slug: regular) is preselected; the button enables once the // membership type query has resolved and its price is shown - await fireEvent.press(await findByText('Kjøp medlemskap (300 NOK)')); + await fireEvent.press(await findByText('Kjøp medlemskap (300 kr)')); expect(await findByText('Your order is confirmed!')).toBeTruthy(); expect(requestPaymentSheet).toHaveBeenCalledWith('test-token', 'regular'); @@ -106,7 +106,7 @@ it('purchases the selected membership type, not the default', async () => { const { findByText } = await renderProof(); await fireEvent.press(await findByText('Student')); - await fireEvent.press(await findByText('Kjøp medlemskap (150 NOK)')); + await fireEvent.press(await findByText('Kjøp medlemskap (150 kr)')); expect(await findByText('Your order is confirmed!')).toBeTruthy(); expect(requestPaymentSheet).toHaveBeenCalledWith('test-token', 'student'); @@ -119,7 +119,7 @@ it('shows the stripe error when the payment sheet is not completed', async () => }); const { findByText, queryByText } = await renderProof(); - await fireEvent.press(await findByText('Kjøp medlemskap (300 NOK)')); + await fireEvent.press(await findByText('Kjøp medlemskap (300 kr)')); expect(await findByText('Error code: Canceled The payment flow has been canceled')).toBeTruthy(); expect(queryByText('Your order is confirmed!')).toBeNull(); @@ -130,13 +130,13 @@ it('shows an error and does not present the sheet when the backend rejects the p jest.spyOn(api, 'requestStripePaymentSheet').mockRejectedValueOnce(new Error('Ugyldig medlemskapstype')); const { findByText, getByRole } = await renderProof(); - await fireEvent.press(await findByText('Kjøp medlemskap (300 NOK)')); + await fireEvent.press(await findByText('Kjøp medlemskap (300 kr)')); expect(await findByText('Oops! Could not initialise payment')).toBeTruthy(); expect(initPaymentSheet).not.toHaveBeenCalled(); expect(presentPaymentSheet).not.toHaveBeenCalled(); // The purchase button recovers so the user can retry - expect(getByRole('button', { name: 'Kjøp medlemskap (300 NOK)' })).toBeEnabled(); + expect(getByRole('button', { name: 'Kjøp medlemskap (300 kr)' })).toBeEnabled(); }); it('shows an error and does not present the sheet when stripe fails to initialise it', async () => { @@ -145,7 +145,7 @@ it('shows an error and does not present the sheet when stripe fails to initialis initPaymentSheet.mockResolvedValueOnce({ error: { code: 'Failed', message: 'Invalid client secret' } }); const { findByText } = await renderProof(); - await fireEvent.press(await findByText('Kjøp medlemskap (300 NOK)')); + await fireEvent.press(await findByText('Kjøp medlemskap (300 kr)')); expect(await findByText('Oops! Could not initialise payment')).toBeTruthy(); expect(presentPaymentSheet).not.toHaveBeenCalled(); diff --git a/src/modules/membership/Proof.tsx b/src/modules/membership/Proof.tsx index d3e6eed..3a7a7e0 100644 --- a/src/modules/membership/Proof.tsx +++ b/src/modules/membership/Proof.tsx @@ -37,7 +37,7 @@ const MembershipValidTo = ({ user }: { user: UserType }) => { return ( - Gyldig til: + Gyldig til: {validTo} ); @@ -78,6 +78,7 @@ interface PurchaseButtonProps { paymentLoading: boolean; isLoadingMembershipType: boolean; membershipPrice: number | null; + priceFor: (slug: string) => number | null; membershipTypesError: boolean; onPurchasePress: () => void; onRetryPress: () => void; @@ -90,6 +91,7 @@ const PurchaseButton = ({ isLoadingMembershipType, onPurchasePress, membershipPrice, + priceFor, membershipTypesError, onRetryPress, setMembershipTypeSlug, @@ -98,7 +100,10 @@ const PurchaseButton = ({ { label: 'Student', value: 'student' }, { label: 'Ordinær', value: 'regular' }, { label: 'Støtte', value: 'support' }, - ]; + ].map((option) => { + const price = priceFor(option.value); + return { ...option, detail: price != null ? `${price} kr` : undefined }; + }); const defaultValue = options[1]; @@ -117,23 +122,22 @@ const PurchaseButton = ({ {paymentLoading && ( - + )} {membershipTypesError && ( <> Kunne ikke hente priser - )} @@ -213,6 +217,7 @@ const Proof = ({ fetchUser, isRefetching }: ProofProps) => { isLoadingMembershipType, onPurchasePress, membershipPrice, + priceFor, membershipTypesError, refetchMembershipTypes, toast, @@ -266,7 +271,7 @@ const Proof = ({ fetchUser, isRefetching }: ProofProps) => { } > - + @@ -275,6 +280,7 @@ const Proof = ({ fetchUser, isRefetching }: ProofProps) => { paymentLoading={paymentLoading} isLoadingMembershipType={isLoadingMembershipType} membershipPrice={membershipPrice} + priceFor={priceFor} membershipTypesError={membershipTypesError} onPurchasePress={onPurchasePress} onRetryPress={() => { @@ -315,10 +321,9 @@ const styles = StyleSheet.create({ marginVertical: 16, }, nameText: { + ...theme.typography.h3, flex: 1, textAlign: 'center', - fontSize: 20, - fontWeight: 'bold', }, valid: { backgroundColor: theme.colors.success, @@ -327,7 +332,7 @@ const styles = StyleSheet.create({ padding: 16, }, validText: { - color: theme.colors.neufWhite, + color: theme.colors.white, textAlign: 'center', fontSize: 30, fontWeight: '800', @@ -340,13 +345,13 @@ const styles = StyleSheet.create({ marginVertical: 16, }, expiredText: { - color: theme.colors.neufWhite, + color: theme.colors.white, textAlign: 'center', fontSize: 24, }, validToValue: { - fontWeight: 'bold', - fontSize: 18, + ...theme.typography.lead, + fontWeight: '600', }, purchaseButton: { marginTop: 8, @@ -360,13 +365,14 @@ const styles = StyleSheet.create({ marginBottom: 20, }, purchaseTextInner: { + ...theme.typography.body, fontStyle: 'italic', - fontSize: 16, textAlign: 'center', paddingTop: 10, paddingBottom: 8, }, errorMessage: { + ...theme.typography.caption, paddingTop: 10, textAlign: 'center', color: theme.colors.danger, @@ -378,13 +384,12 @@ const styles = StyleSheet.create({ alignSelf: 'center', }, radioForm: { - marginTop: 4, + marginTop: 16, marginBottom: 20, }, selectText: { + ...theme.typography.h3, textAlign: 'center', - fontSize: 16, - fontWeight: 'bold', marginTop: 30, }, container: { @@ -392,4 +397,8 @@ const styles = StyleSheet.create({ display: 'flex', flexDirection: 'column', }, + // On the page itself, not a card: the status banner bleeds to the edges + content: { + padding: 16, + }, }); diff --git a/src/modules/membership/UserDelete.tsx b/src/modules/membership/UserDelete.tsx index f18ac45..4829f3e 100644 --- a/src/modules/membership/UserDelete.tsx +++ b/src/modules/membership/UserDelete.tsx @@ -1,10 +1,11 @@ import { useNavigation } from '@react-navigation/native'; import React, { useEffect } from 'react'; -import { ActivityIndicator, ScrollView, Text, TextInput, View, StyleSheet } from 'react-native'; +import { ActivityIndicator, ScrollView, Text, View, StyleSheet } from 'react-native'; import { useAuth } from '@/auth/AuthContext'; import Button from '@/components/Button'; import FormErrors from '@/components/FormErrors'; +import TextField from '@/components/TextField'; import { useUser } from '@/queries/useUser'; import theme from '@/theme'; @@ -31,33 +32,29 @@ const UserDelete = () => { const isDisabled = !isDeletingUser && value !== user.username; return ( - + Slett meg Dette sletter kontoen med brukernavn {user.username} inkludert all tilknyttet - persondata for alltid. + personinformasjon for alltid. Skriv inn brukernavnet ditt for å fortsette. - onChangeText(text)} + autoCorrect={false} + onChangeText={onChangeText} value={value} - style={styles.input} /> {deleteUserError && } - {isDeletingUser && ( - + )} @@ -68,11 +65,10 @@ export default UserDelete; const styles = StyleSheet.create({ paragraph: { + ...theme.typography.body, textAlign: 'center', - fontSize: 16, - color: theme.colors.neufBlack, + color: theme.colors.black, paddingBottom: 10, - lineHeight: 25, }, bold: { fontWeight: 'bold', @@ -82,33 +78,13 @@ const styles = StyleSheet.create({ marginBottom: 16, backgroundColor: theme.colors.danger, }, - deleteButtonDisabled: { - marginTop: 16, - marginBottom: 16, - backgroundColor: theme.colors.gray1, - }, whiteText: { - color: theme.colors.neufWhite, - fontWeight: '900', - }, - disabledText: { - opacity: 0.5, + color: theme.colors.white, }, headerTitle: { + ...theme.typography.h2, textAlign: 'center', - fontWeight: 'bold', - fontSize: 20, - color: theme.colors.neufBlack, + color: theme.colors.black, paddingVertical: 10, - lineHeight: 25, - }, - input: { - height: 40, - borderColor: theme.colors.neufPurple, - backgroundColor: 'white', - borderWidth: 1, - margin: 10, - padding: 10, - textAlign: 'center', }, }); diff --git a/src/modules/membership/useProof.tsx b/src/modules/membership/useProof.tsx index a0cbcf6..714ddeb 100644 --- a/src/modules/membership/useProof.tsx +++ b/src/modules/membership/useProof.tsx @@ -23,10 +23,11 @@ const useProof = ({ fetchUser }: UseProofProps) => { const [membershipTypeSlug, setMembershipTypeSlug] = useState('regular'); const [toast, setToast] = useState(null); - // null until the query has delivered a price for the selected type; the - // purchase button stays disabled rather than showing a guessed amount - const membershipPrice = - membershipTypes?.find((membershipTypeOption) => membershipTypeOption.slug === membershipTypeSlug)?.price ?? null; + // Price in kr (the API sends øre), null until the query has delivered it + const priceFor = (slug: string) => { + const price = membershipTypes?.find((membershipTypeOption) => membershipTypeOption.slug === slug)?.price; + return price != null ? price / 100 : null; + }; const openPaymentSheet = async () => { setPaymentLoading(true); @@ -82,7 +83,8 @@ const useProof = ({ fetchUser }: UseProofProps) => { return { isLoadingMembershipType: isPending, - membershipPrice: membershipPrice != null ? membershipPrice / 100 : null, + membershipPrice: priceFor(membershipTypeSlug), + priceFor, membershipTypesError: isError, refetchMembershipTypes: refetch, onPurchasePress: openPaymentSheet, diff --git a/src/modules/register/UserRegister.tsx b/src/modules/register/UserRegister.tsx index 584f218..1712dd4 100644 --- a/src/modules/register/UserRegister.tsx +++ b/src/modules/register/UserRegister.tsx @@ -1,6 +1,6 @@ import * as EmailValidator from 'email-validator'; import React from 'react'; -import { ActivityIndicator, StyleSheet, Text, ScrollView } from 'react-native'; +import { ActivityIndicator, StyleSheet, ScrollView } from 'react-native'; import Button from '@/components/Button'; import FormErrors from '@/components/FormErrors'; @@ -17,7 +17,7 @@ const UserRegister = () => { return ( <> - + { /> {registerError && isSubmitted && } {isRegisteringUser && ( - + )} - @@ -82,15 +82,6 @@ const UserRegister = () => { export default UserRegister; const styles = StyleSheet.create({ - container: { - flex: 1, - justifyContent: 'center', - }, - input: { - width: 320, - fontSize: 20, - height: 52, - }, registerButton: { marginVertical: 16, }, diff --git a/src/modules/settings/NotificationRow.tsx b/src/modules/settings/NotificationRow.tsx index 24a5171..4fc5c8f 100644 --- a/src/modules/settings/NotificationRow.tsx +++ b/src/modules/settings/NotificationRow.tsx @@ -37,9 +37,9 @@ const NotificationRow = ({ expiryDate, showToast }: NotificationRowProps) => { accessibilityLabel={LABEL} value={hasPermission || false} onValueChange={handleToggle} - trackColor={{ false: '#E0E0E0', true: theme.colors.neufOrange }} - thumbColor="#FFFFFF" - ios_backgroundColor="#E0E0E0" + trackColor={{ false: theme.colors.betongGrayDarkened, true: theme.colors.goldenOrange }} + thumbColor={theme.colors.white} + ios_backgroundColor={theme.colors.betongGrayDarkened} /> } /> diff --git a/src/modules/settings/Settings.tsx b/src/modules/settings/Settings.tsx index 6cfea42..d024ab2 100644 --- a/src/modules/settings/Settings.tsx +++ b/src/modules/settings/Settings.tsx @@ -35,7 +35,7 @@ const Settings = () => { )} - + @@ -58,7 +58,6 @@ export default Settings; const styles = StyleSheet.create({ container: { flex: 1, - ...theme.background, }, content: { paddingBottom: 32, @@ -69,13 +68,12 @@ const styles = StyleSheet.create({ paddingHorizontal: 16, }, name: { - fontSize: 20, - fontWeight: 'bold', + ...theme.typography.h3, textAlign: 'center', }, email: { - color: theme.colors.lightText, - fontSize: 16, + ...theme.typography.body, + color: theme.colors.chateauBlue05, marginTop: 4, textAlign: 'center', }, diff --git a/src/theme.ts b/src/theme.ts index ec6dcec..b0097c3 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -1,57 +1,52 @@ -import { Platform } from 'react-native'; - +// The brand palette, named as in neuf-www's variables.scss const colors = { - danger: '#d9534f', - success: '#5cb85c', - lightText: '#666', - neufPurple: '#720328', - neufGray: '#E5E2D7', - neufOrange: '#f58220', - neufBeige: '#F8BC90', - neufBlue: '#0A0F6D', - neufWhite: '#FFFFFF', - neufBlack: '#161516', - gray1: '#dddddd', - // 2024 colors deepBrick: '#720328', neufPink: '#cd96ab', betongGray: '#e5e2d7', goldenOrange: '#ff6915', goldenBeige: '#f8bc90', chateauBlue: '#0a0f6d', + white: '#ffffff', + black: '#161516', + // Derived colours neuf.no uses for muted text and secondary surfaces + chateauBlue05: 'rgba(10, 15, 109, 0.5)', + betongGrayDarkened: '#dcd8cc', + // Not from the brand palette: status banners, errors and destructive actions + danger: '#d9534f', + success: '#5cb85c', }; +// Secondary surface, as neuf.no's forms and info boxes const card = { - ...Platform.select({ - ios: { - shadowColor: 'rgba(0,0,0, .2)', - shadowOffset: { height: 0, width: 0 }, - shadowOpacity: 1, - shadowRadius: 1, - }, - android: { - elevation: 1, - }, - }), + backgroundColor: colors.betongGrayDarkened, padding: 16, - backgroundColor: colors.neufGray, + margin: 16, }; +// Painted behind every screen by the navigator (see Navigation.tsx) const background = { - backgroundColor: colors.neufGray, + backgroundColor: colors.betongGray, }; const text = { - color: colors.neufBlue, + color: colors.chateauBlue, }; -const button = { - backgroundColor: colors.neufBeige, +// Button colour pairs from neuf.no: primary is its default button, secondary its beige menu style +const buttons = { + primary: { background: colors.goldenOrange, text: colors.black }, + secondary: { background: colors.goldenBeige, text: colors.deepBrick }, }; -const buttonText = { - color: colors.neufPurple, - fontWeight: '500', +// neuf.no's type scale at phone widths: body 1.5 line height, headings 600 at 1.2 +const typography = { + h1: { fontSize: 32, fontWeight: '600', lineHeight: 38 }, + h2: { fontSize: 26, fontWeight: '600', lineHeight: 31 }, + h3: { fontSize: 21, fontWeight: '600', lineHeight: 25 }, + lead: { fontSize: 19, lineHeight: 28 }, + body: { fontSize: 16, lineHeight: 24 }, + caption: { fontSize: 14, lineHeight: 21 }, + suphead: { fontSize: 14, fontWeight: '700', lineHeight: 21, textTransform: 'uppercase', letterSpacing: 0.7 }, } as const; -export default { card, colors, background, text, button, buttonText }; +export default { card, colors, background, text, typography, buttons };