first ever agent push

This commit is contained in:
test 2026-01-16 00:22:35 +03:00
commit 8e0deae6d0
332 changed files with 59005 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

8
.expo/README.md Normal file
View File

@ -0,0 +1,8 @@
> Why do I have a folder named ".expo" in my project?
The ".expo" folder is created when an Expo project is started using "expo start" command.
> What do the files contain?
- "devices.json": contains information about devices that have recently opened this project. This is used to populate the "Development sessions" list in your development builds.
- "settings.json": contains the server configuration that is used to serve the application manifest.
> Should I commit the ".expo" folder?
No, you should not share the ".expo" folder. It does not contain any information that is relevant for other developers working on the project, it is specific to your machine.
Upon project creation, the ".expo" folder is already added to your ".gitignore" file.

8
.expo/devices.json Normal file
View File

@ -0,0 +1,8 @@
{
"devices": [
{
"installationId": "5eab1301-ca81-48d8-bdaa-42f65cae7ad8",
"lastUsed": 1768508821071
}
]
}

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/node_modules

38
GoogleService-Info.plist Normal file
View File

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CLIENT_ID</key>
<string>613864011564-atsg9nau8hicla4td6dedcab15g7qr04.apps.googleusercontent.com</string>
<key>REVERSED_CLIENT_ID</key>
<string>com.googleusercontent.apps.613864011564-atsg9nau8hicla4td6dedcab15g7qr04</string>
<key>ANDROID_CLIENT_ID</key>
<string>613864011564-2h1fb41f6conaabli0uq7scacpfmvuiq.apps.googleusercontent.com</string>
<key>API_KEY</key>
<string>AIzaSyCquhCKEsKmvZ5_JzqyWXGoImBF5L2Xlbc</string>
<key>GCM_SENDER_ID</key>
<string>613864011564</string>
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>com.ambapays.app</string>
<key>PROJECT_ID</key>
<string>ambapaydemo</string>
<key>STORAGE_BUCKET</key>
<string>ambapaydemo.firebasestorage.app</string>
<key>IS_ADS_ENABLED</key>
<false></false>
<key>IS_ANALYTICS_ENABLED</key>
<false></false>
<key>IS_APPINVITE_ENABLED</key>
<true></true>
<key>IS_GCM_ENABLED</key>
<true></true>
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>1:613864011564:ios:79336eea584d1ba8249e89</string>
<key>DATABASE_URL</key>
<string>https://ambapaydemo-default-rtdb.europe-west1.firebasedatabase.app</string>
</dict>
</plist>

49
Makefile Normal file
View File

@ -0,0 +1,49 @@
# ===== Android Targets =====
run:
ANDROID_SERIAL=RF8T90SNT7W npx expo start --dev-client --clear --android
run_dev:
ANDROID_SERIAL=RF8T90SNT7W npx expo run:android
run_reinstall:
ANDROID_SERIAL=RF8T90SNT7W npx expo run:android
ANDROID_SERIAL=RF8T90SNT7W npx expo start --dev-client --clear --android
regen_android:
npx expo prebuild --platform android --clean
# ===== iOS Targets =====
run_ios:
npx expo start --dev-client --clear --ios
run_dev_ios:
npx expo run:ios
run_reinstall_ios:
npx expo run:ios
npx expo start --dev-client --clear --ios
regen_ios:
npx expo prebuild --platform ios --clean
# ===== Web Targets =====
# Run local web development server
web:
npx expo start --web --clear
# Build static web export for production (outputs to dist/)
web_build:
npx expo export --platform web
# Serve the built web app locally for testing (requires npx serve)
web_serve: web_build
npx serve dist
# Clean web build artifacts
web_clean:
rm -rf dist
# Deploy to Firebase Hosting (requires firebase CLI: npm i -g firebase-tools)
# Note: Run 'firebase init hosting' first and set public directory to 'dist'
web_deploy_firebase: web_build
firebase deploy --only hosting

10
README.md Normal file
View File

@ -0,0 +1,10 @@
* Backlog
- [X] Add Biometric locks on the app
- [X] Move Authentication to Prod
- [X] Improve the speed of the app
- [X] Improve UX of app via loader
- [X] Configure Firebase Rules
- [] Twilio SMS for SMS Notifications
- [] Resend for Email Notifications
- [] FCM Notifications
- [] Reduced but did not elmintate the lag when switching tabs

540
agent-app-ui-plan.md Normal file
View File

@ -0,0 +1,540 @@
# Amba Agent App UI Plan (Phase 1, UI-Only)
## 1. Purpose
The Amba Agent app provides agents with a platform to:
- Manage multiple clients payment accounts.
- Schedule reminders and manage payment-related events.
- Communicate and act on client payments through a streamlined UI.
This phase focuses **only on UI/flows**, with dummy data and no real backend integration.
---
## 2. Scope (Phase 1)
From the SRS:
- **Multi-client management**
- Each agent manages multiple client profiles.
- Each client can have multiple linked payment accounts.
- **Transaction management**
- Agents can initiate payments on behalf of clients.
- Transactions are displayed and tracked in a list view.
- **Scheduling & reminders**
- Simple UI to show upcoming reminders.
- Scheduling controls are UI-only for now.
- **Notifications**
- UI toggles for SMS, in-app, email notifications (no real sending yet).
- **Localization**
- English-first UI with a language selector prepared for Arabic and others.
- No full RTL logic in this phase, only the settings hooks.
- **Security**
- Login screen + simulated biometric toggle (UI-only).
- **Reporting**
- Transactions list and basic summary cards (dummy data).
---
## 3. Navigation Structure
### Bottom Tabs (Agent App)
1. **Home**
2. **Recipients**
3. **Requests**
4. **Transactions**
5. **Profile**
From these tabs we navigate to:
- Recipient Detail
- Pay (Payment initiation) Screen
- Request Detail (bottom sheet)
- Optional “Reports” placeholder (or reuse Transactions)
---
## 4. Screens and UI Details
### 4.1 Login (Agent Login)
**Goal:** Basic auth + prompt for biometrics after first login.
- Fields:
- Email / Phone
- Password
- Buttons:
- Primary: **Sign In**
- Secondary: **Forgot password?**
- After first successful login (simulated):
- Show a card or bottom sheet:
- Title: “Enable biometric login”
- Buttons: **Enable Face ID / Fingerprint**, **Not now**
- For Phase 1, this just flips a local toggle (no OS integration).
---
### 4.2 Home
**From flow:**
- Login → Home → Decision (to Profile / Recipients / Transactions / Requests / Pay).
- “On this page he can show the amount of credits he has on the application.”
**UI Sections:**
- **TopBar**
- Greeting (“Hi, [Agent Name]”)
- Notification bell
- Agent avatar
- **Credits Card**
- Large amount: “Available Credits”
- Subtitle: “Balance usable for client payments”
- Optional “Last updated” label (dummy)
- **Quick Actions Row**
- Buttons to navigate:
- Recipients
- Requests
- Transactions
- Pay
- **Upcoming Reminders**
- Vertical list with:
- Client name
- Due date / time
- Small status pill (e.g. “Upcoming”)
- All populated with dummy items.
---
### 4.3 Recipients (Client Management)
**From doc:**
- “Recipient list including account associated with detail page also showing the schedule for when they want payments.”
- “Multi-client management; associate multiple accounts per client.”
#### Recipients List Screen
- **Search bar**: “Search recipients”
- Optional filter icon on the right.
- **Recipient Card** per client:
- Name (Individual / Business)
- Tag pill: `Individual` or `Business`
- Short line: e.g. “3 accounts” or main account alias
- Small “Next payment” label if schedule exists
- On tap → Recipient Detail
#### Recipient Detail Screen
- **Header**
- Name, avatar/initials
- Client type pill
- **Accounts Section**
- Card or list: bank name + account number for each linked account
- “Add Account” button:
- Opens bottom sheet similar to Edit Profiles multi-account UI:
- Bank selector
- Account number input
- Save (dummy only)
- **Schedules Section**
- List of schedules:
- e.g. “Every Monday 10:00”, “Monthly, 1st at 09:00”
- Button: **Manage Schedules**:
- Opens UI-only bottom sheet with:
- Frequency selector (Daily / Weekly / Monthly / Custom)
- Time selector (simple text field for now)
- Save (no backend)
- **Actions**
- **Pay Now** → navigates to Pay screen with this recipient preselected.
- **View Transactions** → navigates to Transactions with a client filter preset (UI-only).
---
### 4.4 Transactions
**From doc:**
- “All the transaction he has processed.”
- Reporting & tracking.
#### Transactions Screen
- **Filter/Search Bar**
- Text input: “Search by client or reference”
- Filter chips:
- All
- Success
- Pending
- Failed
- **Summary Row (Optional)**
- Todays total
- This weeks total
- Total transactions
- All dummy data.
- **Transactions List**
- Each row:
- Amount + currency
- Status pill: Success/Pending/Failed
- Client name
- Date and time
- Small method icon (Telebirr/Chapa/etc., static icon)
For Phase 1, the list is fed by a mocked array.
---
### 4.5 Requests
**From flow:**
- “Every book now request for the agent himself stands here. With the person, time, and date.”
#### Requests Screen
- List of **Request Cards**:
- Client name
- Scheduled date & time
- Requested amount (optional)
- Status: Pending / Accepted / Completed (local state only)
- Buttons:
- **View Details**
- **Pay Now**
- **Request Detail Bottom Sheet**
- Client info
- Requested schedule
- Notes/description (dummy)
- Actions:
- **Mark as Accepted**
- **Mark as Done**
- All actions affect only in-memory state.
- **Pay Now** from a request:
- Opens Pay screen with recipient + amount pre-filled (no real payment).
---
### 4.6 Pay
**From flow:**
- “Where he can make the payment same flow as per usual.”
#### Pay Screen
- **Context**
- If navigated from Recipient:
- Recipient field is pre-filled and locked.
- If from Home:
- Recipient selector row: “Select Recipient” → bottom-sheet list.
- **Fields**
- Amount input
- Payment method dropdown:
- Telebirr
- Chapa
- Other methods (dummy options)
- Optional note / description
- **Actions**
- Primary: **Review & Confirm**
- Opens bottom sheet:
- Shows summary: recipient, amount, method
- Button: **Confirm (UI only)**:
- Closes sheet and maybe sets a local “Success” toast.
- No network calls in Phase 1.
---
### 4.7 Profile (Agent Profile)
**From doc:**
- Multi-level access, notification channels, localization, etc.
#### Profile Screen
- **Agent Info Card**
- Name
- Role: Agent
- Agent ID or code
- **Sections**
1. **Account & Security**
- `Change Password` row (placeholder screen)
- `Biometric Login` toggle (ties to login biometric prompt state)
2. **Notifications**
- Toggles:
- SMS notifications
- In-app notifications
- Email notifications
- All stored in local state only.
3. **Localization**
- Language selector row:
- English
- Arabic
- Other supported languages (am, fr, om, ti)
- For Phase 1, dont do anything on localization.
4. **Reports**
- Row: **View Reports**
- Navigates to Transactions screen or a placeholder “Reports coming soon” screen.
5. **Logout**
- Simple button that routes back to Login screen (no token logic yet).
---
## 5. Implementation Notes (Phase 1)
- **No backend calls**:
- All screens use mocked arrays/state.
- No SMS, email, or payment gateway calls.
- **Biometrics**
- Only UI affordances (prompt, toggle).
- Underlying OS integration is future work.
- **Localization**
- This file is stored under [locales/en](cci:7://file:///Users/user/PROJECTS/NOVEMBER/amba/locales/en:0:0-0:0) as a reference for future translation.
- Actual text keys (i18n) can be extracted later.
- **Design**
- Reuse existing Amba components where possible:
- `ScreenWrapper`, [TopBar](cci:1://file:///Users/user/PROJECTS/NOVEMBER/amba/components/ui/topBar.tsx:10:0-55:1), `Input`, `Button`, `BottomSheet`, cards, etc.
- Stick to the existing Amba color palette and typography.
---
Overall structure
Goal: Agent can log in, see credits, manage recipients/clients, see transactions, handle “book now” requests, and trigger payments.
For this phase we:
Keep Amba look & feel (colors, typography, cards).
Build new agent-focused screens and navigation.
Use static/dummy data only, no real payment/notification backend yet. 2. Navigation proposal
Bottom tabs for Agent app:
Home
Recipients
Requests
Transactions
Profile
From these, we can navigate to:
Recipient Detail (per client)
Pay flow (can be reached from Home or Recipient Detail)
Maybe a Calendar/Reminders view later (for now: simple section in Home / Recipient Detail). 3. Screen-by-screen UI plan
3.1 Login (Agent Login)
From doc:
“Login should promote for biometric access as well rather than password after first login.”
UI changes:
Login Screen
Email / Phone input.
Password field.
Primary CTA: Sign In.
Secondary: Forgot password?.
After first successful login (simulated via local state for now), show:
Bottom sheet or inline card:
“Enable biometric login” with Enable Face ID / Fingerprint button and Not now.
Next phase we only simulate biometrics (no real OS-level integration yet, just toggle state).
3.2 Home
From doc + flow:
Show agent credits.
Entry point to other flows.
UI:
Top bar:
TopBar
-style with:
Greeting (“Hi, [Agent Name]”).
Notification bell.
Agent avatar.
Credits Card:
Big number: “Available Credits”.
Subtitle: “Balance usable for client payments”.
Quick Actions Row (buttons/cards):
Recipients
Requests
Transactions
Pay
Upcoming Reminders (for scheduling feature, UI-only):
List of “Next payment reminders” with:
Client name.
Due date/time.
Small status pill (e.g. “Pending”).
All dummy data for now.
3.3 Recipients (Client Management)
From doc + post-it:
“This will have the recipient list including account associated with detail page also showing the schedule for when they want payments.”
“Multi-client management, multiple accounts per client.”
List Screen UI:
Search bar: Search recipients.
Filters (optional for now, icon only).
List of Recipient Cards:
Name (person or business).
Tags: Individual / Business.
Short info: main account or “3 accounts”.
Next scheduled payment date (if any).
Tap → Recipient Detail.
Recipient Detail UI:
Header:
Name, avatar/initial icon.
Client type pill.
Accounts section:
Card listing linked accounts (bank name, account number).
“Add Account” button (UI only; bottom sheet like Edit Profile multi-account).
Schedule section (reminders):
List of schedules: “Every Monday 10:00”, “Monthly 1st at 9:00”.
Button: Manage Schedules (for now shows bottom sheet with dummy options).
Actions:
Pay Now button (goes to Pay UI with recipient preselected).
View Transactions (filter Transactions screen by this recipient UI-only filter).
3.4 Transactions
From doc:
“All the transaction he has processed.”
Reporting & tracking.
UI:
Search / filter bar:
Search by client.
Filter chip row: All, Success, Pending, Failed.
Transactions list:
Each row: amount, status pill, client name, date/time, small method icon.
Optional summary at top:
Todays total, This week, etc. (dummy numbers).
No real data, just static mocked list.
3.5 Requests
From flow note:
“Every book now request for the agent himself stands here. With the person, time, and date.”
This is basically a queue of incoming scheduled “Book Now” / payment requests.
UI:
List of Request Cards:
Client name.
Requested date & time.
Amount (if part of the request).
Status: Pending, Accepted, Completed (UI-only).
CTA buttons:
View Details → Request Detail bottom sheet.
Pay Now → Pay screen with details pre-filled.
Request Detail (BottomSheet):
Client info.
Requested schedule.
Notes.
UI-only actions: Mark as Accepted, Mark as Done (just local state change).
3.6 Pay
From flow note:
“Where he can make the payment same flow as per usual.”
This is the payment initiation screen.
UI:
If navigated from Recipient / Request:
Recipient pre-filled and locked.
If from Home:
Recipient picker at top (Select Recipient row → bottom sheet list).
Fields:
Amount.
Payment method (dropdown: Telebirr, Chapa, etc).
Optional note / description.
CTA:
Review & Confirm (UI-only):
Opens confirmation bottom sheet showing summary, “Confirm (UI only)” button.
We wont hit any gateway APIs yet; we just simulate the flow.
3.7 Profile (Agent Profile)
From doc:
Multi-level access, localization, notification channels, etc.
UI:
Agent info card: name, role (Agent), ID.
Sections:
Account & Security:
Change password (UI-only).
Biometric login toggle (syncs with Login prompt state).
Notifications:
Toggles for SMS, In-app, Email (UI-only).
Localization:
Language selector (English / Arabic; no real RTL yet in phase 1, just selection).
Reports:
“View Reports” → for now goes to Transactions screen pre-filtered or a placeholder “coming soon” page.
Logout button. 4. Phase 1 implementation plan (UI-only)
Once you confirm the above direction, Ill:
Set up Agent navigation:
New or modified tabs for
Home
, Recipients, Requests, Transactions, Profile.
Create minimal versions of:
AgentHomeScreen
AgentRecipientsScreen + RecipientDetail
AgentTransactionsScreen
AgentRequestsScreen
AgentPayScreen
AgentProfileScreen
Wire in dummy data for:
Recipients (with multiple accounts & schedules).
Transactions.
Requests (bookings).
Keep everything UI-only:
No real payment, no SMS/email sending, no real biometrics or gateway calls.

352
amba-about.html Normal file

File diff suppressed because one or more lines are too long

BIN
amba_release.keystore Normal file

Binary file not shown.

16
android/.gitignore vendored Normal file
View File

@ -0,0 +1,16 @@
# OSX
#
.DS_Store
# Android/IntelliJ
#
build/
.idea
.gradle
local.properties
*.iml
*.hprof
.cxx/
# Bundle artifacts
*.jsbundle

View File

@ -0,0 +1,4 @@
kotlin version: 2.0.21
error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
1. Kotlin compile daemon is ready

View File

@ -0,0 +1,4 @@
kotlin version: 2.0.21
error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
1. Kotlin compile daemon is ready

View File

@ -0,0 +1,4 @@
kotlin version: 2.0.21
error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
1. Kotlin compile daemon is ready

View File

@ -0,0 +1,4 @@
kotlin version: 2.0.21
error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
1. Kotlin compile daemon is ready

179
android/app/build.gradle Normal file
View File

@ -0,0 +1,179 @@
apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.facebook.react"
def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
/**
* This is the configuration block to customize your React Native Android app.
* By default you don't need to apply any configuration, just uncomment the lines you need.
*/
react {
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc"
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean()
// Use Expo CLI to bundle the app, this ensures the Metro config
// works correctly with Expo projects.
cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
bundleCommand = "export:embed"
/* Folders */
// The root of your project, i.e. where "package.json" lives. Default is '../..'
// root = file("../../")
// The folder where the react-native NPM package is. Default is ../../node_modules/react-native
// reactNativeDir = file("../../node_modules/react-native")
// The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen
// codegenDir = file("../../node_modules/@react-native/codegen")
/* Variants */
// The list of variants to that are debuggable. For those we're going to
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
// debuggableVariants = ["liteDebug", "prodDebug"]
/* Bundling */
// A list containing the node command and its flags. Default is just 'node'.
// nodeExecutableAndArgs = ["node"]
//
// The path to the CLI configuration file. Default is empty.
// bundleConfig = file(../rn-cli.config.js)
//
// The name of the generated asset file containing your JS bundle
// bundleAssetName = "MyApplication.android.bundle"
//
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
// entryFile = file("../js/MyApplication.android.js")
//
// A list of extra flags to pass to the 'bundle' commands.
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
// extraPackagerArgs = []
/* Hermes Commands */
// The hermes compiler command to run. By default it is 'hermesc'
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
//
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
// hermesFlags = ["-O", "-output-source-map"]
/* Autolinking */
autolinkLibrariesWithApp()
}
/**
* Set this to true to Run Proguard on Release builds to minify the Java bytecode.
*/
def enableProguardInReleaseBuilds = (findProperty('android.enableProguardInReleaseBuilds') ?: false).toBoolean()
/**
* The preferred build flavor of JavaScriptCore (JSC)
*
* For example, to use the international variant, you can use:
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
*
* The international variant includes ICU i18n library and necessary data
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
* give correct results when using with locales other than en-US. Note that
* this variant is about 6MiB larger per architecture than default.
*/
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
android {
ndkVersion rootProject.ext.ndkVersion
buildToolsVersion rootProject.ext.buildToolsVersion
compileSdk rootProject.ext.compileSdkVersion
namespace 'com.ambapay.ambaagent'
defaultConfig {
applicationId 'com.ambapay.ambaagent'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0.0"
}
signingConfigs {
debug {
storeFile file('debug.keystore')
storePassword 'android'
keyAlias 'androiddebugkey'
keyPassword 'android'
}
}
buildTypes {
debug {
signingConfig signingConfigs.debug
}
release {
// Caution! In production, you need to generate your own keystore file.
// see https://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.debug
shrinkResources (findProperty('android.enableShrinkResourcesInReleaseBuilds')?.toBoolean() ?: false)
minifyEnabled enableProguardInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
crunchPngs (findProperty('android.enablePngCrunchInReleaseBuilds')?.toBoolean() ?: true)
}
}
packagingOptions {
jniLibs {
useLegacyPackaging (findProperty('expo.useLegacyPackaging')?.toBoolean() ?: false)
}
}
androidResources {
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
// Apply static values from `gradle.properties` to the `android.packagingOptions`
// Accepts values in comma delimited lists, example:
// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini
["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop ->
// Split option: 'foo,bar' -> ['foo', 'bar']
def options = (findProperty("android.packagingOptions.$prop") ?: "").split(",");
// Trim all elements in place.
for (i in 0..<options.size()) options[i] = options[i].trim();
// `[] - ""` is essentially `[""].filter(Boolean)` removing all empty strings.
options -= ""
if (options.length > 0) {
println "android.packagingOptions.$prop += $options ($options.length)"
// Ex: android.packagingOptions.pickFirsts += '**/SCCS/**'
options.each {
android.packagingOptions[prop] += it
}
}
}
dependencies {
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
if (isGifEnabled) {
// For animated gif support
implementation("com.facebook.fresco:animated-gif:${expoLibs.versions.fresco.get()}")
}
if (isWebpEnabled) {
// For webp support
implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}")
if (isWebpAnimatedEnabled) {
// Animated webp support
implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}")
}
}
if (hermesEnabled.toBoolean()) {
implementation("com.facebook.react:hermes-android")
} else {
implementation jscFlavor
}
}
apply plugin: 'com.google.gms.google-services'

BIN
android/app/debug.keystore Normal file

Binary file not shown.

View File

@ -0,0 +1,107 @@
{
"project_info": {
"project_number": "613864011564",
"firebase_url": "https://ambapaydemo-default-rtdb.europe-west1.firebasedatabase.app",
"project_id": "ambapaydemo",
"storage_bucket": "ambapaydemo.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:613864011564:android:fd35c9ac4fc05b38249e89",
"android_client_info": {
"package_name": "com.amba"
}
},
"oauth_client": [
{
"client_id": "613864011564-2h1fb41f6conaabli0uq7scacpfmvuiq.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "com.amba",
"certificate_hash": "e5160567582b67483fec269eadfc9a286a2e14b0"
}
},
{
"client_id": "613864011564-c8hfpatgmgvse0a1hdhho3ipj3ddrut3.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "com.amba",
"certificate_hash": "5e8f16062ea3cd2c4a0d547876baa6f38cabf625"
}
},
{
"client_id": "613864011564-78d915g0hm9sbveskkfcch6mrd8atktb.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyDTSIxujhye42iB8bDDvUR7jjRLo7Et3CU"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "613864011564-78d915g0hm9sbveskkfcch6mrd8atktb.apps.googleusercontent.com",
"client_type": 3
},
{
"client_id": "613864011564-4mof6e14th4jdo4nrqe4qocntpovgof2.apps.googleusercontent.com",
"client_type": 2,
"ios_info": {
"bundle_id": "com.ambapay.ambaagent"
}
}
]
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:613864011564:android:7aad206fe697dc32249e89",
"android_client_info": {
"package_name": "com.ambapay.ambaagent"
}
},
"oauth_client": [
{
"client_id": "613864011564-tnj0afut9mmg7vk7u2v6qhenel2auq7i.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "com.ambapay.ambaagent",
"certificate_hash": "5e8f16062ea3cd2c4a0d547876baa6f38cabf625"
}
},
{
"client_id": "613864011564-78d915g0hm9sbveskkfcch6mrd8atktb.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyDTSIxujhye42iB8bDDvUR7jjRLo7Et3CU"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "613864011564-78d915g0hm9sbveskkfcch6mrd8atktb.apps.googleusercontent.com",
"client_type": 3
},
{
"client_id": "613864011564-4mof6e14th4jdo4nrqe4qocntpovgof2.apps.googleusercontent.com",
"client_type": 2,
"ios_info": {
"bundle_id": "com.ambapay.ambaagent"
}
}
]
}
}
}
],
"configuration_version": "1"
}

14
android/app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,14 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# react-native-reanimated
-keep class com.swmansion.reanimated.** { *; }
-keep class com.facebook.react.turbomodule.** { *; }
# Add any project specific keep options here:

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
</manifest>

View File

@ -0,0 +1,39 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.READ_CONTACTS"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
<uses-permission android:name="android.permission.USE_FINGERPRINT"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_CALENDAR"/>
<uses-permission android:name="android.permission.WRITE_CALENDAR"/>
<queries>
<intent>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https"/>
</intent>
</queries>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:usesCleartextTraffic="true">
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="ambaagent"/>
<data android:scheme="exp+ambaagent"/>
</intent-filter>
</activity>
</application>
</manifest>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,61 @@
package com.ambapay.ambaagent
import android.os.Build
import android.os.Bundle
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
import com.facebook.react.defaults.DefaultReactActivityDelegate
import expo.modules.ReactActivityDelegateWrapper
class MainActivity : ReactActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// Set the theme to AppTheme BEFORE onCreate to support
// coloring the background, status bar, and navigation bar.
// This is required for expo-splash-screen.
setTheme(R.style.AppTheme);
super.onCreate(null)
}
/**
* Returns the name of the main component registered from JavaScript. This is used to schedule
* rendering of the component.
*/
override fun getMainComponentName(): String = "main"
/**
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
*/
override fun createReactActivityDelegate(): ReactActivityDelegate {
return ReactActivityDelegateWrapper(
this,
BuildConfig.IS_NEW_ARCHITECTURE_ENABLED,
object : DefaultReactActivityDelegate(
this,
mainComponentName,
fabricEnabled
){})
}
/**
* Align the back button behavior with Android S
* where moving root activities to background instead of finishing activities.
* @see <a href="https://developer.android.com/reference/android/app/Activity#onBackPressed()">onBackPressed</a>
*/
override fun invokeDefaultOnBackPressed() {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
if (!moveTaskToBack(false)) {
// For non-root activities, use the default implementation to finish them.
super.invokeDefaultOnBackPressed()
}
return
}
// Use the default back button implementation on Android S
// because it's doing more than [Activity.moveTaskToBack] in fact.
super.invokeDefaultOnBackPressed()
}
}

View File

@ -0,0 +1,61 @@
package com.ambapay.ambaagent
import com.facebook.react.common.assets.ReactFontManager
import android.app.Application
import android.content.res.Configuration
import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactNativeHost
import com.facebook.react.ReactPackage
import com.facebook.react.ReactHost
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load
import com.facebook.react.defaults.DefaultReactNativeHost
import com.facebook.react.soloader.OpenSourceMergedSoMapping
import com.facebook.soloader.SoLoader
import expo.modules.ApplicationLifecycleDispatcher
import expo.modules.ReactNativeHostWrapper
class MainApplication : Application(), ReactApplication {
override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(
this,
object : DefaultReactNativeHost(this) {
override fun getPackages(): List<ReactPackage> {
val packages = PackageList(this).packages
// Packages that cannot be autolinked yet can be added manually here, for example:
// packages.add(MyReactNativePackage())
return packages
}
override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED
}
)
override val reactHost: ReactHost
get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost)
override fun onCreate() {
super.onCreate()
// @generated begin xml-fonts-init - expo prebuild (DO NOT MODIFY) sync-da39a3ee5e6b4b0d3255bfef95601890afd80709
// @generated end xml-fonts-init
SoLoader.init(this, OpenSourceMergedSoMapping)
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
// If you opted-in for the New Architecture, we load the native entry point for this app.
load()
}
ApplicationLifecycleDispatcher.onApplicationCreate(this)
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

View File

@ -0,0 +1,6 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splashscreen_background"/>
<item>
<bitmap android:gravity="center" android:src="@drawable/splashscreen_logo"/>
</item>
</layer-list>

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2014 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material"
android:insetRight="@dimen/abc_edit_text_inset_horizontal_material"
android:insetTop="@dimen/abc_edit_text_inset_top_material"
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material"
>
<selector>
<!--
This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I).
The item below with state_pressed="false" and state_focused="false" causes a NullPointerException.
NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)'
<item android:state_pressed="false" android:state_focused="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
For more info, see https://bit.ly/3CdLStv (react-native/pull/29452) and https://bit.ly/3nxOMoR.
-->
<item android:state_enabled="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
<item android:drawable="@drawable/abc_textfield_activated_mtrl_alpha"/>
</selector>
</inset>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/iconBackground"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/iconBackground"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1 @@
<resources/>

View File

@ -0,0 +1,6 @@
<resources>
<color name="splashscreen_background">#ffffff</color>
<color name="iconBackground">#ffffff</color>
<color name="colorPrimary">#023c69</color>
<color name="colorPrimaryDark">#ffffff</color>
</resources>

View File

@ -0,0 +1,5 @@
<resources>
<string name="app_name">ambaagent</string>
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
</resources>

View File

@ -0,0 +1,11 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
<item name="android:windowOptOutEdgeToEdgeEnforcement" tools:targetApi="35">true</item>
<item name="colorPrimary">@color/colorPrimary</item>
<item name="android:statusBarColor">#ffffff</item>
</style>
<style name="Theme.App.SplashScreen" parent="AppTheme">
<item name="android:windowBackground">@drawable/ic_launcher_background</item>
</style>
</resources>

38
android/build.gradle Normal file
View File

@ -0,0 +1,38 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.google.gms:google-services:4.4.1'
classpath('com.android.tools.build:gradle')
classpath('com.facebook.react:react-native-gradle-plugin')
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
}
}
def reactNativeAndroidDir = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('react-native/package.json')")
}.standardOutput.asText.get().trim(),
"../android"
)
allprojects {
repositories {
maven {
// All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
url(reactNativeAndroidDir)
}
google()
mavenCentral()
maven { url 'https://www.jitpack.io' }
}
}
apply plugin: "expo-root-project"
apply plugin: "com.facebook.react.rootproject"

59
android/gradle.properties Normal file
View File

@ -0,0 +1,59 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Enable AAPT2 PNG crunching
android.enablePngCrunchInReleaseBuilds=true
# Use this property to specify which architecture you want to build.
# You can also override it from the CLI using
# ./gradlew <task> -PreactNativeArchitectures=x86_64
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
# Use this property to enable support to the new architecture.
# This will allow you to use TurboModules and the Fabric render in
# your application. You should enable this flag either if you want
# to write custom TurboModules/Fabric components OR use libraries that
# are providing them.
newArchEnabled=true
# Use this property to enable or disable the Hermes JS engine.
# If set to false, you will be using JSC instead.
hermesEnabled=true
# Enable GIF support in React Native images (~200 B increase)
expo.gif.enabled=true
# Enable webp support in React Native images (~85 KB increase)
expo.webp.enabled=true
# Enable animated webp support (~3.4 MB increase)
# Disabled by default because iOS doesn't support animated webp
expo.webp.animated=false
# Enable network inspector
EX_DEV_CLIENT_NETWORK_INSPECTOR=true
# Use legacy packaging to compress native libraries in the resulting APK.
expo.useLegacyPackaging=false
# Whether the app is configured to use edge-to-edge via the app config or `react-native-edge-to-edge` plugin
expo.edgeToEdgeEnabled=false

Binary file not shown.

View File

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
android/gradlew vendored Executable file
View File

@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
android/gradlew.bat vendored Normal file
View File

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

39
android/settings.gradle Normal file
View File

@ -0,0 +1,39 @@
pluginManagement {
def reactNativeGradlePlugin = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })")
}.standardOutput.asText.get().trim()
).getParentFile().absolutePath
includeBuild(reactNativeGradlePlugin)
def expoPluginsPath = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })")
}.standardOutput.asText.get().trim(),
"../android/expo-gradle-plugin"
).absolutePath
includeBuild(expoPluginsPath)
}
plugins {
id("com.facebook.react.settings")
id("expo-autolinking-settings")
}
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
ex.autolinkLibrariesFromCommand()
} else {
ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand)
}
}
expoAutolinking.useExpoModules()
rootProject.name = 'amba'
expoAutolinking.useExpoVersionCatalog()
include ':app'
includeBuild(expoAutolinking.reactNativeGradlePlugin)

94
app.json Normal file
View File

@ -0,0 +1,94 @@
{
"expo": {
"name": "ambaagent",
"slug": "ambaagent",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.ambapay.ambaagent",
"buildNumber": "1",
"infoPlist": {
"NSContactsUsageDescription": "Allow AmbaPay to access your contacts to find friends and recipients for money transfers.",
"NSFaceIDUsageDescription": "Allow AmbaPay to use Face ID for secure authentication.",
"NSCalendarsUsageDescription": "Allow Amba Pay to add your payment schedules to your calendar.",
"ITSAppUsesNonExemptEncryption": false,
"CFBundleURLTypes": [
{
"CFBundleTypeRole": "Editor",
"CFBundleURLSchemes": [
"com.googleusercontent.apps.613864011564-atsg9nau8hicla4td6dedcab15g7qr04"
]
}
]
},
"googleServicesFile": "./GoogleService-Info.plist",
"entitlements": {
"aps-environment": "production"
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"package": "com.ambapay.ambaagent",
"googleServicesFile": "./google-services.json",
"permissions": [
"READ_CONTACTS",
"USE_BIOMETRIC",
"USE_FINGERPRINT",
"android.permission.READ_CONTACTS",
"android.permission.WRITE_CONTACTS",
"android.permission.POST_NOTIFICATIONS",
"android.permission.READ_CALENDAR",
"android.permission.WRITE_CALENDAR",
"READ_CALENDAR",
"WRITE_CALENDAR"
]
},
"web": {
"favicon": "./assets/favicon.png",
"bundler": "metro"
},
"plugins": [
"expo-router",
[
"expo-font",
{
"fonts": [
"./assets/fonts/DMSans-Regular.ttf",
"./assets/fonts/DMSans-Bold.ttf",
"./assets/fonts/DMSans-Medium.ttf",
"./assets/fonts/DMSans-Black.ttf",
"./assets/fonts/DMSans-SemiBold.ttf",
"./assets/fonts/DMSans-Light.ttf",
"./assets/fonts/DMSans-Thin.ttf",
"./assets/fonts/DMSans-ExtraLight.ttf",
"./assets/fonts/DMSans-ExtraBold.ttf"
]
}
],
[
"expo-contacts",
{
"contactsPermission": "Allow $(PRODUCT_NAME) to access your contacts to find friends and recipients for money transfers."
}
],
"@react-native-firebase/app",
"@react-native-google-signin/google-signin"
],
"autolinking": {
"exclude": ["expo-firebase-core"]
},
"scheme": "ambaagent"
}
}

View File

