Wire up start-session flow (variable-rate aware) + fix About dark mode
- New StartSession screen off MeterDetail: pick vehicle -> duration (from the live multi-estimate ladder, exact cost per option) -> card; detects a free prefix and shows "Free until X, then charged"; explicit Pay $X confirmation before charging via postStartParkingSession; schedules the local expiry reminder on success - About screen themed (was black-on-dark in dark mode) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
9a5c9d445f
commit
f044a9a511
4 changed files with 340 additions and 11 deletions
|
|
@ -20,12 +20,14 @@ import { ProfileScreen } from '@/screens/ProfileScreen';
|
||||||
import { VehiclesScreen } from '@/screens/VehiclesScreen';
|
import { VehiclesScreen } from '@/screens/VehiclesScreen';
|
||||||
import { PaymentMethodsScreen } from '@/screens/PaymentMethodsScreen';
|
import { PaymentMethodsScreen } from '@/screens/PaymentMethodsScreen';
|
||||||
import { NotificationsScreen } from '@/screens/NotificationsScreen';
|
import { NotificationsScreen } from '@/screens/NotificationsScreen';
|
||||||
|
import { StartSessionScreen } from '@/screens/StartSessionScreen';
|
||||||
import { useTheme } from '@/theme/ThemeContext';
|
import { useTheme } from '@/theme/ThemeContext';
|
||||||
import type { Zone } from 'parksmarter-client';
|
import type { Zone } from 'parksmarter-client';
|
||||||
|
|
||||||
export type RootStackParamList = {
|
export type RootStackParamList = {
|
||||||
Tabs: undefined;
|
Tabs: undefined;
|
||||||
MeterDetail: { zone: Zone };
|
MeterDetail: { zone: Zone };
|
||||||
|
StartSession: { zone: Zone };
|
||||||
About: undefined;
|
About: undefined;
|
||||||
Profile: undefined;
|
Profile: undefined;
|
||||||
Vehicles: undefined;
|
Vehicles: undefined;
|
||||||
|
|
@ -111,6 +113,11 @@ export function RootNavigator() {
|
||||||
component={MeterDetailScreen}
|
component={MeterDetailScreen}
|
||||||
options={{ title: 'Meter' }}
|
options={{ title: 'Meter' }}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="StartSession"
|
||||||
|
component={StartSessionScreen}
|
||||||
|
options={{ title: 'Start session' }}
|
||||||
|
/>
|
||||||
<Stack.Screen name="About" component={AboutScreen} options={{ title: 'About' }} />
|
<Stack.Screen name="About" component={AboutScreen} options={{ title: 'About' }} />
|
||||||
<Stack.Screen name="Profile" component={ProfileScreen} options={{ title: 'Profile' }} />
|
<Stack.Screen name="Profile" component={ProfileScreen} options={{ title: 'Profile' }} />
|
||||||
<Stack.Screen name="Vehicles" component={VehiclesScreen} options={{ title: 'Vehicles' }} />
|
<Stack.Screen name="Vehicles" component={VehiclesScreen} options={{ title: 'Vehicles' }} />
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,12 @@ import {
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { ps } from '@/api/client';
|
import { ps } from '@/api/client';
|
||||||
|
import { useTheme } from '@/theme/ThemeContext';
|
||||||
|
|
||||||
const TAPS_TO_UNLOCK = 7;
|
const TAPS_TO_UNLOCK = 7;
|
||||||
|
|
||||||
export function AboutScreen() {
|
export function AboutScreen() {
|
||||||
|
const { colors } = useTheme();
|
||||||
const [about, setAbout] = useState<string | null>(null);
|
const [about, setAbout] = useState<string | null>(null);
|
||||||
const [showJoel, setShowJoel] = useState(false);
|
const [showJoel, setShowJoel] = useState(false);
|
||||||
const taps = useRef(0);
|
const taps = useRef(0);
|
||||||
|
|
@ -37,14 +39,14 @@ export function AboutScreen() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView contentContainerStyle={styles.container}>
|
<ScrollView style={{ backgroundColor: colors.bg }} contentContainerStyle={styles.container}>
|
||||||
<Pressable onPress={onTitleTap}>
|
<Pressable onPress={onTitleTap}>
|
||||||
<Text style={styles.title}>About BigBrainParking</Text>
|
<Text style={[styles.title, { color: colors.text }]}>About BigBrainParking</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<Text style={styles.body}>{about ?? 'Loading…'}</Text>
|
<Text style={[styles.body, { color: colors.text }]}>{about ?? 'Loading…'}</Text>
|
||||||
|
|
||||||
<Text style={styles.meta}>
|
<Text style={[styles.meta, { color: colors.subtext }]}>
|
||||||
Unofficial, de-Googled client for ParkSmarter. Not affiliated with IPS Group.
|
Unofficial, de-Googled client for ParkSmarter. Not affiliated with IPS Group.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
import type { RouteProp } from '@react-navigation/native';
|
import type { RouteProp } from '@react-navigation/native';
|
||||||
import { useRoute } from '@react-navigation/native';
|
import { useNavigation, useRoute } from '@react-navigation/native';
|
||||||
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||||
import { saveKiosk, toSavedKiosk } from '@/features/favorites/favoritesStore';
|
import { saveKiosk, toSavedKiosk } from '@/features/favorites/favoritesStore';
|
||||||
import { useTheme } from '@/theme/ThemeContext';
|
import { useTheme } from '@/theme/ThemeContext';
|
||||||
|
|
@ -39,8 +40,11 @@ function policyMeta(p: SpacePolicy): { title: string; rate?: string; color?: str
|
||||||
return { title, rate, color };
|
return { title, rate, color };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
|
||||||
export function MeterDetailScreen() {
|
export function MeterDetailScreen() {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
|
const navigation = useNavigation<Nav>();
|
||||||
const { params } = useRoute<DetailRoute>();
|
const { params } = useRoute<DetailRoute>();
|
||||||
const z = params.zone;
|
const z = params.zone;
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
@ -122,12 +126,7 @@ export function MeterDetailScreen() {
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.button, { backgroundColor: colors.primary }]}
|
style={[styles.button, { backgroundColor: colors.primary }]}
|
||||||
onPress={() =>
|
onPress={() => navigation.navigate('StartSession', { zone: z })}
|
||||||
Alert.alert(
|
|
||||||
'Start session',
|
|
||||||
'Starting a paid session is a real charge — this flow (vehicle + duration + card selection) is wired to postStartParkingSession and will be enabled after review.',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Text style={[styles.buttonText, { color: '#fff' }]}>Start parking session</Text>
|
<Text style={[styles.buttonText, { color: '#fff' }]}>Start parking session</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
|
||||||
321
app/src/screens/StartSessionScreen.tsx
Normal file
321
app/src/screens/StartSessionScreen.tsx
Normal file
|
|
@ -0,0 +1,321 @@
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
FlatList,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import type { RouteProp } from '@react-navigation/native';
|
||||||
|
import { useNavigation, useRoute } from '@react-navigation/native';
|
||||||
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
|
import { ps } from '@/api/client';
|
||||||
|
import { useTheme } from '@/theme/ThemeContext';
|
||||||
|
import { scheduleExpiryReminder } from '@/notifications/localReminders';
|
||||||
|
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||||
|
import type {
|
||||||
|
CreditCardDetail,
|
||||||
|
ParkingDetail,
|
||||||
|
VehicleDetail,
|
||||||
|
} from 'parksmarter-client';
|
||||||
|
|
||||||
|
type SessionRoute = RouteProp<RootStackParamList, 'StartSession'>;
|
||||||
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
|
||||||
|
function fmtDuration(min?: number): string {
|
||||||
|
if (!min) return '';
|
||||||
|
const h = Math.floor(min / 60);
|
||||||
|
const m = min % 60;
|
||||||
|
return h ? (m ? `${h}h ${m}m` : `${h}h`) : `${m}m`;
|
||||||
|
}
|
||||||
|
const money = (v?: string | number) => `$${Number(v ?? 0).toFixed(2)}`;
|
||||||
|
|
||||||
|
/** Parse the API's "MM-DD-YYYY hh:mm AM" end-time string into a Date for reminders. */
|
||||||
|
function parseApiTime(s?: string): Date | null {
|
||||||
|
if (!s) return null;
|
||||||
|
const m = s.match(/(\d{2})-(\d{2})-(\d{4})\s+(\d{1,2}):(\d{2})\s*(AM|PM)/i);
|
||||||
|
if (!m) return null;
|
||||||
|
let hr = parseInt(m[4], 10);
|
||||||
|
const pm = /pm/i.test(m[6]);
|
||||||
|
if (pm && hr !== 12) hr += 12;
|
||||||
|
if (!pm && hr === 12) hr = 0;
|
||||||
|
return new Date(+m[3], +m[1] - 1, +m[2], hr, +m[5]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StartSessionScreen() {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const navigation = useNavigation<Nav>();
|
||||||
|
const { zone } = useRoute<SessionRoute>().params;
|
||||||
|
const space = zone.Spaces?.[0];
|
||||||
|
|
||||||
|
const [vehicles, setVehicles] = useState<VehicleDetail[]>([]);
|
||||||
|
const [cards, setCards] = useState<CreditCardDetail[]>([]);
|
||||||
|
const [vehicleId, setVehicleId] = useState<number | undefined>();
|
||||||
|
const [cardId, setCardId] = useState<number | undefined>();
|
||||||
|
const [ladder, setLadder] = useState<ParkingDetail[]>([]);
|
||||||
|
const [selIdx, setSelIdx] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [paying, setPaying] = useState(false);
|
||||||
|
|
||||||
|
// Load the account's vehicles + cards and pick the defaults.
|
||||||
|
useEffect(() => {
|
||||||
|
ps.getUserDetail()
|
||||||
|
.then((u) => {
|
||||||
|
const vs = u.VehicleDetails ?? [];
|
||||||
|
const cs = u.CreditCardDetails ?? [];
|
||||||
|
setVehicles(vs);
|
||||||
|
setCards(cs);
|
||||||
|
const defV = vs.find((v) => String(v.IsDefault) === 'true') ?? vs[0];
|
||||||
|
const defC = cs.find((c) => String(c.CCDefault) === 'true') ?? cs[0];
|
||||||
|
setVehicleId(defV?.VehicleID as number | undefined);
|
||||||
|
setCardId(defC?.CCID as number | undefined);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// (Re)fetch the price ladder whenever the vehicle changes — rates can vary by vehicle.
|
||||||
|
const loadLadder = useCallback(async () => {
|
||||||
|
if (vehicleId == null || space?.SpaceId == null) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const est = await ps.getParkingEstimateMulti({
|
||||||
|
zoneId: zone.ZoneId!,
|
||||||
|
spaceId: space.SpaceId!,
|
||||||
|
customerId: zone.CustomerId!,
|
||||||
|
vehicleId,
|
||||||
|
});
|
||||||
|
const details = est.ParkingDetail ?? [];
|
||||||
|
setLadder(details);
|
||||||
|
setSelIdx((i) => Math.min(i, Math.max(0, details.length - 1)));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [vehicleId, space?.SpaceId, zone.ZoneId, zone.CustomerId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadLadder();
|
||||||
|
}, [loadLadder]);
|
||||||
|
|
||||||
|
const selected = ladder[selIdx];
|
||||||
|
|
||||||
|
// Detect a free prefix (e.g., inside a "Free until 6:15 AM" policy window).
|
||||||
|
const freeInfo = useMemo(() => {
|
||||||
|
const firstPaid = ladder.findIndex((d) => Number(d.ParkingCost) > 0);
|
||||||
|
if (firstPaid > 0) {
|
||||||
|
return { until: ladder[firstPaid - 1]?.EndTime, minutes: ladder[firstPaid - 1]?.Minutes };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [ladder]);
|
||||||
|
|
||||||
|
const total = selected
|
||||||
|
? Number(selected.ParkingCost ?? 0) + Number(selected.TransactionFee ?? 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const onPay = () => {
|
||||||
|
if (!selected || cardId == null || vehicleId == null) {
|
||||||
|
Alert.alert('Missing info', 'Pick a vehicle, duration, and card first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Alert.alert(
|
||||||
|
'Confirm payment',
|
||||||
|
`Park at ${zone.ZoneName} for ${fmtDuration(selected.Minutes)}.\n\n` +
|
||||||
|
`Parking ${money(selected.ParkingCost)}` +
|
||||||
|
(Number(selected.TransactionFee) > 0 ? ` + fee ${money(selected.TransactionFee)}` : '') +
|
||||||
|
`\nTotal: ${money(total)}\n\nThis charges your card now.`,
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{ text: `Pay ${money(total)}`, style: 'default', onPress: doStart },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const doStart = async () => {
|
||||||
|
if (!selected) return;
|
||||||
|
setPaying(true);
|
||||||
|
try {
|
||||||
|
const res = await ps.startParkingSession({
|
||||||
|
creditCardId: cardId!,
|
||||||
|
vehicleId: vehicleId!,
|
||||||
|
zoneId: zone.ZoneId!,
|
||||||
|
spaceId: space!.SpaceId!,
|
||||||
|
customerId: zone.CustomerId!,
|
||||||
|
startTime: selected.StartTime!,
|
||||||
|
endTime: selected.EndTime!,
|
||||||
|
minutesToPurchase: selected.Minutes!,
|
||||||
|
parkingCost: Number(selected.ParkingCost ?? 0),
|
||||||
|
transactionFee: Number(selected.TransactionFee ?? 0),
|
||||||
|
minCreditAmount: zone.MinimumAmount,
|
||||||
|
meterTypeId: zone.MeterTypeId!,
|
||||||
|
});
|
||||||
|
// Schedule the local expiry reminder from the purchased end time.
|
||||||
|
const end = parseApiTime(selected.EndTime);
|
||||||
|
if (end) {
|
||||||
|
await scheduleExpiryReminder({
|
||||||
|
transactionId: (res as any)?.TransactionID ?? Date.now(),
|
||||||
|
zoneName: zone.ZoneName ?? 'Parking',
|
||||||
|
endTime: end,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Alert.alert('Parked!', `Session started at ${zone.ZoneName}.`, [
|
||||||
|
{ text: 'OK', onPress: () => navigation.navigate('Tabs') },
|
||||||
|
]);
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Could not start session', e?.serverMessage ?? e?.message ?? 'error');
|
||||||
|
} finally {
|
||||||
|
setPaying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && !ladder.length) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.center, { backgroundColor: colors.bg }]}>
|
||||||
|
<ActivityIndicator color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Chip = ({
|
||||||
|
active,
|
||||||
|
label,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
label: string;
|
||||||
|
onPress: () => void;
|
||||||
|
}) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPress}
|
||||||
|
style={[
|
||||||
|
styles.chip,
|
||||||
|
{ borderColor: active ? colors.primary : colors.border, backgroundColor: active ? colors.primary : colors.card },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={{ color: active ? '#fff' : colors.text, fontWeight: '600' }}>{label}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={{ backgroundColor: colors.bg }} contentContainerStyle={{ padding: 16 }}>
|
||||||
|
<Text style={[styles.zone, { color: colors.text }]}>{zone.ZoneName}</Text>
|
||||||
|
<Text style={[styles.sub, { color: colors.subtext }]}>
|
||||||
|
{zone.ZoneLocation ?? space?.SpaceName ?? ''}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text style={[styles.label, { color: colors.subtext }]}>Vehicle</Text>
|
||||||
|
<View style={styles.chipRow}>
|
||||||
|
{vehicles.map((v) => (
|
||||||
|
<Chip
|
||||||
|
key={String(v.VehicleID)}
|
||||||
|
active={v.VehicleID === vehicleId}
|
||||||
|
label={`${v.VehicleAlias || v.VehiclePlate} `}
|
||||||
|
onPress={() => setVehicleId(v.VehicleID as number)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={[styles.label, { color: colors.subtext }]}>Duration</Text>
|
||||||
|
<FlatList
|
||||||
|
horizontal
|
||||||
|
data={ladder}
|
||||||
|
keyExtractor={(d, i) => String(d.Minutes ?? i)}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
renderItem={({ item, index }) => (
|
||||||
|
<Chip
|
||||||
|
active={index === selIdx}
|
||||||
|
label={`${fmtDuration(item.Minutes)} · ${money(item.ParkingCost)}`}
|
||||||
|
onPress={() => setSelIdx(index)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={[styles.label, { color: colors.subtext }]}>Card</Text>
|
||||||
|
<View style={styles.chipRow}>
|
||||||
|
{cards.map((c) => (
|
||||||
|
<Chip
|
||||||
|
key={String(c.CCID)}
|
||||||
|
active={c.CCID === cardId}
|
||||||
|
label={`${c.CCAlias || 'Card'} ••${c.CCLastFour ?? ''}`}
|
||||||
|
onPress={() => setCardId(c.CCID as number)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{cards.length === 0 ? (
|
||||||
|
<Text style={{ color: colors.subtext }}>No cards on file.</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{freeInfo ? (
|
||||||
|
<View style={[styles.freeBanner, { backgroundColor: '#e8f5e9' }]}>
|
||||||
|
<Text style={{ color: '#2e7d32', fontWeight: '600' }}>
|
||||||
|
Free until {freeInfo.until} ({fmtDuration(freeInfo.minutes)}), then charged.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{selected ? (
|
||||||
|
<View style={[styles.quote, { backgroundColor: colors.card }]}>
|
||||||
|
<Row label="Duration" value={fmtDuration(selected.Minutes)} colors={colors} />
|
||||||
|
<Row label="Ends" value={selected.EndTime ?? ''} colors={colors} />
|
||||||
|
<Row label="Parking" value={money(selected.ParkingCost)} colors={colors} />
|
||||||
|
{Number(selected.TransactionFee) > 0 ? (
|
||||||
|
<Row label="Fee" value={money(selected.TransactionFee)} colors={colors} />
|
||||||
|
) : null}
|
||||||
|
<Row label="Total" value={money(total)} bold colors={colors} />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.payBtn, { backgroundColor: colors.primary, opacity: paying ? 0.6 : 1 }]}
|
||||||
|
disabled={paying || !selected}
|
||||||
|
onPress={onPay}
|
||||||
|
>
|
||||||
|
<Text style={styles.payText}>
|
||||||
|
{paying ? 'Starting…' : `Pay ${money(total)} & start`}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
bold,
|
||||||
|
colors,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
bold?: boolean;
|
||||||
|
colors: { text: string; subtext: string };
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={styles.quoteRow}>
|
||||||
|
<Text style={{ color: colors.subtext }}>{label}</Text>
|
||||||
|
<Text style={{ color: colors.text, fontWeight: bold ? '800' : '600', fontSize: bold ? 18 : 15 }}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
center: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
zone: { fontSize: 24, fontWeight: '700' },
|
||||||
|
sub: { marginBottom: 8 },
|
||||||
|
label: { fontSize: 13, fontWeight: '600', marginTop: 18, marginBottom: 8 },
|
||||||
|
chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
||||||
|
chip: {
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderRadius: 20,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 9,
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
freeBanner: { borderRadius: 10, padding: 12, marginTop: 16 },
|
||||||
|
quote: { borderRadius: 12, padding: 16, marginTop: 16 },
|
||||||
|
quoteRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 5 },
|
||||||
|
payBtn: { borderRadius: 12, padding: 18, alignItems: 'center', marginTop: 20 },
|
||||||
|
payText: { color: '#fff', fontWeight: '800', fontSize: 17 },
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue