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