first ever agent push
8
.expo/README.md
Normal 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
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"devices": [
|
||||||
|
{
|
||||||
|
"installationId": "5eab1301-ca81-48d8-bdaa-42f65cae7ad8",
|
||||||
|
"lastUsed": 1768508821071
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/node_modules
|
||||||
38
GoogleService-Info.plist
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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 Profile’s 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)**
|
||||||
|
|
||||||
|
- Today’s total
|
||||||
|
- This week’s 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:
|
||||||
|
Today’s 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 won’t 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, I’ll:
|
||||||
|
|
||||||
|
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
BIN
amba_release.keystore
Normal file
16
android/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# OSX
|
||||||
|
#
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Android/IntelliJ
|
||||||
|
#
|
||||||
|
build/
|
||||||
|
.idea
|
||||||
|
.gradle
|
||||||
|
local.properties
|
||||||
|
*.iml
|
||||||
|
*.hprof
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Bundle artifacts
|
||||||
|
*.jsbundle
|
||||||
4
android/.kotlin/errors/errors-1765842676555.log
Normal 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
|
||||||
|
|
||||||
4
android/.kotlin/errors/errors-1766617533941.log
Normal 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
|
||||||
|
|
||||||
4
android/.kotlin/errors/errors-1766791234477.log
Normal 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
|
||||||
|
|
||||||
4
android/.kotlin/errors/errors-1768389090763.log
Normal 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
|
|
@ -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
107
android/app/google-services.json
Normal 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
|
|
@ -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:
|
||||||
7
android/app/src/debug/AndroidManifest.xml
Normal 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>
|
||||||
39
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
BIN
android/app/src/main/assets/fonts/DMSans-Black.ttf
Normal file
BIN
android/app/src/main/assets/fonts/DMSans-Bold.ttf
Normal file
BIN
android/app/src/main/assets/fonts/DMSans-ExtraBold.ttf
Normal file
BIN
android/app/src/main/assets/fonts/DMSans-ExtraLight.ttf
Normal file
BIN
android/app/src/main/assets/fonts/DMSans-Light.ttf
Normal file
BIN
android/app/src/main/assets/fonts/DMSans-Medium.ttf
Normal file
BIN
android/app/src/main/assets/fonts/DMSans-Regular.ttf
Normal file
BIN
android/app/src/main/assets/fonts/DMSans-SemiBold.ttf
Normal file
BIN
android/app/src/main/assets/fonts/DMSans-Thin.ttf
Normal file
61
android/app/src/main/java/com/amba/MainActivity.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
61
android/app/src/main/java/com/amba/MainApplication.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
android/app/src/main/res/drawable-hdpi/splashscreen_logo.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
android/app/src/main/res/drawable-mdpi/splashscreen_logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
|
|
@ -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>
|
||||||
37
android/app/src/main/res/drawable/rn_edit_text_material.xml
Normal 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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 11 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 18 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 24 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
1
android/app/src/main/res/values-night/colors.xml
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<resources/>
|
||||||
6
android/app/src/main/res/values/colors.xml
Normal 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>
|
||||||
5
android/app/src/main/res/values/strings.xml
Normal 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>
|
||||||
11
android/app/src/main/res/values/styles.xml
Normal 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
|
|
@ -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
|
|
@ -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
|
||||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
424
app/(root)/(screens)/addcard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
313
app/(root)/(screens)/addcash.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
app/(root)/(screens)/addcashcomp.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
728
app/(root)/(screens)/addrecipient.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
app/(root)/(screens)/cardaddedcomp.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
302
app/(root)/(screens)/cardmang.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
340
app/(root)/(screens)/cashout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
app/(root)/(screens)/cashoutcomp.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
305
app/(root)/(screens)/changepin.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
657
app/(root)/(screens)/checkout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
218
app/(root)/(screens)/crowdfunding.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
322
app/(root)/(screens)/donation.tsx
Normal 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 Children’s 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
896
app/(root)/(screens)/editprofile.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
500
app/(root)/(screens)/eventdetail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
app/(root)/(screens)/eventqrscreen.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
388
app/(root)/(screens)/events.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
181
app/(root)/(screens)/helpsupport.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
569
app/(root)/(screens)/history.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
462
app/(root)/(screens)/kyc.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
app/(root)/(screens)/moneydonated.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
app/(root)/(screens)/moneyrequested.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
323
app/(root)/(screens)/mytickets.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
665
app/(root)/(screens)/notification.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
277
app/(root)/(screens)/notificationOption.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
237
app/(root)/(screens)/points.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
app/(root)/(screens)/pointsactivity.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
538
app/(root)/(screens)/profile.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
364
app/(root)/(screens)/qrscreen.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
app/(root)/(screens)/recipaddedcomp.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
262
app/(root)/(screens)/recipdetail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
203
app/(root)/(screens)/requestprovider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
220
app/(root)/(screens)/scanprofileqr.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||