@ -0,0 +1,424 @@
import React, { useState, useEffect } from "react";
import { ScrollView, View, TouchableOpacity, Keyboard } from "react-native";
import { LucideEye, EyeOff, Trash2, CreditCard } from "lucide-react-native";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Text } from "~/components/ui/text";
import { router, useLocalSearchParams } from "expo-router";
import { useAuthWithProfile } from "~/lib/hooks/useAuthWithProfile";
import { useUserWallet } from "~/lib/hooks/useUserWallet";
import { CreditCard as CreditCardType } from "~/lib/services/walletService";
import { useTabStore } from "~/lib/stores";
import { addCardSchema, validate } from "~/lib/utils/validationSchemas";
import BackButton from "~/components/ui/backButton";
import { ROUTES } from "~/lib/routes";
import ScreenWrapper from "~/components/ui/ScreenWrapper";
import ModalToast from "~/components/ui/toast";
import { useTranslation } from "react-i18next";
// Credit Card Component
const CreditCardComponent = ({
card,
onRemove,
}: {
card: CreditCardType;
onRemove: () => void;
}) => {
const getCardColor = (cardType: string) => {
switch (cardType?.toLowerCase()) {
case "visa":
return "bg-blue-700";
case "mastercard":
return "bg-red-600";
case "american express":
return "bg-green-700";
case "discover":
return "bg-orange-600";
default:
return "bg-gray-700";
}
};
const getCardIcon = (cardType: string) => {
switch (cardType?.toLowerCase()) {
case "visa":
return "VISA";
case "mastercard":
return "MC";
case "american express":
return "AMEX";
case "discover":
return "DISC";
default:
return "CARD";
}
};
return (
<View
className={`w-80 h-48 rounded-xl ${getCardColor(
card.cardType || ""
)} p-6 relative shadow-lg`}
style={{ overflow: "visible" }}
>
{/* Card Brand and Remove Button */}
<View className="flex-row justify-between items-start mb-4">
<View className="bg-white/20 px-3 py-1 rounded-md">
<Text className="text-white font-dmsans-bold text-sm">
{getCardIcon(card.cardType || "")}
</Text>
</View>
<TouchableOpacity
onPress={() => {
console.log("Remove button pressed for card:", card.id);
onRemove();
}}
className="bg-gray-500/80 p-3 rounded-full min-w-10 min-h-10 flex items-center justify-center"
activeOpacity={0.7}
style={{ zIndex: 10 }}
>
<Trash2 color="#FFFFFF" size={18} />
</TouchableOpacity>
</View>
{/* Card Number */}
<View className="mb-6">
<Text className="text-white font-dmsans-mono text-lg tracking-wider">
{card.cardNumber}
</Text>
</View>
{/* Card Details */}
<View className="flex-row justify-between items-end">
<View>
<Text className="text-white/70 font-dmsans text-xs uppercase tracking-wide mb-1">
Valid Thru
</Text>
<Text className="text-white font-dmsans-medium text-sm">
{card.expiryDate}
</Text>
</View>
<View>
<Text className="text-white/70 font-dmsans text-xs uppercase tracking-wide mb-1">
Card Type
</Text>
<Text className="text-white font-dmsans-medium text-sm capitalize">
{card.cardType || "Unknown"}
</Text>
</View>
</View>
{/* Decorative Elements */}
<View className="absolute top-4 right-4 opacity-20">
<CreditCard color="#FFFFFF" size={32} />
</View>
<View className="absolute bottom-4 right-4 opacity-10">
<View className="w-12 h-8 bg-white/30 rounded-sm" />
</View>
</View>
);
};
export default function AddCard() {
const { user } = useAuthWithProfile();
const { addCreditCard, loading, error } = useUserWallet(user);
const { setLastVisitedTab } = useTabStore();
const { from } = useLocalSearchParams<{ from?: string }>();
const { t } = useTranslation();
const [toastVisible, setToastVisible] = useState(false);
const [toastTitle, setToastTitle] = useState("");
const [toastDescription, setToastDescription] = useState<string | undefined>(
undefined
);
const [toastVariant, setToastVariant] = useState<
"success" | "error" | "warning" | "info"
>("info");
const toastTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(
null
);
const showToast = (
title: string,
description?: string,
variant: "success" | "error" | "warning" | "info" = "info"
) => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
setToastTitle(title);
setToastDescription(description);
setToastVariant(variant);
setToastVisible(true);
toastTimeoutRef.current = setTimeout(() => {
setToastVisible(false);
toastTimeoutRef.current = null;
}, 2500);
};
// Set the tab state when component mounts based on where user came from
useEffect(() => {
if (from === "addcash") {
setLastVisitedTab("/(tabs)/cardmang");
} else {
setLastVisitedTab("/"); // Default to home if no specific from parameter
}
}, [setLastVisitedTab, from]);
useEffect(() => {
return () => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
};
}, []);
// Form state
const [cardNumber, setCardNumber] = useState("");
const [expiryDate, setExpiryDate] = useState("");
const [cvv, setCvv] = useState("");
const [showCvv, setShowCvv] = useState(false);
// Format card number as user types (add spaces every 4 digits)
const formatCardNumber = (value: string) => {
const v = value.replace(/\s+/g, "").replace(/[^0-9]/gi, "");
const matches = v.match(/\d{4,16}/g);
const match = (matches && matches[0]) || "";
const parts = [];
for (let i = 0, len = match.length; i < len; i += 4) {
parts.push(match.substring(i, i + 4));
}
if (parts.length) {
return parts.join(" ");
} else {
return v;
}
};
// Format expiry date as MM/YY
const formatExpiryDate = (value: string) => {
const v = value.replace(/\s+/g, "").replace(/[^0-9]/gi, "");
if (v.length >= 2) {
return `${v.substring(0, 2)}/${v.substring(2, 4)}`;
}
return v;
};
const handleCardNumberChange = (value: string) => {
const formatted = formatCardNumber(value);
if (formatted.length <= 19) {
// 16 digits + 3 spaces
setCardNumber(formatted);
}
};
const handleExpiryDateChange = (value: string) => {
const formatted = formatExpiryDate(value);
if (formatted.length <= 5) {
// MM/YY
setExpiryDate(formatted);
}
};
const handleCvvChange = (value: string) => {
const v = value.replace(/[^0-9]/gi, "");
if (v.length <= 4) {
setCvv(v);
}
};
const handleAddCard = async () => {
Keyboard.dismiss();
// Basic field validation
if (!cardNumber.trim()) {
showToast(
t("addcard.validationErrorTitle"),
t("addcard.validationCardNumberRequired"),
"error"
);
return;
}
if (!expiryDate.trim()) {
showToast(
t("addcard.validationErrorTitle"),
t("addcard.validationExpiryRequired"),
"error"
);
return;
}
if (!cvv.trim()) {
showToast(
t("addcard.validationErrorTitle"),
t("addcard.validationCvvRequired"),
"error"
);
return;
}
// Validate form using valibot
const validationResult = validate(addCardSchema, {
cardNumber,
expiryDate,
cvv,
});
if (!validationResult.success) {
showToast(
t("addcard.validationErrorTitle"),
validationResult.error || t("addcard.validationInvalidCard"),
"error"
);
return;
}
try {
await addCreditCard({
cardNumber: validationResult.data.cardNumber.replace(/\s/g, ""), // Remove spaces
expiryDate: validationResult.data.expiryDate,
cvv: validationResult.data.cvv,
});
router.replace(ROUTES.CARD_ADDED);
} catch (err) {
showToast(
t("addcard.toastErrorTitle"),
t("addcard.toastAddFailed"),
"error"
);
}
};
// Show errors
useEffect(() => {
if (error) {
showToast(t("addcard.toastErrorTitle"), error, "error");
}
}, [error]);
return (
<ScreenWrapper edges={[]}>
<View className="flex-1 w-full justify-between">
<ScrollView
className="flex-1"
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 24 }}
>
<BackButton />
<View className="flex flex-col space-y-1 py-5 pt-8 items-center">
<Text className="text-2xl font-semibold font-dmsans text-black">
{t("addcard.title")}
</Text>
</View>
<View className="h-12" />
<View className="px-5">
<View>
<Text className="text-primary font-dmsans-medium">
{t("addcard.sectionCardTitle")}
</Text>
</View>
<View className="h-4" />
<View>
<Text className="font-dmsans-medium">
{t("addcard.sectionCardSubtitle")}
</Text>
</View>
<View className="h-12" />
<Label className="text-base font-dmsans-medium">
{t("addcard.cardNumberLabel")}
</Label>
<View className="h-2" />
<Input
placeholder={t("addcard.cardNumberPlaceholder")}
value={cardNumber}
onChangeText={handleCardNumberChange}
keyboardType="numeric"
maxLength={19}
containerClassName="w-full"
borderClassName="border-[#D9DBE9] bg-white"
placeholderColor="#7E7E7E"
textClassName="text-[#000] text-sm"
/>
<View className="h-4" />
<Label className="text-base font-dmsans-medium">
{t("addcard.expiryDateLabel")}
</Label>
<View className="h-2" />
<Input
placeholder={t("addcard.expiryDatePlaceholder")}
value={expiryDate}
onChangeText={handleExpiryDateChange}
keyboardType="numeric"
maxLength={5}
containerClassName="w-full"
borderClassName="border-[#D9DBE9] bg-white"
placeholderColor="#7E7E7E"
textClassName="text-[#000] text-sm"
/>
<View className="h-4" />
<Label className="text-base font-dmsans-medium">
{t("addcard.cvvLabel")}
</Label>
<View className="h-2" />
<Input
placeholder={t("addcard.cvvPlaceholder")}
value={cvv}
onChangeText={handleCvvChange}
keyboardType="numeric"
secureTextEntry={!showCvv}
maxLength={4}
containerClassName="w-full"
borderClassName="border-[#D9DBE9] bg-white"
placeholderColor="#7E7E7E"
textClassName="text-[#000] text-sm"
rightIcon={
<TouchableOpacity onPress={() => setShowCvv(!showCvv)}>
{showCvv ? (
<EyeOff color="#4B5563" size={20} />
) : (
<LucideEye color="#4B5563" size={20} />
)}
</TouchableOpacity>
}
/>
<View className="h-4" />
</View>
</ScrollView>
<View className="w-full px-5 pb-8">
<Button
className="bg-primary rounded-3xl"
onPress={handleAddCard}
disabled={
loading || !cardNumber.trim() || !expiryDate.trim() || !cvv.trim()
}
>
<Text className="font-dmsans text-white">
{loading ? t("addcard.addButtonLoading") : t("addcard.addButton")}
</Text>
</Button>
</View>
</View>
<ModalToast
visible={toastVisible}
title={toastTitle}
description={toastDescription}
variant={toastVariant}
/>
</ScreenWrapper>
);
}

View File

@ -0,0 +1,313 @@
import React, { useState, useEffect } from "react";
import { View, InteractionManager, ActivityIndicator } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useFocusEffect } from "expo-router";
import { Button } from "~/components/ui/button";
import { Text } from "~/components/ui/text";
import { PhonePinKeypad } from "~/components/ui/PhonePinKeypad";
import { router } from "expo-router";
import { ROUTES } from "~/lib/routes";
import {
parseDisplayToCents,
formatDisplayAmount,
} from "~/lib/utils/monetaryUtils";
import { Big } from "big.js";
import { PinConfirmationModal } from "~/components/ui/pinConfirmationModal";
import { amountSchema, validate } from "~/lib/utils/validationSchemas";
import BackButton from "~/components/ui/backButton";
import { showAlert } from "~/lib/utils/alertUtils";
import ScreenWrapper from "~/components/ui/ScreenWrapper";
import { useTabStore } from "~/lib/stores";
import ModalToast from "~/components/ui/toast";
import { useTranslation } from "react-i18next";
export default function AddCash() {
const [amount, setAmount] = useState("");
const [showPinModal, setShowPinModal] = useState(false);
const [isSecurityVerified, setIsSecurityVerified] = useState(false);
const { setLastVisitedTab } = useTabStore();
const { t } = useTranslation();
const [toastVisible, setToastVisible] = useState(false);
const [toastTitle, setToastTitle] = useState("");
const [toastDescription, setToastDescription] = useState<string | undefined>(
undefined
);
const [toastVariant, setToastVariant] = useState<
"success" | "error" | "warning" | "info"
>("info");
const toastTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(
null
);
const showToast = (
title: string,
description?: string,
variant: "success" | "error" | "warning" | "info" = "info"
) => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
setToastTitle(title);
setToastDescription(description);
setToastVariant(variant);
setToastVisible(true);
toastTimeoutRef.current = setTimeout(() => {
setToastVisible(false);
toastTimeoutRef.current = null;
}, 2500);
};
// Set the tab state when component mounts (defer non-critical work)
useEffect(() => {
const task = InteractionManager.runAfterInteractions(() => {
if (__DEV__) {
console.log("ADD CASH PAGE MOUNTED");
}
setLastVisitedTab("/");
});
return () => task.cancel();
}, [setLastVisitedTab]);
useEffect(() => {
return () => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
};
}, []);
// Reset state and show PIN modal when screen comes into focus
// useFocusEffect only runs when screen is focused, so we can safely show modal here
useFocusEffect(
React.useCallback(() => {
setIsSecurityVerified(false);
setShowPinModal(true);
setAmount("");
// Cleanup: hide modal when screen loses focus
return () => {
setShowPinModal(false);
setIsSecurityVerified(false);
};
}, [])
);
// Handle number input and special actions
const handleNumberPress = (input: string) => {
if (input === "clear") {
handleClear();
return;
}
if (input === "backspace") {
handleBackspace();
return;
}
// Handle decimal point
if (input === ".") {
// Prevent multiple decimals
if (amount.includes(".")) return;
// If empty, start with "0."
if (amount === "") {
setAmount("0.");
return;
}
// Add decimal point
setAmount(amount + ".");
return;
}
// Handle digit input (0-9)
if (!/^[0-9]$/.test(input)) return;
// Handle leading zeros
if (amount === "0" && input !== ".") {
setAmount(input); // Replace leading zero
return;
}
const newAmount = amount + input;
// Check decimal places limit (max 2 decimal places)
if (newAmount.includes(".")) {
const [whole, decimal] = newAmount.split(".");
if (decimal && decimal.length > 2) return;
}
// Check maximum amount (max $999.99)
try {
const numValue = new Big(newAmount);
if (numValue.gt(999.99)) return;
} catch (error) {
return;
}
// Check total length to prevent very long inputs
if (newAmount.length > 6) return; // Max: 999.99
setAmount(newAmount);
};
// Handle backspace
const handleBackspace = () => {
if (amount.length === 0) return;
// Remove last character
const newAmount = amount.slice(0, -1);
setAmount(newAmount);
};
// Clear all input
const handleClear = () => {
setAmount("");
};
// Validate if amount is valid for submission
const isValidAmount = () => {
if (
!amount ||
amount === "" ||
amount === "0" ||
amount === "0." ||
amount === "0.00"
) {
return false;
}
const amountInCents = parseDisplayToCents(amount);
return amountInCents >= 1000 && amountInCents <= 99999; // $10.00 to $999.99
};
// Handle PIN confirmation success
const handlePinSuccess = () => {
setShowPinModal(false);
setIsSecurityVerified(true);
};
// Handle add cash action (called after PIN is verified)
const handleAddCash = () => {
// Validate amount using valibot (minimum $10.00 = 1000 cents)
const amountValidationResult = validate(
amountSchema({ min: 1000, max: 99999, minDisplay: "$10.00" }),
amount
);
if (!amountValidationResult.success) {
showToast(
t("addcash.validationErrorTitle"),
t("addcash.validationEnterAmount"),
"error"
);
return;
}
const amountInCents = parseDisplayToCents(amount);
if (amountInCents < 1000) {
// $10.00 minimum
showToast(
t("addcash.validationErrorTitle"),
t("addcash.validationMinAmount"),
"error"
);
return;
}
if (amountInCents > 99999) {
// $999.99 maximum
showToast(
t("addcash.validationErrorTitle"),
t("addcash.validationMaxAmount"),
"error"
);
return;
}
console.log("Adding cash:", amountInCents, "cents");
// Navigate into the add-cash donation + checkout flow
router.push({
pathname: ROUTES.DONATION,
params: {
amount: amountInCents.toString(),
type: "add_cash",
},
});
};
return (
<ScreenWrapper edges={[]}>
{!isSecurityVerified ? (
<View className="flex-1 justify-center items-center bg-white">
<ActivityIndicator size="large" color="hsl(147,55%,28%)" />
<View className="h-4" />
<Text className="text-gray-600 font-dmsans text-lg">
{t("addcash.verifyingSecurity")}
</Text>
</View>
) : (
<>
<BackButton />
<View className="flex h-full">
<View className="flex-1 justify-start items-center px-5 h-1/6 ">
<View className="h-12" />
<Text className="text-3xl font-dmsans text-primary">
{t("addcash.title")}
</Text>
</View>
<View className="h-2/6">
<Text className="text-8xl font-dmsans-bold text-center text-black pt-2">
{formatDisplayAmount(amount)}
</Text>
</View>
<View className="h-3/6 flex justify-around">
<View className="px-8">
<PhonePinKeypad
onKeyPress={handleNumberPress}
showDecimal={true}
/>
</View>
<View className="px-5">
<Button
className="bg-primary rounded-3xl mb-5"
onPress={handleAddCash}
disabled={!isValidAmount()}
>
<Text className="font-dmsans text-white">
{isValidAmount()
? t("addcash.addButtonWithAmount", {
amount: formatDisplayAmount(amount),
})
: t("addcash.addButton")}
</Text>
</Button>
</View>
<View className="h-12" />
</View>
</View>
</>
)}
<PinConfirmationModal
visible={showPinModal}
onClose={() => setShowPinModal(false)}
onSuccess={handlePinSuccess}
title={t("addcash.pinModalTitle")}
/>
<ModalToast
visible={toastVisible}
title={toastTitle}
description={toastDescription}
variant={toastVariant}
/>
</ScreenWrapper>
);
}

View File

@ -0,0 +1,67 @@
import React from "react";
import { View, Text } from "react-native";
import LottieView from "lottie-react-native";
import { Button } from "~/components/ui/button";
import { router, useLocalSearchParams } from "expo-router";
import { ROUTES } from "~/lib/routes";
import ScreenWrapper from "~/components/ui/ScreenWrapper";
import { useTranslation } from "react-i18next";
export default function AddCashComp() {
const params = useLocalSearchParams<{
amount: string; // required
}>();
const { t } = useTranslation();
const handleAddCashAgain = () => {
router.replace(ROUTES.HOME);
router.push(ROUTES.ADD_CASH);
};
return (
<ScreenWrapper edges={[]}>
<View className="flex-1 justify-between w-full">
{/* Centered success content */}
<View className="flex-1 items-center justify-center px-5">
<LottieView
source={require("../../../assets/lottie/Success.json")}
autoPlay
loop={false}
style={{ width: 260, height: 260 }}
/>
<View className="h-10" />
{params.amount && (
<Text className="text-secondary font-dmsans-bold text-3xl">
{String(params.amount)}
</Text>
)}
<View className="h-8" />
<Text className="text-xl px-5 font-regular text-gray-400 font-dmsans text-center">
{t("addcashcomp.successNote")}
</Text>
</View>
{/* Bottom buttons */}
<View className="w-full px-5 pb-8">
<Button
className="bg-primary rounded-full"
onPress={handleAddCashAgain}
>
<Text className="font-dmsans text-white">
{t("addcashcomp.addAgainButton")}
</Text>
</Button>
<View className="h-4" />
<Button
className="bg-white border border-dashed border-secondary rounded-full"
onPress={() => router.replace(ROUTES.HOME)}
>
<Text className="font-dmsans text-black">
{t("addcashcomp.goHomeButton")}
</Text>
</Button>
</View>
</View>
</ScreenWrapper>
);
}

View File

