Mohammed Foud commited on
Commit
a90edf4
·
1 Parent(s): 93de659
src/bots/handlers/serviceHandlers.ts CHANGED
@@ -12,7 +12,9 @@ import {
12
  getPricesErrorMessage,
13
  getSearchProductPromptMessage,
14
  getNoSearchResultsMessage,
15
- getInvalidSearchInputMessage
 
 
16
  } from "../utils/messageUtils";
17
  import {
18
  getLoggedInMenuKeyboard,
@@ -34,7 +36,12 @@ const virtualNumberService = VirtualNumberService.getInstance();
34
 
35
  // Temporary in-memory state to track if a user is searching for a product
36
  interface UserSearchState {
37
- waitingForProductSearch: boolean;
 
 
 
 
 
38
  }
39
  const userSearchStates = new Map<number, UserSearchState>();
40
 
@@ -86,6 +93,26 @@ export const setupServiceHandlers = (bot: any) => {
86
  });
87
  });
88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  // Add handler for canceling search
90
  bot.action('cancel_search', async (ctx: BotContext) => {
91
  await ctx.answerCbQuery();
@@ -96,10 +123,26 @@ export const setupServiceHandlers = (bot: any) => {
96
  await handleBrowseServices(ctx);
97
  });
98
 
99
- // Text handler for product search input
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  bot.on('text', async (ctx: BotContext) => {
101
  const telegramId = ctx.from?.id;
102
- if (!telegramId || !userSearchStates.get(telegramId)?.waitingForProductSearch) {
 
 
103
  return; // Not in search state, let other text handlers process or ignore
104
  }
105
 
@@ -111,60 +154,143 @@ export const setupServiceHandlers = (bot: any) => {
111
 
112
  if (!query || query.length < 2) { // Require at least 2 characters for search
113
  await ctx.reply(getInvalidSearchInputMessage(), {
114
- reply_markup: Markup.inlineKeyboard([Markup.button.callback('↩️ Cancel Search', 'cancel_search')]).reply_markup
115
  });
116
  return;
117
  }
118
 
119
- // Filter products based on search query
120
- const searchResults = Object.entries(fiveSimProducts).filter(([serviceId, serviceData]) => {
121
- return serviceId.toLowerCase().includes(query) || serviceData.label_en.toLowerCase().includes(query);
122
- });
123
-
124
  userSearchStates.delete(telegramId); // Clear search state after processing
125
 
126
- if (searchResults.length === 0) {
127
- await ctx.editMessageText(getNoSearchResultsMessage(), {
128
- reply_markup: Markup.inlineKeyboard([Markup.button.callback('🔙 Back to Services', 'browse_services')]).reply_markup
 
129
  });
130
- return;
131
- }
132
 
133
- const buttons = [];
134
- const rowSize = 2; // 2 buttons per row
135
- const servicesPerPage = 20; // Same pagination as main services list
136
- const totalPages = Math.ceil(searchResults.length / servicesPerPage);
137
- const currentPage = 0; // Always start search results on the first page
 
138
 
139
- const startIndex = currentPage * servicesPerPage;
140
- const endIndex = Math.min(startIndex + servicesPerPage, searchResults.length);
141
- const pageServices = searchResults.slice(startIndex, endIndex);
 
142
 
143
- // Generate service buttons in pairs
144
- for (let i = 0; i < pageServices.length; i += rowSize) {
145
- const row = [];
146
- for (let j = 0; j < rowSize && i + j < pageServices.length; j++) {
147
- const [serviceId, serviceData] = pageServices[i + j];
148
- row.push(
149
- Markup.button.callback(
150
- `${serviceData.icon} ${serviceData.label_en}`,
151
- `service_${serviceId}`
152
- )
153
- );
 
 
 
 
 
 
154
  }
155
- buttons.push(row);
156
- }
157
 
158
- // Add pagination for search results (if needed, simplified for now)
159
- // If you need full pagination for search results, it would involve more complex state management
160
- // and modifying the callback data for pagination buttons to include the search query.
161
 
162
- buttons.push([Markup.button.callback('🔙 Back to Services', 'browse_services')]);
 
 
 
163
 
164
- await ctx.reply(getServicesPaginationInfo(currentPage), {
165
- parse_mode: 'HTML',
166
- reply_markup: Markup.inlineKeyboard(buttons).reply_markup
167
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  });
169
 
170
  // Register service selection handlers
@@ -283,7 +409,7 @@ export const handleServiceSelection = async (ctx: BotContext, service: string, s
283
  // Create buttons for countries with available numbers
284
  const buttons = [];
285
  const rowSize = 2; // 2 buttons per row
286
- const countriesPerPage = 10; // 10 countries per page
287
  let countries = Object.entries(servicePrices)
288
  .filter(([_, operators]) => {
289
  // Check if any operator has available numbers
@@ -324,6 +450,9 @@ export const handleServiceSelection = async (ctx: BotContext, service: string, s
324
  // Add sort button
325
  buttons.push([Markup.button.callback('Price ⬇️ (Cheapest)', `sort_countries_price_asc_${service}_${page}`)]);
326
 
 
 
 
327
  // Generate country buttons in pairs
328
  for (let i = 0; i < pageCountries.length; i += rowSize) {
329
  const row = [];
 
12
  getPricesErrorMessage,
13
  getSearchProductPromptMessage,
14
  getNoSearchResultsMessage,
15
+ getInvalidSearchInputMessage,
16
+ getSearchCountryPromptMessage,
17
+ getNoCountrySearchResultsMessage
18
  } from "../utils/messageUtils";
19
  import {
20
  getLoggedInMenuKeyboard,
 
36
 
37
  // Temporary in-memory state to track if a user is searching for a product
38
  interface UserSearchState {
39
+ waitingForProductSearch?: boolean;
40
+ waitingForCountrySearch?: { // If searching for a country
41
+ serviceId: string;
42
+ sortBy?: 'price_asc'; // Preserve sorting preference
43
+ page: number; // Preserve current page
44
+ };
45
  }
46
  const userSearchStates = new Map<number, UserSearchState>();
47
 
 
93
  });
94
  });
95
 
96
+ // Add handler for search country button
97
+ bot.action(/^search_country_(.+)_([a-zA-Z0-9]+|default)_(\d+)$/, async (ctx: BotContext) => {
98
+ await ctx.answerCbQuery();
99
+ const telegramId = ctx.from?.id;
100
+ if (!telegramId || !authService.isUserLoggedIn(telegramId, ctx)) {
101
+ await ctx.editMessageText(getAuthRequiredMessage(), { reply_markup: getMainMenuKeyboard().reply_markup });
102
+ return;
103
+ }
104
+
105
+ const match = ctx.match as RegExpMatchArray;
106
+ const serviceId = match[1];
107
+ const sortBy = match[2] === 'default' ? undefined : match[2] as 'price_asc';
108
+ const page = parseInt(match[3], 10);
109
+
110
+ userSearchStates.set(telegramId, { waitingForCountrySearch: { serviceId, sortBy, page } });
111
+ await ctx.editMessageText(getSearchCountryPromptMessage(), {
112
+ reply_markup: Markup.inlineKeyboard([Markup.button.callback('↩️ Cancel Search', `cancel_country_search_${serviceId}_${sortBy || 'default'}_${page}`)]).reply_markup
113
+ });
114
+ });
115
+
116
  // Add handler for canceling search
117
  bot.action('cancel_search', async (ctx: BotContext) => {
118
  await ctx.answerCbQuery();
 
123
  await handleBrowseServices(ctx);
124
  });
125
 
126
+ // Add handler for canceling country search
127
+ bot.action(/^cancel_country_search_(.+)_([a-zA-Z0-9]+|default)_(\d+)$/, async (ctx: BotContext) => {
128
+ await ctx.answerCbQuery();
129
+ const telegramId = ctx.from?.id;
130
+ if (telegramId) {
131
+ userSearchStates.delete(telegramId);
132
+ }
133
+ const match = ctx.match as RegExpMatchArray;
134
+ const serviceId = match[1];
135
+ const sortBy = match[2] === 'default' ? undefined : match[2] as 'price_asc';
136
+ const page = parseInt(match[3], 10);
137
+ await handleServiceSelection(ctx, serviceId, sortBy, page);
138
+ });
139
+
140
+ // Text handler for product/country search input
141
  bot.on('text', async (ctx: BotContext) => {
142
  const telegramId = ctx.from?.id;
143
+ const userState = userSearchStates.get(telegramId);
144
+
145
+ if (!telegramId || (!userState?.waitingForProductSearch && !userState?.waitingForCountrySearch)) {
146
  return; // Not in search state, let other text handlers process or ignore
147
  }
148
 
 
154
 
155
  if (!query || query.length < 2) { // Require at least 2 characters for search
156
  await ctx.reply(getInvalidSearchInputMessage(), {
157
+ reply_markup: Markup.inlineKeyboard([Markup.button.callback('↩️ Cancel Search', userState.waitingForProductSearch ? 'cancel_search' : `cancel_country_search_${userState.waitingForCountrySearch?.serviceId}_${userState.waitingForCountrySearch?.sortBy || 'default'}_${userState.waitingForCountrySearch?.page}`)]).reply_markup
158
  });
159
  return;
160
  }
161
 
 
 
 
 
 
162
  userSearchStates.delete(telegramId); // Clear search state after processing
163
 
164
+ if (userState.waitingForProductSearch) {
165
+ // Filter products based on search query
166
+ const searchResults = Object.entries(fiveSimProducts).filter(([serviceId, serviceData]) => {
167
+ return serviceId.toLowerCase().includes(query) || serviceData.label_en.toLowerCase().includes(query);
168
  });
 
 
169
 
170
+ if (searchResults.length === 0) {
171
+ await ctx.editMessageText(getNoSearchResultsMessage(), {
172
+ reply_markup: Markup.inlineKeyboard([Markup.button.callback('🔙 Back to Services', 'browse_services')]).reply_markup
173
+ });
174
+ return;
175
+ }
176
 
177
+ const buttons = [];
178
+ const rowSize = 2; // 2 buttons per row
179
+ const servicesPerPage = 20;
180
+ const currentPage = 0; // Always start search results on the first page
181
 
182
+ const startIndex = currentPage * servicesPerPage;
183
+ const endIndex = Math.min(startIndex + servicesPerPage, searchResults.length);
184
+ const pageServices = searchResults.slice(startIndex, endIndex);
185
+
186
+ // Generate service buttons in pairs
187
+ for (let i = 0; i < pageServices.length; i += rowSize) {
188
+ const row = [];
189
+ for (let j = 0; j < rowSize && i + j < pageServices.length; j++) {
190
+ const [serviceId, serviceData] = pageServices[i + j];
191
+ row.push(
192
+ Markup.button.callback(
193
+ `${serviceData.icon} ${serviceData.label_en}`,
194
+ `service_${serviceId}`
195
+ )
196
+ );
197
+ }
198
+ buttons.push(row);
199
  }
 
 
200
 
201
+ buttons.push([Markup.button.callback('🔙 Back to Services', 'browse_services')]);
 
 
202
 
203
+ await ctx.reply(getServicesPaginationInfo(currentPage), {
204
+ parse_mode: 'HTML',
205
+ reply_markup: Markup.inlineKeyboard(buttons).reply_markup
206
+ });
207
 
208
+ } else if (userState.waitingForCountrySearch) {
209
+ const { serviceId, sortBy, page } = userState.waitingForCountrySearch;
210
+ const allCountries = Object.entries(countryData); // Use all countries for search
211
+
212
+ const queryResults = allCountries.filter(([countryShortId, countryInfo]) => {
213
+ const searchTarget = `${countryInfo.label.toLowerCase()} ${countryInfo.flag.toLowerCase()} ${countryInfo.code.toLowerCase()} ${countryShortId.toLowerCase()}`;
214
+ return searchTarget.includes(query);
215
+ });
216
+
217
+ // Get prices for the selected service to filter countries by availability and get min/max prices
218
+ let productPricesForService: any = {};
219
+ try {
220
+ const fullProductPrices = await virtualNumberService.getProductPrices(serviceId);
221
+ if (fullProductPrices && fullProductPrices[serviceId]) {
222
+ productPricesForService = fullProductPrices[serviceId];
223
+ }
224
+ } catch (error: any) {
225
+ logger.error(`Error fetching prices for service ${serviceId} during country search: ${error.message}`);
226
+ await ctx.editMessageText(getServiceErrorMessage(serviceId), {
227
+ parse_mode: 'HTML',
228
+ reply_markup: Markup.inlineKeyboard([Markup.button.callback('🔙 Back to Services', 'browse_services')]).reply_markup
229
+ });
230
+ return;
231
+ }
232
+
233
+ const searchResults = queryResults.filter(([countryShortId, _]) => {
234
+ const countryOperators = productPricesForService[countryShortId];
235
+ // Only include countries that have available numbers for the current service
236
+ return countryOperators && Object.values(countryOperators).some((op: any) => op.count > 0);
237
+ }).map(([countryId, countryInfo]) => {
238
+ const operators = productPricesForService[countryId];
239
+ const prices = Object.values(operators)
240
+ .filter((op: any) => op.count > 0)
241
+ .map((op: any) => calculatePriceWithProfit(ctx, op.cost, 'RUB'));
242
+
243
+ const minPrice = prices.length > 0 ? parseFloat(Math.min(...prices).toFixed(2)) : 0;
244
+ const maxPrice = prices.length > 0 ? parseFloat(Math.max(...prices).toFixed(2)) : 0;
245
+
246
+ return { countryId, countryInfo, minPrice, maxPrice };
247
+ });
248
+
249
+ if (searchResults.length === 0) {
250
+ await ctx.editMessageText(getNoCountrySearchResultsMessage(), {
251
+ reply_markup: Markup.inlineKeyboard([Markup.button.callback('↩️ Cancel Search', `cancel_country_search_${serviceId}_${sortBy || 'default'}_${page}`)]).reply_markup
252
+ });
253
+ return;
254
+ }
255
+
256
+ const buttons = [];
257
+ const rowSize = 2;
258
+ const countriesPerPage = 10;
259
+ const totalPages = Math.ceil(searchResults.length / countriesPerPage);
260
+ const currentPage = 0;
261
+
262
+ const startIndex = currentPage * countriesPerPage;
263
+ const endIndex = Math.min(startIndex + countriesPerPage, searchResults.length);
264
+ const pageCountries = searchResults.slice(startIndex, endIndex);
265
+
266
+ for (let i = 0; i < pageCountries.length; i += rowSize) {
267
+ const row = [];
268
+ for (let j = 0; j < rowSize && i + j < pageCountries.length; j++) {
269
+ const { countryId, countryInfo, minPrice, maxPrice } = pageCountries[i + j];
270
+
271
+ const priceText = minPrice === maxPrice
272
+ ? `${minPrice}$`
273
+ : `${minPrice}$-${maxPrice}$`;
274
+
275
+ row.push(
276
+ Markup.button.callback(
277
+ `${countryInfo.flag} ${countryInfo.label} (${priceText})`,
278
+ `country_${serviceId}_${countryId}`
279
+ )
280
+ );
281
+ }
282
+ if (row.length > 0) {
283
+ buttons.push(row);
284
+ }
285
+ }
286
+
287
+ buttons.push([Markup.button.callback('↩️ Back to Countries', `cancel_country_search_${serviceId}_${sortBy || 'default'}_${page}`)]);
288
+
289
+ await ctx.reply(getCountriesListMessage(serviceId), {
290
+ parse_mode: 'HTML',
291
+ reply_markup: Markup.inlineKeyboard(buttons).reply_markup
292
+ });
293
+ }
294
  });
295
 
296
  // Register service selection handlers
 
409
  // Create buttons for countries with available numbers
410
  const buttons = [];
411
  const rowSize = 2; // 2 buttons per row
412
+ const countriesPerPage = 20; // 10 countries per page
413
  let countries = Object.entries(servicePrices)
414
  .filter(([_, operators]) => {
415
  // Check if any operator has available numbers
 
450
  // Add sort button
451
  buttons.push([Markup.button.callback('Price ⬇️ (Cheapest)', `sort_countries_price_asc_${service}_${page}`)]);
452
 
453
+ // Add search country button
454
+ buttons.push([Markup.button.callback('🔍 Search Country', `search_country_${service}_${sortBy || 'default'}_${page}`)]);
455
+
456
  // Generate country buttons in pairs
457
  for (let i = 0; i < pageCountries.length; i += rowSize) {
458
  const row = [];
src/bots/utils/messageManager.ts CHANGED
@@ -82,6 +82,14 @@ class MessageManager {
82
  'invalid_search_input': {
83
  ar_value: 'الرجاء إدخال ما لا يقل عن حرفين للبحث.',
84
  en_value: 'Please enter at least 2 characters to search.'
 
 
 
 
 
 
 
 
85
  }
86
  };
87
 
 
82
  'invalid_search_input': {
83
  ar_value: 'الرجاء إدخال ما لا يقل عن حرفين للبحث.',
84
  en_value: 'Please enter at least 2 characters to search.'
85
+ },
86
+ 'search_country_prompt': {
87
+ ar_value: 'الرجاء إدخال اسم الدولة أو رمزها أو علمها:',
88
+ en_value: 'Please enter the country name, code, or flag:'
89
+ },
90
+ 'no_country_search_results': {
91
+ ar_value: 'عذراً، لم يتم العثور على دول مطابقة لبحثك.',
92
+ en_value: 'Sorry, no matching countries found for your search.'
93
  }
94
  };
95
 
src/bots/utils/messageUtils.ts CHANGED
@@ -169,4 +169,12 @@ export const getNoSearchResultsMessage = () => {
169
 
170
  export const getInvalidSearchInputMessage = () => {
171
  return messageManager.getMessage('invalid_search_input');
 
 
 
 
 
 
 
 
172
  };
 
169
 
170
  export const getInvalidSearchInputMessage = () => {
171
  return messageManager.getMessage('invalid_search_input');
172
+ };
173
+
174
+ export const getSearchCountryPromptMessage = () => {
175
+ return messageManager.getMessage('search_country_prompt');
176
+ };
177
+
178
+ export const getNoCountrySearchResultsMessage = () => {
179
+ return messageManager.getMessage('no_country_search_results');
180
  };