From 0ce0e0df77f1bcf7f3b152b791efac651398bc88 Mon Sep 17 00:00:00 2001 From: nns Date: Mon, 12 Oct 2026 21:46:15 +0500 Subject: [PATCH] =?UTF-8?q?fix(=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F):=207=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BE?= =?UTF-8?q?=D0=BA=20=D1=8D=D1=82=D0=B0=D0=BF=D0=B0=20=C2=AB=D0=A0=D0=B5?= =?UTF-8?q?=D0=B3=D0=B8=D1=81=D1=82=D1=80=D0=B0=D1=86=D0=B8=D1=8F=C2=BB=20?= =?UTF-8?q?(=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=2012.10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Маска телефона: префикс «+7 », оператор не подставляется заранее. Нормализация снимает ведущую «8» и код страны «7» (на 11 цифрах), value хранится каноническим «+7»+national и восстанавливается снятием литерального «+7» (без 8/11-эвристик) — ввод больше не «скачет». Обе копии (public + admin). Проверено на всех вариантах + вставка. 2. Тариф и бесплатный период: Organization.Plan/FreeTrialEndsAt + конфигурация «Бизнес» (Phase50, существующие → «start», дата NULL). Signup сохраняет план и 90 дней; показ в кабинете владельца и в карточке/списке суперадмина. Биллинга/блокировки нет. 3. Ошибки формы: переход к первому полю с ошибкой + фокус; единые сообщения «Укажите email/телефон/название магазина», «Придумайте пароль». 4. «Первые шаги»: кнопка «Скрыть» (возврат из настроек организации); шаг каталога показывает статус копии реестра («загружается…» / «Каталог готов: N товаров — проверьте продажные цены»); ссылка на Forgejo заменена на «Базу знаний». 5. «Новые функции»: не считаем изменения до даты регистрации орг (новой орг баннер скрыт) + русская плюрализация улучшений/исправлений. 6. Карточка товара: «Закупочная цена» видна и в простом режиме (настройка ShowReferencePriceOnProduct), подпись настройки приведена к новой схеме. 7. Копия реестра: первый штрихкод помечается «Основным»; бэкфилл уже скопированных товаров без основного (Phase51). Тесты: PlanSignupTests (3), RegistryCopyPrimaryBarcodeTests (1), MarkupToggleTests (3, регрессия) — зелёные. Phone-нормализация покрыта симуляцией ввода/вставки. Co-Authored-By: Claude Opus 4.8 --- .../Controllers/AuthSignupController.cs | 17 ++- .../OrganizationSettingsController.cs | 17 ++- .../SuperAdminOrganizationsController.cs | 15 ++- .../Controllers/WhatsNewController.cs | 29 ++++- .../Organizations/Organization.cs | 20 ++++ .../20261012060000_Phase50_PlanAndTrial.cs | 36 +++++++ ...12070000_Phase51_BackfillPrimaryBarcode.cs | 42 ++++++++ .../Registry/ProductRegistryService.cs | 17 ++- .../src/components/PhoneInput.tsx | 98 +++++++++-------- .../src/components/SignupForm.tsx | 30 +++++- src/food-market.public/src/lib/validation.ts | 6 +- .../src/components/PhoneInput.tsx | 100 +++++++++++------- .../src/components/WhatsNewBanner.tsx | 16 ++- .../src/lib/onboardingPrefs.ts | 28 +++++ .../src/lib/useOnboardingProgress.ts | 12 +++ src/food-market.web/src/lib/useOrgSettings.ts | 7 ++ .../src/pages/OnboardingPage.tsx | 81 +++++++++++--- .../src/pages/OrganizationSettingsPage.tsx | 71 ++++++++++++- .../src/pages/ProductEditPage.tsx | 7 +- .../src/pages/SuperAdminOrganizationsPage.tsx | 13 +++ .../PlanSignupTests.cs | 81 ++++++++++++++ .../RegistryCopyPrimaryBarcodeTests.cs | 74 +++++++++++++ 22 files changed, 690 insertions(+), 127 deletions(-) create mode 100644 src/food-market.infrastructure/Persistence/Migrations/20261012060000_Phase50_PlanAndTrial.cs create mode 100644 src/food-market.infrastructure/Persistence/Migrations/20261012070000_Phase51_BackfillPrimaryBarcode.cs create mode 100644 src/food-market.web/src/lib/onboardingPrefs.ts create mode 100644 tests/food-market.IntegrationTests/PlanSignupTests.cs create mode 100644 tests/food-market.IntegrationTests/RegistryCopyPrimaryBarcodeTests.cs diff --git a/src/food-market.api/Controllers/AuthSignupController.cs b/src/food-market.api/Controllers/AuthSignupController.cs index 7de8640..fd1a702 100644 --- a/src/food-market.api/Controllers/AuthSignupController.cs +++ b/src/food-market.api/Controllers/AuthSignupController.cs @@ -31,7 +31,10 @@ public AuthSignupController(AppDbContext db, UserManager userMgr, IBackgro _db = db; _userMgr = userMgr; _jobs = jobs; } - public record SignupInput(string Email, string Password, string OrganizationName, string Phone, string? Plan); + public record SignupInput(string Email, string Password, string OrganizationName, string Phone, string? Plan, + int? Shops = null, int? Cashes = null, int? Warehouses = null, int? Employees = null); + + private static readonly string[] ValidPlans = { "start", "business", "network" }; public record SignupResult(Guid OrganizationId, string Email); [HttpPost("signup")] @@ -64,6 +67,9 @@ public async Task> Signup([FromBody] SignupInput inpu // 1. Organization + полный bootstrap tenant-сущностей. var kzt = await _db.Currencies.FirstOrDefaultAsync(c => c.Code == "KZT", ct); + // Тариф: нормализуем к одному из трёх; неизвестный/пустой → «start». + var plan = (input.Plan ?? "").Trim().ToLowerInvariant(); + if (!ValidPlans.Contains(plan)) plan = "start"; var org = new Organization { Name = input.OrganizationName.Trim(), @@ -71,6 +77,15 @@ public async Task> Signup([FromBody] SignupInput inpu DefaultCurrencyId = kzt?.Id, Phone = normalizedPhone, Email = input.Email.Trim(), + Plan = plan, + // Бесплатный период — 90 дней с момента регистрации. Только хранение + // и показ; блокировки по истечении нет. + FreeTrialEndsAt = DateTime.UtcNow.AddDays(90), + // Конфигурацию конструктора сохраняем только для «business». + PlanShops = plan == "business" ? input.Shops : null, + PlanCashes = plan == "business" ? input.Cashes : null, + PlanWarehouses = plan == "business" ? input.Warehouses : null, + PlanEmployees = plan == "business" ? input.Employees : null, }; _db.Organizations.Add(org); await _db.SaveChangesAsync(ct); diff --git a/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs b/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs index 32877d2..9cd00d2 100644 --- a/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs +++ b/src/food-market.api/Controllers/Organizations/OrganizationSettingsController.cs @@ -54,7 +54,14 @@ public record OrgSettingsDto( int NegStockNotifyIntervalMinutes, int? NegStockQuietFromHour, int? NegStockQuietToHour, - int NegStockEscalateHours); + int NegStockEscalateHours, + // Тариф и бесплатный период — read-only (выбор был при регистрации). + string Plan, + DateTime? FreeTrialEndsAt, + int? PlanShops, + int? PlanCashes, + int? PlanWarehouses, + int? PlanEmployees); // DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId). public record OrgSettingsInput( @@ -199,5 +206,11 @@ private async Task ReadVatRateAsync(string countryCode, CancellationTok o.NegStockNotifyIntervalMinutes, o.NegStockQuietFromHour, o.NegStockQuietToHour, - o.NegStockEscalateHours); + o.NegStockEscalateHours, + o.Plan, + o.FreeTrialEndsAt, + o.PlanShops, + o.PlanCashes, + o.PlanWarehouses, + o.PlanEmployees); } diff --git a/src/food-market.api/Controllers/SuperAdmin/SuperAdminOrganizationsController.cs b/src/food-market.api/Controllers/SuperAdmin/SuperAdminOrganizationsController.cs index cbc0309..8d9bc52 100644 --- a/src/food-market.api/Controllers/SuperAdmin/SuperAdminOrganizationsController.cs +++ b/src/food-market.api/Controllers/SuperAdmin/SuperAdminOrganizationsController.cs @@ -33,7 +33,8 @@ public record OrgRow( bool IsActive, bool IsArchived, DateTime? ArchivedAt, DateTime CreatedAt, int EmployeeCount, int ProductCount, DateTime? LastLoginAt, - bool IsAdvancedMode); + bool IsAdvancedMode, + string Plan, DateTime? FreeTrialEndsAt); public record OrgDetail( Guid Id, string Name, string CountryCode, string? Bin, string? Address, string? Phone, string? Email, @@ -42,7 +43,10 @@ public record OrgDetail( Guid? AccountOwnerUserId, string? AccountOwnerName, string? AccountOwnerEmail, DateTime CreatedAt, DateTime? UpdatedAt, int EmployeeCount, int ProductCount, int CounterpartyCount, int SupplyCountThisMonth, - bool IsAdvancedMode); + bool IsAdvancedMode, + // Тариф и бесплатный период (выбор при регистрации). + string Plan, DateTime? FreeTrialEndsAt, + int? PlanShops, int? PlanCashes, int? PlanWarehouses, int? PlanEmployees); public record OrgInput( string Name, string CountryCode, string? Bin, string? Address, string? Phone, string? Email, @@ -82,7 +86,8 @@ public record AdvancedModeRequest(bool Enabled); _db.Products.IgnoreQueryFilters().Count(p => p.OrganizationId == o.Id), _db.Users.Where(u => u.OrganizationId == o.Id && u.LastLoginAt != null) .Max(u => (DateTime?)u.LastLoginAt), - o.IsAdvancedMode)) + o.IsAdvancedMode, + o.Plan, o.FreeTrialEndsAt)) .ToListAsync(ct); return new PagedResult { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; } @@ -306,7 +311,9 @@ public async Task ChangeOwner(Guid id, [FromBody] ChangeOwnerRequ o.IsActive, o.IsArchived, o.ArchivedAt, o.AccountOwnerUserId, owner?.FullName, owner?.Email, o.CreatedAt, o.UpdatedAt, emp, prod, cp, supplies, - o.IsAdvancedMode); + o.IsAdvancedMode, + o.Plan, o.FreeTrialEndsAt, + o.PlanShops, o.PlanCashes, o.PlanWarehouses, o.PlanEmployees); } private async Task LogAsync(string actionType, Guid orgId, string description, string? reason, string changesJson, CancellationToken ct) diff --git a/src/food-market.api/Controllers/WhatsNewController.cs b/src/food-market.api/Controllers/WhatsNewController.cs index bac9484..78d9bf5 100644 --- a/src/food-market.api/Controllers/WhatsNewController.cs +++ b/src/food-market.api/Controllers/WhatsNewController.cs @@ -1,5 +1,8 @@ +using foodmarket.Application.Common.Tenancy; +using foodmarket.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; namespace foodmarket.Api.Controllers; @@ -21,17 +24,19 @@ public class WhatsNewController : ControllerBase { private readonly IWebHostEnvironment _env; private readonly ILogger _log; + private readonly AppDbContext _db; + private readonly ITenantContext _tenant; - public WhatsNewController(IWebHostEnvironment env, ILogger log) + public WhatsNewController(IWebHostEnvironment env, ILogger log, AppDbContext db, ITenantContext tenant) { - _env = env; _log = log; + _env = env; _log = log; _db = db; _tenant = tenant; } public record WhatsNewItem(string Date, string Title, string Body, string Type); public record WhatsNewResponse(string BuildVersion, IReadOnlyList Items); [HttpGet] - public ActionResult Get() + public async Task> Get(CancellationToken ct) { // BuildVersion — short SHA / тэг билд-времени. Берём из // VERSION файла (создаётся Docker-build'ом) или ассембли. @@ -54,6 +59,24 @@ public ActionResult Get() var content = System.IO.File.ReadAllText(changelogPath); var items = ParseChangelog(content).ToList(); + + // Не показываем новой организации улучшения/исправления, которые были + // ДО её регистрации — для неё они не «новые». Фильтруем по дате создания + // организации (решение владельца 12.10). SuperAdmin (без org) видит всё. + var orgId = _tenant.OrganizationId; + if (orgId is not null) + { + var createdAt = await _db.Organizations.IgnoreQueryFilters() + .Where(o => o.Id == orgId) + .Select(o => (DateTime?)o.CreatedAt) + .FirstOrDefaultAsync(ct); + if (createdAt is DateTime reg) + { + var regDate = reg.Date; + items = items.Where(i => DateTime.TryParse(i.Date, out var d) && d.Date >= regDate).ToList(); + } + } + return new WhatsNewResponse(buildVersion, items); } diff --git a/src/food-market.domain/Organizations/Organization.cs b/src/food-market.domain/Organizations/Organization.cs index 600cc50..4c96358 100644 --- a/src/food-market.domain/Organizations/Organization.cs +++ b/src/food-market.domain/Organizations/Organization.cs @@ -205,4 +205,24 @@ public class Organization : Entity /// показывается «Каталог из реестра загружается…». Повторно не копируется. public int RegistryCopyState { get; set; } public DateTime? RegistryCopiedAt { get; set; } + + // ─ Тариф и бесплатный период (выбор при регистрации) ─────────────────────── + /// Выбранный при регистрации тариф: «start», «business», «network». + /// Биллинга пока нет — значение только хранится и показывается владельцу и + /// SuperAdmin. Существующие организации (миграция) → «start». + public string Plan { get; set; } = "start"; + + /// До какой даты действует бесплатный период (регистрация + 90 дней). + /// null — у организаций, созданных до появления тарифов (миграция даты не + /// проставляет). Блокировки по истечении нет — только отображение. + public DateTime? FreeTrialEndsAt { get; set; } + + /// Конфигурация тарифа «Бизнес», выбранная в конструкторе при + /// регистрации (магазины/кассы/склады/сотрудники). null для «start»/«network» + /// и для организаций без выбранной конфигурации. Сейчас не ограничивает — + /// хранится и показывается. + public int? PlanShops { get; set; } + public int? PlanCashes { get; set; } + public int? PlanWarehouses { get; set; } + public int? PlanEmployees { get; set; } } diff --git a/src/food-market.infrastructure/Persistence/Migrations/20261012060000_Phase50_PlanAndTrial.cs b/src/food-market.infrastructure/Persistence/Migrations/20261012060000_Phase50_PlanAndTrial.cs new file mode 100644 index 0000000..323e9e6 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20261012060000_Phase50_PlanAndTrial.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Тариф, выбранный при регистрации («start»/«business»/«network»), + /// бесплатный период (регистрация + 90 дней) и конфигурация «Бизнес». + /// Существующие организации → тариф «start», дата бесплатного периода не + /// проставляется (NULL). Биллинга/блокировки нет — только хранение и показ. + /// Решение владельца 12.10. + [DbContext(typeof(AppDbContext))] + [Migration("20261012060000_Phase50_PlanAndTrial")] + public partial class Phase50_PlanAndTrial : Migration + { + protected override void Up(MigrationBuilder b) => b.Sql(@" + ALTER TABLE public.organizations + ADD COLUMN IF NOT EXISTS ""Plan"" text NOT NULL DEFAULT 'start', + ADD COLUMN IF NOT EXISTS ""FreeTrialEndsAt"" timestamp with time zone NULL, + ADD COLUMN IF NOT EXISTS ""PlanShops"" integer NULL, + ADD COLUMN IF NOT EXISTS ""PlanCashes"" integer NULL, + ADD COLUMN IF NOT EXISTS ""PlanWarehouses"" integer NULL, + ADD COLUMN IF NOT EXISTS ""PlanEmployees"" integer NULL;"); + + protected override void Down(MigrationBuilder b) => b.Sql(@" + ALTER TABLE public.organizations + DROP COLUMN IF EXISTS ""Plan"", + DROP COLUMN IF EXISTS ""FreeTrialEndsAt"", + DROP COLUMN IF EXISTS ""PlanShops"", + DROP COLUMN IF EXISTS ""PlanCashes"", + DROP COLUMN IF EXISTS ""PlanWarehouses"", + DROP COLUMN IF EXISTS ""PlanEmployees"";"); + } +} diff --git a/src/food-market.infrastructure/Persistence/Migrations/20261012070000_Phase51_BackfillPrimaryBarcode.cs b/src/food-market.infrastructure/Persistence/Migrations/20261012070000_Phase51_BackfillPrimaryBarcode.cs new file mode 100644 index 0000000..8fd61df --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20261012070000_Phase51_BackfillPrimaryBarcode.cs @@ -0,0 +1,42 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Бэкфилл «Основного» штрихкода у товаров, у которых штрихкоды есть, + /// но ни один не помечен IsPrimary — следствие бага копии «Реестра товаров» + /// (товар с единственным штрихкодом оставался без «Основного», fix #7). + /// Выбираем по одному штрихкоду на товар — лексикографически первый по Code + /// (так же, как теперь делает копия). Идемпотентно: товары, у которых основной + /// уже есть, не трогаются. + [DbContext(typeof(AppDbContext))] + [Migration("20261012070000_Phase51_BackfillPrimaryBarcode")] + public partial class Phase51_BackfillPrimaryBarcode : Migration + { + protected override void Up(MigrationBuilder b) => b.Sql(@" + WITH to_fix AS ( + SELECT DISTINCT b.""ProductId"" + FROM public.product_barcodes b + WHERE NOT EXISTS ( + SELECT 1 FROM public.product_barcodes p + WHERE p.""ProductId"" = b.""ProductId"" AND p.""IsPrimary"" = true + ) + ), + pick AS ( + SELECT DISTINCT ON (b.""ProductId"") b.""Id"" + FROM public.product_barcodes b + JOIN to_fix f ON f.""ProductId"" = b.""ProductId"" + ORDER BY b.""ProductId"", b.""Code"" + ) + UPDATE public.product_barcodes + SET ""IsPrimary"" = true, ""UpdatedAt"" = now() + WHERE ""Id"" IN (SELECT ""Id"" FROM pick);"); + + // Откат бессмысленен и небезопасен (мы не знаем, какой штрихкод был + // «Основным» до бэкфилла) — ничего не делаем. + protected override void Down(MigrationBuilder b) { } + } +} diff --git a/src/food-market.infrastructure/Registry/ProductRegistryService.cs b/src/food-market.infrastructure/Registry/ProductRegistryService.cs index 6a08e01..8be918c 100644 --- a/src/food-market.infrastructure/Registry/ProductRegistryService.cs +++ b/src/food-market.infrastructure/Registry/ProductRegistryService.cs @@ -323,8 +323,21 @@ public async Task CopyRegistryToOrgAsync(Guid orgId, CancellationToken ct) PurchasePrice = card.PurchasePrice, PurchaseCurrencyId = kztId, }; - foreach (var b in card.Barcodes) - product.Barcodes.Add(new ProductBarcode { OrganizationId = orgId, Code = b.Code, Type = b.Type }); + // Первый штрихкод помечаем «Основным» (IsPrimary). Для товара + // с единственным штрихкодом это он и есть — иначе «Основной» + // оставался пустым (баг копии реестра, fix #7). Порядок + // детерминируем по Code, чтобы при повторной копии основной + // был тем же. + var primarySet = false; + foreach (var b in card.Barcodes.OrderBy(x => x.Code, StringComparer.Ordinal)) + { + product.Barcodes.Add(new ProductBarcode + { + OrganizationId = orgId, Code = b.Code, Type = b.Type, + IsPrimary = !primarySet, + }); + primarySet = true; + } if (card.RetailPrice is { } rp && retailTypeId != Guid.Empty) product.Prices.Add(new ProductPrice { OrganizationId = orgId, PriceTypeId = retailTypeId, Amount = rp, CurrencyId = kztId }); _db.Products.Add(product); diff --git a/src/food-market.public/src/components/PhoneInput.tsx b/src/food-market.public/src/components/PhoneInput.tsx index f67a4b4..eedadc0 100644 --- a/src/food-market.public/src/components/PhoneInput.tsx +++ b/src/food-market.public/src/components/PhoneInput.tsx @@ -1,41 +1,50 @@ import { useMemo, useRef, useLayoutEffect, type InputHTMLAttributes, type FormEvent } from 'react' -const PREFIX = '+7 7' -const PREFIX_LEN = 4 +// Код страны (+7) живёт в префиксе; оператор НЕ подставляем заранее. +const PREFIX = '+7 ' +const PREFIX_LEN = PREFIX.length // 3 -function rawToUserDigits(raw: string): string { +// Национальный номер KZ — 10 цифр, начинается с 7 (например 701 000 00 00). +// Из любой формы вытаскиваем до 10 цифр нац. номера: снимаем ведущую «8» +// (межгород) и, если получилось 11 цифр с ведущей «7» — это код страны, снимаем. +// Прим.: при посимвольном вводе в handleChange мы передаём сюда ТОЛЬКО часть +// после префикса, чтобы «7» из «+7 » не попала в номер. +function toNational(raw: string): string { let d = raw.replace(/\D/g, '') if (d[0] === '8') d = d.slice(1) - if (d[0] === '7') d = d.slice(1) - if (d[0] === '7') d = d.slice(1) - return d.slice(0, 9) + if (d.length === 11 && d[0] === '7') d = d.slice(1) + return d.slice(0, 10) } -function userDigitsToDisplay(d: string): string { +// Восстановление нац. номера из каноничного value. Мы ВСЕГДА эмитим value как +// «+7»+national, поэтому снимаем ровно литеральный код страны «+7» и не +// применяем 8/11-эвристики toNational — иначе «7» кода страны при неполном +// номере сливается с номером и ввод «скачет» (+7 77, +7 777 0, …). +function valueToNational(value: string): string { + if (value.startsWith('+7')) return value.slice(2).replace(/\D/g, '').slice(0, 10) + return toNational(value) +} + +function nationalToDisplay(d: string): string { if (d.length === 0) return PREFIX - if (d.length <= 2) return `${PREFIX}${d}` - if (d.length <= 5) return `${PREFIX}${d.slice(0, 2)} ${d.slice(2)}` - if (d.length <= 7) return `${PREFIX}${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5)}` - return `${PREFIX}${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}` + let s = PREFIX + d.slice(0, 3) + if (d.length > 3) s += ' ' + d.slice(3, 6) + if (d.length > 6) s += ' ' + d.slice(6, 8) + if (d.length > 8) s += ' ' + d.slice(8, 10) + return s } -function digitsBeforeCursor(value: string, cursor: number): number { - let count = 0 - for (let i = 0; i < cursor && i < value.length; i++) { - if (/\d/.test(value[i])) count++ - } - return count +// Курсор считаем в цифрах нац. номера (цифры ПОСЛЕ префикса), чтобы «7» из +// «+7 » не сбивала позицию. +function natDigitsBeforeCursor(value: string, cursor: number): number { + let c = 0 + for (let i = PREFIX_LEN; i < cursor && i < value.length; i++) if (/\d/.test(value[i])) c++ + return c } - -function cursorAfterNthDigit(value: string, n: number): number { - if (n <= 0) return 0 - let count = 0 - for (let i = 0; i < value.length; i++) { - if (/\d/.test(value[i])) { - count++ - if (count === n) return i + 1 - } - } +function cursorAfterNthNatDigit(value: string, n: number): number { + if (n <= 0) return PREFIX_LEN + let c = 0 + for (let i = PREFIX_LEN; i < value.length; i++) if (/\d/.test(value[i])) { c++; if (c === n) return i + 1 } return value.length } @@ -44,35 +53,38 @@ interface PhoneInputProps extends Omit, 'v onChange: (value: string) => void } -/** PhoneInput для public — см. food-market.web/components/PhoneInput.tsx. */ +/** PhoneInput для public — зеркалит food-market.web/components/PhoneInput.tsx. */ export function PhoneInput({ value, onChange, className, disabled, ...rest }: PhoneInputProps) { const ref = useRef(null) - const userDigits = useMemo(() => rawToUserDigits(value), [value]) - const display = userDigitsToDisplay(userDigits) - const pendingDigitCountRef = useRef(null) + const national = useMemo(() => valueToNational(value), [value]) + const display = nationalToDisplay(national) + const pendingRef = useRef(null) useLayoutEffect(() => { - const n = pendingDigitCountRef.current - pendingDigitCountRef.current = null + const n = pendingRef.current + pendingRef.current = null if (n === null) return const el = ref.current if (!el) return - const pos = Math.max(PREFIX_LEN, cursorAfterNthDigit(display, n)) + const pos = Math.max(PREFIX_LEN, cursorAfterNthNatDigit(display, n)) el.setSelectionRange(pos, pos) }, [display]) const handleBeforeInput = (e: FormEvent) => { const native = e.nativeEvent as InputEvent - if (native.data && /\D/.test(native.data)) { - e.preventDefault() - } + if (native.data && /\D/.test(native.data)) e.preventDefault() } const handleChange = (e: React.ChangeEvent) => { - const cursorPos = e.target.selectionStart ?? e.target.value.length - pendingDigitCountRef.current = digitsBeforeCursor(e.target.value, cursorPos) - const newDigits = rawToUserDigits(e.target.value) - onChange(newDigits.length > 0 ? `+77${newDigits}` : '') + const raw = e.target.value + const cursorPos = e.target.selectionStart ?? raw.length + pendingRef.current = natDigitsBeforeCursor(raw, cursorPos) + // При посимвольном вводе значение начинается с «+7 » — берём часть после + // префикса, чтобы его «7» не попала в номер. При полной вставке (replace) + // значения без префикса нормализуем целиком (снимем код страны/«8»). + const src = raw.startsWith(PREFIX) ? raw.slice(PREFIX_LEN) : raw + const nat = toNational(src) + onChange(nat.length > 0 ? '+7' + nat : '') } const guardCursor = (el: HTMLInputElement) => { @@ -82,12 +94,10 @@ export function PhoneInput({ value, onChange, className, disabled, ...rest }: Ph el.setSelectionRange(Math.max(PREFIX_LEN, start), Math.max(PREFIX_LEN, end)) } } - const handleSelect = (e: React.SyntheticEvent) => guardCursor(e.currentTarget) const handleFocus = () => { const el = ref.current - if (!el) return - requestAnimationFrame(() => guardCursor(el)) + if (el) requestAnimationFrame(() => guardCursor(el)) } return ( diff --git a/src/food-market.public/src/components/SignupForm.tsx b/src/food-market.public/src/components/SignupForm.tsx index 6ce6765..55e10ac 100644 --- a/src/food-market.public/src/components/SignupForm.tsx +++ b/src/food-market.public/src/components/SignupForm.tsx @@ -90,17 +90,33 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) { const e: FieldErrors = {} const em = validateEmail(email); if (em) e.email = em const pw = validatePassword(password); if (pw) e.password = pw - if (!orgName.trim()) e.orgName = 'Название магазина обязательно для заполнения' + if (!orgName.trim()) e.orgName = 'Укажите название магазина' const ph = validatePhone(phone); if (ph) e.phone = ph if (!agree) e.agree = 'Подтвердите согласие с офертой и политикой ПДн' return e } + // Порядок полей сверху вниз — для перехода к первой ошибке. id совпадает с + // id инпутов ниже. + const FIELD_ORDER: Array = ['email', 'password', 'orgName', 'phone', 'agree'] + const focusFirstError = (errs: FieldErrors) => { + const firstKey = FIELD_ORDER.find((k) => errs[k]) + if (!firstKey) return + // На 1280×665 верхние поля часто выше вьюпорта — сначала проскроллим к полю, + // затем сфокусируем (focus сам по себе прокручивает неплавно/неточно). + const el = document.getElementById(`signup-${firstKey}`) + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }) + // Фокус после старта прокрутки — иначе браузер перебьёт плавный скролл. + setTimeout(() => el.focus({ preventScroll: true }), 150) + } + } + const submit = async (ev: React.FormEvent) => { ev.preventDefault() const errs = validate() setFieldErrors(errs) - if (Object.keys(errs).length) return + if (Object.keys(errs).length) { focusFirstError(errs); return } setError(null); setBusy(true) try { const res = await fetch(`${API_URL}/api/auth/signup`, { @@ -151,6 +167,7 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) { { @@ -171,6 +188,7 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) { { @@ -188,15 +206,16 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) { { const v = e.target.value setOrgName(v) - setFieldErrors(p => p.orgName ? {...p, orgName: !v.trim() ? 'Название магазина обязательно для заполнения' : undefined} : p) + setFieldErrors(p => p.orgName ? {...p, orgName: !v.trim() ? 'Укажите название магазина' : undefined} : p) }} onBlur={(e) => { - setFieldErrors(p => ({...p, orgName: !e.target.value.trim() ? 'Название магазина обязательно для заполнения' : undefined})) + setFieldErrors(p => ({...p, orgName: !e.target.value.trim() ? 'Укажите название магазина' : undefined})) }} placeholder="Наименование организации" className={inputCls(!!fieldErrors.orgName)} @@ -205,6 +224,7 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) { { setPhone(v) @@ -266,7 +286,7 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) {
{fieldErrors.agree &&

{fieldErrors.agree}

} diff --git a/src/food-market.public/src/lib/validation.ts b/src/food-market.public/src/lib/validation.ts index f6b7bf6..ee80446 100644 --- a/src/food-market.public/src/lib/validation.ts +++ b/src/food-market.public/src/lib/validation.ts @@ -5,7 +5,7 @@ const STRICT_EMAIL = /^[^@\s]+@[^@\s]+\.[A-Za-z]{2,}$/ export const emailMsg = { - required: 'Email обязателен', + required: 'Укажите email', format: 'Некорректный формат email. Пример: user@example.kz', noTld: 'Email должен содержать доменную зону (например, .kz, .com)', } @@ -19,7 +19,7 @@ export function validateEmail(value: string): string | null { } export function validatePassword(value: string): string | null { - if (!value) return 'Это поле обязательно для заполнения' + if (!value) return 'Придумайте пароль' if (value.length < 8) return 'Пароль должен быть не менее 8 символов' if (!/[A-Z]/.test(value)) return 'Пароль должен содержать хотя бы одну заглавную букву' if (!/[0-9]/.test(value)) return 'Пароль должен содержать хотя бы одну цифру' @@ -27,7 +27,7 @@ export function validatePassword(value: string): string | null { } export function validatePhone(value: string): string | null { - if (!value || value.trim() === '') return 'Телефон обязателен для заполнения' + if (!value || value.trim() === '') return 'Укажите телефон' // Казахстан: +7 7XX XXX XX XX (мобильные коды KZ начинаются с 77, // 79 — это РФ, исключаем). Принимаем любое форматирование (пробелы, // скобки, дефисы), нормализуем к 11 цифрам, ведущая «8» = «7». diff --git a/src/food-market.web/src/components/PhoneInput.tsx b/src/food-market.web/src/components/PhoneInput.tsx index fe50631..e2a2e59 100644 --- a/src/food-market.web/src/components/PhoneInput.tsx +++ b/src/food-market.web/src/components/PhoneInput.tsx @@ -3,46 +3,59 @@ import { cn } from '@/lib/utils' const inputClass = 'w-full h-10 rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 px-3 text-sm leading-none focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)] disabled:opacity-60 disabled:bg-slate-50 dark:disabled:bg-slate-800/60 tabular-nums' -const PREFIX = '+7 7' // Залочено: «+», «7», пробел, «7» (мобильный код KZ). -const PREFIX_LEN = PREFIX.length // 4 +// Код страны (+7) живёт в префиксе; оператор НЕ подставляем заранее, иначе +// при вводе полного номера «7XX…» его первая цифра налезает на префиксную «7». +const PREFIX = '+7 ' +const PREFIX_LEN = PREFIX.length // 3 -/** Извлекает 9 пользовательских цифр из любой формы значения (canonical - * «+77XXXXXXXXX», display «+7 7XX XXX XX XX», raw paste «8 707…», и т.д.). - * Снимает по одной ведущей «8», «7», «7» — это код страны и мобильный - * префикс KZ, оба зашиты в наш PREFIX. */ -function rawToUserDigits(raw: string): string { +// Национальный номер KZ — 10 цифр, начинается с 7 (например 701 000 00 00). +// Из любой формы вытаскиваем до 10 цифр нац. номера: снимаем ведущую «8» +// (межгород) и, если получилось 11 цифр с ведущей «7» — это код страны, снимаем. +// Прим.: при посимвольном вводе в handleChange сюда передаётся ТОЛЬКО часть +// после префикса, чтобы «7» из «+7 » не попала в номер. +function toNational(raw: string): string { let d = raw.replace(/\D/g, '') if (d[0] === '8') d = d.slice(1) - if (d[0] === '7') d = d.slice(1) - if (d[0] === '7') d = d.slice(1) - return d.slice(0, 9) + if (d.length === 11 && d[0] === '7') d = d.slice(1) + return d.slice(0, 10) } -/** Display из 9 пользовательских цифр. Формат: «+7 7XX XXX XX XX» — после - * залоченного «+7 7» идут 2+3+2+2 = 9 цифр через пробелы. */ -function userDigitsToDisplay(d: string): string { +// Восстановление нац. номера из каноничного value. Мы ВСЕГДА эмитим value как +// «+7»+national, поэтому снимаем ровно литеральный код страны «+7» и не +// применяем 8/11-эвристики toNational — иначе «7» кода страны при неполном +// номере сливается с номером и ввод «скачет» (+7 77, +7 777 0, …). +function valueToNational(value: string): string { + if (value.startsWith('+7')) return value.slice(2).replace(/\D/g, '').slice(0, 10) + return toNational(value) +} + +// Display из нац. номера. Формат: «+7 7XX XXX XX XX» — после «+7 » идут +// 3+3+2+2 = 10 цифр через пробелы. +function nationalToDisplay(d: string): string { if (d.length === 0) return PREFIX - if (d.length <= 2) return `${PREFIX}${d}` - if (d.length <= 5) return `${PREFIX}${d.slice(0, 2)} ${d.slice(2)}` - if (d.length <= 7) return `${PREFIX}${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5)}` - return `${PREFIX}${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}` + let s = PREFIX + d.slice(0, 3) + if (d.length > 3) s += ' ' + d.slice(3, 6) + if (d.length > 6) s += ' ' + d.slice(6, 8) + if (d.length > 8) s += ' ' + d.slice(8, 10) + return s } -/** Сколько цифровых символов стоит ДО позиции cursor в строке value. */ -function digitsBeforeCursor(value: string, cursor: number): number { +// Курсор считаем в цифрах нац. номера (цифры ПОСЛЕ префикса), чтобы «7» из +// «+7 » не сбивала позицию. +function natDigitsBeforeCursor(value: string, cursor: number): number { let count = 0 - for (let i = 0; i < cursor && i < value.length; i++) { + for (let i = PREFIX_LEN; i < cursor && i < value.length; i++) { if (/\d/.test(value[i])) count++ } return count } -/** Позиция в value сразу ПОСЛЕ n-й цифры (1-indexed). Если n больше чем - * есть цифр — возвращает конец строки. */ -function cursorAfterNthDigit(value: string, n: number): number { - if (n <= 0) return 0 +// Позиция в value сразу ПОСЛЕ n-й цифры нац. номера (1-indexed). Если n<=0 — +// начало digit-зоны (сразу за префиксом); если больше чем есть — конец строки. +function cursorAfterNthNatDigit(value: string, n: number): number { + if (n <= 0) return PREFIX_LEN let count = 0 - for (let i = 0; i < value.length; i++) { + for (let i = PREFIX_LEN; i < value.length; i++) { if (/\d/.test(value[i])) { count++ if (count === n) return i + 1 @@ -57,19 +70,19 @@ interface PhoneInputProps extends Omit, 'v onChange: (value: string) => void } -/** Поле ввода телефона Казахстана. Префикс «+7 7» (4 символа) залочен — - * курсор не пускается в позиции 0..3, выделение клампится на digit-зону. - * В остальном работает как обычный input: native selection, backspace, - * click, Cmd+A, paste, drag — всё нативное. Единственное ограничение — - * не-цифры блокируются через onBeforeInput до того как попасть в value. */ +/** Поле ввода телефона Казахстана. Префикс «+7 » (3 символа) залочен — курсор + * не пускается в позиции 0..2, выделение клампится на digit-зону. В остальном + * работает как обычный input: native selection, backspace, click, Cmd+A, + * paste, drag. Не-цифры блокируются через onBeforeInput. Нормализация снимает + * ведущую «8» и код страны «7», поэтому «8701…», «+7701…», «7701…» и «701…» + * дают один и тот же номер «+7 701 …». */ export function PhoneInput({ value, onChange, className, disabled, ...rest }: PhoneInputProps) { const ref = useRef(null) - const userDigits = useMemo(() => rawToUserDigits(value), [value]) - const display = userDigitsToDisplay(userDigits) - // Запоминаем сколько цифр стояло до курсора в момент редактирования — - // после React-нормализации (которая может сдвинуть пробелы формата) - // ставим курсор обратно после той же по счёту цифры. Без этого React - // сбрасывал бы курсор в конец на каждом изменении value. + const national = useMemo(() => valueToNational(value), [value]) + const display = nationalToDisplay(national) + // Запоминаем сколько цифр нац. номера стояло до курсора в момент + // редактирования — после React-нормализации возвращаем курсор после той же + // по счёту цифры, иначе React сбрасывал бы его в конец на каждом изменении. const pendingDigitCountRef = useRef(null) useLayoutEffect(() => { @@ -78,7 +91,7 @@ export function PhoneInput({ value, onChange, className, disabled, ...rest }: Ph if (n === null) return const el = ref.current if (!el) return - const pos = Math.max(PREFIX_LEN, cursorAfterNthDigit(display, n)) + const pos = Math.max(PREFIX_LEN, cursorAfterNthNatDigit(display, n)) el.setSelectionRange(pos, pos) }, [display]) @@ -95,10 +108,15 @@ export function PhoneInput({ value, onChange, className, disabled, ...rest }: Ph // нормализуем значение. Запоминаем позицию курсора в digit-индексах, // чтобы useLayoutEffect восстановил её после ре-рендера. const handleChange = (e: React.ChangeEvent) => { - const cursorPos = e.target.selectionStart ?? e.target.value.length - pendingDigitCountRef.current = digitsBeforeCursor(e.target.value, cursorPos) - const newDigits = rawToUserDigits(e.target.value) - onChange(newDigits.length > 0 ? `+77${newDigits}` : '') + const raw = e.target.value + const cursorPos = e.target.selectionStart ?? raw.length + pendingDigitCountRef.current = natDigitsBeforeCursor(raw, cursorPos) + // При посимвольном вводе значение начинается с «+7 » — берём часть после + // префикса, чтобы его «7» не попала в номер. При полной вставке значения + // без префикса нормализуем целиком (снимем код страны/«8»). + const src = raw.startsWith(PREFIX) ? raw.slice(PREFIX_LEN) : raw + const nat = toNational(src) + onChange(nat.length > 0 ? '+7' + nat : '') } // Не пускаем курсор/selection в префикс. onSelect ловит любые попытки diff --git a/src/food-market.web/src/components/WhatsNewBanner.tsx b/src/food-market.web/src/components/WhatsNewBanner.tsx index 6ca1f53..3d0d528 100644 --- a/src/food-market.web/src/components/WhatsNewBanner.tsx +++ b/src/food-market.web/src/components/WhatsNewBanner.tsx @@ -21,6 +21,18 @@ interface WhatsNewResponse { const STORAGE_KEY = 'fm.lastSeenBuildVersion' +/** Русская плюрализация: forms = [одна, две-четыре, пять-и-более]. + * Напр. pluralRu(21, ['улучшение','улучшения','улучшений']) → «улучшение», + * pluralRu(34, [...]) → «исправления», pluralRu(50, [...]) → «улучшений». */ +function pluralRu(n: number, forms: [string, string, string]): string { + const mod100 = n % 100 + const mod10 = n % 10 + if (mod100 >= 11 && mod100 <= 14) return forms[2] + if (mod10 === 1) return forms[0] + if (mod10 >= 2 && mod10 <= 4) return forms[1] + return forms[2] +} + export function WhatsNewBanner() { const [dismissed, setDismissed] = useState(false) const { data } = useQuery({ @@ -58,8 +70,8 @@ export function WhatsNewBanner() {