@ -0,0 +1,728 @@
import React, { useState, useEffect } from "react";
import {
View,
ScrollView,
Keyboard,
TouchableOpacity,
Platform,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { ArrowLeftIcon, ChevronLeft, ChevronRight } from "lucide-react-native";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Text } from "~/components/ui/text";
import Dropdown from "~/components/ui/dropdown";
import { router } from "expo-router";
import BackButton from "~/components/ui/backButton";
import { useRecipientsStore } from "~/lib/stores";
import { useTabStore } from "~/lib/stores";
import { ROUTES } from "~/lib/routes";
import { formatPhoneNumber } from "~/lib/utils/phoneUtils";
import ScreenWrapper from "~/components/ui/ScreenWrapper";
import ModalToast from "~/components/ui/toast";
import { useTranslation } from "react-i18next";
import { awardPoints } from "~/lib/services/pointsService";
import BottomSheet from "~/components/ui/bottomSheet";
import { Picker } from "react-native-wheel-pick";
const PROFILE_BANK_OPTIONS: { id: string; name: string }[] = [
{ id: "cbe", name: "Commercial Bank of Ethiopia" },
{ id: "dashen", name: "Dashen Bank" },
{ id: "abay", name: "Abay Bank" },
{ id: "awash", name: "Awash Bank" },
{ id: "hibret", name: "Hibret Bank" },
{ id: "telebirr", name: "Ethio Telecom (Telebirr)" },
{ id: "safaricom", name: "Safaricom M-PESA" },
];
export default function AddRecpi() {
const { addRecipient, loading, addError, clearAddError } =
useRecipientsStore();
const { setLastVisitedTab } = useTabStore();
const [fullName, setFullName] = useState("");
const [phoneNumber, setPhoneNumber] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false);
const [clientType, setClientType] = useState<"Individual" | "Business">(
"Individual"
);
const [selectedBank, setSelectedBank] = useState<string | null>(null);
const [accountNumberInput, setAccountNumberInput] = useState("");
const [accountSheetVisible, setAccountSheetVisible] = useState(false);
const [scheduleSheetVisible, setScheduleSheetVisible] = useState(false);
const [scheduleFrequency, setScheduleFrequency] = useState("");
const [scheduleHour, setScheduleHour] = useState("00");
const [scheduleMinute, setScheduleMinute] = useState("00");
const [schedulePeriod, setSchedulePeriod] = useState<"AM" | "PM">("AM");
const [scheduleTime, setScheduleTime] = useState("");
const [scheduleDate, setScheduleDate] = useState("");
const { t } = useTranslation();
const isTelecomWallet =
selectedBank === "telebirr" || selectedBank === "safaricom";
const accountLabel = isTelecomWallet ? "Phone Number" : "Account Number";
const accountPlaceholder = isTelecomWallet
? "Enter phone number"
: "Enter account number";
// Time wheel options
const HOURS = Array.from({ length: 24 }, (_, i) =>
String(i).padStart(2, "0")
);
const MINUTES = Array.from({ length: 60 }, (_, i) =>
String(i).padStart(2, "0")
);
const PERIODS: ("AM" | "PM")[] = ["AM", "PM"];
// Calendar state for month navigation (UI-only)
const today = new Date();
const [calendarCursor, setCalendarCursor] = useState(() => {
const start = new Date();
start.setDate(1);
return start;
});
const cursorYear = calendarCursor.getFullYear();
const cursorMonth = calendarCursor.getMonth();
const firstOfMonth = new Date(cursorYear, cursorMonth, 1);
const lastOfMonth = new Date(cursorYear, cursorMonth + 1, 0);
const firstWeekday = firstOfMonth.getDay(); // 0 = Sun
const calendarDays = [
// Leading empty slots before the 1st
...Array.from({ length: firstWeekday }, () => null),
// Actual days
...Array.from({ length: lastOfMonth.getDate() }, (_, idx) => {
const day = idx + 1;
const key = `${cursorYear}-${cursorMonth + 1}-${day}`;
const isToday =
today.getFullYear() === cursorYear &&
today.getMonth() === cursorMonth &&
today.getDate() === day;
return { key, label: String(day), isToday };
}),
];
const MONTH_NAMES = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
const [toastVisible, setToastVisible] = useState(false);
const [toastTitle, setToastTitle] = useState("");
const [toastDescription, setToastDescription] = useState<string | undefined>(
undefined
);
const [toastVariant, setToastVariant] = useState<
"success" | "error" | "warning" | "info"
>("info");
const toastTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(
null
);
// Keep a combined time string for potential future summary usage
useEffect(() => {
setScheduleTime(`${scheduleHour}:${scheduleMinute} ${schedulePeriod}`);
}, [scheduleHour, scheduleMinute, schedulePeriod]);
const showToast = (
title: string,
description?: string,
variant: "success" | "error" | "warning" | "info" = "info"
) => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
setToastTitle(title);
setToastDescription(description);
setToastVariant(variant);
setToastVisible(true);
toastTimeoutRef.current = setTimeout(() => {
setToastVisible(false);
toastTimeoutRef.current = null;
}, 2500);
};
// Set the tab state when component mounts
useEffect(() => {
setLastVisitedTab("/(tabs)/listrecipient");
}, [setLastVisitedTab]);
useEffect(() => {
return () => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
};
}, []);
const handlePhoneChange = (value: string) => {
const formatted = formatPhoneNumber(value);
setPhoneNumber(formatted);
};
const validateForm = () => {
if (!fullName.trim()) {
showToast(
t("addrecipient.validationErrorTitle"),
t("addrecipient.validationFullNameRequired"),
"error"
);
return false;
}
if (!phoneNumber.trim()) {
showToast(
t("addrecipient.validationErrorTitle"),
t("addrecipient.validationPhoneRequired"),
"error"
);
return false;
}
const cleanPhone = phoneNumber.replace(/[^+\d]/g, "");
if (cleanPhone.length < 7) {
showToast(
t("addrecipient.validationErrorTitle"),
t("addrecipient.validationPhoneInvalid"),
"error"
);
return false;
}
return true;
};
const handleAddRecipient = async () => {
Keyboard.dismiss();
if (!validateForm()) return;
setIsSubmitting(true);
setHasAttemptedSubmit(true);
await addRecipient({
fullName: fullName.trim(),
phoneNumber: phoneNumber.trim(),
});
setIsSubmitting(false);
};
// Show error if any
useEffect(() => {
if (addError) {
showToast(
t("addrecipient.toastErrorTitle"),
addError || t("addrecipient.toastAddError"),
"error"
);
// Clear the error after showing it
clearAddError();
// Reset submission state
setHasAttemptedSubmit(false);
}
}, [addError, clearAddError]);
// Show success when operation completes without error
useEffect(() => {
if (hasAttemptedSubmit && !loading && !addError && !isSubmitting) {
// Operation completed successfully
router.replace(ROUTES.RECIPIENT_ADDED);
awardPoints("add_recipient").catch((error) => {
console.warn(
"[AddRecipient] Failed to award add recipient points",
error
);
});
}
}, [loading, addError, isSubmitting, hasAttemptedSubmit]);
const isFormValid =
fullName.trim() &&
phoneNumber.trim() &&
phoneNumber.replace(/[^+\d]/g, "").length >= 7;
return (
<ScreenWrapper edges={[]}>
<View className="flex-1 w-full justify-between">
<ScrollView
className="flex-1"
contentContainerStyle={{ paddingBottom: 32 }}
>
{/* Header */}
<View className="">
<BackButton />
</View>
<View className="px-5 pt-4">
<Text
className="text-xl font-dmsans-bold text-primary"
numberOfLines={1}
>
{t("addrecipient.title")}
</Text>
<Text className="text-sm font-dmsans text-gray-500 mt-1">
{t("addrecipient.sectionSubtitle")}
</Text>
</View>
{/* Client details card */}
<View className="px-5 pt-6">
<View className="bg-white rounded-md py-5 space-y-5">
{/* Client type toggle */}
<View className="flex-row items-center justify-between mb-1">
<Text className="text-xs font-dmsans text-gray-500">
{t("addrecipient.clientTypeLabel", "Client type")}
</Text>
<View className="flex-row bg-[#F3F4F6] rounded-full p-[2px]">
{(["Individual", "Business"] as const).map((type) => {
const isActive = clientType === type;
return (
<TouchableOpacity
key={type}
className={`px-3 py-1 rounded-full ${
isActive ? "bg-primary" : "bg-transparent"
}`}
onPress={() => setClientType(type)}
>
<Text
className={`text-[11px] font-dmsans-medium ${
isActive ? "text-white" : "text-gray-500"
}`}
>
{type}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
<View>
<Label className="text-xs font-dmsans text-gray-500 mb-2">
{t("addrecipient.fullNameLabel")}
</Label>
<Input
placeholder={t("addrecipient.fullNamePlaceholder")}
value={fullName}
onChangeText={setFullName}
editable={!isSubmitting}
containerClassName="w-full"
borderClassName="border-[#D9DBE9] bg-white"
placeholderColor="#7E7E7E"
textClassName="text-[#000] text-sm"
/>
</View>
<View>
<Label className="text-xs font-dmsans text-gray-500 mb-2">
{t("addrecipient.phoneLabel")}
</Label>
<Input
placeholder={t("addrecipient.phonePlaceholder")}
value={phoneNumber}
onChangeText={handlePhoneChange}
keyboardType="phone-pad"
editable={!isSubmitting}
containerClassName="w-full"
borderClassName="border-[#D9DBE9] bg-white"
placeholderColor="#7E7E7E"
textClassName="text-[#000] text-sm"
/>
</View>
{/* Actions for account & schedule (UI-only) */}
<View className="pt-2 flex-row gap-3">
<Button
className="flex-1 bg-white border border-primary rounded-2xl"
onPress={() => setAccountSheetVisible(true)}
>
<Text className="text-primary font-dmsans-medium text-sm">
{t("addrecipient.addAccountButton", "Add Account")}
</Text>
</Button>
<Button
className="flex-1 bg-primary/5 border border-primary rounded-2xl"
onPress={() => setScheduleSheetVisible(true)}
>
<Text className="text-primary font-dmsans-medium text-sm">
{t("addrecipient.setScheduleButton", "Set Schedule")}
</Text>
</Button>
</View>
</View>
</View>
</ScrollView>
<View className="w-full px-5 pb-8">
<Button
className="bg-primary rounded-3xl"
onPress={handleAddRecipient}
disabled={!isFormValid || isSubmitting || loading}
>
<Text className="font-dmsans text-white">
{isSubmitting || loading
? t("addrecipient.addButtonLoading")
: t("addrecipient.addButton")}
</Text>
</Button>
</View>
</View>
<ModalToast
visible={toastVisible}
title={toastTitle}
description={toastDescription}
variant={toastVariant}
/>
{/* Bottom sheet: Add Account (UI-only, matches Edit Profile add account) */}
<BottomSheet
visible={accountSheetVisible}
onClose={() => setAccountSheetVisible(false)}
maxHeightRatio={0.9}
>
<View className="mb-4">
<Text className="text-xl font-dmsans-bold text-primary text-center">
{t("addrecipient.accountSheetTitle", "Add Account")}
</Text>
</View>
<View className="mb-4 ">
<Text className="text-base font-dmsans text-black mb-2">
{t("addrecipient.accountBankLabel", "Bank")}
</Text>
<View className="flex-row flex-wrap justify-between">
{PROFILE_BANK_OPTIONS.map((bank) => {
const isSelected = selectedBank === bank.id;
const initials = bank.name
.split(" ")
.map((part) => part[0])
.join("")
.toUpperCase()
.slice(0, 2);
return (
<TouchableOpacity
key={bank.id}
activeOpacity={0.8}
onPress={() => setSelectedBank(bank.id)}
className={`items-center justify-between px-3 py-4 mb-3 rounded-2xl border ${
isSelected
? "border-primary bg-primary/5"
: "border-gray-200 bg-white"
}`}
style={{ width: "30%" }}
>
<View className="w-10 h-10 mb-2 rounded-full bg-primary/10 items-center justify-center">
<Text className="text-primary font-dmsans-bold text-sm">
{initials}
</Text>
</View>
<Text
className="text-center text-xs font-dmsans text-gray-800"
numberOfLines={2}
>
{bank.name}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
<View className="mb-4">
<Text className="text-base font-dmsans text-black mb-2">
{accountLabel}
</Text>
<Input
placeholder={accountPlaceholder}
value={accountNumberInput}
onChangeText={(text) =>
setAccountNumberInput(text.replace(/[^0-9]/g, ""))
}
containerClassName="w-full mb-4"
borderClassName="border-[#E5E7EB] bg-white rounded-[4px]"
placeholderColor="#9CA3AF"
textClassName="text-[#111827] text-sm"
keyboardType="number-pad"
/>
</View>
<View className="mb-4">
<Button
className="bg-primary rounded-3xl w-full"
onPress={() => setAccountSheetVisible(false)}
disabled={!selectedBank || !accountNumberInput.trim()}
>
<Text className="font-dmsans text-white">
{t("common.save", "Save")}
</Text>
</Button>
</View>
</BottomSheet>
{/* Bottom sheet: Set Schedule (UI-only with dropdowns & calendar) */}
<BottomSheet
visible={scheduleSheetVisible}
onClose={() => setScheduleSheetVisible(false)}
maxHeightRatio={0.95}
>
<View className="w-full px-5 pt-4 pb-6">
<Text className="text-base font-dmsans-bold text-primary mb-1">
{t("addrecipient.scheduleSheetTitle", "Set Schedule")}
</Text>
<Text className="text-xs font-dmsans text-gray-500 mb-4">
{t(
"addrecipient.scheduleSheetSubtitle",
"Choose a simple reminder schedule for this client. This is UI-only for now."
)}
</Text>
<View className="space-y-4">
{/* Frequency dropdown */}
<View>
<Label className="text-[11px] font-dmsans text-gray-500 mb-1">
{t("addrecipient.scheduleFrequencyLabel", "Frequency")}
</Label>
<Dropdown
value={scheduleFrequency || null}
options={[
{ label: "Daily", value: "daily" },
{ label: "Weekly", value: "weekly" },
{ label: "Monthly", value: "monthly" },
{ label: "Custom", value: "custom" },
]}
onSelect={(val) => setScheduleFrequency(val)}
placeholder={t(
"addrecipient.scheduleFrequencyPlaceholder",
"Daily / Weekly / Monthly / Custom"
)}
/>
</View>
{/* Time-of-day selector: 3-column wheel (hour, minute, AM/PM) */}
<View>
<Label className="text-[11px] font-dmsans text-gray-500 mt-3 mb-1">
{t("addrecipient.scheduleTimeLabel", "Time of day")}
</Label>
<View className="flex-row bg-white rounded-2xl border border-[#E5E7EB] px-3 py-3">
{/* Hour wheel */}
<View className="flex-1 items-center">
<Text className="text-[10px] font-dmsans text-gray-400 mb-1">
{t("addrecipient.scheduleHourLabel", "Hour")}
</Text>
<Picker
style={{
width: "100%",
height: 150,
backgroundColor: "transparent",
}}
pickerData={HOURS}
selectedValue={scheduleHour}
onValueChange={(val: string) => setScheduleHour(val)}
textColor="#9CA3AF"
selectTextColor="#16A34A"
isCyclic
/>
</View>
{/* Minute wheel */}
<View className="flex-1 items-center">
<Text className="text-[10px] font-dmsans text-gray-400 mb-1">
{t("addrecipient.scheduleMinuteLabel", "Min")}
</Text>
<Picker
style={{
width: "100%",
height: 150,
backgroundColor: "transparent",
}}
pickerData={MINUTES}
selectedValue={scheduleMinute}
onValueChange={(val: string) => setScheduleMinute(val)}
textColor="#9CA3AF"
selectTextColor="#16A34A"
isCyclic
/>
</View>
{/* AM/PM wheel */}
<View className="w-16 items-center">
<Text className="text-[10px] font-dmsans text-gray-400 mb-1">
{t("addrecipient.schedulePeriodLabel", "AM/PM")}
</Text>
<Picker
style={{
width: "100%",
height: 150,
backgroundColor: "transparent",
}}
pickerData={PERIODS}
selectedValue={schedulePeriod}
onValueChange={(val: string) =>
setSchedulePeriod(val as "AM" | "PM")
}
textColor="#9CA3AF"
selectTextColor="#16A34A"
/>
</View>
</View>
</View>
{/* Date selector - calendar with header & weekdays */}
<View>
<Label className="text-[11px] font-dmsans mt-3 text-gray-500 mb-2">
{t("addrecipient.scheduleDateLabel", "Date")}
</Label>
{/* Month header */}
<View className="flex-row items-center justify-between mb-2">
<TouchableOpacity
onPress={() => {
setCalendarCursor(
(prev) =>
new Date(prev.getFullYear(), prev.getMonth() - 1, 1)
);
}}
className="p-1"
>
<ChevronLeft size={18} color="#6B7280" />
</TouchableOpacity>
<Text className="text-sm font-dmsans-medium text-gray-800">
{MONTH_NAMES[cursorMonth]} {cursorYear}
</Text>
<TouchableOpacity
onPress={() => {
setCalendarCursor(
(prev) =>
new Date(prev.getFullYear(), prev.getMonth() + 1, 1)
);
}}
className="p-1"
>
<ChevronRight size={18} color="#6B7280" />
</TouchableOpacity>
</View>
{/* Weekday labels */}
<View className="flex-row mb-1">
{["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map((d) => (
<View key={d} className="flex-1 items-center">
<Text className="text-[10px] font-dmsans text-gray-400">
{d}
</Text>
</View>
))}
</View>
{/* Day grid: 7 columns (Sun-Sat) */}
<View className="space-y-[4px]">
{Array.from(
{ length: Math.ceil(calendarDays.length / 7) },
(_, rowIndex) => {
const row = calendarDays.slice(
rowIndex * 7,
rowIndex * 7 + 7
);
return (
<View key={rowIndex} className="flex-row">
{row.map((day, idx) => {
if (!day) {
return (
<View
key={`empty-${rowIndex}-${idx}`}
className="flex-1 items-center justify-center"
/>
);
}
const safeDay = day as {
key: string;
label: string;
isToday: boolean;
};
const isSelected = scheduleDate === safeDay.key;
const isToday = safeDay.isToday;
return (
<View
key={safeDay.key}
className="flex-1 items-center justify-center"
>
<TouchableOpacity
activeOpacity={0.8}
onPress={() => setScheduleDate(safeDay.key)}
className={`items-center justify-center rounded-full border ${
isSelected
? "bg-primary border-primary"
: "bg-white border-transparent"
}`}
style={{ width: 32, height: 32 }}
>
<Text
className={`text-xs font-dmsans-medium ${
isSelected
? "text-white"
: isToday
? "text-primary"
: "text-gray-800"
}`}
>
{safeDay.label}
</Text>
</TouchableOpacity>
{isToday && !isSelected && (
<Text className="text-[8px] font-dmsans text-primary mt-0.5">
{t(
"addrecipient.scheduleTodayLabel",
"Today"
)}
</Text>
)}
</View>
);
})}
</View>
);
}
)}
</View>
</View>
</View>
<View className="flex-row gap-3 mt-5">
<Button
className="flex-1 bg-white border border-[#E5E7EB] rounded-2xl"
onPress={() => setScheduleSheetVisible(false)}
>
<Text className="text-gray-700 font-dmsans-medium text-sm">
{t("common.cancel", "Cancel")}
</Text>
</Button>
<Button
className="flex-1 bg-primary rounded-2xl"
onPress={() => setScheduleSheetVisible(false)}
>
<Text className="text-white font-dmsans-medium text-sm">
{t("common.save", "Save")}
</Text>
</Button>
</View>
</View>
</BottomSheet>
</ScreenWrapper>
);
}

View File

@ -0,0 +1,139 @@
import React from "react";
import { View, Text, ScrollView, Share } from "react-native";
import { Button } from "~/components/ui/button";
import { SafeAreaView } from "react-native-safe-area-context";
import { router, useLocalSearchParams } from "expo-router";
import { ROUTES } from "~/lib/routes";
import { SuccessIconNewCard } from "~/components/ui/icons";
import ScreenWrapper from "~/components/ui/ScreenWrapper";
import ModalToast from "~/components/ui/toast";
import { useTranslation } from "react-i18next";
export default function CardAddedComp() {
const params = useLocalSearchParams<{
message?: string;
cardName?: string;
cardNumber?: string;
}>();
const { t } = useTranslation();
const [toastVisible, setToastVisible] = React.useState(false);
const [toastTitle, setToastTitle] = React.useState("");
const [toastDescription, setToastDescription] = React.useState<
string | undefined
>(undefined);
const [toastVariant, setToastVariant] = React.useState<
"success" | "error" | "warning" | "info"
>("info");
const toastTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(
null
);
const showToast = (
title: string,
description?: string,
variant: "success" | "error" | "warning" | "info" = "info"
) => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
setToastTitle(title);
setToastDescription(description);
setToastVariant(variant);
setToastVisible(true);
toastTimeoutRef.current = setTimeout(() => {
setToastVisible(false);
toastTimeoutRef.current = null;
}, 2500);
};
const handleAddAnother = () => {
// Navigate to add card page
router.replace(ROUTES.ADD_CARD);
};
const handleGoToCards = () => {
// Navigate to list cards page
router.replace(ROUTES.LIST_CARD);
};
const handleShare = async () => {
try {
const shareMessage = params.message
? t("cardaddedcomp.shareMessageWithParam", {
message: params.message,
})
: t("cardaddedcomp.shareMessageDefault");
const result = await Share.share({
message: shareMessage,
title: t("cardaddedcomp.shareTitle"),
});
if (result.action === Share.sharedAction) {
// Content was shared
console.log("Content shared successfully");
} else if (result.action === Share.dismissedAction) {
// Share dialog was dismissed
console.log("Share dialog dismissed");
}
} catch (error) {
console.error("Error sharing:", error);
showToast(
t("cardaddedcomp.toastErrorTitle"),
t("cardaddedcomp.toastShareError"),
"error"
);
}
};
return (
<ScreenWrapper edges={[]}>
<View className="flex-1 w-full justify-between">
{/* Center content */}
<View className="flex-1 justify-center items-center px-5">
<SuccessIconNewCard />
<View className="h-10" />
<Text className="text-primary text-3xl">
{t("cardaddedcomp.title")}
</Text>
<View className="h-4" />
<View className="mx-8">
<Text className="text-xl font-regular text-gray-400 font-dmsans text-center">
{t("cardaddedcomp.description")}
</Text>
</View>
</View>
{/* Bottom buttons */}
<View className="w-full px-5 pb-8">
<Button
className="bg-primary rounded-full"
onPress={handleAddAnother}
>
<Text className="font-dmsans text-white">
{t("cardaddedcomp.addButton")}
</Text>
</Button>
<View className="h-4" />
<Button
className="bg-white border border-dashed border-secondary rounded-full"
onPress={() => router.replace(ROUTES.HOME)}
>
<Text className="font-dmsans text-black">
{t("cardaddedcomp.goHomeButton")}
</Text>
</Button>
</View>
</View>
<ModalToast
visible={toastVisible}
title={toastTitle}
description={toastDescription}
variant={toastVariant}
/>
</ScreenWrapper>
);
}

View File

@ -0,0 +1,302 @@
import React from "react";
import {
View,
Text,
ScrollView,
TouchableOpacity,
FlatList,
} from "react-native";
import { Button } from "~/components/ui/button";
import { LucideCreditCard, LucidePlus, LucideTrash } from "lucide-react-native";
import { ROUTES } from "~/lib/routes";
import { router } from "expo-router";
import { useAuthWithProfile } from "~/lib/hooks/useAuthWithProfile";
import { useUserWallet } from "~/lib/hooks/useUserWallet";
import { CreditCard } from "~/lib/services/walletService";
import TopBar from "~/components/ui/topBar";
import {
ApplePayIcon,
CreditDebitCardIcon,
GooglePayIcon,
} from "~/components/ui/icons";
import ScreenWrapper from "~/components/ui/ScreenWrapper";
import { Input } from "~/components/ui/input";
import ModalToast from "~/components/ui/toast";
import { useTranslation } from "react-i18next";
import PermissionAlertModal from "~/components/ui/permissionAlertModal";
// Individual Card Component
const CardItem = ({
card,
onRemove,
}: {
card: CreditCard;
onRemove: (card: CreditCard) => void;
}) => {
const getCardColor = (cardType: string) => {
switch (cardType?.toLowerCase()) {
case "visa":
return "bg-blue-50";
case "mastercard":
return "bg-green-50";
case "american express":
return "bg-green-50";
case "discover":
return "bg-orange-50";
default:
return "bg-gray-50";
}
};
const handleRemovePress = (card: CreditCard) => {
setSelectedCard(card);
setRemoveModalVisible(true);
};
const handleRemove = () => {
onRemove(card);
};
return (
<View
className={`flex flex-row justify-between w-full items-center py-4 ${getCardColor(
card.cardType || ""
)} rounded-md px-3`}
>
<View className="flex flex-row space-x-3 items-center flex-1">
<View className="bg-[#FFB668]/15 p-5 h-15 items-center justify-center flex rounded">
<CreditDebitCardIcon width={30} height={30} />
</View>
<View className="w-4" />
<View className="flex space-y-1 flex-1">
<Text className="font-dmsans text-primary">
{card.cardType || "Card"}
</Text>
<Text className="font-dmsans-medium text-secondary text-sm">
{card.cardNumber}
</Text>
<Text className="font-dmsans-medium text-gray-500 text-xs">
Expires {card.expiryDate}
</Text>
</View>
</View>
<View className="flex flex-row space-x-2 items-center">
<TouchableOpacity onPress={handleRemove} className="p-2 rounded-full">
<LucideTrash color="#FFB84D" className="text-red-600" size={20} />
</TouchableOpacity>
</View>
</View>
);
};
export default function ListCard() {
const { user } = useAuthWithProfile();
const { wallet, loading, error, removeCreditCard } = useUserWallet(user);
const [searchQuery, setSearchQuery] = React.useState("");
const { t } = useTranslation();
const [toastVisible, setToastVisible] = React.useState(false);
const [toastTitle, setToastTitle] = React.useState("");
const [toastDescription, setToastDescription] = React.useState<
string | undefined
>(undefined);
const [toastVariant, setToastVariant] = React.useState<
"success" | "error" | "warning" | "info"
>("info");
const toastTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(
null
);
const [removeModalVisible, setRemoveModalVisible] = React.useState(false);
const [selectedCard, setSelectedCard] = React.useState<CreditCard | null>(
null
);
const showToast = (
title: string,
description?: string,
variant: "success" | "error" | "warning" | "info" = "info"
) => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
setToastTitle(title);
setToastDescription(description);
setToastVariant(variant);
setToastVisible(true);
toastTimeoutRef.current = setTimeout(() => {
setToastVisible(false);
toastTimeoutRef.current = null;
}, 2500);
};
React.useEffect(() => {
return () => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
};
}, []);
const handleRemoveCard = async (cardId: string) => {
try {
await removeCreditCard(cardId);
showToast(
t("cards.toastRemoveSuccessTitle"),
t("cards.toastRemoveSuccess"),
"success"
);
} catch (error) {
showToast(
t("cards.toastRemoveErrorTitle"),
t("cards.toastRemoveError"),
"error"
);
}
};
const renderCards = () => {
if (loading) {
return (
<View className="flex items-center justify-center">
<Text className="text-gray-500 font-dmsans">
{t("cardmang.loading")}
</Text>
</View>
);
}
if (error) {
return (
<View className="flex items-center justify-center py-10">
<Text className="text-red-500 font-dmsans">
{t("cardmang.errorTitle")}
</Text>
<Text className="text-gray-400 font-dmsans-medium text-sm mt-1">
{error}
</Text>
</View>
);
}
if (!wallet?.cards || wallet.cards.length === 0) {
return (
<View className="flex items-center justify-center py-10">
<LucideCreditCard color="#D1D5DB" size={48} />
<Text className="text-gray-500 font-dmsans mt-4">
{t("cardmang.emptyTitle")}
</Text>
<Text className="text-gray-400 font-dmsans-medium text-sm mt-1 text-center px-4">
{t("cardmang.emptySubtitle")}
</Text>
</View>
);
}
return (
<FlatList
data={wallet.cards}
keyExtractor={(item, index) => item.id + String(index)}
scrollEnabled={false}
ItemSeparatorComponent={() => <View className="h-2" />}
renderItem={({ item }) => (
<CardItem card={item} onRemove={handleRemovePress} />
)}
/>
);
};
return (
<ScreenWrapper edges={["bottom"]}>
<View className="flex items-center h-full w-full">
<ScrollView
contentContainerStyle={{ paddingBottom: 96 }}
showsVerticalScrollIndicator={false}
>
<TopBar />
<View className="flex flex-col px-5 space-y-1 py-5 items-left">
<Text className="text-xl font-dmsans text-primary">
{t("cardmang.title")}
</Text>
<View className="h-2" />
<Text className="text-base font-dmsans text-gray-400">
{t("cardmang.subtitle")}
</Text>
</View>
<View className="px-5">
<Input
value={searchQuery}
onChangeText={setSearchQuery}
placeholderText={t("cardmang.searchPlaceholder")}
containerClassName="w-full"
borderClassName="border-[#D9DBE9] bg-white"
placeholderColor="#7E7E7E"
textClassName="text-[#000] text-sm"
/>
</View>
<View className="flex flex-col items-left px-5 py-5 w-full">
{/* Add Card Button */}
<View className="flex flex-row items-center w-full">
<Button
className="flex flex-row items-center space-x-2 bg-primary rounded-md p-3 w-full"
onPress={() => router.push(ROUTES.ADD_CARD)}
>
<LucidePlus color="#FFB84D" className="w-[14px] h-[14px]" />
<View className="w-2" />
<Text className="text-white text-sm font-dmsans-regular">
{t("cardmang.addCardButton")}
</Text>
</Button>
</View>
<View className="h-2" />
{/* Cards List */}
<View className="flex flex-col w-full mt-5">
<Text className="text-lg font-dmsans-medium text-primary mb-4">
{t("cardmang.paymentOptionsTitle")}
</Text>
{renderCards()}
</View>
</View>
</ScrollView>
</View>
<ModalToast
visible={toastVisible}
title={toastTitle}
description={toastDescription}
variant={toastVariant}
/>
<PermissionAlertModal
visible={removeModalVisible}
title={t("cardmang.removeTitle") || "Remove Card"}
message={
selectedCard
? t(
"cardmang.removeMessage",
"Are you sure you want to remove this card?"
)
: "Are you sure you want to remove this card?"
}
primaryText={t("cardmang.removeConfirm", "Remove")}
secondaryText={t("cardmang.removeCancel", "Cancel")}
primaryVariant="danger"
onPrimary={async () => {
if (selectedCard) {
await handleRemoveCard(selectedCard.id);
}
setRemoveModalVisible(false);
setSelectedCard(null);
}}
onSecondary={() => {
setRemoveModalVisible(false);
setSelectedCard(null);
}}
/>
</ScreenWrapper>
);
}

View File

@ -0,0 +1,340 @@
import React, { useState, useEffect } from "react";
import { View, ScrollView, InteractionManager } from "react-native";
import { useFocusEffect } from "expo-router";
import { Button } from "~/components/ui/button";
import { Text } from "~/components/ui/text";
import { PhonePinKeypad } from "~/components/ui/PhonePinKeypad";
import { router } from "expo-router";
import { ROUTES } from "~/lib/routes";
import { useAuthWithProfile } from "~/lib/hooks/useAuthWithProfile";
import { useUserWallet } from "~/lib/hooks/useUserWallet";
import {
parseDisplayToCents,
formatDisplayAmount,
} from "~/lib/utils/monetaryUtils";
import { Big } from "big.js";
import BackButton from "~/components/ui/backButton";
import { PinConfirmationModal } from "~/components/ui/pinConfirmationModal";
import { amountSchema, validate } from "~/lib/utils/validationSchemas";
import { showAlert } from "~/lib/utils/alertUtils";
import ScreenWrapper from "~/components/ui/ScreenWrapper";
import ModalToast from "~/components/ui/toast";
import { useTranslation } from "react-i18next";
import FourDotLoader from "~/components/ui/FourDotLoader";
export default function CashOut() {
const { user } = useAuthWithProfile();
const { wallet } = useUserWallet(user);
const [amount, setAmount] = useState("");
const [showPinModal, setShowPinModal] = useState(false);
const [isSecurityVerified, setIsSecurityVerified] = useState(false);
const { t } = useTranslation();
const [toastVisible, setToastVisible] = useState(false);
const [toastTitle, setToastTitle] = useState("");
const [toastDescription, setToastDescription] = useState<string | undefined>(
undefined
);
const [toastVariant, setToastVariant] = useState<
"success" | "error" | "warning" | "info"
>("info");
const toastTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(
null
);
const showToast = (
title: string,
description?: string,
variant: "success" | "error" | "warning" | "info" = "info"
) => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
setToastTitle(title);
setToastDescription(description);
setToastVariant(variant);
setToastVisible(true);
toastTimeoutRef.current = setTimeout(() => {
setToastVisible(false);
toastTimeoutRef.current = null;
}, 2500);
};
useEffect(() => {
const task = InteractionManager.runAfterInteractions(() => {
if (__DEV__) {
console.log("CASHOUT PAGE MOUNTED");
}
});
return () => task.cancel();
}, []);
useEffect(() => {
return () => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
};
}, []);
// Reset state and show PIN modal when screen comes into focus
// useFocusEffect only runs when screen is focused, so we can safely show modal here
useFocusEffect(
React.useCallback(() => {
setIsSecurityVerified(false);
setShowPinModal(true);
setAmount("");
// Cleanup: hide modal when screen loses focus
return () => {
setShowPinModal(false);
setIsSecurityVerified(false);
};
}, [])
);
// Handle number input and special actions (copied from addcash.tsx)
const handleNumberPress = (input: string) => {
if (input === "clear") {
handleClear();
return;
}
if (input === "backspace") {
handleBackspace();
return;
}
// Handle decimal point
if (input === ".") {
// Prevent multiple decimals
if (amount.includes(".")) return;
// If empty, start with "0."
if (amount === "") {
setAmount("0.");
return;
}
// Add decimal point
setAmount(amount + ".");
return;
}
// Handle digit input (0-9)
if (!/^[0-9]$/.test(input)) return;
// Handle leading zeros
if (amount === "0" && input !== ".") {
setAmount(input); // Replace leading zero
return;
}
const newAmount = amount + input;
// Check decimal places limit (max 2 decimal places)
if (newAmount.includes(".")) {
const [whole, decimal] = newAmount.split(".");
if (decimal && decimal.length > 2) return;
}
// Check maximum amount (max $999.99)
try {
const numValue = new Big(newAmount);
if (numValue.gt(999.99)) return;
} catch (error) {
return;
}
// Check total length to prevent very long inputs
if (newAmount.length > 6) return; // Max: 999.99
setAmount(newAmount);
};
// Handle backspace
const handleBackspace = () => {
if (amount.length === 0) return;
// Remove last character
const newAmount = amount.slice(0, -1);
setAmount(newAmount);
};
// Clear all input
const handleClear = () => {
setAmount("");
};
// Validate if amount is valid for submission
const isValidAmount = () => {
if (
!amount ||
amount === "" ||
amount === "0" ||
amount === "0." ||
amount === "0.00"
) {
return false;
}
const amountInCents = parseDisplayToCents(amount);
if (amountInCents < 1 || amountInCents > 99999) {
// 1 cent to $999.99
return false;
}
// Check if amount is within wallet balance (no processing fee for cashout)
const currentBalance = wallet?.balance || 0; // Balance in cents
return currentBalance >= amountInCents;
};
// Get validation error message
const getValidationError = () => {
// Validate basic amount using valibot
const amountValidationResult = validate(
amountSchema({ min: 1, max: 99999 }),
amount
);
if (!amountValidationResult.success) {
return amountValidationResult.error;
}
const amountInCents = parseDisplayToCents(amount);
if (amountInCents < 1) {
return t("cashout.validationMinAmount");
}
if (amountInCents > 99999) {
// $999.99
return t("cashout.validationMaxAmount");
}
// Check if amount exceeds wallet balance (no processing fee for cashout)
const currentBalance = wallet?.balance || 0; // Balance in cents
if (currentBalance < amountInCents) {
const balanceInDollars = currentBalance / 100;
const requiredInDollars = amountInCents / 100;
return t("cashout.validationInsufficientBalance", {
required: requiredInDollars.toFixed(2),
available: balanceInDollars.toFixed(2),
});
}
return null;
};
// Handle PIN confirmation success
const handlePinSuccess = () => {
setShowPinModal(false);
setIsSecurityVerified(true);
};
// Handle cash out action (called after PIN is verified)
const handleCashOut = async () => {
const validationError = getValidationError();
if (validationError) {
showToast(t("cashout.validationErrorTitle"), validationError, "error");
return;
}
const amountInCents = parseDisplayToCents(amount);
console.log("Cashing out:", amountInCents, "cents");
router.push({
pathname: ROUTES.SEND_BANK,
params: {
amount: amountInCents.toString(), // Pass cents as string
recipientName: user?.displayName || "Self",
recipientPhoneNumber: user?.phoneNumber || "",
recipientType: "saved",
recipientId: user?.uid || "",
note: "Cash out to bank account",
transactionType: "cashout",
},
});
};
return (
<ScreenWrapper edges={[]}>
{!isSecurityVerified && !showPinModal ? (
<View className="flex-1 justify-center items-center bg-white">
<Text className="text-gray-600 font-dmsans text-lg mb-4">
{t("cashout.verifyingSecurity")}
</Text>
<FourDotLoader />
</View>
) : (
<View className="h-full">
<BackButton />
<View className="flex h-full w-full">
<View className="flex-1 justify-start items-center px-5 h-1/6 ">
<View className="h-12" />
{/* Wallet Balance Display */}
<View className="flex flex-row items-center space-y-1">
<View className="h-12" />
<Text className="text-lg font-dmsans-medium text-gray-600">
{t("cashout.availableBalanceLabel")}
</Text>
<View className="w-2" />
<Text className="text-lg font-dmsans-medium text-gray-800">
${wallet ? (wallet.balance / 100).toFixed(2) : "0.00"}
</Text>
</View>
</View>
<View className="h-2/6">
<Text className="text-8xl font-dmsans-bold text-center text-black pt-2">
${formatDisplayAmount(amount)}
</Text>
</View>
<View className="h-3/6 flex justify-around">
<View className="px-8">
<PhonePinKeypad
onKeyPress={handleNumberPress}
showDecimal={true}
/>
</View>
<View className="px-5">
<Button
className="bg-primary rounded-3xl mb-5"
onPress={handleCashOut}
disabled={!isValidAmount()}
>
<Text className="font-dmsans text-white">
{isValidAmount()
? t("cashout.buttonWithAmount", {
amount: formatDisplayAmount(amount),
})
: t("cashout.button")}
</Text>
</Button>
</View>
<View className="h-12" />
</View>
</View>
</View>
)}
{/* PIN Confirmation Modal */}
<PinConfirmationModal
visible={showPinModal}
onClose={() => setShowPinModal(false)}
onSuccess={handlePinSuccess}
title={t("cashout.pinModalTitle")}
/>
<ModalToast
visible={toastVisible}
title={toastTitle}
description={toastDescription}
variant={toastVariant}
/>
</ScreenWrapper>
);
}

View File

@ -0,0 +1,136 @@
import React from "react";
import { View, Text } from "react-native";
import { Button } from "~/components/ui/button";
import { SafeAreaView } from "react-native-safe-area-context";
import { router, useLocalSearchParams } from "expo-router";
import { ROUTES } from "~/lib/routes";
import LottieView from "lottie-react-native";
import { useAuthWithProfile } from "~/lib/hooks/useAuthWithProfile";
import { showAlert } from "~/lib/utils/alertUtils";
import ModalToast from "~/components/ui/toast";
import { useTranslation } from "react-i18next";
import ScreenWrapper from "~/components/ui/ScreenWrapper";
export default function CashOutComp() {
const params = useLocalSearchParams<{
amount: string; // required
note?: string; // optional
}>();
const { wallet } = useAuthWithProfile();
const { t } = useTranslation();
const [toastVisible, setToastVisible] = React.useState(false);
const [toastTitle, setToastTitle] = React.useState("");
const [toastDescription, setToastDescription] = React.useState<
string | undefined
>(undefined);
const [toastVariant, setToastVariant] = React.useState<
"success" | "error" | "warning" | "info"
>("info");
const toastTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(
null
);
const showToast = (
title: string,
description?: string,
variant: "success" | "error" | "warning" | "info" = "info"
) => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
setToastTitle(title);
setToastDescription(description);
setToastVariant(variant);
setToastVisible(true);
toastTimeoutRef.current = setTimeout(() => {
setToastVisible(false);
toastTimeoutRef.current = null;
}, 2500);
};
const handleCashOutAgain = () => {
const balance = wallet?.balance;
if (balance === undefined) {
showToast(
t("cashoutcomp.toastErrorTitle"),
t("cashoutcomp.toastNoBalance"),
"error"
);
return;
}
if (balance < 1000) {
showToast(
t("cashoutcomp.toastErrorTitle"),
t("cashoutcomp.toastMinError"),
"error"
);
return;
}
router.replace(ROUTES.HOME);
router.push(ROUTES.CASH_OUT);
};
const note =
params.note && String(params.note).trim().length > 0
? String(params.note)
: t("cashoutcomp.successNote");
return (
<ScreenWrapper edges={[]}>
{/* Main content */}
<View className="flex-1 items-center justify-center px-5">
<View className="items-center">
<LottieView
source={require("../../../assets/lottie/Success.json")}
autoPlay
loop={false}
style={{ width: 260, height: 260 }}
/>
{/* <View className="h-10" /> */}
{params.amount && (
<Text className="text-secondary font-dmsans-bold text-3xl">
{String(params.amount)}
</Text>
)}
<View className="h-8" />
<Text className="text-xl px-5 font-regular text-gray-400 font-dmsans text-center">
{note}
</Text>
</View>
</View>
{/* Bottom actions */}
<View className="w-full px-5 pb-6">
<Button
className="bg-primary rounded-full"
onPress={handleCashOutAgain}
>
<Text className="font-dmsans text-white">
{t("cashoutcomp.cashOutAgainButton")}
</Text>
</Button>
<View className="h-4" />
<Button
className="bg-white border border-dashed border-secondary rounded-full"
onPress={() => router.replace(ROUTES.HOME)}
>
<Text className="font-dmsans text-black">
{t("cashoutcomp.goHomeButton")}
</Text>
</Button>
</View>
<ModalToast
visible={toastVisible}
title={toastTitle}
description={toastDescription}
variant={toastVariant}
/>
</ScreenWrapper>
);
}

View File

@ -0,0 +1,305 @@
import React, { useState, useRef, useEffect } from "react";
import { View, TouchableOpacity } from "react-native";
import { Text } from "~/components/ui/text";
import { Button } from "~/components/ui/button";
import BackButton from "~/components/ui/backButton";
import ScreenWrapper from "~/components/ui/ScreenWrapper";
import { PhonePinKeypad } from "~/components/ui/PhonePinKeypad";
import { router } from "expo-router";
import { useAuthWithProfile } from "~/lib/hooks/useAuthWithProfile";
import ModalToast from "~/components/ui/toast";
import { X } from "lucide-react-native";
type Step = "old" | "new" | "confirm";
export default function ChangePin() {
const { user } = useAuthWithProfile();
const [currentStep, setCurrentStep] = useState<Step>("old");
const [oldPin, setOldPin] = useState("");
const [newPin, setNewPin] = useState("");
const [confirmPin, setConfirmPin] = useState("");
const [loading, setLoading] = useState(false);
const [toastVisible, setToastVisible] = useState(false);
const [toastTitle, setToastTitle] = useState("");
const [toastDescription, setToastDescription] = useState<string | undefined>(
undefined
);
const [toastVariant, setToastVariant] = useState<
"success" | "error" | "warning" | "info"
>("info");
const toastTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const showToast = (
title: string,
description?: string,
variant: "success" | "error" | "warning" | "info" = "info"
) => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
setToastTitle(title);
setToastDescription(description);
setToastVariant(variant);
setToastVisible(true);
toastTimeoutRef.current = setTimeout(() => {
setToastVisible(false);
toastTimeoutRef.current = null;
}, 2500);
};
useEffect(() => {
return () => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
};
}, []);
const getCurrentPin = () => {
switch (currentStep) {
case "old":
return oldPin;
case "new":
return newPin;
case "confirm":
return confirmPin;
}
};
const handleKeyPress = (key: string) => {
const currentPin = getCurrentPin();
if (key === "clear") {
switch (currentStep) {
case "old":
setOldPin("");
break;
case "new":
setNewPin("");
break;
case "confirm":
setConfirmPin("");
break;
}
} else if (key === "backspace") {
switch (currentStep) {
case "old":
setOldPin((prev) => prev.slice(0, -1));
break;
case "new":
setNewPin((prev) => prev.slice(0, -1));
break;
case "confirm":
setConfirmPin((prev) => prev.slice(0, -1));
break;
}
} else if (currentPin.length < 6) {
switch (currentStep) {
case "old":
setOldPin((prev) => prev + key);
break;
case "new":
setNewPin((prev) => prev + key);
break;
case "confirm":
setConfirmPin((prev) => prev + key);
break;
}
}
};
const handleContinue = async () => {
const currentPin = getCurrentPin();
if (currentPin.length !== 6) {
showToast("Error", "Please enter a 6-digit PIN", "error");
return;
}
if (currentStep === "old") {
// TODO: Verify old PIN with backend
// For now, just move to next step
setLoading(true);
setTimeout(() => {
setLoading(false);
setCurrentStep("new");
}, 500);
} else if (currentStep === "new") {
if (newPin === oldPin) {
showToast("Error", "New PIN must be different from old PIN", "error");
return;
}
setCurrentStep("confirm");
} else if (currentStep === "confirm") {
if (confirmPin !== newPin) {
showToast("Error", "PINs do not match", "error");
setConfirmPin("");
return;
}
// TODO: Update PIN in backend
setLoading(true);
setTimeout(() => {
setLoading(false);
showToast("Success", "PIN changed successfully", "success");
setTimeout(() => {
router.back();
}, 1500);
}, 1000);
}
};
const getStepTitle = () => {
switch (currentStep) {
case "old":
return "Enter Old PIN";
case "new":
return "Enter New PIN";
case "confirm":
return "Confirm New PIN";
}
};
const getStepDescription = () => {
switch (currentStep) {
case "old":
return "Enter your current PIN";
case "new":
return "Enter your new PIN";
case "confirm":
return "Re-enter your new PIN to confirm";
}
};
const renderPinDots = () => {
const currentPin = getCurrentPin();
return (
<View className="mb-8">
<Text className="text-center text-base font-dmsans text-gray-600 mb-6">
{getStepDescription()}
</Text>
<View className="flex-row justify-between items-center px-5">
{[0, 1, 2, 3, 4, 5].map((index) => (
<View
key={index}
style={{
width: 18,
height: 18,
borderRadius: 999,
borderWidth: 2,
borderColor: index < currentPin.length ? "#105D38" : "#D1D5DB",
backgroundColor:
index < currentPin.length ? "#105D38" : "transparent",
}}
/>
))}
</View>
</View>
);
};
return (
<ScreenWrapper edges={[]}>
<BackButton />
<View className="flex-1 bg-white px-5">
{/* Header */}
<View className="py-8">
<Text className="text-3xl font-dmsans-bold text-gray-900 mb-2">
Change PIN
</Text>
<Text className="text-base font-dmsans text-gray-500">
{getStepDescription()}
</Text>
</View>
{/* Step Indicator */}
<View className="flex-row justify-center items-center mb-8">
<View
className={`w-8 h-8 rounded-full items-center justify-center ${
currentStep === "old" ? "bg-primary" : "bg-gray-300"
}`}
>
<Text
className={`font-dmsans-bold text-sm ${
currentStep === "old" ? "text-white" : "text-gray-600"
}`}
>
1
</Text>
</View>
<View className="w-12 h-1 bg-gray-300 mx-2" />
<View
className={`w-8 h-8 rounded-full items-center justify-center ${
currentStep === "new" || currentStep === "confirm"
? "bg-primary"
: "bg-gray-300"
}`}
>
<Text
className={`font-dmsans-bold text-sm ${
currentStep === "new" || currentStep === "confirm"
? "text-white"
: "text-gray-600"
}`}
>
2
</Text>
</View>
<View className="w-12 h-1 bg-gray-300 mx-2" />
<View
className={`w-8 h-8 rounded-full items-center justify-center ${
currentStep === "confirm" ? "bg-primary" : "bg-gray-300"
}`}
>
<Text
className={`font-dmsans-bold text-sm ${
currentStep === "confirm" ? "text-white" : "text-gray-600"
}`}
>
3
</Text>
</View>
</View>
{/* Spacer */}
<View className="flex-1" />
{/* PIN Dots - positioned right above keypad */}
{renderPinDots()}
{/* Keypad */}
<View className="mb-6">
<PhonePinKeypad onKeyPress={handleKeyPress} />
</View>
{/* Continue Button */}
<View className="pb-6">
<Button
className="bg-primary rounded-3xl"
onPress={handleContinue}
disabled={loading || getCurrentPin().length !== 6}
>
<Text className="font-dmsans text-white">
{loading
? "Processing..."
: currentStep === "confirm"
? "Change PIN"
: "Continue"}
</Text>
</Button>
</View>
</View>
<ModalToast
visible={toastVisible}
title={toastTitle}
description={toastDescription}
variant={toastVariant}
/>
</ScreenWrapper>
);
}

View File

@ -0,0 +1,657 @@
import React, { useEffect, useRef, useState } from "react";
import { View, ScrollView, Image, TouchableOpacity } from "react-native";
import { useLocalSearchParams, router } from "expo-router";
import ScreenWrapper from "~/components/ui/ScreenWrapper";
import BackButton from "~/components/ui/backButton";
import { Text } from "~/components/ui/text";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { useAuthWithProfile } from "~/lib/hooks/useAuthWithProfile";
import { TransactionService } from "~/lib/services/transactionService";
import { GoogleIcon } from "~/components/ui/icons";
import { Icons } from "~/assets/icons";
import { WalletService } from "~/lib/services/walletService";
import { ROUTES } from "~/lib/routes";
import {
calculateTotalAmountForSending,
calculateProcessingFee,
} from "~/lib/utils/feeUtils";
import ModalToast from "~/components/ui/toast";
import { useTranslation } from "react-i18next";
type PaymentOptionCardProps = {
label: string;
icon: React.ReactNode;
selected?: boolean;
onPress?: () => void;
};
const PaymentOptionCard = ({
label,
icon,
selected,
onPress,
}: PaymentOptionCardProps) => {
return (
<TouchableOpacity className="flex-1" activeOpacity={0.8} onPress={onPress}>
<View
className={`rounded-xl px-3 py-2 border ${
selected ? "border-[#105D38]" : "border-gray-300"
}`}
>
<View className="flex-row items-start justify-between mb-3">
{icon}
{selected && (
<View className="w-5 h-5 rounded-full bg-[#105D38] items-center justify-center">
<Text className="text-xs font-dmsans-bold text-white"></Text>
</View>
)}
</View>
<Text className="text-sm font-dmsans-semibold text-black">{label}</Text>
</View>
</TouchableOpacity>
);
};
export default function CheckoutScreen() {
const { t } = useTranslation();
const params = useLocalSearchParams<{
amount?: string;
type?: string;
recipientName?: string;
recipientPhoneNumber?: string;
recipientType?: string;
recipientId?: string;
note?: string;
donationSkipped?: string;
donationType?: string;
donationAmount?: string;
donateAnonymously?: string;
donationCampaignId?: string;
donationCampaignTitle?: string;
ticketTierName?: string;
ticketTierPrice?: string;
eventName?: string;
eventId?: string;
ticketTierId?: string;
ticketCount?: string;
}>();
const { user, profile, wallet, refreshWallet } = useAuthWithProfile();
const [selectedPayment, setSelectedPayment] = useState<
"card" | "apple" | "google"
>("card");
const [email, setEmail] = useState("");
const [nameOnCard, setNameOnCard] = useState("");
const [cardNumber, setCardNumber] = useState("");
const [expiry, setExpiry] = useState("");
const [cvv, setCvv] = useState("");
const [showCvv, setShowCvv] = useState(false);
const [address, setAddress] = useState("");
const [isProcessing, setIsProcessing] = useState(false);
const [toastVisible, setToastVisible] = useState(false);
const [toastTitle, setToastTitle] = useState("");
const [toastDescription, setToastDescription] = useState<string | undefined>(
undefined
);
const [toastVariant, setToastVariant] = useState<
"success" | "error" | "warning" | "info"
>("info");
const toastTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isAddCashFlow = params.type === "add_cash";
const isEventTicketFlow = params.type === "event_ticket";
const headerTitle = isEventTicketFlow
? params.ticketTierName || "-"
: isAddCashFlow
? profile?.fullName || user?.email || "-"
: params.recipientName || "-";
const headerSubtitle = isEventTicketFlow
? params.eventName || ""
: isAddCashFlow
? profile?.phoneNumber || profile?.email || ""
: params.recipientPhoneNumber || "";
const amountInCents = parseInt(params.amount || "0");
const amountInDollars = amountInCents / 100;
const hasDonation =
params.donationSkipped === "false" && !!params.donationAmount;
const donationAmountNumber = params.donationAmount
? Number(params.donationAmount)
: NaN;
const donationAmountDollars =
!isNaN(donationAmountNumber) && hasDonation ? donationAmountNumber : 0;
const processingFeeInCents = Math.round(amountInCents * 0.0125);
const processingFeeInDollars = processingFeeInCents / 100;
const subtotalInDollars = amountInDollars + donationAmountDollars;
const totalInDollars = subtotalInDollars + processingFeeInDollars;
// Card input helpers (mirrored from AddCard screen)
const formatCardNumber = (value: string) => {
const v = value.replace(/\s+/g, "").replace(/[^0-9]/gi, "");
const matches = v.match(/\d{4,16}/g);
const match = (matches && matches[0]) || "";
const parts: string[] = [];
for (let i = 0, len = match.length; i < len; i += 4) {
parts.push(match.substring(i, i + 4));
}
if (parts.length) {
return parts.join(" ");
} else {
return v;
}
};
const formatExpiry = (value: string) => {
const v = value.replace(/\s+/g, "").replace(/[^0-9]/gi, "");
if (v.length >= 2) {
return `${v.substring(0, 2)}/${v.substring(2, 4)}`;
}
return v;
};
const handleCardNumberChange = (value: string) => {
const formatted = formatCardNumber(value);
if (formatted.length <= 19) {
// 16 digits + 3 spaces
setCardNumber(formatted);
}
};
const handleExpiryChange = (value: string) => {
const formatted = formatExpiry(value);
if (formatted.length <= 5) {
// MM/YY
setExpiry(formatted);
}
};
const handleCvvChange = (value: string) => {
const v = value.replace(/[^0-9]/gi, "");
if (v.length <= 4) {
setCvv(v);
}
};
const showToast = (
title: string,
description?: string,
variant: "success" | "error" | "warning" | "info" = "info"
) => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
setToastTitle(title);
setToastDescription(description);
setToastVariant(variant);
setToastVisible(true);
toastTimeoutRef.current = setTimeout(() => {
setToastVisible(false);
toastTimeoutRef.current = null;
}, 2500);
};
useEffect(() => {
return () => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
};
}, []);
useEffect(() => {
if (!profile) return;
if (!email && profile.email) {
setEmail(profile.email);
}
if (!nameOnCard && profile.fullName) {
setNameOnCard(profile.fullName);
}
if (!address && profile.address) {
setAddress(profile.address);
}
}, [profile, email, nameOnCard, address]);
const handlePay = async () => {
if (isAddCashFlow) {
if (!params.amount || !user?.uid || !wallet) {
showToast(
t("selectacc.toastErrorTitle"),
t("selectacc.toastMissingInfo"),
"error"
);
return;
}
const amountInCents = parseInt(params.amount);
if (isNaN(amountInCents) || amountInCents <= 0) {
showToast(
t("addcash.validationErrorTitle"),
t("addcash.validationEnterAmount"),
"error"
);
return;
}
setIsProcessing(true);
try {
const currentBalance = wallet.balance || 0;
const newBalance = currentBalance + amountInCents;
const result = await WalletService.updateWalletBalance(
user.uid,
newBalance
);
if (result.success) {
await refreshWallet();
router.replace({
pathname: ROUTES.ADDCASH_COMPLETION,
params: {
amount: (amountInCents / 100).toFixed(2),
},
});
} else {
showToast(
t("selectacc.toastErrorTitle"),
result.error || t("selectacc.toastAddCashFailed"),
"error"
);
}
} catch (error) {
console.error("Error adding cash from checkout:", error);
showToast(
t("selectacc.toastErrorTitle"),
t("selectacc.toastAddCashFailedWithRetry"),
"error"
);
} finally {
setIsProcessing(false);
}
return;
}
if (params.type === "event_ticket") {
if (!params.amount || !user?.uid) {
showToast(
t("transconfirm.toastErrorTitle"),
t("transconfirm.toastMissingDetails"),
"error"
);
return;
}
const amountInCents = parseInt(params.amount);
if (isNaN(amountInCents) || amountInCents <= 0) {
showToast(
t("transconfirm.toastErrorTitle"),
t("transconfirm.toastInvalidAmount"),
"error"
);
return;
}
setIsProcessing(true);
try {
router.push({
pathname: ROUTES.TRANSACTION_DETAIL,
params: {
amount: params.amount,
type: "event_ticket",
recipientName: params.eventName || "",
date: new Date().toISOString(),
status: "Completed",
note: params.ticketTierName || "",
flowType: "event_ticket",
ticketTierId: params.ticketTierId || "",
ticketCount: params.ticketCount || "1",
eventId: params.eventId || "",
},
});
} finally {
setIsProcessing(false);
}
return;
}
if (!params.amount || !user?.uid) {
showToast(
t("transconfirm.toastErrorTitle"),
t("transconfirm.toastMissingDetails"),
"error"
);
return;
}
const amountInCents = parseInt(params.amount);
if (isNaN(amountInCents) || amountInCents <= 0) {
showToast(
t("transconfirm.toastErrorTitle"),
t("transconfirm.toastInvalidAmount"),
"error"
);
return;
}
const totalRequired = calculateTotalAmountForSending(amountInCents);
if (!wallet) {
showToast(
t("transconfirm.toastErrorTitle"),
t("transconfirm.toastWalletNotFound"),
"error"
);
return;
}
if (wallet.balance < totalRequired) {
const processingFee = calculateProcessingFee(amountInCents);
const required = (totalRequired / 100).toFixed(2);
const fee = (processingFee / 100).toFixed(2);
const available = (wallet.balance / 100).toFixed(2);
showToast(
t("transconfirm.toastInsufficientBalanceTitle"),
t("transconfirm.toastInsufficientBalanceDescription", {
required,
fee,
available,
}),
"error"
);
return;
}
if (
!params.recipientName ||
!params.recipientPhoneNumber ||
!params.recipientType ||
!params.recipientId
) {
showToast(
t("transconfirm.toastErrorTitle"),
t("transconfirm.toastRecipientMissing"),
"error"
);
return;
}
setIsProcessing(true);
try {
const result = await TransactionService.sendMoney(user.uid, {
amount: amountInCents,
recipientName: params.recipientName,
recipientPhoneNumber: params.recipientPhoneNumber,
recipientType: params.recipientType as "saved" | "contact",
recipientId: params.recipientId,
note: params.note?.trim() || "",
});
if (result.success) {
await refreshWallet();
router.replace({
pathname: "/(screens)/taskcomp",
params: {
message: `Transaction completed on your end. $${(
amountInCents / 100
).toFixed(2)} will be claimed by ${
params.recipientName
} within 7 days, or the money will revert to your wallet.`,
amount: (amountInCents / 100).toFixed(2),
recipientName: params.recipientName,
recipientPhoneNumber: params.recipientPhoneNumber,
},
});
} else {
showToast(
t("transconfirm.toastErrorTitle"),
result.error || t("transconfirm.toastSendFailed"),
"error"
);
}
} catch (error) {
console.error("Error sending money:", error);
showToast(
t("transconfirm.toastErrorTitle"),
t("transconfirm.toastSendFailedWithRetry"),
"error"
);
} finally {
setIsProcessing(false);
}
};
return (
<ScreenWrapper edges={[]}>
<View className="flex-1">
<View className="pt-4">
<BackButton />
</View>
<ScrollView
className="flex-1 px-5"
contentContainerStyle={{ paddingBottom: 32 }}
showsVerticalScrollIndicator={false}
>
<Text className="text-2xl font-dmsans-bold text-black mb-3">
{t("checkout.title")}
</Text>
<View className="bg-gray-100 rounded-2xl px-4 py-3 mb-4 flex-row justify-between items-center">
<View style={{ flex: 1 }}>
<Text className="text-xs font-dmsans text-gray-500 mb-1">
{isEventTicketFlow ? "Ticket" : t("checkout.recipientLabel")}
</Text>
<Text
className="text-base font-dmsans-medium text-gray-900"
numberOfLines={1}
>
{headerTitle}
</Text>
<Text className="text-xs font-dmsans text-gray-500 mt-1">
{headerSubtitle}
</Text>
</View>
<View className="items-end ml-4">
<Text className="text-xs font-dmsans text-gray-500 mb-1">
{t("checkout.totalLabel")}
</Text>
<Text className="text-xl font-dmsans-bold text-gray-900">
${totalInDollars.toFixed(2)}
</Text>
</View>
</View>
<Text className="text-lg font-dmsans-medium mb-6">
{t("checkout.subtitle")}
</Text>
{/* Payment options */}
<View className="mb-6">
<Text className="text-base font-dmsans-medium mb-3">
{t("checkout.paymentOptionsTitle")}
</Text>
<View className="flex-row gap-3">
<PaymentOptionCard
label="Card"
icon={
<Image
source={Icons.cardCheck}
style={{ width: 20, height: 20, resizeMode: "contain" }}
/>
}
selected={selectedPayment === "card"}
onPress={() => setSelectedPayment("card")}
/>
<PaymentOptionCard
label="Apple Pay"
icon={
<Image
source={Icons.applePay}
style={{ width: 20, height: 20, resizeMode: "contain" }}
/>
}
selected={selectedPayment === "apple"}
onPress={() => setSelectedPayment("apple")}
/>
<PaymentOptionCard
label="Google Pay"
icon={<GoogleIcon width={20} height={20} />}
selected={selectedPayment === "google"}
onPress={() => setSelectedPayment("google")}
/>
</View>
</View>
{selectedPayment === "card" ? (
<View className="mb-6">
<Text className="text-base font-dmsans-medium mb-2">
{t("checkout.cardInfoTitle")}
</Text>
<View className="mb-3">
<Input
value={cardNumber}
onChangeText={handleCardNumberChange}
placeholderText={t("checkout.cardNumberPlaceholder")}
placeholderColor="#7E7E7E"
containerClassName="w-full"
borderClassName="border-[#D9DBE9] bg-white"
textClassName="text-[#000] text-sm"
keyboardType="numeric"
maxLength={19}
/>
</View>
<View className="flex-row gap-3">
<View className="flex-1">
<Input
value={expiry}
onChangeText={handleExpiryChange}
placeholderText={t("checkout.expiryPlaceholder")}
placeholderColor="#7E7E7E"
containerClassName="w-full"
borderClassName="border-[#D9DBE9] bg-white"
textClassName="text-[#000] text-sm"
keyboardType="numeric"
maxLength={5}
/>
</View>
<View className="flex-1">
<Input
value={cvv}
onChangeText={handleCvvChange}
placeholderText={t("checkout.cvvPlaceholder")}
placeholderColor="#7E7E7E"
containerClassName="w-full"
borderClassName="border-[#D9DBE9] bg-white"
textClassName="text-[#000] text-sm"
keyboardType="numeric"
secureTextEntry={!showCvv}
maxLength={4}
/>
</View>
</View>
</View>
) : (
<View className="mb-6">
<Text className="text-base font-dmsans-medium mb-2">
{selectedPayment === "apple"
? t("checkout.appleIdTitle")
: t("checkout.paymentEmailTitle")}
</Text>
<Input
value={email}
onChangeText={setEmail}
placeholderText={
selectedPayment === "apple"
? t("checkout.appleIdPlaceholder")
: t("checkout.paymentEmailPlaceholder")
}
placeholderColor="#6B7280"
containerClassName="w-full"
borderClassName="border-[#D9DBE9] bg-white"
textClassName="text-[#000] text-sm"
keyboardType="email-address"
autoCapitalize="none"
/>
</View>
)}
<View className="h-px bg-gray-200 mb-4" />
{/* Contact information */}
<View className="mb-6">
<Text className="text-sm font-dmsans-medium mb-2">
{t("checkout.contactInfoTitle")}
</Text>
<Input
value={email || profile?.phoneNumber || profile?.email || ""}
onChangeText={setEmail}
placeholderText="Email"
placeholderColor="#6B7280"
containerClassName="w-full"
borderClassName="border-[#D9DBE9]"
textClassName="text-base text-black"
editable={false}
multiline
/>
</View>
{/* Billing address */}
<View className="mb-6">
<Text className="text-sm font-dmsans-medium mb-2">
{t("checkout.billingAddressTitle")}
</Text>
<Input
value={address || profile?.address || ""}
onChangeText={setAddress}
placeholderText="Address"
placeholderColor="#6B7280"
containerClassName="w-full"
borderClassName="border-[#D9DBE9]"
textClassName="text-base text-black"
editable={false}
multiline
/>
</View>
</ScrollView>
<View className="px-5 pb-6 pt-2 ">
<Button
className="bg-primary rounded-2xl h-12 items-center justify-center"
onPress={handlePay}
disabled={isProcessing}
>
<Text className="text-white font-dmsans-medium text-base">
{isProcessing
? t("checkout.payButtonProcessing")
: t("checkout.payButton")}
</Text>
</Button>
</View>
</View>
<ModalToast
visible={toastVisible}
title={toastTitle}
description={toastDescription}
variant={toastVariant}
/>
</ScreenWrapper>
);
}

