1916 lines
67 KiB
TypeScript
1916 lines
67 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
|
import type { CSSProperties } from 'react';
|
|
import {
|
|
IonAvatar,
|
|
IonButton,
|
|
IonButtons,
|
|
IonContent,
|
|
IonHeader,
|
|
IonIcon,
|
|
IonInput,
|
|
IonItem,
|
|
IonLabel,
|
|
IonModal,
|
|
IonPage,
|
|
IonTitle,
|
|
IonToolbar,
|
|
IonSelect,
|
|
IonSelectOption,
|
|
IonList,
|
|
useIonViewWillEnter,
|
|
} from '@ionic/react';
|
|
import {
|
|
addOutline,
|
|
cardOutline,
|
|
chevronBackOutline,
|
|
chevronDownOutline,
|
|
checkmarkCircleOutline,
|
|
closeCircleOutline,
|
|
closeOutline,
|
|
flashOutline,
|
|
logoApple,
|
|
logoPaypal,
|
|
medkitOutline,
|
|
phonePortraitOutline,
|
|
timeOutline,
|
|
} from 'ionicons/icons';
|
|
import { useHistory, useLocation } from 'react-router-dom';
|
|
import { supabase } from '../supabase';
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
import momImage from '../assets/mom.jpg';
|
|
import dadImage from '../assets/dad.jpg';
|
|
import basketIcon from '../assets/basket.png';
|
|
import { formatMoney } from '../utils/formatMoney';
|
|
import { buildCacheKey, readCache, writeCache } from '../utils/localCache';
|
|
import '../styles/support.css';
|
|
|
|
type ServiceType = 'grocery' | 'medication' | 'airtime' | 'electricity';
|
|
|
|
type RecipientOption = {
|
|
id: string;
|
|
first_name: string;
|
|
last_name: string;
|
|
photo_url?: string | null;
|
|
photo_path?: string | null;
|
|
};
|
|
|
|
type MerchantOption = {
|
|
id: string;
|
|
name: string;
|
|
branch_name: string | null;
|
|
merchant_type: string;
|
|
};
|
|
|
|
type LocationState = {
|
|
recipientId?: string;
|
|
serviceType?: ServiceType;
|
|
amount?: number;
|
|
merchantId?: string | null;
|
|
network?: string | null;
|
|
meterNumber?: string | null;
|
|
startAtAmount?: boolean;
|
|
parentRoot?: string;
|
|
};
|
|
|
|
const services: Array<{
|
|
type: ServiceType;
|
|
label: string;
|
|
reviewLabel: string;
|
|
amountHeading: string;
|
|
amountHint: string;
|
|
icon?: string;
|
|
imageSrc?: string;
|
|
imageAlt?: string;
|
|
color: string;
|
|
bg: string;
|
|
cardBg: string;
|
|
selectedTint: string;
|
|
}> = [
|
|
{
|
|
type: 'grocery',
|
|
label: 'Groceries',
|
|
reviewLabel: 'Grocery voucher',
|
|
amountHeading: 'Voucher amount',
|
|
amountHint: 'You are sending a grocery voucher.',
|
|
imageSrc: basketIcon,
|
|
imageAlt: 'Groceries',
|
|
color: '#6d28d9',
|
|
bg: 'rgba(109,40,217,0.12)',
|
|
cardBg: 'rgba(109,40,217,0.03)',
|
|
selectedTint: 'rgba(109,40,217,0.08)',
|
|
},
|
|
{
|
|
type: 'medication',
|
|
label: 'Medication',
|
|
reviewLabel: 'Medication voucher',
|
|
amountHeading: 'Voucher amount',
|
|
amountHint: 'You are sending a medication voucher.',
|
|
icon: medkitOutline,
|
|
color: '#16a34a',
|
|
bg: 'rgba(22,163,74,0.14)',
|
|
cardBg: 'rgba(22,163,74,0.06)',
|
|
selectedTint: 'rgba(22,163,74,0.08)',
|
|
},
|
|
{
|
|
type: 'airtime',
|
|
label: 'Airtime & Data',
|
|
reviewLabel: 'Airtime & data',
|
|
amountHeading: 'Choose airtime & data amount',
|
|
amountHint: 'You are topping up airtime or data.',
|
|
icon: phonePortraitOutline,
|
|
color: '#3b82f6',
|
|
bg: 'rgba(59,130,246,0.1)',
|
|
cardBg: 'rgba(59,130,246,0.03)',
|
|
selectedTint: 'rgba(59,130,246,0.08)',
|
|
},
|
|
{
|
|
type: 'electricity',
|
|
label: 'Electricity',
|
|
reviewLabel: 'Electricity',
|
|
amountHeading: 'Choose electricity amount',
|
|
amountHint: 'You are buying an electricity token.',
|
|
icon: flashOutline,
|
|
color: '#f59e0b',
|
|
bg: 'rgba(245,158,11,0.1)',
|
|
cardBg: 'rgba(245,158,11,0.03)',
|
|
selectedTint: 'rgba(245,158,11,0.08)',
|
|
},
|
|
];
|
|
|
|
const amountPresets = [10, 20, 50, 100];
|
|
const networks = ['Econet', 'NetOne', 'Telecel'];
|
|
const previewUserId = '00000000-0000-0000-0000-000000000000';
|
|
|
|
const previewRecipients: RecipientOption[] = [
|
|
{
|
|
id: 'preview-mum',
|
|
first_name: 'Mum',
|
|
last_name: 'Moyo',
|
|
photo_url: momImage,
|
|
},
|
|
{
|
|
id: 'preview-dad',
|
|
first_name: 'Dad',
|
|
last_name: 'Moyo',
|
|
photo_url: dadImage,
|
|
},
|
|
];
|
|
|
|
const paymentMethods = [
|
|
{
|
|
value: 'card',
|
|
label: 'Visa ending 4242',
|
|
detail: 'Default card • reusable checkout',
|
|
kind: 'card' as const,
|
|
},
|
|
{
|
|
value: 'apple_pay',
|
|
label: 'Apple Pay',
|
|
detail: 'Wallet checkout',
|
|
kind: 'apple' as const,
|
|
},
|
|
{
|
|
value: 'google_pay',
|
|
label: 'Google Pay',
|
|
detail: 'Fast wallet checkout',
|
|
kind: 'google' as const,
|
|
},
|
|
{
|
|
value: 'paypal',
|
|
label: 'PayPal',
|
|
detail: 'Pay with PayPal',
|
|
kind: 'paypal' as const,
|
|
},
|
|
] as const;
|
|
|
|
const getRecipientPhoto = (recipient?: RecipientOption | null) => {
|
|
if (!recipient) return null;
|
|
|
|
const photoPath = recipient.photo_path?.toLowerCase() ?? '';
|
|
if (photoPath === 'mom.jpg' || photoPath === 'mum.jpg') return momImage;
|
|
if (photoPath === 'dad.jpg' || photoPath === 'father.jpg') return dadImage;
|
|
|
|
if (recipient.photo_url) return recipient.photo_url;
|
|
if (recipient.first_name.toLowerCase() === 'mum') return momImage;
|
|
if (recipient.first_name.toLowerCase() === 'dad') return dadImage;
|
|
return null;
|
|
};
|
|
|
|
const sanitizeAmount = (raw: string) => {
|
|
const cleaned = raw.replace(/[^\d.]/g, '');
|
|
const parts = cleaned.split('.');
|
|
const normalized =
|
|
parts.length <= 1 ? cleaned : `${parts[0]}.${parts.slice(1).join('')}`;
|
|
const dotIndex = normalized.indexOf('.');
|
|
return dotIndex === -1 ? normalized : normalized.slice(0, dotIndex + 3);
|
|
};
|
|
|
|
const SupportFlowPage: React.FC = () => {
|
|
const history = useHistory();
|
|
const location = useLocation<LocationState>();
|
|
const { user } = useAuth();
|
|
|
|
const [step, setStep] = useState(1);
|
|
const [recipients, setRecipients] = useState<RecipientOption[]>([]);
|
|
const [merchants, setMerchants] = useState<MerchantOption[]>([]);
|
|
const [selectedRecipientId, setSelectedRecipientId] = useState<string | null>(
|
|
location.state?.recipientId ?? null
|
|
);
|
|
const [serviceType, setServiceType] = useState<ServiceType | null>(
|
|
location.state?.serviceType ?? null
|
|
);
|
|
const [merchantId, setMerchantId] = useState<string | null>(null);
|
|
const [network, setNetwork] = useState<string | null>(null);
|
|
const [meterNumber, setMeterNumber] = useState('');
|
|
const [amountDisplay, setAmountDisplay] = useState('');
|
|
const [isCustomAmountSelected, setIsCustomAmountSelected] = useState(false);
|
|
const [platformFee, setPlatformFee] = useState(0);
|
|
const [totalAmount, setTotalAmount] = useState(0);
|
|
const [orderId, setOrderId] = useState<string | null>(null);
|
|
const [paymentMethod, setPaymentMethod] = useState('card');
|
|
const [isPaymentSheetOpen, setIsPaymentSheetOpen] = useState(false);
|
|
const [loading, setLoading] = useState(!user);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isRecipientSheetOpen, setIsRecipientSheetOpen] = useState(false);
|
|
|
|
const parentRoot = location.state?.parentRoot ?? '/home';
|
|
const hasPresetRecipient = Boolean(location.state?.recipientId);
|
|
const hasPresetService = Boolean(location.state?.serviceType);
|
|
|
|
const [paymentResult, setPaymentResult] = useState<
|
|
'idle' | 'success' | 'failure'
|
|
>('idle');
|
|
const [paymentResultMessage, setPaymentResultMessage] = useState<
|
|
string | null
|
|
>(null);
|
|
const isShowingPaymentResult = paymentResult !== 'idle';
|
|
|
|
const [isRecurringEnabled, setIsRecurringEnabled] = useState(false);
|
|
const [frequencyUnit, setFrequencyUnit] = useState<'weekly' | 'monthly'>(
|
|
'weekly'
|
|
);
|
|
const [frequencyInterval, setFrequencyInterval] = useState(1);
|
|
|
|
const requiresSupportDetails =
|
|
serviceType === 'airtime' || serviceType === 'electricity';
|
|
const totalSteps = requiresSupportDetails ? 5 : 4;
|
|
const amountStep = requiresSupportDetails ? 4 : 3;
|
|
const reviewStep = requiresSupportDetails ? 5 : 4;
|
|
const visibleFlowSteps = useMemo(() => {
|
|
if (!hasPresetService) {
|
|
return Array.from({ length: totalSteps }, (_, index) => index + 1);
|
|
}
|
|
|
|
return requiresSupportDetails ? [1, 3, 4, 5] : [1, 3, 4];
|
|
}, [hasPresetService, requiresSupportDetails, totalSteps]);
|
|
const visualStep = Math.max(1, visibleFlowSteps.indexOf(step) + 1);
|
|
|
|
const parsedAmount = useMemo(() => {
|
|
if (!amountDisplay || amountDisplay === '.') return null;
|
|
const value = Number(amountDisplay);
|
|
return Number.isFinite(value) && value > 0 ? value : null;
|
|
}, [amountDisplay]);
|
|
|
|
const selectedService = useMemo(
|
|
() => services.find((item) => item.type === serviceType) ?? null,
|
|
[serviceType]
|
|
);
|
|
|
|
const pageBackground = selectedService
|
|
? `linear-gradient(180deg, ${selectedService.selectedTint} 0%, #fafafa 240px)`
|
|
: '#fafafa';
|
|
|
|
const themeStyle = selectedService
|
|
? ({
|
|
'--flow-accent': selectedService.color,
|
|
'--flow-accent-soft': selectedService.selectedTint,
|
|
'--flow-icon-soft': selectedService.bg,
|
|
'--flow-card-soft': selectedService.cardBg,
|
|
} as CSSProperties)
|
|
: undefined;
|
|
|
|
const flowTitle = selectedService?.label ?? 'Send support';
|
|
const isPresetAmountSelected = amountPresets.includes(Number(amountDisplay));
|
|
|
|
useEffect(() => {
|
|
void loadInitialData();
|
|
}, [user?.id]);
|
|
|
|
useIonViewWillEnter(() => {
|
|
const presetRecipientId = location.state?.recipientId ?? null;
|
|
const presetServiceType = location.state?.serviceType ?? null;
|
|
const presetAmount = location.state?.amount;
|
|
const presetAmountDisplay =
|
|
typeof presetAmount === 'number' && Number.isFinite(presetAmount)
|
|
? presetAmount.toFixed(2)
|
|
: '';
|
|
const presetStartAtAmount = Boolean(location.state?.startAtAmount);
|
|
const presetRequiresSupportDetails =
|
|
presetServiceType === 'airtime' || presetServiceType === 'electricity';
|
|
const presetAmountStep = presetRequiresSupportDetails ? 4 : 3;
|
|
const presetDetailsStep = presetRequiresSupportDetails
|
|
? 3
|
|
: presetAmountStep;
|
|
|
|
setStep(
|
|
presetStartAtAmount && presetRecipientId && presetServiceType
|
|
? presetAmountStep
|
|
: presetRecipientId && presetServiceType
|
|
? presetDetailsStep
|
|
: presetRecipientId && !presetServiceType
|
|
? 2
|
|
: 1
|
|
);
|
|
setSelectedRecipientId(presetRecipientId);
|
|
setServiceType(presetServiceType);
|
|
setMerchantId(location.state?.merchantId ?? null);
|
|
setNetwork(location.state?.network ?? null);
|
|
setMeterNumber(location.state?.meterNumber ?? '');
|
|
setAmountDisplay(presetAmountDisplay);
|
|
setIsCustomAmountSelected(
|
|
Boolean(
|
|
presetAmountDisplay &&
|
|
!amountPresets.includes(Number(presetAmountDisplay))
|
|
)
|
|
);
|
|
setPlatformFee(0);
|
|
setTotalAmount(0);
|
|
setOrderId(null);
|
|
setPaymentMethod('card');
|
|
setIsPaymentSheetOpen(false);
|
|
setSubmitting(false);
|
|
setError(null);
|
|
setPaymentResult('idle');
|
|
setPaymentResultMessage(null);
|
|
setIsRecurringEnabled(false);
|
|
setFrequencyUnit('weekly');
|
|
setFrequencyInterval(1);
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (serviceType === 'grocery' || serviceType === 'medication') {
|
|
void loadMerchantsForService(serviceType);
|
|
} else {
|
|
setMerchantId(null);
|
|
setMerchants([]);
|
|
}
|
|
}, [serviceType]);
|
|
|
|
useEffect(() => {
|
|
const fee = parsedAmount
|
|
? Number((2.99 + parsedAmount * 0.03).toFixed(2))
|
|
: 0;
|
|
setPlatformFee(fee);
|
|
setTotalAmount(parsedAmount ? Number((parsedAmount + fee).toFixed(2)) : 0);
|
|
}, [parsedAmount]);
|
|
|
|
useEffect(() => {
|
|
const loadExistingSchedule = async () => {
|
|
if (!user || !selectedRecipientId || !serviceType) return;
|
|
const { data, error: fetchError } = await supabase
|
|
.from('support_schedules')
|
|
.select(
|
|
'id, frequency_unit, frequency_interval, next_due_at, is_active'
|
|
)
|
|
.eq('recipient_id', selectedRecipientId)
|
|
.eq('service_type', serviceType)
|
|
.eq('user_id', user.id)
|
|
.maybeSingle();
|
|
|
|
if (fetchError) {
|
|
showError('Could not load support frequency');
|
|
} else if (data) {
|
|
setIsRecurringEnabled(data.is_active);
|
|
setFrequencyUnit(data.frequency_unit as 'weekly' | 'monthly');
|
|
setFrequencyInterval(data.frequency_interval);
|
|
} else {
|
|
setIsRecurringEnabled(false);
|
|
setFrequencyUnit('weekly');
|
|
setFrequencyInterval(
|
|
serviceType === 'grocery' || serviceType === 'medication' ? 4 : 1
|
|
);
|
|
}
|
|
};
|
|
void loadExistingSchedule();
|
|
}, [user?.id, selectedRecipientId, serviceType]);
|
|
|
|
const showError = (message: string) => {
|
|
setError(message);
|
|
setTimeout(() => setError(null), 4000);
|
|
};
|
|
|
|
const resolveRecipientOptions = async (rows: RecipientOption[]) =>
|
|
Promise.all(
|
|
rows.map(async (recipient) => {
|
|
const seededPhoto = getRecipientPhoto(recipient);
|
|
if (seededPhoto || !recipient.photo_path) {
|
|
return {
|
|
...recipient,
|
|
photo_url: seededPhoto ?? recipient.photo_url,
|
|
};
|
|
}
|
|
|
|
const { data: urlData } = await supabase.storage
|
|
.from('recipient-photos')
|
|
.createSignedUrl(recipient.photo_path, 3600);
|
|
|
|
return {
|
|
...recipient,
|
|
photo_url: urlData?.signedUrl ?? recipient.photo_url ?? null,
|
|
};
|
|
})
|
|
);
|
|
|
|
const loadInitialData = async () => {
|
|
const activeUserId = user?.id ?? previewUserId;
|
|
const cacheKey = buildCacheKey(activeUserId, 'supportRecipients');
|
|
|
|
let hasCache = false;
|
|
try {
|
|
const cached = await readCache<RecipientOption[]>(cacheKey);
|
|
if (cached && Array.isArray(cached) && cached.length > 0) {
|
|
setRecipients(cached);
|
|
setSelectedRecipientId((current) => {
|
|
if (current && cached.some((recipient) => recipient.id === current)) {
|
|
return current;
|
|
}
|
|
return current ?? cached[0]?.id ?? null;
|
|
});
|
|
setLoading(false);
|
|
hasCache = true;
|
|
}
|
|
} catch (err) {
|
|
console.error('[support recipients cache] error', err);
|
|
}
|
|
|
|
if (!hasCache) setLoading(true);
|
|
|
|
const query = supabase
|
|
.from('recipients')
|
|
.select('id,first_name,last_name,photo_path')
|
|
.eq('user_id', activeUserId)
|
|
.order('created_at', { ascending: false });
|
|
|
|
const { data, error: loadError } = user
|
|
? await query.eq('is_active', true)
|
|
: await query;
|
|
|
|
if (loadError) {
|
|
showError(loadError.message);
|
|
if (!hasCache) {
|
|
const fallbackRecipients =
|
|
await resolveRecipientOptions(previewRecipients);
|
|
setRecipients(fallbackRecipients);
|
|
setSelectedRecipientId(
|
|
(current) => current ?? fallbackRecipients[0]?.id ?? null
|
|
);
|
|
setLoading(false);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const resolvedRecipients = await resolveRecipientOptions(
|
|
(data ?? []) as RecipientOption[]
|
|
);
|
|
setRecipients(resolvedRecipients);
|
|
setSelectedRecipientId((current) => {
|
|
if (
|
|
current &&
|
|
resolvedRecipients.some((recipient) => recipient.id === current)
|
|
) {
|
|
return current;
|
|
}
|
|
return current ?? resolvedRecipients[0]?.id ?? null;
|
|
});
|
|
setLoading(false);
|
|
void writeCache(cacheKey, resolvedRecipients);
|
|
};
|
|
|
|
const loadMerchantsForService = async (nextService: ServiceType) => {
|
|
const merchantType = nextService === 'grocery' ? 'grocery' : 'pharmacy';
|
|
const activeUserId = user?.id ?? previewUserId;
|
|
const cacheKey = buildCacheKey(activeUserId, `merchants_${merchantType}`);
|
|
|
|
try {
|
|
const cached = await readCache<MerchantOption[]>(cacheKey);
|
|
if (cached && Array.isArray(cached) && cached.length > 0) {
|
|
setMerchants(cached);
|
|
}
|
|
} catch (err) {
|
|
console.error('[support merchants cache] error', err);
|
|
}
|
|
|
|
const { data, error: merchantError } = await supabase
|
|
.from('merchants')
|
|
.select('id,name,branch_name,merchant_type')
|
|
.eq('merchant_type', merchantType)
|
|
.eq('is_active', true)
|
|
.order('name', { ascending: true });
|
|
|
|
if (merchantError) {
|
|
showError(merchantError.message);
|
|
return;
|
|
}
|
|
const freshMerchants = (data ?? []) as MerchantOption[];
|
|
setMerchants(freshMerchants);
|
|
void writeCache(cacheKey, freshMerchants);
|
|
};
|
|
|
|
const handleServiceSelect = (nextService: ServiceType) => {
|
|
setServiceType(nextService);
|
|
setMerchantId(null);
|
|
setNetwork(null);
|
|
setMeterNumber('');
|
|
setOrderId(null);
|
|
setAmountDisplay('');
|
|
setIsCustomAmountSelected(false);
|
|
};
|
|
|
|
const validateCurrentStep = () => {
|
|
if (step === 1 && !selectedRecipientId)
|
|
return 'Choose who will receive support';
|
|
if (step === 2 && !serviceType) return 'Choose a support type';
|
|
if (step === 3 && requiresSupportDetails) {
|
|
if (serviceType === 'airtime' && !network)
|
|
return 'Choose a mobile network';
|
|
if (serviceType === 'electricity' && !meterNumber.trim())
|
|
return 'Enter the meter number';
|
|
}
|
|
if (step === amountStep && !parsedAmount) return 'Enter a support amount';
|
|
return null;
|
|
};
|
|
|
|
const createOrderAndIntent = async () => {
|
|
if (!selectedRecipientId || !serviceType || !parsedAmount) return false;
|
|
|
|
setSubmitting(true);
|
|
|
|
const activeUserId = user?.id ?? previewUserId;
|
|
let currentOrderId = orderId;
|
|
if (!currentOrderId) {
|
|
const { data, error: orderError } = await supabase
|
|
.from('support_orders')
|
|
.insert({
|
|
user_id: activeUserId,
|
|
recipient_id: selectedRecipientId,
|
|
merchant_id: merchantId,
|
|
service_type: serviceType,
|
|
amount: parsedAmount,
|
|
platform_fee: platformFee,
|
|
total_amount: totalAmount,
|
|
currency: 'USD',
|
|
status: 'pending_payment',
|
|
payment_method: paymentMethod,
|
|
network,
|
|
meter_number: meterNumber.trim() || null,
|
|
})
|
|
.select('id')
|
|
.single();
|
|
|
|
if (orderError || !data) {
|
|
showError(orderError?.message ?? 'Could not create order');
|
|
setSubmitting(false);
|
|
return false;
|
|
}
|
|
currentOrderId = data.id;
|
|
setOrderId(currentOrderId);
|
|
}
|
|
|
|
if (!user) {
|
|
setSubmitting(false);
|
|
return true;
|
|
}
|
|
|
|
const { data: intentData, error: intentError } =
|
|
await supabase.functions.invoke('calculate-payment-intent', {
|
|
body: {
|
|
orderId: currentOrderId,
|
|
serviceType,
|
|
amount: parsedAmount,
|
|
currency: 'USD',
|
|
paymentMethod,
|
|
},
|
|
});
|
|
|
|
if (intentError) {
|
|
showError(intentError.message);
|
|
setSubmitting(false);
|
|
return false;
|
|
}
|
|
|
|
const response = intentData as {
|
|
platformFee?: number;
|
|
totalAmount?: number;
|
|
} | null;
|
|
setPlatformFee(Number(response?.platformFee ?? platformFee));
|
|
setTotalAmount(Number(response?.totalAmount ?? totalAmount));
|
|
setSubmitting(false);
|
|
return true;
|
|
};
|
|
|
|
const handleContinue = async () => {
|
|
const validation = validateCurrentStep();
|
|
if (validation) {
|
|
showError(validation);
|
|
return;
|
|
}
|
|
|
|
if (step === amountStep) {
|
|
const ok = await createOrderAndIntent();
|
|
if (!ok) return;
|
|
}
|
|
|
|
const currentVisibleIndex = visibleFlowSteps.indexOf(step);
|
|
const nextVisibleStep = visibleFlowSteps[currentVisibleIndex + 1];
|
|
setStep(nextVisibleStep ?? reviewStep);
|
|
};
|
|
|
|
const handleBack = () => {
|
|
if (paymentResult === 'failure') {
|
|
setPaymentResult('idle');
|
|
return;
|
|
}
|
|
if (paymentResult === 'success') {
|
|
if (orderId) {
|
|
history.push(`/orders/${orderId}`, { parentRoot });
|
|
} else {
|
|
history.push(parentRoot);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const currentVisibleIndex = visibleFlowSteps.indexOf(step);
|
|
if (currentVisibleIndex > 0) {
|
|
setStep(visibleFlowSteps[currentVisibleIndex - 1]);
|
|
return;
|
|
}
|
|
|
|
history.length > 1 ? history.goBack() : history.replace('/home');
|
|
};
|
|
|
|
const handleConfirmPayment = async () => {
|
|
if (!orderId || !selectedRecipientId || !serviceType) {
|
|
showError('Order is not ready yet');
|
|
return;
|
|
}
|
|
|
|
setSubmitting(true);
|
|
setPaymentResult('idle');
|
|
setPaymentResultMessage(null);
|
|
|
|
const { error: fulfilError } = await supabase.functions.invoke(
|
|
'confirm-payment-and-fulfil',
|
|
{
|
|
body: { orderId },
|
|
}
|
|
);
|
|
|
|
if (fulfilError) {
|
|
setSubmitting(false);
|
|
setPaymentResult('failure');
|
|
setPaymentResultMessage(
|
|
fulfilError.message || 'Payment could not be processed at this time.'
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (isRecurringEnabled) {
|
|
const scheduleId = await upsertSupportSchedule();
|
|
if (scheduleId) {
|
|
await supabase
|
|
.from('support_orders')
|
|
.update({ recurring_schedule_id: scheduleId })
|
|
.eq('id', orderId);
|
|
|
|
await createGeneralizedCareAlert(scheduleId);
|
|
}
|
|
}
|
|
|
|
setSubmitting(false);
|
|
setPaymentResult('success');
|
|
setPaymentResultMessage(
|
|
`Your ${selectedService?.reviewLabel.toLowerCase()} order is confirmed and ready for ${selectedRecipient?.first_name}.`
|
|
);
|
|
};
|
|
|
|
const handleViewOrderDetails = () => {
|
|
if (orderId) {
|
|
history.push(`/orders/${orderId}`, { parentRoot });
|
|
} else {
|
|
history.push(parentRoot);
|
|
}
|
|
};
|
|
|
|
const handlePaymentResultSecondaryAction = () => {
|
|
if (paymentResult === 'success') {
|
|
history.push(parentRoot);
|
|
} else {
|
|
setPaymentResult('idle');
|
|
setPaymentResultMessage(null);
|
|
}
|
|
};
|
|
|
|
const handleRetryPayment = () => {
|
|
setError(null);
|
|
setPaymentResult('idle');
|
|
setPaymentResultMessage(null);
|
|
void handleConfirmPayment();
|
|
};
|
|
|
|
const calculateNextDueDate = () => {
|
|
const date = new Date();
|
|
if (frequencyUnit === 'weekly') {
|
|
date.setDate(date.getDate() + 7 * frequencyInterval);
|
|
} else {
|
|
date.setMonth(date.getMonth() + frequencyInterval);
|
|
}
|
|
return date.toISOString();
|
|
};
|
|
|
|
const upsertSupportSchedule = async () => {
|
|
if (!user || !selectedRecipientId || !serviceType) return null;
|
|
const nextDueAt = calculateNextDueDate();
|
|
|
|
const { data: existing } = await supabase
|
|
.from('support_schedules')
|
|
.select('id')
|
|
.eq('user_id', user.id)
|
|
.eq('recipient_id', selectedRecipientId)
|
|
.eq('service_type', serviceType)
|
|
.maybeSingle();
|
|
|
|
if (existing) {
|
|
const { error: updateError } = await supabase
|
|
.from('support_schedules')
|
|
.update({
|
|
frequency_unit: frequencyUnit,
|
|
frequency_interval: frequencyInterval,
|
|
last_supported_at: new Date().toISOString(),
|
|
next_due_at: nextDueAt,
|
|
is_active: true,
|
|
})
|
|
.eq('id', existing.id);
|
|
|
|
if (updateError) {
|
|
showError('Failed to update recurring schedule');
|
|
return null;
|
|
}
|
|
return existing.id;
|
|
}
|
|
|
|
const { data: inserted, error: insertError } = await supabase
|
|
.from('support_schedules')
|
|
.insert({
|
|
user_id: user.id,
|
|
recipient_id: selectedRecipientId,
|
|
service_type: serviceType,
|
|
frequency_unit: frequencyUnit,
|
|
frequency_interval: frequencyInterval,
|
|
last_supported_at: new Date().toISOString(),
|
|
next_due_at: nextDueAt,
|
|
is_active: true,
|
|
})
|
|
.select('id')
|
|
.single();
|
|
|
|
if (insertError || !inserted) {
|
|
showError('Failed to save recurring schedule');
|
|
return null;
|
|
}
|
|
return inserted.id;
|
|
};
|
|
|
|
const createGeneralizedCareAlert = async (scheduleId: string) => {
|
|
if (!user || !selectedRecipientId || !serviceType) return;
|
|
const nextDueAt = calculateNextDueDate();
|
|
|
|
const recipient = recipients.find((r) => r.id === selectedRecipientId);
|
|
const firstName = recipient?.first_name || 'Loved one';
|
|
|
|
let title = '';
|
|
if (serviceType === 'grocery') title = 'Grocery support due soon';
|
|
else if (serviceType === 'medication') {
|
|
title = 'Medication support due soon';
|
|
} else if (serviceType === 'airtime') {
|
|
title = 'Airtime running low soon';
|
|
} else if (serviceType === 'electricity') {
|
|
title = 'Electricity top-up may be needed';
|
|
}
|
|
|
|
let intervalStr = '';
|
|
if (frequencyInterval === 1) {
|
|
intervalStr = frequencyUnit === 'weekly' ? 'a week' : 'a month';
|
|
} else {
|
|
intervalStr = `${frequencyInterval} ${frequencyUnit === 'weekly' ? 'weeks' : 'months'}`;
|
|
}
|
|
const body = `For ${firstName} • in ${intervalStr}`;
|
|
|
|
const { data: existingAlert } = await supabase
|
|
.from('care_alerts')
|
|
.select('id')
|
|
.eq('schedule_id', scheduleId)
|
|
.is('dismissed_at', null)
|
|
.maybeSingle();
|
|
|
|
if (existingAlert) {
|
|
await supabase
|
|
.from('care_alerts')
|
|
.update({
|
|
title,
|
|
body,
|
|
due_at: nextDueAt,
|
|
service_type: serviceType,
|
|
})
|
|
.eq('id', existingAlert.id);
|
|
} else {
|
|
await supabase.from('care_alerts').insert({
|
|
user_id: user.id,
|
|
recipient_id: selectedRecipientId,
|
|
schedule_id: scheduleId,
|
|
service_type: serviceType,
|
|
alert_type: 'support_due',
|
|
title,
|
|
body,
|
|
severity: 'info',
|
|
due_at: nextDueAt,
|
|
});
|
|
}
|
|
};
|
|
|
|
const showInlineTotalCard = !isShowingPaymentResult && step === amountStep;
|
|
|
|
const ctaLabel =
|
|
step === amountStep
|
|
? submitting
|
|
? 'Preparing...'
|
|
: 'Review payment'
|
|
: step === reviewStep
|
|
? submitting
|
|
? 'Confirming...'
|
|
: 'Confirm payment'
|
|
: 'Continue';
|
|
|
|
const renderPaymentResultCard = () => {
|
|
if (paymentResult === 'idle') return null;
|
|
|
|
const isSuccess = paymentResult === 'success';
|
|
|
|
return (
|
|
<div className="support-payment-result-card">
|
|
<div className={`sprc-icon-shell ${isSuccess ? 'success' : 'failure'}`}>
|
|
<IonIcon
|
|
icon={isSuccess ? checkmarkCircleOutline : closeCircleOutline}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<p className="sprc-eyebrow">
|
|
Payment {isSuccess ? 'Confirmed' : 'Declined'}
|
|
</p>
|
|
<h2 className="sprc-title">
|
|
{isSuccess ? 'Payment successful' : 'Payment failed'}
|
|
</h2>
|
|
<p className="sprc-body">{paymentResultMessage}</p>
|
|
</div>
|
|
|
|
<div className="sprc-summary-slab">
|
|
<div className="sprc-summary-row">
|
|
<span>Recipient</span>
|
|
<strong>
|
|
{selectedRecipient
|
|
? `${selectedRecipient.first_name} ${selectedRecipient.last_name}`
|
|
: ''}
|
|
</strong>
|
|
</div>
|
|
<div className="sprc-summary-row">
|
|
<span>Support type</span>
|
|
<strong>{selectedService?.label}</strong>
|
|
</div>
|
|
<div className="sprc-summary-row">
|
|
<span>Total paid</span>
|
|
<strong>{formatMoney(totalAmount)}</strong>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="sprc-actions">
|
|
{isSuccess ? (
|
|
<>
|
|
<IonButton
|
|
className="sprc-primary-btn"
|
|
expand="block"
|
|
onClick={handleViewOrderDetails}
|
|
>
|
|
View order details
|
|
</IonButton>
|
|
<IonButton
|
|
className="sprc-secondary-btn"
|
|
expand="block"
|
|
onClick={handlePaymentResultSecondaryAction}
|
|
>
|
|
Back to activity
|
|
</IonButton>
|
|
</>
|
|
) : (
|
|
<>
|
|
<IonButton
|
|
className="sprc-primary-btn"
|
|
expand="block"
|
|
onClick={handleRetryPayment}
|
|
disabled={submitting}
|
|
>
|
|
{submitting ? 'Retrying...' : 'Try payment again'}
|
|
</IonButton>
|
|
<IonButton
|
|
className="sprc-secondary-btn"
|
|
expand="block"
|
|
onClick={handlePaymentResultSecondaryAction}
|
|
>
|
|
Back to review
|
|
</IonButton>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const selectedRecipient =
|
|
recipients.find((recipient) => recipient.id === selectedRecipientId) ??
|
|
null;
|
|
const selectedRecipientPhoto = getRecipientPhoto(selectedRecipient);
|
|
const selectedPaymentMethod =
|
|
paymentMethods.find((method) => method.value === paymentMethod) ??
|
|
paymentMethods[0];
|
|
|
|
return (
|
|
<IonPage style={{ backgroundColor: '#fafafa' }}>
|
|
<IonHeader className="ion-no-border">
|
|
<IonToolbar className="support-toolbar" style={themeStyle}>
|
|
<IonButtons slot="start">
|
|
<IonButton
|
|
className="support-back-button"
|
|
fill="clear"
|
|
onClick={handleBack}
|
|
aria-label={step > 1 ? 'Go to previous step' : 'Go back'}
|
|
>
|
|
<IonIcon icon={chevronBackOutline} slot="icon-only" />
|
|
</IonButton>
|
|
</IonButtons>
|
|
<IonTitle className="support-toolbar-title">
|
|
{selectedService && (
|
|
<span className="support-toolbar-service-icon" aria-hidden="true">
|
|
{selectedService.imageSrc ? (
|
|
<img
|
|
src={selectedService.imageSrc}
|
|
alt=""
|
|
className="support-toolbar-service-image"
|
|
/>
|
|
) : (
|
|
<IonIcon icon={selectedService.icon} />
|
|
)}
|
|
</span>
|
|
)}
|
|
<span>{flowTitle}</span>
|
|
</IonTitle>
|
|
</IonToolbar>
|
|
</IonHeader>
|
|
|
|
<IonContent
|
|
style={
|
|
{
|
|
'--background': pageBackground,
|
|
'--padding-top': '8px',
|
|
'--padding-bottom': '32px',
|
|
} as React.CSSProperties
|
|
}
|
|
>
|
|
<div className="support-page-shell" style={themeStyle}>
|
|
{!isShowingPaymentResult && (
|
|
<div className="support-step-indicator">
|
|
{visibleFlowSteps.map((item, index) => {
|
|
const itemVisualStep = index + 1;
|
|
return (
|
|
<React.Fragment key={item}>
|
|
<div
|
|
className={`ssi-chip ${itemVisualStep === visualStep ? 'active' : itemVisualStep < visualStep ? 'completed' : ''}`}
|
|
>
|
|
{itemVisualStep}
|
|
</div>
|
|
{index < visibleFlowSteps.length - 1 && (
|
|
<div
|
|
className={`ssi-line ${itemVisualStep < visualStep ? 'active' : ''}`}
|
|
/>
|
|
)}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{error && !isShowingPaymentResult && (
|
|
<p
|
|
style={{ margin: '0 20px 12px', color: '#dc2626', fontSize: 13 }}
|
|
>
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
{isShowingPaymentResult && renderPaymentResultCard()}
|
|
|
|
{!isShowingPaymentResult && step === 1 && !hasPresetService && (
|
|
<div className="support-form-card">
|
|
<h2 className="sfc-title">Who are you supporting?</h2>
|
|
<button
|
|
type="button"
|
|
className="support-recipient-trigger"
|
|
onClick={() => !loading && setIsRecipientSheetOpen(true)}
|
|
disabled={loading}
|
|
>
|
|
<div className="support-recipient-trigger-left">
|
|
<IonAvatar className="support-recipient-avatar">
|
|
{selectedRecipientPhoto ? (
|
|
<img
|
|
src={selectedRecipientPhoto}
|
|
alt={
|
|
selectedRecipient
|
|
? `${selectedRecipient.first_name} ${selectedRecipient.last_name}`
|
|
: 'Recipient'
|
|
}
|
|
/>
|
|
) : (
|
|
<div className="support-recipient-avatar-fallback">
|
|
{selectedRecipient
|
|
? `${selectedRecipient.first_name.charAt(0)}${selectedRecipient.last_name.charAt(0)}`
|
|
: '?'}
|
|
</div>
|
|
)}
|
|
</IonAvatar>
|
|
<div className="support-recipient-copy">
|
|
<span className="support-recipient-name">
|
|
{selectedRecipient
|
|
? `${selectedRecipient.first_name} ${selectedRecipient.last_name}`
|
|
: 'Choose loved one'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<IonIcon
|
|
icon={chevronDownOutline}
|
|
className="support-recipient-chevron"
|
|
/>
|
|
</button>
|
|
{!loading && recipients.length === 0 && (
|
|
<IonButton
|
|
fill="clear"
|
|
onClick={() => history.push('/recipients/new')}
|
|
>
|
|
Add your first loved one
|
|
</IonButton>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{!isShowingPaymentResult &&
|
|
((step === 1 && hasPresetService) ||
|
|
(step === 2 && !hasPresetService)) && (
|
|
<div className="support-form-card">
|
|
<h2 className="sfc-title">
|
|
{hasPresetService
|
|
? 'Who are you supporting?'
|
|
: 'Choose support type'}
|
|
</h2>
|
|
{hasPresetService && selectedService ? (
|
|
<button
|
|
type="button"
|
|
className="support-recipient-trigger"
|
|
onClick={() => !loading && setIsRecipientSheetOpen(true)}
|
|
disabled={loading}
|
|
>
|
|
<div className="support-recipient-trigger-left">
|
|
<IonAvatar className="support-recipient-avatar">
|
|
{selectedRecipientPhoto ? (
|
|
<img
|
|
src={selectedRecipientPhoto}
|
|
alt={
|
|
selectedRecipient
|
|
? `${selectedRecipient.first_name} ${selectedRecipient.last_name}`
|
|
: 'Recipient'
|
|
}
|
|
/>
|
|
) : (
|
|
<div className="support-recipient-avatar-fallback">
|
|
{selectedRecipient
|
|
? `${selectedRecipient.first_name.charAt(0)}${selectedRecipient.last_name.charAt(0)}`
|
|
: '?'}
|
|
</div>
|
|
)}
|
|
</IonAvatar>
|
|
<div className="support-recipient-copy">
|
|
<span className="support-recipient-name">
|
|
{selectedRecipient
|
|
? `${selectedRecipient.first_name} ${selectedRecipient.last_name}`
|
|
: 'Choose loved one'}
|
|
</span>
|
|
<span className="support-recipient-hint">
|
|
{selectedService.label} already selected
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<IonIcon
|
|
icon={chevronDownOutline}
|
|
className="support-recipient-chevron"
|
|
/>
|
|
</button>
|
|
) : (
|
|
<div className="service-type-grid">
|
|
{services.map((service) => (
|
|
<button
|
|
key={service.type}
|
|
type="button"
|
|
className={`service-type-card service-${service.type} ${serviceType === service.type ? 'selected' : ''}`}
|
|
style={
|
|
{
|
|
'--service-accent': service.color,
|
|
'--service-tint': service.selectedTint,
|
|
'--service-card-bg': service.cardBg,
|
|
} as React.CSSProperties
|
|
}
|
|
onClick={() => handleServiceSelect(service.type)}
|
|
>
|
|
<div
|
|
className="stc-icon-box"
|
|
style={{ background: service.bg }}
|
|
>
|
|
{service.imageSrc ? (
|
|
<img
|
|
src={service.imageSrc}
|
|
alt={service.imageAlt ?? service.label}
|
|
className={`support-service-image ${service.type === 'grocery' ? 'support-service-image-grocery' : ''}`}
|
|
/>
|
|
) : (
|
|
<IonIcon
|
|
icon={service.icon}
|
|
style={{ color: service.color }}
|
|
/>
|
|
)}
|
|
</div>
|
|
<span className="stc-label">{service.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{!isShowingPaymentResult && step === 3 && (
|
|
<>
|
|
{(serviceType === 'airtime' || serviceType === 'electricity') && (
|
|
<div className="support-form-card">
|
|
<h2 className="sfc-title">Support details</h2>
|
|
{serviceType === 'airtime' && (
|
|
<IonSelect
|
|
className="support-select"
|
|
interface="action-sheet"
|
|
placeholder="Choose network"
|
|
value={network}
|
|
onIonChange={(event) => setNetwork(event.detail.value)}
|
|
>
|
|
{networks.map((item) => (
|
|
<IonSelectOption key={item} value={item}>
|
|
{item}
|
|
</IonSelectOption>
|
|
))}
|
|
</IonSelect>
|
|
)}
|
|
{serviceType === 'electricity' && (
|
|
<IonInput
|
|
className="support-text-input"
|
|
type="text"
|
|
inputMode="numeric"
|
|
placeholder="e.g. 12345678901"
|
|
label="Meter number"
|
|
labelPlacement="floating"
|
|
value={meterNumber}
|
|
onIonInput={(event) =>
|
|
setMeterNumber(
|
|
(event.detail.value ?? '').replace(/\D/g, '')
|
|
)
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{!isShowingPaymentResult && step === amountStep && (
|
|
<div className="support-form-card support-form-card-themed">
|
|
{selectedService && (
|
|
<div className="support-context-row support-context-row-amount">
|
|
<div
|
|
className="support-context-avatar-wrap"
|
|
aria-hidden="true"
|
|
>
|
|
<IonAvatar className="support-context-avatar">
|
|
{selectedRecipientPhoto ? (
|
|
<img src={selectedRecipientPhoto} alt="" />
|
|
) : (
|
|
<div className="support-recipient-avatar-fallback">
|
|
{selectedRecipient
|
|
? `${selectedRecipient.first_name.charAt(0)}${selectedRecipient.last_name.charAt(0)}`
|
|
: '?'}
|
|
</div>
|
|
)}
|
|
</IonAvatar>
|
|
<div className="support-context-badge">
|
|
{selectedService.imageSrc ? (
|
|
<img
|
|
src={selectedService.imageSrc}
|
|
alt=""
|
|
className="support-context-badge-image"
|
|
/>
|
|
) : (
|
|
<IonIcon icon={selectedService.icon} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<p className="support-step-kicker">
|
|
{selectedRecipient
|
|
? `For ${selectedRecipient.first_name} ${selectedRecipient.last_name}`
|
|
: 'Selected recipient'}
|
|
</p>
|
|
<h2 className="sfc-title support-themed-title">
|
|
{selectedService.amountHeading}
|
|
</h2>
|
|
<p className="support-amount-hint">
|
|
{selectedService.amountHint}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{!selectedService && (
|
|
<h2 className="sfc-title support-themed-title">
|
|
Choose amount
|
|
</h2>
|
|
)}
|
|
<div className="amount-preset-chips">
|
|
{amountPresets.map((amount) => (
|
|
<button
|
|
key={amount}
|
|
type="button"
|
|
className={`amount-chip ${!isCustomAmountSelected && Number(amountDisplay) === amount ? 'active' : ''}`}
|
|
onClick={() => {
|
|
setIsCustomAmountSelected(false);
|
|
setAmountDisplay(amount.toFixed(2));
|
|
}}
|
|
>
|
|
${amount}
|
|
</button>
|
|
))}
|
|
<button
|
|
type="button"
|
|
className={`amount-chip ${isCustomAmountSelected ? 'active' : ''}`}
|
|
onClick={() => {
|
|
setIsCustomAmountSelected(true);
|
|
if (isPresetAmountSelected) {
|
|
setAmountDisplay('');
|
|
}
|
|
}}
|
|
>
|
|
Custom
|
|
</button>
|
|
</div>
|
|
{isCustomAmountSelected && (
|
|
<IonItem
|
|
lines="none"
|
|
style={
|
|
{
|
|
'--background': 'var(--flow-card-soft, #fafafa)',
|
|
'--border-radius': '12px',
|
|
} as React.CSSProperties
|
|
}
|
|
>
|
|
<span
|
|
slot="start"
|
|
style={{ fontWeight: 700, color: '#6b7280' }}
|
|
>
|
|
$
|
|
</span>
|
|
<IonInput
|
|
className="custom-amount-input"
|
|
type="text"
|
|
inputMode="decimal"
|
|
placeholder="0.00"
|
|
value={amountDisplay}
|
|
onIonInput={(event) =>
|
|
setAmountDisplay(sanitizeAmount(event.detail.value ?? ''))
|
|
}
|
|
/>
|
|
</IonItem>
|
|
)}
|
|
|
|
{parsedAmount && parsedAmount > 0 && (
|
|
<div
|
|
style={{
|
|
background: '#ffffff',
|
|
borderRadius: '24px',
|
|
padding: '20px',
|
|
margin: '24px 0 0',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '14px',
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '12px',
|
|
}}
|
|
>
|
|
<div className="support-recurring-icon">
|
|
<IonIcon icon={timeOutline} />
|
|
</div>
|
|
<div>
|
|
<h3
|
|
style={{
|
|
margin: '0 0 2px',
|
|
fontSize: 16,
|
|
fontWeight: 700,
|
|
color: '#171827',
|
|
}}
|
|
>
|
|
Repeat support
|
|
</h3>
|
|
<p
|
|
style={{
|
|
margin: 0,
|
|
fontSize: 13,
|
|
fontWeight: 500,
|
|
color: 'rgba(23,24,39,0.62)',
|
|
lineHeight: 1.45,
|
|
}}
|
|
>
|
|
Set up automatic reminders
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div
|
|
onClick={() => setIsRecurringEnabled(!isRecurringEnabled)}
|
|
style={{
|
|
width: 44,
|
|
height: 24,
|
|
borderRadius: 12,
|
|
background: isRecurringEnabled
|
|
? 'var(--flow-accent, #6d28d9)'
|
|
: 'rgba(23,24,39,0.1)',
|
|
position: 'relative',
|
|
cursor: 'pointer',
|
|
transition: '0.2s',
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: 20,
|
|
height: 20,
|
|
background: '#fff',
|
|
borderRadius: 10,
|
|
position: 'absolute',
|
|
top: 2,
|
|
left: isRecurringEnabled ? 22 : 2,
|
|
transition: '0.2s',
|
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{isRecurringEnabled && (
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '14px',
|
|
marginTop: '6px',
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', gap: '8px' }}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setFrequencyUnit('weekly')}
|
|
style={{
|
|
flex: 1,
|
|
height: 40,
|
|
borderRadius: 999,
|
|
fontSize: 14,
|
|
fontWeight: 600,
|
|
transition: '0.2s',
|
|
background:
|
|
frequencyUnit === 'weekly'
|
|
? 'var(--flow-accent, #6d28d9)'
|
|
: 'var(--flow-accent-soft, rgba(109, 40, 217, 0.08))',
|
|
color:
|
|
frequencyUnit === 'weekly'
|
|
? '#ffffff'
|
|
: 'var(--flow-accent, #6d28d9)',
|
|
}}
|
|
>
|
|
Weekly
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setFrequencyUnit('monthly')}
|
|
style={{
|
|
flex: 1,
|
|
height: 40,
|
|
borderRadius: 999,
|
|
fontSize: 14,
|
|
fontWeight: 600,
|
|
transition: '0.2s',
|
|
background:
|
|
frequencyUnit === 'monthly'
|
|
? 'var(--flow-accent, #6d28d9)'
|
|
: 'var(--flow-accent-soft, rgba(109, 40, 217, 0.08))',
|
|
color:
|
|
frequencyUnit === 'monthly'
|
|
? '#ffffff'
|
|
: 'var(--flow-accent, #6d28d9)',
|
|
}}
|
|
>
|
|
Monthly
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
background: '#f5f3f8',
|
|
borderRadius: 12,
|
|
height: 48,
|
|
padding: '0 16px',
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
fontSize: 14,
|
|
fontWeight: 600,
|
|
color: '#171827',
|
|
}}
|
|
>
|
|
Every
|
|
</span>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '16px',
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setFrequencyInterval(
|
|
Math.max(1, frequencyInterval - 1)
|
|
)
|
|
}
|
|
style={{
|
|
width: 24,
|
|
height: 24,
|
|
borderRadius: 12,
|
|
background: 'rgba(109,40,217,0.1)',
|
|
color: '#6d28d9',
|
|
fontSize: 16,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
}}
|
|
>
|
|
-
|
|
</button>
|
|
<span
|
|
style={{
|
|
fontSize: 15,
|
|
fontWeight: 700,
|
|
color: '#171827',
|
|
width: 20,
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
{frequencyInterval}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setFrequencyInterval(
|
|
Math.min(12, frequencyInterval + 1)
|
|
)
|
|
}
|
|
style={{
|
|
width: 24,
|
|
height: 24,
|
|
borderRadius: 12,
|
|
background: 'rgba(109,40,217,0.1)',
|
|
color: '#6d28d9',
|
|
fontSize: 16,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
}}
|
|
>
|
|
+
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{!isShowingPaymentResult && step === reviewStep && (
|
|
<div className="support-form-card">
|
|
<h2 className="sfc-title">Review and pay</h2>
|
|
<div className="mbl-row support-review-primary-row">
|
|
<span>{selectedService?.reviewLabel ?? 'Support amount'}</span>
|
|
<strong>{formatMoney(parsedAmount ?? 0)}</strong>
|
|
</div>
|
|
{(serviceType === 'grocery' || serviceType === 'medication') && (
|
|
<div className="support-redemption-note">
|
|
<div className="support-redemption-note-icon">
|
|
{selectedService?.imageSrc ? (
|
|
<img src={selectedService.imageSrc} alt="" />
|
|
) : (
|
|
<IonIcon icon={selectedService?.icon} />
|
|
)}
|
|
</div>
|
|
<div className="support-redemption-note-copy">
|
|
<p className="support-redemption-note-title">
|
|
Redeemable at any Kumusha{' '}
|
|
{serviceType === 'grocery' ? 'grocery' : 'pharmacy'}{' '}
|
|
partner
|
|
</p>
|
|
<p className="support-redemption-note-subtitle">
|
|
The recipient can use this voucher with approved
|
|
participating merchants.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="mbl-row">
|
|
<span>Service fee</span>
|
|
<strong>{formatMoney(platformFee)}</strong>
|
|
</div>
|
|
<div className="mbl-row total">
|
|
<span>Total</span>
|
|
<strong>{formatMoney(totalAmount)}</strong>
|
|
</div>
|
|
|
|
{isRecurringEnabled && (
|
|
<div
|
|
style={{
|
|
background:
|
|
'var(--flow-accent-soft, rgba(109, 40, 217, 0.05))',
|
|
borderRadius: 12,
|
|
padding: 12,
|
|
marginTop: 12,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 10,
|
|
}}
|
|
>
|
|
<IonIcon
|
|
icon={timeOutline}
|
|
style={{
|
|
color: 'var(--flow-accent, #6d28d9)',
|
|
fontSize: 18,
|
|
}}
|
|
/>
|
|
<span
|
|
style={{
|
|
fontSize: 13,
|
|
fontWeight: 600,
|
|
color: 'var(--flow-accent, #6d28d9)',
|
|
}}
|
|
>
|
|
Remind me every{' '}
|
|
{frequencyInterval === 1
|
|
? frequencyUnit === 'weekly'
|
|
? 'week'
|
|
: 'month'
|
|
: `${frequencyInterval} ${frequencyUnit === 'weekly' ? 'weeks' : 'months'}`}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="support-payment-select-wrap">
|
|
<span className="support-payment-select-label">
|
|
Payment method
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="support-payment-preview support-payment-preview-button"
|
|
onClick={() => setIsPaymentSheetOpen(true)}
|
|
aria-label="Choose payment method"
|
|
>
|
|
<div
|
|
className={`support-payment-method-icon support-payment-method-icon-${selectedPaymentMethod.kind}`}
|
|
aria-hidden="true"
|
|
>
|
|
{selectedPaymentMethod.kind === 'card' && (
|
|
<IonIcon icon={cardOutline} />
|
|
)}
|
|
{selectedPaymentMethod.kind === 'apple' && (
|
|
<IonIcon icon={logoApple} />
|
|
)}
|
|
{selectedPaymentMethod.kind === 'google' && (
|
|
<span
|
|
className="support-google-pay-mark"
|
|
aria-label="Google Pay"
|
|
>
|
|
<svg
|
|
className="support-google-pay-g"
|
|
viewBox="0 0 24 24"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
|
fill="#4285F4"
|
|
/>
|
|
<path
|
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
|
fill="#34A853"
|
|
/>
|
|
<path
|
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"
|
|
fill="#FBBC05"
|
|
/>
|
|
<path
|
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
|
fill="#EA4335"
|
|
/>
|
|
</svg>
|
|
<span className="support-google-pay-pay">Pay</span>
|
|
</span>
|
|
)}
|
|
{selectedPaymentMethod.kind === 'paypal' && (
|
|
<IonIcon icon={logoPaypal} />
|
|
)}
|
|
</div>
|
|
<div className="support-payment-trigger-copy">
|
|
<span className="support-payment-trigger-name">
|
|
{selectedPaymentMethod.label}
|
|
</span>
|
|
<span className="support-payment-trigger-detail">
|
|
{selectedPaymentMethod.detail}
|
|
</span>
|
|
</div>
|
|
<IonIcon
|
|
icon={chevronDownOutline}
|
|
className="support-payment-preview-chevron"
|
|
/>
|
|
</button>
|
|
</div>
|
|
<IonLabel
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
marginTop: 12,
|
|
color: '#6b7280',
|
|
fontSize: 13,
|
|
}}
|
|
>
|
|
<IonIcon icon={cardOutline} /> Saved card will be reused for
|
|
future support in the live version.
|
|
</IonLabel>
|
|
</div>
|
|
)}
|
|
|
|
{!isShowingPaymentResult && (
|
|
<div className="support-inline-action-card">
|
|
<div
|
|
className={`ssb-info ${!showInlineTotalCard ? 'ssb-info-placeholder' : ''}`}
|
|
aria-hidden={!showInlineTotalCard}
|
|
>
|
|
<p className="ssb-label">Total</p>
|
|
<p className="ssb-total">{formatMoney(totalAmount)}</p>
|
|
</div>
|
|
<IonButton
|
|
className="ssb-btn"
|
|
disabled={submitting || loading}
|
|
onClick={
|
|
step === reviewStep ? handleConfirmPayment : handleContinue
|
|
}
|
|
>
|
|
{ctaLabel}
|
|
</IonButton>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<IonModal
|
|
isOpen={isPaymentSheetOpen}
|
|
onDidDismiss={() => setIsPaymentSheetOpen(false)}
|
|
className="support-payment-modal"
|
|
initialBreakpoint={0.58}
|
|
breakpoints={[0, 0.58, 0.84]}
|
|
handle={true}
|
|
>
|
|
<IonContent
|
|
className="support-payment-sheet-content"
|
|
scrollY={true}
|
|
style={{ '--background': '#ffffff' } as React.CSSProperties}
|
|
>
|
|
<div className="support-payment-sheet">
|
|
<div className="support-payment-sheet-header">
|
|
<div>
|
|
<h3 className="support-payment-sheet-title">
|
|
Payment method
|
|
</h3>
|
|
<p className="support-payment-sheet-subtitle">
|
|
Choose how you want to pay for this support.
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="support-payment-sheet-close"
|
|
onClick={() => setIsPaymentSheetOpen(false)}
|
|
aria-label="Close payment method sheet"
|
|
>
|
|
<IonIcon icon={closeOutline} />
|
|
</button>
|
|
</div>
|
|
|
|
<IonList className="support-payment-sheet-list">
|
|
{paymentMethods.map((method) => {
|
|
const isSelected = method.value === paymentMethod;
|
|
return (
|
|
<button
|
|
key={method.value}
|
|
type="button"
|
|
className={`support-payment-sheet-option ${isSelected ? 'selected' : ''}`}
|
|
onClick={() => {
|
|
setPaymentMethod(method.value);
|
|
setIsPaymentSheetOpen(false);
|
|
}}
|
|
>
|
|
<div
|
|
className={`support-payment-method-icon support-payment-method-icon-${method.kind}`}
|
|
aria-hidden="true"
|
|
>
|
|
{method.kind === 'card' && (
|
|
<IonIcon icon={cardOutline} />
|
|
)}
|
|
{method.kind === 'apple' && (
|
|
<IonIcon icon={logoApple} />
|
|
)}
|
|
{method.kind === 'google' && (
|
|
<span
|
|
className="support-google-pay-mark"
|
|
aria-label="Google Pay"
|
|
>
|
|
<svg
|
|
className="support-google-pay-g"
|
|
viewBox="0 0 24 24"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
|
fill="#4285F4"
|
|
/>
|
|
<path
|
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
|
fill="#34A853"
|
|
/>
|
|
<path
|
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"
|
|
fill="#FBBC05"
|
|
/>
|
|
<path
|
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
|
fill="#EA4335"
|
|
/>
|
|
</svg>
|
|
<span className="support-google-pay-pay">Pay</span>
|
|
</span>
|
|
)}
|
|
{method.kind === 'paypal' && (
|
|
<IonIcon icon={logoPaypal} />
|
|
)}
|
|
</div>
|
|
<div className="support-payment-trigger-copy">
|
|
<span className="support-payment-trigger-name">
|
|
{method.label}
|
|
</span>
|
|
<span className="support-payment-trigger-detail">
|
|
{method.detail}
|
|
</span>
|
|
</div>
|
|
{isSelected && (
|
|
<div className="support-payment-selected-dot" />
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</IonList>
|
|
</div>
|
|
</IonContent>
|
|
</IonModal>
|
|
|
|
<IonModal
|
|
isOpen={isRecipientSheetOpen}
|
|
onDidDismiss={() => setIsRecipientSheetOpen(false)}
|
|
className="support-recipient-modal"
|
|
initialBreakpoint={0.55}
|
|
breakpoints={[0, 0.55, 0.82]}
|
|
handle={true}
|
|
>
|
|
<IonContent
|
|
style={{ '--background': '#ffffff' } as React.CSSProperties}
|
|
>
|
|
<div className="support-recipient-sheet">
|
|
<div className="support-recipient-sheet-header">
|
|
<div>
|
|
<h3 className="support-recipient-sheet-title">
|
|
Choose recipient
|
|
</h3>
|
|
<p className="support-recipient-sheet-subtitle">
|
|
Pick who should receive this support.
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="support-recipient-sheet-close"
|
|
onClick={() => setIsRecipientSheetOpen(false)}
|
|
aria-label="Close recipient sheet"
|
|
>
|
|
<IonIcon icon={closeOutline} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="support-recipient-sheet-list">
|
|
{recipients.map((recipient) => {
|
|
const photo = getRecipientPhoto(recipient);
|
|
const isSelected = recipient.id === selectedRecipientId;
|
|
return (
|
|
<button
|
|
key={recipient.id}
|
|
type="button"
|
|
className={`support-recipient-sheet-item ${isSelected ? 'selected' : ''}`}
|
|
onClick={() => {
|
|
setSelectedRecipientId(recipient.id);
|
|
setIsRecipientSheetOpen(false);
|
|
}}
|
|
>
|
|
<div className="support-recipient-sheet-item-left">
|
|
<IonAvatar className="support-recipient-avatar support-recipient-sheet-avatar">
|
|
{photo ? (
|
|
<img
|
|
src={photo}
|
|
alt={`${recipient.first_name} ${recipient.last_name}`}
|
|
/>
|
|
) : (
|
|
<div className="support-recipient-avatar-fallback">
|
|
{`${recipient.first_name.charAt(0)}${recipient.last_name.charAt(0)}`}
|
|
</div>
|
|
)}
|
|
</IonAvatar>
|
|
<div className="support-recipient-copy">
|
|
<span className="support-recipient-name">
|
|
{recipient.first_name} {recipient.last_name}
|
|
</span>
|
|
<span className="support-recipient-hint">
|
|
{isSelected
|
|
? 'Currently selected'
|
|
: 'Tap to choose recipient'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{isSelected && (
|
|
<div className="support-recipient-selected-dot" />
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</IonContent>
|
|
</IonModal>
|
|
</IonContent>
|
|
</IonPage>
|
|
);
|
|
};
|
|
|
|
export default SupportFlowPage;
|