fitst commit

This commit is contained in:
Samandar Turgunboyev
2026-01-28 18:26:50 +05:00
parent 166a55b1e9
commit 124798419b
196 changed files with 26627 additions and 421 deletions

View File

@@ -0,0 +1,32 @@
import httpClient from '@/api/httpClient';
import { API_URLS } from '@/api/URLs';
import { AxiosResponse } from 'axios';
import { CreateAdsResponse, PriceCalculationRes } from './types';
export const price_calculation = {
async calculation(params: {
country: string;
region: string;
district: string | 'all';
letters: string | any;
types: string;
}): Promise<AxiosResponse<PriceCalculationRes>> {
const res = await httpClient.get(API_URLS.Price_Calculation, { params });
return res;
},
async ad(body: FormData): Promise<AxiosResponse<CreateAdsResponse>> {
const res = await httpClient.post(API_URLS.Add_Ads, body, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return res;
},
async payment(body: { return_url: string; adId: number; paymentType: 'payme' | 'referral' }) {
const res = await httpClient.post(API_URLS.Payment_Ads(body.paymentType, body.adId), {
return_url: body.return_url,
});
return res;
},
};

View File

@@ -0,0 +1,21 @@
export interface PriceCalculationRes {
country: string;
region: string;
district: string;
letters: string[];
one_person_price: number;
user_count: number;
total_price: number;
user_ids: number[];
}
export interface CreateAdsResponse {
status: boolean;
data: {
description: string;
id: number;
phone_number: string;
title: string;
total_price: number;
};
}

View File

@@ -0,0 +1,139 @@
import { useTheme } from '@/components/ThemeContext';
import {
BottomSheetBackdrop,
BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetScrollView,
} from '@gorhom/bottom-sheet';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
export type Option = {
label: string;
value: string;
};
type CategorySelectorProps = {
isOpen: boolean;
onClose: () => void;
selectedValue: string;
onSelect: (value: string) => void;
data: Option[];
};
export default function CategorySelectorBottomSheet({
isOpen,
onClose,
selectedValue,
onSelect,
data = [],
}: CategorySelectorProps) {
const { isDark } = useTheme();
const { t } = useTranslation();
const theme = {
background: isDark ? '#1e293b' : '#ffffff',
text: isDark ? '#f8fafc' : '#0f172a',
border: isDark ? '#334155' : '#e2e8f0',
selectedBg: '#2563eb',
selectedText: '#ffffff',
indicator: isDark ? '#cbd5e1' : '#94a3b8',
};
const bottomSheetRef = useRef<BottomSheetModal>(null);
const snapPoints = useMemo(() => ['60%', '85%'], []);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.5} />
),
[]
);
useEffect(() => {
if (isOpen) {
bottomSheetRef.current?.present();
} else {
bottomSheetRef.current?.dismiss();
}
}, [isOpen]);
return (
<BottomSheetModal
ref={bottomSheetRef}
index={0}
snapPoints={snapPoints}
backdropComponent={renderBackdrop}
enablePanDownToClose
onDismiss={onClose}
handleIndicatorStyle={{ backgroundColor: theme.indicator, width: 50 }}
backgroundStyle={{
backgroundColor: theme.background,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
}}
>
<View style={[styles.header, { borderBottomColor: theme.border }]}>
<Text style={[styles.title, { color: theme.text }]}>{t('Tanlang')}</Text>
</View>
<BottomSheetScrollView style={styles.content} contentContainerStyle={styles.contentContainer}>
{data.map((item) => (
<TouchableOpacity
key={item.value}
style={[
styles.optionRow,
{ borderBottomColor: theme.border },
selectedValue === item.value && { backgroundColor: theme.selectedBg },
]}
onPress={() => {
onSelect(item.value);
onClose();
}}
>
<Text
style={[
styles.optionText,
{ color: selectedValue === item.value ? theme.selectedText : theme.text },
selectedValue === item.value && styles.optionTextSelected,
]}
>
{item.label}
</Text>
</TouchableOpacity>
))}
</BottomSheetScrollView>
</BottomSheetModal>
);
}
const styles = StyleSheet.create({
header: {
paddingVertical: 16,
paddingHorizontal: 20,
borderBottomWidth: 1,
alignItems: 'center',
},
title: {
fontSize: 18,
fontWeight: '700',
},
content: {
flex: 1,
},
contentContainer: {
paddingHorizontal: 8,
paddingBottom: 40,
},
optionRow: {
paddingVertical: 16,
paddingHorizontal: 20,
borderBottomWidth: 1,
},
optionText: {
fontSize: 16,
},
optionTextSelected: {
fontWeight: '600',
},
});

View File