View File

@ -0,0 +1,218 @@
import * as React from "react";
import { Image, View, ScrollView } from "react-native";
import { Button } from "~/components/ui/button";
import { Progress } from "~/components/ui/progress";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { Text } from "~/components/ui/text";
import {
ArrowLeftIcon,
CircleDollarSign,
LucideEye,
} from "lucide-react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
export default function CrowdFund() {
const [value, setValue] = React.useState("overview");
const { t } = useTranslation();
return (
<SafeAreaView className="flex my-10 space-y-10 overflow-y-scroll">
<ScrollView className="space-y-2">
<View className="flex items-left px-5 space-y-3 w-full">
<Image
source={{
uri: "https://www.gravatar.com/avatar/2c7d99fe281ecd3bcd65ab915bac6dd5?s=250",
}}
alt=""
className="w-full h-[200px] object-cover rounded"
/>
</View>
<View className="flex-1 justify-center px-5 mt-3">
<Tabs
value={value}
onValueChange={setValue}
className="w-full max-w-[400px] mx-auto flex-col gap-1.5"
>
<TabsList className="flex-row w-full">
<TabsTrigger value="overview" className="flex-1">
<Text className="font-dmsans">
{t("crowdfunding.tabsOverview")}
</Text>
</TabsTrigger>
<TabsTrigger value="campagin" className="flex-1">
<Text className="font-dmsans">
{t("crowdfunding.tabsCampaign")}
</Text>
</TabsTrigger>
<TabsTrigger value="faq" className="flex-1">
<Text className="font-dmsans">{t("crowdfunding.tabsFaq")}</Text>
</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<View className="space-y-3 mt-3">
<Text className="text-xl font-dmsans text-primary">
{t("crowdfunding.title")}
</Text>
<Text className="text-sm font-dmsans-light">
{t("crowdfunding.description")}
</Text>
<View className="py-5">
<Progress value={87} className="w-full " />
</View>
<View className="flex flex-row space-x-3">
<View className="flex space-y-1">
<Text className="text-base font-dmsans-bold text-secondary">
{t("crowdfunding.pledgedAmount")}
</Text>
<Text className="text-sm font-dmsans-light text-primary">
{t("crowdfunding.pledgedOf", { target: "1,000,000" })}
</Text>
</View>
<View className="flex space-y-1">
<Text className="text-base font-dmsans-bold text-secondary">
1,000
</Text>
<Text className="text-sm font-dmsans-light text-primary">
{t("crowdfunding.backersCountLabel")}
</Text>
</View>
<View className="flex space-y-1">
<Text className="text-base font-dmsans-bold text-secondary">
32
</Text>
<Text className="text-sm font-dmsans-light text-primary">
{t("crowdfunding.daysToGoLabel")}
</Text>
</View>
</View>
</View>
</TabsContent>
<TabsContent value="campagin">
<View className="space-y-3 mt-3">
<View className="space-y-3">
<Label className=" text-base font-dmsans-medium">
{t("crowdfunding.emailLabel")}
</Label>
<Input
aria-aria-labelledby="name"
className="font-dmsans"
placeholder={t("crowdfunding.emailPlaceholder")}
/>
<Label className=" text-base font-dmsans-medium">
{t("crowdfunding.otpLabel")}
</Label>
<Input
aria-aria-labelledby="name"
className="font-dmsans"
placeholder={t("crowdfunding.otpPlaceholder")}
/>
<Label className=" text-base font-dmsans-medium">
{t("crowdfunding.passwordLabel")}
</Label>
<View className="flex flex-row justify-between items-center space-x-3">
<Input
placeholder={t("crowdfunding.passwordPlaceholder")}
/>
<Button className="bg-white border border-gray-300 rounded-md">
<LucideEye className="" />
</Button>
</View>
<Label className=" text-base font-dmsans-medium">
{t("crowdfunding.confirmPasswordLabel")}
</Label>
<View className="flex flex-row justify-between items-center space-x-3">
<Input
placeholder={t("crowdfunding.confirmPasswordPlaceholder")}
/>
<Button className="bg-white border border-gray-300 rounded-md">
<LucideEye className="" />
</Button>
</View>
</View>
<Button className="bg-primary mt-5">
<Text className="font-dmsans">
{t("crowdfunding.resetButton")}
</Text>
</Button>
<Button className="bg-white border border-gray-800 border-dashed rounded-md">
<Text className="font-dmsans text-secondary">
{t("crowdfunding.resendButton")}
</Text>
</Button>
</View>
</TabsContent>
<TabsContent value="faq">
<View className="space-y-3 mt-3">
<View className="space-y-3">
<Label className=" text-base font-dmsans-medium">
{t("crowdfunding.emailLabel")}
</Label>
<Input
aria-aria-labelledby="name"
className="font-dmsans"
placeholder={t("crowdfunding.emailPlaceholder")}
/>
<Label className=" text-base font-dmsans-medium">
{t("crowdfunding.otpLabel")}
</Label>
<Input
aria-aria-labelledby="name"
className="font-dmsans"
placeholder={t("crowdfunding.otpPlaceholder")}
/>
<Label className=" text-base font-dmsans-medium">
{t("crowdfunding.passwordLabel")}
</Label>
<View className="flex flex-row justify-between items-center space-x-3">
<Input
placeholder={t("crowdfunding.passwordPlaceholder")}
/>
<Button className="bg-white border border-gray-300 rounded-md">
<LucideEye className="" />
</Button>
</View>
<Label className=" text-base font-dmsans-medium">
{t("crowdfunding.confirmPasswordLabel")}
</Label>
<View className="flex flex-row justify-between items-center space-x-3">
<Input
placeholder={t("crowdfunding.confirmPasswordPlaceholder")}
/>
<Button className="bg-white border border-gray-300 rounded-md">
<LucideEye className="" />
</Button>
</View>
</View>
<Button className="bg-primary mt-5">
<Text className="font-dmsans">
{t("crowdfunding.resetButton")}
</Text>
</Button>
<Button className="bg-white border border-gray-800 border-dashed rounded-md">
<Text className="font-dmsans text-secondary">
{t("crowdfunding.resendButton")}
</Text>
</Button>
</View>
</TabsContent>
</Tabs>
</View>
</ScrollView>
</SafeAreaView>
);
}

View File

@ -0,0 +1,322 @@
import React, { useState } from "react";
import { View, ScrollView, Image, TouchableOpacity } from "react-native";
import { router, useLocalSearchParams } from "expo-router";
import ScreenWrapper from "~/components/ui/ScreenWrapper";
import BackButton from "~/components/ui/backButton";
import { Text } from "~/components/ui/text";
import { Button } from "~/components/ui/button";
import { ROUTES } from "~/lib/routes";
import { Icons } from "~/assets/icons";
import { Check } from "lucide-react-native";
import { useAuthWithProfile } from "~/lib/hooks/useAuthWithProfile";
import { useTranslation } from "react-i18next";
const campaigns = [
{
id: "1",
title: "Fresh Fruits for Improving Childrens Health",
image: Icons.profileImage,
progress: 0.8,
raised: "$54,090",
timeLeft: "8 Hours left",
},
{
id: "2",
title: "Support Local Farmers Market Initiative",
image: Icons.mainBG,
progress: 0.45,
raised: "$21,400",
timeLeft: "2 Days left",
},
{
id: "3",
title: "School Meal Program for Kids",
image: Icons.qrImage,
progress: 0.62,
raised: "$33,250",
timeLeft: "1 Day left",
},
];
export default function DonationScreen() {
const { t } = useTranslation();
const params = useLocalSearchParams<Record<string, string>>();
const { profile } = useAuthWithProfile();
const amountInCents = Number(params.amount ?? "0");
const baseAmount = isNaN(amountInCents)
? "0.00"
: (amountInCents / 100).toFixed(2);
const [donationType, setDonationType] = useState<"one-time" | "monthly">(
"monthly"
);
const [donationAmount, setDonationAmount] = useState(baseAmount);
const [donateAnonymously, setDonateAnonymously] = useState(false);
const [selectedCampaignId, setSelectedCampaignId] = useState<string | null>(
null
);
const isCampaignSelected = !!selectedCampaignId;
const displayName = donateAnonymously
? t("donation.anonymousLabel")
: profile?.fullName || t("donation.displayNameFallback");
const goToConfirm = (options?: { skipped?: boolean }) => {
const selectedCampaign = campaigns.find((c) => c.id === selectedCampaignId);
router.push({
pathname: ROUTES.TRANSACTION_CONFIRM,
params: {
...params,
donationSkipped: options?.skipped ? "true" : "false",
donationType,
donationAmount,
donateAnonymously: donateAnonymously ? "true" : "false",
donationCampaignId: selectedCampaign?.id ?? "",
donationCampaignTitle: selectedCampaign?.title ?? "",
fromSelectRecipientFlow: "true",
},
});
};
const handleSkip = () => {
goToConfirm({ skipped: true });
};
const handleDonate = () => {
goToConfirm({ skipped: false });
};
return (
<ScreenWrapper edges={[]}>
<BackButton />
<ScrollView
className="flex-1 bg-white"
showsVerticalScrollIndicator={false}
>
<View className="pt-4 pb-8">
<View className="px-5">
<Text className="text-3xl font-dmsans-bold text-gray-900 mb-2">
{t("donation.title")}
</Text>
<Text className="text-base font-dmsans text-gray-500 mb-6">
{t("donation.subtitle")}
</Text>
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
pagingEnabled
snapToAlignment="center"
decelerationRate="fast"
className="px-5 pr-5 py-5"
contentContainerStyle={{ paddingRight: 20 }}
>
{campaigns.map((campaign) => {
const isSelected = selectedCampaignId === campaign.id;
return (
<TouchableOpacity
key={campaign.id}
activeOpacity={0.9}
onPress={() => setSelectedCampaignId(campaign.id)}
>
<View
className="mr-4 bg-white rounded-2xl"
style={{
width: 320,
height: 280,
shadowColor: "#000",
shadowOpacity: 0.08,
shadowRadius: 20,
shadowOffset: { width: 0, height: 6 },
elevation: 4,
borderWidth: isSelected ? 2 : 0,
borderColor: isSelected
? "rgba(19,83,53,0.5)"
: "transparent",
}}
>
<Image
source={campaign.image}
resizeMode="cover"
style={{ width: "100%", height: 150 }}
/>
{/* Content */}
<View className="p-4 flex-1 justify-between">
<Text
numberOfLines={1}
ellipsizeMode="tail"
className="text-base font-dmsans-medium text-gray-900 mb-3"
>
{campaign.title}
</Text>
{/* Progress bar */}
<View className="w-full h-1.5 rounded-full bg-gray-200 mb-2 overflow-hidden">
<View
style={{ width: `${campaign.progress * 100}%` }}
className="h-full bg-primary"
/>
</View>
<View className="flex-row justify-between items-center mb-2">
<Text className="text-xs font-dmsans text-gray-500">
{t("donation.donationRaisedLabel")}
</Text>
<Text className="text-xs font-dmsans text-gray-500">
{Math.round(campaign.progress * 100)}%
</Text>
</View>
<View className="flex-row justify-between items-center mt-2">
<Text className="text-lg font-dmsans-bold text-gray-900">
{campaign.raised}
</Text>
<Text className="text-xs font-dmsans text-gray-500">
{campaign.timeLeft}
</Text>
</View>
</View>
</View>
</TouchableOpacity>
);
})}
</ScrollView>
<View
className="px-5 mt-6"
style={{ opacity: isCampaignSelected ? 1 : 0.4 }}
pointerEvents={isCampaignSelected ? "auto" : "none"}
>
<View className="bg-gray-100 rounded-xl p-4">
<Text className="text-lg font-dmsans-bold text-gray-900 mb-4 text-center">
{t("donation.chooseAmountTitle")}
</Text>
{/* One-Time / Monthly toggle */}
<View className="flex-row bg-white rounded-full p-1 mb-4">
<TouchableOpacity
className={`flex-1 rounded-full py-2 items-center justify-center ${
donationType === "one-time" ? "bg-primary" : ""
}`}
activeOpacity={0.8}
onPress={() => setDonationType("one-time")}
>
<Text
className={`text-sm font-dmsans-medium ${
donationType === "one-time"
? "text-white"
: "text-gray-600"
}`}
>
{t("donation.donationTypeOneTime")}
</Text>
</TouchableOpacity>
<TouchableOpacity
className={`flex-1 rounded-full py-2 items-center justify-center flex-row gap-1 ${
donationType === "monthly" ? "bg-primary" : ""
}`}
activeOpacity={0.8}
onPress={() => setDonationType("monthly")}
>
<Text
className={`text-sm font-dmsans-medium ${
donationType === "monthly"
? "text-white"
: "text-gray-600"
}`}
>
{t("donation.donationTypeMonthly")}
</Text>
</TouchableOpacity>
</View>
{/* Amount row */}
<View className="flex-row items-center bg-white rounded-full px-4 py-3 mb-3">
<Text className="flex-1 text-2xl font-dmsans-bold text-gray-900">
{donationAmount}
</Text>
</View>
{/* Quick amount chips */}
<View className="flex-row justify-between mb-4">
{["5", "10", "25", "50", "100"].map((value) => {
const isActive = donationAmount === value;
return (
<TouchableOpacity
key={value}
activeOpacity={0.8}
onPress={() => setDonationAmount(value)}
>
<View
className={`px-4 py-2 rounded-full border ${
isActive
? "bg-primary border-primary"
: "bg-white border-gray-200"
}`}
>
<Text
className={`text-sm font-dmsans-medium ${
isActive ? "text-white" : "text-gray-700"
}`}
>
${value}
</Text>
</View>
</TouchableOpacity>
);
})}
</View>
<TouchableOpacity
className="flex-row items-center mb-3"
activeOpacity={0.8}
onPress={() => setDonateAnonymously(!donateAnonymously)}
>
<View
className={`w-5 h-5 rounded-md border items-center justify-center mr-3 ${
donateAnonymously
? "bg-primary border-primary"
: "border-gray-400 bg-white"
}`}
>
{donateAnonymously && <Check size={14} color="#ffffff" />}
</View>
<Text className="text-sm font-dmsans text-gray-700">
{t("donation.donateAnonymouslyLabel")}
</Text>
</TouchableOpacity>
<View className="bg-white rounded-xl px-4 py-3 mb-3">
<Text className="text-base font-dmsans-medium text-gray-900">
{displayName}
</Text>
</View>
</View>
</View>
</View>
</ScrollView>
<View className="px-5 pb-6 pt-2 flex-row gap-3 bg-white">
<View className="flex-1">
<Button className="bg-gray-100 rounded-full" onPress={handleSkip}>
<Text className="text-gray-700 font-dmsans-medium text-base text-center">
{t("donation.skipButton")}
</Text>
</Button>
</View>
<View className="flex-1">
<Button className="bg-primary rounded-full" onPress={handleDonate}>
<Text className="text-white font-dmsans-medium text-base text-center">
{t("donation.donateButton")}
</Text>
</Button>
</View>
</View>
</ScreenWrapper>
);
}

View File

