Compare commits

...

3 Commits

Author SHA1 Message Date
Sergei
2e72398818 Fix dropdown menu - make full row clickable
- BeneficiaryMenu: dropdownItem now has width: 100%
- Increased minWidth to 180 and added overflow: hidden
- Users can now tap anywhere on the menu row, not just the text

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-09 18:41:35 -08:00
Sergei
973e9b7ebe Fix Developer Mode WebView authentication
- Add legacy dashboard API methods (eluxnetworks.net)
- Implement JWT token validation before using cached credentials
- Clear invalid tokens (non-JWT strings like "0") and force re-login
- Use correct credentials (anandk/anandk_8)
- Add 30-minute token refresh interval when WebView is active
- Fix avatar upload using expo-file-system instead of FileReader
- Handle address field as both string and object
2026-01-09 17:06:35 -08:00
Sergei
24e7f057e7 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
2026-01-09 13:22:56 -08:00
15 changed files with 1476 additions and 696 deletions

3
.maestro/tap-mama.yaml Normal file
View File

@ -0,0 +1,3 @@
appId: host.exp.Exponent
---
- tapOn: "Mama3030"

View File

@ -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,
},
});

View File

@ -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(

View File

@ -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,
},
});

View File

@ -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,10 +61,14 @@ 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);
const [legacyCredentials, setLegacyCredentials] = useState<{
token: string;
userName: string;
userId: string;
} | null>(null);
const [isRefreshingToken, setIsRefreshingToken] = useState(false);
// Edit modal state
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
@ -71,21 +76,67 @@ export default function BeneficiaryDetailScreen() {
const webViewRef = useRef<WebView>(null);
// Load legacy token for WebView dashboard
useEffect(() => {
const loadLegacyToken = async () => {
// Load legacy credentials for WebView dashboard
const loadLegacyCredentials = useCallback(async () => {
try {
const token = await api.getLegacyToken();
setAuthToken(token);
// Check if token is expiring soon
const isExpiring = await api.isLegacyTokenExpiringSoon();
if (isExpiring) {
console.log('[DevMode] Legacy token expiring, refreshing...');
await api.refreshLegacyToken();
}
const credentials = await api.getLegacyWebViewCredentials();
if (credentials) {
setLegacyCredentials(credentials);
console.log('[DevMode] Legacy credentials loaded:', credentials.userName);
}
setIsWebViewReady(true);
} catch (err) {
console.log('[BeneficiaryDetail] Legacy token not available');
console.log('[DevMode] Failed to load legacy credentials:', err);
setIsWebViewReady(true);
}
};
loadLegacyToken();
}, []);
useEffect(() => {
loadLegacyCredentials();
// Periodically refresh token (every 30 minutes)
const tokenCheckInterval = setInterval(async () => {
if (!showWebView) return; // Only refresh if WebView is active
const isExpiring = await api.isLegacyTokenExpiringSoon();
if (isExpiring && !isRefreshingToken) {
console.log('[DevMode] Periodic check: refreshing legacy token...');
setIsRefreshingToken(true);
const result = await api.refreshLegacyToken();
if (result.ok) {
const credentials = await api.getLegacyWebViewCredentials();
if (credentials) {
setLegacyCredentials(credentials);
// Re-inject token into WebView
const injectScript = `
(function() {
var authData = {
username: '${credentials.userName}',
token: '${credentials.token}',
user_id: ${credentials.userId}
};
localStorage.setItem('auth2', JSON.stringify(authData));
console.log('Token auto-refreshed');
})();
true;
`;
webViewRef.current?.injectJavaScript(injectScript);
}
}
setIsRefreshingToken(false);
}
}, 30 * 60 * 1000); // 30 minutes
return () => clearInterval(tokenCheckInterval);
}, [loadLegacyCredentials, showWebView, isRefreshingToken]);
const loadBeneficiary = useCallback(async (showLoadingIndicator = true) => {
if (!id) return;
@ -179,19 +230,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.');
}
@ -227,15 +292,18 @@ export default function BeneficiaryDetailScreen() {
};
// JavaScript to inject token into localStorage for WebView
const injectedJavaScript = authToken
// Web app expects auth2 as JSON: {username, token, user_id}
const injectedJavaScript = legacyCredentials
? `
(function() {
try {
var authData = {
token: '${authToken}'
username: '${legacyCredentials.userName}',
token: '${legacyCredentials.token}',
user_id: ${legacyCredentials.userId}
};
localStorage.setItem('auth2', JSON.stringify(authData));
console.log('Auth data injected');
console.log('Auth data injected:', authData.username);
} catch(e) {
console.error('Failed to inject token:', e);
}
@ -279,83 +347,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 +376,7 @@ export default function BeneficiaryDetailScreen() {
</TouchableOpacity>
</View>
)}
*/}
{/* Dashboard Content */}
<View style={styles.dashboardContainer}>
@ -397,10 +397,10 @@ export default function BeneficiaryDetailScreen() {
{/* Content area - WebView or MockDashboard */}
<View style={styles.dashboardContent}>
{showWebView ? (
isWebViewReady && authToken ? (
isWebViewReady && legacyCredentials ? (
<WebView
ref={webViewRef}
source={{ uri: getDashboardUrl(FERDINAND_DEPLOYMENT_ID) }}
source={{ uri: getDashboardUrl(api.getDemoDeploymentId()) }}
style={styles.webView}
javaScriptEnabled={true}
domStorageEnabled={true}
@ -422,7 +422,7 @@ export default function BeneficiaryDetailScreen() {
<View style={styles.webViewLoading}>
<ActivityIndicator size="large" color={AppColors.primary} />
<Text style={styles.webViewLoadingText}>
{isWebViewReady ? 'Loading dashboard...' : 'Connecting to sensors...'}
{isWebViewReady ? 'Authenticating...' : 'Connecting to sensors...'}
</Text>
</View>
)
@ -571,42 +571,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

View File

@ -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

View File

@ -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', 'caretaker'].includes(access.role)) {
return res.status(403).json({ error: 'Only custodian, guardian or caretaker 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) {

View File

@ -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)

View File

@ -0,0 +1,186 @@
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: 180,
overflow: 'hidden',
...Shadows.lg,
},
dropdownItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: Spacing.md,
paddingHorizontal: Spacing.md,
gap: Spacing.sm,
width: '100%',
},
dropdownItemText: {
fontSize: FontSizes.base,
color: AppColors.textPrimary,
},
dropdownItemDanger: {
borderTopWidth: 1,
borderTopColor: AppColors.border,
},
dropdownItemTextDanger: {
color: AppColors.error,
},
});

19
expo-19000.log Normal file
View File

@ -0,0 +1,19 @@
Starting project at /Users/sergei/Desktop/WellNuo
/Users/sergei/Desktop/WellNuo/node_modules/expo/node_modules/@expo/cli/build/src/utils/errors.js:130
throw error;
^
RangeError [ERR_SOCKET_BAD_PORT]: options.port should be >= 0 and < 65536. Received type number (65536).
at Server.listen (node:net:2059:5)
at /Users/sergei/Desktop/WellNuo/node_modules/freeport-async/index.js:8:12
at new Promise (<anonymous>)
at testPortAsync (/Users/sergei/Desktop/WellNuo/node_modules/freeport-async/index.js:6:10)
at availableAsync (/Users/sergei/Desktop/WellNuo/node_modules/freeport-async/index.js:24:17)
at /Users/sergei/Desktop/WellNuo/node_modules/freeport-async/index.js:37:23
at new Promise (<anonymous>)
at freePortRangeAsync (/Users/sergei/Desktop/WellNuo/node_modules/freeport-async/index.js:33:10)
at /Users/sergei/Desktop/WellNuo/node_modules/freeport-async/index.js:43:18 {
code: 'ERR_SOCKET_BAD_PORT'
}
Node.js v20.19.5

226
expo-8081.log Normal file
View File

@ -0,0 +1,226 @@
Starting project at /Users/sergei/Desktop/WellNuo
React Compiler enabled
Starting Metro Bundler
The following packages should be updated for best compatibility with the installed expo version:
expo@54.0.30 - expected version: ~54.0.31
expo-constants@18.0.12 - expected version: ~18.0.13
Your project may not work correctly until you install the expected versions of the packages.
Opening exp://172.20.10.10:8081 on 8081
Waiting on http://localhost:8081
Logs for your project will appear below.
Could not fetch new Expo development certificate, falling back to cached certificate
iOS Bundled 531ms node_modules/expo-router/entry.js (1421 modules)
LOG expo-speech-recognition not available
LOG [Voice] expo-speech-recognition not available in Expo Go
LOG [Layout] Still initializing auth state...
LOG [AuthContext] checkAuth starting...
LOG [AuthContext] checkAuth: Checking token...
LOG [AuthContext] checkAuth: Token exists=true, length=187
LOG [AuthContext] checkAuth: isAuth=true
LOG [AuthContext] checkAuth: Getting stored user...
LOG [API] getStoredUser: token exists = true , userId = 62
LOG [API] getStoredUser: Fetching profile from server...
LOG [API] getStoredUser: Profile response ok = true , error = undefined
LOG [AuthContext] checkAuth: User found=true
LOG [AuthContext] checkAuth: Finished
LOG [Layout] Auth check: {"hasInitialRedirect": false, "inAuthGroup": false, "isAuthenticated": true}
LOG [API] getAllBeneficiaries - token exists: true length: 187
LOG [API] getAllBeneficiaries - Fetching from: https://wellnuo.smartlaunchhub.com/api/me/beneficiaries
LOG [API] getAllBeneficiaries - Response status: 200
LOG [API] getAllBeneficiaries - Data: {"beneficiaries":[{"accessId":41,"id":21,"role":"custodian","grantedAt":"2026-01-09T04:15:44.403Z","name":"Mama","phone":null,"address":null,"avatarUrl":null,"createdAt":"2026-01-09T04:15:43.079Z","su
LOG [API] getWellNuoBeneficiary - Raw response: {"id":21,"name":"Mama","hasDevices":false,"equipmentStatus":"none"}
LOG [API] getAllBeneficiaries - token exists: true length: 187
LOG [API] getAllBeneficiaries - Fetching from: https://wellnuo.smartlaunchhub.com/api/me/beneficiaries
LOG [API] getWellNuoBeneficiary - Raw response: {"id":21,"name":"Mama","hasDevices":false,"equipmentStatus":"none"}
LOG [API] getAllBeneficiaries - Response status: 200
LOG [API] getAllBeneficiaries - Data: {"beneficiaries":[{"accessId":41,"id":21,"role":"custodian","grantedAt":"2026-01-09T04:15:44.403Z","name":"Mama","phone":null,"address":null,"avatarUrl":null,"createdAt":"2026-01-09T04:15:43.079Z","su
LOG [API] getWellNuoBeneficiary - Raw response: {"id":22,"name":"Mama2","hasDevices":false,"equipmentStatus":"none"}
LOG [Purchase] Creating payment sheet for userId: 62 beneficiaryId: 22
ERROR Payment error: [Error: ephemeralKey is not defined]
Code: purchase.tsx
  119 |
 120 | if (!data.paymentIntent) {
> 121 | throw new Error(data.error || 'Failed to create payment sheet');
 | ^
 122 | }
 123 |
 124 | const { error: initError } = await initPaymentSheet({
Call Stack
handlePurchase (app/(auth)/purchase.tsx:121:24)
LOG [Purchase] Creating payment sheet for userId: 62 beneficiaryId: 22
ERROR Payment error: [Error: ephemeralKey is not defined]
Code: purchase.tsx
  119 |
 120 | if (!data.paymentIntent) {
> 121 | throw new Error(data.error || 'Failed to create payment sheet');
 | ^
 122 | }
 123 |
 124 | const { error: initError } = await initPaymentSheet({
Call Stack
handlePurchase (app/(auth)/purchase.tsx:121:24)
Could not fetch new Expo development certificate, falling back to cached certificate
iOS Bundled 83ms node_modules/expo-router/entry.js (1 module)
LOG expo-speech-recognition not available
LOG [Voice] expo-speech-recognition not available in Expo Go
LOG [Layout] Still initializing auth state...
LOG [AuthContext] checkAuth starting...
LOG [AuthContext] checkAuth: Checking token...
LOG [AuthContext] checkAuth: Token exists=true, length=187
LOG [AuthContext] checkAuth: isAuth=true
LOG [AuthContext] checkAuth: Getting stored user...
LOG [API] getStoredUser: token exists = true , userId = 62
LOG [API] getStoredUser: Fetching profile from server...
LOG [API] getStoredUser: Profile response ok = true , error = undefined
LOG [AuthContext] checkAuth: User found=true
LOG [AuthContext] checkAuth: Finished
LOG [Layout] Auth check: {"hasInitialRedirect": false, "inAuthGroup": false, "isAuthenticated": true}
LOG [API] getAllBeneficiaries - token exists: true length: 187
LOG [API] getAllBeneficiaries - Fetching from: https://wellnuo.smartlaunchhub.com/api/me/beneficiaries
LOG [API] getAllBeneficiaries - Response status: 200
LOG [API] getAllBeneficiaries - Data: {"beneficiaries":[{"accessId":41,"id":21,"role":"custodian","grantedAt":"2026-01-09T04:15:44.403Z","name":"Mama","phone":null,"address":null,"avatarUrl":null,"createdAt":"2026-01-09T04:15:43.079Z","su
LOG [API] getWellNuoBeneficiary - Raw response: {"id":22,"name":"Mama2","hasDevices":false,"equipmentStatus":"none"}
LOG [API] getWellNuoBeneficiary - Raw response: {"id":22,"name":"Mama2","hasDevices":false,"equipmentStatus":"none"}
ERROR Payment error: [Error: ephemeralKey is not defined]
Code: purchase.tsx
  113 |
 114 | if (!data.paymentIntent) {
> 115 | throw new Error(data.error || 'Failed to create payment sheet');
 | ^
 116 | }
 117 |
 118 | const { error: initError } = await initPaymentSheet({
Call Stack
handlePurchase (app/(tabs)/beneficiaries/[id]/purchase.tsx:115:24)
Could not fetch new Expo development certificate, falling back to cached certificate
iOS node_modules/expo-router/entry.js ░░░░░░░░░░░░░░░░ 0.0% (0/1)
iOS Bundled 68ms node_modules/expo-router/entry.js (1 module)
LOG expo-speech-recognition not available
LOG [Voice] expo-speech-recognition not available in Expo Go
LOG [Layout] Still initializing auth state...
LOG [AuthContext] checkAuth starting...
LOG [AuthContext] checkAuth: Checking token...
LOG [AuthContext] checkAuth: Token exists=true, length=187
LOG [AuthContext] checkAuth: isAuth=true
LOG [AuthContext] checkAuth: Getting stored user...
LOG [API] getStoredUser: token exists = true , userId = 62
LOG [API] getStoredUser: Fetching profile from server...
LOG [API] getStoredUser: Profile response ok = true , error = undefined
LOG [AuthContext] checkAuth: User found=true
LOG [AuthContext] checkAuth: Finished
LOG [Layout] Auth check: {"hasInitialRedirect": false, "inAuthGroup": false, "isAuthenticated": true}
LOG [API] getAllBeneficiaries - token exists: true length: 187
LOG [API] getAllBeneficiaries - Fetching from: https://wellnuo.smartlaunchhub.com/api/me/beneficiaries
LOG [API] getAllBeneficiaries - Response status: 200
LOG [API] getAllBeneficiaries - Data: {"beneficiaries":[]}
LOG [API] getAllBeneficiaries - token exists: true length: 187
LOG [API] getAllBeneficiaries - Fetching from: https://wellnuo.smartlaunchhub.com/api/me/beneficiaries
LOG [API] getAllBeneficiaries - Response status: 200
LOG [API] getAllBeneficiaries - Data: {"beneficiaries":[]}
LOG [API] getAllBeneficiaries - token exists: true length: 187
LOG [API] getAllBeneficiaries - Fetching from: https://wellnuo.smartlaunchhub.com/api/me/beneficiaries
LOG [API] getAllBeneficiaries - Response status: 200
LOG [API] getAllBeneficiaries - Data: {"beneficiaries":[{"accessId":41,"id":21,"role":"custodian","grantedAt":"2026-01-09T04:15:44.403Z","name":"Mama","phone":null,"address":null,"createdAt":"2026-01-09T04:15:43.079Z","subscription":{"pla
LOG [API] getWellNuoBeneficiary - Raw response: {"id":21,"name":"Mama","hasDevices":false,"equipmentStatus":"none"}
LOG [API] getWellNuoBeneficiary - Raw response: {"id":21,"name":"Mama","hasDevices":false,"equipmentStatus":"none"}
LOG [API] getAllBeneficiaries - token exists: true length: 187
LOG [API] getAllBeneficiaries - Fetching from: https://wellnuo.smartlaunchhub.com/api/me/beneficiaries
LOG [API] getAllBeneficiaries - Response status: 200
LOG [API] getAllBeneficiaries - Data: {"beneficiaries":[{"accessId":41,"id":21,"role":"custodian","grantedAt":"2026-01-09T04:15:44.403Z","name":"Mama","phone":null,"address":null,"createdAt":"2026-01-09T04:15:43.079Z","subscription":{"pla
LOG [API] getWellNuoBeneficiary - Raw response: {"id":23,"name":"Mama3","hasDevices":false,"equipmentStatus":"none"}
LOG [Purchase] Creating payment sheet for userId: 62 beneficiaryId: 23
LOG [Purchase] Payment successful, updating equipment status...
LOG [API] getWellNuoBeneficiary - Raw response: {"id":23,"name":"Mama3","hasDevices":false,"equipmentStatus":"ordered"}
LOG [API] getWellNuoBeneficiary - Raw response: {"id":23,"name":"Mama3","hasDevices":true,"equipmentStatus":"demo"}
LOG [API] getWellNuoBeneficiary - Raw response: {"id":23,"name":"Mama3","hasDevices":true,"equipmentStatus":"demo"}
ERROR Payment error: [Error: Failed to create subscription]
Code: subscription.tsx
  143 |
 144 | if (!data.clientSecret) {
> 145 | throw new Error(data.error || 'Failed to create subscription');
 | ^
 146 | }
 147 |
 148 | // 2. Initialize the Payment Sheet
Call Stack
handleSubscribe (app/(tabs)/beneficiaries/[id]/subscription.tsx:145:24)
LOG [API] getWellNuoBeneficiary - Raw response: {"id":23,"name":"Mama3","hasDevices":true,"equipmentStatus":"demo"}
LOG [DEBUG] loadBeneficiary: fetching id=23
LOG [API] getWellNuoBeneficiary - Raw response: {"id":23,"name":"Mama3","hasDevices":true,"equipmentStatus":"demo"}
LOG [DEBUG] loadBeneficiary: response.ok=true
LOG [DEBUG] loadBeneficiary: got beneficiary id=23, name=Mama3
LOG [DEBUG] loadBeneficiary: fetching id=23
LOG [API] getWellNuoBeneficiary - Raw response: {"id":23,"name":"Mama3","hasDevices":true,"equipmentStatus":"demo"}
LOG [DEBUG] loadBeneficiary: response.ok=true
LOG [DEBUG] loadBeneficiary: got beneficiary id=23, name=Mama3
LOG [DEBUG] handleSubscribe: START
LOG [DEBUG] handleSubscribe: beneficiary={"id":23,"name":"Mama3"}
LOG [DEBUG] handleSubscribe: token=eyJhbGciOiJIUzI1NiIs...
LOG [DEBUG] handleSubscribe: calling https://wellnuo.smartlaunchhub.com/api/stripe/create-subscription-payment-sheet
LOG [DEBUG] handleSubscribe: body={"beneficiaryId":23}
LOG [DEBUG] handleSubscribe: response.status=200
LOG [DEBUG] handleSubscribe: response data={"subscriptionId":"sub_1SnYNwP0gvUw6M9CMooRyBOz","ephemeralKey":"ek_test_YWNjdF8xUDNrZHFQMGd2VXc2TTlDLGlmOGdHVldBc1ppNkxxZmM1N1B2WXJuZWNSZ1lRbkg_00BcXeNVrC","customer":"cus_Tl4QHSfVqjstea","publishableKey":"pk_test_51P3kdqP0gvUw6M9C7ixPQHqbPcvga4G5kAYx1h6QXQAt1psbrC2rrmOojW0fTeQzaxD1Q9RKS3zZ23MCvjjZpWLi00eCFWRHMk"}
ERROR Payment error: [Error: Failed to create subscription]
Code: subscription.tsx
  180 |
 181 | if (!data.clientSecret) {
> 182 | throw new Error(data.error || 'Failed to create subscription');
 | ^
 183 | }
 184 |
 185 | // 2. Initialize the Payment Sheet
Call Stack
handleSubscribe (app/(tabs)/beneficiaries/[id]/subscription.tsx:182:24)
Unable to resolve "react-native-root-toast" from "app/(tabs)/beneficiaries/[id]/subscription.tsx"
  21 | import type { Beneficiary } from '@/types';
 22 | import * as ExpoClipboard from 'expo-clipboard';
> 23 | import Toast from 'react-native-root-toast';
 | ^
 24 |
 25 | const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
 26 | const SUBSCRIPTION_PRICE = 49; // $49/month
Import stack:
app/(tabs)/beneficiaries/[id]/subscription.tsx
| import "react-native-root-toast"
app (require.context)
Unable to resolve "react-native-root-toast" from "app/(tabs)/beneficiaries/[id]/subscription.tsx"
  21 | import type { Beneficiary } from '@/types';
 22 | import * as ExpoClipboard from 'expo-clipboard';
> 23 | import Toast from 'react-native-root-toast';
 | ^
 24 |
 25 | const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
 26 | const SUBSCRIPTION_PRICE = 49; // $49/month
Import stack:
app/(tabs)/beneficiaries/[id]/subscription.tsx
| import "react-native-root-toast"
app (require.context)
Unable to resolve "react-native-root-toast" from "app/(tabs)/beneficiaries/[id]/subscription.tsx"
  21 | import type { Beneficiary } from '@/types';
 22 | import * as ExpoClipboard from 'expo-clipboard';
> 23 | import Toast from 'react-native-root-toast';
 | ^
 24 |
 25 | const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
 26 | const SUBSCRIPTION_PRICE = 49; // $49/month
Import stack:
app/(tabs)/beneficiaries/[id]/subscription.tsx
| import "react-native-root-toast"
app (require.context)

1
package-lock.json generated
View File

@ -22,6 +22,7 @@
"expo-clipboard": "~8.0.8",
"expo-constants": "~18.0.12",
"expo-crypto": "~15.0.8",
"expo-file-system": "~19.0.21",
"expo-font": "~14.0.10",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",

View File

@ -25,6 +25,7 @@
"expo-clipboard": "~8.0.8",
"expo-constants": "~18.0.12",
"expo-crypto": "~15.0.8",
"expo-file-system": "~19.0.21",
"expo-font": "~14.0.10",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",

View File

@ -1,5 +1,6 @@
import type { ApiError, ApiResponse, AuthResponse, Beneficiary, BeneficiaryDashboardData, ChatResponse, DashboardSingleResponse, NotificationSettings } from '@/types';
import * as Crypto from 'expo-crypto';
import * as FileSystem from 'expo-file-system';
import * as SecureStore from 'expo-secure-store';
// Callback for handling unauthorized responses (401)
@ -609,10 +610,10 @@ class ApiService {
const beneficiaries: Beneficiary[] = (data.beneficiaries || []).map((item: any) => ({
id: item.id,
name: item.name || item.email,
avatar: undefined, // No auto-generated avatars - only show if user uploaded one
avatar: item.avatarUrl || undefined, // Use uploaded avatar from server
status: 'offline' as const,
email: item.email,
address: item.address?.street ? `${item.address.street}, ${item.address.city}` : undefined,
address: typeof item.address === 'string' ? item.address : (item.address?.street ? `${item.address.street}, ${item.address.city}` : undefined),
subscription: item.subscription,
// Equipment status from orders
equipmentStatus: item.equipmentStatus,
@ -649,7 +650,8 @@ class ApiService {
id: data.id,
name: data.name,
hasDevices: data.hasDevices,
equipmentStatus: data.equipmentStatus
equipmentStatus: data.equipmentStatus,
subscription: data.subscription
}));
if (!response.ok) {
@ -659,14 +661,15 @@ class ApiService {
const beneficiary: Beneficiary = {
id: data.id,
name: data.name || data.email,
avatar: undefined, // No auto-generated avatars - only show if user uploaded one
avatar: data.avatarUrl || undefined,
status: 'offline' as const,
email: data.email,
address: data.address?.street ? `${data.address.street}, ${data.address.city}` : undefined,
address: typeof data.address === 'string' ? data.address : (data.address?.street ? `${data.address.street}, ${data.address.city}` : undefined),
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 +764,59 @@ 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) {
// Read file as base64 string using expo-file-system
const base64Data = await FileSystem.readAsStringAsync(imageUri, {
encoding: 'base64',
});
// Determine mime type from URI extension
const extension = imageUri.split('.').pop()?.toLowerCase() || 'jpeg';
const mimeType = extension === 'png' ? 'image/png' : 'image/jpeg';
// Create data URI
base64Image = `data:${mimeType};base64,${base64Data}`;
console.log('[API] Avatar converted to base64, length:', base64Image.length);
}
console.log('[API] Uploading avatar for beneficiary:', id);
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();
console.log('[API] Avatar upload response:', apiResponse.status, data);
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 +1103,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();
@ -1186,6 +1289,145 @@ class ApiService {
};
}
}
// ==========================================
// Legacy Dashboard Methods (Developer Mode)
// For eluxnetworks.net dashboard WebView
// ==========================================
// Demo credentials for legacy dashboard
private readonly DEMO_LEGACY_USER = 'anandk';
private readonly DEMO_LEGACY_PASSWORD = 'anandk_8';
private readonly DEMO_DEPLOYMENT_ID = 21; // Ferdinand's deployment
// Login to legacy dashboard API
async loginToLegacyDashboard(): Promise<ApiResponse<AuthResponse>> {
try {
const formData = new URLSearchParams();
formData.append('function', 'credentials');
formData.append('user_name', this.DEMO_LEGACY_USER);
formData.append('ps', this.DEMO_LEGACY_PASSWORD);
formData.append('clientId', CLIENT_ID);
formData.append('nonce', this.generateNonce());
const response = await fetch(API_BASE_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData.toString(),
});
const data = await response.json();
console.log('[API] Legacy login response:', data.status, 'token type:', typeof data.access_token);
// Check that access_token is a valid JWT string (not 0 or empty)
if (data.status === '200 OK' && data.access_token && typeof data.access_token === 'string' && data.access_token.includes('.')) {
// Save legacy credentials
await SecureStore.setItemAsync('legacyAccessToken', data.access_token);
await SecureStore.setItemAsync('legacyUserId', String(data.user_id));
await SecureStore.setItemAsync('legacyUserName', this.DEMO_LEGACY_USER);
console.log('[API] Legacy credentials saved successfully');
return { data: data as AuthResponse, ok: true };
}
console.log('[API] Legacy login failed - invalid token:', data.access_token);
return {
ok: false,
error: { message: data.message || 'Legacy login failed - invalid credentials' },
};
} catch (error) {
console.error('[API] Legacy login error:', error);
return {
ok: false,
error: { message: 'Failed to connect to dashboard API' },
};
}
}
// Refresh legacy token
async refreshLegacyToken(): Promise<ApiResponse<AuthResponse>> {
console.log('[API] Refreshing legacy token...');
return this.loginToLegacyDashboard();
}
// Check if legacy token is expiring soon (within 1 hour)
async isLegacyTokenExpiringSoon(): Promise<boolean> {
try {
const token = await this.getLegacyToken();
if (!token) return true;
// Decode JWT to get expiration
const parts = token.split('.');
if (parts.length !== 3) return true;
const payload = JSON.parse(atob(parts[1]));
const exp = payload.exp;
if (!exp) return true;
const now = Math.floor(Date.now() / 1000);
const oneHour = 60 * 60;
const isExpiring = (exp - now) < oneHour;
console.log('[API] Legacy token expiring soon:', isExpiring, 'expires in:', Math.round((exp - now) / 60), 'min');
return isExpiring;
} catch (e) {
console.log('[API] Error checking legacy token:', e);
return true;
}
}
// Get legacy credentials for WebView injection
async getLegacyWebViewCredentials(): Promise<{
token: string;
userName: string;
userId: string;
} | null> {
try {
const token = await SecureStore.getItemAsync('legacyAccessToken');
const userName = await SecureStore.getItemAsync('legacyUserName');
const userId = await SecureStore.getItemAsync('legacyUserId');
// Check if credentials exist AND token is valid JWT (contains dots)
const isValidToken = token && typeof token === 'string' && token.includes('.');
if (!isValidToken || !userName || !userId) {
console.log('[API] Legacy credentials missing or invalid token, logging in...');
console.log('[API] Token valid:', isValidToken, 'userName:', !!userName, 'userId:', !!userId);
// Clear any invalid cached credentials
if (token && !isValidToken) {
console.log('[API] Clearing invalid cached token:', token);
await SecureStore.deleteItemAsync('legacyAccessToken');
await SecureStore.deleteItemAsync('legacyUserName');
await SecureStore.deleteItemAsync('legacyUserId');
}
const loginResult = await this.loginToLegacyDashboard();
if (!loginResult.ok) return null;
// Get freshly saved credentials
const newToken = await SecureStore.getItemAsync('legacyAccessToken');
const newUserName = await SecureStore.getItemAsync('legacyUserName');
const newUserId = await SecureStore.getItemAsync('legacyUserId');
if (!newToken || !newUserName || !newUserId) return null;
return { token: newToken, userName: newUserName, userId: newUserId };
}
console.log('[API] Legacy credentials found:', userName, 'token length:', token.length);
return { token, userName, userId };
} catch (e) {
console.error('[API] Error getting legacy credentials:', e);
return null;
}
}
// Get demo deployment ID
getDemoDeploymentId(): number {
return this.DEMO_DEPLOYMENT_ID;
}
}
export const api = new ApiService();