Restyle to better match brand style guidelines and neuf.no
CI / checks (push) Failing after 2m38s

This commit is contained in:
ponas committed 2026-09-06 03:26:25 +02:00
1 parent dfa68f50ec
commit 8a4dbc2c6e
29 files changed
+561 -363

No files matched your search

+10 -4
View File
@@ -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 = () => (
</EventStack.Navigator>
);
// 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 }) => <SettingsHeaderButton tintColor={tintColor} />;
@@ -84,7 +90,7 @@ const EventIcon: TabBarIconType = ({ color, size }) => <IconEvent width={size} h
const UserIcon: TabBarIconType = ({ color, size }) => <IconPerson width={size + 8} height={size + 8} fill={color} />;
const AboutIcon: TabBarIconType = ({ color, size }) => <IconInfo width={size} height={size} fill={color} />;
const Navigation = () => (
<NavigationContainer>
<NavigationContainer theme={navigationTheme}>
<HomeTabs.Navigator initialRouteName="EventStack">
<HomeTabs.Screen
name="EventStack"
+44
View File
@@ -0,0 +1,44 @@
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import theme from '@/theme';
type BulletListProps = {
items: string[];
};
/** Unordered list with neuf.no's orange dot bullets */
const BulletList = ({ items }: BulletListProps) => (
<View accessibilityRole="list">
{items.map((item) => (
<View key={item} style={styles.item}>
<View style={styles.bullet} />
<Text style={[theme.text, styles.text]}>{item}</Text>
</View>
))}
</View>
);
// 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;
+55 -22
View File
@@ -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<PressableProps, 'style'> & {
/** primary is the orange call to action, secondary the beige one, as on neuf.no */
variant?: ButtonVariant;
style?: StyleProp<ViewStyle>;
/** Applies when children is a string */
textStyle?: StyleProp<TextStyle>;
icon?: React.ReactNode;
fullWidth?: boolean;
small?: boolean;
children?: React.ReactNode;
};
const Button = ({ style, fullWidth, small, disabled, children, ...rest }: ButtonProps) => (
<Pressable
accessibilityRole="button"
disabled={disabled}
style={({ pressed }) => [
styles.base,
small && styles.small,
fullWidth && styles.fullWidth,
style,
pressed && styles.pressed,
disabled && styles.disabled,
]}
{...rest}
>
{children}
</Pressable>
);
const Button = ({
variant = 'secondary',
style,
textStyle,
icon,
fullWidth,
small,
disabled,
children,
...rest
}: ButtonProps) => {
const colors = theme.buttons[variant];
return (
<Pressable
accessibilityRole="button"
disabled={disabled}
style={({ pressed }) => [
styles.base,
{ backgroundColor: colors.background },
small && styles.small,
fullWidth && styles.fullWidth,
style,
pressed && styles.pressed,
disabled && styles.disabled,
]}
{...rest}
>
{icon}
{typeof children === 'string' ? (
<Text style={[styles.text, { color: colors.text }, small && styles.textSmall, textStyle]}>{children}</Text>
) : (
children
)}
</Pressable>
);
};
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;
+1 -1
View File
@@ -32,7 +32,7 @@ const FormErrors = ({ errors }: { errors: Record<string, any> | Error }) => {
<View style={styles.container}>
<IconExclamationTriangle width={20} height={20} style={styles.icon} />
<View>
<Text>{errorFormatted}</Text>
<Text style={theme.typography.body}>{errorFormatted}</Text>
</View>
</View>
);
+4 -11
View File
@@ -35,12 +35,12 @@ const FormInput = ({
<>
<TextField
ref={ref}
label={<Text style={theme.text}>{label}</Text>}
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,
},
});
+31
View File
@@ -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<ViewStyle>;
};
const KeyPig = ({ color = theme.colors.neufPink, style }: KeyPigProps) => (
<View pointerEvents="none" style={[styles.pig, style]} testID="key-pig">
<KeyPigSvg width={WIDTH} height={HEIGHT} color={color} />
</View>
);
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;
+6 -12
View File
@@ -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 = (
<>
<Text style={[theme.text, styles.label, destructive && styles.destructive]}>{label}</Text>
{accessory}
{chevron && <Text style={styles.chevron}>›</Text>}
{arrow && <Icon name="arrow-right" size={20} color={theme.colors.chateauBlue} />}
</>
);
@@ -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;
+8 -9
View File
@@ -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,
},
});
+3 -3
View File
@@ -5,7 +5,7 @@ import theme from '@/theme';
import { openPrivacyPolicy } from '@/utils';
const PrivacyButton = () => (
<TouchableOpacity style={[theme.background, styles.button]} onPress={openPrivacyPolicy}>
<TouchableOpacity style={styles.button} onPress={openPrivacyPolicy}>
<Text style={styles.linkText}>Personvern</Text>
</TouchableOpacity>
);
@@ -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',
},
+57 -16
View File
@@ -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 (
<>
<View accessibilityRole="radiogroup" style={styles.group}>
{data.map((item) => {
const selected = item.value === userOption;
return (
<Pressable
key={item.value}
style={[styles.box, item.value === userOption ? styles.selected : styles.unselected]}
accessibilityRole="radio"
accessibilityState={{ checked: selected }}
accessibilityLabel={item.detail ? `${item.label}, ${item.detail}` : item.label}
onPress={() => selectHandler(item.value)}
style={({ pressed }) => [styles.option, selected && styles.optionSelected, pressed && styles.pressed]}
>
<Text style={styles.text}>{item.label}</Text>
<Text style={[styles.label, selected && styles.textSelected]}>{item.label}</Text>
{item.detail && <Text style={[styles.detail, selected && styles.textSelected]}>{item.detail}</Text>}
<View style={styles.dot}>
{selected && <Icon name="check" size={12} color={theme.colors.chateauBlue} />}
</View>
</Pressable>
);
})}
</>
</View>
);
}
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',
},
});
+50 -10
View File
@@ -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<ViewStyle | TextStyle>;
};
const TextField = React.forwardRef<TextInput, TextFieldProps>(({ label, fieldStyle, style, ...rest }, ref) => (
<View style={styles.container}>
{label != null &&
(typeof label === 'string' || typeof label === 'number' ? <Text style={styles.label}>{label}</Text> : label)}
<TextInput ref={ref} style={[styles.input, fieldStyle, style]} {...rest} />
</View>
));
// 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<TextInput, TextFieldProps>(
({ label, fieldStyle, style, onFocus, onBlur, placeholderTextColor, ...rest }, ref) => {
const [focused, setFocused] = useState(false);
return (
<View style={styles.container}>
{label != null &&
(typeof label === 'string' || typeof label === 'number' ? <Text style={styles.label}>{label}</Text> : label)}
<TextInput
ref={ref}
style={[styles.input, focused && styles.inputFocused, fieldStyle, style]}
placeholderTextColor={placeholderTextColor ?? PLACEHOLDER_COLOR}
onFocus={(event) => {
setFocused(true);
onFocus?.(event);
}}
onBlur={(event) => {
setFocused(false);
onBlur?.(event);
}}
{...rest}
/>
</View>
);
},
);
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,
},
});
+3 -3
View File
@@ -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,
},
});
+23 -23
View File
@@ -94,9 +94,9 @@ const openMap = () => {
/** FIXME: Values here are pretty hardcoded. Should fetch config from a JSON-file or API */
const About = () => (
<ScrollView style={theme.background}>
<ScrollView>
<View style={theme.card}>
<Text style={[theme.text, styles.header, { marginTop: 8 }]}>Det Norske Studentersamfund</Text>
<Text style={[theme.text, styles.title]}>Det Norske Studentersamfund</Text>
<TouchableOpacity onPress={() => Linking.openURL(withAppSource('https://neuf.no/'))}>
<Image style={styles.logo} source={logo} />
</TouchableOpacity>
@@ -121,36 +121,37 @@ const About = () => (
Det Norske Studentersamfund{'\n'}Slemdalsveien 15, 0369 Oslo
</Text>
<View style={styles.section}>
<Button onPress={openMap} style={theme.button}>
<IconMap width={20} height={20} />
<Text style={theme.buttonText}> Vis i kart</Text>
<Button onPress={openMap} icon={<IconMap width={20} height={20} />}>
Vis i kart
</Button>
</View>
<Text style={[theme.text, styles.header]}>Åpningstider</Text>
<View style={styles.section}>
<Button
onPress={() => Linking.openURL(withAppSource('https://neuf.no/praktisk#apningstider'))}
style={theme.button}
icon={<IconClock width={20} height={20} />}
>
<IconClock width={20} height={20} style={styles.icon} />
<Text style={theme.buttonText}>Vis åpningstider</Text>
Vis åpningstider
</Button>
</View>
<Text style={[theme.text, styles.header]}>Ta kontakt</Text>
<View style={[styles.section, { flex: 1, flexDirection: 'row' }]}>
<View>
<Button onPress={() => Linking.openURL('tel:+4794430002')} style={[theme.button, { marginHorizontal: 10 }]}>
<IconPhone width={20} height={20} style={styles.icon} />
<Text style={theme.buttonText}>Ring</Text>
<Button
onPress={() => Linking.openURL('tel:+4794430002')}
style={{ marginHorizontal: 10 }}
icon={<IconPhone width={20} height={20} />}
>
Ring
</Button>
</View>
<View>
<Button
onPress={() => Linking.openURL('mailto:ledelsen@studentersamfundet.no')}
style={[theme.button, { marginHorizontal: 10 }]}
style={{ marginHorizontal: 10 }}
icon={<IconMail width={20} height={20} />}
>
<IconMail width={20} height={20} style={styles.icon} />
<Text style={theme.buttonText}>Send e-post</Text>
Send e-post
</Button>
</View>
</View>
@@ -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,
},
+5 -5
View File
@@ -49,7 +49,7 @@ it('formats the next occurrence as a Norwegian date with the venue', async () =>
const { findByText } = await renderWithProviders(<EventList />);
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(<EventList />);
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(<EventList />);
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(<EventList />);
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();
});
+13 -32
View File
@@ -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 = () => <View style={styles.listHeader} />;
@@ -51,7 +51,7 @@ const EventList = () => {
)}
</View>
<View style={styles.arrow}>
<Icon name="arrow-right" size={20} style={{ color: theme.colors.neufBlack }} />
<Icon name="arrow-right" size={20} style={{ color: theme.colors.black }} />
</View>
</View>
</TouchableOpacity>
@@ -64,9 +64,7 @@ const EventList = () => {
<Text style={styles.loadingText}>Kunne ikke hente programmet...</Text>
</View>
<View>
<Button onPress={handleRefresh}>
<Text>Prøv igjen</Text>
</Button>
<Button onPress={handleRefresh}>Prøv igjen</Button>
</View>
</View>
);
@@ -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,
+2 -1
View File
@@ -28,12 +28,13 @@ beforeEach(() => {
});
it('renders the login form', async () => {
const { getByText } = await renderWithProviders(<Login />);
const { getByText, getByTestId } = await renderWithProviders(<Login />);
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 () => {
+53 -46
View File
@@ -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 (
<ScrollView style={theme.background}>
<View style={theme.card}>
{errors && <FormErrors errors={errors} />}
<FormInput
name="email"
label="E-post / Brukernavn"
control={control}
errors={errors}
keyboardType="email-address"
autoFocus
autoCapitalize="none"
autoComplete="email"
autoCorrect={false}
onSubmitEditing={() => setFocus('password')}
/>
<FormInput
name="password"
label="Passord"
control={control}
errors={errors}
secureTextEntry
autoCapitalize="none"
autoCorrect={false}
autoComplete="current-password"
onSubmitEditing={onSubmit}
returnKeyType="send"
/>
<Button fullWidth onPress={onSubmit} style={[theme.button, styles.loginButton]}>
<Text style={theme.buttonText}>Logg inn</Text>
</Button>
{loginError && isSubmitted && <FormErrors errors={loginError} />}
{isLoggingIn && (
<ActivityIndicator accessible accessibilityRole="progressbar" color={theme.colors.neufOrange} />
)}
<Button
small
onPress={() => {
Linking.openURL(withAppSource(process.env.EXPO_PUBLIC_DUSKEN_FORGOT_PASSWORD_URL || ''));
}}
style={[theme.button, styles.forgotPasswordButton]}
>
<Text style={theme.buttonText}>Glemt passord?</Text>
</Button>
</View>
</ScrollView>
<View style={styles.screen}>
<KeyPig color={theme.colors.white} />
<ScrollView>
<View style={theme.card}>
{errors && <FormErrors errors={errors} />}
<FormInput
name="email"
label="E-post / Brukernavn"
control={control}
errors={errors}
keyboardType="email-address"
autoFocus
autoCapitalize="none"
autoComplete="email"
autoCorrect={false}
onSubmitEditing={() => setFocus('password')}
/>
<FormInput
name="password"
label="Passord"
control={control}
errors={errors}
secureTextEntry
autoCapitalize="none"
autoCorrect={false}
autoComplete="current-password"
onSubmitEditing={onSubmit}
returnKeyType="send"
/>
<Button variant="primary" fullWidth onPress={onSubmit} style={styles.loginButton}>
Logg inn
</Button>
{loginError && isSubmitted && <FormErrors errors={loginError} />}
{isLoggingIn && (
<ActivityIndicator accessible accessibilityRole="progressbar" color={theme.colors.goldenOrange} />
)}
<Button
small
onPress={() => {
Linking.openURL(withAppSource(process.env.EXPO_PUBLIC_DUSKEN_FORGOT_PASSWORD_URL || ''));
}}
style={styles.forgotPasswordButton}
>
Glemt passord?
</Button>
</View>
</ScrollView>
</View>
);
};
export default Login;
const styles = StyleSheet.create({
screen: {
flex: 1,
},
loginButton: {
marginVertical: 16,
},
+11 -4
View File
@@ -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 () => {
+2 -8
View File
@@ -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 (
<View style={theme.card}>
<ActivityIndicator accessible accessibilityRole="progressbar" color={theme.colors.neufOrange} />
<ActivityIndicator accessible accessibilityRole="progressbar" color={theme.colors.goldenOrange} />
</View>
);
}
@@ -25,11 +24,6 @@ const Membership = () => {
return <Proof fetchUser={fetchUser} isRefetching={isRefetching} />;
}
return (
<>
<MembershipInfo onRegister={onRegisterPress} onLogin={onLoginPress} />
<PrivacyButton />
</>
);
return <MembershipInfo onRegister={onRegisterPress} onLogin={onLoginPress} />;
};
export default Membership;
+30 -21
View File
@@ -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) => (
<ScrollView style={theme.background}>
<ScrollView>
<View style={theme.card}>
<Text style={[theme.text, styles.headerTitle]}>Hei 👋</Text>
<Text style={[theme.text, styles.paragraph]}>
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.
</Text>
<View style={styles.button}>
<Button onPress={onRegister} fullWidth style={theme.button}>
<Text style={theme.buttonText}>Bli medlem</Text>
<Button variant="primary" onPress={onRegister} fullWidth>
Bli medlem
</Button>
</View>
<Text style={[theme.text, styles.header]}>Hva får jeg?</Text>
<Text style={[theme.text, styles.paragraph, { textAlign: 'left', paddingLeft: 4 }]}>
- 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
</Text>
<Text style={[theme.text, styles.header]}>Medlemsfordeler</Text>
<Text style={[theme.text, styles.intro]}>Som medlem i Det Norske Studentersamfund får man:</Text>
<BulletList items={BENEFITS} />
<Text style={[theme.text, styles.em]}>Allerede medlem?</Text>
<View style={styles.button}>
<Button onPress={onLogin} fullWidth style={theme.button}>
<Text style={theme.buttonText}>Logg inn</Text>
<Button onPress={onLogin} fullWidth>
Logg inn
</Button>
</View>
</View>
<PrivacyButton />
</ScrollView>
);
@@ -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,
},
});
+9 -9
View File
@@ -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();
+29 -20
View File
@@ -37,7 +37,7 @@ const MembershipValidTo = ({ user }: { user: UserType }) => {
return (
<View style={{ alignItems: 'center', flex: 1 }}>
<Text style={theme.text}>Gyldig til: </Text>
<Text style={[theme.text, theme.typography.body]}>Gyldig til: </Text>
<Text style={[theme.text, styles.validToValue]}>{validTo}</Text>
</View>
);
@@ -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 = ({
<RadioButton data={options} onSelect={onSelect} defaultValue={defaultValue} />
</View>
{paymentLoading && (
<ActivityIndicator accessible accessibilityRole="progressbar" color={theme.colors.neufOrange} />
<ActivityIndicator accessible accessibilityRole="progressbar" color={theme.colors.goldenOrange} />
)}
<Button
variant="primary"
onPress={onPurchasePress}
fullWidth
disabled={paymentLoading || isLoadingMembershipType || membershipPrice == null}
style={[theme.button, styles.purchaseButton]}
style={styles.purchaseButton}
>
<Text style={theme.buttonText}>
{membershipPrice != null ? `Kjøp medlemskap (${membershipPrice} NOK)` : 'Kjøp medlemskap'}
</Text>
{membershipPrice != null ? `Kjøp medlemskap (${membershipPrice} kr)` : 'Kjøp medlemskap'}
</Button>
{membershipTypesError && (
<>
<Text style={[theme.text, styles.errorMessage]}>Kunne ikke hente priser</Text>
<Button small onPress={onRetryPress} style={[theme.button, styles.retryButton]}>
<Text style={theme.buttonText}>Prøv igjen</Text>
<Button small onPress={onRetryPress} style={styles.retryButton}>
Prøv igjen
</Button>
</>
)}
@@ -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) => {
<ScrollView
refreshControl={<RefreshControl testID="refresh-control" refreshing={isRefetching} onRefresh={fetchUser} />}
>
<View style={[theme.card, { paddingVertical: 16, backgroundColor: 'transparent' }]}>
<View style={styles.content}>
<MembershipName user={user} />
<Logo user={user} />
<MembershipStatus user={user} onMembershipPress={onMembershipPress} />
@@ -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,
},
});
+17 -41
View File
@@ -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 (
<ScrollView style={theme.background}>
<ScrollView>
<View style={theme.card}>
<Text style={styles.headerTitle}>Slett meg</Text>
<Text style={styles.paragraph}>
Dette sletter kontoen med brukernavn <Text style={styles.bold}>{user.username}</Text> inkludert all tilknyttet
persondata <Text style={styles.bold}>for alltid</Text>.
personinformasjon <Text style={styles.bold}>for alltid</Text>.
</Text>
<Text style={styles.paragraph}>Skriv inn brukernavnet ditt for å fortsette.</Text>
<TextInput
editable
numberOfLines={1}
<TextField
label="Brukernavn"
accessibilityLabel="Brukernavn"
maxLength={32}
autoCapitalize="none"
onChangeText={(text) => onChangeText(text)}
autoCorrect={false}
onChangeText={onChangeText}
value={value}
style={styles.input}
/>
{deleteUserError && <FormErrors errors={deleteUserError} />}
<Button
onPress={onPress}
disabled={isDisabled}
style={!isDisabled ? styles.deleteButton : styles.deleteButtonDisabled}
>
<Text style={!isDisabled ? styles.whiteText : styles.disabledText}>Jeg forstår, slett kontoen min</Text>
<Button onPress={onPress} disabled={isDisabled} style={styles.deleteButton} textStyle={styles.whiteText}>
Jeg forstår, slett kontoen min
</Button>
{isDeletingUser && (
<ActivityIndicator accessible accessibilityRole="progressbar" color={theme.colors.neufOrange} />
<ActivityIndicator accessible accessibilityRole="progressbar" color={theme.colors.goldenOrange} />
)}
</View>
</ScrollView>
@@ -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',
},
});
+7 -5
View File
@@ -23,10 +23,11 @@ const useProof = ({ fetchUser }: UseProofProps) => {
const [membershipTypeSlug, setMembershipTypeSlug] = useState('regular');
const [toast, setToast] = useState<ToastProps | null>(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,
+5 -14
View File
@@ -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 (
<>
<ScrollView style={theme.card}>
<ScrollView contentContainerStyle={theme.card}>
<FormInput
name="first_name"
label="Fornavn"
@@ -68,10 +68,10 @@ const UserRegister = () => {
/>
{registerError && isSubmitted && <FormErrors errors={registerError} />}
{isRegisteringUser && (
<ActivityIndicator accessible accessibilityRole="progressbar" color={theme.colors.neufOrange} />
<ActivityIndicator accessible accessibilityRole="progressbar" color={theme.colors.goldenOrange} />
)}
<Button fullWidth disabled={isDisabled} onPress={onSubmit} style={[theme.button, styles.registerButton]}>
<Text style={theme.buttonText}>Registrer meg</Text>
<Button variant="primary" fullWidth disabled={isDisabled} onPress={onSubmit} style={styles.registerButton}>
Registrer meg
</Button>
</ScrollView>
<PrivacyButton />
@@ -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,
},
+3 -3
View File
@@ -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}
/>
}
/>
+4 -6
View File
@@ -35,7 +35,7 @@ const Settings = () => {
</ListSection>
)}
<ListSection>
<ListRow label="Personvern" chevron accessibilityRole="link" onPress={onPrivacyPress} />
<ListRow label="Personvern" arrow accessibilityRole="link" onPress={onPrivacyPress} />
<ListRow label="Logg ut" onPress={onLogoutPress} />
</ListSection>
<ListSection>
@@ -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',
},
+29 -34
View File
@@ -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 };