@ -0,0 +1,896 @@
import React, { useState, useEffect, useRef } from "react";
import {
ScrollView,
View,
Keyboard,
TouchableOpacity,
Image,
Alert,
ActivityIndicator,
} from "react-native";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Text } from "~/components/ui/text";
import { router } from "expo-router";
import { useAuthWithProfile } from "~/lib/hooks/useAuthWithProfile";
import { ROUTES } from "~/lib/routes";
import { profileUpdateSchema, validate } from "~/lib/utils/validationSchemas";
import BackButton from "~/components/ui/backButton";
import ScreenWrapper from "~/components/ui/ScreenWrapper";
import ModalToast from "~/components/ui/toast";
import { useTranslation } from "react-i18next";
import Dropdown, { DropdownOption } from "~/components/ui/dropdown";
import { useLangStore } from "~/lib/stores";
import { Copy, Plus, ChevronDown } from "lucide-react-native";
import * as Clipboard from "expo-clipboard";
import * as ImagePicker from "expo-image-picker";
import { Icons } from "~/assets/icons";
import { AuthService } from "~/lib/services/authServices";
import { uploadProfileImage } from "~/lib/services/profileImageService";
import BottomSheet from "~/components/ui/bottomSheet";
type ProfileLinkedAccount = {
id: string;
bankId: string;
bankName: string;
accountNumber: string;
isDefault: boolean;
};
const PROFILE_BANK_OPTIONS: { id: string; name: string }[] = [
{ id: "cbe", name: "Commercial Bank of Ethiopia" },
{ id: "dashen", name: "Dashen Bank" },
{ id: "abay", name: "Abay Bank" },
{ id: "awash", name: "Awash Bank" },
{ id: "hibret", name: "Hibret Bank" },
{ id: "telebirr", name: "Ethio Telecom (Telebirr)" },
{ id: "safaricom", name: "Safaricom M-PESA" },
];
export default function EditProfile() {
const { t } = useTranslation();
const language = useLangStore((state) => state.language);
const setLanguage = useLangStore((state) => state.setLanguage);
const { user, profile, wallet, profileLoading, profileError } =
useAuthWithProfile();
const [updateLoading, setUpdateLoading] = useState(false);
const [updateError, setUpdateError] = useState<string | null>(null);
const [profileImage, setProfileImage] = useState<string | null>(null);
const [imagePicking, setImagePicking] = useState(false);
// Editable state for form fields
const [editedProfile, setEditedProfile] = useState({
fullName: "",
phoneNumber: "",
email: "",
address: "",
});
const [profileAccounts, setProfileAccounts] = useState<
ProfileLinkedAccount[]
>([]);
const [isSelectAccountSheetVisible, setIsSelectAccountSheetVisible] =
useState(false);
const [isAddingAccount, setIsAddingAccount] = useState(false);
const [selectedBank, setSelectedBank] = useState<string | null>(null);
const [accountNumberInput, setAccountNumberInput] = useState("");
const [savingAccount, setSavingAccount] = useState(false);
const [pendingDefaultAccountId, setPendingDefaultAccountId] = useState<
string | null
>(null);
const [toastVisible, setToastVisible] = useState(false);
const [toastTitle, setToastTitle] = useState("");
const [toastDescription, setToastDescription] = useState<string | undefined>(
undefined
);
const [toastVariant, setToastVariant] = useState<
"success" | "error" | "warning" | "info"
>("info");
const toastTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const showToast = (
title: string,
description?: string,
variant: "success" | "error" | "warning" | "info" = "info"
) => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
setToastTitle(title);
setToastDescription(description);
setToastVariant(variant);
setToastVisible(true);
toastTimeoutRef.current = setTimeout(() => {
setToastVisible(false);
toastTimeoutRef.current = null;
}, 2500);
};
useEffect(() => {
return () => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
};
}, []);
// Update local state when profile loads
useEffect(() => {
if (profile) {
setEditedProfile({
fullName: profile.fullName || "",
phoneNumber: profile.phoneNumber || "",
email: profile.email || "",
address: profile.address || "",
});
// Only sync image from profile if user hasn't picked a local file
setProfileImage((prev) => {
if (prev && prev.startsWith("file:")) {
return prev;
}
return profile.photoUrl || null;
});
const linkedFromProfile: any = (profile as any).linkedAccounts;
if (Array.isArray(linkedFromProfile)) {
const mapped: ProfileLinkedAccount[] = linkedFromProfile.map(
(acc: any) => ({
id: String(acc.id),
bankId: String(acc.bankId || ""),
bankName: String(acc.bankName || ""),
accountNumber: String(acc.accountNumber || ""),
isDefault: !!acc.isDefault,
})
);
setProfileAccounts(mapped);
} else {
setProfileAccounts([]);
}
}
}, [profile]);
const handleSelectProfileImage = async () => {
try {
setImagePicking(true);
const permissionResult =
await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permissionResult.granted) {
Alert.alert(
"Permission Required",
"Please allow access to your photo library to select a profile picture."
);
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
aspect: [1, 1],
quality: 0.8,
});
if (!result.canceled && result.assets[0]) {
const localUri = result.assets[0].uri;
console.log("[EditProfile] Image selected locally", { localUri });
// Only update local preview here. Actual upload happens on Save.
setProfileImage(localUri);
}
} catch (e) {
console.error("[EditProfile] Error while selecting profile image", e);
showToast("Error", "Failed to select image", "error");
} finally {
setImagePicking(false);
}
};
const handleUpdateProfile = async () => {
Keyboard.dismiss();
if (!user?.uid) {
showToast(
t("profile.toastErrorTitle"),
t("profile.toastUserNotFound"),
"error"
);
return;
}
// Prepare data for validation based on signup type
const validationData: any = {
fullName: editedProfile.fullName,
address: editedProfile.address || undefined,
};
// Only validate phone number if user didn't sign up with phone
if (profile?.signupType !== "phone") {
validationData.phoneNumber = editedProfile.phoneNumber || undefined;
}
// Only validate email if user signed up with phone
if (profile?.signupType === "phone") {
validationData.email = editedProfile.email || undefined;
}
// Validate form using valibot
const validationResult = validate(profileUpdateSchema, validationData);
if (!validationResult.success) {
showToast("Error", validationResult.error, "warning");
return;
}
try {
setUpdateLoading(true);
setUpdateError(null);
// If the user picked a new local image (file://), upload it first
let finalPhotoUrl: string | undefined = profile?.photoUrl || undefined;
if (
profileImage &&
profileImage !== profile?.photoUrl &&
profileImage.startsWith("file:")
) {
try {
console.log("[EditProfile] Uploading profile image on save", {
uid: user.uid,
localUri: profileImage,
});
const uploadedUrl = await uploadProfileImage(user.uid, profileImage);
console.log(
"[EditProfile] Profile image uploaded on save, url:",
uploadedUrl
);
finalPhotoUrl = uploadedUrl;
setProfileImage(uploadedUrl);
} catch (e) {
console.error(
"[EditProfile] Failed to upload profile image on save",
e
);
throw new Error(
e instanceof Error ? e.message : "Failed to upload profile image"
);
}
}
// Prepare update data based on signup type
const updateData: any = {
fullName: validationResult.data.fullName,
address: validationResult.data.address || undefined,
};
if (finalPhotoUrl) {
updateData.photoUrl = finalPhotoUrl;
}
// Only allow phone number update if user didn't sign up with phone
if (
profile?.signupType !== "phone" &&
validationResult.data.phoneNumber
) {
updateData.phoneNumber = validationResult.data.phoneNumber;
}
// Only allow email update if user signed up with phone
if (profile?.signupType === "phone" && validationResult.data.email) {
updateData.email = validationResult.data.email;
}
const result = await AuthService.updateUserProfile(user.uid, updateData);
if (!result.success) {
throw new Error(result.error || "Failed to update profile");
}
showToast(
t("profile.toastProfileUpdatedTitle"),
t("profile.toastProfileUpdatedDescription"),
"success"
);
// Navigate back to profile after successful update
setTimeout(() => {
router.back();
}, 1000);
} catch (error) {
setUpdateError(
error instanceof Error ? error.message : "Failed to update profile"
);
showToast(
t("profile.toastErrorTitle"),
t("profile.toastUpdateErrorDescription"),
"error"
);
} finally {
setUpdateLoading(false);
}
};
const handleCancel = () => {
router.back();
};
const handleCopyUsername = async () => {
if (
!usernameDisplay ||
usernameDisplay === t("profile.usernamePlaceholder")
) {
showToast("Error", "No username to copy", "error");
return;
}
await Clipboard.setStringAsync(usernameDisplay);
showToast("Copied", "Username copied to clipboard", "success");
};
const handleStartAddAccount = () => {
if (profileAccounts.length >= 5) {
showToast("Limit reached", "You can link up to 5 accounts.", "warning");
return;
}
setIsSelectAccountSheetVisible(false);
setSelectedBank(null);
setAccountNumberInput("");
setIsAddingAccount(true);
};
const handleSelectBank = (bankId: string) => {
setSelectedBank(bankId);
};
const handleSaveAccount = async () => {
if (!selectedBank || !accountNumberInput.trim()) {
return;
}
if (profileAccounts.length >= 5) {
showToast("Limit reached", "You can link up to 5 accounts.", "warning");
return;
}
const bank = PROFILE_BANK_OPTIONS.find((b) => b.id === selectedBank);
if (!bank) return;
const newAccount: ProfileLinkedAccount = {
id: `${selectedBank}-${Date.now()}`,
bankId: selectedBank,
bankName: bank.name,
accountNumber: accountNumberInput.trim(),
isDefault: profileAccounts.length === 0,
};
const updatedAccounts = [...profileAccounts, newAccount];
setProfileAccounts(updatedAccounts);
if (!user?.uid) {
showToast(
t("profile.toastErrorTitle"),
t("profile.toastUserNotFound"),
"error"
);
return;
}
try {
setSavingAccount(true);
const result = await AuthService.updateUserProfile(user.uid, {
linkedAccounts: updatedAccounts,
});
if (!result.success) {
throw new Error(result.error || "Failed to save account");
}
} catch (error) {
showToast(
t("profile.toastErrorTitle"),
error instanceof Error ? error.message : "Failed to save account",
"error"
);
} finally {
setSavingAccount(false);
}
setAccountNumberInput("");
setSelectedBank(null);
setIsAddingAccount(false);
};
const handleConfirmDefaultAccount = async () => {
if (!user?.uid) {
showToast(
t("profile.toastErrorTitle"),
t("profile.toastUserNotFound"),
"error"
);
return;
}
if (!pendingDefaultAccountId) {
setIsSelectAccountSheetVisible(false);
return;
}
const updatedAccounts = profileAccounts.map((account) => ({
...account,
isDefault: account.id === pendingDefaultAccountId,
}));
setProfileAccounts(updatedAccounts);
try {
const result = await AuthService.updateUserProfile(user.uid, {
linkedAccounts: updatedAccounts,
});
if (!result.success) {
throw new Error(result.error || "Failed to update default account");
}
} catch (error) {
showToast(
t("profile.toastErrorTitle"),
error instanceof Error
? error.message
: "Failed to update default account",
"error"
);
} finally {
setIsSelectAccountSheetVisible(false);
setPendingDefaultAccountId(null);
}
};
// Show update error
useEffect(() => {
if (updateError) {
showToast(t("profile.toastUpdateErrorTitle"), updateError, "error");
}
}, [updateError]);
const languageOptions: DropdownOption[] = [
{ value: "en", label: t("profile.languageOptionEnglish") },
{ value: "am", label: t("profile.languageOptionAmharic") },
{ value: "fr", label: t("profile.languageOptionFrench") },
{ value: "ti", label: t("profile.languageOptionTigrinya") },
{ value: "om", label: t("profile.languageOptionOromo") },
];
const isTelecomWallet =
selectedBank === "telebirr" || selectedBank === "safaricom";
const accountLabel = isTelecomWallet ? "Phone Number" : "Account Number";
const accountPlaceholder = isTelecomWallet
? "Enter phone number"
: "Enter account number";
const defaultAccount =
profileAccounts.find((account) => account.isDefault) || null;
const usernameDisplay = profile?.fullName
? `@${profile.fullName.split(" ")[0]}`
: user?.email
? `@${user.email.split("@")[0]}`
: t("profile.usernamePlaceholder");
return (
<ScreenWrapper edges={[]}>
<BackButton />
<ScrollView
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
showsVerticalScrollIndicator={false}
>
<View className="flex h-[100%] justify-between px-5 space-y-3 w-full ">
<View className="py-8">
<Text className="text-xl font-bold font-dmsans">Edit Profile</Text>
</View>
<View className="flex flex-col space-y-3 pt-5 ">
<View className="items-center mb-4">
<TouchableOpacity
onPress={handleSelectProfileImage}
activeOpacity={0.8}
className="relative"
>
<View className="w-24 h-24 rounded-full bg-[#C8E6C9] items-center justify-center overflow-hidden relative">
{profileImage ? (
<Image
source={{ uri: profileImage }}
className="w-24 h-24 rounded-full"
resizeMode="cover"
/>
) : (
<Image
source={Icons.avatar}
style={{ width: 84, height: 84, resizeMode: "contain" }}
/>
)}
{(updateLoading || imagePicking) && (
<View className="absolute inset-0 items-center justify-center bg-black/20">
<ActivityIndicator color="#ffffff" />
</View>
)}
</View>
<View className="absolute bottom-0 -right-2 w-8 h-8 rounded-full bg-primary items-center justify-center border-2 border-white">
<Plus size={16} color="#fff" strokeWidth={3} />
</View>
</TouchableOpacity>
</View>
<View className="flex flex-col space-y-1 py-2 items-left">
<Text className="text-base font-dmsans text-gray-400">
{profileLoading ? t("profile.loadingProfile") : ""}
</Text>
{profileError && (
<Text className="text-sm font-dmsans text-red-500">
{t("profile.errorWithMessage", { error: profileError })}
</Text>
)}
</View>
<Label className="text-base font-dmsans-medium">
{t("profile.fullNameLabel")}
</Label>
<View className="h-2" />
<Input
placeholder={t("profile.fullNamePlaceholder")}
value={editedProfile.fullName}
onChangeText={(text) =>
setEditedProfile((prev) => ({ ...prev, fullName: text }))
}
containerClassName="w-full"
borderClassName="border-[#D9DBE9] bg-white"
placeholderColor="#7E7E7E"
textClassName="text-[#000] text-sm"
/>
<View className="h-2" />
<Label className="text-base font-dmsans-medium">
{t("profile.addressLabel")}
</Label>
<View className="h-2" />
<Input
placeholder={t("profile.addressPlaceholder")}
value={editedProfile.address}
onChangeText={(text) =>
setEditedProfile((prev) => ({ ...prev, address: text }))
}
containerClassName="w-full"
borderClassName="border-[#D9DBE9] bg-white"
placeholderColor="#7E7E7E"
textClassName="text-[#000] text-sm"
/>
<View className="h-2" />
<Label className="text-base font-dmsans-medium">
{t("profile.phoneLabel")}
</Label>
<View className="h-2" />
<Input
placeholder={t("profile.phonePlaceholder")}
value={editedProfile.phoneNumber}
onChangeText={(text) =>
setEditedProfile((prev) => ({ ...prev, phoneNumber: text }))
}
editable={profile?.signupType !== "phone"}
containerClassName="w-full"
borderClassName="border-[#D9DBE9] bg-white"
placeholderColor="#7E7E7E"
textClassName="text-[#000] text-sm"
keyboardType="phone-pad"
/>
<View className="h-2" />
<Label className="text-base font-dmsans-medium">
{t("profile.emailLabel")}
</Label>
<View className="h-2" />
<Input
placeholder={t("profile.emailPlaceholder")}
value={editedProfile.email}
onChangeText={(text) =>
setEditedProfile((prev) => ({ ...prev, email: text }))
}
editable={profile?.signupType === "phone"}
containerClassName="w-full"
borderClassName="border-[#D9DBE9] bg-white"
placeholderColor="#7E7E7E"
textClassName="text-[#000] text-sm"
/>
<View className="h-4" />
{/* Language Dropdown */}
<Label className="text-base font-dmsans-medium">
{t("profile.languageLabel")}
</Label>
<View className="h-2" />
<Dropdown
value={language}
options={languageOptions}
onSelect={(value) => setLanguage(value as any)}
placeholder={t("profile.languagePlaceholder")}
/>
<View className="h-4" />
{/* Account Number (read-only) */}
<Label className="text-base font-dmsans-medium">
{t("profile.accountNumberLabel")}
</Label>
<View className="h-2" />
<View className="space-y-1">
<TouchableOpacity
activeOpacity={0.8}
onPress={() => {
setPendingDefaultAccountId(defaultAccount?.id || null);
setIsSelectAccountSheetVisible(true);
}}
className={`flex-row items-center justify-between ${
defaultAccount ? "py-1 px-4" : "py-3 px-4"
} rounded-md border border-[#D9DBE9]`}
>
<View className="flex-1 mr-3">
<Text className="text-base font-dmsans text-gray-800">
{defaultAccount
? defaultAccount.bankName
: "Choose account"}
</Text>
{defaultAccount && (
<Text className="text-sm font-dmsans text-gray-500 ">
{defaultAccount.accountNumber}
</Text>
)}
</View>
<ChevronDown size={18} color="#9CA3AF" />
</TouchableOpacity>
{/* {profileAccounts.length > 0 && (
<Text className="text-sm pt-1 font-dmsans-medium text-gray-500">
Tap to change default account or add another.
</Text>
)} */}
</View>
<View className="h-4" />
{/* Username (read-only) with copy icon */}
<Label className="text-base font-dmsans-medium">
{t("profile.usernameLabel")}
</Label>
<View className="h-2" />
<View className="relative">
<Input
value={usernameDisplay}
editable={false}
containerClassName="w-full"
borderClassName="border-[#D9DBE9] bg-white"
placeholderColor="#7E7E7E"
textClassName="text-[#7E7E7E] text-sm pr-12"
/>
<TouchableOpacity
onPress={handleCopyUsername}
className="absolute right-3 top-1/2 -translate-y-1/2"
style={{ transform: [{ translateY: -12 }] }}
activeOpacity={0.6}
>
<Copy size={20} color="#9CA3AF" />
</TouchableOpacity>
</View>
</View>
</View>
</ScrollView>
<View className="flex flex-col pt-5 px-5 pb-6">
<View className="flex-row mb-4">
<View className="flex-1 mr-2">
<Button
className="bg-primary rounded-3xl"
onPress={handleUpdateProfile}
disabled={updateLoading}
>
<Text className="font-dmsans">
{updateLoading
? t("profile.savingButton")
: t("profile.saveButton")}
</Text>
</Button>
</View>
<View className="flex-1 ml-2">
<Button
className="bg-gray-500 rounded-3xl"
onPress={handleCancel}
disabled={updateLoading}
>
<Text className="font-dmsans text-white">
{t("profile.cancelButton")}
</Text>
</Button>
</View>
</View>
</View>
<BottomSheet
visible={isSelectAccountSheetVisible}
onClose={() => setIsSelectAccountSheetVisible(false)}
maxHeightRatio={0.9}
>
<View className="mb-4">
<Text className="text-xl font-dmsans-bold text-primary text-center">
Choose Account
</Text>
</View>
{profileAccounts.length === 0 ? (
<View className="flex-1 items-center justify-center mb-6 px-5">
<Text className="text-base font-dmsans text-gray-600 text-center">
You have not added any accounts yet.
</Text>
</View>
) : (
<View className="mb-4 space-y-2">
{profileAccounts.map((account) => (
<TouchableOpacity
key={account.id}
activeOpacity={0.8}
onPress={() => {
setPendingDefaultAccountId(account.id);
}}
className={`flex-row items-center justify-between py-1 px-4 rounded-md mb-2 border ${
(
pendingDefaultAccountId
? pendingDefaultAccountId === account.id
: account.isDefault
)
? "border-primary bg-primary/5"
: "border-[#D9DBE9] bg-white"
}`}
>
<View className="flex-1 mr-3">
<Text className="text-base font-dmsans text-gray-800">
{account.bankName}
</Text>
<Text className="text-sm font-dmsans text-gray-500">
{account.accountNumber}
</Text>
</View>
{account.isDefault && (
<View className="px-3 py-1 rounded-full bg-primary">
<Text className="text-xs font-dmsans-medium text-white">
Default
</Text>
</View>
)}
</TouchableOpacity>
))}
</View>
)}
{profileAccounts.length > 0 && (
<Button
className="bg-primary rounded-3xl w-full mt-2"
onPress={handleConfirmDefaultAccount}
disabled={
!pendingDefaultAccountId ||
pendingDefaultAccountId === defaultAccount?.id
}
>
<Text className="font-dmsans text-white">Save Default</Text>
</Button>
)}
<Button
className="bg-primary rounded-3xl w-full mt-2"
onPress={handleStartAddAccount}
disabled={profileAccounts.length >= 5}
>
<Text className="font-dmsans text-white">Add Account</Text>
</Button>
{profileAccounts.length >= 5 && (
<Text className="mt-2 text-xs font-dmsans text-gray-500 text-center">
You can link up to 5 accounts.
</Text>
)}
</BottomSheet>
<BottomSheet
visible={isAddingAccount}
onClose={() => setIsAddingAccount(false)}
maxHeightRatio={0.9}
>
<View className="mb-4">
<Text className="text-xl font-dmsans-bold text-primary text-center">
Add Account
</Text>
</View>
<View className="mb-4">
<Text className="text-base font-dmsans text-black mb-2">Bank</Text>
<View className="flex-row flex-wrap justify-between">
{PROFILE_BANK_OPTIONS.map((bank) => {
const isSelected = selectedBank === bank.id;
const initials = bank.name
.split(" ")
.map((part) => part[0])
.join("")
.toUpperCase()
.slice(0, 2);
return (
<TouchableOpacity
key={bank.id}
activeOpacity={0.8}
onPress={() => handleSelectBank(bank.id)}
className={`items-center justify-between px-3 py-4 mb-3 rounded-2xl border ${
isSelected
? "border-primary bg-primary/5"
: "border-gray-200 bg-white"
}`}
style={{ width: "30%" }}
>
<View className="w-10 h-10 mb-2 rounded-full bg-primary/10 items-center justify-center">
<Text className="text-primary font-dmsans-bold text-sm">
{initials}
</Text>
</View>
<Text
className="text-center text-xs font-dmsans text-gray-800"
numberOfLines={2}
>
{bank.name}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
<View className="mb-4">
<Text className="text-base font-dmsans text-black mb-2">
{accountLabel}
</Text>
<Input
placeholder={accountPlaceholder}
value={accountNumberInput}
onChangeText={(text) =>
setAccountNumberInput(text.replace(/[^0-9]/g, ""))
}
containerClassName="w-full mb-4"
borderClassName="border-[#E5E7EB] bg-white rounded-[4px]"
placeholderColor="#9CA3AF"
textClassName="text-[#111827] text-sm"
keyboardType="number-pad"
/>
</View>
<Button
className="bg-primary rounded-3xl w-full"
onPress={handleSaveAccount}
disabled={
!selectedBank ||
!accountNumberInput.trim() ||
profileAccounts.length >= 5 ||
savingAccount
}
>
{savingAccount ? (
<ActivityIndicator color="#ffffff" />
) : (
<Text className="font-dmsans text-white">Save Account</Text>
)}
</Button>
</BottomSheet>
<ModalToast
visible={toastVisible}
title={toastTitle}
description={toastDescription}
variant={toastVariant}
/>
</ScreenWrapper>
);
}

View File

@ -0,0 +1,500 @@
import React, { useEffect, useRef, useState } from "react";
import {
View,
Text,
Image,
ScrollView,
ActivityIndicator,
TouchableOpacity,
Dimensions,
} from "react-native";
import ScreenWrapper from "~/components/ui/ScreenWrapper";
import { Button } from "~/components/ui/button";
import { Icons } from "~/assets/icons";
import { Share } from "react-native";
import ModalToast from "~/components/ui/toast";
import { useTranslation } from "react-i18next";
import { router, useLocalSearchParams } from "expo-router";
import BackButton from "~/components/ui/backButton";
import { useAuthWithProfile } from "~/lib/hooks/useAuthWithProfile";
import { EventService, type EventDto } from "~/lib/services/eventService";
import BottomSheet from "~/components/ui/bottomSheet";
import { ROUTES } from "~/lib/routes";
import { awardPoints } from "~/lib/services/pointsService";
export default function EventDetailScreen() {
const { t } = useTranslation();
const params = useLocalSearchParams<{ id?: string }>();
const { user } = useAuthWithProfile();
const [event, setEvent] = useState<EventDto | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [currentImageIndex, setCurrentImageIndex] = useState(0);
const [selectedTierId, setSelectedTierId] = useState<string | null>(null);
const [toastVisible, setToastVisible] = useState(false);
const [toastTitle, setToastTitle] = useState("");
const [toastDescription, setToastDescription] = useState<
string | undefined
>();
const [toastVariant, setToastVariant] = useState<
"success" | "error" | "warning" | "info"
>("info");
const toastTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const screenWidth = Dimensions.get("window").width;
const [buySheetVisible, setBuySheetVisible] = useState(false);
const [ticketCount, setTicketCount] = useState(1);
const showToast = (
title: string,
description?: string,
variant: "success" | "error" | "warning" | "info" = "info"
) => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
setToastTitle(title);
setToastDescription(description);
setToastVariant(variant);
setToastVisible(true);
toastTimeoutRef.current = setTimeout(() => {
setToastVisible(false);
toastTimeoutRef.current = null;
}, 2500);
};
const handleShare = async () => {
try {
await Share.share({
message: t("eventdetail.shareMessage"),
});
try {
await awardPoints("share_event");
} catch (error) {
console.warn("[EventDetail] Failed to award share event points", error);
}
} catch (error) {
console.log("Error sharing event:", error);
showToast(
t("eventdetail.toastErrorTitle"),
t("eventdetail.toastShareError"),
"error"
);
}
};
useEffect(() => {
let cancelled = false;
const loadEvent = async () => {
if (!user || !params.id) return;
setLoading(true);
setError(null);
try {
const token = await user.getIdToken();
const eventData = await EventService.getEventById(
token,
String(params.id)
);
if (!cancelled) {
setEvent(eventData);
// Debug images and description to verify response
console.log("[EventDetail] event images", eventData?.images);
console.log(
"[EventDetail] event description",
eventData?.description
);
}
} catch (err) {
if (!cancelled) {
setError("Failed to load event details");
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
loadEvent();
return () => {
cancelled = true;
};
}, [user, params.id]);
const fallbackImage =
"https://images.pexels.com/photos/1190297/pexels-photo-1190297.jpeg?auto=compress&cs=tinysrgb&w=800";
const images =
event?.images && event.images.length > 0 ? event.images : [fallbackImage];
const startDate = event?.startDate ? new Date(event.startDate) : null;
const endDate = event?.endDate ? new Date(event.endDate) : null;
const formatEventDateRange = (start: Date, end: Date) => {
// Format like: Sat, Jan 10, 2026 at 6:00 PM 1:00 AM (EAT)
const datePart = start.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
});
const startTime = start.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
});
const endTime = end.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
});
return `${datePart} at ${startTime} ${endTime} (EAT)`;
};
const formattedDate =
startDate && endDate
? formatEventDateRange(startDate, endDate)
: t("eventdetail.dateTime");
const handleBuy = () => {
if (!event || !selectedTierId) {
showToast(
"Ticket selection required",
"Please select a ticket tier first",
"error"
);
return;
}
const tier = event.ticketTiers?.find((t: any) => t.id === selectedTierId);
if (!tier) {
showToast(
"Ticket selection required",
"Please select a ticket tier first",
"error"
);
return;
}
console.log("[EventDetail] Buy pressed", {
eventId: event.id,
selectedTierId,
});
setTicketCount(1);
setBuySheetVisible(true);
};
const handleConfirmBuy = () => {
if (!event || !selectedTierId) {
showToast(
"Ticket selection required",
"Please select a ticket tier first",
"error"
);
return;
}
const tier = event.ticketTiers?.find((t: any) => t.id === selectedTierId);
if (!tier) {
showToast(
"Ticket selection required",
"Please select a ticket tier first",
"error"
);
return;
}
const priceNumber = Number(tier.price || 0);
const amountInCents = Math.round(priceNumber * 100 * ticketCount);
setBuySheetVisible(false);
router.push({
pathname: ROUTES.CHECKOUT,
params: {
amount: String(amountInCents),
type: "event_ticket",
ticketTierName: tier.name,
ticketTierPrice: priceNumber.toFixed(2),
eventName: event.name,
eventId: event.id,
ticketTierId: tier.id,
ticketCount: String(ticketCount),
},
});
};
return (
<ScreenWrapper edges={[]}>
<View className="flex-1 bg-white">
<ScrollView
className="flex-1"
contentContainerStyle={{ paddingBottom: 32 }}
showsVerticalScrollIndicator={false}
>
<View className="">
<BackButton />
<Text className="mt-4 text-lg px-4 font-dmsans-bold text-[#0F7B4A]">
{event?.name ?? t("eventdetail.title")}
</Text>
</View>
<View className="px-4 pt-4">
{loading && (
<View className="flex items-center justify-center py-8">
<ActivityIndicator size="small" color="#0F7B4A" />
<Text className="mt-2 text-gray-500 font-dmsans text-sm">
Loading event details...
</Text>
</View>
)}
{!loading && error && (
<View className="flex items-center justify-center py-8">
<Text className="text-red-500 font-dmsans text-sm mb-2">
{error}
</Text>
</View>
)}
{!loading && !error && (
<>
<Text className="text-sm font-dmsans text-[#4B5563] mb-4 leading-5">
{event?.description ?? t("eventdetail.description")}
</Text>
<Text className="text-sm font-dmsans text-[#111827] mb-1">
{event?.venue ?? t("eventdetail.location")}
</Text>
<Text className="text-sm font-dmsans-bold text-[#0F7B4A] mb-4">
{formattedDate}
</Text>
<View className="w-full mb-4">
<ScrollView
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
snapToInterval={screenWidth}
decelerationRate="fast"
onMomentumScrollEnd={(e) => {
const { contentOffset, layoutMeasurement } =
e.nativeEvent;
const index = Math.round(
contentOffset.x / layoutMeasurement.width
);
setCurrentImageIndex(index);
}}
>
{images.map((img, index) => (
<View
key={index}
style={{ width: screenWidth, height: 192 }}
className="rounded-[4px] overflow-hidden bg-gray-300"
>
<Image
source={{ uri: img }}
style={{ width: "100%", height: "100%" }}
resizeMode="cover"
/>
</View>
))}
</ScrollView>
<View className="flex-row justify-center mt-2 space-x-1">
{images.map((_, index) => (
<View
key={index}
className={`w-2 h-2 mr-1 rounded-full ${
index === currentImageIndex
? "bg-[#0F7B4A]"
: "bg-gray-300"
}`}
/>
))}
</View>
</View>
<View className="flex-row items-center mb-6">
<View className="flex-row -space-x-2 mr-3">
{[0, 1, 2, 3, 4].map((i) => (
<View
key={i}
className="w-8 h-8 rounded-full overflow-hidden border border-white -ml-2 first:ml-0"
>
<Image
source={Icons.profileImage}
style={{ width: 28, height: 28 }}
resizeMode="cover"
/>
</View>
))}
</View>
<Text className="text-xs font-dmsans text-[#4B5563]">
{t("eventdetail.peopleComing")}
</Text>
</View>
{event?.ticketTiers && event.ticketTiers.length > 0 && (
<View className="flex flex-col gap-3 mb-4 pb-2">
{event.ticketTiers.map((tier: any) => {
const isSelected = tier.id === selectedTierId;
return (
<TouchableOpacity
key={tier.id}
activeOpacity={0.9}
onPress={() => setSelectedTierId(tier.id)}
>
<View
className={`flex-row items-center justify-between rounded-[8px] px-4 py-4 ${
isSelected ? "bg-[#E9F9F0]" : "bg-[#FFF4E5]"
}`}
>
<View className="flex-row items-center">
<View className="w-5 h-5 rounded-full border border-[#FFB668] items-center justify-center mr-3">
{isSelected && (
<View className="w-2.5 h-2.5 rounded-full bg-[#0F7B4A]" />
)}
</View>
<Text className="text-sm font-dmsans text-[#374151]">
{tier.name}
</Text>
</View>
<Text className="text-sm font-dmsans-medium text-[#0F7B4A]">
${tier.price}
</Text>
</View>
</TouchableOpacity>
);
})}
</View>
)}
</>
)}
</View>
</ScrollView>
</View>
<View className="px-4 pb-4 pt-2 border-t border-gray-100 bg-white">
<View className="flex flex-col gap-3">
<Button
className="h-11 rounded-full bg-[#0F7B4A]"
disabled={!selectedTierId}
onPress={handleBuy}
>
<Text className="text-white font-dmsans-medium text-sm">
{t("eventdetail.buyButton")}
</Text>
</Button>
<Button
className="h-11 rounded-full bg-[#FFB668]"
onPress={handleShare}
>
<Text className="text-white font-dmsans-medium text-sm">
{t("eventdetail.shareButton")}
</Text>
</Button>
</View>
</View>
<ModalToast
visible={toastVisible}
title={toastTitle}
description={toastDescription}
variant={toastVariant}
/>
<BottomSheet
visible={buySheetVisible}
onClose={() => setBuySheetVisible(false)}
maxHeightRatio={0.5}
>
{event && selectedTierId && (
<View>
<Text className="text-lg font-dmsans-bold text-black mb-1">
Confirm tickets
</Text>
<Text className="text-xs font-dmsans text-gray-500 mb-4">
{event.name}
</Text>
{(() => {
const tier = event.ticketTiers?.find(
(t: any) => t.id === selectedTierId
);
if (!tier) return null;
const priceNumber = Number(tier.price || 0);
const total = priceNumber * ticketCount;
return (
<>
<View className="flex-row justify-between items-center mb-4">
<View>
<Text className="text-base font-dmsans-medium text-black">
{tier.name}
</Text>
<Text className="text-xs font-dmsans text-gray-500 mt-1">
${priceNumber.toFixed(2)} per ticket
</Text>
</View>
<View className="items-end">
<Text className="text-xs font-dmsans text-gray-500 mb-1">
Total
</Text>
<Text className="text-lg font-dmsans-bold text-[#0F7B4A]">
${total.toFixed(2)}
</Text>
</View>
</View>
<View className="flex-row items-center justify-between mb-6">
<Text className="text-sm font-dmsans text-black">
Ticket count
</Text>
<View className="flex-row items-center">
<TouchableOpacity
className="w-8 h-8 rounded-full border border-gray-300 items-center justify-center"
onPress={() =>
setTicketCount((c) => Math.max(1, c - 1))
}
>
<Text className="text-lg font-dmsans text-gray-700">
-
</Text>
</TouchableOpacity>
<Text className="mx-4 text-base font-dmsans text-black">
{ticketCount}
</Text>
<TouchableOpacity
className="w-8 h-8 rounded-full border border-gray-300 items-center justify-center"
onPress={() => setTicketCount((c) => c + 1)}
>
<Text className="text-lg font-dmsans text-gray-700">
+
</Text>
</TouchableOpacity>
</View>
</View>
<Button
className="h-11 rounded-full bg-[#0F7B4A]"
onPress={handleConfirmBuy}
>
<Text className="text-white font-dmsans-medium text-sm">
Confirm purchase
</Text>
</Button>
</>
);
})()}
</View>
)}
</BottomSheet>
</ScreenWrapper>
);
}

View File

@ -0,0 +1,82 @@
import React from "react";
import { View, Text, TouchableOpacity, Image } from "react-native";
import QRCode from "react-native-qrcode-svg";
import ScreenWrapper from "~/components/ui/ScreenWrapper";
import { Button } from "~/components/ui/button";
import { router, useLocalSearchParams } from "expo-router";
import { useTranslation } from "react-i18next";
export default function EventQRScreen() {
const params = useLocalSearchParams<{
code?: string;
packageName?: string;
qrImage?: string;
}>();
const { t } = useTranslation();
const code = params.code || "AMB-123-2025";
const packageName = params.packageName || "Package 2";
const qrImage = params.qrImage as string | undefined;
console.log("qrimage", params.qrImage);
const handleClose = () => {
router.back();
};
const handlePrint = () => {
router.back();
};
return (
<ScreenWrapper edges={[]}>
<View className="flex-1 bg-white">
{/* QR image */}
<View className="flex-1 items-center justify-center px-5">
<View className="items-center justify-center">
{qrImage ? (
<Image
source={{ uri: qrImage }}
style={{ width: 200, height: 200 }}
resizeMode="contain"
/>
) : (
<QRCode
value={code}
size={200}
color="#0F7B4A"
backgroundColor="transparent"
/>
)}
</View>
<View className="mt-6 items-center">
<Text className="text-sm font-dmsans text-[#6B7280]">
{packageName}
</Text>
</View>
</View>
{/* Bottom buttons */}
<View className="w-full flex flex-col px-5 pb-10 gap-4">
<Button
className="h-13 rounded-full bg-[#FFB668]"
onPress={handlePrint}
>
<Text className="text-white font-dmsans-bold text-base">
{t("eventqrscreen.printButton")}
</Text>
</Button>
<Button
className="h-13 rounded-full bg-[#0F7B4A]"
onPress={handleClose}
>
<Text className="text-white font-dmsans-bold text-base">
{t("eventqrscreen.goBackButton")}
</Text>
</Button>
</View>
</View>
</ScreenWrapper>
);
}

View File

@ -0,0 +1,388 @@
import React, { useMemo, useState } from "react";
import {
View,
Text,
Image,
ScrollView,
TouchableOpacity,
ActivityIndicator,
} from "react-native";
import { ChevronRight } from "lucide-react-native";
import ScreenWrapper from "~/components/ui/ScreenWrapper";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Icons } from "~/assets/icons";
import TopBar from "~/components/ui/topBar";
import BottomSheet from "~/components/ui/bottomSheet";
import { router } from "expo-router";
import { ROUTES } from "~/lib/routes";
import { useTranslation } from "react-i18next";
import { useEvents } from "~/lib/hooks/useEvents";
import Skeleton from "~/components/ui/skeleton";
export default function EventsScreen() {
const { t } = useTranslation();
const {
data: events,
loading,
error,
refetch,
} = useEvents({
status: "ACTIVE",
limit: 50,
});
const [searchQuery, setSearchQuery] = useState("");
const [filterVisible, setFilterVisible] = useState(false);
const [filterName, setFilterName] = useState("");
const [filterLocation, setFilterLocation] = useState("");
const [dateFilter, setDateFilter] = useState<"all" | "today" | "this_week">(
"all"
);
const normalizedQuery = searchQuery.trim().toLowerCase();
const normalizedFilterName = filterName.trim().toLowerCase();
const normalizedFilterLocation = filterLocation.trim().toLowerCase();
const filteredEvents = useMemo(() => {
if (!events) return [];
return events.filter((event) => {
const name = (event as any).name ?? "";
const venue = (event as any).venue ?? "";
const haystack = `${name} ${venue}`.toLowerCase();
if (normalizedQuery && !haystack.includes(normalizedQuery)) {
return false;
}
if (
normalizedFilterName &&
!name.toLowerCase().includes(normalizedFilterName)
) {
return false;
}
if (
normalizedFilterLocation &&
!venue.toLowerCase().includes(normalizedFilterLocation)
) {
return false;
}
if (dateFilter !== "all" && (event as any).startDate) {
const start = new Date((event as any).startDate);
const now = new Date();
const startDay = new Date(
start.getFullYear(),
start.getMonth(),
start.getDate()
);
const today = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate()
);
if (dateFilter === "today") {
if (startDay.getTime() !== today.getTime()) return false;
} else if (dateFilter === "this_week") {
const endOfWeek = new Date(today);
endOfWeek.setDate(today.getDate() + 7);
if (startDay < today || startDay > endOfWeek) return false;
}
}
return true;
});
}, [
events,
normalizedQuery,
normalizedFilterName,
normalizedFilterLocation,
dateFilter,
]);
return (
<ScreenWrapper edges={[]}>
<View className="flex-1 bg-white">
<ScrollView
className="flex-1"
contentContainerStyle={{ paddingBottom: 32 }}
showsVerticalScrollIndicator={false}
>
<TopBar />
<View className="px-5 pt-6">
<Text className="text-lg font-dmsans-medium text-[#0F7B4A] mb-1">
{t("events.title")}
</Text>
<Text className="text-base font-dmsans text-gray-500 mb-6">
{t("events.subtitle")}
</Text>
<View className="mb-4">
<Input
placeholderText={t("events.searchPlaceholder")}
containerClassName="w-full"
borderClassName="border-[#E5E7EB] bg-white rounded-[4px]"
placeholderColor="#9CA3AF"
textClassName="text-[#111827] text-sm"
value={searchQuery}
onChangeText={setSearchQuery}
/>
</View>
<View className="flex flex-col gap-4 mb-10">
<Button
className="h-11 rounded-[4px] bg-[#0F7B4A]"
onPress={() => setFilterVisible(true)}
>
<View className="flex-row items-center justify-center space-x-2">
<Image
source={Icons.filterBar}
style={{ width: 18, height: 18 }}
resizeMode="contain"
/>
<Text className="text-white ml-2 font-dmsans-medium text-sm">
{t("events.filterButton")}
</Text>
</View>
</Button>
<Button
className="h-11 rounded-[4px] bg-[#FFB668]"
onPress={() => router.push(ROUTES.MY_TICKETS)}
>
<View className="flex-row items-center justify-center space-x-2">
<Image
source={Icons.ticketIcon}
style={{ width: 18, height: 18 }}
resizeMode="contain"
/>
<Text className="text-white ml-2 font-dmsans-medium text-sm">
{t("events.myTicketsButton")}
</Text>
</View>
</Button>
</View>
<Text className="text-lg font-dmsans-medium text-[#0F7B4A] mb-4">
{t("events.featuredTitle")}
</Text>
{loading && (
<View className="space-y-4 mb-6">
{Array.from({ length: 3 }).map((_, index) => (
<View
key={index}
className="bg-[#E9F9F0] rounded-[4px] p-6 mb-2"
>
<View className="w-full mb-4">
<Skeleton width="100%" height={176} radius={4} />
</View>
<View className="space-y-2">
<Skeleton width="60%" height={14} radius={4} />
<Skeleton width="40%" height={12} radius={4} />
</View>
<View className="mt-4 flex-row items-center justify-between">
<Skeleton width="55%" height={10} radius={4} />
<Skeleton width={26} height={26} radius={13} />
</View>
</View>
))}
</View>
)}
{!loading && error && (
<View className="flex items-center justify-center py-8">
<Text className="text-red-500 font-dmsans text-sm mb-2">
Failed to load events
</Text>
<Button
className="h-9 px-4 bg-primary rounded-full"
onPress={() => refetch()}
>
<Text className="text-white font-dmsans text-xs">Retry</Text>
</Button>
</View>
)}
{!loading && !error && events && events.length === 0 && (
<View className="flex items-center justify-center py-8">
<Text className="text-gray-500 font-dmsans text-sm">
No events found.
</Text>
</View>
)}
{!loading &&
!error &&
events &&
events.length > 0 &&
filteredEvents.length === 0 && (
<View className="flex items-center justify-center py-8">
<Text className="text-gray-500 font-dmsans text-sm">
No events match your search.
</Text>
</View>
)}
{!loading &&
!error &&
filteredEvents &&
filteredEvents.length > 0 && (
<View className="space-y-4 mb-6">
{filteredEvents.map((event) => {
const heroImage =
event.images && event.images.length > 0
? event.images[0]
: "https://images.pexels.com/photos/1190297/pexels-photo-1190297.jpeg?auto=compress&cs=tinysrgb&w=800";
const startDate = new Date(event.startDate);
const formattedDate = startDate.toLocaleDateString();
return (
<TouchableOpacity
key={event.id}
activeOpacity={0.9}
onPress={() =>
router.push({
pathname: ROUTES.EVENT_DETAIL,
params: { id: event.id },
})
}
>
<View className="bg-[#E9F9F0] rounded-[4px] p-6 mb-2">
<View className="w-full h-44 rounded-[4px] overflow-hidden mb-4 bg-gray-300">
<Image
source={{ uri: heroImage }}
style={{ width: "100%", height: "100%" }}
resizeMode="cover"
/>
</View>
<Text className="text-sm font-dmsans-bold text-[#FFB668] mb-1">
{event.name}
</Text>
<Text className="text-sm font-dmsans text-[#105D38] mb-4">
{event.venue}
</Text>
<View className="flex-row items-center justify-between">
<Text className="text-xs font-dmsans text-[#111827]">
<Text className="font-dmsans-bold">
{t("events.ticketCountPrefix")}
</Text>
{event.organizer?.name || ""} -
<Text className="italic"> {formattedDate}</Text>
</Text>
<ChevronRight size={26} color="#FFB668" />
</View>
</View>
</TouchableOpacity>
);
})}
</View>
)}
</View>
</ScrollView>
</View>
<BottomSheet
visible={filterVisible}
onClose={() => setFilterVisible(false)}
maxHeightRatio={0.7}
>
<Text className="text-lg font-dmsans-bold text-black mb-1">
Filter events
</Text>
<Text className="text-xs font-dmsans text-gray-500 mb-4">
Filter by date, name and location
</Text>
<Text className="text-base font-dmsans-medium text-black mb-2">
Date
</Text>
<View className="flex-row mb-4">
{[
{ key: "all", label: "All dates" },
{ key: "today", label: "Today" },
{ key: "this_week", label: "This week" },
].map((option) => (
<TouchableOpacity
key={option.key}
onPress={() =>
setDateFilter(option.key as "all" | "today" | "this_week")
}
className={`px-3 py-1 rounded-full mr-2 border ${
dateFilter === option.key
? "bg-[#0F7B4A] border-[#0F7B4A]"
: "bg-white border-gray-300"
}`}
>
<Text
className={`text-xs font-dmsans ${
dateFilter === option.key ? "text-white" : "text-gray-700"
}`}
>
{option.label}
</Text>
</TouchableOpacity>
))}
</View>
<Text className="text-base font-dmsans-medium text-black mb-2">
Event name
</Text>
<View className="mb-3">
<Input
value={filterName}
onChangeText={setFilterName}
placeholderText="Search by event name"
placeholderColor="#9CA3AF"
containerClassName="w-full"
borderClassName="border-[#E5E7EB] bg-white rounded-[4px]"
textClassName="text-[#111827] text-sm"
/>
</View>
<Text className="text-base font-dmsans-medium text-black mb-2 mt-2">
Location
</Text>
<View className="mb-4">
<Input
value={filterLocation}
onChangeText={setFilterLocation}
placeholderText="Search by location"
placeholderColor="#9CA3AF"
containerClassName="w-full"
borderClassName="border-[#E5E7EB] bg-white rounded-[4px]"
textClassName="text-[#111827] text-sm"
/>
</View>
<View className="flex-row justify-between mt-4">
<TouchableOpacity
onPress={() => {
setFilterName("");
setFilterLocation("");
setDateFilter("all");
}}
>
<Text className="text-sm font-dmsans text-primary">Clear</Text>
</TouchableOpacity>
<Button
className="h-9 px-4 rounded-full bg-[#0F7B4A]"
onPress={() => setFilterVisible(false)}
>
<Text className="text-xs font-dmsans text-white">
Apply filters
</Text>
</Button>
</View>
</BottomSheet>
</ScreenWrapper>
);
}

View File

@ -0,0 +1,181 @@
import React, { useState } from "react";
import { ScrollView, View, TouchableOpacity, Linking } from "react-native";
import { Text } from "~/components/ui/text";
import { Button } from "~/components/ui/button";
import BackButton from "~/components/ui/backButton";
import ScreenWrapper from "~/components/ui/ScreenWrapper";
import { ChevronDown, ChevronUp, Mail, Phone } from "lucide-react-native";
import { router } from "expo-router";
import { ROUTES } from "~/lib/routes";
interface FAQItem {
question: string;
answer: string;
}
const faqData: FAQItem[] = [
{
question: "How do I add money to my wallet?",
answer:
"You can add money to your wallet by linking a debit/credit card or bank account. Go to Home > Add Cash, select your payment method, enter the amount, and confirm the transaction.",
},
{
question: "How do I send money to someone?",
answer:
"Tap on 'Request' from the home screen, select a recipient from your contacts or enter their phone number/email, enter the amount, add an optional note, and confirm the transfer.",
},
{
question: "What are the transaction fees?",
answer:
"Standard transfers to other Amba users are free. Cash out to bank accounts may incur a small fee depending on the amount and processing time. Check the transaction details before confirming.",
},
{
question: "How long do transfers take?",
answer:
"Transfers to other Amba users are instant. Bank transfers typically take 1-3 business days depending on your bank and the transfer method selected.",
},
{
question: "Is my money safe?",
answer:
"Yes, we use bank-level encryption and security measures to protect your funds and personal information. Your money is held in secure, FDIC-insured accounts.",
},
{
question: "How do I cash out to my bank?",
answer:
"Go to Home > Cash Out, select your linked bank account, enter the amount you want to withdraw, and confirm. The money will be transferred to your bank within 1-3 business days.",
},
{
question: "Can I cancel a transaction?",
answer:
"Once a transaction is completed, it cannot be cancelled. However, you can request a refund from the recipient. For disputed transactions, please contact our support team.",
},
{
question: "How do I update my profile information?",
answer:
"Go to Profile > Edit Profile to update your name, email, phone number, and address. Some fields may be restricted based on how you signed up.",
},
];
export default function HelpSupport() {
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
const toggleAccordion = (index: number) => {
setExpandedIndex(expandedIndex === index ? null : index);
};
const handleContactEmail = () => {
Linking.openURL("mailto:support@ambapay.com");
};
const handleContactPhone = () => {
Linking.openURL("tel:+1234567890");
};
const handleViewTerms = () => {
router.push(ROUTES.TERMS);
};
return (
<ScreenWrapper edges={[]}>
<BackButton />
<ScrollView
showsVerticalScrollIndicator={false}
className="flex-1 bg-white"
>
<View className="px-5">
<Text className="text-3xl font-dmsans-bold text-gray-900 mb-2">
Help & Support
</Text>
<Text className="text-base font-dmsans text-gray-500 mb-8">
Find answers to common questions
</Text>
{/* FAQ Section */}
<View className="space-y-3">
{faqData.map((faq, index) => (
<View
key={index}
className="bg-gray-50 rounded-2xl overflow-hidden"
>
<TouchableOpacity
onPress={() => toggleAccordion(index)}
className="p-4 flex-row items-center justify-between"
activeOpacity={0.7}
>
<Text className="text-base font-dmsans-medium text-gray-900 flex-1 pr-3">
{faq.question}
</Text>
{expandedIndex === index ? (
<ChevronUp size={20} color="#9CA3AF" />
) : (
<ChevronDown size={20} color="#9CA3AF" />
)}
</TouchableOpacity>
{expandedIndex === index && (
<View className="px-4 pb-4">
<View className="h-px bg-gray-200 mb-3" />
<Text className="text-sm font-dmsans text-gray-600 leading-6">
{faq.answer}
</Text>
</View>
)}
</View>
))}
</View>
{/* Contact Section */}
<View className="mt-8">
<Text className="text-xl font-dmsans-bold text-gray-900 mb-4">
Still need help?
</Text>
<Text className="text-sm font-dmsans text-gray-500 mb-6">
Our support team is here to assist you
</Text>
<View className="space-y-3">
{/* Email Support */}
<TouchableOpacity
onPress={handleContactEmail}
className="bg-gray-50 rounded-2xl p-4 flex-row items-center"
activeOpacity={0.7}
>
<View className="w-10 h-10 bg-primary/10 rounded-full items-center justify-center mr-3">
<Mail size={20} color="#105D38" />
</View>
<View className="flex-1">
<Text className="text-base font-dmsans-medium text-gray-900">
Email Support
</Text>
<Text className="text-sm font-dmsans text-gray-500">
support@ambapay.com
</Text>
</View>
</TouchableOpacity>
{/* Phone Support */}
<TouchableOpacity
onPress={handleContactPhone}
className="bg-gray-50 rounded-2xl p-4 flex-row items-center"
activeOpacity={0.7}
>
<View className="w-10 h-10 bg-primary/10 rounded-full items-center justify-center mr-3">
<Phone size={20} color="#105D38" />
</View>
<View className="flex-1">
<Text className="text-base font-dmsans-medium text-gray-900">
Phone Support
</Text>
<Text className="text-sm font-dmsans text-gray-500">
+1 (234) 567-890
</Text>
</View>
</TouchableOpacity>
</View>
</View>
</View>
</ScrollView>
</ScreenWrapper>
);
}

View File