@@ -0,0 +1,404 @@
import { useTheme } from '@/components/ThemeContext';
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from '@gorhom/bottom-sheet';
import { useMutation, useQuery } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { router, useFocusEffect } from 'expo-router';
import React, { useCallback, useRef, useState } from 'react';
import {
Alert,
Image,
KeyboardAvoidingView,
Linking,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import PAYME from '@/assets/images/Payme_NEW.png';
import { useTranslation } from 'react-i18next';
import { price_calculation } from '../lib/api';
import { CreateAdsResponse } from '../lib/types';
import StepFour from './StepFour';
import StepOne from './StepOne';
import StepThree from './StepThree';
import StepTwo from './StepTwo';
type MediaFile = {
uri: string;
type: string;
name: string;
};
interface formDataType {
title: string;
description: string;
phone: string;
media: MediaFile[];
category: any[];
country: string;
region: string;
district: string;
company: any[];
}
const getMimeType = (uri: string) => {
const ext = uri.split('.').pop()?.toLowerCase();
switch (ext) {
case 'jpg':
case 'jpeg':
return 'image/jpeg';
case 'png':
return 'image/png';
case 'webp':
return 'image/webp';
case 'heic':
return 'image/heic';
default:
return 'application/octet-stream';
}
};
export default function CreateAdsScreens() {
const { isDark } = useTheme();
const { t } = useTranslation();
const [paymentType, setPaymentType] = useState<'PAYME' | 'REFFERAL'>('PAYME');
const [ads, setAds] = useState<CreateAdsResponse | null>(null);
const [currentStep, setCurrentStep] = useState(1);
const stepOneRef = useRef<{ validate: () => boolean } | null>(null);
const stepTwoRef = useRef<{ validate: () => boolean } | null>(null);
const stepThreeRef = useRef<{ validate: () => boolean } | null>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const renderBackdrop = useCallback(
(props: any) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.5} />
),
[]
);
const [formData, setFormData] = useState<formDataType>({
title: '',
description: '',
phone: '',
media: [],
category: [],
country: '',
region: '',
district: 'all',
company: [],
});
useFocusEffect(
useCallback(() => {
return () => {
setFormData({
title: '',
description: '',
phone: '',
media: [],
category: [],
country: '',
region: '',
district: 'all',
company: [],
});
setCurrentStep(1);
};
}, [])
);
const { data } = useQuery({
queryKey: ['price-calculation', formData],
queryFn: () =>
price_calculation.calculation({
country: formData.country,
district: formData.district,
region: formData.region,
types: formData.category.map((c: any) => c.id).join(','),
letters: formData.company.map((c: any) => c.latter).join(','),
}),
enabled: formData.company.length > 0,
});
const updateForm = (key: string, value: any) => setFormData((p) => ({ ...p, [key]: value }));
const { mutate, isPending } = useMutation({
mutationFn: (body: FormData) => price_calculation.ad(body),
onSuccess: (res) => {
setAds(res.data);
setCurrentStep(4);
},
onError: (err: AxiosError) => {
Alert.alert('Xatolik', err.message);
},
});
const handleSubmit = () => {
const form = new FormData();
form.append('title', formData.title);
form.append('description', formData.description);
formData.media.forEach((file, index) => {
form.append(`files[${index}]`, {
uri: file.uri,
type: getMimeType(file.uri),
name: file.uri.split('/').pop(),
} as any);
});
formData.category.forEach((e, index) => {
form.append(`types[${index}]`, e.id);
});
if (data) {
form.append('total_price', data.data.total_price.toString());
}
form.append('phone_number', `998${formData.phone}`);
const letters = formData.company.map((c: any) => c.latter).join(',');
form.append('company_selections[0]continent', 'Asia');
form.append('company_selections[0]country', formData.country);
form.append('company_selections[0]region', formData.region);
form.append('company_selections[0]district', formData.district);
form.append('company_selections[0]letters', letters);
form.append('company_selections[0]total_companies', data?.data.user_count.toString() || '0');
form.append('company_selections[0]ad_price', data?.data.one_person_price.toString() || '0');
form.append('company_selections[0]total_price', data?.data.total_price.toString() || '0');
mutate(form);
};
const handlePresentModalPress = useCallback(() => {
bottomSheetModalRef.current?.present();
}, []);
const { mutate: payment } = useMutation({
mutationFn: (body: { return_url: string; adId: number; paymentType: 'payme' | 'referral' }) =>
price_calculation.payment(body),
onSuccess: async (res, variables) => {
if (variables.paymentType === 'payme') {
await Linking.openURL(res.data.url);
router.push('/(dashboard)/announcements');
} else {
router.push('/(dashboard)/announcements');
}
},
onError: (err) => {
Alert.alert('Xatolik yuz berdi', err.message);
},
});
const sendPayment = (type: 'payme' | 'referral') => {
if (ads) {
payment({
adId: ads.data.id,
paymentType: type,
return_url: 'https://infotarget.uz/en/main/dashboard',
});
}
};
return (
<KeyboardAvoidingView
behavior="padding"
style={[styles.safeArea, isDark ? styles.darkBg : styles.lightBg]}
>
<ScrollView contentContainerStyle={styles.container}>
<Text style={[styles.title, isDark ? styles.darkText : styles.lightText]}>
{currentStep === 1
? t("E'lon ma'lumotlari")
: currentStep === 2
? t('Sohalar')
: currentStep === 3
? t('Manzil')
: t("To'lov")}
</Text>
{currentStep === 1 && (
<StepOne ref={stepOneRef} formData={formData} updateForm={updateForm} />
)}
{currentStep === 2 && (
<StepTwo ref={stepTwoRef} formData={formData} updateForm={updateForm} />
)}
{currentStep === 3 && (
<StepThree
ref={stepThreeRef}
formData={formData}
updateForm={updateForm}
data={data?.data}
/>
)}
{currentStep === 4 && <StepFour data={ads} setPayment={setPaymentType} />}
</ScrollView>
{/* FOOTER */}
<View style={styles.footer}>
{currentStep > 1 && currentStep !== 4 && (
<TouchableOpacity
style={[styles.back, isDark ? styles.darkBack : styles.lightBack]}
onPress={() => setCurrentStep((s) => s - 1)}
>
<Text style={[styles.btnText, isDark ? styles.darkBtnText : styles.lightBtnText]}>
{t('Orqaga')}
</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={styles.next}
disabled={isPending}
onPress={() => {
let isValid = true;
if (currentStep === 1) isValid = stepOneRef.current?.validate() ?? false;
if (currentStep === 2) isValid = stepTwoRef.current?.validate() ?? false;
if (currentStep === 3) isValid = stepThreeRef.current?.validate() ?? false;
if (!isValid) return;
if (currentStep < 3) setCurrentStep((s) => s + 1);
if (currentStep === 3) handleSubmit();
if (currentStep === 4) handlePresentModalPress();
}}
>
<Text style={styles.btnText}>
{currentStep === 3 ? t('Yaratish') : currentStep === 4 ? t("To'lash") : t('Keyingisi')}
</Text>
</TouchableOpacity>
</View>
{/* PAYMENT BOTTOM SHEET */}
<BottomSheetModal
ref={bottomSheetModalRef}
index={0}
snapPoints={['70%', '95%']}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{ backgroundColor: '#94a3b8', width: 50 }}
backgroundStyle={{ backgroundColor: isDark ? '#0f172a' : '#ffffff' }}
enablePanDownToClose
>
<BottomSheetScrollView
style={styles.sheetContent}
contentContainerStyle={styles.sheetContentContainer}
>
<View style={{ padding: 20 }}>
<Text style={[styles.sheetTitle, isDark ? styles.darkText : styles.lightText]}>
{t("To'lov turini tanlang")}
</Text>
<TouchableOpacity
style={[
styles.paymentItem,
isDark ? styles.darkPaymentItem : styles.lightPaymentItem,
{ flexDirection: 'row', justifyContent: 'flex-start', alignItems: 'center' },
]}
onPress={() => sendPayment('payme')}
>
<Image source={PAYME} style={{ width: 80, height: 80 }} />
</TouchableOpacity>
<TouchableOpacity
style={[
styles.paymentItem,
isDark ? styles.darkPaymentItem : styles.lightPaymentItem,
]}
onPress={() => sendPayment('referral')}
>
<Text style={[styles.paymentText, isDark ? styles.darkText : styles.lightText]}>
{t('Referal orqali')}
</Text>
</TouchableOpacity>
</View>
</BottomSheetScrollView>
</BottomSheetModal>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
safeArea: { flex: 1 },
darkBg: {
backgroundColor: '#0f172a',
},
lightBg: {
backgroundColor: '#f8fafc',
},
container: { padding: 20, paddingBottom: 140 },
title: { fontSize: 22, fontWeight: '800', marginBottom: 20 },
darkText: {
color: '#f1f5f9',
},
lightText: {
color: '#0f172a',
},
footer: {
position: 'absolute',
bottom: 80,
left: 20,
right: 20,
flexDirection: 'row',
gap: 10,
},
back: {
flex: 1,
height: 56,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
},
darkBack: {
backgroundColor: '#1e293b',
},
lightBack: {
backgroundColor: '#ffffff',
borderWidth: 1,
borderColor: '#e2e8f0',
},
sheetContent: { flex: 1 },
sheetContentContainer: { paddingBottom: 40 },
next: {
flex: 2,
height: 56,
backgroundColor: '#3b82f6',
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
},
btnText: { color: '#ffffff', fontWeight: '700', fontSize: 16 },
darkBtnText: {
color: '#f1f5f9',
},
lightBtnText: {
color: '#0f172a',
},
sheetTitle: {
fontSize: 18,
fontWeight: '700',
marginBottom: 16,
},
paymentItem: {
height: 56,
borderRadius: 14,
justifyContent: 'center',
paddingHorizontal: 16,
marginBottom: 12,
},
darkPaymentItem: {
backgroundColor: '#1e293b',
},
lightPaymentItem: {
backgroundColor: '#f8fafc',
},
paymentText: {
fontSize: 16,
fontWeight: '600',
},
});

