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 {
|
||||
// Create beneficiary on server IMMEDIATELY
|
||||
const trimmedAddress = address.trim();
|
||||
const result = await api.createBeneficiary({
|
||||
name: trimmedName,
|
||||
address: trimmedAddress || undefined,
|
||||
});
|
||||
|
||||
if (!result.ok || !result.data) {
|
||||
@ -104,6 +106,15 @@ export default function AddLovedOneScreen() {
|
||||
|
||||
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
|
||||
router.replace({
|
||||
pathname: '/(auth)/purchase',
|
||||
@ -111,7 +122,6 @@ export default function AddLovedOneScreen() {
|
||||
beneficiaryId: String(beneficiaryId),
|
||||
lovedOneName: trimmedName,
|
||||
lovedOneAddress: address.trim(),
|
||||
lovedOneAvatar: avatarUri || '',
|
||||
inviteCode,
|
||||
},
|
||||
});
|
||||
|
||||
@ -164,7 +164,9 @@ export default function PurchaseScreen() {
|
||||
}
|
||||
|
||||
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) {
|
||||
console.error('Payment error:', error);
|
||||
Alert.alert(
|
||||
|
||||
@ -326,6 +326,15 @@ export default function EquipmentStatusScreen() {
|
||||
<Text style={styles.supportText}>Need help? Contact support</Text>
|
||||
<Ionicons name="chevron-forward" size={18} color={AppColors.textMuted} />
|
||||
</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>
|
||||
</SafeAreaView>
|
||||
);
|
||||
@ -539,4 +548,22 @@ const styles = StyleSheet.create({
|
||||
fontSize: FontSizes.base,
|
||||
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,
|
||||
shouldShowSubscriptionWarning,
|
||||
} from '@/services/BeneficiaryDetailController';
|
||||
import { BeneficiaryMenu } from '@/components/ui/BeneficiaryMenu';
|
||||
|
||||
// WebView Dashboard URL - opens specific deployment directly
|
||||
const getDashboardUrl = (deploymentId?: number) => {
|
||||
@ -60,7 +61,6 @@ export default function BeneficiaryDetailScreen() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isMenuVisible, setIsMenuVisible] = useState(false);
|
||||
const [showWebView, setShowWebView] = useState(false);
|
||||
const [isWebViewReady, setIsWebViewReady] = useState(false);
|
||||
const [authToken, setAuthToken] = useState<string | null>(null);
|
||||
@ -179,19 +179,33 @@ export default function BeneficiaryDetailScreen() {
|
||||
return;
|
||||
}
|
||||
|
||||
const beneficiaryId = parseInt(id, 10);
|
||||
|
||||
try {
|
||||
const response = await api.updateWellNuoBeneficiary(parseInt(id, 10), {
|
||||
// Update basic info
|
||||
const response = await api.updateWellNuoBeneficiary(beneficiaryId, {
|
||||
name: editForm.name.trim(),
|
||||
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);
|
||||
toast.success('Saved', 'Profile updated successfully');
|
||||
loadBeneficiary(false);
|
||||
} else {
|
||||
toast.error('Error', response.error?.message || 'Failed to save changes.');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error', 'Failed to save changes.');
|
||||
}
|
||||
@ -279,83 +293,14 @@ export default function BeneficiaryDetailScreen() {
|
||||
<Text style={styles.headerTitle}>{beneficiary.name}</Text>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<TouchableOpacity style={styles.headerButton} onPress={() => setIsMenuVisible(!isMenuVisible)}>
|
||||
<Ionicons name="ellipsis-vertical" size={22} color={AppColors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 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)}
|
||||
<BeneficiaryMenu
|
||||
beneficiaryId={id || ''}
|
||||
onEdit={handleEditPress}
|
||||
onRemove={handleDeleteBeneficiary}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* DEBUG PANEL */}
|
||||
{/* DEBUG PANEL - commented out
|
||||
{__DEV__ && (
|
||||
<View style={styles.debugPanel}>
|
||||
<Text style={styles.debugTitle}>DEBUG INFO (tap to copy)</Text>
|
||||
@ -377,6 +322,7 @@ export default function BeneficiaryDetailScreen() {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
*/}
|
||||
|
||||
{/* Dashboard Content */}
|
||||
<View style={styles.dashboardContainer}>
|
||||
@ -571,42 +517,6 @@ const styles = StyleSheet.create({
|
||||
fontWeight: FontWeights.semibold,
|
||||
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
|
||||
debugPanel: {
|
||||
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');
|
||||
|
||||
// PostgreSQL connection to eluxnetworks.net
|
||||
|
||||
@ -46,12 +46,16 @@ async function getStripeSubscriptionStatus(stripeCustomerId) {
|
||||
|
||||
if (subscriptions.data.length > 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 {
|
||||
plan: 'premium',
|
||||
status: 'active',
|
||||
hasSubscription: true,
|
||||
currentPeriodEnd: new Date(sub.current_period_end * 1000).toISOString(),
|
||||
cancelAtPeriodEnd: sub.cancel_at_period_end
|
||||
endDate: endDate,
|
||||
cancelAtPeriodEnd: sub.cancel_at_period_end || false
|
||||
};
|
||||
}
|
||||
|
||||
@ -64,11 +68,15 @@ async function getStripeSubscriptionStatus(stripeCustomerId) {
|
||||
if (allSubs.data.length > 0) {
|
||||
const sub = allSubs.data[0];
|
||||
const normalizedStatus = normalizeStripeStatus(sub.status);
|
||||
const periodEndTimestamp = sub.cancel_at || sub.current_period_end;
|
||||
const endDate = periodEndTimestamp ? new Date(periodEndTimestamp * 1000).toISOString() : null;
|
||||
|
||||
return {
|
||||
plan: normalizedStatus === 'canceled' || normalizedStatus === 'none' || normalizedStatus === 'expired' ? 'free' : 'premium',
|
||||
status: normalizedStatus,
|
||||
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)
|
||||
const { data: beneficiary } = await supabase
|
||||
console.log('[GET BENEFICIARIES] querying beneficiaries table for id:', beneficiaryTableId);
|
||||
const { data: beneficiary, error: beneficiaryError } = await supabase
|
||||
.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)
|
||||
.single();
|
||||
|
||||
console.log('[GET BENEFICIARIES] got beneficiary:', beneficiary ? beneficiary.name : null, 'error:', beneficiaryError);
|
||||
|
||||
if (beneficiary) {
|
||||
const subscription = await getStripeSubscriptionStatus(beneficiary.stripe_customer_id);
|
||||
beneficiaries.push({
|
||||
@ -151,13 +162,8 @@ router.get('/', async (req, res) => {
|
||||
grantedAt: record.granted_at,
|
||||
name: beneficiary.name,
|
||||
phone: beneficiary.phone,
|
||||
address: {
|
||||
street: beneficiary.address_street,
|
||||
city: beneficiary.address_city,
|
||||
zip: beneficiary.address_zip,
|
||||
state: beneficiary.address_state,
|
||||
country: beneficiary.address_country
|
||||
},
|
||||
address: beneficiary.address || null,
|
||||
avatarUrl: beneficiary.avatar_url,
|
||||
createdAt: beneficiary.created_at,
|
||||
subscription: subscription,
|
||||
// Equipment status from beneficiaries table - CRITICAL for navigation!
|
||||
@ -223,13 +229,8 @@ router.get('/:id', async (req, res) => {
|
||||
id: beneficiary.id,
|
||||
name: beneficiary.name,
|
||||
phone: beneficiary.phone,
|
||||
address: {
|
||||
street: beneficiary.address_street,
|
||||
city: beneficiary.address_city,
|
||||
zip: beneficiary.address_zip,
|
||||
state: beneficiary.address_state,
|
||||
country: beneficiary.address_country
|
||||
},
|
||||
address: beneficiary.address || null,
|
||||
avatarUrl: beneficiary.avatar_url,
|
||||
role: access.role,
|
||||
subscription: subscription,
|
||||
orders: orders || [],
|
||||
@ -266,11 +267,7 @@ router.post('/', async (req, res) => {
|
||||
.insert({
|
||||
name: name,
|
||||
phone: phone || null,
|
||||
address_street: address?.street || null,
|
||||
address_city: address?.city || null,
|
||||
address_zip: address?.zip || null,
|
||||
address_state: address?.state || null,
|
||||
address_country: address?.country || null,
|
||||
address: address || null,
|
||||
equipment_status: 'none',
|
||||
created_by: userId,
|
||||
created_at: new Date().toISOString(),
|
||||
@ -313,13 +310,8 @@ router.post('/', async (req, res) => {
|
||||
id: beneficiary.id,
|
||||
name: beneficiary.name,
|
||||
phone: beneficiary.phone,
|
||||
address: {
|
||||
street: beneficiary.address_street,
|
||||
city: beneficiary.address_city,
|
||||
zip: beneficiary.address_zip,
|
||||
state: beneficiary.address_state,
|
||||
country: beneficiary.address_country
|
||||
},
|
||||
address: beneficiary.address || null,
|
||||
avatarUrl: beneficiary.avatar_url,
|
||||
role: 'custodian',
|
||||
equipmentStatus: 'none'
|
||||
}
|
||||
@ -341,6 +333,8 @@ router.patch('/:id', async (req, res) => {
|
||||
const userId = req.user.userId;
|
||||
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
|
||||
const { data: access, error: accessError } = await supabase
|
||||
.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' });
|
||||
}
|
||||
|
||||
const { name, phone, addressStreet, addressCity, addressZip, addressState, addressCountry } = req.body;
|
||||
const { name, phone, address } = req.body;
|
||||
|
||||
const updateData = {
|
||||
updated_at: new Date().toISOString()
|
||||
@ -361,11 +355,7 @@ router.patch('/:id', async (req, res) => {
|
||||
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (phone !== undefined) updateData.phone = phone;
|
||||
if (addressStreet !== undefined) updateData.address_street = addressStreet;
|
||||
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;
|
||||
if (address !== undefined) updateData.address = address;
|
||||
|
||||
// Update in beneficiaries table
|
||||
const { data: beneficiary, error } = await supabase
|
||||
@ -376,27 +366,25 @@ router.patch('/:id', async (req, res) => {
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('[BENEFICIARY PATCH] Supabase error:', error);
|
||||
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({
|
||||
success: true,
|
||||
beneficiary: {
|
||||
id: beneficiary.id,
|
||||
name: beneficiary.name,
|
||||
phone: beneficiary.phone,
|
||||
address: {
|
||||
street: beneficiary.address_street,
|
||||
city: beneficiary.address_city,
|
||||
zip: beneficiary.address_zip,
|
||||
state: beneficiary.address_state,
|
||||
country: beneficiary.address_country
|
||||
}
|
||||
address: beneficiary.address || null,
|
||||
avatarUrl: beneficiary.avatar_url
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Update beneficiary error:', error);
|
||||
console.error('[BENEFICIARY PATCH] Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
@ -713,7 +701,7 @@ router.post('/:id/activate', async (req, res) => {
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', beneficiaryId)
|
||||
.select('id, first_name, last_name, equipment_status')
|
||||
.select('id, name, equipment_status')
|
||||
.single();
|
||||
|
||||
if (updateError) {
|
||||
@ -727,8 +715,7 @@ router.post('/:id/activate', async (req, res) => {
|
||||
success: true,
|
||||
beneficiary: {
|
||||
id: beneficiary?.id || beneficiaryId,
|
||||
firstName: beneficiary?.first_name || null,
|
||||
lastName: beneficiary?.last_name || null,
|
||||
name: beneficiary?.name || null,
|
||||
hasDevices: true,
|
||||
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
|
||||
* PATCH /me/beneficiaries/:id/equipment-status
|
||||
@ -870,7 +919,7 @@ router.patch('/:id/equipment-status', authMiddleware, async (req, res) => {
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', beneficiaryId)
|
||||
.select('id, first_name, equipment_status')
|
||||
.select('id, name, equipment_status')
|
||||
.single();
|
||||
|
||||
if (updateError) {
|
||||
@ -888,7 +937,7 @@ router.patch('/:id/equipment-status', authMiddleware, async (req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
id: updated.id,
|
||||
firstName: updated.first_name,
|
||||
name: updated.name,
|
||||
equipmentStatus: updated.equipment_status
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@ -424,19 +424,23 @@ router.get('/subscription-status/:beneficiaryId', async (req, res) => {
|
||||
router.post('/cancel-subscription', async (req, res) => {
|
||||
try {
|
||||
const { beneficiaryId } = req.body;
|
||||
console.log('[CANCEL] Request received for beneficiaryId:', beneficiaryId);
|
||||
|
||||
if (!beneficiaryId) {
|
||||
return res.status(400).json({ error: 'beneficiaryId is required' });
|
||||
}
|
||||
|
||||
// Get beneficiary's stripe_customer_id
|
||||
const { data: beneficiary } = await supabase
|
||||
const { data: beneficiary, error: dbError } = await supabase
|
||||
.from('beneficiaries')
|
||||
.select('stripe_customer_id')
|
||||
.eq('id', beneficiaryId)
|
||||
.single();
|
||||
|
||||
console.log('[CANCEL] DB result:', { beneficiary, dbError });
|
||||
|
||||
if (!beneficiary?.stripe_customer_id) {
|
||||
console.log('[CANCEL] No stripe_customer_id 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
|
||||
});
|
||||
|
||||
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({
|
||||
success: true,
|
||||
message: 'Subscription will cancel at the end of the billing period',
|
||||
cancelAt: new Date(subscription.current_period_end * 1000).toISOString()
|
||||
cancelAt
|
||||
});
|
||||
|
||||
} 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
|
||||
const ephemeralKey = await stripe.ephemeralKeys.create(
|
||||
{ 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({
|
||||
subscriptionId: subscription.id,
|
||||
clientSecret: paymentIntent?.client_secret,
|
||||
clientSecret: clientSecret,
|
||||
ephemeralKey: ephemeralKey.secret,
|
||||
customer: customerId,
|
||||
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 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,
|
||||
name: data.name,
|
||||
hasDevices: data.hasDevices,
|
||||
equipmentStatus: data.equipmentStatus
|
||||
equipmentStatus: data.equipmentStatus,
|
||||
subscription: data.subscription
|
||||
}));
|
||||
|
||||
if (!response.ok) {
|
||||
@ -666,7 +667,8 @@ class ApiService {
|
||||
subscription: data.subscription ? {
|
||||
status: data.subscription.status,
|
||||
plan: data.subscription.plan,
|
||||
endDate: data.subscription.currentPeriodEnd,
|
||||
endDate: data.subscription.endDate,
|
||||
cancelAtPeriodEnd: data.subscription.cancelAtPeriodEnd,
|
||||
} : undefined,
|
||||
// Equipment status from orders
|
||||
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)
|
||||
async deleteBeneficiary(id: number): Promise<ApiResponse<{ success: boolean }>> {
|
||||
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
|
||||
async reactivateSubscription(beneficiaryId: number): Promise<ApiResponse<{ success: boolean; status: string }>> {
|
||||
const token = await this.getToken();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user