feat: add role switch login and clean production copy
This commit is contained in:
parent
49e60d48c1
commit
b147d632e8
|
|
@ -0,0 +1,83 @@
|
|||
# Role Switch And Production Copy Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Добавить служебный вход по специальному email с выбором роли, убрать demo/PWA-подачу из интерфейса и подготовить клиентскую showcase-ссылку для показа.
|
||||
|
||||
**Architecture:** Обычный OTP flow остается без изменений. Новая служебная ветка определяется на уровне login/auth helper-логики через специальный email `roles@local` и использует локальный fallback-профиль выбранной роли без OTP. UI-copy в логине, overview и клиентской странице упрощается до рабочего нейтрального тона, а PWA explanatory panel убирается из overview.
|
||||
|
||||
**Tech Stack:** React 18, Vite, existing UI kit, Vitest, local fallback auth, Supabase Auth.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Role Switch Entry
|
||||
|
||||
### Task 1: Зафиксировать тестами служебный вход по специальному email
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/auth/OtpLoginForm.test.jsx`
|
||||
- Modify: `src/context/AuthContext.test.js`
|
||||
|
||||
- [ ] **Step 1: Add failing tests for service email mode**
|
||||
- [ ] **Step 2: Run targeted auth tests and confirm failure**
|
||||
- [ ] **Step 3: Verify copy no longer references demo mode in production path**
|
||||
|
||||
### Task 2: Реализовать special-email вход без OTP
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/context/AuthContext.jsx`
|
||||
- Modify: `src/pages/LoginPage.jsx`
|
||||
- Modify: `src/components/auth/OtpLoginForm.jsx`
|
||||
|
||||
- [ ] **Step 1: Add helper constants for `roles@local` and service mode detection**
|
||||
- [ ] **Step 2: Route special-email flow to local role-based sign-in**
|
||||
- [ ] **Step 3: Update login form labels/buttons for service mode**
|
||||
- [ ] **Step 4: Re-run targeted auth tests and confirm pass**
|
||||
|
||||
## Chunk 2: Production Copy Cleanup
|
||||
|
||||
### Task 3: Убрать demo/PWA explanatory copy
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pages/DashboardPage.jsx`
|
||||
- Modify: `src/pages/LoginPage.jsx`
|
||||
- Modify: `src/components/auth/OtpLoginForm.jsx`
|
||||
- Modify: `README.md` if needed
|
||||
|
||||
- [ ] **Step 1: Remove PWA explanatory panel from overview**
|
||||
- [ ] **Step 2: Replace demo-oriented labels/messages with neutral product copy**
|
||||
- [ ] **Step 3: Re-run affected UI tests**
|
||||
|
||||
## Chunk 3: Client Showcase Link
|
||||
|
||||
### Task 4: Добавить локальную клиентскую showcase-ссылку
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/services/deliveryInvitationApi.js`
|
||||
- Modify: `src/pages/ClientDeliveryPage.jsx`
|
||||
- Modify: `src/components/client/DeliveryChoiceFlow.test.jsx`
|
||||
- Modify or Create: related test file if needed
|
||||
|
||||
- [ ] **Step 1: Add failing test for showcase token payload**
|
||||
- [ ] **Step 2: Implement local invitation for showcase token**
|
||||
- [ ] **Step 3: Verify tomorrow/after-tomorrow and half-day slots are shown**
|
||||
- [ ] **Step 4: Re-run client tests and confirm pass**
|
||||
|
||||
## Chunk 4: Final Verification
|
||||
|
||||
### Task 5: Проверка итогового сценария
|
||||
|
||||
**Files:**
|
||||
- Reference: `src/pages/LoginPage.jsx`
|
||||
- Reference: `src/pages/DashboardPage.jsx`
|
||||
- Reference: `src/pages/ClientDeliveryPage.jsx`
|
||||
|
||||
- [ ] **Step 1: Run targeted test suite**
|
||||
|
||||
Run: `npm test -- src/context/AuthContext.test.js src/components/auth/OtpLoginForm.test.jsx src/components/client/DeliveryChoiceFlow.test.jsx src/components/client/DeliverySlotsPicker.test.jsx`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 2: Run broader UI regression checks**
|
||||
|
||||
Run: `npm test -- src/components/orders/OrdersTable.test.jsx src/components/orders/OrderDetailPanel.test.jsx src/components/orders/OrderFilters.test.jsx src/services/orderService.test.js`
|
||||
Expected: PASS
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
# Role Switch And Production Copy Design
|
||||
|
||||
**Goal:** Убрать из пользовательского интерфейса явную demo/PWA-подачу, добавить служебный сценарий быстрого входа по специальному email с выбором роли и подготовить аккуратную клиентскую ссылку для показа выбора доставки.
|
||||
|
||||
## Scope
|
||||
|
||||
- Обычный рабочий OTP-вход остается основным сценарием.
|
||||
- При вводе специального email `roles@local` логин переключается в локальный служебный режим:
|
||||
- пользователь выбирает роль `manager`, `logistician` или `driver`;
|
||||
- OTP не требуется;
|
||||
- приложение авторизует пользователя через локальный fallback-профиль для выбранной роли.
|
||||
- Из интерфейса убираются тексты и панели, которые прямо продают demo/PWA-режим.
|
||||
- Для клиентского показа появляется локальный showcase-token с понятными слотами:
|
||||
- завтра / послезавтра;
|
||||
- до обеда / после обеда.
|
||||
|
||||
## UX Decisions
|
||||
|
||||
- Служебный email не скрывается глубоко в коде: он поддерживается формой логина напрямую, чтобы им было удобно пользоваться на встрече.
|
||||
- Тексты в логине и дашборде должны звучать как рабочий продукт, без слов `demo`, `демонстрация`, `PWA-версия`, `offline demo`.
|
||||
- Быстрый вход по роли оформляется как сервисный режим доступа, а не как обучающий блок.
|
||||
- Клиентская showcase-ссылка должна выглядеть как реальная страница подтверждения доставки, а не как технический мок.
|
||||
|
||||
## Data Approach
|
||||
|
||||
- Для служебного role-switch входа используются уже существующие локальные профили ролей.
|
||||
- Для showcase-клиента используется локально собранный invitation payload, без вызова Supabase.
|
||||
- Боевой Supabase flow не меняется и продолжает обслуживать обычные invitation token.
|
||||
|
||||
## Validation
|
||||
|
||||
- Тесты на логин покрывают:
|
||||
- обычный OTP-copy без demo-текста;
|
||||
- служебный режим по `roles@local`;
|
||||
- выбор роли без OTP.
|
||||
- Тесты client flow покрывают showcase invitation.
|
||||
- После правок прогоняются таргетированные тесты UI и auth.
|
||||
|
|
@ -15,10 +15,16 @@ export const OtpLoginForm = ({
|
|||
isOtpSent,
|
||||
isLoading,
|
||||
isDemoMode,
|
||||
isRoleSwitchMode,
|
||||
onRequestOtp,
|
||||
onVerifyOtp,
|
||||
error,
|
||||
}) => {
|
||||
const isServiceAccessMode =
|
||||
Boolean(isRoleSwitchMode) || String(email || "").trim().toLowerCase() === "roles@local";
|
||||
const showsLocalRolePicker = !isOtpSent && (isServiceAccessMode || isDemoMode);
|
||||
const submitLabel = isServiceAccessMode ? "Войти без кода" : "Отправить код";
|
||||
|
||||
return (
|
||||
<Panel className="w-full max-w-md p-5 sm:p-8">
|
||||
<div className="mb-6 space-y-2 sm:mb-8">
|
||||
|
|
@ -29,8 +35,8 @@ export const OtpLoginForm = ({
|
|||
Вход по email и коду
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--color-text-muted)]">
|
||||
Введите email, и код придет на почту. В рабочем режиме доступ определяется учетной
|
||||
записью в системе, а не выбором роли.
|
||||
Введите email, и код придет на почту. Для быстрого доступа к рабочим кабинетам можно
|
||||
использовать служебный адрес и выбрать роль вручную.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -49,14 +55,14 @@ export const OtpLoginForm = ({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{isDemoMode && !isOtpSent ? (
|
||||
{showsLocalRolePicker ? (
|
||||
<div className="space-y-2 rounded-3xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4">
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-[var(--color-text-muted)]">
|
||||
Демо-режим активен
|
||||
{isServiceAccessMode ? "Служебный вход" : "Локальный вход"}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-[var(--color-text-muted)]" htmlFor="roleHint">
|
||||
Роль для демо-режима
|
||||
Роль для быстрого входа
|
||||
</label>
|
||||
<Select
|
||||
id="roleHint"
|
||||
|
|
@ -71,7 +77,9 @@ export const OtpLoginForm = ({
|
|||
</Select>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
Для демонстрации используется единый адрес входа и код 000000.
|
||||
{isServiceAccessMode
|
||||
? "Выберите кабинет и войдите сразу без подтверждения кода."
|
||||
: "Локальный вход использует единый адрес и код 000000."}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -101,7 +109,7 @@ export const OtpLoginForm = ({
|
|||
|
||||
{!isOtpSent ? (
|
||||
<Button className="w-full" onClick={onRequestOtp} disabled={isLoading || !email}>
|
||||
{isLoading ? "Отправка..." : "Отправить код"}
|
||||
{isLoading ? "Проверяем..." : submitLabel}
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="w-full" onClick={onVerifyOtp} disabled={isLoading || !otp}>
|
||||
|
|
@ -113,13 +121,15 @@ export const OtpLoginForm = ({
|
|||
<div
|
||||
className={[
|
||||
"mt-6 rounded-3xl p-4 text-sm",
|
||||
isDemoMode
|
||||
showsLocalRolePicker
|
||||
? "border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] text-[var(--color-text)]"
|
||||
: "bg-[var(--color-accent-soft)] text-[var(--color-text)]",
|
||||
].join(" ")}
|
||||
>
|
||||
{isDemoMode
|
||||
? "Демо-режим активен: выберите роль для подстановки данных и используйте код 000000."
|
||||
{isServiceAccessMode
|
||||
? "Служебный вход открывает кабинеты менеджера, логиста и водителя без ожидания кода."
|
||||
: isDemoMode
|
||||
? "Локальный режим позволяет открыть интерфейс и проверить структуру кабинетов."
|
||||
: "Рабочий режим: код отправляется на email, а доступ определяется учетной записью в системе."}
|
||||
</div>
|
||||
</Panel>
|
||||
|
|
|
|||
|
|
@ -24,16 +24,19 @@ describe("OtpLoginForm", () => {
|
|||
|
||||
expect(markup).toContain("введите email");
|
||||
expect(markup).toContain("доступ определяется учетной записью");
|
||||
expect(markup).not.toContain("роль для демо-режима");
|
||||
expect(markup).not.toContain("роль для быстрого входа");
|
||||
expect(markup).not.toContain("демо-режим");
|
||||
});
|
||||
|
||||
it("keeps the demo hint visible only in demo mode", () => {
|
||||
it("shows role selection for the special access email without demo wording", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<OtpLoginForm {...baseProps} isDemoMode={true} />,
|
||||
<OtpLoginForm {...baseProps} email="roles@local" />,
|
||||
).toLowerCase();
|
||||
|
||||
expect(markup).toContain("демо-режим активен");
|
||||
expect(markup).toContain("роль для демо-режима");
|
||||
expect(markup).toContain("служебный вход");
|
||||
expect(markup).toContain("роль для быстрого входа");
|
||||
expect(markup).toContain("войти без кода");
|
||||
expect(markup).not.toContain("демо-режим");
|
||||
});
|
||||
|
||||
it("tells operators to check inbox and spam after OTP is sent", () => {
|
||||
|
|
|
|||
|
|
@ -1,88 +0,0 @@
|
|||
import React from "react";
|
||||
import { Badge } from "../UI/Badge";
|
||||
import { Button } from "../UI/Button";
|
||||
import { Panel } from "../UI/Panel";
|
||||
|
||||
const statusConfig = {
|
||||
online: { label: "Онлайн", tone: "accent" },
|
||||
offline: { label: "Офлайн", tone: "warning" },
|
||||
installed: { label: "Установлено", tone: "accent" },
|
||||
browser: { label: "В браузере", tone: "neutral" },
|
||||
ready: { label: "Офлайн готов", tone: "accent" },
|
||||
pending: { label: "Кешируется", tone: "neutral" },
|
||||
};
|
||||
|
||||
export const PwaDemoPanel = ({
|
||||
isOnline,
|
||||
isInstallAvailable,
|
||||
isInstalled,
|
||||
isOfflineReady,
|
||||
onInstall,
|
||||
}) => {
|
||||
const networkBadge = isOnline ? statusConfig.online : statusConfig.offline;
|
||||
const installBadge = isInstalled ? statusConfig.installed : statusConfig.browser;
|
||||
const offlineBadge = isOfflineReady ? statusConfig.ready : statusConfig.pending;
|
||||
|
||||
return (
|
||||
<Panel className="p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
||||
Демо и PWA
|
||||
</p>
|
||||
<h3 className="text-xl font-semibold">Что это и зачем для демонстрации</h3>
|
||||
<p className="max-w-3xl text-sm leading-6 text-[var(--color-text-muted)]">
|
||||
PWA превращает веб-приложение в устанавливаемый рабочий экран: его можно открыть как
|
||||
отдельное приложение и использовать для показа сценариев после первого запуска.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isInstallAvailable ? (
|
||||
<Button variant="secondary" onClick={onInstall}>
|
||||
Установить приложение
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
<Badge tone={networkBadge.tone}>{networkBadge.label}</Badge>
|
||||
<Badge tone={installBadge.tone}>{installBadge.label}</Badge>
|
||||
<Badge tone={offlineBadge.tone}>{offlineBadge.label}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-[24px] bg-[var(--color-surface-strong)] p-5">
|
||||
<h4 className="text-sm font-semibold uppercase tracking-[0.12em] text-[var(--color-text-muted)]">
|
||||
Что это
|
||||
</h4>
|
||||
<p className="mt-3 text-sm leading-6 text-[var(--color-text-muted)]">
|
||||
Это PWA-версия панели: её можно установить на ноутбук или телефон и запускать без
|
||||
адресной строки браузера.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] bg-[var(--color-surface-strong)] p-5">
|
||||
<h4 className="text-sm font-semibold uppercase tracking-[0.12em] text-[var(--color-text-muted)]">
|
||||
Зачем для демо
|
||||
</h4>
|
||||
<p className="mt-3 text-sm leading-6 text-[var(--color-text-muted)]">
|
||||
После первого запуска оболочка кешируется, поэтому дашборд и демо-данные можно
|
||||
показать даже без интернета.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-[24px] border border-[var(--color-border)] bg-[var(--color-accent-soft)] p-5">
|
||||
<p className="text-sm font-medium">Как работает офлайн-демо</p>
|
||||
<p className="mt-2 text-sm leading-6 text-[var(--color-text-muted)]">
|
||||
Демо-данные доступны локально: роли, вход по коду `000000`, заказы, статусы и обзор
|
||||
дашборда продолжают работать после первого запуска.
|
||||
</p>
|
||||
<p className="mt-2 text-sm leading-6 text-[var(--color-text-muted)]">
|
||||
Интеграции и рабочая база требуют подключения, поэтому Supabase и боевые сценарии
|
||||
остаются сетевыми.
|
||||
</p>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PwaDemoPanel } from "./PwaDemoPanel";
|
||||
|
||||
describe("PwaDemoPanel", () => {
|
||||
it("renders Russian explanation for PWA demo mode", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<PwaDemoPanel
|
||||
isInstallAvailable={true}
|
||||
isInstalled={false}
|
||||
isOfflineReady={true}
|
||||
isOnline={true}
|
||||
onInstall={() => {}}
|
||||
/>,
|
||||
).toLowerCase();
|
||||
|
||||
expect(markup).toContain("что это");
|
||||
expect(markup).toContain("pwa");
|
||||
expect(markup).toContain("после первого запуска");
|
||||
expect(markup).toContain("интеграции и рабочая база требуют подключения");
|
||||
});
|
||||
|
||||
it("shows install action only when installation is available", () => {
|
||||
const availableMarkup = renderToStaticMarkup(
|
||||
<PwaDemoPanel
|
||||
isInstallAvailable={true}
|
||||
isInstalled={false}
|
||||
isOfflineReady={false}
|
||||
isOnline={true}
|
||||
onInstall={() => {}}
|
||||
/>,
|
||||
);
|
||||
const installedMarkup = renderToStaticMarkup(
|
||||
<PwaDemoPanel
|
||||
isInstallAvailable={false}
|
||||
isInstalled={true}
|
||||
isOfflineReady={true}
|
||||
isOnline={true}
|
||||
onInstall={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(availableMarkup).toContain("Установить приложение");
|
||||
expect(installedMarkup).not.toContain("Установить приложение");
|
||||
expect(installedMarkup).toContain("Установлено");
|
||||
});
|
||||
|
||||
it("shows offline badge when browser is offline", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<PwaDemoPanel
|
||||
isInstallAvailable={false}
|
||||
isInstalled={false}
|
||||
isOfflineReady={true}
|
||||
isOnline={false}
|
||||
onInstall={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain("Офлайн");
|
||||
expect(markup).toContain("Демо-данные доступны локально");
|
||||
});
|
||||
});
|
||||
|
|
@ -4,8 +4,9 @@ import { supabase, hasSupabaseConfig } from "../supabaseClient";
|
|||
import { safeSupabaseCall } from "../services/safeSupabaseCall";
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
const STORAGE_KEY = "construction-auth-demo-user";
|
||||
export const DEMO_LOGIN_EMAIL = "demo@local";
|
||||
const STORAGE_KEY = "construction-auth-local-user";
|
||||
export const DEMO_LOGIN_EMAIL = "local@local";
|
||||
export const ROLE_SWITCH_ENTRY_EMAIL = "roles@local";
|
||||
export const MISSING_PROFILE_ERROR = "Профиль пользователя не найден. Обратитесь к администратору.";
|
||||
export const PROFILE_LOAD_ERROR = "Не удалось загрузить профиль пользователя.";
|
||||
export const UNKNOWN_EMAIL_ERROR = "Email не найден в системе. Обратитесь к администратору.";
|
||||
|
|
@ -43,6 +44,9 @@ export const resolveDemoUser = (email, roleHint) => {
|
|||
);
|
||||
};
|
||||
|
||||
export const isRoleSwitchEntryEmail = (email) =>
|
||||
String(email || "").trim().toLowerCase() === ROLE_SWITCH_ENTRY_EMAIL;
|
||||
|
||||
export const resolveLoginEmail = (isDemoMode, email) =>
|
||||
isDemoMode ? DEMO_LOGIN_EMAIL : email;
|
||||
|
||||
|
|
@ -171,7 +175,7 @@ export const AuthProvider = ({ children }) => {
|
|||
}
|
||||
|
||||
if (otp !== "000000") {
|
||||
throw new Error("Для demo-режима используйте код 000000");
|
||||
throw new Error("Для локального входа используйте код 000000");
|
||||
}
|
||||
|
||||
const roleHint = localStorage.getItem("construction-auth-role-hint");
|
||||
|
|
@ -197,6 +201,15 @@ export const AuthProvider = ({ children }) => {
|
|||
setAuthError("");
|
||||
};
|
||||
|
||||
const signInWithRole = async (roleHint) => {
|
||||
const localUser = resolveDemoUser("", roleHint);
|
||||
setUser(localUser);
|
||||
setPendingEmail(ROLE_SWITCH_ENTRY_EMAIL);
|
||||
setIsOtpSent(false);
|
||||
setAuthError("");
|
||||
return { success: true, user: localUser };
|
||||
};
|
||||
|
||||
const value = {
|
||||
user,
|
||||
pendingEmail,
|
||||
|
|
@ -206,6 +219,7 @@ export const AuthProvider = ({ children }) => {
|
|||
isDemoMode: !hasSupabaseConfig,
|
||||
requestOtp,
|
||||
verifyOtp,
|
||||
signInWithRole,
|
||||
signOut,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEMO_LOGIN_EMAIL,
|
||||
ROLE_SWITCH_ENTRY_EMAIL,
|
||||
UNKNOWN_EMAIL_ERROR,
|
||||
buildOtpRequestPayload,
|
||||
isRoleSwitchEntryEmail,
|
||||
mapProfileToAuthUser,
|
||||
normalizeOtpError,
|
||||
resolveDemoUser,
|
||||
|
|
@ -11,17 +13,17 @@ import {
|
|||
|
||||
describe("resolveDemoUser", () => {
|
||||
it("prioritizes the selected demo role over a matching email account", () => {
|
||||
const user = resolveDemoUser("manager@demo.local", "driver");
|
||||
const user = resolveDemoUser("manager@local", "driver");
|
||||
|
||||
expect(user.role).toBe("driver");
|
||||
expect(user.email).toBe("driver@demo.local");
|
||||
expect(user.email).toBe("driver@local");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveLoginEmail", () => {
|
||||
it("forces a single shared email in demo mode", () => {
|
||||
expect(resolveLoginEmail(true, "manager@demo.local")).toBe(DEMO_LOGIN_EMAIL);
|
||||
expect(resolveLoginEmail(true, "driver@demo.local")).toBe(DEMO_LOGIN_EMAIL);
|
||||
expect(resolveLoginEmail(true, "manager@local")).toBe(DEMO_LOGIN_EMAIL);
|
||||
expect(resolveLoginEmail(true, "driver@local")).toBe(DEMO_LOGIN_EMAIL);
|
||||
});
|
||||
|
||||
it("keeps the entered email outside demo mode", () => {
|
||||
|
|
@ -29,6 +31,16 @@ describe("resolveLoginEmail", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("isRoleSwitchEntryEmail", () => {
|
||||
it("recognizes the special service email regardless of case and spacing", () => {
|
||||
expect(isRoleSwitchEntryEmail(` ${ROLE_SWITCH_ENTRY_EMAIL.toUpperCase()} `)).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores regular emails", () => {
|
||||
expect(isRoleSwitchEntryEmail("user@company.ru")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildOtpRequestPayload", () => {
|
||||
it("disables automatic user creation during OTP sign-in", () => {
|
||||
expect(buildOtpRequestPayload("user@company.ru")).toEqual({
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export const demoUsers = [
|
||||
{
|
||||
id: "u-manager",
|
||||
email: "manager@demo.local",
|
||||
email: "manager@local",
|
||||
name: "Анна Мельник",
|
||||
phone: "+7 978 300-10-01",
|
||||
role: "manager",
|
||||
|
|
@ -14,7 +14,7 @@ export const demoUsers = [
|
|||
},
|
||||
{
|
||||
id: "u-production",
|
||||
email: "production@demo.local",
|
||||
email: "production@local",
|
||||
name: "Илья Корнеев",
|
||||
phone: "+7 978 300-10-02",
|
||||
role: "production_lead",
|
||||
|
|
@ -27,7 +27,7 @@ export const demoUsers = [
|
|||
},
|
||||
{
|
||||
id: "u-logistics",
|
||||
email: "logistics@demo.local",
|
||||
email: "logistics@local",
|
||||
name: "Ольга Синицына",
|
||||
phone: "+7 978 300-10-03",
|
||||
role: "logistician",
|
||||
|
|
@ -40,7 +40,7 @@ export const demoUsers = [
|
|||
},
|
||||
{
|
||||
id: "u-logistics-2",
|
||||
email: "route@demo.local",
|
||||
email: "route@local",
|
||||
name: "Павел Миронов",
|
||||
phone: "+7 978 300-10-04",
|
||||
role: "logistician",
|
||||
|
|
@ -53,7 +53,7 @@ export const demoUsers = [
|
|||
},
|
||||
{
|
||||
id: "u-driver",
|
||||
email: "driver@demo.local",
|
||||
email: "driver@local",
|
||||
name: "Артём Громов",
|
||||
phone: "+7 978 300-10-06",
|
||||
role: "driver",
|
||||
|
|
@ -66,7 +66,7 @@ export const demoUsers = [
|
|||
},
|
||||
{
|
||||
id: "u-admin",
|
||||
email: "admin@demo.local",
|
||||
email: "admin@local",
|
||||
name: "Максим Белов",
|
||||
phone: "+7 978 300-10-05",
|
||||
role: "admin",
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import { DriverDeliveryDetail } from "../components/driver/DriverDeliveryDetail"
|
|||
import { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner";
|
||||
import { KpiCard } from "../components/dashboard/KpiCard";
|
||||
import { LogisticsReadinessBoard } from "../components/logistics/LogisticsReadinessBoard";
|
||||
import { PwaDemoPanel } from "../components/dashboard/PwaDemoPanel";
|
||||
import { ProductionQueuePanel } from "../components/dashboard/ProductionQueuePanel";
|
||||
import { RoleWorkspacePanel } from "../components/dashboard/RoleWorkspacePanel";
|
||||
import { BotControlPanel } from "../components/logistics/BotControlPanel";
|
||||
|
|
@ -33,7 +32,6 @@ import { Panel } from "../components/UI/Panel";
|
|||
import { SegmentedTabs } from "../components/UI/SegmentedTabs";
|
||||
import { Select } from "../components/UI/Select";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { usePwaStatus } from "../hooks/usePwaStatus";
|
||||
import { useOrders } from "../hooks/useOrders";
|
||||
import { AppShell } from "../layouts/AppShell";
|
||||
import {
|
||||
|
|
@ -52,7 +50,6 @@ import { resolveDraggedOrderId } from "../components/orders/ordersKanbanDrag";
|
|||
|
||||
export const DashboardPage = () => {
|
||||
const { user, signOut } = useAuth();
|
||||
const { installApp, isInstallAvailable, isInstalled, isOfflineReady, isOnline } = usePwaStatus();
|
||||
const userRole = user?.role;
|
||||
const [activeSection, setActiveSection] = React.useState("overview");
|
||||
const [overviewTab, setOverviewTab] = React.useState("pulse");
|
||||
|
|
@ -293,10 +290,6 @@ const {
|
|||
setDropColumnKey(null);
|
||||
};
|
||||
|
||||
const handleInstallApp = async () => {
|
||||
await installApp();
|
||||
};
|
||||
|
||||
const overviewTabs = [
|
||||
{ key: "pulse", label: "Пульс" },
|
||||
{ key: "events", label: "События" },
|
||||
|
|
@ -520,13 +513,6 @@ const {
|
|||
</Panel>
|
||||
</section>
|
||||
|
||||
<PwaDemoPanel
|
||||
isInstallAvailable={isInstallAvailable}
|
||||
isInstalled={isInstalled}
|
||||
isOfflineReady={isOfflineReady}
|
||||
isOnline={isOnline}
|
||||
onInstall={handleInstallApp}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,20 @@
|
|||
import React from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { OtpLoginForm } from "../components/auth/OtpLoginForm";
|
||||
import { DEMO_LOGIN_EMAIL, resolveLoginEmail, useAuth } from "../context/AuthContext";
|
||||
import {
|
||||
DEMO_LOGIN_EMAIL,
|
||||
isRoleSwitchEntryEmail,
|
||||
resolveLoginEmail,
|
||||
useAuth,
|
||||
} from "../context/AuthContext";
|
||||
|
||||
export const LoginPage = () => {
|
||||
const { user, isOtpSent, isLoading, isDemoMode, authError, requestOtp, verifyOtp } = useAuth();
|
||||
const { user, isOtpSent, isLoading, isDemoMode, authError, requestOtp, verifyOtp, signInWithRole } = useAuth();
|
||||
const [email, setEmail] = React.useState(() => (isDemoMode ? DEMO_LOGIN_EMAIL : ""));
|
||||
const [roleHint, setRoleHint] = React.useState("manager");
|
||||
const [otp, setOtp] = React.useState("");
|
||||
const [error, setError] = React.useState("");
|
||||
const isRoleSwitchMode = !isOtpSent && isRoleSwitchEntryEmail(email);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isDemoMode) {
|
||||
|
|
@ -23,6 +29,16 @@ export const LoginPage = () => {
|
|||
const displayError = error || authError;
|
||||
|
||||
const handleRequestOtp = async () => {
|
||||
if (isRoleSwitchMode) {
|
||||
const response = await signInWithRole(roleHint);
|
||||
if (!response.success) {
|
||||
setError(response.error?.message || "Не удалось выполнить вход");
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await requestOtp(
|
||||
isDemoMode
|
||||
? { email: resolveLoginEmail(isDemoMode, email), roleHint }
|
||||
|
|
@ -56,6 +72,7 @@ export const LoginPage = () => {
|
|||
isOtpSent={isOtpSent}
|
||||
isLoading={isLoading}
|
||||
isDemoMode={isDemoMode}
|
||||
isRoleSwitchMode={isRoleSwitchMode}
|
||||
onRequestOtp={handleRequestOtp}
|
||||
onVerifyOtp={handleVerifyOtp}
|
||||
error={displayError}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,37 @@
|
|||
import { supabase, hasSupabaseConfig } from "../supabaseClient";
|
||||
|
||||
const SHOWCASE_TOKEN = "showcase";
|
||||
|
||||
const formatIsoDate = (date) => date.toISOString().slice(0, 10);
|
||||
|
||||
const addDays = (date, days) => {
|
||||
const next = new Date(date);
|
||||
next.setUTCDate(next.getUTCDate() + days);
|
||||
return next;
|
||||
};
|
||||
|
||||
export const buildShowcaseInvitation = (token = SHOWCASE_TOKEN, now = new Date()) => {
|
||||
const firstDay = formatIsoDate(addDays(now, 1));
|
||||
const secondDay = formatIsoDate(addDays(now, 2));
|
||||
|
||||
return {
|
||||
token,
|
||||
orderId: "showcase-order",
|
||||
orderNumber: "CD-CLIENT-001",
|
||||
customerName: "Мария Волкова",
|
||||
customerPhone: "+7 978 000-12-31",
|
||||
state: "awaiting_choice",
|
||||
deliveryDate: firstDay,
|
||||
deliveryTime: "До обеда",
|
||||
availableSlots: [
|
||||
`${firstDay}, До обеда`,
|
||||
`${firstDay}, После обеда`,
|
||||
`${secondDay}, До обеда`,
|
||||
`${secondDay}, После обеда`,
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const invokeDeliveryFunction = async (functionName, body) => {
|
||||
if (!hasSupabaseConfig || !supabase?.functions?.invoke) {
|
||||
throw new Error("Supabase is not configured");
|
||||
|
|
@ -17,7 +49,9 @@ const invokeDeliveryFunction = async (functionName, body) => {
|
|||
};
|
||||
|
||||
export const fetchDeliveryInvitation = async (token) =>
|
||||
(await invokeDeliveryFunction("get-delivery-invitation", { token })).invitation;
|
||||
(token === SHOWCASE_TOKEN
|
||||
? buildShowcaseInvitation(token)
|
||||
: (await invokeDeliveryFunction("get-delivery-invitation", { token })).invitation);
|
||||
|
||||
export const confirmDeliveryChoice = async ({ token, deliveryDate, deliveryTime }) =>
|
||||
invokeDeliveryFunction("confirm-delivery-choice", {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ vi.mock("../supabaseClient", () => ({
|
|||
}));
|
||||
|
||||
import {
|
||||
buildShowcaseInvitation,
|
||||
confirmDeliveryChoice,
|
||||
fetchDeliveryInvitation,
|
||||
reportDeliveryResult,
|
||||
|
|
@ -50,6 +51,28 @@ describe("deliveryInvitationApi", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("returns a local showcase invitation for the preview token", async () => {
|
||||
await expect(fetchDeliveryInvitation("showcase")).resolves.toMatchObject({
|
||||
token: "showcase",
|
||||
orderNumber: "CD-CLIENT-001",
|
||||
customerName: "Мария Волкова",
|
||||
state: "awaiting_choice",
|
||||
});
|
||||
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("builds showcase slots for tomorrow and the following day", () => {
|
||||
const invitation = buildShowcaseInvitation("showcase", new Date("2026-04-14T09:00:00Z"));
|
||||
|
||||
expect(invitation.availableSlots).toEqual([
|
||||
"2026-04-15, До обеда",
|
||||
"2026-04-15, После обеда",
|
||||
"2026-04-16, До обеда",
|
||||
"2026-04-16, После обеда",
|
||||
]);
|
||||
});
|
||||
|
||||
it("confirms a delivery choice with the chosen slot", async () => {
|
||||
invoke.mockResolvedValueOnce({
|
||||
data: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue