Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".MainApplication"
android:label="@string/app_name"
Expand Down Expand Up @@ -35,4 +36,4 @@
<meta-data android:name="com.facebook.sdk.ClientToken" android:value="@string/FACEBOOK_CLIENT_TOKEN"/>
<meta-data android:name="com.google.android.geo.API_KEY" android:value="@string/GOOGLE_MAPS_API_KEY" />
</application>
</manifest>
</manifest>
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@fleetbase/storefront-app",
"version": "0.0.18",
"version": "0.0.19",
"private": true,
"scripts": {
"android": "react-native run-android",
Expand Down Expand Up @@ -88,7 +88,7 @@
"react-native-keychain": "^10.0.0",
"react-native-linear-gradient": "^2.8.3",
"react-native-localize": "^3.3.0",
"react-native-maps": "^1.26.18",
"react-native-maps": "^1.26.19",
"react-native-maps-directions": "^1.9.0",
"react-native-mmkv-storage": "^12.0.0",
"react-native-notifications": "^5.1.0",
Expand Down Expand Up @@ -149,5 +149,6 @@
},
"engines": {
"node": ">=20"
}
},
"packageManager": "[email protected]+sha512.e70835d4d6d62c07be76b3c1529cb640c7443f0fe434ef4b6478a5a399218cbaf1511b396b3c56eb03bc86424cff2320f6167ad2fde273aa0df6e60b7754029f"
}
2 changes: 1 addition & 1 deletion src/components/CustomerLocationSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ const CustomerLocationSelect = ({ onChange, onSelectNewLocation, redirectTo = 'C
</YStack>
<YStack flex={1} px='$3'>
<Text size={15} color='$textPrimary' fontWeight='bold' mb={2}>
{currentLocation.getAttribute('name') ?? 'Your Location'}
{currentLocation?.getAttribute('name') ?? 'Your Location'}
</Text>
<Text color='$textSecondary'>{formattedAddressFromPlace(currentLocation)}</Text>
</YStack>
Expand Down
31 changes: 31 additions & 0 deletions src/components/MinimumCheckoutNotice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React from 'react';
import { YStack, Text } from 'tamagui';
import { useLanguage } from '../contexts/LanguageContext';
import { formatCurrency } from '../utils/format';
import useCart from '../hooks/use-cart';

interface MinimumCheckoutNoticeProps {
minimumAmount: number;
currentSubtotal: number;
}

const MinimumCheckoutNotice = ({ minimumAmount, currentSubtotal }: MinimumCheckoutNoticeProps) => {
const { t } = useLanguage();
const [cart] = useCart();

return (
<YStack bg='$warning' borderWidth={1} borderColor='$warningBorder' borderRadius='$4' px='$3' py='$2' space='$2'>
<Text color='$warningText' fontSize='$5' fontWeight='bold'>
{t('checkout.minimumOrderNotReached')}
</Text>
<Text color='$warningText' fontSize='$4'>
{t('checkout.minimumOrderMessage', {
minimum: formatCurrency(minimumAmount, cart.getAttribute('currency')),
current: formatCurrency(currentSubtotal, cart.getAttribute('currency')),
})}
</Text>
</YStack>
);
};

export default MinimumCheckoutNotice;
4 changes: 2 additions & 2 deletions src/components/QPayTaxRegistrationSwitch.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import TabSwitch from './TabSwitch';
import { useLanguage } from '../contexts/LanguageContext';

const QPayTaxRegistrationSwitch = ({ isPeronal = true, onChange }) => {
const QPayTaxRegistrationSwitch = ({ isPersonal = true, onChange }) => {
const { t } = useLanguage();
const receivingOptions = [
{ label: t('QPayCheckoutScreen.personal'), value: 'personal' },
Expand All @@ -15,7 +15,7 @@ const QPayTaxRegistrationSwitch = ({ isPeronal = true, onChange }) => {
}
};

return <TabSwitch options={receivingOptions} onTabChange={handleTabChange} initialIndex={isPeronal ? 0 : 1} />;
return <TabSwitch options={receivingOptions} onTabChange={handleTabChange} initialIndex={isPersonal ? 0 : 1} />;
};

export default QPayTaxRegistrationSwitch;
147 changes: 104 additions & 43 deletions src/contexts/NotificationContext.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { createContext, useContext, useEffect, useState, useRef } from 'react';
import { Notifications } from 'react-native-notifications';
import { Platform, PermissionsAndroid } from 'react-native';
import useStorage from '../hooks/use-storage';

export const NotificationContext = createContext();
Expand All @@ -8,6 +9,7 @@ export const NotificationProvider = ({ children }) => {
const [notifications, setNotifications] = useStorage('_push_notifications', []);
const [lastNotification, setLastNotification] = useStorage('_last_push_notification');
const [deviceToken, setDeviceToken] = useStorage('_device_token');
const [permissionGranted, setPermissionGranted] = useState(false);
const notificationListeners = useRef([]);

// Function to add a listener
Expand All @@ -20,54 +22,113 @@ export const NotificationProvider = ({ children }) => {
notificationListeners.current = notificationListeners.current.filter((listener) => listener !== callback);
};

// Request notification permission for Android 13+
const requestNotificationPermission = async () => {
if (Platform.OS === 'android' && Platform.Version >= 33) {
try {
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS,
{
title: 'Notification Permission',
message: 'This app needs permission to send you notifications about your orders and updates.',
buttonNeutral: 'Ask Me Later',
buttonNegative: 'Cancel',
buttonPositive: 'OK',
}
);

if (granted === PermissionsAndroid.RESULTS.GRANTED) {
console.log('Notification permission granted');
setPermissionGranted(true);
return true;
} else {
console.log('Notification permission denied');
setPermissionGranted(false);
return false;
}
} catch (err) {
console.warn('Error requesting notification permission:', err);
return false;
}
} else {
// For iOS or Android < 13, permission is handled differently
setPermissionGranted(true);
return true;
}
};

useEffect(() => {
Notifications.registerRemoteNotifications();

// Foreground notification handler
const notificationDisplayedListener = Notifications.events().registerNotificationReceivedForeground((notification, completion) => {
console.log('Notification received in foreground:', notification);
setLastNotification(notification);
setNotifications((prev) => [...prev, notification]);

// Notify all listeners
notificationListeners.current.forEach((listener) => listener(notification));

completion({ alert: true, sound: true, badge: false });
});

// Notification opened handler
const notificationOpenedListener = Notifications.events().registerNotificationOpened((notification, completion, action) => {
console.log('Notification opened:', notification);
setLastNotification(notification);

// Notify all listeners (optional, based on use case)
notificationListeners.current.forEach((listener) => listener(notification, action));

completion();
});

// Remote notifications registered successfully
const registeredListener = Notifications.events().registerRemoteNotificationsRegistered((event) => {
setDeviceToken(event.deviceToken);
console.log('Device registered for remote notifications:', event.deviceToken);
});

// Failed to register for remote notifications
const registrationFailedListener = Notifications.events().registerRemoteNotificationsRegistrationFailed((error) => {
console.error('Failed to register for remote notifications:', error);
});

// Clean up listeners on unmount
return () => {
notificationDisplayedListener.remove();
notificationOpenedListener.remove();
registeredListener.remove();
registrationFailedListener.remove();
// Request permission first, then register for notifications
const initializeNotifications = async () => {
const hasPermission = await requestNotificationPermission();

if (!hasPermission && Platform.OS === 'android' && Platform.Version >= 33) {
console.log('Notification permission not granted, skipping registration');
return;
}

Notifications.registerRemoteNotifications();

// Foreground notification handler
const notificationDisplayedListener = Notifications.events().registerNotificationReceivedForeground((notification, completion) => {
console.log('Notification received in foreground:', notification);
setLastNotification(notification);
setNotifications((prev) => [...prev, notification]);

// Notify all listeners
notificationListeners.current.forEach((listener) => listener(notification));

completion({ alert: true, sound: true, badge: false });
});

// Notification opened handler
const notificationOpenedListener = Notifications.events().registerNotificationOpened((notification, completion, action) => {
console.log('Notification opened:', notification);
setLastNotification(notification);

// Notify all listeners (optional, based on use case)
notificationListeners.current.forEach((listener) => listener(notification, action));

completion();
});

// Remote notifications registered successfully
const registeredListener = Notifications.events().registerRemoteNotificationsRegistered((event) => {
setDeviceToken(event.deviceToken);
console.log('Device registered for remote notifications:', event.deviceToken);
});

// Failed to register for remote notifications
const registrationFailedListener = Notifications.events().registerRemoteNotificationsRegistrationFailed((error) => {
console.error('Failed to register for remote notifications:', error);
});

// Clean up listeners on unmount
return () => {
notificationDisplayedListener.remove();
notificationOpenedListener.remove();
registeredListener.remove();
registrationFailedListener.remove();
};
};

initializeNotifications();
}, []);

return (
<NotificationContext.Provider value={{ notifications, lastNotification, deviceToken, addNotificationListener, removeNotificationListener }}>{children}</NotificationContext.Provider>
<NotificationContext.Provider
value={{
notifications,
lastNotification,
deviceToken,
permissionGranted,
addNotificationListener,
removeNotificationListener,
requestNotificationPermission
}}
>
{children}
</NotificationContext.Provider>
);
};

Expand Down
16 changes: 13 additions & 3 deletions src/hooks/use-fleetbase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import { useMemo, useState, useEffect } from 'react';
import Fleetbase from '@fleetbase/sdk';
import Config from 'react-native-config';
import { getString } from './use-storage';
import { useLanguage } from '../contexts/LanguageContext';

const { FLEETBASE_KEY, FLEETBASE_HOST } = Config;
export let instance = new Fleetbase(FLEETBASE_KEY, { host: FLEETBASE_HOST });
export let adapter = instance.getAdapter();

const useFleetbase = () => {
const { locale } = useLanguage();
const [fleetbase, setFleetbase] = useState<Fleetbase | null>(null);
const [error, setError] = useState<Error | null>(null);

Expand All @@ -21,17 +23,25 @@ const useFleetbase = () => {

useEffect(() => {
const authToken = getString('_customer_token');

// Build headers object with locale and auth
const headers: Record<string, string> = {
'Accept-Language': locale || 'en',
};

if (authToken) {
const authorizedAdapter = adapter.setHeaders({ 'Customer-Token': authToken });
instance.setAdapter(authorizedAdapter);
headers['Customer-Token'] = authToken;
}

const configuredAdapter = adapter.setHeaders(headers);
instance.setAdapter(configuredAdapter);

try {
setFleetbase(instance);
} catch (initializationError) {
setError(initializationError);
}
}, []);
}, [locale]);

return { fleetbase, adapter: fleetbaseAdapter, error };
};
Expand Down
26 changes: 22 additions & 4 deletions src/hooks/use-qpay-checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,15 @@ export default function useQPayCheckout({ onOrderComplete }) {
const totalItem = lineItems.find((item) => item.name === 'Total');
return totalItem ? totalItem.value : 0;
}, [checkoutOptions, subtotal, serviceQuote]);
const isReady = serviceQuote && !isLoading;
const isNotReady = !isReady;
const isPickupEnabled = get(info, 'options.pickup_enabled') === true;

// Minimum checkout validation
const isMinimumCheckoutEnabled = get(info, 'options.required_checkout_min') === true;
const minimumCheckoutAmount = get(info, 'options.required_checkout_min_amount', 0);
const isBelowMinimum = isMinimumCheckoutEnabled && subtotal < minimumCheckoutAmount;

const isReady = serviceQuote && !isLoading && !isBelowMinimum;
const isNotReady = !isReady;

// Calculate line items
function computeLineItems() {
Expand Down Expand Up @@ -363,8 +369,12 @@ export default function useQPayCheckout({ onOrderComplete }) {
setPickup,
isPickup: !!checkoutOptions.pickup,
error,
isReady: serviceQuote && !isLoading,
isNotReady: !(serviceQuote && !isLoading),
isReady: serviceQuote && !isLoading && !isBelowMinimum,
isNotReady: !(serviceQuote && !isLoading && !isBelowMinimum),
isBelowMinimum,
minimumCheckoutAmount,
isMinimumCheckoutEnabled,
subtotal,
orderNotes,
setOrderNotes,
storeLocationId,
Expand All @@ -373,6 +383,10 @@ export default function useQPayCheckout({ onOrderComplete }) {
hasOrderCompleted: hasOrderCompleted.current,
isCapturingOrder,
isServiceQuoteUnavailable,
isBelowMinimum,
minimumCheckoutAmount,
isMinimumCheckoutEnabled,
subtotal,
isPersonal,
setIsPersonal,
isCompany: !isPersonal,
Expand All @@ -399,6 +413,10 @@ export default function useQPayCheckout({ onOrderComplete }) {
hasOrderCompleted.current,
isCapturingOrder,
isServiceQuoteUnavailable,
isBelowMinimum,
minimumCheckoutAmount,
isMinimumCheckoutEnabled,
subtotal,
isPersonal,
setIsPersonal,
companyRegistrationNumber,
Expand Down
Loading
Loading