clear last tickets

This commit is contained in:
Thomas Demirdjian
2025-11-25 14:58:06 +01:00
parent 72852ad92b
commit ee9cc5dccd
31 changed files with 1009 additions and 531 deletions
+989
View File
@@ -0,0 +1,989 @@
import React from "react";
import {
ActivityIndicator,
FlatList,
Pressable,
StyleSheet,
Text,
View,
} from "react-native";
import { useRoute } from "@react-navigation/native";
import { BlurView } from "expo-blur";
import { Image as ExpoImage } from "expo-image";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import GradientButton from "../components/GradientButton";
import CreditAmount from "../components/CreditAmount";
import Page from "../layouts/Page";
import { background, icons, subBadges } from "../assets";
import { Palette, gutters } from "../styles";
import { FONT_FAMILY } from "../styles/Fonts";
import { isWeb } from "../hooks/useLayoutType";
import { useStripe } from "../providers/StripeProvider";
import { goBack } from "../navigation/NavigationService";
const formatCurrency = (amount, currency = "eur") => {
if (typeof amount !== "number") {
return null;
}
const normalized = amount / 100;
const upperCurrency =
typeof currency === "string" && currency.trim()
? currency.trim().toUpperCase()
: "EUR";
if (typeof Intl !== "undefined" && Intl.NumberFormat) {
try {
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: upperCurrency,
minimumFractionDigits: 2,
}).format(normalized);
} catch (_error) {
// Ignore format error and fall back to manual format.
}
}
return `${normalized.toFixed(2)} ${upperCurrency}`;
};
const getIntervalLabel = (recurring) => {
if (!recurring || typeof recurring !== "object") {
return null;
}
const interval = recurring.interval || "month";
const count = recurring.interval_count || 1;
const vocabulary = {
day: { singular: "jour", plural: "jours" },
week: { singular: "semaine", plural: "semaines" },
month: { singular: "mois", plural: "mois" },
year: { singular: "an", plural: "ans" },
};
const terms = vocabulary[interval] || vocabulary.month;
if (count <= 1) {
return `par ${terms.singular}`;
}
return `tous les ${count} ${count > 1 ? terms.plural : terms.singular}`;
};
function SubscriptionCard({ plan, selected, onSelect }) {
const planName =
plan?.product?.name || plan?.nickname || plan?.priceId || "Abonnement";
const planDescription = plan?.product?.description || "";
const formattedPrice = formatCurrency(plan?.unitAmount, plan?.currency);
const intervalLabel = getIntervalLabel(plan?.recurring);
const coinsPerMonth =
typeof plan?.coinsPerMonth === "number" &&
Number.isFinite(plan.coinsPerMonth)
? Math.round(plan.coinsPerMonth)
: null;
const songsPerMonth =
coinsPerMonth !== null
? Math.floor(coinsPerMonth / CREDITS_PER_MUSIC)
: null;
const songsPerMonthLabel =
songsPerMonth === null
? null
: songsPerMonth >= 1
? `${songsPerMonth} ${
songsPerMonth > 1 ? "musiques" : "musique"
} / mois`
: "Moins d'une musique / mois";
const planBadgeKey = getPlanKeyForBadge(plan);
const planBadgeSource =
planBadgeKey && subBadges[planBadgeKey] ? subBadges[planBadgeKey] : null;
const handleSelect = React.useCallback(() => {
if (typeof onSelect === "function" && plan?.priceId) {
onSelect(plan.priceId);
}
}, [onSelect, plan?.priceId]);
return (
<Pressable
onPress={handleSelect}
style={({ pressed }) => [
styles.cardWrapper,
selected && styles.cardWrapperSelected,
pressed && styles.cardWrapperPressed,
plan?.active === false && styles.cardWrapperInactive,
]}
accessibilityRole="button"
accessibilityState={{ selected, disabled: plan?.active === false }}
disabled={plan?.active === false}
>
<BlurView intensity={18} tint="dark" style={styles.cardBlur}>
<View style={styles.cardContent}>
{plan?.badge ? (
<View style={styles.badge}>
<Text style={styles.badgeText}>{plan.badge}</Text>
</View>
) : null}
<View style={styles.cardHeader}>
<View style={styles.titleRow}>
<Text style={styles.planName}>{planName}</Text>
{planBadgeSource ? (
<ExpoImage
source={planBadgeSource}
style={styles.planBadgeImage}
contentFit="contain"
/>
) : null}
</View>
{plan?.nickname ? (
<Text style={styles.cardSubtitle}>{plan.nickname}</Text>
) : null}
</View>
{formattedPrice ? (
<View style={styles.priceBlock}>
<View style={styles.priceRow}>
<Text style={styles.priceValue}>{formattedPrice}</Text>
{intervalLabel ? (
<Text style={styles.period}>{intervalLabel}</Text>
) : null}
</View>
{coinsPerMonth !== null ? (
<View style={styles.coinsPerMonthRow}>
<CreditAmount
value={coinsPerMonth}
showPlus
textStyle={styles.coinsPerMonth}
iconSize={16}
accessibilityLabel={`+${coinsPerMonth} pièces par mois`}
/>
<Text style={styles.coinsPerMonthSuffix}>/ mois</Text>
</View>
) : null}
{songsPerMonthLabel ? (
<Text style={styles.songsPerMonth}>{songsPerMonthLabel}</Text>
) : null}
</View>
) : null}
{planDescription ? (
<Text style={styles.description}>{planDescription}</Text>
) : null}
</View>
</BlurView>
</Pressable>
);
}
const PRICE_PRIORITY_BY_PERIOD = {
monthly: [
"price_1SPgitCzf2o5bDRdbnhLFx6f", // Starter
"price_1SPgjCCzf2o5bDRdr08Xzp8u", // Pro
"price_1SPgjaCzf2o5bDRdd9Xo2u26", // Premium
],
annual: [
"price_1SPgkDCzf2o5bDRdNGLVNeQ3", // Starter
"price_1SPgkXCzf2o5bDRdejBVxEBY", // Pro
"price_1SPgkqCzf2o5bDRdIcUwTDrm", // Premium
],
};
const PACK_PRICE_ID_BY_PERIOD = {
monthly: {
starter: "price_1SPgitCzf2o5bDRdbnhLFx6f",
pro: "price_1SPgjCCzf2o5bDRdr08Xzp8u",
prop: "price_1SPgjCCzf2o5bDRdr08Xzp8u",
premium: "price_1SPgjaCzf2o5bDRdd9Xo2u26",
},
annual: {
starter: "price_1SPgkDCzf2o5bDRdNGLVNeQ3",
pro: "price_1SPgkXCzf2o5bDRdejBVxEBY",
prop: "price_1SPgkXCzf2o5bDRdejBVxEBY",
premium: "price_1SPgkqCzf2o5bDRdIcUwTDrm",
},
};
const PRICE_ID_TO_PLAN_KEY = {};
Object.values(PACK_PRICE_ID_BY_PERIOD).forEach((mapping) => {
Object.entries(mapping).forEach(([planKey, priceId]) => {
if (!priceId) {
return;
}
PRICE_ID_TO_PLAN_KEY[priceId] = planKey === "prop" ? "pro" : planKey;
});
});
const PLAN_FALLBACK_ORDER = ["starter", "pro", "premium"];
const PLAN_SYNONYMS = {
starter: ["starter"],
pro: ["pro", "prop"],
premium: ["premium"],
};
const getPlanKeyForBadge = (plan) => {
if (!plan || typeof plan !== "object") {
return null;
}
const priceId =
typeof plan?.priceId === "string" ? plan.priceId : plan?.id || null;
if (priceId && PRICE_ID_TO_PLAN_KEY[priceId]) {
return PRICE_ID_TO_PLAN_KEY[priceId];
}
const label = (plan?.product?.name || plan?.nickname || "")
.toString()
.toLowerCase();
if (!label) {
return null;
}
const match = Object.entries(PLAN_SYNONYMS).find(([, variants]) =>
variants.some((variant) => label.includes(variant))
);
return match ? match[0] : null;
};
const getPlanPriority = (plan, periodKey) => {
const priceId =
typeof plan?.priceId === "string" ? plan.priceId : plan?.id || null;
const priceOrder = PRICE_PRIORITY_BY_PERIOD[periodKey] || [];
if (priceId) {
const priceIndex = priceOrder.indexOf(priceId);
if (priceIndex !== -1) {
return priceIndex;
}
}
const label = (plan?.product?.name || plan?.nickname || "")
.toString()
.toLowerCase();
const fallbackIndex = PLAN_FALLBACK_ORDER.findIndex((candidate) => {
const variants = PLAN_SYNONYMS[candidate] || [candidate];
return variants.some((variant) => label.includes(variant));
});
const baseOffset = priceOrder.length;
return fallbackIndex === -1
? baseOffset + PLAN_FALLBACK_ORDER.length
: baseOffset + fallbackIndex;
};
const normalizePlans = (plans = [], periodKey) => {
if (!Array.isArray(plans)) {
return [];
}
return plans
.filter((plan) => plan && typeof plan === "object" && plan.priceId)
.map((plan) => ({
...plan,
badge: null,
}))
.sort(
(planA, planB) =>
getPlanPriority(planA, periodKey) - getPlanPriority(planB, periodKey)
);
};
const HERO_IMAGE_WIDTH = isWeb ? 1280 : 700;
const HERO_IMAGE_HEIGHT = isWeb ? 760 : 360;
const PAGE_BACKGROUND_COLOR = "#303438";
const SUBSCRIPTION_DISCLAIMER =
"Résiliable en un clic à tout moment. Les prix sont indiqués TTC. Contacte-nous pour des besoins spécifiques (facturation annuelle, volume, offres éducation).";
const CARD_MIN_HEIGHT = isWeb ? 320 : 240;
const CREDITS_PER_MUSIC = 8;
export default function Subscriptions() {
const route = useRoute();
const insets = useSafeAreaInsets();
const isMobile = !isWeb;
const initialPack = React.useMemo(() => {
const rawPack = route?.params?.pack;
return typeof rawPack === "string" ? rawPack.toLowerCase() : null;
}, [route?.params?.pack]);
const initialSubscriptionPack =
initialPack && initialPack !== "packs" ? initialPack : null;
const {
subscriptions,
isCatalogLoading,
catalogError,
createSubscriptionCheckout,
} = useStripe();
const [selectedPriceId, setSelectedPriceId] = React.useState(null);
const [processingPriceId, setProcessingPriceId] = React.useState(null);
const [errorMessage, setErrorMessage] = React.useState(null);
const initialPackHandledRef = React.useRef(false);
const normalizedPlans = React.useMemo(
() => normalizePlans(subscriptions?.annual, "annual"),
[subscriptions?.annual]
);
const hasAnyPlan = (normalizedPlans?.length || 0) > 0;
const isLoadingPlans = isCatalogLoading && !hasAnyPlan;
const combinedErrorMessage = errorMessage || catalogError;
React.useEffect(() => {
initialPackHandledRef.current = false;
}, [initialSubscriptionPack]);
React.useEffect(() => {
const shouldApplyPack =
Boolean(initialSubscriptionPack) && !initialPackHandledRef.current;
let matchedPriceId = null;
if (shouldApplyPack && normalizedPlans?.length) {
const desired = initialSubscriptionPack.trim().toLowerCase();
const candidateId = PACK_PRICE_ID_BY_PERIOD?.annual?.[desired];
if (candidateId) {
const exists = normalizedPlans.some(
(plan) => plan?.priceId === candidateId
);
if (exists) {
matchedPriceId = candidateId;
}
}
if (!matchedPriceId) {
const matched = normalizedPlans.find((plan) => {
const label = (plan?.product?.name || plan?.nickname || "")
.toString()
.toLowerCase();
return label.includes(desired);
});
if (matched?.priceId) {
matchedPriceId = matched.priceId;
}
}
}
setSelectedPriceId((current) => {
if (matchedPriceId) {
return matchedPriceId;
}
const hasCurrent = normalizedPlans?.some(
(plan) => plan.priceId === current
);
if (hasCurrent) {
return current;
}
return normalizedPlans?.[0]?.priceId || null;
});
if (matchedPriceId || (shouldApplyPack && !isCatalogLoading)) {
initialPackHandledRef.current = true;
}
}, [
initialSubscriptionPack,
isCatalogLoading,
normalizedPlans,
]);
const currentPlans = normalizedPlans || [];
const handleSelect = React.useCallback(
(priceId) => {
setSelectedPriceId(priceId);
setProcessingPriceId(null);
},
[]
);
const handleCheckout = React.useCallback(async () => {
if (!selectedPriceId) {
return;
}
setProcessingPriceId(selectedPriceId);
setErrorMessage(null);
try {
await createSubscriptionCheckout(selectedPriceId);
} catch (error) {
console.error("[Subscriptions] checkout error", error);
setErrorMessage(
error?.message ||
"Une erreur est survenue lors de la création de la session Stripe."
);
} finally {
setProcessingPriceId(null);
}
}, [selectedPriceId, createSubscriptionCheckout]);
const isProcessing = Boolean(processingPriceId);
const isActionDisabled = !selectedPriceId || isProcessing || isLoadingPlans;
const actionButtonTitle = isProcessing
? "Redirection..."
: "Choisir cet abonnement";
const currentPlanCount = currentPlans.length;
const mobileActionSafePadding = React.useMemo(
() => Math.max(insets.bottom, gutters),
[insets.bottom]
);
const mobileListContentInset = React.useMemo(
() => ({
paddingBottom: mobileActionSafePadding + gutters * 3,
}),
[mobileActionSafePadding]
);
const handleBackPress = React.useCallback(() => {
goBack();
}, []);
const renderHeaderSection = React.useCallback(() => {
return (
<>
<View style={styles.header}>
<Text style={styles.title}>
Choisissez labonnement qui vous correspond
</Text>
</View>
{combinedErrorMessage ? (
<Text style={styles.errorText}>{combinedErrorMessage}</Text>
) : null}
</>
);
}, [combinedErrorMessage]);
const renderSegmentedControl = React.useCallback(() => null, []);
const renderMobilePlanItem = React.useCallback(
({ item }) => (
<View style={styles.mobileCard}>
<SubscriptionCard
plan={item}
selected={selectedPriceId === item.priceId}
onSelect={handleSelect}
/>
</View>
),
[handleSelect, selectedPriceId]
);
const renderMobileEmptyComponent = React.useCallback(() => {
return (
<View style={styles.mobileEmptyWrapper}>
{isLoadingPlans ? (
<ActivityIndicator color={Palette.white} />
) : (
<Text style={styles.emptyState}>
Aucun abonnement Stripe disponible pour le moment.
</Text>
)}
</View>
);
}, [isLoadingPlans]);
const renderMobileFooterComponent = React.useCallback(() => {
return (
<View style={styles.mobileFooter}>
{isLoadingPlans && currentPlanCount > 0 ? (
<View style={styles.inlineLoader}>
<ActivityIndicator color={Palette.white} size="small" />
</View>
) : null}
<Text style={styles.disclaimer}>{SUBSCRIPTION_DISCLAIMER}</Text>
</View>
);
}, [currentPlanCount, isLoadingPlans]);
const renderMobileTopSticky = React.useCallback(() => {
return (
<View style={styles.mobileStickyHeader}>
{renderHeaderSection()}
{renderSegmentedControl()}
</View>
);
}, [renderHeaderSection, renderSegmentedControl]);
const renderMobileBottomActions = React.useCallback(() => {
return (
<View
style={[
styles.mobileActions,
{ paddingBottom: mobileActionSafePadding },
]}
>
<GradientButton
title={actionButtonTitle}
onPress={handleCheckout}
disabled={isActionDisabled}
gradientStyle={[
styles.actionButtonGradient,
styles.mobileActionButtonGradient,
]}
/>
</View>
);
}, [
actionButtonTitle,
handleCheckout,
isActionDisabled,
mobileActionSafePadding,
]);
const renderWebBackButton = React.useCallback(() => {
if (isMobile) return null;
return (
<View style={styles.webBackContainer}>
<Pressable
accessibilityRole="button"
onPress={handleBackPress}
style={styles.webBackButton}
>
<ExpoImage
source={icons.chevronDown}
contentFit="contain"
style={styles.webBackIcon}
/>
<Text style={styles.webBackText}>Retour</Text>
</Pressable>
</View>
);
}, [handleBackPress, isMobile]);
return (
<View style={styles.root}>
<Page
headerType="NAVIGATE"
title="Abonnements"
scrollEnabled={false}
width={isWeb ? 960 : undefined}
containerStyle={styles.page}
contentContainerStyle={[
styles.pageContent,
isMobile && styles.pageContentMobile,
]}
backgroundColor={PAGE_BACKGROUND_COLOR}
hideBackButton={!isMobile}
topStickyContent={isMobile ? renderMobileTopSticky : renderWebBackButton}
>
<View style={[styles.inner, isMobile && styles.mobileInner]}>
<ExpoImage
source={background.bgTrans}
contentFit="cover"
style={styles.centerImage}
/>
<View style={[styles.content, isMobile && styles.mobileContent]}>
{isMobile ? (
<FlatList
data={currentPlans}
keyExtractor={(plan) => plan.priceId}
renderItem={renderMobilePlanItem}
style={styles.mobileList}
contentContainerStyle={[
styles.mobileListContent,
mobileListContentInset,
]}
ListEmptyComponent={renderMobileEmptyComponent}
ListFooterComponent={renderMobileFooterComponent}
showsVerticalScrollIndicator={false}
/>
) : (
<>
{renderHeaderSection()}
{isLoadingPlans && currentPlanCount === 0 ? (
<View style={styles.loaderContainer}>
<ActivityIndicator color={Palette.white} />
</View>
) : null}
{!isLoadingPlans && currentPlanCount === 0 ? (
<Text style={styles.emptyState}>
Aucun abonnement Stripe disponible pour le moment.
</Text>
) : null}
{renderSegmentedControl()}
<View style={[styles.packs, isWeb && styles.packsWeb]}>
{currentPlans.map((plan) => (
<SubscriptionCard
key={plan.priceId}
plan={plan}
selected={selectedPriceId === plan.priceId}
onSelect={handleSelect}
/>
))}
</View>
{isLoadingPlans && currentPlanCount > 0 ? (
<View style={styles.inlineLoader}>
<ActivityIndicator color={Palette.white} size="small" />
</View>
) : null}
<View style={styles.actions}>
<GradientButton
title={actionButtonTitle}
onPress={handleCheckout}
disabled={isActionDisabled}
gradientStyle={styles.actionButtonGradient}
/>
</View>
<Text style={styles.disclaimer}>{SUBSCRIPTION_DISCLAIMER}</Text>
</>
)}
</View>
</View>
</Page>
{isMobile ? renderMobileBottomActions() : null}
</View>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: PAGE_BACKGROUND_COLOR,
},
page: {
backgroundColor: "transparent",
},
pageContent: {
flexGrow: 1,
paddingTop: 48,
paddingBottom: 48,
},
pageContentMobile: {
paddingTop: gutters,
paddingBottom: gutters * 2,
},
inner: {
flex: 1,
width: "100%",
maxWidth: 960,
alignSelf: "center",
alignItems: "center",
justifyContent: "center",
paddingHorizontal: gutters,
position: "relative",
},
mobileInner: {
alignItems: "stretch",
justifyContent: "flex-start",
},
content: {
width: "100%",
gap: 32,
alignItems: "center",
justifyContent: "center",
position: "relative",
zIndex: 1,
},
mobileContent: {
flex: 1,
gap: 0,
alignItems: "stretch",
justifyContent: "flex-start",
},
centerImage: {
width: HERO_IMAGE_WIDTH,
height: HERO_IMAGE_HEIGHT,
borderRadius: 28,
overflow: "hidden",
position: "absolute",
top: "50%",
left: "50%",
transform: [
{ translateX: -HERO_IMAGE_WIDTH / 2 },
{ translateY: -HERO_IMAGE_HEIGHT / 2 },
],
pointerEvents: "none",
},
header: {
gap: 12,
alignItems: "center",
},
webBackContainer: {
alignSelf: "flex-start",
marginBottom: 12,
},
webBackButton: {
flexDirection: "row",
alignItems: "center",
gap: 8,
alignSelf: "flex-start",
paddingHorizontal: 14,
paddingVertical: 7,
borderRadius: 999,
borderWidth: 1,
borderColor: Palette.ultraLightWhite,
backgroundColor: Palette.ultraLightWhite,
},
webBackIcon: {
width: 16,
height: 16,
transform: [{ rotate: "90deg" }],
},
webBackText: {
fontSize: 14,
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
},
title: {
fontFamily: FONT_FAMILY.InterBold,
fontSize: isWeb ? 32 : 24,
lineHeight: isWeb ? 40 : 28,
color: Palette.white,
textAlign: "center",
},
errorText: {
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 14,
color: Palette.red,
textAlign: "center",
},
loaderContainer: {
alignItems: "center",
},
emptyState: {
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 14,
color: "rgba(255, 255, 255, 0.7)",
textAlign: "center",
},
packs: {
width: "100%",
maxWidth: 960,
gap: gutters,
alignItems: "center",
},
packsWeb: {
flexDirection: "row",
gap: gutters,
alignItems: "stretch",
justifyContent: "center",
},
segmentedControl: {
flexDirection: "row",
alignSelf: "center",
justifyContent: "center",
padding: 4,
borderRadius: 999,
backgroundColor: "rgba(255, 255, 255, 0.08)",
marginTop: isWeb ? 12 : 8,
marginBottom: isWeb ? 8 : 4,
},
segmentButton: {
paddingVertical: 8,
paddingHorizontal: 18,
borderRadius: 999,
},
segmentButtonActive: {
backgroundColor: "rgba(255, 255, 255, 0.18)",
},
segmentLabel: {
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 14,
color: "rgba(255, 255, 255, 0.7)",
},
segmentLabelActive: {
color: Palette.white,
},
actions: {
width: "100%",
alignItems: "center",
},
actionButtonGradient: {
width: isWeb ? 320 : "90%",
},
cardWrapper: {
flex: 1,
width: "100%",
minWidth: 0,
minHeight: CARD_MIN_HEIGHT,
borderRadius: 24,
overflow: "hidden",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.12)",
backgroundColor: "rgba(12, 14, 18, 0.45)",
},
cardWrapperSelected: {
borderColor: Palette.primary,
shadowColor: "#000",
shadowOffset: { width: 0, height: 12 },
shadowOpacity: 0.35,
shadowRadius: 20,
elevation: 8,
},
cardWrapperPressed: {
transform: [{ scale: 0.98 }],
},
cardWrapperInactive: {
opacity: 0.6,
},
cardBlur: {
flex: 1,
paddingHorizontal: gutters,
paddingVertical: isWeb ? gutters : Math.max(gutters * 0.2, 10),
gap: isWeb ? 18 : 10,
justifyContent: "center",
backgroundColor: "rgba(48, 52, 56, 0.55)",
borderRadius: 24,
},
cardContent: {
gap: isWeb ? 16 : 4,
},
cardHeader: {
gap: 6,
},
titleRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
width: "100%",
},
planName: {
fontFamily: FONT_FAMILY.InterBold,
fontSize: isWeb ? 24 : 20,
color: Palette.white,
flexShrink: 1,
},
planBadgeImage: {
width: 48,
height: 48,
flexShrink: 0,
},
cardSubtitle: {
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 14,
lineHeight: 20,
color: "rgba(255, 255, 255, 0.75)",
},
priceBlock: {
gap: isWeb ? 4 : 1,
},
priceRow: {
flexDirection: "row",
alignItems: "baseline",
gap: 8,
},
coinsPerMonthRow: {
flexDirection: "row",
alignItems: "center",
gap: 4,
},
coinsPerMonth: {
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 13,
color: Palette.primary,
},
coinsPerMonthSuffix: {
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 13,
color: Palette.primary,
},
songsPerMonth: {
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 12,
color: "rgba(255, 255, 255, 0.75)",
},
priceValue: {
fontFamily: FONT_FAMILY.InterBold,
fontSize: isWeb ? 22 : 20,
color: Palette.white,
},
period: {
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 14,
color: "rgba(255, 255, 255, 0.7)",
},
description: {
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 14,
lineHeight: 22,
color: Palette.white,
},
badge: {
alignSelf: "flex-start",
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 999,
backgroundColor: "rgba(112, 35, 247, 0.25)",
},
badgeText: {
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 12,
color: Palette.primary,
textTransform: "uppercase",
},
inlineLoader: {
alignItems: "center",
},
disclaimer: {
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 12,
color: "rgba(255, 255, 255, 0.6)",
lineHeight: 18,
textAlign: "center",
},
mobileStickyHeader: {
width: "100%",
gap: 6,
paddingBottom: gutters * 0.2,
backgroundColor: PAGE_BACKGROUND_COLOR,
alignItems: "center",
},
segmentedControlMobile: {
alignSelf: "center",
marginBottom: 0,
},
mobileList: {
flex: 1,
width: "100%",
},
mobileListContent: {
flexGrow: 1,
paddingTop: 0,
paddingBottom: 0,
},
mobileCard: {
marginBottom: gutters * 0.6,
},
mobileEmptyWrapper: {
paddingVertical: gutters * 2,
alignItems: "center",
justifyContent: "center",
},
mobileFooter: {
width: "100%",
paddingTop: gutters,
gap: gutters,
alignItems: "center",
},
mobileActions: {
position: "absolute",
left: 0,
right: 0,
bottom: 0,
zIndex: 10,
paddingHorizontal: gutters,
paddingTop: gutters * 0.4,
backgroundColor: PAGE_BACKGROUND_COLOR,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: "rgba(255, 255, 255, 0.12)",
shadowColor: "#000",
shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.25,
shadowRadius: 12,
elevation: 12,
},
mobileActionButtonGradient: {
width: "100%",
},
});