@ -0,0 +1,569 @@
import React from "react";
import {
View,
Text,
FlatList,
Image,
ScrollView,
TouchableOpacity,
} from "react-native";
import { router } from "expo-router";
import { ROUTES } from "~/lib/routes";
import TransactionCard from "~/components/ui/transactionCard";
import { useAuthWithProfile } from "~/lib/hooks/useAuthWithProfile";
import { useTransactions } from "~/lib/hooks/useTransactions";
import ScreenWrapper from "~/components/ui/ScreenWrapper";
import { Icons } from "~/assets/icons";
import { Input } from "~/components/ui/input";
import Skeleton from "~/components/ui/skeleton";
import BottomSheet from "~/components/ui/bottomSheet";
import ModalToast from "~/components/ui/toast";
import { useTranslation } from "react-i18next";
import BackButton from "~/components/ui/backButton";
type TransactionTypeFilter = "all" | "incoming" | "outgoing";
interface DateRange {
startDate: Date | null;
endDate: Date | null;
}
const daysOfWeek = ["S", "M", "T", "W", "T", "F", "S"];
const stripTime = (date: Date) => {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
return d;
};
const isSameDay = (a: Date | null, b: Date | null) => {
if (!a || !b) return false;
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
};
const SimpleCalendarRange: React.FC<{
value: DateRange;
onChange: (range: DateRange) => void;
}> = ({ value, onChange }) => {
const initialBase = value.startDate || new Date();
const [currentMonth, setCurrentMonth] = React.useState(
() => new Date(initialBase.getFullYear(), initialBase.getMonth(), 1)
);
const monthLabel = currentMonth.toLocaleString("default", {
month: "short",
year: "numeric",
});
const days: (Date | null)[] = React.useMemo(() => {
const year = currentMonth.getFullYear();
const month = currentMonth.getMonth();
const firstDayOfMonth = new Date(year, month, 1);
const firstWeekday = firstDayOfMonth.getDay();
const numDays = new Date(year, month + 1, 0).getDate();
const arr: (Date | null)[] = [];
for (let i = 0; i < firstWeekday; i++) {
arr.push(null);
}
for (let d = 1; d <= numDays; d++) {
arr.push(new Date(year, month, d));
}
return arr;
}, [currentMonth]);
const handleSelectDay = (day: Date) => {
const { startDate, endDate } = value;
if (!startDate || (startDate && endDate)) {
onChange({ startDate: stripTime(day), endDate: null });
return;
}
const start = stripTime(startDate);
const selected = stripTime(day);
if (selected < start) {
onChange({ startDate: selected, endDate: start });
} else {
onChange({ startDate: start, endDate: selected });
}
};
const inRange = (day: Date) => {
const { startDate, endDate } = value;
if (!startDate || !endDate) return false;
const d = stripTime(day);
const s = stripTime(startDate);
const e = stripTime(endDate);
return d > s && d < e;
};
const goMonth = (offset: number) => {
setCurrentMonth(
(prev) => new Date(prev.getFullYear(), prev.getMonth() + offset, 1)
);
};
return (
<View className="mt-3">
<View className="flex-row justify-between items-center mb-3">
<TouchableOpacity
onPress={() => goMonth(-1)}
className="w-10 h-10 rounded-full bg-white items-center justify-center"
>
<Text className="text-sm font-dmsans">{"<"}</Text>
</TouchableOpacity>
<Text className="text-sm font-dmsans-medium text-black">
{monthLabel}
</Text>
<TouchableOpacity
onPress={() => goMonth(1)}
className="w-10 h-10 rounded-full bg-white items-center justify-center"
>
<Text className="text-sm font-dmsans">{">"}</Text>
</TouchableOpacity>
</View>
<View className="flex-row mb-1">
{daysOfWeek.map((d, idx) => (
<View
key={`${d}-${idx}`}
style={{ width: `${100 / 7}%` }}
className="items-center"
>
<Text className="text-[10px] text-gray-400 font-dmsans">{d}</Text>
</View>
))}
</View>
<View className="flex-row flex-wrap">
{days.map((day, index) => {
if (!day) {
return (
<View
key={index}
style={{ width: `${100 / 7}%`, aspectRatio: 1 }}
/>
);
}
const selectedStart = isSameDay(day, value.startDate);
const selectedEnd = isSameDay(day, value.endDate);
const between = inRange(day);
const isSelected = selectedStart || selectedEnd;
const bgColor = isSelected
? "#0F7B4A"
: between
? "rgba(15,123,74,0.08)"
: "transparent";
const textColor = isSelected ? "#ffffff" : "#111827";
return (
<TouchableOpacity
key={index}
onPress={() => handleSelectDay(day)}
style={{ width: `${100 / 7}%`, aspectRatio: 1 }}
className="items-center justify-center"
>
<View
style={{
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: bgColor,
alignItems: "center",
justifyContent: "center",
}}
>
<Text
style={{ color: textColor, fontSize: 12 }}
className="font-dmsans"
>
{day.getDate()}
</Text>
</View>
</TouchableOpacity>
);
})}
</View>
</View>
);
};
export default function History() {
const { user } = useAuthWithProfile();
const {
transactions,
loading: transactionsLoading,
error: transactionsError,
} = useTransactions(user?.uid);
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = React.useState("");
const [filterVisible, setFilterVisible] = React.useState(false);
const [dateRange, setDateRange] = React.useState<DateRange>({
startDate: null,
endDate: null,
});
const [typeFilter, setTypeFilter] =
React.useState<TransactionTypeFilter>("all");
const [toastVisible, setToastVisible] = React.useState(false);
const [toastTitle, setToastTitle] = React.useState("");
const [toastDescription, setToastDescription] = React.useState<
string | undefined
>(undefined);
const [toastVariant, setToastVariant] = React.useState<
"success" | "error" | "warning" | "info"
>("info");
const toastTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(
null
);
const showToast = (
title: string,
description?: string,
variant: "success" | "error" | "warning" | "info" = "info"
) => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
setToastTitle(title);
setToastDescription(description);
setToastVariant(variant);
setToastVisible(true);
toastTimeoutRef.current = setTimeout(() => {
setToastVisible(false);
toastTimeoutRef.current = null;
}, 2500);
};
React.useEffect(() => {
return () => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
};
}, []);
React.useEffect(() => {
if (transactionsError) {
showToast(
t("history.toastErrorTitle"),
transactionsError || t("history.errorTitle"),
"error"
);
}
}, [transactionsError, t]);
const filteredTransactions = React.useMemo(() => {
let data = transactions || [];
const query = searchQuery.trim().toLowerCase();
if (query) {
data = data.filter((t) => {
const parts: string[] = [];
if ((t as any).recipientName)
parts.push(String((t as any).recipientName));
if ((t as any).senderName) parts.push(String((t as any).senderName));
if ((t as any).note) parts.push(String((t as any).note));
parts.push(String(t.type));
const haystack = parts.join(" ").toLowerCase();
return haystack.includes(query);
});
}
if (dateRange.startDate || dateRange.endDate) {
const start = dateRange.startDate ? stripTime(dateRange.startDate) : null;
const end = dateRange.endDate ? stripTime(dateRange.endDate) : null;
data = data.filter((t) => {
const created =
t.createdAt instanceof Date
? stripTime(t.createdAt)
: stripTime(new Date(t.createdAt));
if (start && created < start) return false;
if (end && created > end) return false;
return true;
});
}
if (typeFilter !== "all") {
data = data.filter((t) => {
const txType = t.type;
const isOutgoing = txType === "send" || txType === "cash_out";
const isIncoming = txType === "receive" || txType === "add_cash";
if (typeFilter === "outgoing") return isOutgoing;
if (typeFilter === "incoming") return isIncoming;
return true;
});
}
return data;
}, [transactions, searchQuery, dateRange, typeFilter]);
return (
<ScreenWrapper edges={["bottom"]}>
<View className="flex items-center h-full w-full ">
{transactionsLoading ? (
<View className="flex items-center justify-center w-full">
<View className=" ">
<BackButton />
</View>
<View className="flex px-5 space-y-3 w-full">
<View className="flex flex-col space-y-1 items-left">
<Text className="text-xl font-dmsans text-primary">
{t("history.title")}
</Text>
<Text className="text-base font-dmsans text-gray-400">
{t("history.subtitle")}
</Text>
</View>
<View className="flex flex-col gap-4 py-4">
{Array.from({ length: 5 }).map((_, index) => (
<View key={index} className="w-full">
<Skeleton width="100%" height={72} radius={12} />
</View>
))}
</View>
</View>
</View>
) : transactionsError ? (
<View className="flex items-center justify-center w-full">
<View className="">
<BackButton />
</View>
<View className="flex px-5 space-y-3 w-full">
<View className="flex flex-col space-y-1 py-5 items-left">
<Text className="text-xl font-dmsans text-primary">
{t("history.title")}
</Text>
<Text className="text-base font-dmsans text-gray-400">
{t("history.subtitle")}
</Text>
</View>
<View className="flex items-center justify-center py-8">
<Text className="text-red-500 font-dmsans">
{t("history.errorTitle")}
</Text>
<Text className="text-gray-400 font-dmsans-medium text-sm mt-1 text-center px-4">
{transactionsError}
</Text>
</View>
</View>
</View>
) : (
<>
<FlatList
className=""
data={filteredTransactions}
keyExtractor={(item) => item.id}
contentContainerStyle={{ paddingBottom: 30 }}
ListHeaderComponent={
<>
<View className="w-full mb-2">
<BackButton />
</View>
<View className="flex px-5 space-y-3 w-full pb-6">
<View className="flex flex-col space-y-1 py-5 items-left">
<Text className="text-xl font-dmsans text-primary">
{t("history.title")}
</Text>
<Text className="text-base font-dmsans text-gray-400">
{t("history.subtitle")}
</Text>
</View>
<View className="w-full">
<Input
value={searchQuery}
onChangeText={setSearchQuery}
placeholderText={t("history.searchPlaceholder")}
containerClassName="w-full"
borderClassName="border-[#D9DBE9] bg-white"
placeholderColor="#7E7E7E"
textClassName="text-[#000] text-sm"
rightIcon={
<TouchableOpacity
onPress={() => setFilterVisible(true)}
className="p-1"
>
<Image
source={Icons.filterIcon}
style={{
width: 25,
height: 25,
tintColor: "#0F7B4A",
}}
resizeMode="contain"
/>
</TouchableOpacity>
}
/>
</View>
</View>
</>
}
ListEmptyComponent={
<View className="flex items-center justify-center pb-8">
<Text className="text-gray-500 font-dmsans">
{t("history.emptyTitle")}
</Text>
<Text className="text-gray-400 font-dmsans-medium text-sm mt-1 text-center px-4">
{t("history.emptySubtitle")}
</Text>
</View>
}
renderItem={({ item: transaction }) => (
<View className="px-5">
<TransactionCard
transaction={transaction}
onPress={() => {
router.push({
pathname: ROUTES.TRANSACTION_DETAIL,
params: {
transactionId: transaction.id,
amount: transaction.amount.toString(),
type: transaction.type,
recipientName:
transaction.type === "send"
? transaction.recipientName
: transaction.type === "receive"
? transaction.senderName
: transaction.type === "add_cash"
? "Card"
: "Bank",
date: transaction.createdAt.toISOString(),
status: transaction.status,
//@ts-ignore
note: transaction?.note || "",
fromHistory: "true",
},
});
}}
/>
</View>
)}
/>
</>
)}
</View>
<BottomSheet
visible={filterVisible}
onClose={() => setFilterVisible(false)}
maxHeightRatio={0.9}
>
<Text className="text-lg font-dmsans-bold text-black mb-1">
{t("history.filterTitle")}
</Text>
<Text className="text-xs font-dmsans text-gray-500 mb-4">
{t("history.filterSubtitle")}
</Text>
{/* Date range */}
<Text className="text-base font-dmsans-medium text-black mb-2">
{t("history.dateRangeLabel")}
</Text>
<View className="flex-row justify-between mb-2">
<View className="flex-1 mr-2">
<Text className="text-[14px] text-gray-500 font-dmsans mb-1">
{t("history.fromLabel")}
</Text>
<View className="h-9 rounded-[4px] bg-gray-100 px-3 justify-center">
<Text className="text-xs font-dmsans text-gray-800">
{dateRange.startDate
? dateRange.startDate.toLocaleDateString()
: t("history.selectStart")}
</Text>
</View>
</View>
<View className="flex-1 ml-2">
<Text className="text-[14px] text-gray-500 font-dmsans mb-1">
{t("history.toLabel")}
</Text>
<View className="h-9 rounded-[4px] bg-gray-100 px-3 justify-center">
<Text className="text-xs font-dmsans text-gray-800">
{dateRange.endDate
? dateRange.endDate.toLocaleDateString()
: t("history.selectEnd")}
</Text>
</View>
</View>
</View>
<SimpleCalendarRange value={dateRange} onChange={setDateRange} />
<View className="flex-row justify-end mt-2 mb-4">
<TouchableOpacity
onPress={() => setDateRange({ startDate: null, endDate: null })}
>
<Text className="text-[14px] text-primary font-dmsans">
{t("history.clearDates")}
</Text>
</TouchableOpacity>
</View>
{/* Type filter */}
<Text className="text-base font-dmsans-medium text-black mb-4 mt-2">
{t("history.typeLabel")}
</Text>
<View className="flex-row mb-6">
{(
[
{ label: t("history.typeAll"), value: "all" },
{ label: t("history.typeIncoming"), value: "incoming" },
{ label: t("history.typeOutgoing"), value: "outgoing" },
] as { label: string; value: TransactionTypeFilter }[]
).map((option) => {
const active = typeFilter === option.value;
return (
<TouchableOpacity
key={option.value}
onPress={() => setTypeFilter(option.value)}
className={`px-4 py-2 rounded-[4px] mr-2 border ${
active
? "bg-primary border-primary"
: "bg-white border-gray-200"
}`}
>
<Text
className={`text-sm font-dmsans-medium ${
active ? "text-white" : "text-gray-700"
}`}
>
{option.label}
</Text>
</TouchableOpacity>
);
})}
</View>
<View className="w-full mt-2">
<TouchableOpacity
onPress={() => setFilterVisible(false)}
className="h-11 rounded-3xl bg-[#FFB668] items-center justify-center"
>
<Text className="text-white font-dmsans-bold text-sm">
{t("history.applyFilters")}
</Text>
</TouchableOpacity>
</View>
</BottomSheet>
<ModalToast
visible={toastVisible}
title={toastTitle}
description={toastDescription}
variant={toastVariant}
/>
</ScreenWrapper>
);
}

View File

@ -0,0 +1,462 @@
import React, { useState, useRef, useEffect } from "react";
import {
View,
Text,
ScrollView,
TouchableOpacity,
Alert,
ActivityIndicator,
} from "react-native";
import ScreenWrapper from "~/components/ui/ScreenWrapper";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { UploadCloud } from "lucide-react-native";
import Dropdown, { type DropdownOption } from "~/components/ui/dropdown";
import ModalToast from "~/components/ui/toast";
import BackButton from "~/components/ui/backButton";
import { useAuthWithProfile } from "~/lib/hooks/useAuthWithProfile";
import AuthService from "~/lib/services/authServices";
import { uploadKycDocument } from "~/lib/services/kycDocumentService";
import * as ImagePicker from "expo-image-picker";
export default function KycScreen() {
const { user, refreshProfile } = useAuthWithProfile();
const [activeTab, setActiveTab] = useState<"personal" | "business">(
"personal"
);
const [fanNumber, setFanNumber] = useState("");
const [tin, setTin] = useState("");
const [businessType, setBusinessType] = useState("");
const [nationalIdUri, setNationalIdUri] = useState<string | null>(null);
const [nationalIdLabel, setNationalIdLabel] = useState("");
const [businessLicenseUri, setBusinessLicenseUri] = useState<string | null>(
null
);
const [businessLicenseLabel, setBusinessLicenseLabel] = useState("");
const [pickingNationalId, setPickingNationalId] = useState(false);
const [pickingBusinessLicense, setPickingBusinessLicense] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [toastVisible, setToastVisible] = useState(false);
const [toastTitle, setToastTitle] = useState("");
const [toastDescription, setToastDescription] = useState<string | undefined>(
undefined
);
const [toastVariant, setToastVariant] = useState<
"success" | "error" | "warning" | "info"
>("info");
const toastTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const businessTypeOptions: DropdownOption[] = [
{ label: "Retail & E-commerce", value: "Retail & E-commerce" },
{ label: "Food & Beverage", value: "Food & Beverage" },
{
label: "Transportation & Logistics",
value: "Transportation & Logistics",
},
{ label: "Finance & Fintech", value: "Finance & Fintech" },
{
label: "Healthcare & Pharmacy",
value: "Healthcare & Pharmacy",
},
{ label: "Education & Training", value: "Education & Training" },
{
label: "Construction & Real Estate",
value: "Construction & Real Estate",
},
{
label: "Technology & Software",
value: "Technology & Software",
},
{ label: "Entertainment & Events", value: "Entertainment & Events" },
{ label: "Agriculture & Farming", value: "Agriculture & Farming" },
{
label: "Freelancer / Sole Proprietor",
value: "Freelancer / Sole Proprietor",
},
{ label: "Other", value: "Other" },
];
const showToast = (
title: string,
description?: string,
variant: "success" | "error" | "warning" | "info" = "info"
) => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
setToastTitle(title);
setToastDescription(description);
setToastVariant(variant);
setToastVisible(true);
toastTimeoutRef.current = setTimeout(() => {
setToastVisible(false);
toastTimeoutRef.current = null;
}, 2500);
};
useEffect(() => {
return () => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
};
}, []);
const handlePrimary = async () => {
if (!user?.uid) {
showToast("Error", "You must be logged in to save KYC.", "error");
return;
}
try {
setSubmitting(true);
const updateData: any = {};
if (activeTab === "personal") {
if (!fanNumber.trim()) {
showToast("Error", "Please enter your FAN Number.", "error");
setSubmitting(false);
return;
}
updateData.fanNumber = fanNumber.trim();
if (nationalIdUri) {
try {
const url = await uploadKycDocument(
user.uid,
nationalIdUri,
"personal-id"
);
updateData.nationalIdUrl = url;
} catch (e) {
console.error("[KYC] Failed to upload national ID", e);
showToast("Error", "Failed to upload National ID.", "error");
setSubmitting(false);
return;
}
}
} else {
if (!tin.trim()) {
showToast("Error", "Please enter your EIN / TIN.", "error");
setSubmitting(false);
return;
}
if (!businessType.trim()) {
showToast("Error", "Please enter your business type.", "error");
setSubmitting(false);
return;
}
updateData.tin = tin.trim();
updateData.businessType = businessType.trim();
if (businessLicenseUri) {
try {
const url = await uploadKycDocument(
user.uid,
businessLicenseUri,
"business-license"
);
updateData.businessLicenseUrl = url;
} catch (e) {
console.error("[KYC] Failed to upload business license", e);
showToast("Error", "Failed to upload Business License.", "error");
setSubmitting(false);
return;
}
}
}
const result = await AuthService.updateUserProfile(user.uid, updateData);
if (!result.success) {
console.error("[KYC] updateUserProfile failed", result.error);
showToast("Error", result.error || "Failed to save KYC.", "error");
return;
}
try {
await refreshProfile();
} catch (e) {
console.warn("[KYC] Failed to refresh profile after KYC save", e);
}
// Successful save: clear all KYC fields
setFanNumber("");
setTin("");
setBusinessType("");
setNationalIdUri(null);
setNationalIdLabel("");
setBusinessLicenseUri(null);
setBusinessLicenseLabel("");
showToast("Success", "KYC information saved.", "success");
} catch (e) {
console.error("[KYC] Unexpected error while saving KYC", e);
showToast("Error", "Unexpected error while saving KYC.", "error");
} finally {
setSubmitting(false);
}
};
const handlePickNationalId = async () => {
if (pickingNationalId) return;
try {
setPickingNationalId(true);
const permissionResult =
await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permissionResult.granted) {
Alert.alert(
"Permission Required",
"Please allow access to your photo library to upload your ID."
);
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
quality: 0.8,
});
if (!result.canceled && result.assets[0]) {
const uri = result.assets[0].uri;
setNationalIdUri(uri);
const name = uri.split("/").pop() || "Selected file";
setNationalIdLabel(name);
}
} catch (e) {
console.error("[KYC] Error while selecting National ID", e);
showToast("Error", "Failed to select National ID.", "error");
} finally {
setPickingNationalId(false);
}
};
const handlePickBusinessLicense = async () => {
if (pickingBusinessLicense) return;
try {
setPickingBusinessLicense(true);
const permissionResult =
await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permissionResult.granted) {
Alert.alert(
"Permission Required",
"Please allow access to your photo library to upload your Business License."
);
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
quality: 0.8,
});
if (!result.canceled && result.assets[0]) {
const uri = result.assets[0].uri;
setBusinessLicenseUri(uri);
const name = uri.split("/").pop() || "Selected file";
setBusinessLicenseLabel(name);
}
} catch (e) {
console.error("[KYC] Error while selecting Business License", e);
showToast("Error", "Failed to select Business License.", "error");
} finally {
setPickingBusinessLicense(false);
}
};
return (
<ScreenWrapper edges={[]}>
<BackButton />
<ScrollView
className="flex-1 bg-white"
showsVerticalScrollIndicator={false}
>
<View className="px-5 pb-8">
<Text className="text-3xl font-dmsans-bold text-gray-900 mb-2">
Information
</Text>
<Text className="text-lg font-dmsans-bold text-[#0F7B4A] mb-1">
KYC
</Text>
<Text className="text-base font-dmsans text-gray-500 mb-5">
Fill out the information below to add more limits to your account
</Text>
{/* tab */}
<View className="flex-row mb-5 bg-[#F3F4F6] rounded-full p-1">
<TouchableOpacity
onPress={() => setActiveTab("personal")}
className={`flex-1 items-center py-2 rounded-full ${
activeTab === "personal" ? "bg-white" : "bg-transparent"
}`}
activeOpacity={0.8}
>
<Text
className={`text-base font-dmsans-medium ${
activeTab === "personal" ? "text-[#0F7B4A]" : "text-gray-500"
}`}
>
Personal
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => setActiveTab("business")}
className={`flex-1 items-center py-2 rounded-full ${
activeTab === "business" ? "bg-white" : "bg-transparent"
}`}
activeOpacity={0.8}
>
<Text
className={`text-base font-dmsans-medium ${
activeTab === "business" ? "text-[#0F7B4A]" : "text-gray-500"
}`}
>
Business
</Text>
</TouchableOpacity>
</View>
{activeTab === "personal" ? (
<View className="mt-2">
<Text className="text-base font-dmsans text-black mb-2">
FAN Number
</Text>
<Input
placeholderText="FAN Number"
value={fanNumber}
onChangeText={setFanNumber}
containerClassName="w-full mb-4"
borderClassName="border-[#E5E7EB] bg-white rounded-[4px]"
placeholderColor="#9CA3AF"
textClassName="text-[#111827] text-sm"
/>
<Text className="text-base font-dmsans text-black mb-2">
National ID Upload
</Text>
<TouchableOpacity
activeOpacity={0.8}
onPress={handlePickNationalId}
disabled={pickingNationalId}
>
<Input
placeholderText={
pickingNationalId
? "Opening picker..."
: "Upload National ID"
}
value={nationalIdLabel}
containerClassName="w-full"
borderClassName="border-[#E5E7EB] bg-white rounded-[4px]"
placeholderColor="#9CA3AF"
textClassName="text-[#111827] text-sm"
editable={false}
rightIcon={
pickingNationalId ? (
<ActivityIndicator size="small" color="#111827" />
) : (
<UploadCloud size={18} color="#111827" />
)
}
/>
</TouchableOpacity>
</View>
) : (
<View className="mt-2">
<Text className="text-base font-dmsans text-black mb-2">
EIN / TIN
</Text>
<Input
placeholderText="EIN / TIN"
value={tin}
onChangeText={setTin}
containerClassName="w-full mb-4"
borderClassName="border-[#E5E7EB] bg-white rounded-[4px]"
placeholderColor="#9CA3AF"
textClassName="text-[#111827] text-sm"
/>
<Text className="text-base font-dmsans text-black mb-2">
Type of Business
</Text>
<View className="w-full mb-4">
<Dropdown
value={businessType || null}
options={businessTypeOptions}
onSelect={(value) => setBusinessType(value)}
placeholder="Select type of business"
/>
</View>
<Text className="text-base font-dmsans text-black mb-2">
Business License
</Text>
<TouchableOpacity
activeOpacity={0.8}
onPress={handlePickBusinessLicense}
disabled={pickingBusinessLicense}
>
<Input
placeholderText={
pickingBusinessLicense
? "Opening picker..."
: "In-cooperation Document"
}
value={businessLicenseLabel}
containerClassName="w-full"
borderClassName="border-[#E5E7EB] bg-white rounded-[4px]"
placeholderColor="#9CA3AF"
textClassName="text-[#111827] text-sm"
editable={false}
rightIcon={
pickingBusinessLicense ? (
<ActivityIndicator size="small" color="#111827" />
) : (
<UploadCloud size={18} color="#111827" />
)
}
/>
</TouchableOpacity>
</View>
)}
</View>
</ScrollView>
<View className="w-full px-5 pb-8 bg-white">
<Button
className="h-13 rounded-full bg-[#0F7B4A]"
onPress={handlePrimary}
disabled={submitting}
>
<Text className="text-white font-dmsans-bold text-base">
{submitting
? "Saving..."
: activeTab === "personal"
? "Done"
: "Add"}
</Text>
</Button>
</View>
<ModalToast
visible={toastVisible}
title={toastTitle}
description={toastDescription}
variant={toastVariant}
/>
</ScreenWrapper>
);
}

View File

@ -0,0 +1,55 @@
import React from "react";
import { View, Text, ScrollView } from "react-native";
import { Button } from "~/components/ui/button";
import { SafeAreaView } from "react-native-safe-area-context";
import { router, useLocalSearchParams } from "expo-router";
import { ROUTES } from "~/lib/routes";
import { SuccessIcon } from "~/components/ui/icons";
import { useTranslation } from "react-i18next";
export default function MoneyDonated() {
const params = useLocalSearchParams<{ fullName?: string }>();
const { t } = useTranslation();
const handleDonateAgain = () => {
router.replace(ROUTES.HOME);
router.push(ROUTES.SEND_OR_REQUEST_MONEY);
};
const fullName = (params.fullName || "").toString().trim();
return (
<SafeAreaView className="flex justify-center items-center h-full w-full space-y-5">
<ScrollView>
<View className="flex py-[50%] justify-center w-full h-full">
<View className="flex items-center space-y-10">
<SuccessIcon />
<View className="h-10" />
<Text className="text-primary text-3xl">
{t("moneydonated.title")}
</Text>
<View className="h-4" />
<View className="mx-12">
<Text className="text-2xl font-regular text-gray-400 font-dmsans text-center">
{t("moneydonated.description")}
</Text>
</View>
</View>
<View className="h-32" />
<View className="w-full px-5">
<Button
className="bg-white border border-dashed border-secondary rounded-full"
onPress={() => router.replace(ROUTES.HOME)}
>
<Text className="font-dmsans text-black">
{t("moneydonated.goHomeButton")}
</Text>
</Button>
</View>
</View>
</ScrollView>
</SafeAreaView>
);
}

View File

@ -0,0 +1,68 @@
import React from "react";
import { View, Text } from "react-native";
import { Button } from "~/components/ui/button";
import { SafeAreaView } from "react-native-safe-area-context";
import { router, useLocalSearchParams } from "expo-router";
import { ROUTES } from "~/lib/routes";
import { NotificationIcon } from "~/components/ui/icons";
import { useTranslation } from "react-i18next";
export default function MoneyRequested() {
const params = useLocalSearchParams<{
fullName?: string;
requesteePhoneNumber?: string;
}>();
const { t } = useTranslation();
const handleRequestAgain = () => {
router.replace(ROUTES.HOME);
router.push(ROUTES.SEND_OR_REQUEST_MONEY);
};
const fullName = (params.fullName || "").toString().trim();
const description = fullName
? t("moneyrequested.descriptionWithName", { fullName })
: t("moneyrequested.description");
return (
<SafeAreaView className="flex-1 bg-white w-full">
{/* Main content */}
<View className="flex-1 items-center justify-center px-5">
<View className="items-center space-y-10 w-full">
<NotificationIcon />
<View className="h-2" />
<Text className="text-primary text-3xl font-dmsans-bold text-center">
{t("moneyrequested.title")}
</Text>
<View className="h-2" />
<View className="mx-8">
<Text className="text-xl font-regular text-gray-400 font-dmsans text-center">
{description}
</Text>
</View>
</View>
</View>
{/* Bottom actions */}
<View className="w-full px-5 pb-8">
<Button
className="bg-primary rounded-full"
onPress={handleRequestAgain}
>
<Text className="font-dmsans text-white">
{t("moneyrequested.requestAgainButton")}
</Text>
</Button>
<View className="h-4" />
<Button
className="bg-white border border-dashed border-secondary rounded-full"
onPress={() => router.replace(ROUTES.HOME)}
>
<Text className="font-dmsans text-black">
{t("moneyrequested.goHomeButton")}
</Text>
</Button>
</View>
</SafeAreaView>
);
}

View File

@ -0,0 +1,323 @@
import React, { useMemo, useState } from "react";
import {
View,
Text,
Image,
ScrollView,
TouchableOpacity,
ActivityIndicator,
} from "react-native";
import ScreenWrapper from "~/components/ui/ScreenWrapper";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Icons } from "~/assets/icons";
import { ChevronRight } from "lucide-react-native";
import { router } from "expo-router";
import { ROUTES } from "~/lib/routes";
import { useTranslation } from "react-i18next";
import BackButton from "~/components/ui/backButton";
import { useTickets } from "~/lib/hooks/useTickets";
import BottomSheet from "~/components/ui/bottomSheet";
export default function MyTicketsScreen() {
const { t } = useTranslation();
const {
data: tickets,
loading,
error,
refetch,
} = useTickets({ status: "ACTIVE", limit: 50, immediate: true });
const [searchQuery, setSearchQuery] = useState("");
const [filterVisible, setFilterVisible] = useState(false);
const [dateFilter, setDateFilter] = useState<"all" | "today" | "this_week">(
"all"
);
const normalizedQuery = searchQuery.trim().toLowerCase();
const filteredTickets = useMemo(() => {
if (!tickets) return [];
return tickets.filter((ticket) => {
const anyTicket = ticket as any;
const eventName = (anyTicket.event?.name ?? "").toLowerCase();
if (normalizedQuery && !eventName.includes(normalizedQuery)) {
return false;
}
if (dateFilter !== "all") {
const rawDate = anyTicket.event?.startDate || anyTicket.createdAt;
if (!rawDate) {
return true;
}
const date = new Date(rawDate);
const now = new Date();
const ticketDay = new Date(
date.getFullYear(),
date.getMonth(),
date.getDate()
);
const today = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate()
);
if (dateFilter === "today") {
if (ticketDay.getTime() !== today.getTime()) return false;
} else if (dateFilter === "this_week") {
const endOfWeek = new Date(today);
endOfWeek.setDate(today.getDate() + 7);
if (ticketDay < today || ticketDay > endOfWeek) return false;
}
}
return true;
});
}, [tickets, normalizedQuery, dateFilter]);
return (
<ScreenWrapper edges={[]}>
<View className="flex-1 bg-white">
<ScrollView
className="flex-1"
contentContainerStyle={{ paddingBottom: 32 }}
showsVerticalScrollIndicator={false}
>
<View className="">
<BackButton />
</View>
<View className="px-5">
<Text className="text-lg font-dmsans-medium text-[#0F7B4A] mb-1">
{t("mytickets.title")}
</Text>
<Text className="text-base font-dmsans text-gray-500 mb-6">
{t("mytickets.subtitle")}
</Text>
<View className="mb-4">
<Input
placeholderText={t("mytickets.searchPlaceholder")}
containerClassName="w-full"
borderClassName="border-[#E5E7EB] bg-white rounded-[4px]"
placeholderColor="#9CA3AF"
textClassName="text-[#111827] text-sm"
value={searchQuery}
onChangeText={setSearchQuery}
/>
</View>
<View className="mb-8">
<Button
className="h-11 rounded-[4px] bg-[#0F7B4A]"
onPress={() => setFilterVisible(true)}
>
<View className="flex-row items-center justify-center space-x-2">
<Image
source={Icons.filterBar}
style={{ width: 18, height: 18 }}
resizeMode="contain"
/>
<Text className="text-white ml-2 font-dmsans-medium text-sm">
{t("mytickets.filterButton")}
</Text>
</View>
</Button>
<Button
className="bg-secondary mt-4 rounded-md border border-dashed border-secondary h-11"
onPress={() => router.back()}
>
<Text className="font-dmsans text-white">
{t("common.back")}
</Text>
</Button>
</View>
<Text className="text-lg font-dmsans-medium text-[#0F7B4A] mb-4">
{t("mytickets.ticketsTitle")}
</Text>
<View className="flex flex-col gap-3">
{loading && (
<View className="flex items-center justify-center py-8">
<ActivityIndicator size="small" color="#0F7B4A" />
<Text className="mt-2 text-gray-500 font-dmsans text-sm">
{t("mytickets.loading")}
</Text>
</View>
)}
{!loading && error && (
<View className="flex items-center justify-center py-8">
<Text className="text-red-500 font-dmsans text-sm mb-2">
{t("mytickets.error")}
</Text>
<Button
className="h-9 px-4 bg-primary rounded-full"
onPress={() => refetch()}
>
<Text className="text-white font-dmsans text-xs">
{t("common.retry")}
</Text>
</Button>
</View>
)}
{!loading && !error && tickets && tickets.length === 0 && (
<View className="flex items-center justify-center py-12">
<Image
source={Icons.ticketHome}
style={{ width: 64, height: 64, marginBottom: 12 }}
resizeMode="contain"
/>
<Text className="text-base font-dmsans-medium text-[#0F7B4A] mb-1">
No tickets found
</Text>
<Text className="text-sm font-dmsans text-gray-500 text-center px-4">
You don't have any tickets yet.
</Text>
</View>
)}
{!loading &&
!error &&
tickets &&
filteredTickets.length === 0 && (
<View className="flex items-center justify-center py-8">
<Text className="text-sm font-dmsans text-gray-500">
No tickets match your search.
</Text>
</View>
)}
{!loading &&
!error &&
filteredTickets &&
filteredTickets.length > 0 && (
<View className="flex flex-col gap-3">
{filteredTickets.map((ticket) => {
const anyTicket = ticket as any;
const eventName = anyTicket.event?.name || "Ticket";
const ticketNo = anyTicket.ticketNo || anyTicket.id;
const qr = anyTicket.qr || ticketNo;
const qrImage = anyTicket.qrImage as string | undefined;
const rawDate =
anyTicket.event?.startDate || anyTicket.createdAt;
const formattedDate = rawDate
? new Date(rawDate).toLocaleDateString()
: "";
return (
<TouchableOpacity
key={anyTicket.id}
activeOpacity={0.9}
onPress={() =>
router.push({
pathname: ROUTES.EVENT_QR,
params: {
code: qr,
packageName: eventName,
...(qrImage ? { qrImage } : {}),
},
})
}
className="flex-row items-center justify-between bg-[#F3FFF7] rounded-[6px] px-4 py-3"
>
<View className="flex-row items-center">
<View className="w-10 h-10 rounded-[4px] bg-[#FFEEDB] items-center justify-center mr-3">
<Image
source={Icons.ticketIcon}
style={{ width: 20, height: 20 }}
resizeMode="contain"
/>
</View>
<View>
<Text className="text-sm font-dmsans-medium text-[#FFB668]">
{eventName}
</Text>
<Text className="text-xs font-dmsans text-[#105D38] mt-1">
{formattedDate}
</Text>
</View>
</View>
<ChevronRight size={20} color="#FFB668" />
</TouchableOpacity>
);
})}
</View>
)}
</View>
</View>
</ScrollView>
</View>
<BottomSheet
visible={filterVisible}
onClose={() => setFilterVisible(false)}
maxHeightRatio={0.5}
>
<Text className="text-lg font-dmsans-bold text-black mb-1">
Filter tickets
</Text>
<Text className="text-xs font-dmsans text-gray-500 mb-4">
Filter by date of event or purchase
</Text>
<Text className="text-base font-dmsans-medium text-black mb-2">
Date
</Text>
<View className="flex-row mb-4">
{[
{ key: "all", label: "All dates" },
{ key: "today", label: "Today" },
{ key: "this_week", label: "This week" },
].map((option) => (
<TouchableOpacity
key={option.key}
onPress={() =>
setDateFilter(option.key as "all" | "today" | "this_week")
}
className={`px-3 py-1 rounded-full mr-2 border ${
dateFilter === option.key
? "bg-[#0F7B4A] border-[#0F7B4A]"
: "bg-white border-gray-300"
}`}
>
<Text
className={`text-xs font-dmsans ${
dateFilter === option.key ? "text-white" : "text-gray-700"
}`}
>
{option.label}
</Text>
</TouchableOpacity>
))}
</View>
<View className="flex-row justify-between mt-4">
<TouchableOpacity
onPress={() => {
setSearchQuery("");
setDateFilter("all");
}}
>
<Text className="text-sm font-dmsans text-primary">Clear</Text>
</TouchableOpacity>
<Button
className="h-9 px-4 rounded-full bg-[#0F7B4A]"
onPress={() => setFilterVisible(false)}
>
<Text className="text-xs font-dmsans text-white">
Apply filters
</Text>
</Button>
</View>
</BottomSheet>
</ScreenWrapper>
);
}

View File