View File

@@ -0,0 +1,73 @@
import { useTheme } from '@/components/ThemeContext';
import React, { Dispatch, forwardRef, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { CreateAdsResponse } from '../lib/types';
type StepFourProps = {
setPayment: Dispatch<SetStateAction<'PAYME' | 'REFFERAL'>>;
data: CreateAdsResponse | null;
};
const StepFour = forwardRef(({ data }: StepFourProps) => {
const { isDark } = useTheme();
const { t } = useTranslation();
const theme = {
background: isDark ? '#0f172a' : '#ffffff',
cardBg: isDark ? '#1e293b' : '#f8fafc',
cardBorder: isDark ? '#334155' : '#e2e8f0',
text: isDark ? '#f8fafc' : '#0f172a',
textSecondary: isDark ? '#cbd5e1' : '#64748b',
totalPrice: isDark ? '#f87171' : '#ef4444',
};
const totalPrice = data?.data.total_price || 0;
return (
<>
<ScrollView
contentContainerStyle={[styles.container, { backgroundColor: theme.background }]}
showsVerticalScrollIndicator={false}
>
<Text style={[styles.sectionTitle, { color: theme.text }]}>
{t("To'lov uchun ma'lumotlar")}
</Text>
<View
style={[styles.card, { backgroundColor: theme.cardBg, borderColor: theme.cardBorder }]}
>
<Text style={[styles.label, { color: theme.text }]}>
{t("E'lon nomi")}: <Text style={styles.value}>{data?.data.title}</Text>
</Text>
<Text style={[styles.label, { color: theme.text }]}>
{t("E'lon tavsifi")}: <Text style={styles.value}>{data?.data.description}</Text>
</Text>
<Text style={[styles.label, styles.total, { color: theme.totalPrice }]}>
{t('Umumiy narx')}:{' '}
<Text style={styles.value}>
{totalPrice} {t("so'm")}
</Text>
</Text>
</View>
</ScrollView>
</>
);
});
export default StepFour;
const styles = StyleSheet.create({
container: { flexGrow: 1 },
sectionTitle: { fontSize: 18, fontWeight: '700', marginVertical: 12 },
card: {
borderRadius: 16,
padding: 20,
marginBottom: 20,
borderWidth: 1,
gap: 8,
},
label: { fontSize: 15, fontWeight: '600' },
value: { fontWeight: '800' },
total: { marginTop: 8, fontSize: 16 },
});

View File

@@ -0,0 +1,265 @@
import { useTheme } from '@/components/ThemeContext';
import { formatPhone, normalizeDigits } from '@/constants/formatPhone';
import * as ImagePicker from 'expo-image-picker';
import { Camera, Play, X } from 'lucide-react-native';
import React, { forwardRef, useCallback, useImperativeHandle, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Image, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native';
type MediaType = { uri: string; type: 'image' | 'video' };
type StepProps = { formData: any; updateForm: (key: string, value: any) => void };
type Errors = {
title?: string;
description?: string;
phone?: string;
media?: string;
};
const MAX_MEDIA = 10;
const StepOne = forwardRef(({ formData, updateForm }: StepProps, ref) => {
const [phone, setPhone] = useState(formData.phone || '');
const [focused, setFocused] = useState(false);
const [errors, setErrors] = useState<Errors>({});
const { isDark } = useTheme();
const { t } = useTranslation();
const validate = () => {
const e: Errors = {};
if (!formData.title || formData.title.trim().length < 5)
e.title = "Sarlavha kamida 5 ta belgidan iborat bo'lishi kerak";
if (!formData.description || formData.description.trim().length < 10)
e.description = "Tavsif kamida 10 ta belgidan iborat bo'lishi kerak";
if (!formData.phone || formData.phone.length !== 9)
e.phone = "Telefon raqam to'liq kiritilmadi";
if (!formData.media || formData.media.length === 0)
e.media = 'Kamida bitta rasm yoki video yuklang';
setErrors(e);
return Object.keys(e).length === 0;
};
useImperativeHandle(ref, () => ({ validate }));
const pickMedia = async () => {
if (formData.media.length >= MAX_MEDIA) return;
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.All,
allowsMultipleSelection: true,
quality: 0.8,
});
if (!result.canceled) {
const assets = result.assets
.slice(0, MAX_MEDIA - formData.media.length)
.map((a) => ({ uri: a.uri, type: a.type as 'image' | 'video' }));
updateForm('media', [...formData.media, ...assets]);
}
};
const handlePhone = useCallback(
(text: string) => {
const n = normalizeDigits(text);
setPhone(n);
updateForm('phone', n);
},
[updateForm]
);
const removeMedia = (i: number) =>
updateForm(
'media',
formData.media.filter((_: any, idx: number) => idx !== i)
);
const theme = {
background: isDark ? '#0f172a' : '#ffffff',
inputBg: isDark ? '#1e293b' : '#f1f5f9',
inputBorder: isDark ? '#334155' : '#e2e8f0',
text: isDark ? '#f8fafc' : '#0f172a',
textSecondary: isDark ? '#cbd5e1' : '#475569',
placeholder: isDark ? '#94a3b8' : '#94a3b8',
error: '#ef4444',
primary: '#2563eb',
divider: isDark ? '#475569' : '#cbd5e1',
};
return (
<View style={styles.stepContainer}>
{/* Sarlavha */}
<Text style={[styles.label, { color: theme.text }]}>{t('Sarlavha')}</Text>
<View
style={[
styles.inputBox,
{ backgroundColor: theme.inputBg, borderColor: theme.inputBorder },
]}
>
<TextInput
style={[styles.input, { color: theme.text }]}
placeholder={t("E'lon sarlavhasi")}
placeholderTextColor={theme.placeholder}
value={formData.title}
onChangeText={(t) => updateForm('title', t)}
/>
</View>
{errors.title && (
<Text style={[styles.error, { color: theme.error }]}>{t(errors.title)}</Text>
)}
{/* Tavsif */}
<Text style={[styles.label, { color: theme.text }]}>{t('Tavsif')}</Text>
<View
style={[
styles.inputBox,
styles.textArea,
{ backgroundColor: theme.inputBg, borderColor: theme.inputBorder },
]}
>
<TextInput
style={[styles.input, { color: theme.text }]}
placeholder={t('Batafsil yozing...')}
placeholderTextColor={theme.placeholder}
multiline
value={formData.description}
onChangeText={(t) => updateForm('description', t)}
/>
</View>
{errors.description && (
<Text style={[styles.error, { color: theme.error }]}>{t(errors.description)}</Text>
)}
{/* Telefon */}
<Text style={[styles.label, { color: theme.text }]}>{t('Telefon raqami')}</Text>
<View
style={[
styles.inputBox,
{ backgroundColor: theme.inputBg, borderColor: theme.inputBorder },
]}
>
<View style={styles.prefixContainer}>
<Text style={[styles.prefix, { color: theme.text }, focused && styles.prefixFocused]}>
+998
</Text>
<View style={[styles.divider, { backgroundColor: theme.divider }]} />
</View>
<TextInput
style={[styles.input, { color: theme.text }]}
value={formatPhone(phone)}
onChangeText={handlePhone}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
keyboardType="phone-pad"
placeholder="90 123 45 67"
maxLength={12}
placeholderTextColor={theme.placeholder}
/>
</View>
{errors.phone && (
<Text style={[styles.error, { color: theme.error }]}>{t(errors.phone)}</Text>
)}
{/* Media */}
<Text style={[styles.label, { color: theme.text }]}>
{t('Media')} ({formData.media.length}/{MAX_MEDIA})
</Text>
<View style={styles.media}>
<TouchableOpacity
style={[styles.upload, { borderColor: theme.primary }]}
onPress={pickMedia}
>
<Camera size={28} color={theme.primary} />
<Text style={[styles.uploadText, { color: theme.primary }]}>{t('Yuklash')}</Text>
</TouchableOpacity>
{formData.media.map((m: MediaType, i: number) => (
<View key={i} style={styles.preview}>
<Image source={{ uri: m.uri }} style={styles.image} />
{m.type === 'video' && (
<View style={styles.play}>
<Play size={14} color="#fff" fill="#fff" />
</View>
)}
<TouchableOpacity
style={[styles.remove, { backgroundColor: theme.error }]}
onPress={() => removeMedia(i)}
>
<X size={12} color="#fff" />
</TouchableOpacity>
</View>
))}
</View>
{errors.media && (
<Text style={[styles.error, { color: theme.error }]}>{t(errors.media)}</Text>
)}
</View>
);
});
export default StepOne;
const styles = StyleSheet.create({
stepContainer: { gap: 10 },
label: { fontWeight: '700' },
error: { fontSize: 13, marginLeft: 6 },
inputBox: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: 16,
borderWidth: 1,
paddingHorizontal: 16,
height: 56,
},
textArea: { height: 120, alignItems: 'flex-start' },
input: { flex: 1, fontSize: 16 },
media: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 },
upload: {
width: 100,
height: 100,
borderRadius: 16,
borderWidth: 2,
borderStyle: 'dashed',
justifyContent: 'center',
alignItems: 'center',
},
uploadText: { fontSize: 11, marginTop: 4 },
preview: { width: 100, height: 100 },
image: { width: '100%', height: '100%', borderRadius: 16 },
play: {
position: 'absolute',
top: '40%',
left: '40%',
backgroundColor: 'rgba(0,0,0,.5)',
padding: 6,
borderRadius: 20,
},
remove: {
position: 'absolute',
top: -6,
right: -6,
padding: 4,
borderRadius: 10,
},
prefixContainer: {
flexDirection: 'row',
alignItems: 'center',
marginRight: 12,
},
prefix: {
fontSize: 16,
fontWeight: '600',
letterSpacing: 0.3,
},
prefixFocused: {},
divider: {
width: 1.5,
height: 24,
marginLeft: 12,
},
});

