Stable version: Reusable BeneficiaryMenu, subscription fixes
- Created reusable BeneficiaryMenu component with Modal backdrop - Menu closes on outside tap (proper Modal + Pressable implementation) - Removed debug panel from subscription and beneficiary detail pages - Fixed subscription creation and equipment status handling - Backend improvements for Stripe integration
This commit is contained in:
parent
79baf86faf
commit
24e7f057e7
@ -93,8 +93,10 @@ export default function AddLovedOneScreen() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Create beneficiary on server IMMEDIATELY
|
// Create beneficiary on server IMMEDIATELY
|
||||||
|
const trimmedAddress = address.trim();
|
||||||
const result = await api.createBeneficiary({
|
const result = await api.createBeneficiary({
|
||||||
name: trimmedName,
|
name: trimmedName,
|
||||||
|
address: trimmedAddress || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.ok || !result.data) {
|
if (!result.ok || !result.data) {
|
||||||
@ -104,6 +106,15 @@ export default function AddLovedOneScreen() {
|
|||||||
|
|
||||||
const beneficiaryId = result.data.id;
|
const beneficiaryId = result.data.id;
|
||||||
|
|
||||||
|
// Upload avatar if selected
|
||||||
|
if (avatarUri) {
|
||||||
|
const avatarResult = await api.updateBeneficiaryAvatar(beneficiaryId, avatarUri);
|
||||||
|
if (!avatarResult.ok) {
|
||||||
|
console.warn('[AddLovedOne] Failed to upload avatar:', avatarResult.error?.message);
|
||||||
|
// Continue anyway - avatar is not critical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Navigate to the purchase/subscription screen with beneficiary ID
|
// Navigate to the purchase/subscription screen with beneficiary ID
|
||||||
router.replace({
|
router.replace({
|
||||||
pathname: '/(auth)/purchase',
|
pathname: '/(auth)/purchase',
|
||||||
@ -111,7 +122,6 @@ export default function AddLovedOneScreen() {
|
|||||||
beneficiaryId: String(beneficiaryId),
|
beneficiaryId: String(beneficiaryId),
|
||||||
lovedOneName: trimmedName,
|
lovedOneName: trimmedName,
|
||||||
lovedOneAddress: address.trim(),
|
lovedOneAddress: address.trim(),
|
||||||
lovedOneAvatar: avatarUri || '',
|
|
||||||
inviteCode,
|
inviteCode,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -164,7 +164,9 @@ export default function PurchaseScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await api.setOnboardingCompleted(true);
|
await api.setOnboardingCompleted(true);
|
||||||
setStep('order_placed');
|
|
||||||
|
// Redirect directly to equipment-status page (skip order_placed screen)
|
||||||
|
router.replace(`/(tabs)/beneficiaries/${beneficiaryId}/equipment-status`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Payment error:', error);
|
console.error('Payment error:', error);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
|
|||||||
@ -326,6 +326,15 @@ export default function EquipmentStatusScreen() {
|
|||||||
<Text style={styles.supportText}>Need help? Contact support</Text>
|
<Text style={styles.supportText}>Need help? Contact support</Text>
|
||||||
<Ionicons name="chevron-forward" size={18} color={AppColors.textMuted} />
|
<Ionicons name="chevron-forward" size={18} color={AppColors.textMuted} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Back to Loved Ones Button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.backToLovedOnesButton}
|
||||||
|
onPress={() => router.replace('/(tabs)/beneficiaries')}
|
||||||
|
>
|
||||||
|
<Ionicons name="arrow-back" size={20} color={AppColors.primary} />
|
||||||
|
<Text style={styles.backToLovedOnesText}>Back to My Loved Ones</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
@ -539,4 +548,22 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: FontSizes.base,
|
fontSize: FontSizes.base,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
},
|
},
|
||||||
|
// Back to Loved Ones Button
|
||||||
|
backToLovedOnesButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: AppColors.surface,
|
||||||
|
paddingVertical: Spacing.md,
|
||||||
|
borderRadius: BorderRadius.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: AppColors.border,
|
||||||
|
gap: Spacing.sm,
|
||||||
|
marginTop: Spacing.lg,
|
||||||
|
},
|
||||||
|
backToLovedOnesText: {
|
||||||
|
fontSize: FontSizes.base,
|
||||||
|
fontWeight: FontWeights.medium,
|
||||||
|
color: AppColors.primary,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -41,6 +41,7 @@ import {
|
|||||||
hasActiveSubscription,
|
hasActiveSubscription,
|
||||||
shouldShowSubscriptionWarning,
|
shouldShowSubscriptionWarning,
|
||||||
} from '@/services/BeneficiaryDetailController';
|
} from '@/services/BeneficiaryDetailController';
|
||||||
|
import { BeneficiaryMenu } from '@/components/ui/BeneficiaryMenu';
|
||||||
|
|
||||||
// WebView Dashboard URL - opens specific deployment directly
|
// WebView Dashboard URL - opens specific deployment directly
|
||||||
const getDashboardUrl = (deploymentId?: number) => {
|
const getDashboardUrl = (deploymentId?: number) => {
|
||||||
@ -60,7 +61,6 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isMenuVisible, setIsMenuVisible] = useState(false);
|
|
||||||
const [showWebView, setShowWebView] = useState(false);
|
const [showWebView, setShowWebView] = useState(false);
|
||||||
const [isWebViewReady, setIsWebViewReady] = useState(false);
|
const [isWebViewReady, setIsWebViewReady] = useState(false);
|
||||||
const [authToken, setAuthToken] = useState<string | null>(null);
|
const [authToken, setAuthToken] = useState<string | null>(null);
|
||||||
@ -179,19 +179,33 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const beneficiaryId = parseInt(id, 10);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.updateWellNuoBeneficiary(parseInt(id, 10), {
|
// Update basic info
|
||||||
|
const response = await api.updateWellNuoBeneficiary(beneficiaryId, {
|
||||||
name: editForm.name.trim(),
|
name: editForm.name.trim(),
|
||||||
address: editForm.address.trim() || undefined,
|
address: editForm.address.trim() || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (!response.ok) {
|
||||||
|
toast.error('Error', response.error?.message || 'Failed to save changes.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload avatar if changed (new local file URI)
|
||||||
|
if (editForm.avatar && editForm.avatar.startsWith('file://')) {
|
||||||
|
const avatarResult = await api.updateBeneficiaryAvatar(beneficiaryId, editForm.avatar);
|
||||||
|
if (!avatarResult.ok) {
|
||||||
|
console.warn('[BeneficiaryDetail] Failed to upload avatar:', avatarResult.error?.message);
|
||||||
|
// Show info but don't fail the whole operation
|
||||||
|
toast.info('Note', 'Profile saved but avatar upload failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setIsEditModalVisible(false);
|
setIsEditModalVisible(false);
|
||||||
toast.success('Saved', 'Profile updated successfully');
|
toast.success('Saved', 'Profile updated successfully');
|
||||||
loadBeneficiary(false);
|
loadBeneficiary(false);
|
||||||
} else {
|
|
||||||
toast.error('Error', response.error?.message || 'Failed to save changes.');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error('Error', 'Failed to save changes.');
|
toast.error('Error', 'Failed to save changes.');
|
||||||
}
|
}
|
||||||
@ -279,83 +293,14 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
<Text style={styles.headerTitle}>{beneficiary.name}</Text>
|
<Text style={styles.headerTitle}>{beneficiary.name}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View>
|
<BeneficiaryMenu
|
||||||
<TouchableOpacity style={styles.headerButton} onPress={() => setIsMenuVisible(!isMenuVisible)}>
|
beneficiaryId={id || ''}
|
||||||
<Ionicons name="ellipsis-vertical" size={22} color={AppColors.textPrimary} />
|
onEdit={handleEditPress}
|
||||||
</TouchableOpacity>
|
onRemove={handleDeleteBeneficiary}
|
||||||
|
|
||||||
{/* Dropdown Menu */}
|
|
||||||
{isMenuVisible && (
|
|
||||||
<View style={styles.dropdownMenu}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.dropdownItem}
|
|
||||||
onPress={() => {
|
|
||||||
setIsMenuVisible(false);
|
|
||||||
handleEditPress();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name="create-outline" size={20} color={AppColors.textPrimary} />
|
|
||||||
<Text style={styles.dropdownItemText}>Edit</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.dropdownItem}
|
|
||||||
onPress={() => {
|
|
||||||
setIsMenuVisible(false);
|
|
||||||
router.push(`/(tabs)/beneficiaries/${id}/share`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name="share-outline" size={20} color={AppColors.textPrimary} />
|
|
||||||
<Text style={styles.dropdownItemText}>Access</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.dropdownItem}
|
|
||||||
onPress={() => {
|
|
||||||
setIsMenuVisible(false);
|
|
||||||
router.push(`/(tabs)/beneficiaries/${id}/subscription`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name="diamond-outline" size={20} color={AppColors.textPrimary} />
|
|
||||||
<Text style={styles.dropdownItemText}>Subscription</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.dropdownItem}
|
|
||||||
onPress={() => {
|
|
||||||
setIsMenuVisible(false);
|
|
||||||
router.push(`/(tabs)/beneficiaries/${id}/equipment`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name="hardware-chip-outline" size={20} color={AppColors.textPrimary} />
|
|
||||||
<Text style={styles.dropdownItemText}>Equipment</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.dropdownItem, styles.dropdownItemDanger]}
|
|
||||||
onPress={() => {
|
|
||||||
setIsMenuVisible(false);
|
|
||||||
handleDeleteBeneficiary();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name="trash-outline" size={20} color={AppColors.error} />
|
|
||||||
<Text style={[styles.dropdownItemText, styles.dropdownItemTextDanger]}>Remove</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Backdrop to close menu */}
|
|
||||||
{isMenuVisible && (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.menuBackdrop}
|
|
||||||
activeOpacity={1}
|
|
||||||
onPress={() => setIsMenuVisible(false)}
|
|
||||||
/>
|
/>
|
||||||
)}
|
</View>
|
||||||
|
|
||||||
{/* DEBUG PANEL */}
|
{/* DEBUG PANEL - commented out
|
||||||
{__DEV__ && (
|
{__DEV__ && (
|
||||||
<View style={styles.debugPanel}>
|
<View style={styles.debugPanel}>
|
||||||
<Text style={styles.debugTitle}>DEBUG INFO (tap to copy)</Text>
|
<Text style={styles.debugTitle}>DEBUG INFO (tap to copy)</Text>
|
||||||
@ -377,6 +322,7 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
*/}
|
||||||
|
|
||||||
{/* Dashboard Content */}
|
{/* Dashboard Content */}
|
||||||
<View style={styles.dashboardContainer}>
|
<View style={styles.dashboardContainer}>
|
||||||
@ -571,42 +517,6 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: FontWeights.semibold,
|
fontWeight: FontWeights.semibold,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
},
|
},
|
||||||
// Dropdown Menu
|
|
||||||
dropdownMenu: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: 44,
|
|
||||||
right: 0,
|
|
||||||
backgroundColor: AppColors.surface,
|
|
||||||
borderRadius: BorderRadius.lg,
|
|
||||||
minWidth: 160,
|
|
||||||
...Shadows.lg,
|
|
||||||
zIndex: 100,
|
|
||||||
},
|
|
||||||
dropdownItem: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: Spacing.md,
|
|
||||||
gap: Spacing.sm,
|
|
||||||
},
|
|
||||||
dropdownItemText: {
|
|
||||||
fontSize: FontSizes.base,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
},
|
|
||||||
dropdownItemDanger: {
|
|
||||||
borderTopWidth: 1,
|
|
||||||
borderTopColor: AppColors.border,
|
|
||||||
},
|
|
||||||
dropdownItemTextDanger: {
|
|
||||||
color: AppColors.error,
|
|
||||||
},
|
|
||||||
menuBackdrop: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
zIndex: 5,
|
|
||||||
},
|
|
||||||
// Debug Panel
|
// Debug Panel
|
||||||
debugPanel: {
|
debugPanel: {
|
||||||
backgroundColor: '#FFF9C4',
|
backgroundColor: '#FFF9C4',
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,3 +1,5 @@
|
|||||||
|
const path = require('path');
|
||||||
|
require('dotenv').config({ path: path.resolve(__dirname, '../../.env') });
|
||||||
const { Pool } = require('pg');
|
const { Pool } = require('pg');
|
||||||
|
|
||||||
// PostgreSQL connection to eluxnetworks.net
|
// PostgreSQL connection to eluxnetworks.net
|
||||||
|
|||||||
@ -46,12 +46,16 @@ async function getStripeSubscriptionStatus(stripeCustomerId) {
|
|||||||
|
|
||||||
if (subscriptions.data.length > 0) {
|
if (subscriptions.data.length > 0) {
|
||||||
const sub = subscriptions.data[0];
|
const sub = subscriptions.data[0];
|
||||||
|
// Use cancel_at if subscription is set to cancel, otherwise use current_period_end
|
||||||
|
const periodEndTimestamp = sub.cancel_at || sub.current_period_end;
|
||||||
|
const endDate = periodEndTimestamp ? new Date(periodEndTimestamp * 1000).toISOString() : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plan: 'premium',
|
plan: 'premium',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
hasSubscription: true,
|
hasSubscription: true,
|
||||||
currentPeriodEnd: new Date(sub.current_period_end * 1000).toISOString(),
|
endDate: endDate,
|
||||||
cancelAtPeriodEnd: sub.cancel_at_period_end
|
cancelAtPeriodEnd: sub.cancel_at_period_end || false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -64,11 +68,15 @@ async function getStripeSubscriptionStatus(stripeCustomerId) {
|
|||||||
if (allSubs.data.length > 0) {
|
if (allSubs.data.length > 0) {
|
||||||
const sub = allSubs.data[0];
|
const sub = allSubs.data[0];
|
||||||
const normalizedStatus = normalizeStripeStatus(sub.status);
|
const normalizedStatus = normalizeStripeStatus(sub.status);
|
||||||
|
const periodEndTimestamp = sub.cancel_at || sub.current_period_end;
|
||||||
|
const endDate = periodEndTimestamp ? new Date(periodEndTimestamp * 1000).toISOString() : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plan: normalizedStatus === 'canceled' || normalizedStatus === 'none' || normalizedStatus === 'expired' ? 'free' : 'premium',
|
plan: normalizedStatus === 'canceled' || normalizedStatus === 'none' || normalizedStatus === 'expired' ? 'free' : 'premium',
|
||||||
status: normalizedStatus,
|
status: normalizedStatus,
|
||||||
hasSubscription: normalizedStatus === 'active' || normalizedStatus === 'trialing',
|
hasSubscription: normalizedStatus === 'active' || normalizedStatus === 'trialing',
|
||||||
currentPeriodEnd: new Date(sub.current_period_end * 1000).toISOString()
|
endDate: endDate,
|
||||||
|
cancelAtPeriodEnd: sub.cancel_at_period_end || false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -136,12 +144,15 @@ router.get('/', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Query from beneficiaries table (new architecture)
|
// Query from beneficiaries table (new architecture)
|
||||||
const { data: beneficiary } = await supabase
|
console.log('[GET BENEFICIARIES] querying beneficiaries table for id:', beneficiaryTableId);
|
||||||
|
const { data: beneficiary, error: beneficiaryError } = await supabase
|
||||||
.from('beneficiaries')
|
.from('beneficiaries')
|
||||||
.select('id, name, phone, address_street, address_city, address_zip, address_state, address_country, created_at, equipment_status, stripe_customer_id')
|
.select('id, name, phone, address, avatar_url, created_at, equipment_status, stripe_customer_id')
|
||||||
.eq('id', beneficiaryTableId)
|
.eq('id', beneficiaryTableId)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
|
console.log('[GET BENEFICIARIES] got beneficiary:', beneficiary ? beneficiary.name : null, 'error:', beneficiaryError);
|
||||||
|
|
||||||
if (beneficiary) {
|
if (beneficiary) {
|
||||||
const subscription = await getStripeSubscriptionStatus(beneficiary.stripe_customer_id);
|
const subscription = await getStripeSubscriptionStatus(beneficiary.stripe_customer_id);
|
||||||
beneficiaries.push({
|
beneficiaries.push({
|
||||||
@ -151,13 +162,8 @@ router.get('/', async (req, res) => {
|
|||||||
grantedAt: record.granted_at,
|
grantedAt: record.granted_at,
|
||||||
name: beneficiary.name,
|
name: beneficiary.name,
|
||||||
phone: beneficiary.phone,
|
phone: beneficiary.phone,
|
||||||
address: {
|
address: beneficiary.address || null,
|
||||||
street: beneficiary.address_street,
|
avatarUrl: beneficiary.avatar_url,
|
||||||
city: beneficiary.address_city,
|
|
||||||
zip: beneficiary.address_zip,
|
|
||||||
state: beneficiary.address_state,
|
|
||||||
country: beneficiary.address_country
|
|
||||||
},
|
|
||||||
createdAt: beneficiary.created_at,
|
createdAt: beneficiary.created_at,
|
||||||
subscription: subscription,
|
subscription: subscription,
|
||||||
// Equipment status from beneficiaries table - CRITICAL for navigation!
|
// Equipment status from beneficiaries table - CRITICAL for navigation!
|
||||||
@ -223,13 +229,8 @@ router.get('/:id', async (req, res) => {
|
|||||||
id: beneficiary.id,
|
id: beneficiary.id,
|
||||||
name: beneficiary.name,
|
name: beneficiary.name,
|
||||||
phone: beneficiary.phone,
|
phone: beneficiary.phone,
|
||||||
address: {
|
address: beneficiary.address || null,
|
||||||
street: beneficiary.address_street,
|
avatarUrl: beneficiary.avatar_url,
|
||||||
city: beneficiary.address_city,
|
|
||||||
zip: beneficiary.address_zip,
|
|
||||||
state: beneficiary.address_state,
|
|
||||||
country: beneficiary.address_country
|
|
||||||
},
|
|
||||||
role: access.role,
|
role: access.role,
|
||||||
subscription: subscription,
|
subscription: subscription,
|
||||||
orders: orders || [],
|
orders: orders || [],
|
||||||
@ -266,11 +267,7 @@ router.post('/', async (req, res) => {
|
|||||||
.insert({
|
.insert({
|
||||||
name: name,
|
name: name,
|
||||||
phone: phone || null,
|
phone: phone || null,
|
||||||
address_street: address?.street || null,
|
address: address || null,
|
||||||
address_city: address?.city || null,
|
|
||||||
address_zip: address?.zip || null,
|
|
||||||
address_state: address?.state || null,
|
|
||||||
address_country: address?.country || null,
|
|
||||||
equipment_status: 'none',
|
equipment_status: 'none',
|
||||||
created_by: userId,
|
created_by: userId,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
@ -313,13 +310,8 @@ router.post('/', async (req, res) => {
|
|||||||
id: beneficiary.id,
|
id: beneficiary.id,
|
||||||
name: beneficiary.name,
|
name: beneficiary.name,
|
||||||
phone: beneficiary.phone,
|
phone: beneficiary.phone,
|
||||||
address: {
|
address: beneficiary.address || null,
|
||||||
street: beneficiary.address_street,
|
avatarUrl: beneficiary.avatar_url,
|
||||||
city: beneficiary.address_city,
|
|
||||||
zip: beneficiary.address_zip,
|
|
||||||
state: beneficiary.address_state,
|
|
||||||
country: beneficiary.address_country
|
|
||||||
},
|
|
||||||
role: 'custodian',
|
role: 'custodian',
|
||||||
equipmentStatus: 'none'
|
equipmentStatus: 'none'
|
||||||
}
|
}
|
||||||
@ -341,6 +333,8 @@ router.patch('/:id', async (req, res) => {
|
|||||||
const userId = req.user.userId;
|
const userId = req.user.userId;
|
||||||
const beneficiaryId = parseInt(req.params.id, 10);
|
const beneficiaryId = parseInt(req.params.id, 10);
|
||||||
|
|
||||||
|
console.log('[BENEFICIARY PATCH] Request:', { userId, beneficiaryId, body: req.body });
|
||||||
|
|
||||||
// Check user has custodian or guardian access - using beneficiary_id
|
// Check user has custodian or guardian access - using beneficiary_id
|
||||||
const { data: access, error: accessError } = await supabase
|
const { data: access, error: accessError } = await supabase
|
||||||
.from('user_access')
|
.from('user_access')
|
||||||
@ -353,7 +347,7 @@ router.patch('/:id', async (req, res) => {
|
|||||||
return res.status(403).json({ error: 'Only custodian or guardian can update beneficiary info' });
|
return res.status(403).json({ error: 'Only custodian or guardian can update beneficiary info' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name, phone, addressStreet, addressCity, addressZip, addressState, addressCountry } = req.body;
|
const { name, phone, address } = req.body;
|
||||||
|
|
||||||
const updateData = {
|
const updateData = {
|
||||||
updated_at: new Date().toISOString()
|
updated_at: new Date().toISOString()
|
||||||
@ -361,11 +355,7 @@ router.patch('/:id', async (req, res) => {
|
|||||||
|
|
||||||
if (name !== undefined) updateData.name = name;
|
if (name !== undefined) updateData.name = name;
|
||||||
if (phone !== undefined) updateData.phone = phone;
|
if (phone !== undefined) updateData.phone = phone;
|
||||||
if (addressStreet !== undefined) updateData.address_street = addressStreet;
|
if (address !== undefined) updateData.address = address;
|
||||||
if (addressCity !== undefined) updateData.address_city = addressCity;
|
|
||||||
if (addressZip !== undefined) updateData.address_zip = addressZip;
|
|
||||||
if (addressState !== undefined) updateData.address_state = addressState;
|
|
||||||
if (addressCountry !== undefined) updateData.address_country = addressCountry;
|
|
||||||
|
|
||||||
// Update in beneficiaries table
|
// Update in beneficiaries table
|
||||||
const { data: beneficiary, error } = await supabase
|
const { data: beneficiary, error } = await supabase
|
||||||
@ -376,27 +366,25 @@ router.patch('/:id', async (req, res) => {
|
|||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
console.error('[BENEFICIARY PATCH] Supabase error:', error);
|
||||||
return res.status(500).json({ error: 'Failed to update beneficiary' });
|
return res.status(500).json({ error: 'Failed to update beneficiary' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[BENEFICIARY PATCH] Success:', { id: beneficiary.id, name: beneficiary.name, address: beneficiary.address });
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
beneficiary: {
|
beneficiary: {
|
||||||
id: beneficiary.id,
|
id: beneficiary.id,
|
||||||
name: beneficiary.name,
|
name: beneficiary.name,
|
||||||
phone: beneficiary.phone,
|
phone: beneficiary.phone,
|
||||||
address: {
|
address: beneficiary.address || null,
|
||||||
street: beneficiary.address_street,
|
avatarUrl: beneficiary.avatar_url
|
||||||
city: beneficiary.address_city,
|
|
||||||
zip: beneficiary.address_zip,
|
|
||||||
state: beneficiary.address_state,
|
|
||||||
country: beneficiary.address_country
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Update beneficiary error:', error);
|
console.error('[BENEFICIARY PATCH] Error:', error);
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -713,7 +701,7 @@ router.post('/:id/activate', async (req, res) => {
|
|||||||
updated_at: new Date().toISOString()
|
updated_at: new Date().toISOString()
|
||||||
})
|
})
|
||||||
.eq('id', beneficiaryId)
|
.eq('id', beneficiaryId)
|
||||||
.select('id, first_name, last_name, equipment_status')
|
.select('id, name, equipment_status')
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (updateError) {
|
if (updateError) {
|
||||||
@ -727,8 +715,7 @@ router.post('/:id/activate', async (req, res) => {
|
|||||||
success: true,
|
success: true,
|
||||||
beneficiary: {
|
beneficiary: {
|
||||||
id: beneficiary?.id || beneficiaryId,
|
id: beneficiary?.id || beneficiaryId,
|
||||||
firstName: beneficiary?.first_name || null,
|
name: beneficiary?.name || null,
|
||||||
lastName: beneficiary?.last_name || null,
|
|
||||||
hasDevices: true,
|
hasDevices: true,
|
||||||
equipmentStatus: equipmentStatus
|
equipmentStatus: equipmentStatus
|
||||||
}
|
}
|
||||||
@ -828,6 +815,68 @@ router.post('/:id/transfer', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/me/beneficiaries/:id/avatar
|
||||||
|
* Upload/update beneficiary avatar (base64 image)
|
||||||
|
*/
|
||||||
|
router.patch('/:id/avatar', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.userId;
|
||||||
|
const beneficiaryId = parseInt(req.params.id, 10);
|
||||||
|
const { avatar } = req.body; // base64 string or null to remove
|
||||||
|
|
||||||
|
console.log('[BENEFICIARY] Avatar update:', { userId, beneficiaryId, hasAvatar: !!avatar });
|
||||||
|
|
||||||
|
// Check user has custodian or guardian access
|
||||||
|
const { data: access, error: accessError } = await supabase
|
||||||
|
.from('user_access')
|
||||||
|
.select('role')
|
||||||
|
.eq('accessor_id', userId)
|
||||||
|
.eq('beneficiary_id', beneficiaryId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (accessError || !access || !['custodian', 'guardian'].includes(access.role)) {
|
||||||
|
return res.status(403).json({ error: 'Only custodian or guardian can update avatar' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate base64 if provided
|
||||||
|
if (avatar && !avatar.startsWith('data:image/')) {
|
||||||
|
return res.status(400).json({ error: 'Invalid image format. Must be base64 data URI' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update avatar_url in beneficiaries table
|
||||||
|
const { data: beneficiary, error } = await supabase
|
||||||
|
.from('beneficiaries')
|
||||||
|
.update({
|
||||||
|
avatar_url: avatar || null,
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
.eq('id', beneficiaryId)
|
||||||
|
.select('id, name, avatar_url')
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('[BENEFICIARY] Avatar update error:', error);
|
||||||
|
return res.status(500).json({ error: 'Failed to update avatar' });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[BENEFICIARY] Avatar updated:', { beneficiaryId, hasAvatar: !!beneficiary.avatar_url });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
beneficiary: {
|
||||||
|
id: beneficiary.id,
|
||||||
|
name: beneficiary.name,
|
||||||
|
avatarUrl: beneficiary.avatar_url
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BENEFICIARY] Avatar error:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update equipment status for a beneficiary
|
* Update equipment status for a beneficiary
|
||||||
* PATCH /me/beneficiaries/:id/equipment-status
|
* PATCH /me/beneficiaries/:id/equipment-status
|
||||||
@ -870,7 +919,7 @@ router.patch('/:id/equipment-status', authMiddleware, async (req, res) => {
|
|||||||
updated_at: new Date().toISOString()
|
updated_at: new Date().toISOString()
|
||||||
})
|
})
|
||||||
.eq('id', beneficiaryId)
|
.eq('id', beneficiaryId)
|
||||||
.select('id, first_name, equipment_status')
|
.select('id, name, equipment_status')
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (updateError) {
|
if (updateError) {
|
||||||
@ -888,7 +937,7 @@ router.patch('/:id/equipment-status', authMiddleware, async (req, res) => {
|
|||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
id: updated.id,
|
id: updated.id,
|
||||||
firstName: updated.first_name,
|
name: updated.name,
|
||||||
equipmentStatus: updated.equipment_status
|
equipmentStatus: updated.equipment_status
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -424,19 +424,23 @@ router.get('/subscription-status/:beneficiaryId', async (req, res) => {
|
|||||||
router.post('/cancel-subscription', async (req, res) => {
|
router.post('/cancel-subscription', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { beneficiaryId } = req.body;
|
const { beneficiaryId } = req.body;
|
||||||
|
console.log('[CANCEL] Request received for beneficiaryId:', beneficiaryId);
|
||||||
|
|
||||||
if (!beneficiaryId) {
|
if (!beneficiaryId) {
|
||||||
return res.status(400).json({ error: 'beneficiaryId is required' });
|
return res.status(400).json({ error: 'beneficiaryId is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get beneficiary's stripe_customer_id
|
// Get beneficiary's stripe_customer_id
|
||||||
const { data: beneficiary } = await supabase
|
const { data: beneficiary, error: dbError } = await supabase
|
||||||
.from('beneficiaries')
|
.from('beneficiaries')
|
||||||
.select('stripe_customer_id')
|
.select('stripe_customer_id')
|
||||||
.eq('id', beneficiaryId)
|
.eq('id', beneficiaryId)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
|
console.log('[CANCEL] DB result:', { beneficiary, dbError });
|
||||||
|
|
||||||
if (!beneficiary?.stripe_customer_id) {
|
if (!beneficiary?.stripe_customer_id) {
|
||||||
|
console.log('[CANCEL] No stripe_customer_id found');
|
||||||
return res.status(404).json({ error: 'No subscription found' });
|
return res.status(404).json({ error: 'No subscription found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -456,12 +460,16 @@ router.post('/cancel-subscription', async (req, res) => {
|
|||||||
cancel_at_period_end: true
|
cancel_at_period_end: true
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`✓ Subscription ${subscription.id} will cancel at period end`);
|
console.log(`✓ Subscription ${subscription.id} will cancel at period end:`, subscription.current_period_end);
|
||||||
|
|
||||||
|
const cancelAt = subscription.current_period_end
|
||||||
|
? new Date(subscription.current_period_end * 1000).toISOString()
|
||||||
|
: null;
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Subscription will cancel at the end of the billing period',
|
message: 'Subscription will cancel at the end of the billing period',
|
||||||
cancelAt: new Date(subscription.current_period_end * 1000).toISOString()
|
cancelAt
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -553,6 +561,22 @@ router.post('/create-subscription-payment-sheet', async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cancel any incomplete subscriptions to avoid duplicates
|
||||||
|
const incompleteSubs = await stripe.subscriptions.list({
|
||||||
|
customer: customerId,
|
||||||
|
status: 'incomplete',
|
||||||
|
limit: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const sub of incompleteSubs.data) {
|
||||||
|
try {
|
||||||
|
await stripe.subscriptions.cancel(sub.id);
|
||||||
|
console.log(`Canceled incomplete subscription ${sub.id} for customer ${customerId}`);
|
||||||
|
} catch (cancelError) {
|
||||||
|
console.warn(`Failed to cancel incomplete subscription ${sub.id}:`, cancelError.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create ephemeral key
|
// Create ephemeral key
|
||||||
const ephemeralKey = await stripe.ephemeralKeys.create(
|
const ephemeralKey = await stripe.ephemeralKeys.create(
|
||||||
{ customer: customerId },
|
{ customer: customerId },
|
||||||
@ -574,11 +598,37 @@ router.post('/create-subscription-payment-sheet', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const paymentIntent = subscription.latest_invoice?.payment_intent;
|
// Try to get payment_intent from expanded invoice
|
||||||
|
let clientSecret = subscription.latest_invoice?.payment_intent?.client_secret;
|
||||||
|
|
||||||
|
// Stripe SDK v20+ doesn't expose payment_intent field in Invoice object
|
||||||
|
// Need to fetch PaymentIntent via list API as a workaround
|
||||||
|
if (!clientSecret && subscription.latest_invoice) {
|
||||||
|
const invoiceId = typeof subscription.latest_invoice === 'string'
|
||||||
|
? subscription.latest_invoice
|
||||||
|
: subscription.latest_invoice.id;
|
||||||
|
|
||||||
|
if (invoiceId) {
|
||||||
|
// List recent PaymentIntents for this customer and find the one for this invoice
|
||||||
|
const paymentIntents = await stripe.paymentIntents.list({
|
||||||
|
customer: customerId,
|
||||||
|
limit: 5
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const pi of paymentIntents.data) {
|
||||||
|
if (pi.invoice === invoiceId || pi.description?.includes('Subscription')) {
|
||||||
|
clientSecret = pi.client_secret;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[SUBSCRIPTION] Created subscription ${subscription.id}, clientSecret: ${!!clientSecret}`);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
subscriptionId: subscription.id,
|
subscriptionId: subscription.id,
|
||||||
clientSecret: paymentIntent?.client_secret,
|
clientSecret: clientSecret,
|
||||||
ephemeralKey: ephemeralKey.secret,
|
ephemeralKey: ephemeralKey.secret,
|
||||||
customer: customerId,
|
customer: customerId,
|
||||||
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY
|
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY
|
||||||
@ -626,6 +676,95 @@ router.post('/confirm-subscription-payment', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/stripe/transaction-history/:beneficiaryId
|
||||||
|
* Gets transaction/invoice history directly from Stripe
|
||||||
|
*/
|
||||||
|
router.get('/transaction-history/:beneficiaryId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { beneficiaryId } = req.params;
|
||||||
|
const limit = parseInt(req.query.limit) || 10;
|
||||||
|
|
||||||
|
// Get beneficiary's stripe_customer_id
|
||||||
|
const { data: beneficiary } = await supabase
|
||||||
|
.from('beneficiaries')
|
||||||
|
.select('stripe_customer_id')
|
||||||
|
.eq('id', beneficiaryId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!beneficiary?.stripe_customer_id) {
|
||||||
|
return res.json({
|
||||||
|
transactions: [],
|
||||||
|
hasMore: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get invoices from Stripe
|
||||||
|
const invoices = await stripe.invoices.list({
|
||||||
|
customer: beneficiary.stripe_customer_id,
|
||||||
|
limit: limit,
|
||||||
|
expand: ['data.subscription']
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also get PaymentIntents for one-time purchases (equipment)
|
||||||
|
const paymentIntents = await stripe.paymentIntents.list({
|
||||||
|
customer: beneficiary.stripe_customer_id,
|
||||||
|
limit: limit
|
||||||
|
});
|
||||||
|
|
||||||
|
// Format invoices (subscription payments) - only show paid invoices
|
||||||
|
const formattedInvoices = invoices.data
|
||||||
|
.filter(invoice => invoice.status === 'paid' && invoice.amount_paid > 0)
|
||||||
|
.map(invoice => ({
|
||||||
|
id: invoice.id,
|
||||||
|
type: 'subscription',
|
||||||
|
amount: invoice.amount_paid / 100,
|
||||||
|
currency: invoice.currency.toUpperCase(),
|
||||||
|
status: invoice.status,
|
||||||
|
date: new Date(invoice.created * 1000).toISOString(),
|
||||||
|
description: invoice.lines.data[0]?.description || 'WellNuo Premium',
|
||||||
|
invoicePdf: invoice.invoice_pdf,
|
||||||
|
hostedUrl: invoice.hosted_invoice_url
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Format payment intents (one-time purchases like equipment)
|
||||||
|
// Exclude subscription-related payments (they're already in invoices)
|
||||||
|
const formattedPayments = paymentIntents.data
|
||||||
|
.filter(pi => {
|
||||||
|
if (pi.status !== 'succeeded') return false;
|
||||||
|
if (pi.invoice) return false; // Has linked invoice
|
||||||
|
// Exclude "Subscription creation" - it's duplicate of invoice
|
||||||
|
if (pi.description === 'Subscription creation') return false;
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map(pi => ({
|
||||||
|
id: pi.id,
|
||||||
|
type: 'one_time',
|
||||||
|
amount: pi.amount / 100,
|
||||||
|
currency: pi.currency.toUpperCase(),
|
||||||
|
status: pi.status,
|
||||||
|
date: new Date(pi.created * 1000).toISOString(),
|
||||||
|
description: pi.metadata?.orderType === 'starter_kit'
|
||||||
|
? 'WellNuo Starter Kit'
|
||||||
|
: (pi.description || 'One-time payment'),
|
||||||
|
receiptUrl: pi.charges?.data[0]?.receipt_url
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Combine and sort by date (newest first)
|
||||||
|
const allTransactions = [...formattedInvoices, ...formattedPayments]
|
||||||
|
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
transactions: allTransactions,
|
||||||
|
hasMore: invoices.has_more || paymentIntents.has_more
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get transaction history error:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/stripe/session/:sessionId
|
* GET /api/stripe/session/:sessionId
|
||||||
* Get checkout session details (for success page)
|
* Get checkout session details (for success page)
|
||||||
|
|||||||
183
components/ui/BeneficiaryMenu.tsx
Normal file
183
components/ui/BeneficiaryMenu.tsx
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { View, Text, TouchableOpacity, StyleSheet, Modal, Pressable } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import { AppColors, BorderRadius, FontSizes, Spacing, Shadows } from '@/constants/theme';
|
||||||
|
|
||||||
|
export type MenuItemId = 'edit' | 'access' | 'subscription' | 'equipment' | 'remove';
|
||||||
|
|
||||||
|
interface MenuItem {
|
||||||
|
id: MenuItemId;
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
label: string;
|
||||||
|
danger?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_MENU_ITEMS: MenuItem[] = [
|
||||||
|
{ id: 'edit', icon: 'create-outline', label: 'Edit' },
|
||||||
|
{ id: 'access', icon: 'share-outline', label: 'Access' },
|
||||||
|
{ id: 'subscription', icon: 'diamond-outline', label: 'Subscription' },
|
||||||
|
{ id: 'equipment', icon: 'hardware-chip-outline', label: 'Equipment' },
|
||||||
|
{ id: 'remove', icon: 'trash-outline', label: 'Remove', danger: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface BeneficiaryMenuProps {
|
||||||
|
beneficiaryId: string | number;
|
||||||
|
/** Which menu items to show. If not provided, shows all except current page */
|
||||||
|
visibleItems?: MenuItemId[];
|
||||||
|
/** Which menu item represents the current page (will be hidden) */
|
||||||
|
currentPage?: MenuItemId;
|
||||||
|
/** Custom handler for Edit action */
|
||||||
|
onEdit?: () => void;
|
||||||
|
/** Custom handler for Remove action */
|
||||||
|
onRemove?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BeneficiaryMenu({
|
||||||
|
beneficiaryId,
|
||||||
|
visibleItems,
|
||||||
|
currentPage,
|
||||||
|
onEdit,
|
||||||
|
onRemove,
|
||||||
|
}: BeneficiaryMenuProps) {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
const handleMenuAction = (itemId: MenuItemId) => {
|
||||||
|
setIsVisible(false);
|
||||||
|
|
||||||
|
switch (itemId) {
|
||||||
|
case 'edit':
|
||||||
|
if (onEdit) {
|
||||||
|
onEdit();
|
||||||
|
} else {
|
||||||
|
// Navigate to main page with edit intent
|
||||||
|
router.push(`/(tabs)/beneficiaries/${beneficiaryId}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'access':
|
||||||
|
router.push(`/(tabs)/beneficiaries/${beneficiaryId}/share`);
|
||||||
|
break;
|
||||||
|
case 'subscription':
|
||||||
|
router.push(`/(tabs)/beneficiaries/${beneficiaryId}/subscription`);
|
||||||
|
break;
|
||||||
|
case 'equipment':
|
||||||
|
router.push(`/(tabs)/beneficiaries/${beneficiaryId}/equipment`);
|
||||||
|
break;
|
||||||
|
case 'remove':
|
||||||
|
if (onRemove) {
|
||||||
|
onRemove();
|
||||||
|
} else {
|
||||||
|
// Navigate to main page
|
||||||
|
router.push(`/(tabs)/beneficiaries/${beneficiaryId}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter menu items - only hide current page
|
||||||
|
let menuItems = ALL_MENU_ITEMS;
|
||||||
|
|
||||||
|
if (visibleItems) {
|
||||||
|
menuItems = ALL_MENU_ITEMS.filter(item => visibleItems.includes(item.id));
|
||||||
|
} else if (currentPage) {
|
||||||
|
menuItems = ALL_MENU_ITEMS.filter(item => item.id !== currentPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.menuButton}
|
||||||
|
onPress={() => setIsVisible(!isVisible)}
|
||||||
|
>
|
||||||
|
<Ionicons name="ellipsis-vertical" size={22} color={AppColors.textPrimary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={isVisible}
|
||||||
|
transparent={true}
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setIsVisible(false)}
|
||||||
|
>
|
||||||
|
{/* Full screen backdrop */}
|
||||||
|
<Pressable
|
||||||
|
style={styles.modalBackdrop}
|
||||||
|
onPress={() => setIsVisible(false)}
|
||||||
|
>
|
||||||
|
{/* Menu positioned at top right */}
|
||||||
|
<Pressable
|
||||||
|
style={styles.dropdownMenuContainer}
|
||||||
|
onPress={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<View style={styles.dropdownMenu}>
|
||||||
|
{menuItems.map((item) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={item.id}
|
||||||
|
style={[
|
||||||
|
styles.dropdownItem,
|
||||||
|
item.danger && styles.dropdownItemDanger,
|
||||||
|
]}
|
||||||
|
onPress={() => handleMenuAction(item.id)}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={item.icon}
|
||||||
|
size={20}
|
||||||
|
color={item.danger ? AppColors.error : AppColors.textPrimary}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.dropdownItemText,
|
||||||
|
item.danger && styles.dropdownItemTextDanger,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
menuButton: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
modalBackdrop: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
|
dropdownMenuContainer: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 100, // Below status bar and header
|
||||||
|
right: Spacing.md,
|
||||||
|
},
|
||||||
|
dropdownMenu: {
|
||||||
|
backgroundColor: AppColors.surface,
|
||||||
|
borderRadius: BorderRadius.lg,
|
||||||
|
minWidth: 160,
|
||||||
|
...Shadows.lg,
|
||||||
|
},
|
||||||
|
dropdownItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: Spacing.md,
|
||||||
|
gap: Spacing.sm,
|
||||||
|
},
|
||||||
|
dropdownItemText: {
|
||||||
|
fontSize: FontSizes.base,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
},
|
||||||
|
dropdownItemDanger: {
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: AppColors.border,
|
||||||
|
},
|
||||||
|
dropdownItemTextDanger: {
|
||||||
|
color: AppColors.error,
|
||||||
|
},
|
||||||
|
});
|
||||||
102
services/api.ts
102
services/api.ts
@ -649,7 +649,8 @@ class ApiService {
|
|||||||
id: data.id,
|
id: data.id,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
hasDevices: data.hasDevices,
|
hasDevices: data.hasDevices,
|
||||||
equipmentStatus: data.equipmentStatus
|
equipmentStatus: data.equipmentStatus,
|
||||||
|
subscription: data.subscription
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@ -666,7 +667,8 @@ class ApiService {
|
|||||||
subscription: data.subscription ? {
|
subscription: data.subscription ? {
|
||||||
status: data.subscription.status,
|
status: data.subscription.status,
|
||||||
plan: data.subscription.plan,
|
plan: data.subscription.plan,
|
||||||
endDate: data.subscription.currentPeriodEnd,
|
endDate: data.subscription.endDate,
|
||||||
|
cancelAtPeriodEnd: data.subscription.cancelAtPeriodEnd,
|
||||||
} : undefined,
|
} : undefined,
|
||||||
// Equipment status from orders
|
// Equipment status from orders
|
||||||
equipmentStatus: data.equipmentStatus,
|
equipmentStatus: data.equipmentStatus,
|
||||||
@ -761,6 +763,55 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Upload/update beneficiary avatar
|
||||||
|
async updateBeneficiaryAvatar(id: number, imageUri: string | null): Promise<ApiResponse<{ avatarUrl: string | null }>> {
|
||||||
|
const token = await this.getToken();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let base64Image: string | null = null;
|
||||||
|
|
||||||
|
if (imageUri) {
|
||||||
|
// Convert file URI to base64
|
||||||
|
const response = await fetch(imageUri);
|
||||||
|
const blob = await response.blob();
|
||||||
|
|
||||||
|
// Convert blob to base64
|
||||||
|
const base64 = await new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => resolve(reader.result as string);
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
|
||||||
|
base64Image = base64;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiResponse = await fetch(`${WELLNUO_API_URL}/me/beneficiaries/${id}/avatar`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ avatar: base64Image }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await apiResponse.json();
|
||||||
|
|
||||||
|
if (!apiResponse.ok) {
|
||||||
|
return { ok: false, error: { message: data.error || 'Failed to update avatar' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data: { avatarUrl: data.beneficiary?.avatarUrl || null }, ok: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[API] updateBeneficiaryAvatar error:', error);
|
||||||
|
return { ok: false, error: { message: 'Network error', code: 'NETWORK_ERROR' } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Delete beneficiary (removes access record)
|
// Delete beneficiary (removes access record)
|
||||||
async deleteBeneficiary(id: number): Promise<ApiResponse<{ success: boolean }>> {
|
async deleteBeneficiary(id: number): Promise<ApiResponse<{ success: boolean }>> {
|
||||||
const token = await this.getToken();
|
const token = await this.getToken();
|
||||||
@ -1047,6 +1098,53 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get transaction history from Stripe
|
||||||
|
async getTransactionHistory(beneficiaryId: number, limit = 10): Promise<ApiResponse<{
|
||||||
|
transactions: Array<{
|
||||||
|
id: string;
|
||||||
|
type: 'subscription' | 'one_time';
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
status: string;
|
||||||
|
date: string;
|
||||||
|
description: string;
|
||||||
|
invoicePdf?: string;
|
||||||
|
hostedUrl?: string;
|
||||||
|
receiptUrl?: string;
|
||||||
|
}>;
|
||||||
|
hasMore: boolean;
|
||||||
|
}>> {
|
||||||
|
const token = await this.getToken();
|
||||||
|
if (!token) {
|
||||||
|
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${WELLNUO_API_URL}/stripe/transaction-history/${beneficiaryId}?limit=${limit}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
return { data, ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: { message: data.error || 'Failed to get transaction history' },
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: { message: 'Network error. Please check your connection.' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Reactivate subscription that was set to cancel
|
// Reactivate subscription that was set to cancel
|
||||||
async reactivateSubscription(beneficiaryId: number): Promise<ApiResponse<{ success: boolean; status: string }>> {
|
async reactivateSubscription(beneficiaryId: number): Promise<ApiResponse<{ success: boolean; status: string }>> {
|
||||||
const token = await this.getToken();
|
const token = await this.getToken();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user