@ -0,0 +1,665 @@
import React from "react";
import {
View,
Text,
ScrollView,
TouchableOpacity,
FlatList,
} from "react-native";
import {
User,
ChevronRight,
ArrowUpRight,
ArrowDownLeft,
UploadCloud,
DollarSign,
Ticket,
} from "lucide-react-native";
import BackButton from "~/components/ui/backButton";
import { useNotifications } from "~/lib/hooks/useNotifications";
import { useAuthWithProfile } from "~/lib/hooks/useAuthWithProfile";
import { NotificationService } from "~/lib/services/notificationService";
import { RequestService } from "~/lib/services/requestService";
import { router } from "expo-router";
import { ROUTES } from "~/lib/routes";
import ScreenWrapper from "~/components/ui/ScreenWrapper";
import ModalToast from "~/components/ui/toast";
import { useTranslation } from "react-i18next";
import PermissionAlertModal from "~/components/ui/permissionAlertModal";
// Notification Card Component
const NotificationCard = ({
notification,
onPress,
onMoneyRequestAction,
onMoneyRequestPrompt,
}: {
notification: any;
onPress?: () => void;
onMoneyRequestAction?: (
requestId: string,
action: "accept" | "decline"
) => void;
onMoneyRequestPrompt?: (notification: any) => void;
}) => {
console.log("Notification:", notification);
const getNotificationIcon = () => {
const rawType = (notification.type || "").toString().toLowerCase();
const title = (notification.title || "").toString().toLowerCase();
const message = (notification.message || "").toString().toLowerCase();
const transactionSubtype = (
notification.transactionType ||
notification.transaction_type ||
notification.subtype ||
""
)
.toString()
.toLowerCase();
// Event notifications (tickets, event reminders, etc.)
if (
rawType === "event" ||
rawType === "event_ticket_purchased" ||
rawType === "event_reminder" ||
notification.category === "event"
) {
return <Ticket color="#FFFFFF" size={20} />;
}
// Money request notifications
if (
rawType === "request_received" ||
title.includes("request") ||
message.includes("request")
) {
// Arrow going 45° down for money request
return <ArrowDownLeft color="#FFFFFF" size={20} />;
}
// Cash out (money icon, red theme handled by color helper)
if (
rawType === "cash_out" ||
title.includes("cash out") ||
message.includes("cash out")
) {
return <DollarSign color="#FFFFFF" size={20} />;
}
// Money received (same money icon but green theme)
if (
rawType === "money_received" ||
rawType === "receive" ||
title.includes("received") ||
message.includes("received")
) {
return <DollarSign color="#FFFFFF" size={20} />;
}
// Money sent (arrow 45° up)
if (
rawType === "money_sent" ||
rawType === "send" ||
title.includes("sent") ||
message.includes("sent")
) {
return <ArrowUpRight color="#FFFFFF" size={20} />;
}
// Fallback for generic transaction-related notifications
if (
rawType === "transaction_completed" ||
rawType === "transaction" ||
title.includes("transaction") ||
message.includes("transaction")
) {
return <DollarSign color="#FFFFFF" size={20} />;
}
// Everything else fall back to user icon
return <User color="#FFFFFF" size={20} />;
};
const getNotificationColor = () => {
const rawType = (notification.type || "").toString().toLowerCase();
const title = (notification.title || "").toString().toLowerCase();
const message = (notification.message || "").toString().toLowerCase();
const transactionSubtype = (
notification.transactionType ||
notification.transaction_type ||
notification.subtype ||
""
)
.toString()
.toLowerCase();
// --- Explicit transaction subtypes (preferred) ---
if (transactionSubtype === "cash_out") {
// Cash out → red
return "bg-red-50";
}
if (transactionSubtype === "receive") {
// Money received → green
return "bg-green-50";
}
if (transactionSubtype === "send") {
// Money sent → blue
return "bg-blue-50";
}
if (transactionSubtype === "add_cash") {
// Add cash → blue
return "bg-blue-50";
}
// Event notifications
if (
rawType === "event" ||
rawType === "event_ticket_purchased" ||
rawType === "event_reminder" ||
notification.category === "event"
) {
return "bg-purple-50";
}
// Money request
if (
rawType === "request_received" ||
title.includes("request") ||
message.includes("request")
) {
return "bg-yellow-50";
}
// Cash out → red
if (
rawType === "cash_out" ||
title.includes("cash out") ||
message.includes("cash out")
) {
return "bg-red-50";
}
// Money received → green
if (
rawType === "money_received" ||
rawType === "receive" ||
title.includes("received") ||
message.includes("received")
) {
return "bg-green-50";
}
// Money sent → blue
if (
rawType === "money_sent" ||
rawType === "send" ||
title.includes("sent") ||
message.includes("sent")
) {
return "bg-blue-50";
}
// Generic transaction
if (
rawType === "transaction_completed" ||
rawType === "transaction" ||
title.includes("transaction") ||
message.includes("transaction")
) {
return "bg-blue-50";
}
// Default
return "bg-gray-50";
};
const getIconBackgroundColor = () => {
const rawType = (notification.type || "").toString().toLowerCase();
const title = (notification.title || "").toString().toLowerCase();
const message = (notification.message || "").toString().toLowerCase();
const transactionSubtype = (
notification.transactionType ||
notification.transaction_type ||
notification.subtype ||
""
)
.toString()
.toLowerCase();
// --- Explicit transaction subtypes (preferred) ---
if (transactionSubtype === "cash_out") {
// Cash out → red
return "bg-red-500";
}
if (transactionSubtype === "receive") {
// Money received → green
return "bg-green-500";
}
if (transactionSubtype === "send") {
// Money sent → blue
return "bg-blue-500";
}
if (transactionSubtype === "add_cash") {
// Add cash → blue
return "bg-blue-500";
}
// Event notifications
if (
rawType === "event" ||
rawType === "event_ticket_purchased" ||
rawType === "event_reminder" ||
notification.category === "event"
) {
return "bg-purple-500";
}
// Money request → yellow
if (
rawType === "request_received" ||
title.includes("request") ||
message.includes("request")
) {
return "bg-yellow-500";
}
// Cash out → red
if (
rawType === "cash_out" ||
title.includes("cash out") ||
message.includes("cash out")
) {
return "bg-red-500";
}
// Money received → green
if (
rawType === "money_received" ||
rawType === "receive" ||
title.includes("received") ||
message.includes("received")
) {
return "bg-green-500";
}
// Money sent → blue
if (
rawType === "money_sent" ||
rawType === "send" ||
title.includes("sent") ||
message.includes("sent")
) {
return "bg-blue-500";
}
// Generic transaction → blue
if (
rawType === "transaction_completed" ||
rawType === "transaction" ||
title.includes("transaction") ||
message.includes("transaction")
) {
return "bg-blue-500";
}
// Default
return "bg-orange-200";
};
const handleMoneyRequestPress = () => {
console.log("NOTIFICATION", notification);
//@ts-ignore
if (notification.type === "request_received" && notification.requestId) {
onMoneyRequestPrompt?.(notification);
} else {
onPress?.();
}
};
return (
<View
className={`${getNotificationColor()} rounded-lg p-4 mb-3 ${
notification.read ? "opacity-60" : ""
}`}
>
<TouchableOpacity
onPress={handleMoneyRequestPress}
className="flex-row items-center"
>
{/* Icon Avatar */}
<View className={`${getIconBackgroundColor()} rounded-full p-3 mr-4`}>
{getNotificationIcon()}
</View>
{/* Notification Content */}
<View className="flex-1">
<Text className="text-black font-dmsans-bold text-base mb-1">
{notification.title}
</Text>
<Text className="text-gray-600 font-dmsans text-sm">
{notification.message}
</Text>
</View>
{/* Chevron for money requests */}
{notification.type === "request_received" && (
<ChevronRight color="#9CA3AF" size={20} />
)}
</TouchableOpacity>
</View>
);
};
export default function Notification() {
const { user } = useAuthWithProfile();
const { t } = useTranslation();
const [toastVisible, setToastVisible] = React.useState(false);
const [toastTitle, setToastTitle] = React.useState("");
const [toastDescription, setToastDescription] = React.useState<
string | undefined
>(undefined);
const [toastVariant, setToastVariant] = React.useState<
"success" | "error" | "warning" | "info"
>("info");
const toastTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(
null
);
const [requestModalVisible, setRequestModalVisible] = React.useState(false);
const [activeRequestNotification, setActiveRequestNotification] =
React.useState<any | null>(null);
const showToast = (
title: string,
description?: string,
variant: "success" | "error" | "warning" | "info" = "info"
) => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
setToastTitle(title);
setToastDescription(description);
setToastVariant(variant);
setToastVisible(true);
toastTimeoutRef.current = setTimeout(() => {
setToastVisible(false);
toastTimeoutRef.current = null;
}, 2500);
};
const {
notifications,
loading,
error,
unreadCount,
markAsRead,
markAllAsRead,
} = useNotifications(user?.uid || null);
const handleMarkAsRead = async () => {
await markAllAsRead();
};
const handleNotificationPress = async (notification: any) => {
if (notification?.id) {
await markAsRead(notification.id);
}
const transactionId =
notification?.transactionId || notification?.transaction_id;
const transactionSubtype =
notification?.transactionType ||
notification?.transaction_type ||
notification?.subtype;
if (
transactionId &&
(notification?.type === "transaction_completed" ||
notification?.type === "money_received" ||
notification?.type === "money_sent" ||
notification?.category === "transaction")
) {
router.push({
pathname: ROUTES.TRANSACTION_DETAIL,
params: {
transactionId,
type: transactionSubtype || "",
amount:
notification?.amount !== undefined && notification?.amount !== null
? String(notification.amount)
: "",
recipientName:
notification?.recipientName || notification?.counterpartyName || "",
date:
(notification?.createdAt &&
typeof notification.createdAt === "string" &&
notification.createdAt) ||
(notification?.date && typeof notification.date === "string"
? notification.date
: ""),
status: notification?.status || "",
note: notification?.note || "",
},
});
return;
}
// For other notification types (e.g. money requests), we rely on their specific handlers
};
const handleMoneyRequestAction = async (
requestId: string,
action: "accept" | "decline"
) => {
try {
// Get the request details
const requestResult = await RequestService.getRequestById(requestId);
if (!requestResult.success || !requestResult.request) {
console.error("Failed to get request:", requestResult.error);
return;
}
const request = requestResult.request;
if (request.status !== "pending") {
console.error("Request is not pending");
showToast(
t("notification.toastErrorTitle"),
t("notification.toastRequestNotPending"),
"error"
);
return;
}
// Get requestor details (the person who made the request)
const requestorResult = await RequestService.getUserByPhoneNumber(
request.requestorPhoneNumber
);
if (!requestorResult.success || !requestorResult.user) {
console.error(
"Failed to get requestor details:",
requestorResult.error
);
return;
}
// Get requestee details (the person receiving the request - current user)
const requesteeResult = await RequestService.getUserByPhoneNumber(
request.requesteePhoneNumber
);
if (!requesteeResult.success || !requesteeResult.user) {
console.error(
"Failed to get requestee details:",
requesteeResult.error
);
return;
}
const result = await NotificationService.acceptDeclineMoneyRequest(
requestId,
requesteeResult.user.uid, // requesteeUid (current user)
requestorResult.user.uid, // requestorUid (person who made the request)
requestorResult.user.displayName, // requestorName
requesteeResult.user.displayName, // requesteeName
request.amount, // actual amount from request
action
);
if (result.success) {
console.log(`Request ${action}ed successfully`);
if (action === "accept") {
// Navigate to success screen only for accepted requests
router.replace({
pathname: ROUTES.MONEY_DONATED,
params: {
message: `Congratulations! Transaction completed on your end.`,
amount: (request.amount / 100).toFixed(2),
recipientName: requestorResult.user!.displayName,
},
});
} else {
// For declined requests, show a simple confirmation toast
showToast(
t("notification.toastInfoTitle", "Request Declined"),
t(
"notification.toastRequestDeclined",
`You have declined the money request from ${
requestorResult.user!.displayName
}.`
),
"info"
);
}
} else {
console.error(`Failed to ${action} request:`, result.error);
showToast(
t("notification.toastErrorTitle"),
t("notification.toastRequestActionFailed", { action }),
"error"
);
}
} catch (error) {
console.error(`Error ${action}ing request:`, error);
showToast(
t("notification.toastErrorTitle"),
t("notification.toastRequestActionFailed", { action }),
"error"
);
}
};
const handleMoneyRequestPrompt = (notification: any) => {
setActiveRequestNotification(notification);
setRequestModalVisible(true);
};
return (
<ScreenWrapper edges={[]}>
<BackButton />
<View className="flex-row items-center justify-center px-5 py-4">
<Text className="text-2xl font-dmsans-bold text-black">
{t("notification.title")}
</Text>
</View>
<ScrollView className="flex-1 px-5">
{/* Today Section */}
<View className="py-4">
<View className="flex-row items-center justify-between">
<View>
<Text className="text-sm text-gray-500 font-dmsans mb-4">
{t("notification.sectionToday")}
</Text>
</View>
</View>
{loading ? (
<View className="flex items-center justify-center py-10">
<Text className="text-gray-500 font-dmsans">
{t("notification.loading")}
</Text>
</View>
) : error ? (
<View className="flex items-center justify-center py-10">
<Text className="text-red-500 font-dmsans">
{t("notification.errorWithMessage", { error })}
</Text>
</View>
) : (
<FlatList
data={notifications}
keyExtractor={(item) => item.id}
scrollEnabled={false}
ItemSeparatorComponent={() => <View className="h-2" />}
renderItem={({ item }) => (
<NotificationCard
notification={item}
onPress={() => handleNotificationPress(item)}
onMoneyRequestAction={handleMoneyRequestAction}
onMoneyRequestPrompt={handleMoneyRequestPrompt}
/>
)}
/>
)}
</View>
{/* Empty state if no notifications */}
{!loading && !error && notifications.length === 0 && (
<View className="flex-1 items-center justify-center py-20">
<View className="bg-gray-100 rounded-full p-6 mb-4">
<User color="#9CA3AF" size={32} />
</View>
<Text className="text-gray-500 font-dmsans text-lg mb-2">
{t("notification.emptyTitle")}
</Text>
<Text className="text-gray-400 font-dmsans text-sm text-center px-8">
{t("notification.emptySubtitle")}
</Text>
</View>
)}
</ScrollView>
<ModalToast
visible={toastVisible}
title={toastTitle}
description={toastDescription}
variant={toastVariant}
/>
<PermissionAlertModal
visible={requestModalVisible}
title={t("notification.moneyRequestTitle", "Money Request")}
message={activeRequestNotification?.message || ""}
primaryText={t("notification.moneyRequestAccept", "Accept")}
secondaryText={t("notification.moneyRequestDecline", "Decline")}
onPrimary={() => {
if (activeRequestNotification?.requestId) {
handleMoneyRequestAction(
activeRequestNotification.requestId,
"accept"
);
}
setRequestModalVisible(false);
setActiveRequestNotification(null);
}}
onSecondary={() => {
if (activeRequestNotification?.requestId) {
handleMoneyRequestAction(
activeRequestNotification.requestId,
"decline"
);
}
setRequestModalVisible(false);
setActiveRequestNotification(null);
}}
/>
</ScreenWrapper>
);
}

View File

@ -0,0 +1,277 @@
import React, { useState, useRef } from "react";
import { View, Text, TouchableOpacity, ScrollView } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import BackButton from "~/components/ui/backButton";
import { router, useLocalSearchParams } from "expo-router";
import { SMSIcon, WhatsappIcon } from "~/components/ui/icons";
import { RequestService } from "~/lib/services/requestService";
import { useAuthStore } from "~/lib/stores";
import { ROUTES } from "~/lib/routes";
import ModalToast from "~/components/ui/toast";
import { useTranslation } from "react-i18next";
import ScreenWrapper from "~/components/ui/ScreenWrapper";
import PermissionAlertModal from "~/components/ui/permissionAlertModal";
export default function NotificationOption() {
const params = useLocalSearchParams<{
amount: string;
recipientName: string;
recipientPhoneNumber: string;
recipientType: string;
recipientId: string;
note: string;
requestorName: string;
requestorPhoneNumber: string;
requestorType: string;
type: string;
provider?: string;
}>();
const [selectedMethod, setSelectedMethod] = useState<
"SMS" | "WhatsApp" | null
>(null);
const { user } = useAuthStore();
const isMoneyRequest = params.type === "request";
const { t } = useTranslation();
const [toastVisible, setToastVisible] = useState(false);
const [toastTitle, setToastTitle] = useState("");
const [toastDescription, setToastDescription] = useState<string | undefined>(
undefined
);
const [toastVariant, setToastVariant] = useState<
"success" | "error" | "warning" | "info"
>("info");
const toastTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(
null
);
const [confirmVisible, setConfirmVisible] = useState(false);
const [confirmTitle, setConfirmTitle] = useState("");
const [confirmMessage, setConfirmMessage] = useState("");
const confirmActionRef = useRef<() => void>(() => {});
const showToast = (
title: string,
description?: string,
variant: "success" | "error" | "warning" | "info" = "info"
) => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
setToastTitle(title);
setToastDescription(description);
setToastVariant(variant);
setToastVisible(true);
toastTimeoutRef.current = setTimeout(() => {
setToastVisible(false);
toastTimeoutRef.current = null;
}, 2500);
};
const handleMoneyRequest = async (notificationMethod: "SMS" | "WhatsApp") => {
if (!user?.uid) {
showToast(
t("notificationOption.toastErrorTitle"),
t("notificationOption.toastAuthRequired"),
"error"
);
return;
}
if (!params.amount || !params.recipientName || !params.requestorName) {
showToast(
t("notificationOption.toastErrorTitle"),
t("notificationOption.toastMissingInfo"),
"error"
);
return;
}
const amountInCents = parseInt(params.amount);
if (isNaN(amountInCents) || amountInCents <= 0) {
showToast(
t("notificationOption.toastErrorTitle"),
t("notificationOption.toastInvalidAmount"),
"error"
);
return;
}
try {
const result = await RequestService.createRequest(user.uid, {
requestorName: params.requestorName,
requesteeName: params.recipientName,
requestorPhoneNumber: params.requestorPhoneNumber || "",
requesteePhoneNumber: params.recipientPhoneNumber,
requestorType: params.requestorType as "saved" | "contact",
amount: amountInCents,
note: params.note || "",
status: "pending" as const,
notificationMethod,
});
if (result.success) {
// Navigate to success screen
router.replace({
pathname: ROUTES.MONEY_REQUESTED,
params: {
fullName: params.recipientName,
requesteePhoneNumber: params.recipientPhoneNumber,
},
});
} else {
showToast(
t("notificationOption.toastErrorTitle"),
result.error || t("notificationOption.toastCreateFailed"),
"error"
);
}
} catch (error) {
console.error("Error requesting money:", error);
showToast(
t("notificationOption.toastErrorTitle"),
t("notificationOption.toastRequestFailed"),
"error"
);
}
};
const handleMethodSelection = (method: "SMS" | "WhatsApp") => {
setSelectedMethod(method);
};
const handleProceed = () => {
if (!selectedMethod) {
showToast(
t("notificationOption.toastErrorTitle"),
t("notificationOption.toastSelectMethod"),
"error"
);
return;
}
const description = `Your money request will be sent to the selected donor and will be notified via ${selectedMethod}.`;
const title = `${selectedMethod} Notification`;
setConfirmTitle(title);
setConfirmMessage(description);
if (isMoneyRequest) {
confirmActionRef.current = () => handleMoneyRequest(selectedMethod);
} else {
confirmActionRef.current = () => {
console.log(`${selectedMethod} notification selected`);
router.back();
};
}
setConfirmVisible(true);
};
return (
<ScreenWrapper edges={["top"]}>
{/* Header with close button */}
<View className="flex-row items-center justify-between">
<BackButton />
</View>
{/* Main content */}
<ScrollView className="flex-1 px-5 pt-10">
{/* Title */}
<View className="items-center mb-8">
<Text className="text-3xl font-dmsans-bold text-black">
{t("notificationOption.title")}
</Text>
</View>
<View className="h-8" />
{/* Notification Options Section */}
<View className="mb-8">
<Text className="text-lg font-dmsans-bold text-primary mb-2">
{t("notificationOption.sectionTitle")}
</Text>
<Text className="text-gray-600 font-dmsans text-base">
{t("notificationOption.sectionSubtitle")}
</Text>
</View>
{/* SMS Notification Option */}
<TouchableOpacity
onPress={() => handleMethodSelection("SMS")}
className={`rounded-lg p-4 mb-4 flex-row items-center ${
selectedMethod === "SMS"
? "bg-green-50 border-2 border-primary"
: "bg-green-50 border border-gray-200"
}`}
>
<View className="p-3 mr-4">
<SMSIcon />
</View>
<View className="flex-1">
<Text className="font-dmsans-bold text-lg text-black">
{t("notificationOption.smsLabel")}
</Text>
</View>
</TouchableOpacity>
{/* WhatsApp Notification Option */}
<TouchableOpacity
onPress={() => handleMethodSelection("WhatsApp")}
className={`rounded-lg p-4 flex-row items-center ${
selectedMethod === "WhatsApp"
? "bg-green-50 border-2 border-primary"
: "bg-green-50 border border-gray-200"
}`}
>
<View className="p-3 mr-4">
<WhatsappIcon />
</View>
<View className="flex-1">
<Text className="font-dmsans-bold text-lg text-black">
{t("notificationOption.whatsappLabel")}
</Text>
</View>
</TouchableOpacity>
</ScrollView>
{/* Proceed Button - Only visible when a method is selected */}
{selectedMethod && (
<View className="px-5">
<TouchableOpacity
onPress={handleProceed}
className="bg-primary rounded-3xl items-center py-3"
>
<Text className="text-white font-dmsans-bold text-lg">
{t("notificationOption.continueButton")}
</Text>
</TouchableOpacity>
</View>
)}
<View className="h-4" />
<ModalToast
visible={toastVisible}
title={toastTitle}
description={toastDescription}
variant={toastVariant}
/>
<PermissionAlertModal
visible={confirmVisible}
title={confirmTitle}
message={confirmMessage}
primaryText="OK"
secondaryText="Don't Allow"
onPrimary={() => {
setConfirmVisible(false);
confirmActionRef.current && confirmActionRef.current();
}}
onSecondary={() => {
setConfirmVisible(false);
}}
/>
</ScreenWrapper>
);
}

View File

@ -0,0 +1,237 @@
import React, { useEffect, useRef, useState } from "react";
import {
View,
Text,
ScrollView,
Image,
TouchableOpacity,
Share,
} from "react-native";
import { router } from "expo-router";
import ScreenWrapper from "~/components/ui/ScreenWrapper";
import BackButton from "~/components/ui/backButton";
import { Button } from "~/components/ui/button";
import { Icons } from "~/assets/icons";
import { ROUTES } from "~/lib/routes";
import { useTranslation } from "react-i18next";
import { getPointsState, type PointsState } from "~/lib/services/pointsService";
import * as Clipboard from "expo-clipboard";
import ModalToast from "~/components/ui/toast";
// Placeholder referral link
const REFERRAL_LINK = "ambapay.com/frars";
export default function PointsScreen() {
const { t } = useTranslation();
const [pointsState, setPointsState] = useState<PointsState | null>(null);
const [toastVisible, setToastVisible] = useState(false);
const [toastTitle, setToastTitle] = useState("");
const [toastDescription, setToastDescription] = useState<string | undefined>(
undefined
);
const [toastVariant, setToastVariant] = useState<
"success" | "error" | "warning" | "info"
>("info");
const toastTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const state = await getPointsState();
if (!cancelled) {
setPointsState(state);
}
} catch (error) {
if (__DEV__) {
console.warn("[PointsScreen] Failed to load points state", error);
}
}
})();
return () => {
cancelled = true;
};
}, []);
const showToast = (
title: string,
description?: string,
variant: "success" | "error" | "warning" | "info" = "info"
) => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
setToastTitle(title);
setToastDescription(description);
setToastVariant(variant);
setToastVisible(true);
toastTimeoutRef.current = setTimeout(() => {
setToastVisible(false);
toastTimeoutRef.current = null;
}, 2500);
};
useEffect(() => {
return () => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
};
}, []);
const handleCopyLink = async () => {
try {
await Clipboard.setStringAsync(REFERRAL_LINK);
showToast("Copied", "Referral link copied to clipboard", "success");
} catch (error) {
showToast("Error", "Failed to copy referral link", "error");
}
};
const handleShare = async () => {
try {
await Share.share({
message: `Use my Amba referral link: ${REFERRAL_LINK}`,
});
} catch (error) {
// Optional: only show error toast if share actually fails (not cancelled)
showToast("Error", "Failed to open share options", "error");
}
};
const handleActivityPress = () => {
router.push(ROUTES.POINTS_ACTIVITY);
};
const handleHowToEarnPress = () => {
// Navigate to how-to-earn details when available
};
const handleRedeem = (rewardId: string) => {
// Integrate redeem API later
};
return (
<ScreenWrapper edges={["bottom"]}>
<BackButton />
<ScrollView contentContainerStyle={{ paddingBottom: 32 }}>
<View className="px-5 pt-4 space-y-6">
<Text className="text-3xl font-dmsans-bold text-black">
{t("points.title")}
</Text>
{/* Total Points Card */}
<View className="mt-4 bg-[#E9FFF4] rounded-[8px] px-5 py-4 flex-row items-center justify-between">
<View>
<Text className="text-sm font-dmsans text-gray-500 mb-1">
Your Points
</Text>
<Text className="text-3xl font-dmsans-bold text-[#0F7B4A]">
{pointsState?.total ?? 0}
</Text>
</View>
<Image
source={Icons.coinIcon}
style={{ width: 36, height: 36 }}
resizeMode="contain"
/>
</View>
<View className="space-y-4 pt-6">
<Text className="text-xl font-dmsans-medium text-black">
{t("points.referTitle")}
</Text>
<Text className="text-xl font-dmsans-medium text-black">
{t("points.earnSubtitle")}
</Text>
</View>
<View className="bg-[#E9FFF4] rounded-[6px] p-6 mt-8 space-y-3">
<View className="flex-row items-center justify-between bg-[#FFB668] rounded-[4px] px-4 py-4">
<Text
className="text-sm font-dmsans-medium text-black"
numberOfLines={1}
>
{REFERRAL_LINK}
</Text>
<TouchableOpacity
onPress={handleCopyLink}
className="flex-row items-center space-x-2"
>
<Text className="text-md font-dmsans mr-2 text-black">
{t("points.copyButton")}
</Text>
<Image
source={Icons.copyIcon}
style={{ width: 17, height: 17 }}
resizeMode="contain"
/>
</TouchableOpacity>
</View>
<Button
className="mt-3 bg-[#0F7B4A] rounded-[4px] h-11"
onPress={handleShare}
>
<Text className="text-white font-dmsans-bold text-base">
{t("points.shareButton")}
</Text>
</Button>
</View>
<View className="flex-row mt-6 space-x-6 gap-6">
<TouchableOpacity
onPress={handleActivityPress}
className="flex-1 bg-[#0F7B4A] rounded-[4px] px-5 py-4 flex-row items-center justify-center space-x-3"
>
<Image
source={Icons.activityIcon}
style={{
width: 22,
height: 22,
tintColor: "#FFB668",
marginRight: 4,
}}
resizeMode="contain"
/>
<Text className="text-white font-dmsans-bold text-base">
{t("points.activityButton")}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleHowToEarnPress}
className="flex-1 bg-[#0F7B4A] rounded-[4px] px-5 py-4 flex-row items-center justify-center space-x-3"
>
<Image
source={Icons.moneyIcon}
style={{
width: 22,
height: 22,
tintColor: "#FFB668",
marginRight: 4,
}}
resizeMode="contain"
/>
<Text className="text-white font-dmsans-bold text-base">
{t("points.howToEarnButton")}
</Text>
</TouchableOpacity>
</View>
{/* Rewards section can be wired to real redemption in the future */}
</View>
</ScrollView>
<ModalToast
visible={toastVisible}
title={toastTitle}
description={toastDescription}
variant={toastVariant}
/>
</ScreenWrapper>
);
}

View File

@ -0,0 +1,137 @@
import React, { useEffect, useState } from "react";
import { View, Text, ScrollView, Image } from "react-native";
import ScreenWrapper from "~/components/ui/ScreenWrapper";
import BackButton from "~/components/ui/backButton";
import { Button } from "~/components/ui/button";
import { Icons } from "~/assets/icons";
import { useTranslation } from "react-i18next";
import {
getPointsState,
type PointsActivityEntry,
} from "~/lib/services/pointsService";
export default function PointsActivityScreen() {
const { t } = useTranslation();
const [activities, setActivities] = useState<PointsActivityEntry[]>([]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const state = await getPointsState();
if (!cancelled) {
setActivities(state.activities);
}
} catch (error) {
if (__DEV__) {
console.warn("[PointsActivity] Failed to load points state", error);
}
}
})();
return () => {
cancelled = true;
};
}, []);
const renderPointsPill = (points: number) => {
const isPositive = points >= 0;
const backgroundColor = isPositive ? "#0F7B4A" : "#EF4444";
const sign = isPositive ? "+" : "-";
const absPoints = Math.abs(points);
return (
<View
className="rounded-[2px] w-[90px] h-[28px] flex-row items-center justify-center"
style={{ backgroundColor }}
>
<Text className="text-xs font-dmsans text-white">
{t("pointsactivity.pointsPill", { sign, points: absPoints })}
</Text>
</View>
);
};
return (
<ScreenWrapper edges={["bottom"]}>
<BackButton />
<ScrollView contentContainerStyle={{ paddingBottom: 32 }}>
<View className="px-5 pt-6 space-y-6">
<Text className="text-xl font-dmsans-bold text-black">
{t("pointsactivity.title")}
</Text>
<View className="space-y-3 pt-6">
{activities.map((item) => {
const date = new Date(item.timestamp);
const formattedDate = `${date.toLocaleDateString()}${date.toLocaleTimeString(
[],
{
hour: "numeric",
minute: "2-digit",
}
)}`;
let titleKey: string;
switch (item.type) {
case "contact_sync":
titleKey = "Contact Sync";
break;
case "send_money":
titleKey = "Send Money";
break;
case "login":
titleKey = "Login";
break;
case "purchase_ticket":
titleKey = "Purchase Ticket";
break;
case "add_recipient":
titleKey = "Add Recipient";
break;
case "share_event":
titleKey = "Share Event";
break;
case "make_request":
titleKey = "Make Request";
break;
case "referral_link":
titleKey = "Referral Link";
break;
default:
titleKey = item.type;
}
return (
<View
key={item.id}
className="flex-row items-center mb-4 justify-between bg-[#E9FFF4] rounded-[6px] px-4 py-3"
>
<View className="flex-row items-center flex-1">
<View className="w-10 h-10 rounded-[6px] bg-[#FFF7E6] items-center justify-center mr-3">
<Image
source={Icons.coinIcon}
style={{ width: 20, height: 20 }}
resizeMode="contain"
/>
</View>
<View className="flex-1">
<Text className="text-sm font-dmsans text-[#FB923C]">
{titleKey}
</Text>
<Text className="text-xs font-dmsans text-[#064E3B] mt-1">
{formattedDate}
</Text>
</View>
</View>
{renderPointsPill(item.points)}
</View>
);
})}
</View>
</View>
</ScrollView>
</ScreenWrapper>
);
}

View File

@ -0,0 +1,538 @@
import React, { useState, useEffect, useRef } from "react";
import { ScrollView, View, Image, TouchableOpacity } from "react-native";
import { Button } from "~/components/ui/button";
import { Text } from "~/components/ui/text";
import { router } from "expo-router";
import { useAuthWithProfile } from "~/lib/hooks/useAuthWithProfile";
import { ROUTES } from "~/lib/routes";
import ScreenWrapper from "~/components/ui/ScreenWrapper";
import { Icons } from "~/assets/icons";
import ModalToast from "~/components/ui/toast";
import { useTranslation } from "react-i18next";
import {
ChevronRight,
Store,
LifeBuoy,
Bell,
ScanFace,
Grid3x3,
LogOut,
Book,
Award,
Settings,
} from "lucide-react-native";
import Toggle from "~/components/ui/toggle";
import BottomSheet from "~/components/ui/bottomSheet";
import { useLangStore } from "~/lib/stores";
import { getPointsState } from "~/lib/services/pointsService";
import BackButton from "~/components/ui/backButton";
export default function Profile() {
const { t } = useTranslation();
const { signOut, user, profile, profileLoading } = useAuthWithProfile();
const language = useLangStore((state) => state.language);
const setLanguage = useLangStore((state) => state.setLanguage);
// Preferences state
const [pushNotifications, setPushNotifications] = useState(true);
const [smsNotifications, setSmsNotifications] = useState(true);
const [emailNotifications, setEmailNotifications] = useState(true);
const [faceID, setFaceID] = useState(true);
const [profileImage, setProfileImage] = useState<string | null>(null);
const [languageSheetVisible, setLanguageSheetVisible] = useState(false);
const [pointsTotal, setPointsTotal] = useState<number | null>(null);
const [toastVisible, setToastVisible] = useState(false);
const [toastTitle, setToastTitle] = useState("");
const [toastDescription, setToastDescription] = useState<string | undefined>(
undefined
);
const [toastVariant, setToastVariant] = useState<
"success" | "error" | "warning" | "info"
>("info");
const toastTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const showToast = (
title: string,
description?: string,
variant: "success" | "error" | "warning" | "info" = "info"
) => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
setToastTitle(title);
setToastDescription(description);
setToastVariant(variant);
setToastVisible(true);
toastTimeoutRef.current = setTimeout(() => {
setToastVisible(false);
toastTimeoutRef.current = null;
}, 2500);
};
useEffect(() => {
return () => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
};
}, []);
useEffect(() => {
if (profile?.photoUrl) {
setProfileImage(profile.photoUrl);
} else {
setProfileImage(null);
}
}, [profile?.photoUrl]);
useEffect(() => {
(async () => {
try {
const state = await getPointsState();
setPointsTotal(state.total);
} catch (error) {
if (__DEV__) {
console.warn("[Profile] Failed to load points state", error);
}
}
})();
}, []);
const handleLogout = async () => {
try {
await signOut();
showToast(
t("profile.toastLoggedOutTitle"),
t("profile.toastLoggedOutDescription"),
"success"
);
router.replace(ROUTES.SIGNIN);
} catch (error) {
console.log(error);
showToast(
t("profile.toastErrorTitle"),
t("profile.toastLogoutFailed"),
"error"
);
}
};
const handleEditProfile = () => {
router.push(ROUTES.EDIT_PROFILE);
};
const handleMyStores = () => {
// TODO: Navigate to My Stores screen
showToast("My stores", "Coming soon", "info");
};
const handleSupport = () => {
router.push(ROUTES.HELP_SUPPORT);
};
const handleKyc = () => {
router.push(ROUTES.KYC);
};
const handlePoints = () => {
router.push(ROUTES.POINTS);
};
const handleHistory = () => {
router.push(ROUTES.HISTORY);
};
const handleChangePassword = () => {
// Placeholder screen for Change Password
showToast("Change Password", "Coming soon", "info");
};
const handlePINCode = () => {
router.push(ROUTES.CHANGE_PIN);
};
const displayName = profile?.fullName || user?.displayName || "User";
const displayEmail = profile?.email || user?.email || "";
const agentId = user?.uid
? `AGENT-${user.uid.slice(-6).toUpperCase()}`
: "AGENT-001";
const languageOptions = [
{ value: "en", label: t("profile.languageOptionEnglish") },
{ value: "am", label: t("profile.languageOptionAmharic") },
{ value: "fr", label: t("profile.languageOptionFrench") },
{ value: "ti", label: t("profile.languageOptionTigrinya") },
{ value: "om", label: t("profile.languageOptionOromo") },
];
const initialLetter = displayName?.trim().charAt(0).toUpperCase() || "U";
return (
<ScreenWrapper edges={[]}>
<BackButton />
<ScrollView
showsVerticalScrollIndicator={false}
className="flex-1 bg-white"
>
{/* Profile Header */}
<View className="items-center pt-6 pb-6">
{/* Avatar with + icon */}
<View className="mb-4">
<View className="w-24 h-24 rounded-full bg-[#C8E6C9] items-center justify-center overflow-hidden">
{profileImage ? (
<Image
source={{ uri: profileImage }}
className="w-24 h-24 rounded-full"
resizeMode="cover"
/>
) : (
<Image
source={Icons.avatar}
style={{ width: 84, height: 84, resizeMode: "contain" }}
/>
)}
</View>
</View>
{/* Agent Info Card: Name, Role, Agent ID, Email */}
<Text className="text-xl font-dmsans-bold text-gray-900 mb-1">
{profileLoading ? "..." : displayName}
</Text>
<Text className="text-xs font-dmsans text-gray-500 mb-0.5">
Role: Agent
</Text>
<Text className="text-xs font-dmsans text-gray-500 mb-1">
Agent ID: {agentId}
</Text>
<Text className="text-sm font-dmsans text-gray-500 mb-6">
{profileLoading ? "..." : displayEmail}
</Text>
{/* Edit Profile Button */}
<TouchableOpacity
onPress={handleEditProfile}
className="bg-primary px-8 py-3 rounded-full"
activeOpacity={0.8}
>
<Text className="text-white font-dmsans-medium text-sm">
Edit profile
</Text>
</TouchableOpacity>
</View>
<View className="px-5 pt-6">
<Text className="text-xs font-dmsans-medium text-gray-400 mb-3">
Inventories
</Text>
{/* Inventories Card - grouped items */}
<View className="bg-gray-50 rounded-2xl overflow-hidden mb-3">
{/* Points */}
<TouchableOpacity
onPress={handlePoints}
className="p-4 flex-row items-center justify-between"
activeOpacity={0.7}
>
<View className="flex-row items-center flex-1">
<View className="w-10 h-10 bg-white rounded-full items-center justify-center mr-3">
<Award size={20} color="#000" />
</View>
<Text className="text-base font-dmsans text-gray-900 flex-1">
Points
</Text>
<View className="bg-primary w-6 h-6 rounded-full items-center justify-center mr-2">
<Text className="text-white font-dmsans-bold text-xs">
{pointsTotal ?? 0}
</Text>
</View>
</View>
<ChevronRight size={20} color="#9CA3AF" />
</TouchableOpacity>
<View className="h-px bg-gray-200 mx-4" />
{/* Help and Support */}
<TouchableOpacity
onPress={handleSupport}
className="p-4 flex-row items-center justify-between"
activeOpacity={0.7}
>
<View className="flex-row items-center flex-1">
<View className="w-10 h-10 bg-white rounded-full items-center justify-center mr-3">
<LifeBuoy size={20} color="#000" />
</View>
<Text className="text-base font-dmsans text-gray-900">
Help & Support
</Text>
</View>
<ChevronRight size={20} color="#9CA3AF" />
</TouchableOpacity>
<View className="h-px bg-gray-200 mx-4" />
{/* Terms and Conditions */}
<TouchableOpacity
onPress={() => router.push(ROUTES.TERMS)}
className="p-4 flex-row items-center justify-between"
activeOpacity={0.7}
>
<View className="flex-row items-center flex-1">
<View className="w-10 h-10 bg-white rounded-full items-center justify-center mr-3">
<Book size={20} color="#000" />
</View>
<Text className="text-base font-dmsans text-gray-900">
Terms & Conditions
</Text>
</View>
<ChevronRight size={20} color="#9CA3AF" />
</TouchableOpacity>
<View className="h-px bg-gray-200 mx-4" />
<TouchableOpacity
onPress={handleKyc}
className="p-4 flex-row items-center justify-between"
activeOpacity={0.7}
>
<View className="flex-row items-center flex-1">
<View className="w-10 h-10 bg-white rounded-full items-center justify-center mr-3">
<ScanFace size={20} color="#000" />
</View>
<Text className="text-base font-dmsans text-gray-900 flex-1">
Information
</Text>
</View>
<ChevronRight size={20} color="#9CA3AF" />
</TouchableOpacity>
<View className="h-px bg-gray-200 mx-4" />
</View>
</View>
{/* Preferences Section */}
<View className="px-5 pt-6 pb-8">
<Text className="text-xs font-dmsans-medium text-gray-400 mb-3">
Preferences
</Text>
{/* Preferences Card - grouped items */}
<View className="bg-gray-50 rounded-2xl overflow-hidden mb-3">
{/* SMS notifications */}
<View className="p-4 flex-row items-center justify-between">
<View className="flex-row items-center flex-1">
<View className="w-10 h-10 bg-white rounded-full items-center justify-center mr-3">
<Bell size={20} color="#000" />
</View>
<Text className="text-base font-dmsans text-gray-900">
SMS notifications
</Text>
</View>
<Toggle
value={smsNotifications}
onValueChange={setSmsNotifications}
/>
</View>
{/* Divider */}
<View className="h-px bg-gray-200 mx-4" />
{/* In-app notifications */}
<View className="p-4 flex-row items-center justify-between">
<View className="flex-row items-center flex-1">
<View className="w-10 h-10 bg-white rounded-full items-center justify-center mr-3">
<Bell size={20} color="#000" />
</View>
<Text className="text-base font-dmsans text-gray-900">
In-app notifications
</Text>
</View>
<Toggle
value={pushNotifications}
onValueChange={setPushNotifications}
/>
</View>
{/* Divider */}
<View className="h-px bg-gray-200 mx-4" />
{/* Email notifications */}
<View className="p-4 flex-row items-center justify-between">
<View className="flex-row items-center flex-1">
<View className="w-10 h-10 bg-white rounded-full items-center justify-center mr-3">
<Bell size={20} color="#000" />
</View>
<Text className="text-base font-dmsans text-gray-900">
Email notifications
</Text>
</View>
<Toggle
value={emailNotifications}
onValueChange={setEmailNotifications}
/>
</View>
{/* Divider */}
<View className="h-px bg-gray-200 mx-4" />
{/* Biometric Login (Face ID) */}
<View className="p-4 flex-row items-center justify-between">
<View className="flex-row items-center flex-1">
<View className="w-10 h-10 bg-white rounded-full items-center justify-center mr-3">
<ScanFace size={20} color="#000" />
</View>
<Text className="text-base font-dmsans text-gray-900">
Biometric Login
</Text>
</View>
<Toggle value={faceID} onValueChange={setFaceID} />
</View>
{/* Divider */}
<View className="h-px bg-gray-200 mx-4" />
{/* Language */}
<TouchableOpacity
onPress={() => setLanguageSheetVisible(true)}
className="p-4 flex-row items-center justify-between"
activeOpacity={0.7}
>
<View className="flex-row items-center flex-1">
<View className="w-10 h-10 bg-white rounded-full items-center justify-center mr-3">
<Grid3x3 size={20} color="#000" />
</View>
<View className="flex-1">
<Text className="text-base font-dmsans text-gray-900">
{t("profile.languageLabel")}
</Text>
<Text className="text-xs font-dmsans text-gray-500 mt-0.5">
{
languageOptions.find((opt) => opt.value === language)
?.label
}
</Text>
</View>
</View>
<ChevronRight size={20} color="#9CA3AF" />
</TouchableOpacity>
{/* Divider */}
<View className="h-px bg-gray-200 mx-4" />
{/* Reports / Transaction history */}
<TouchableOpacity
onPress={handleHistory}
className="p-4 flex-row items-center justify-between"
activeOpacity={0.7}
>
<View className="flex-row items-center flex-1">
<View className="w-10 h-10 bg-white rounded-full items-center justify-center mr-3">
<Settings size={20} color="#000" />
</View>
<Text className="text-base font-dmsans text-gray-900 flex-1">
View Reports
</Text>
</View>
<ChevronRight size={20} color="#9CA3AF" />
</TouchableOpacity>
{/* Divider */}
<View className="h-px bg-gray-200 mx-4" />
{/* Change Password (placeholder) */}
<TouchableOpacity
onPress={handleChangePassword}
className="p-4 flex-row items-center justify-between"
activeOpacity={0.7}
>
<View className="flex-row items-center flex-1">
<View className="w-10 h-10 bg-white rounded-full items-center justify-center mr-3">
<Grid3x3 size={20} color="#000" />
</View>
<Text className="text-base font-dmsans text-gray-900">
Change Password
</Text>
</View>
<ChevronRight size={20} color="#9CA3AF" />
</TouchableOpacity>
{/* Divider */}
<View className="h-px bg-gray-200 mx-4" />
{/* PIN Code */}
<TouchableOpacity
onPress={handlePINCode}
className="p-4 flex-row items-center justify-between"
activeOpacity={0.7}
>
<View className="flex-row items-center flex-1">
<View className="w-10 h-10 bg-white rounded-full items-center justify-center mr-3">
<Grid3x3 size={20} color="#000" />
</View>
<Text className="text-base font-dmsans text-gray-900">
PIN Code
</Text>
</View>
<ChevronRight size={20} color="#9CA3AF" />
</TouchableOpacity>
</View>
{/* Logout - separate card */}
<TouchableOpacity
onPress={handleLogout}
className="bg-gray-50 rounded-2xl p-4 flex-row items-center justify-between"
activeOpacity={0.7}
>
<View className="flex-row items-center flex-1">
<View className="w-10 h-10 bg-white rounded-full items-center justify-center mr-3">
<LogOut size={20} color="#EF4444" />
</View>
<Text className="text-base font-dmsans text-red-500">Logout</Text>
</View>
</TouchableOpacity>
</View>
</ScrollView>
<ModalToast
visible={toastVisible}
title={toastTitle}
description={toastDescription}
variant={toastVariant}
/>
<BottomSheet
visible={languageSheetVisible}
onClose={() => setLanguageSheetVisible(false)}
maxHeightRatio={0.4}
>
<View className="w-full py-2">
{languageOptions.map((opt) => {
const selected = language === opt.value;
return (
<TouchableOpacity
key={opt.value}
activeOpacity={0.8}
onPress={() => {
setLanguage(opt.value as any);
setLanguageSheetVisible(false);
}}
className="py-3 flex-row items-center border-b border-gray-100"
>
<View className="mr-3">
{selected ? (
<View className="w-4 h-4 rounded-full bg-primary" />
) : (
<View className="w-4 h-4 rounded-full border border-gray-300" />
)}
</View>
<Text className="text-base font-dmsans text-gray-800">
{opt.label}
</Text>
</TouchableOpacity>
);
})}
</View>
</BottomSheet>
</ScreenWrapper>
);
}