View File

@@ -0,0 +1,289 @@
import { useTheme } from '@/components/ThemeContext';
import { products_api } from '@/screens/home/lib/api';
import { useQuery } from '@tanstack/react-query';
import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Dimensions,
FlatList,
ListRenderItemInfo,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { PriceCalculationRes } from '../lib/types';
import CategorySelectorBottomSheet from './CategorySelectorBottomSheet';
type StepProps = {
formData: any;
data: PriceCalculationRes | undefined;
updateForm: (key: string, value: any) => void;
};
const SCREEN_WIDTH = Dimensions.get('window').width;
const GAP = 8;
const NUM_COLUMNS = 6;
const ITEM_SIZE = (SCREEN_WIDTH - GAP * (NUM_COLUMNS + 1)) / NUM_COLUMNS;
const StepThree = forwardRef(({ formData, updateForm, data }: StepProps, ref) => {
const { isDark } = useTheme();
const { t } = useTranslation();
const theme = {
background: isDark ? '#0f172a' : '#ffffff',
cardBg: isDark ? '#1e293b' : '#f8fafc',
cardBorder: isDark ? '#334155' : '#e2e8f0',
text: isDark ? '#f8fafc' : '#0f172a',
textSecondary: isDark ? '#cbd5e1' : '#64748b',
primary: '#2563eb',
error: '#ef4444',
priceText: isDark ? '#dc2626' : '#ef4444',
companyBg: isDark ? '#1e293b' : '#f1f5f9',
companyBorder: isDark ? '#334155' : '#cbd5e1',
};
const { data: statesData } = useQuery({
queryKey: ['country-detail'],
queryFn: async () => products_api.getStates(),
select: (res) => res.data.data || [],
});
const [showCountry, setShowCountry] = useState(false);
const [showRegion, setShowRegion] = useState(false);
const [showDistrict, setShowDistrict] = useState(false);
const [regions, setRegions] = useState<any[]>([]);
const [districts, setDistricts] = useState<any[]>([]);
const [error, setError] = useState<string | null>(null);
const corporations = Array.from({ length: 26 }).map((_, i) => ({
id: i + 1,
latter: String.fromCharCode(65 + i),
}));
const onCompanyPress = (item: { id: number; latter: string }) => {
const selected = formData.company || [];
const exists = selected.some((c: any) => c.id === item.id);
if (exists) {
updateForm(
'company',
selected.filter((c: any) => c.id !== item.id)
);
} else {
updateForm('company', [...selected, item]);
}
};
useEffect(() => {
const country = statesData?.find((c) => c.code === formData.country);
setRegions(country?.region || []);
if (!country?.region.some((r) => r.code === formData.region)) {
updateForm('region', '');
updateForm('district', '');
setDistricts([]);
}
}, [formData.country, statesData]);
useEffect(() => {
const country = statesData?.find((c) => c.code === formData.country);
const region = country?.region.find((r) => r.code === formData.region);
setDistricts(region?.districts || []);
if (!region?.districts.some((d) => d.code === formData.district)) {
updateForm('district', '');
}
}, [formData.region, formData.country, statesData]);
const getLabel = (arr: { name: string; code: string }[], val: string) =>
arr.find((item) => item.code === val)?.name || t('— Tanlang —');
const renderCompanyItem = ({ item }: ListRenderItemInfo<{ id: number; latter: string }>) => {
const isSelected = formData.company?.some((c: any) => c.id === item.id);
return (
<TouchableOpacity
style={[
styles.companyItem,
{
backgroundColor: isSelected ? theme.primary : theme.companyBg,
borderColor: isSelected ? theme.primary : theme.companyBorder,
},
]}
onPress={() => onCompanyPress(item)}
>
<Text
style={[
styles.companyText,
{ color: isSelected ? '#fff' : theme.text },
isSelected && styles.companyTextActive,
]}
>
{item.latter}
</Text>
</TouchableOpacity>
);
};
const validate = () => {
if (!formData.country) {
setError('Iltimos, davlat tanlang');
return false;
}
if (!formData.region) {
setError('Iltimos, viloyat tanlang');
return false;
}
if (!formData.district) {
setError('Iltimos, tuman/shahar tanlang');
return false;
}
setError(null);
return true;
};
useImperativeHandle(ref, () => ({
validate,
}));
return (
<ScrollView
contentContainerStyle={[styles.container, { backgroundColor: theme.background }]}
showsVerticalScrollIndicator={false}
>
{error && <Text style={[styles.error, { color: theme.error }]}>{t(error)}</Text>}
<Text style={[styles.label, { color: theme.text }]}>{t('Davlat')}</Text>
<TouchableOpacity
style={[
styles.pickerButton,
{ backgroundColor: theme.cardBg, borderColor: theme.cardBorder },
]}
onPress={() => setShowCountry(true)}
>
<Text style={[styles.pickerText, { color: theme.text }]}>
{statesData &&
getLabel(
statesData.map((c) => ({ name: c.name, code: c.code })),
formData.country
)}
</Text>
</TouchableOpacity>
<Text style={[styles.label, { color: theme.text }]}>{t('Viloyat')}</Text>
<TouchableOpacity
style={[
styles.pickerButton,
{ backgroundColor: theme.cardBg, borderColor: theme.cardBorder },
]}
onPress={() => setShowRegion(true)}
>
<Text style={[styles.pickerText, { color: theme.text }]}>
{getLabel(
regions.map((r) => ({ name: r.name, code: r.code })),
formData.region
)}
</Text>
</TouchableOpacity>
<Text style={[styles.label, { color: theme.text }]}>{t('Tuman / Shahar')}</Text>
<TouchableOpacity
style={[
styles.pickerButton,
{ backgroundColor: theme.cardBg, borderColor: theme.cardBorder },
]}
onPress={() => setShowDistrict(true)}
>
<Text style={[styles.pickerText, { color: theme.text }]}>
{getLabel(
districts.map((d) => ({ name: d.name, code: d.code })),
formData.district
)}
</Text>
</TouchableOpacity>
<Text style={[styles.sectionTitle, { color: theme.text }]}>
{t('Reklama joylashtirish kompaniyasi')}
</Text>
<FlatList
data={corporations}
renderItem={renderCompanyItem}
keyExtractor={(item) => item.id.toString()}
numColumns={6}
columnWrapperStyle={{ gap: 2, marginBottom: GAP, justifyContent: 'flex-start' }}
scrollEnabled={false}
/>
<View
style={[styles.priceCard, { backgroundColor: theme.cardBg, borderColor: theme.cardBorder }]}
>
<Text style={[styles.priceLine, { color: theme.text }]}>
{t('Jami kampaniyalar soni')}: {data ? data.user_count : '0'}
</Text>
<Text style={[styles.priceLine, { color: theme.text }]}>
{t('Reklama narxi')}: {data ? data.one_person_price : '0'}
</Text>
<Text style={[styles.totalPrice, { color: theme.priceText }]}>
{t('Umumiy narx')}: {data ? data.total_price : '0'}
</Text>
</View>
<CategorySelectorBottomSheet
isOpen={showCountry}
onClose={() => setShowCountry(false)}
selectedValue={formData.country}
data={statesData ? statesData.map((c) => ({ label: c.name, value: c.code })) : []}
onSelect={(v) => updateForm('country', v)}
/>
<CategorySelectorBottomSheet
isOpen={showRegion}
onClose={() => setShowRegion(false)}
selectedValue={formData.region}
data={regions.map((r) => ({ label: r.name, value: r.code }))}
onSelect={(v) => updateForm('region', v)}
/>
<CategorySelectorBottomSheet
isOpen={showDistrict}
onClose={() => setShowDistrict(false)}
selectedValue={formData.district}
data={districts.map((d) => ({ label: d.name, value: d.code }))}
onSelect={(v) => updateForm('district', v)}
/>
</ScrollView>
);
});
export default StepThree;
const styles = StyleSheet.create({
container: { flexGrow: 1 },
label: { fontSize: 14, fontWeight: '700', marginBottom: 6, marginTop: 10 },
pickerButton: {
borderWidth: 1,
borderRadius: 12,
padding: 16,
marginBottom: 12,
},
pickerText: { fontSize: 16 },
sectionTitle: { fontSize: 16, fontWeight: '700', marginVertical: 12 },
companyItem: {
width: 55,
height: 55,
borderRadius: ITEM_SIZE / 2,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
companyText: { fontSize: 14 },
companyTextActive: { fontWeight: '600' },
priceCard: {
marginTop: 24,
padding: 20,
borderRadius: 16,
borderWidth: 1,
gap: 8,
},
priceLine: { fontSize: 15 },
totalPrice: { fontSize: 18, fontWeight: '700', marginTop: 6 },
error: { fontWeight: '600', marginBottom: 10 },
});

View File

@@ -0,0 +1,143 @@
import { useTheme } from '@/components/ThemeContext';
import CategorySelection from '@/components/ui/IndustrySelection';
import { XIcon } from 'lucide-react-native';
import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FlatList, StyleSheet, Text, ToastAndroid, TouchableOpacity, View } from 'react-native';
type StepProps = {
formData: any;
updateForm: (key: string, value: any) => void;
};
const StepTwo = forwardRef(({ formData, updateForm }: StepProps, ref) => {
const [selectedCategories, setSelectedCategories] = useState<any[]>(formData.category || []);
const [error, setError] = useState<string | null>(null);
const { isDark } = useTheme();
const { t } = useTranslation();
const theme = {
text: isDark ? '#f8fafc' : '#0f172a',
tabBg: isDark ? '#1e293b' : '#f1f5f9',
tabText: isDark ? '#ffffff' : '#1e293b',
deleteBg: isDark ? '#394e73' : '#cbd5e1',
deleteIcon: isDark ? '#f8fafc' : '#475569',
error: '#ef4444',
shadow: isDark ? '#000' : '#64748b',
};
// FormData-ni yangilash
useEffect(() => {
updateForm('category', selectedCategories);
if (selectedCategories.length > 0) setError(null);
}, [selectedCategories]);
// Validatsiya
const validate = () => {
if (selectedCategories.length === 0) {
setError('Iltimos, kompaniyalarni tanlang');
ToastAndroid.show(t('Iltimos, kompaniyalarni tanlang'), ToastAndroid.TOP);
return false;
}
return true;
};
useImperativeHandle(ref, () => ({
validate,
}));
// O'chirish funksiyasi
const removeCategory = (id: string | number) => {
setSelectedCategories((prev) => prev.filter((c) => c.id !== id));
};
// SearchTabs uchun render funksiyasi
const renderTab = ({ item }: { item: any }) => (
<View style={[styles.tabWrapper, { backgroundColor: theme.tabBg, shadowColor: theme.shadow }]}>
<Text
style={[styles.tabText, { color: theme.tabText }]}
numberOfLines={1}
ellipsizeMode="tail"
>
{item.name}
</Text>
<TouchableOpacity
onPress={() => removeCategory(item.id)}
style={[styles.deleteTab, { backgroundColor: theme.deleteBg }]}
>
<XIcon size={15} color={theme.deleteIcon} />
</TouchableOpacity>
</View>
);
return (
<View style={[styles.container]}>
{/* Tanlangan kategoriya tablari */}
{selectedCategories.length > 0 && (
<>
<Text style={[styles.label, { color: theme.text }]}>{t('Tanlangan sohalar')}:</Text>
<FlatList
data={selectedCategories}
renderItem={renderTab}
keyExtractor={(item) => item.id.toString()}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.tabsContainer}
ItemSeparatorComponent={() => <View style={{ width: 8 }} />}
/>
</>
)}
{/* Kategoriya tanlash */}
<Text style={[styles.label, { color: theme.text }]}>{t("Sohalar ro'yxati")}:</Text>
<CategorySelection
selectedCategories={selectedCategories}
setSelectedCategories={setSelectedCategories}
/>
</View>
);
});
export default StepTwo;
const styles = StyleSheet.create({
container: { flexGrow: 1 },
label: {
fontSize: 16,
marginBottom: 5,
fontWeight: '600',
},
error: {
marginTop: 10,
fontWeight: '600',
fontSize: 14,
},
tabsContainer: {
marginBottom: 16,
paddingVertical: 4,
},
tabWrapper: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 8,
paddingHorizontal: 12,
borderRadius: 20,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.2,
shadowRadius: 3,
elevation: 3,
},
tabText: {
fontSize: 14,
fontWeight: '600',
marginRight: 6,
maxWidth: 200,
flexShrink: 1,
},
deleteTab: {
padding: 4,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
},
});