Mohammed Foud commited on
Commit
59bee0f
·
1 Parent(s): 33ed46f
src/bots/handlers/serviceHandlers.ts CHANGED
@@ -49,15 +49,32 @@ export const setupServiceHandlers = (bot: any) => {
49
  // Add handler for browse services button
50
  bot.action('browse_services', async (ctx: BotContext) => {
51
  await ctx.answerCbQuery();
52
- await handleBrowseServices(ctx);
53
  });
54
 
55
  // Add handler for services pagination
56
- bot.action(/^services_page_(\d+)$/, async (ctx: BotContext) => {
57
  await ctx.answerCbQuery();
58
  const match = ctx.match as RegExpMatchArray;
59
  const page = parseInt(match[1], 10);
60
- await handleServicesPagination(ctx, page);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  });
62
 
63
  // Add handler for sorting countries by price
@@ -153,7 +170,7 @@ export const setupServiceHandlers = (bot: any) => {
153
  userSearchStates.delete(telegramId);
154
  }
155
  try {
156
- await handleBrowseServices(ctx);
157
  } catch (editError: any) {
158
  if (editError.message && editError.message.includes('message is not modified')) {
159
  logger.info(`Browse services message not modified (cancel_search). No action needed.`);
@@ -187,157 +204,191 @@ export const setupServiceHandlers = (bot: any) => {
187
 
188
  // Text handler for product/country search input
189
  bot.on('text', async (ctx: BotContext) => {
190
- const telegramId = ctx.from?.id;
191
- const userState = userSearchStates.get(telegramId);
192
-
193
- if (!telegramId || (!userState?.waitingForProductSearch && !userState?.waitingForCountrySearch)) {
194
- return; // Not in search state, let other text handlers process or ignore
195
- }
196
-
197
- // Ensure ctx.message is a text message. Telegraf's `bot.on('text')` guarantees this, but TypeScript needs explicit narrowing.
198
- const message = ctx.message as Message.TextMessage;
199
-
200
- // Now 'text' property is safely accessible
201
- const query = message.text?.toLowerCase().trim();
202
 
203
- if (!query || query.length < 2) { // Require at least 2 characters for search
204
- await ctx.reply(getInvalidSearchInputMessage(), {
205
- 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
206
- });
207
- return;
208
- }
209
 
210
- userSearchStates.delete(telegramId); // Clear search state after processing
 
211
 
212
- if (userState.waitingForProductSearch) {
213
- // Filter products based on search query
214
- const searchResults = Object.entries(fiveSimProducts).filter(([serviceId, serviceData]) => {
215
- return serviceId.toLowerCase().includes(query) || serviceData.label_en.toLowerCase().includes(query);
216
- });
217
 
218
- if (searchResults.length === 0) {
219
- await ctx.reply(getNoSearchResultsMessage(), {
220
- reply_markup: Markup.inlineKeyboard([Markup.button.callback('🔙 Back to Services', 'browse_services')]).reply_markup
221
- });
 
 
 
 
222
  return;
223
  }
224
 
225
- const buttons = [];
226
- const rowSize = 2; // 2 buttons per row
227
- const servicesPerPage = 20;
228
- const currentPage = 0; // Always start search results on the first page
229
 
230
- const startIndex = currentPage * servicesPerPage;
231
- const endIndex = Math.min(startIndex + servicesPerPage, searchResults.length);
232
- const pageServices = searchResults.slice(startIndex, endIndex);
 
 
233
 
234
- // Generate service buttons in pairs
235
- for (let i = 0; i < pageServices.length; i += rowSize) {
236
- const row = [];
237
- for (let j = 0; j < rowSize && i + j < pageServices.length; j++) {
238
- const [serviceId, serviceData] = pageServices[i + j];
239
- row.push(
240
- Markup.button.callback(
241
- `${serviceData.icon} ${serviceData.label_en}`,
242
- `service_${serviceId}`
243
- )
244
- );
245
  }
246
- buttons.push(row);
247
- }
248
 
249
- buttons.push([Markup.button.callback('🔙 Back to Services', 'browse_services')]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
 
251
- await ctx.reply(getServicesPaginationInfo(currentPage), {
252
- parse_mode: 'HTML',
253
- reply_markup: Markup.inlineKeyboard(buttons).reply_markup
254
- });
255
 
256
- } else if (userState.waitingForCountrySearch) {
257
- const { serviceId, sortBy, page } = userState.waitingForCountrySearch;
258
- const allCountries = Object.entries(countryData); // Use all countries for search
 
 
 
 
 
259
 
260
- const queryResults = allCountries.filter(([countryShortId, countryInfo]) => {
261
- const searchTarget = `${countryInfo.label.toLowerCase()} ${countryInfo.flag.toLowerCase()} ${countryInfo.code.toLowerCase()} ${countryShortId.toLowerCase()}`;
262
- return searchTarget.includes(query);
263
- });
264
 
265
- // Get prices for the selected service to filter countries by availability and get min/max prices
266
- let productPricesForService: any = {};
267
- try {
268
- const fullProductPrices = await virtualNumberService.getProductPrices(serviceId);
269
- if (fullProductPrices && fullProductPrices[serviceId]) {
270
- productPricesForService = fullProductPrices[serviceId];
271
- }
272
- } catch (error: any) {
273
- logger.error(`Error fetching prices for service ${serviceId} during country search: ${error.message}`);
274
- await ctx.reply(getServiceErrorMessage(serviceId), {
275
- parse_mode: 'HTML',
276
- reply_markup: Markup.inlineKeyboard([Markup.button.callback('🔙 Back to Services', 'browse_services')]).reply_markup
277
  });
278
- return;
279
- }
280
 
281
- const searchResults = queryResults.filter(([countryShortId, _]) => {
282
- const countryOperators = productPricesForService[countryShortId];
283
- // Only include countries that have available numbers for the current service
284
- return countryOperators && Object.values(countryOperators).some((op: any) => op.count > 0);
285
- }).map(([countryId, countryInfo]) => {
286
- const operators = productPricesForService[countryId];
287
- const prices = Object.values(operators)
288
- .filter((op: any) => op.count > 0)
289
- .map((op: any) => calculatePriceWithProfit(ctx, op.cost, 'RUB'));
290
-
291
- const minPrice = prices.length > 0 ? parseFloat(Math.min(...prices).toFixed(2)) : 0;
292
- const maxPrice = prices.length > 0 ? parseFloat(Math.max(...prices).toFixed(2)) : 0;
293
-
294
- return { countryId, countryInfo, minPrice, maxPrice };
295
- });
 
 
 
 
296
 
297
- if (searchResults.length === 0) {
298
- await ctx.reply(getNoCountrySearchResultsMessage(), {
299
- reply_markup: Markup.inlineKeyboard([Markup.button.callback('↩️ Cancel Search', `cancel_country_search_${serviceId}_${sortBy || 'default'}_${page}`)]).reply_markup
 
 
 
 
 
 
 
 
 
 
 
300
  });
301
- return;
302
- }
303
 
304
- const buttons = [];
305
- const rowSize = 2;
306
- const countriesPerPage = 10;
307
- const totalPages = Math.ceil(searchResults.length / countriesPerPage);
308
- const currentPage = 0;
 
 
 
 
 
309
 
310
- const startIndex = currentPage * countriesPerPage;
311
- const endIndex = Math.min(startIndex + countriesPerPage, searchResults.length);
312
- const pageCountries = searchResults.slice(startIndex, endIndex);
313
 
314
- for (let i = 0; i < pageCountries.length; i += rowSize) {
315
- const row = [];
316
- for (let j = 0; j < rowSize && i + j < pageCountries.length; j++) {
317
- const { countryId, countryInfo, minPrice, maxPrice } = pageCountries[i + j];
318
-
319
- const priceText = minPrice === maxPrice
320
- ? `${minPrice}$`
321
- : `${minPrice}$-${maxPrice}$`;
322
 
323
- row.push(
324
- Markup.button.callback(
325
- `${countryInfo.flag} ${countryInfo.label} (${priceText})`,
326
- `country_${serviceId}_${countryId}`
327
- )
328
- );
329
- }
330
- if (row.length > 0) {
 
 
 
 
 
 
 
 
 
 
 
 
331
  buttons.push(row);
332
  }
333
- }
334
 
335
- buttons.push([Markup.button.callback('↩️ Back to Countries', `cancel_country_search_${serviceId}_${sortBy || 'default'}_${page}`)]);
336
 
337
- await ctx.reply(getCountriesListMessage(serviceId), {
338
- parse_mode: 'HTML',
339
- reply_markup: Markup.inlineKeyboard(buttons).reply_markup
340
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
341
  }
342
  });
343
 
@@ -356,7 +407,7 @@ export const setupServiceHandlers = (bot: any) => {
356
  });
357
  };
358
 
359
- export const handleBrowseServices = async (ctx: BotContext) => {
360
  // 🔒 Authentication check
361
  const telegramId = ctx.from?.id;
362
  if (!telegramId || !authService.isUserLoggedIn(telegramId, ctx)) {
@@ -377,7 +428,7 @@ export const handleBrowseServices = async (ctx: BotContext) => {
377
  getServicesPaginationInfo(0),
378
  {
379
  parse_mode: 'HTML',
380
- reply_markup: getServicesKeyboard(0).reply_markup
381
  }
382
  );
383
  } catch (editError: any) {
@@ -389,11 +440,19 @@ export const handleBrowseServices = async (ctx: BotContext) => {
389
  }
390
  };
391
 
392
- export const handleServicesPagination = async (ctx: BotContext, page: number) => {
393
  // 🔒 Authentication check
394
  const telegramId = ctx.from?.id;
395
  if (!telegramId || !authService.isUserLoggedIn(telegramId, ctx)) {
396
- await ctx.editMessageText(getAuthRequiredMessage(), { reply_markup: getMainMenuKeyboard().reply_markup });
 
 
 
 
 
 
 
 
397
  return; // Explicitly return nothing after editing
398
  }
399
 
@@ -404,7 +463,7 @@ export const handleServicesPagination = async (ctx: BotContext, page: number) =>
404
  getServicesPaginationInfo(page),
405
  {
406
  parse_mode: 'HTML',
407
- reply_markup: getServicesKeyboard(page).reply_markup
408
  }
409
  );
410
  } catch (editError: any) {
 
49
  // Add handler for browse services button
50
  bot.action('browse_services', async (ctx: BotContext) => {
51
  await ctx.answerCbQuery();
52
+ await handleBrowseServices(ctx, undefined); // Pass undefined for initial sortBy
53
  });
54
 
55
  // Add handler for services pagination
56
+ bot.action(/^services_page_(\d+)(_([a-zA-Z]+))?$/, async (ctx: BotContext) => {
57
  await ctx.answerCbQuery();
58
  const match = ctx.match as RegExpMatchArray;
59
  const page = parseInt(match[1], 10);
60
+ const sortBy = match[3] as 'az' | 'za' | undefined;
61
+ await handleServicesPagination(ctx, page, sortBy);
62
+ });
63
+
64
+ // Add handler for sorting services by A-Z
65
+ bot.action(/^sort_services_az_(\d+)$/, async (ctx: BotContext) => {
66
+ await ctx.answerCbQuery();
67
+ const match = ctx.match as RegExpMatchArray;
68
+ const page = parseInt(match[1], 10);
69
+ await handleServicesPagination(ctx, page, 'az');
70
+ });
71
+
72
+ // Add handler for sorting services by Z-A
73
+ bot.action(/^sort_services_za_(\d+)$/, async (ctx: BotContext) => {
74
+ await ctx.answerCbQuery();
75
+ const match = ctx.match as RegExpMatchArray;
76
+ const page = parseInt(match[1], 10);
77
+ await handleServicesPagination(ctx, page, 'za');
78
  });
79
 
80
  // Add handler for sorting countries by price
 
170
  userSearchStates.delete(telegramId);
171
  }
172
  try {
173
+ await handleBrowseServices(ctx, undefined); // Pass undefined to reset sort
174
  } catch (editError: any) {
175
  if (editError.message && editError.message.includes('message is not modified')) {
176
  logger.info(`Browse services message not modified (cancel_search). No action needed.`);
 
204
 
205
  // Text handler for product/country search input
206
  bot.on('text', async (ctx: BotContext) => {
207
+ try {
208
+ const telegramId = ctx.from?.id;
209
+ const userState = userSearchStates.get(telegramId);
 
 
 
 
 
 
 
 
 
210
 
211
+ if (!telegramId || (!userState?.waitingForProductSearch && !userState?.waitingForCountrySearch)) {
212
+ return; // Not in search state, let other text handlers process or ignore
213
+ }
 
 
 
214
 
215
+ // Ensure ctx.message is a text message. Telegraf's `bot.on('text')` guarantees this, but TypeScript needs explicit narrowing.
216
+ const message = ctx.message as Message.TextMessage;
217
 
218
+ // Now 'text' property is safely accessible
219
+ const query = message.text?.toLowerCase().trim();
 
 
 
220
 
221
+ if (!query || query.length < 2) { // Require at least 2 characters for search
222
+ try {
223
+ await ctx.reply(getInvalidSearchInputMessage(), {
224
+ 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
225
+ });
226
+ } catch (replyError: any) {
227
+ logger.error(`Error replying with invalid search input message: ${replyError.message}`);
228
+ }
229
  return;
230
  }
231
 
232
+ userSearchStates.delete(telegramId); // Clear search state after processing
 
 
 
233
 
234
+ if (userState.waitingForProductSearch) {
235
+ // Filter products based on search query
236
+ const searchResults = Object.entries(fiveSimProducts).filter(([serviceId, serviceData]) => {
237
+ return serviceId.toLowerCase().includes(query) || serviceData.label_en.toLowerCase().includes(query);
238
+ });
239
 
240
+ if (searchResults.length === 0) {
241
+ try {
242
+ await ctx.reply(getNoSearchResultsMessage(), {
243
+ reply_markup: Markup.inlineKeyboard([Markup.button.callback('🔙 Back to Services', 'browse_services')]).reply_markup
244
+ });
245
+ } catch (replyError: any) {
246
+ logger.error(`Error replying with no search results message: ${replyError.message}`);
247
+ }
248
+ return;
 
 
249
  }
 
 
250
 
251
+ const buttons = [];
252
+ const rowSize = 2; // 2 buttons per row
253
+ const servicesPerPage = 20;
254
+
255
+ const currentPage = 0; // Always start search results on the first page
256
+
257
+ const startIndex = currentPage * servicesPerPage;
258
+ const endIndex = Math.min(startIndex + servicesPerPage, searchResults.length);
259
+ const pageServices = searchResults.slice(startIndex, endIndex);
260
+
261
+ // Generate service buttons in pairs
262
+ for (let i = 0; i < pageServices.length; i += rowSize) {
263
+ const row = [];
264
+ for (let j = 0; j < rowSize && i + j < pageServices.length; j++) {
265
+ const [serviceId, serviceData] = pageServices[i + j];
266
+ row.push(
267
+ Markup.button.callback(
268
+ `${serviceData.icon} ${serviceData.label_en}`,
269
+ `service_${serviceId}`
270
+ )
271
+ );
272
+ }
273
+ buttons.push(row);
274
+ }
275
 
276
+ buttons.push([Markup.button.callback('🔙 Back to Services', 'browse_services')]);
 
 
 
277
 
278
+ try {
279
+ await ctx.reply(getServicesPaginationInfo(currentPage), {
280
+ parse_mode: 'HTML',
281
+ reply_markup: Markup.inlineKeyboard(buttons).reply_markup
282
+ });
283
+ } catch (replyError: any) {
284
+ logger.error(`Error replying with product search results: ${replyError.message}`);
285
+ }
286
 
287
+ } else if (userState.waitingForCountrySearch) {
288
+ const { serviceId, sortBy, page } = userState.waitingForCountrySearch;
289
+ const allCountries = Object.entries(countryData); // Use all countries for search
 
290
 
291
+ const queryResults = allCountries.filter(([countryShortId, countryInfo]) => {
292
+ const searchTarget = `${countryInfo.label.toLowerCase()} ${countryInfo.flag.toLowerCase()} ${countryInfo.code.toLowerCase()} ${countryShortId.toLowerCase()}`;
293
+ return searchTarget.includes(query);
 
 
 
 
 
 
 
 
 
294
  });
 
 
295
 
296
+ // Get prices for the selected service to filter countries by availability and get min/max prices
297
+ let productPricesForService: any = {};
298
+ try {
299
+ const fullProductPrices = await virtualNumberService.getProductPrices(serviceId);
300
+ if (fullProductPrices && fullProductPrices[serviceId]) {
301
+ productPricesForService = fullProductPrices[serviceId];
302
+ }
303
+ } catch (error: any) {
304
+ logger.error(`Error fetching prices for service ${serviceId} during country search: ${error.message}`);
305
+ try {
306
+ await ctx.reply(getServiceErrorMessage(serviceId), {
307
+ parse_mode: 'HTML',
308
+ reply_markup: Markup.inlineKeyboard([Markup.button.callback('🔙 Back to Services', 'browse_services')]).reply_markup
309
+ });
310
+ } catch (replyError: any) {
311
+ logger.error(`Error replying with service error message during country search: ${replyError.message}`);
312
+ }
313
+ return;
314
+ }
315
 
316
+ const searchResults = queryResults.filter(([countryShortId, _]) => {
317
+ const countryOperators = productPricesForService[countryShortId];
318
+ // Only include countries that have available numbers for the current service
319
+ return countryOperators && Object.values(countryOperators).some((op: any) => op.count > 0);
320
+ }).map(([countryId, countryInfo]) => {
321
+ const operators = productPricesForService[countryId];
322
+ const prices = Object.values(operators)
323
+ .filter((op: any) => op.count > 0)
324
+ .map((op: any) => calculatePriceWithProfit(ctx, op.cost, 'RUB'));
325
+
326
+ const minPrice = prices.length > 0 ? parseFloat(Math.min(...prices).toFixed(2)) : 0;
327
+ const maxPrice = prices.length > 0 ? parseFloat(Math.max(...prices).toFixed(2)) : 0;
328
+
329
+ return { countryId, countryInfo, minPrice, maxPrice };
330
  });
 
 
331
 
332
+ if (searchResults.length === 0) {
333
+ try {
334
+ await ctx.reply(getNoCountrySearchResultsMessage(), {
335
+ reply_markup: Markup.inlineKeyboard([Markup.button.callback('↩️ Cancel Search', `cancel_country_search_${serviceId}_${sortBy || 'default'}_${page}`)]).reply_markup
336
+ });
337
+ } catch (replyError: any) {
338
+ logger.error(`Error replying with no country search results message: ${replyError.message}`);
339
+ }
340
+ return;
341
+ }
342
 
343
+ const buttons = [];
344
+ const rowSize = 2;
345
+ const countriesPerPage = 10;
346
 
347
+ const currentPage = 0;
 
 
 
 
 
 
 
348
 
349
+ const startIndex = currentPage * countriesPerPage;
350
+ const endIndex = Math.min(startIndex + countriesPerPage, searchResults.length);
351
+ const pageCountries = searchResults.slice(startIndex, endIndex);
352
+
353
+ for (let i = 0; i < pageCountries.length; i += rowSize) {
354
+ const row = [];
355
+ for (let j = 0; j < rowSize && i + j < pageCountries.length; j++) {
356
+ const { countryId, countryInfo, minPrice, maxPrice } = pageCountries[i + j];
357
+
358
+ const priceText = minPrice === maxPrice
359
+ ? `${minPrice}$`
360
+ : `${minPrice}$-${maxPrice}$`;
361
+
362
+ row.push(
363
+ Markup.button.callback(
364
+ `${countryInfo.flag} ${countryInfo.label} (${priceText})`,
365
+ `country_${serviceId}_${countryId}`
366
+ )
367
+ );
368
+ }
369
  buttons.push(row);
370
  }
 
371
 
372
+ buttons.push([Markup.button.callback('↩️ Back to Countries', `cancel_country_search_${serviceId}_${sortBy || 'default'}_${page}`)]);
373
 
374
+ try {
375
+ await ctx.reply(getCountriesListMessage(serviceId), {
376
+ parse_mode: 'HTML',
377
+ reply_markup: Markup.inlineKeyboard(buttons).reply_markup
378
+ });
379
+ } catch (replyError: any) {
380
+ logger.error(`Error replying with country search results: ${replyError.message}`);
381
+ }
382
+ }
383
+ } catch (handlerError: any) {
384
+ logger.error(`Unhandled error in text handler: ${handlerError.message}`);
385
+ try {
386
+ await ctx.reply('⚠️ An unexpected error occurred. Please try again or go back to the main menu.', {
387
+ reply_markup: getLoggedInMenuKeyboard().reply_markup
388
+ });
389
+ } catch (finalReplyError: any) {
390
+ logger.error(`Error sending final error message: ${finalReplyError.message}`);
391
+ }
392
  }
393
  });
394
 
 
407
  });
408
  };
409
 
410
+ export const handleBrowseServices = async (ctx: BotContext, sortBy?: 'az' | 'za') => {
411
  // 🔒 Authentication check
412
  const telegramId = ctx.from?.id;
413
  if (!telegramId || !authService.isUserLoggedIn(telegramId, ctx)) {
 
428
  getServicesPaginationInfo(0),
429
  {
430
  parse_mode: 'HTML',
431
+ reply_markup: getServicesKeyboard(0, sortBy).reply_markup
432
  }
433
  );
434
  } catch (editError: any) {
 
440
  }
441
  };
442
 
443
+ export const handleServicesPagination = async (ctx: BotContext, page: number, sortBy?: 'az' | 'za') => {
444
  // 🔒 Authentication check
445
  const telegramId = ctx.from?.id;
446
  if (!telegramId || !authService.isUserLoggedIn(telegramId, ctx)) {
447
+ try {
448
+ await ctx.editMessageText(getAuthRequiredMessage(), { reply_markup: getMainMenuKeyboard().reply_markup });
449
+ } catch (editError: any) {
450
+ if (editError.message && editError.message.includes('message is not modified')) {
451
+ logger.info(`Auth required message not modified. No action needed.`);
452
+ } else {
453
+ logger.error(`Error editing auth required message: ${editError.message}`);
454
+ }
455
+ }
456
  return; // Explicitly return nothing after editing
457
  }
458
 
 
463
  getServicesPaginationInfo(page),
464
  {
465
  parse_mode: 'HTML',
466
+ reply_markup: getServicesKeyboard(page, sortBy).reply_markup // Pass sortBy here
467
  }
468
  );
469
  } catch (editError: any) {
src/bots/utils/keyboardUtils.ts CHANGED
@@ -36,7 +36,7 @@ export const getLoggedInMenuKeyboard = () => {
36
  ]);
37
  };
38
 
39
- export const getServicesKeyboard = (page: number = 0) => {
40
  const buttons = [];
41
  const services = Object.entries(fiveSimProducts);
42
  const rowSize = 2; // 2 buttons per row
@@ -46,7 +46,19 @@ export const getServicesKeyboard = (page: number = 0) => {
46
  // Get services for current page
47
  const startIndex = page * servicesPerPage;
48
  const endIndex = Math.min(startIndex + servicesPerPage, services.length);
49
- const pageServices = services.slice(startIndex, endIndex);
 
 
 
 
 
 
 
 
 
 
 
 
50
 
51
  // Generate service buttons in pairs
52
  for (let i = 0; i < pageServices.length; i += rowSize) {
@@ -69,7 +81,7 @@ export const getServicesKeyboard = (page: number = 0) => {
69
  paginationRow.push(
70
  Markup.button.callback(
71
  '⬅️ Previous',
72
- `services_page_${page - 1}`
73
  )
74
  );
75
  }
@@ -85,7 +97,7 @@ export const getServicesKeyboard = (page: number = 0) => {
85
  paginationRow.push(
86
  Markup.button.callback(
87
  'Next ➡️',
88
- `services_page_${page + 1}`
89
  )
90
  );
91
  }
 
36
  ]);
37
  };
38
 
39
+ export const getServicesKeyboard = (page: number = 0, sortBy?: 'az' | 'za') => {
40
  const buttons = [];
41
  const services = Object.entries(fiveSimProducts);
42
  const rowSize = 2; // 2 buttons per row
 
46
  // Get services for current page
47
  const startIndex = page * servicesPerPage;
48
  const endIndex = Math.min(startIndex + servicesPerPage, services.length);
49
+ let pageServices = services.slice(startIndex, endIndex);
50
+
51
+ // Apply sorting for services
52
+ if (sortBy === 'az') {
53
+ pageServices.sort((a, b) => a[1].label_en.localeCompare(b[1].label_en));
54
+ } else if (sortBy === 'za') {
55
+ pageServices.sort((a, b) => b[1].label_en.localeCompare(a[1].label_en));
56
+ }
57
+
58
+ // Add sort button
59
+ const sortButtonText = sortBy === 'az' ? 'Sort Z-A ⬇️' : 'Sort A-Z ⬆️';
60
+ const sortButtonCallback = sortBy === 'az' ? `sort_services_za_${page}` : `sort_services_az_${page}`;
61
+ buttons.push([Markup.button.callback(sortButtonText, sortButtonCallback)]);
62
 
63
  // Generate service buttons in pairs
64
  for (let i = 0; i < pageServices.length; i += rowSize) {
 
81
  paginationRow.push(
82
  Markup.button.callback(
83
  '⬅️ Previous',
84
+ `services_page_${page - 1}${sortBy ? `_${sortBy}` : ''}`
85
  )
86
  );
87
  }
 
97
  paginationRow.push(
98
  Markup.button.callback(
99
  'Next ➡️',
100
+ `services_page_${page + 1}${sortBy ? `_${sortBy}` : ''}`
101
  )
102
  );
103
  }