View File

@ -0,0 +1,364 @@
import React, { useEffect, useRef, useState } from "react";
import {
View,
Text,
TouchableOpacity,
Share,
ActivityIndicator,
Platform,
} from "react-native";
import { ArrowLeft } from "lucide-react-native";
import ScreenWrapper from "~/components/ui/ScreenWrapper";
import { Button } from "~/components/ui/button";
import { router } from "expo-router";
import ModalToast from "~/components/ui/toast";
import { useTranslation } from "react-i18next";
import QRCode from "react-native-qrcode-svg";
import { useAuthWithProfile } from "~/lib/hooks/useAuthWithProfile";
import { UserQrService } from "~/lib/services/userQrService";
import {
CameraView,
useCameraPermissions,
type BarcodeScanningResult,
} from "expo-camera";
import { ROUTES } from "~/lib/routes";
import BackButton from "~/components/ui/backButton";
export default function QRScreen() {
const { t } = useTranslation();
const { user, profile, wallet } = useAuthWithProfile();
const [activeTab, setActiveTab] = useState<"scan" | "my">("scan");
const [toastVisible, setToastVisible] = useState(false);
const [toastTitle, setToastTitle] = useState("");
const [toastDescription, setToastDescription] = useState<string | undefined>(
undefined
);
const [toastVariant, setToastVariant] = useState<
"success" | "error" | "warning" | "info"
>("info");
const toastTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Scanner state
const [hasPermission, setHasPermission] = useState<null | boolean>(null);
const [scanned, setScanned] = useState(false);
const [permission, requestPermission] = useCameraPermissions();
const displayName =
profile?.fullName || user?.displayName || t("profile.usernamePlaceholder");
const phoneNumber = profile?.phoneNumber || (user as any)?.phoneNumber || "";
const accountId = wallet?.uid || user?.uid || "";
const [qrPayload, setQrPayload] = useState<string | null>(null);
const showToast = (
title: string,
description?: string,
variant: "success" | "error" | "warning" | "info" = "info"
) => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
setToastTitle(title);
setToastDescription(description);
setToastVariant(variant);
setToastVisible(true);
toastTimeoutRef.current = setTimeout(() => {
setToastVisible(false);
toastTimeoutRef.current = null;
}, 2500);
};
useEffect(() => {
return () => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
};
}, []);
// Sync camera permission state for Scan tab
useEffect(() => {
if (Platform.OS === "web") {
setHasPermission(false);
return;
}
if (!permission) {
return;
}
setHasPermission(permission.granted);
}, [permission]);
useEffect(() => {
let isMounted = true;
const loadQr = async () => {
if (!user?.uid) return;
try {
const payload = await UserQrService.getOrCreateProfileQr({
uid: user.uid,
accountId,
name: displayName,
phoneNumber,
});
if (isMounted) {
setQrPayload(payload);
}
} catch (error) {
console.error("[QRScreen] Failed to load/create profile QR", error);
}
};
loadQr();
return () => {
isMounted = false;
};
}, [user?.uid, accountId, displayName, phoneNumber]);
const handleBarCodeScanned = ({ data }: BarcodeScanningResult) => {
if (scanned) return;
try {
setScanned(true);
const parsed = JSON.parse(data);
if (!parsed || parsed.type !== "AMBA_PROFILE") {
showToast(
"Invalid QR",
"This is not a valid Amba profile QR.",
"error"
);
setScanned(false);
return;
}
const accountIdFromQr: string | undefined = parsed.accountId;
const nameFromQr: string | undefined = parsed.name;
const phoneNumberFromQr: string | undefined = parsed.phoneNumber;
if (!phoneNumberFromQr) {
showToast(
"Invalid QR",
"This profile QR does not contain a phone number.",
"error"
);
setScanned(false);
return;
}
router.push({
pathname: ROUTES.SEND_OR_REQUEST_MONEY,
params: {
selectedContactId: accountIdFromQr || phoneNumberFromQr,
selectedContactName: nameFromQr || "User",
selectedContactPhone: phoneNumberFromQr,
},
});
} catch (error) {
console.warn("[QRScreen] Failed to parse QR payload", error);
showToast("Scan failed", "Could not read this QR code.", "error");
setScanned(false);
}
};
const handleShare = async () => {
try {
await Share.share({
message: t("qrscreen.shareMessage"),
});
} catch (error) {
console.log("Error sharing QR:", error);
showToast(
t("qrscreen.toastErrorTitle"),
t("qrscreen.toastShareError"),
"error"
);
}
};
const handleClose = () => {
router.back();
};
return (
<ScreenWrapper edges={[]}>
<View className="flex-1 bg-white">
{/* Top back button */}
<View className="flex-row justify-start">
<BackButton />
</View>
{/* Tabs - match KYC style */}
<View className="flex-row mt-5 mb-4 mx-6 bg-[#F3F4F6] rounded-full p-1">
<TouchableOpacity
onPress={() => {
setActiveTab("scan");
setScanned(false);
}}
className={`flex-1 items-center py-2 rounded-full ${
activeTab === "scan" ? "bg-white" : "bg-transparent"
}`}
activeOpacity={0.8}
>
<Text
className={`text-base font-dmsans-medium ${
activeTab === "scan" ? "text-[#0F7B4A]" : "text-gray-500"
}`}
>
{t("qrscreen.scanTabLabel", "Scan QR")}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => setActiveTab("my")}
className={`flex-1 items-center py-2 rounded-full ${
activeTab === "my" ? "bg-white" : "bg-transparent"
}`}
activeOpacity={0.8}
>
<Text
className={`text-base font-dmsans-medium ${
activeTab === "my" ? "text-[#0F7B4A]" : "text-gray-500"
}`}
>
{t("qrscreen.myTabLabel", "My QR")}
</Text>
</TouchableOpacity>
</View>
{/* Tab content */}
{activeTab === "my" ? (
<>
<View className="flex-1 items-center justify-center px-5">
{qrPayload ? (
<QRCode
value={qrPayload}
size={260}
color="#0F7B4A"
backgroundColor="white"
/>
) : (
<View className="items-center justify-center">
<ActivityIndicator size="large" color="#0F7B4A" />
<View className="h-3" />
<Text className="text-sm font-dmsans text-gray-600">
{t("qrscreen.loadingLabel", "Preparing your QR code...")}
</Text>
</View>
)}
</View>
<View className="w-full flex flex-col px-5 pb-10 gap-4">
<Button
className="h-13 pb-6 rounded-full bg-[#FFB668]"
onPress={handleShare}
>
<Text className="text-white font-dmsans-bold text-base">
{t("qrscreen.shareButton")}
</Text>
</Button>
<Button
className="h-13 rounded-full bg-[#0F7B4A]"
onPress={handleClose}
>
<Text className="text-white font-dmsans-bold text-base">
{t("qrscreen.goBackButton")}
</Text>
</Button>
</View>
</>
) : (
<View className="flex-1 items-center justify-center px-5">
{Platform.OS === "web" ? (
<View className="flex-1 items-center justify-center px-5">
<Text className="text-base font-dmsans text-gray-600 text-center">
QR scanning is not supported on web. Please use a mobile
device.
</Text>
</View>
) : hasPermission === null ? (
<View className="flex-1 items-center justify-center px-5">
<ActivityIndicator size="large" color="#0F7B4A" />
<View className="h-4" />
<Text className="text-base font-dmsans text-gray-600">
Requesting camera permission...
</Text>
</View>
) : hasPermission === false ? (
<View className="flex-1 items-center justify-center px-5">
<Text className="text-base font-dmsans text-gray-800 mb-2 text-center">
Camera permission needed
</Text>
<Text className="text-sm font-dmsans text-gray-500 mb-4 text-center">
Please grant camera access to scan profile QR codes.
</Text>
<Button
className="rounded-full bg-primary px-8"
onPress={async () => {
try {
const result = await requestPermission();
if (result) {
setHasPermission(result.granted);
}
} catch (error) {
console.warn(
"[QRScreen] Failed to re-request camera permission",
error
);
showToast(
"Permission error",
"Could not update camera permission.",
"error"
);
}
}}
>
<Text className="text-white font-dmsans-medium">
Grant permission
</Text>
</Button>
</View>
) : (
<View className="flex-1 items-center justify-center px-5">
<View
style={{ overflow: "hidden", borderRadius: 24 }}
className="w-full aspect-square max-w-[320px] bg-black"
>
<CameraView
style={{ width: "100%", height: "100%" }}
barcodeScannerSettings={{ barcodeTypes: ["qr"] }}
onBarcodeScanned={
scanned ? undefined : handleBarCodeScanned
}
active={true}
/>
</View>
<View className="h-6" />
<Text className="text-base font-dmsans text-gray-800 mb-1 text-center">
Scan profile QR code
</Text>
<Text className="text-sm font-dmsans text-gray-500 text-center px-4">
Align the QR code inside the frame. We will automatically
select the account and take you to the amount screen.
</Text>
</View>
)}
</View>
)}
</View>
<ModalToast
visible={toastVisible}
title={toastTitle}
description={toastDescription}
variant={toastVariant}
/>
</ScreenWrapper>
);
}

View File

@ -0,0 +1,147 @@
import React, { useEffect, useRef, useState } from "react";
import { View, Text, ScrollView, Share } from "react-native";
import { Button } from "~/components/ui/button";
import { SafeAreaView } from "react-native-safe-area-context";
import { router, useLocalSearchParams } from "expo-router";
import { ROUTES } from "~/lib/routes";
import ScreenWrapper from "~/components/ui/ScreenWrapper";
import ModalToast from "~/components/ui/toast";
import { useTranslation } from "react-i18next";
import LottieView from "lottie-react-native";
export default function RecipAddedComp() {
const { t } = useTranslation();
const params = useLocalSearchParams<{
message?: string;
recipientName?: string;
recipientPhone?: string;
}>();
const [toastVisible, setToastVisible] = useState(false);
const [toastTitle, setToastTitle] = useState("");
const [toastDescription, setToastDescription] = useState<string | undefined>(
undefined
);
const [toastVariant, setToastVariant] = useState<
"success" | "error" | "warning" | "info"
>("info");
const toastTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const showToast = (
title: string,
description?: string,
variant: "success" | "error" | "warning" | "info" = "info"
) => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
setToastTitle(title);
setToastDescription(description);
setToastVariant(variant);
setToastVisible(true);
toastTimeoutRef.current = setTimeout(() => {
setToastVisible(false);
toastTimeoutRef.current = null;
}, 2500);
};
useEffect(() => {
return () => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
};
}, []);
const handleAddAnother = () => {
// Navigate to add recipient page
router.replace(ROUTES.ADD_RECIPIENT);
};
const handleGoToRecipients = () => {
// Navigate to list recipients page
router.replace(ROUTES.LIST_RECIPIENTS);
};
const handleShare = async () => {
try {
const shareMessage = params.message
? t("recipaddedcomp.shareMessageWithParam", {
message: params.message,
})
: t("recipaddedcomp.shareMessageDefault");
const result = await Share.share({
message: shareMessage,
title: t("recipaddedcomp.shareTitle"),
});
if (result.action === Share.sharedAction) {
// Content was shared
console.log("Content shared successfully");
} else if (result.action === Share.dismissedAction) {
// Share dialog was dismissed
console.log("Share dialog dismissed");
}
} catch (error) {
console.error("Error sharing:", error);
showToast(
t("recipaddedcomp.toastErrorTitle"),
t("recipaddedcomp.toastShareError"),
"error"
);
}
};
return (
<ScreenWrapper edges={[]}>
<View className="flex-1 w-full justify-between">
{/* Center content */}
<View className="flex-1 justify-center items-center px-5">
<LottieView
source={require("../../../assets/lottie/Recipient.json")}
autoPlay
loop={false}
style={{ width: 350, height: 350 }}
/>
<View className="h-10" />
<Text className="text-primary text-3xl">
{t("recipaddedcomp.title")}
</Text>
<View className="h-4" />
<Text className="text-xl font-regular text-gray-400 font-dmsans text-center">
{t("recipaddedcomp.description")}
</Text>
</View>
<View className="w-full px-5 pb-8">
<Button
className="bg-primary rounded-full"
onPress={handleAddAnother}
>
<Text className="font-dmsans text-white">
{t("recipaddedcomp.addButton")}
</Text>
</Button>
<View className="h-4" />
<Button
className="bg-white border border-dashed border-secondary rounded-full"
onPress={() => router.replace(ROUTES.HOME)}
>
<Text className="font-dmsans text-black">
{t("recipaddedcomp.goHomeButton")}
</Text>
</Button>
</View>
</View>
<ModalToast
visible={toastVisible}
title={toastTitle}
description={toastDescription}
variant={toastVariant}
/>
</ScreenWrapper>
);
}

View File

@ -0,0 +1,262 @@
import React, { useMemo, useState } from "react";
import { View, Text, TouchableOpacity, ScrollView } from "react-native";
import { useLocalSearchParams, router } from "expo-router";
import { useRecipientsStore } from "~/lib/stores";
import ScreenWrapper from "~/components/ui/ScreenWrapper";
import BackButton from "~/components/ui/backButton";
import { Button } from "~/components/ui/button";
import { ROUTES } from "~/lib/routes";
import {
LucideUser,
LucideCreditCard,
LucideCalendarClock,
LucideChevronRight,
} from "lucide-react-native";
function getInitials(name: string) {
return name
.split(" ")
.map((word) => word.charAt(0).toUpperCase())
.slice(0, 2)
.join("");
}
export default function RecipDetail() {
const { recipientId } = useLocalSearchParams<{ recipientId?: string }>();
const { recipients } = useRecipientsStore();
const recipient = useMemo(
() => recipients.find((r) => r.id === recipientId),
[recipients, recipientId]
);
const initials = recipient ? getInitials(recipient.fullName) : "?";
const lowerName = recipient?.fullName.toLowerCase() ?? "";
const isBusiness =
lowerName.includes("ltd") ||
lowerName.includes("plc") ||
lowerName.includes("inc") ||
lowerName.includes("company");
const clientType = isBusiness ? "Business" : "Individual";
// Dummy accounts for UI-only
const accounts = useMemo(
() => [
{
id: "acc-1",
bank: "Bank of Abyssinia",
number: "***1234",
primary: true,
},
{
id: "acc-2",
bank: "Dashen Bank",
number: "***5678",
primary: false,
},
],
[]
);
// Dummy schedules for UI-only
const schedules = useMemo(
() => [
{ id: "sch-1", label: "Every Monday · 10:00" },
{ id: "sch-2", label: "Monthly, 1st · 09:00" },
],
[]
);
const [showManageSchedules, setShowManageSchedules] = useState(false);
const handlePayNow = () => {
if (!recipient) return;
router.push({
pathname: ROUTES.SEND_OR_REQUEST_MONEY,
params: { recipientId: recipient.id, recipientName: recipient.fullName },
});
};
const handleViewTransactions = () => {
if (!recipient) return;
router.push({
pathname: ROUTES.HISTORY,
params: { recipientId: recipient.id, recipientName: recipient.fullName },
});
};
if (!recipient) {
return (
<ScreenWrapper edges={[]}>
<View className="flex-1 items-center justify-center px-6">
<Text className="text-lg font-dmsans text-gray-500 text-center">
Recipient not found.
</Text>
<Button className="mt-4" onPress={() => router.back()}>
<Text className="text-white font-dmsans">Go back</Text>
</Button>
</View>
</ScreenWrapper>
);
}
return (
<ScreenWrapper edges={[]}>
<View className="flex-1 w-full">
<ScrollView
className="flex-1 w-full"
contentContainerStyle={{ paddingBottom: 32 }}
>
<View className="">
<BackButton />
</View>
{/* Header */}
<View className="px-5 pt-6 flex flex-row items-center">
<View className="w-14 h-14 rounded-full bg-primary items-center justify-center mr-3">
<Text className="text-white font-dmsans-bold text-xl">
{initials}
</Text>
</View>
<View className="flex-1">
<Text
className="text-lg font-dmsans-bold text-primary"
numberOfLines={1}
>
{recipient.fullName}
</Text>
<View className="mt-1 flex-row items-center">
<View className="px-2 py-[2px] rounded-md bg-[#FFB668] mr-2">
<Text className="text-[11px] font-dmsans-medium text-white">
{clientType}
</Text>
</View>
<Text
className="text-xs font-dmsans text-gray-500"
numberOfLines={1}
>
{recipient.phoneNumber}
</Text>
</View>
</View>
</View>
{/* Accounts Section */}
<View className="px-5 pt-8">
<Text className="text-base font-dmsans-bold text-primary mb-3">
Linked Accounts
</Text>
{accounts.map((acc) => (
<View
key={acc.id}
className="flex-row items-center justify-between bg-white rounded-md px-4 py-3 mb-2 border border-[#E5E7EB]"
>
<View className="flex-row items-center">
<View className="w-8 h-8 rounded-full bg-emerald-50 items-center justify-center mr-3">
<LucideCreditCard color="#10B981" size={16} />
</View>
<View>
<Text className="text-sm font-dmsans-medium text-gray-900">
{acc.bank}
</Text>
<Text className="text-xs font-dmsans text-gray-500 mt-[2px]">
{acc.number}
</Text>
</View>
</View>
{acc.primary && (
<Text className="text-[11px] font-dmsans-medium text-emerald-600">
Primary
</Text>
)}
</View>
))}
<TouchableOpacity
className="mt-3 flex-row items-center justify-center py-3 rounded-md border border-dashed border-primary"
onPress={() => {}}
>
<Text className="text-sm font-dmsans-medium text-primary">
Add Account
</Text>
</TouchableOpacity>
</View>
{/* Schedules Section */}
<View className="px-5 pt-8">
<Text className="text-base font-dmsans-bold text-primary mb-3">
Payment Schedules
</Text>
{schedules.map((sch) => (
<View
key={sch.id}
className="flex-row items-center justify-between bg-white rounded-md px-4 py-3 mb-2 border border-[#E5E7EB]"
>
<View className="flex-row items-center">
<View className="w-8 h-8 rounded-full bg-amber-50 items-center justify-center mr-3">
<LucideCalendarClock color="#F59E0B" size={16} />
</View>
<Text className="text-sm font-dmsans-medium text-gray-900">
{sch.label}
</Text>
</View>
<LucideChevronRight color="#9CA3AF" size={16} />
</View>
))}
<TouchableOpacity
className="mt-3 flex-row items-center justify-center py-3 rounded-md border border-primary bg-primary/5"
onPress={() => setShowManageSchedules(true)}
>
<Text className="text-sm font-dmsans-medium text-primary">
Manage Schedules
</Text>
</TouchableOpacity>
</View>
</ScrollView>
{/* Actions */}
<View className="px-5 pb-7 flex-row gap-3">
<Button
className="flex-1 bg-primary rounded-3xl"
onPress={handlePayNow}
>
<Text className="text-white font-dmsans-medium text-base">
Pay Now
</Text>
</Button>
<Button
className="flex-1 bg-white border border-[#E5E7EB] rounded-3xl"
onPress={handleViewTransactions}
>
<Text className="text-primary font-dmsans-medium text-base">
View Transactions
</Text>
</Button>
</View>
</View>
{/* Simple placeholder bottom sheet for Manage Schedules */}
{showManageSchedules && (
<View className="absolute inset-x-0 bottom-0 bg-white rounded-t-3xl px-5 pt-4 pb-6 border-t border-[#E5E7EB]">
<View className="items-center mb-3">
<View className="w-10 h-1.5 rounded-full bg-gray-300" />
</View>
<Text className="text-base font-dmsans-bold text-primary mb-3">
Manage Schedules (UI only)
</Text>
<Text className="text-xs font-dmsans text-gray-500 mb-4">
This is a placeholder UI. Editing schedules will be wired to backend
in a later phase.
</Text>
<Button
className="bg-primary rounded-2xl"
onPress={() => setShowManageSchedules(false)}
>
<Text className="text-white font-dmsans-medium">Close</Text>
</Button>
</View>
)}
</ScreenWrapper>
);
}

View File

@ -0,0 +1,203 @@
import React, { useState, useRef } from "react";
import { View, Text, ScrollView, TouchableOpacity } from "react-native";
import { router, useLocalSearchParams } from "expo-router";
import ScreenWrapper from "~/components/ui/ScreenWrapper";
import BackButton from "~/components/ui/backButton";
import { AwashIcon, TeleBirrIcon } from "~/components/ui/icons";
import { LucideChevronRightCircle } from "lucide-react-native";
import { Button } from "~/components/ui/button";
import ModalToast from "~/components/ui/toast";
import { useTranslation } from "react-i18next";
import { ROUTES } from "~/lib/routes";
import { awardPoints } from "~/lib/services/pointsService";
export default function RequestProvider() {
const { t } = useTranslation();
const params = useLocalSearchParams<{
amount: string;
recipientName: string;
recipientPhoneNumber: string;
recipientType: string;
recipientId: string;
note: string;
requestorName: string;
requestorPhoneNumber: string;
requestorType: string;
type: string;
}>();
const [selectedProvider, setSelectedProvider] = useState<
"awash" | "telebirr" | null
>(null);
const [toastVisible, setToastVisible] = useState(false);
const [toastTitle, setToastTitle] = useState("");
const [toastDescription, setToastDescription] = useState<string | undefined>(
undefined
);
const [toastVariant, setToastVariant] = useState<
"success" | "error" | "warning" | "info"
>("info");
const toastTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const showToast = (
title: string,
description?: string,
variant: "success" | "error" | "warning" | "info" = "info"
) => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
setToastTitle(title);
setToastDescription(description);
setToastVariant(variant);
setToastVisible(true);
toastTimeoutRef.current = setTimeout(() => {
setToastVisible(false);
toastTimeoutRef.current = null;
}, 2500);
};
const handleContinue = () => {
if (!selectedProvider) {
showToast(
t("sendbank.toastErrorTitle"),
t("sendbank.toastNoMethod"),
"error"
);
return;
}
awardPoints("make_request").catch((error) => {
console.warn(
"[RequestProvider] Failed to award make request points",
error
);
});
router.push({
pathname: ROUTES.NOTIFICATION_OPTION,
params: {
...params,
provider: selectedProvider,
} as any,
});
};
return (
<ScreenWrapper edges={["top"]}>
<View className="flex-row items-center justify-between">
<BackButton />
</View>
<ScrollView className="flex-1 px-5 pt-2">
<View className="mb-6">
<Text className="text-2xl font-dmsans-bold text-black mb-2">
{t("sendbank.paymentOptionsTitle")}
</Text>
<Text className="text-base font-dmsans text-gray-500">
{selectedProvider
? t("sendbank.paymentOptionsSelected", {
providerName:
selectedProvider === "awash"
? t("sendbank.awashName")
: t("sendbank.telebirrName"),
})
: t("sendbank.paymentOptionsUnselected")}
</Text>
</View>
{/* Telebirr Section */}
<View className="mb-6">
<Text className="text-base font-dmsans-medium text-gray-600 mb-1">
{t("sendbank.telebirrName")}
</Text>
<TouchableOpacity
onPress={() => setSelectedProvider("telebirr")}
className={`flex flex-row w-full justify-between items-center py-4 rounded-md px-3 mb-4 ${
selectedProvider === "telebirr"
? "bg-orange-100 border-2 border-orange-300"
: "bg-green-50 border border-gray-200"
}`}
>
<View className="flex flex-row space-x-3 items-center flex-1">
<View className="p-2 rounded">
<TeleBirrIcon />
</View>
<View className="w-2" />
<View className="flex flex-col">
<Text className="font-dmsans text-primary">
{t("sendbank.telebirrName")}
</Text>
<Text className="font-dmsans-medium text-secondary text-sm">
{t("sendbank.telebirrSubtitle")}
</Text>
</View>
</View>
<View className="flex space-y-1">
<LucideChevronRightCircle
color={selectedProvider === "telebirr" ? "#EA580C" : "#FFB84D"}
size={24}
/>
</View>
</TouchableOpacity>
</View>
{/* Bank Section with Awash */}
<View className="mb-6">
<Text className="text-base font-dmsans-medium text-gray-600 mb-1">
Bank
</Text>
<TouchableOpacity
onPress={() => setSelectedProvider("awash")}
className={`flex flex-row w-full justify-between items-center py-4 rounded-md px-3 ${
selectedProvider === "awash"
? "bg-blue-100 border-2 border-blue-300"
: "bg-green-50 border border-gray-200"
}`}
>
<View className="flex flex-row space-x-3 items-center flex-1">
<View className="p-2 rounded">
<AwashIcon />
</View>
<View className="flex flex-col">
<Text className="font-dmsans text-primary">
{t("sendbank.awashName")}
</Text>
<Text className="font-dmsans-medium text-secondary text-sm">
{t("sendbank.awashSubtitle")}
</Text>
</View>
</View>
<View className="flex space-y-1">
<LucideChevronRightCircle
color={selectedProvider === "awash" ? "#2563EB" : "#FFB84D"}
size={24}
/>
</View>
</TouchableOpacity>
</View>
</ScrollView>
<View className="w-full px-5 pb-6">
<Button
className="bg-primary rounded-3xl w-full py-4"
onPress={handleContinue}
>
<Text className="text-white font-dmsans-bold text-lg">
{t("notificationOption.continueButton")}
</Text>
</Button>
</View>
<ModalToast
visible={toastVisible}
title={toastTitle}
description={toastDescription}
variant={toastVariant}
/>
</ScreenWrapper>
);
}

View File

@ -0,0 +1,220 @@
import React, { useEffect, useRef, useState } from "react";
import {
View,
Text,
TouchableOpacity,
ActivityIndicator,
Platform,
} from "react-native";
import { ArrowLeft } from "lucide-react-native";
import { router } from "expo-router";
import {
CameraView,
useCameraPermissions,
type BarcodeScanningResult,
} from "expo-camera";
import ScreenWrapper from "~/components/ui/ScreenWrapper";
import { Button } from "~/components/ui/button";
import ModalToast from "~/components/ui/toast";
import { ROUTES } from "~/lib/routes";
export default function ScanProfileQR() {
const [hasPermission, setHasPermission] = useState<null | boolean>(null);
const [scanned, setScanned] = useState(false);
const [toastVisible, setToastVisible] = useState(false);
const [toastTitle, setToastTitle] = useState("");
const [toastDescription, setToastDescription] = useState<string | undefined>(
undefined
);
const [toastVariant, setToastVariant] = useState<
"success" | "error" | "warning" | "info"
>("info");
const toastTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [permission, requestPermission] = useCameraPermissions();
const showToast = (
title: string,
description?: string,
variant: "success" | "error" | "warning" | "info" = "info"
) => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
setToastTitle(title);
setToastDescription(description);
setToastVariant(variant);
setToastVisible(true);
toastTimeoutRef.current = setTimeout(() => {
setToastVisible(false);
toastTimeoutRef.current = null;
}, 2500);
};
useEffect(() => {
return () => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
};
}, []);
useEffect(() => {
if (Platform.OS === "web") {
setHasPermission(false);
return;
}
if (!permission) {
return;
}
setHasPermission(permission.granted);
}, [permission]);
const handleClose = () => {
router.back();
};
const handleBarCodeScanned = ({ data }: BarcodeScanningResult) => {
if (scanned) return;
try {
setScanned(true);
const parsed = JSON.parse(data);
if (!parsed || parsed.type !== "AMBA_PROFILE") {
showToast(
"Invalid QR",
"This is not a valid Amba profile QR.",
"error"
);
setScanned(false);
return;
}
const accountId: string | undefined = parsed.accountId;
const name: string | undefined = parsed.name;
const phoneNumber: string | undefined = parsed.phoneNumber;
if (!phoneNumber) {
showToast(
"Invalid QR",
"This profile QR does not contain a phone number.",
"error"
);
setScanned(false);
return;
}
router.push({
pathname: ROUTES.SEND_OR_REQUEST_MONEY,
params: {
selectedContactId: accountId || phoneNumber,
selectedContactName: name || "User",
selectedContactPhone: phoneNumber,
},
});
} catch (error) {
console.warn("[ScanProfileQR] Failed to parse QR payload", error);
showToast("Scan failed", "Could not read this QR code.", "error");
setScanned(false);
}
};
return (
<ScreenWrapper edges={[]}>
<View className="flex-1 bg-white">
{/* Top back button */}
<View className="flex-row justify-start px-5 pt-5">
<TouchableOpacity
onPress={handleClose}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<ArrowLeft size={22} color="#111827" />
</TouchableOpacity>
</View>
{Platform.OS === "web" ? (
<View className="flex-1 items-center justify-center px-5">
<Text className="text-base font-dmsans text-gray-600 text-center">
QR scanning is not supported on web. Please use a mobile device.
</Text>
</View>
) : hasPermission === null ? (
<View className="flex-1 items-center justify-center px-5">
<ActivityIndicator size="large" color="#0F7B4A" />
<View className="h-4" />
<Text className="text-base font-dmsans text-gray-600">
Requesting camera permission...
</Text>
</View>
) : hasPermission === false ? (
<View className="flex-1 items-center justify-center px-5">
<Text className="text-base font-dmsans text-gray-800 mb-2 text-center">
Camera permission needed
</Text>
<Text className="text-sm font-dmsans text-gray-500 mb-4 text-center">
Please grant camera access to scan profile QR codes.
</Text>
<Button
className="rounded-full bg-primary px-8"
onPress={async () => {
try {
const result = await requestPermission();
if (result) {
setHasPermission(result.granted);
}
} catch (error) {
console.warn(
"[ScanProfileQR] Failed to re-request camera permission",
error
);
showToast(
"Permission error",
"Could not update camera permission.",
"error"
);
}
}}
>
<Text className="text-white font-dmsans-medium">
Grant permission
</Text>
</Button>
</View>
) : (
<View className="flex-1 items-center justify-center px-5">
<View
style={{ overflow: "hidden", borderRadius: 24 }}
className="w-full aspect-square max-w-[320px] bg-black"
>
<CameraView
style={{ width: "100%", height: "100%" }}
barcodeScannerSettings={{ barcodeTypes: ["qr"] }}
onBarcodeScanned={scanned ? undefined : handleBarCodeScanned}
active={true}
/>
</View>
<View className="h-6" />
<Text className="text-base font-dmsans text-gray-800 mb-1 text-center">
Scan profile QR code
</Text>
<Text className="text-sm font-dmsans text-gray-500 text-center px-4">
Align the QR code inside the frame. We will automatically select
the account and take you to the amount screen.
</Text>
</View>
)}
</View>
<ModalToast
visible={toastVisible}
title={toastTitle}
description={toastDescription}
variant={toastVariant}
/>
</ScreenWrapper>
);
}

Some files were not shown because too many files have changed in this diff Show More