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
Linking.openURL(withAppSource('https://neuf.no/praktisk#apningstider'))}
- style={theme.button}
+ icon={}
>
-
- Vis åpningstider
+ Vis åpningstider
Ta kontakt
- Linking.openURL('tel:+4794430002')} style={[theme.button, { marginHorizontal: 10 }]}>
-
- Ring
+ Linking.openURL('tel:+4794430002')}
+ style={{ marginHorizontal: 10 }}
+ icon={}
+ >
+ Ring
Linking.openURL('mailto:ledelsen@studentersamfundet.no')}
- style={[theme.button, { marginHorizontal: 10 }]}
+ style={{ marginHorizontal: 10 }}
+ icon={}
>
-
- Send e-post
+ Send e-post
@@ -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...
-
- Prøv igjen
-
+ Prøv igjen
);
@@ -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')}
- />
-
-
- Logg inn
-
- {loginError && isSubmitted && }
- {isLoggingIn && (
-
- )}
- {
- Linking.openURL(withAppSource(process.env.EXPO_PUBLIC_DUSKEN_FORGOT_PASSWORD_URL || ''));
- }}
- style={[theme.button, styles.forgotPasswordButton]}
- >
- Glemt passord?
-
-
-
+
+
+
+
+ {errors && }
+ setFocus('password')}
+ />
+
+
+ Logg inn
+
+ {loginError && isSubmitted && }
+ {isLoggingIn && (
+
+ )}
+ {
+ Linking.openURL(withAppSource(process.env.EXPO_PUBLIC_DUSKEN_FORGOT_PASSWORD_URL || ''));
+ }}
+ style={styles.forgotPasswordButton}
+ >
+ Glemt passord?
+
+
+
+
);
};
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.
-
- Bli medlem
+
+ Bli medlem
- 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?
-
- Logg inn
+
+ Logg inn
+
);
@@ -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 && (
-
+
)}
-
- {membershipPrice != null ? `Kjøp medlemskap (${membershipPrice} NOK)` : 'Kjøp medlemskap'}
-
+ {membershipPrice != null ? `Kjøp medlemskap (${membershipPrice} kr)` : 'Kjøp medlemskap'}
{membershipTypesError && (
<>
Kunne ikke hente priser
-
- Prøv igjen
+
+ Prøv igjen
>
)}
@@ -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 && }
-
- Jeg forstår, slett kontoen min
+
+ Jeg forstår, slett kontoen min
{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 && (
-
+
)}
-
- Registrer meg
+
+ Registrer meg
@@ -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 };