This commit is contained in:
29 files changed
+561
-363
No files matched your search
+10
-4
@@ -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"
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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,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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
Reference in new issue
Block a user