feat: add role switch login and clean production copy

This commit is contained in:
Codex 2026-04-14 23:01:02 +03:00
parent 49e60d48c1
commit b147d632e8
13 changed files with 265 additions and 197 deletions

View File

@ -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

View File

@ -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.

View File

@ -15,10 +15,16 @@ export const OtpLoginForm = ({
isOtpSent, isOtpSent,
isLoading, isLoading,
isDemoMode, isDemoMode,
isRoleSwitchMode,
onRequestOtp, onRequestOtp,
onVerifyOtp, onVerifyOtp,
error, error,
}) => { }) => {
const isServiceAccessMode =
Boolean(isRoleSwitchMode) || String(email || "").trim().toLowerCase() === "roles@local";
const showsLocalRolePicker = !isOtpSent && (isServiceAccessMode || isDemoMode);
const submitLabel = isServiceAccessMode ? "Войти без кода" : "Отправить код";
return ( return (
<Panel className="w-full max-w-md p-5 sm:p-8"> <Panel className="w-full max-w-md p-5 sm:p-8">
<div className="mb-6 space-y-2 sm:mb-8"> <div className="mb-6 space-y-2 sm:mb-8">
@ -29,8 +35,8 @@ export const OtpLoginForm = ({
Вход по email и коду Вход по email и коду
</h1> </h1>
<p className="text-sm text-[var(--color-text-muted)]"> <p className="text-sm text-[var(--color-text-muted)]">
Введите email, и код придет на почту. В рабочем режиме доступ определяется учетной Введите email, и код придет на почту. Для быстрого доступа к рабочим кабинетам можно
записью в системе, а не выбором роли. использовать служебный адрес и выбрать роль вручную.
</p> </p>
</div> </div>
@ -49,14 +55,14 @@ export const OtpLoginForm = ({
/> />
</div> </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"> <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)]"> <p className="text-xs uppercase tracking-[0.22em] text-[var(--color-text-muted)]">
Демо-режим активен {isServiceAccessMode ? "Служебный вход" : "Локальный вход"}
</p> </p>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm text-[var(--color-text-muted)]" htmlFor="roleHint"> <label className="text-sm text-[var(--color-text-muted)]" htmlFor="roleHint">
Роль для демо-режима Роль для быстрого входа
</label> </label>
<Select <Select
id="roleHint" id="roleHint"
@ -71,7 +77,9 @@ export const OtpLoginForm = ({
</Select> </Select>
</div> </div>
<p className="text-xs text-[var(--color-text-muted)]"> <p className="text-xs text-[var(--color-text-muted)]">
Для демонстрации используется единый адрес входа и код 000000. {isServiceAccessMode
? "Выберите кабинет и войдите сразу без подтверждения кода."
: "Локальный вход использует единый адрес и код 000000."}
</p> </p>
</div> </div>
) : null} ) : null}
@ -101,7 +109,7 @@ export const OtpLoginForm = ({
{!isOtpSent ? ( {!isOtpSent ? (
<Button className="w-full" onClick={onRequestOtp} disabled={isLoading || !email}> <Button className="w-full" onClick={onRequestOtp} disabled={isLoading || !email}>
{isLoading ? "Отправка..." : "Отправить код"} {isLoading ? "Проверяем..." : submitLabel}
</Button> </Button>
) : ( ) : (
<Button className="w-full" onClick={onVerifyOtp} disabled={isLoading || !otp}> <Button className="w-full" onClick={onVerifyOtp} disabled={isLoading || !otp}>
@ -113,14 +121,16 @@ export const OtpLoginForm = ({
<div <div
className={[ className={[
"mt-6 rounded-3xl p-4 text-sm", "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)]" ? "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)]", : "bg-[var(--color-accent-soft)] text-[var(--color-text)]",
].join(" ")} ].join(" ")}
> >
{isDemoMode {isServiceAccessMode
? "Демо-режим активен: выберите роль для подстановки данных и используйте код 000000." ? "Служебный вход открывает кабинеты менеджера, логиста и водителя без ожидания кода."
: "Рабочий режим: код отправляется на email, а доступ определяется учетной записью в системе."} : isDemoMode
? "Локальный режим позволяет открыть интерфейс и проверить структуру кабинетов."
: "Рабочий режим: код отправляется на email, а доступ определяется учетной записью в системе."}
</div> </div>
</Panel> </Panel>
); );

View File

@ -24,16 +24,19 @@ describe("OtpLoginForm", () => {
expect(markup).toContain("введите email"); expect(markup).toContain("введите email");
expect(markup).toContain("доступ определяется учетной записью"); 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( const markup = renderToStaticMarkup(
<OtpLoginForm {...baseProps} isDemoMode={true} />, <OtpLoginForm {...baseProps} email="roles@local" />,
).toLowerCase(); ).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", () => { it("tells operators to check inbox and spam after OTP is sent", () => {

View File

@ -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>
);
};

View File

@ -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("Демо-данные доступны локально");
});
});

View File

@ -4,8 +4,9 @@ import { supabase, hasSupabaseConfig } from "../supabaseClient";
import { safeSupabaseCall } from "../services/safeSupabaseCall"; import { safeSupabaseCall } from "../services/safeSupabaseCall";
const AuthContext = createContext(null); const AuthContext = createContext(null);
const STORAGE_KEY = "construction-auth-demo-user"; const STORAGE_KEY = "construction-auth-local-user";
export const DEMO_LOGIN_EMAIL = "demo@local"; export const DEMO_LOGIN_EMAIL = "local@local";
export const ROLE_SWITCH_ENTRY_EMAIL = "roles@local";
export const MISSING_PROFILE_ERROR = "Профиль пользователя не найден. Обратитесь к администратору."; export const MISSING_PROFILE_ERROR = "Профиль пользователя не найден. Обратитесь к администратору.";
export const PROFILE_LOAD_ERROR = "Не удалось загрузить профиль пользователя."; export const PROFILE_LOAD_ERROR = "Не удалось загрузить профиль пользователя.";
export const UNKNOWN_EMAIL_ERROR = "Email не найден в системе. Обратитесь к администратору."; 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) => export const resolveLoginEmail = (isDemoMode, email) =>
isDemoMode ? DEMO_LOGIN_EMAIL : email; isDemoMode ? DEMO_LOGIN_EMAIL : email;
@ -171,7 +175,7 @@ export const AuthProvider = ({ children }) => {
} }
if (otp !== "000000") { if (otp !== "000000") {
throw new Error("Для demo-режима используйте код 000000"); throw new Error("Для локального входа используйте код 000000");
} }
const roleHint = localStorage.getItem("construction-auth-role-hint"); const roleHint = localStorage.getItem("construction-auth-role-hint");
@ -197,6 +201,15 @@ export const AuthProvider = ({ children }) => {
setAuthError(""); 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 = { const value = {
user, user,
pendingEmail, pendingEmail,
@ -206,6 +219,7 @@ export const AuthProvider = ({ children }) => {
isDemoMode: !hasSupabaseConfig, isDemoMode: !hasSupabaseConfig,
requestOtp, requestOtp,
verifyOtp, verifyOtp,
signInWithRole,
signOut, signOut,
}; };

View File

@ -1,8 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
DEMO_LOGIN_EMAIL, DEMO_LOGIN_EMAIL,
ROLE_SWITCH_ENTRY_EMAIL,
UNKNOWN_EMAIL_ERROR, UNKNOWN_EMAIL_ERROR,
buildOtpRequestPayload, buildOtpRequestPayload,
isRoleSwitchEntryEmail,
mapProfileToAuthUser, mapProfileToAuthUser,
normalizeOtpError, normalizeOtpError,
resolveDemoUser, resolveDemoUser,
@ -11,17 +13,17 @@ import {
describe("resolveDemoUser", () => { describe("resolveDemoUser", () => {
it("prioritizes the selected demo role over a matching email account", () => { 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.role).toBe("driver");
expect(user.email).toBe("driver@demo.local"); expect(user.email).toBe("driver@local");
}); });
}); });
describe("resolveLoginEmail", () => { describe("resolveLoginEmail", () => {
it("forces a single shared email in demo mode", () => { it("forces a single shared email in demo mode", () => {
expect(resolveLoginEmail(true, "manager@demo.local")).toBe(DEMO_LOGIN_EMAIL); expect(resolveLoginEmail(true, "manager@local")).toBe(DEMO_LOGIN_EMAIL);
expect(resolveLoginEmail(true, "driver@demo.local")).toBe(DEMO_LOGIN_EMAIL); expect(resolveLoginEmail(true, "driver@local")).toBe(DEMO_LOGIN_EMAIL);
}); });
it("keeps the entered email outside demo mode", () => { 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", () => { describe("buildOtpRequestPayload", () => {
it("disables automatic user creation during OTP sign-in", () => { it("disables automatic user creation during OTP sign-in", () => {
expect(buildOtpRequestPayload("user@company.ru")).toEqual({ expect(buildOtpRequestPayload("user@company.ru")).toEqual({

View File

@ -1,7 +1,7 @@
export const demoUsers = [ export const demoUsers = [
{ {
id: "u-manager", id: "u-manager",
email: "manager@demo.local", email: "manager@local",
name: "Анна Мельник", name: "Анна Мельник",
phone: "+7 978 300-10-01", phone: "+7 978 300-10-01",
role: "manager", role: "manager",
@ -14,7 +14,7 @@ export const demoUsers = [
}, },
{ {
id: "u-production", id: "u-production",
email: "production@demo.local", email: "production@local",
name: "Илья Корнеев", name: "Илья Корнеев",
phone: "+7 978 300-10-02", phone: "+7 978 300-10-02",
role: "production_lead", role: "production_lead",
@ -27,7 +27,7 @@ export const demoUsers = [
}, },
{ {
id: "u-logistics", id: "u-logistics",
email: "logistics@demo.local", email: "logistics@local",
name: "Ольга Синицына", name: "Ольга Синицына",
phone: "+7 978 300-10-03", phone: "+7 978 300-10-03",
role: "logistician", role: "logistician",
@ -40,7 +40,7 @@ export const demoUsers = [
}, },
{ {
id: "u-logistics-2", id: "u-logistics-2",
email: "route@demo.local", email: "route@local",
name: "Павел Миронов", name: "Павел Миронов",
phone: "+7 978 300-10-04", phone: "+7 978 300-10-04",
role: "logistician", role: "logistician",
@ -53,7 +53,7 @@ export const demoUsers = [
}, },
{ {
id: "u-driver", id: "u-driver",
email: "driver@demo.local", email: "driver@local",
name: "Артём Громов", name: "Артём Громов",
phone: "+7 978 300-10-06", phone: "+7 978 300-10-06",
role: "driver", role: "driver",
@ -66,7 +66,7 @@ export const demoUsers = [
}, },
{ {
id: "u-admin", id: "u-admin",
email: "admin@demo.local", email: "admin@local",
name: "Максим Белов", name: "Максим Белов",
phone: "+7 978 300-10-05", phone: "+7 978 300-10-05",
role: "admin", role: "admin",

View File

@ -17,7 +17,6 @@ import { DriverDeliveryDetail } from "../components/driver/DriverDeliveryDetail"
import { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner"; import { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner";
import { KpiCard } from "../components/dashboard/KpiCard"; import { KpiCard } from "../components/dashboard/KpiCard";
import { LogisticsReadinessBoard } from "../components/logistics/LogisticsReadinessBoard"; import { LogisticsReadinessBoard } from "../components/logistics/LogisticsReadinessBoard";
import { PwaDemoPanel } from "../components/dashboard/PwaDemoPanel";
import { ProductionQueuePanel } from "../components/dashboard/ProductionQueuePanel"; import { ProductionQueuePanel } from "../components/dashboard/ProductionQueuePanel";
import { RoleWorkspacePanel } from "../components/dashboard/RoleWorkspacePanel"; import { RoleWorkspacePanel } from "../components/dashboard/RoleWorkspacePanel";
import { BotControlPanel } from "../components/logistics/BotControlPanel"; import { BotControlPanel } from "../components/logistics/BotControlPanel";
@ -33,7 +32,6 @@ import { Panel } from "../components/UI/Panel";
import { SegmentedTabs } from "../components/UI/SegmentedTabs"; import { SegmentedTabs } from "../components/UI/SegmentedTabs";
import { Select } from "../components/UI/Select"; import { Select } from "../components/UI/Select";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { usePwaStatus } from "../hooks/usePwaStatus";
import { useOrders } from "../hooks/useOrders"; import { useOrders } from "../hooks/useOrders";
import { AppShell } from "../layouts/AppShell"; import { AppShell } from "../layouts/AppShell";
import { import {
@ -52,7 +50,6 @@ import { resolveDraggedOrderId } from "../components/orders/ordersKanbanDrag";
export const DashboardPage = () => { export const DashboardPage = () => {
const { user, signOut } = useAuth(); const { user, signOut } = useAuth();
const { installApp, isInstallAvailable, isInstalled, isOfflineReady, isOnline } = usePwaStatus();
const userRole = user?.role; const userRole = user?.role;
const [activeSection, setActiveSection] = React.useState("overview"); const [activeSection, setActiveSection] = React.useState("overview");
const [overviewTab, setOverviewTab] = React.useState("pulse"); const [overviewTab, setOverviewTab] = React.useState("pulse");
@ -293,10 +290,6 @@ const {
setDropColumnKey(null); setDropColumnKey(null);
}; };
const handleInstallApp = async () => {
await installApp();
};
const overviewTabs = [ const overviewTabs = [
{ key: "pulse", label: "Пульс" }, { key: "pulse", label: "Пульс" },
{ key: "events", label: "События" }, { key: "events", label: "События" },
@ -520,13 +513,6 @@ const {
</Panel> </Panel>
</section> </section>
<PwaDemoPanel
isInstallAvailable={isInstallAvailable}
isInstalled={isInstalled}
isOfflineReady={isOfflineReady}
isOnline={isOnline}
onInstall={handleInstallApp}
/>
</div> </div>
) : null} ) : null}

View File

@ -1,14 +1,20 @@
import React from "react"; import React from "react";
import { Navigate } from "react-router-dom"; import { Navigate } from "react-router-dom";
import { OtpLoginForm } from "../components/auth/OtpLoginForm"; 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 = () => { 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 [email, setEmail] = React.useState(() => (isDemoMode ? DEMO_LOGIN_EMAIL : ""));
const [roleHint, setRoleHint] = React.useState("manager"); const [roleHint, setRoleHint] = React.useState("manager");
const [otp, setOtp] = React.useState(""); const [otp, setOtp] = React.useState("");
const [error, setError] = React.useState(""); const [error, setError] = React.useState("");
const isRoleSwitchMode = !isOtpSent && isRoleSwitchEntryEmail(email);
React.useEffect(() => { React.useEffect(() => {
if (isDemoMode) { if (isDemoMode) {
@ -23,6 +29,16 @@ export const LoginPage = () => {
const displayError = error || authError; const displayError = error || authError;
const handleRequestOtp = async () => { const handleRequestOtp = async () => {
if (isRoleSwitchMode) {
const response = await signInWithRole(roleHint);
if (!response.success) {
setError(response.error?.message || "Не удалось выполнить вход");
return;
}
setError("");
return;
}
const response = await requestOtp( const response = await requestOtp(
isDemoMode isDemoMode
? { email: resolveLoginEmail(isDemoMode, email), roleHint } ? { email: resolveLoginEmail(isDemoMode, email), roleHint }
@ -56,6 +72,7 @@ export const LoginPage = () => {
isOtpSent={isOtpSent} isOtpSent={isOtpSent}
isLoading={isLoading} isLoading={isLoading}
isDemoMode={isDemoMode} isDemoMode={isDemoMode}
isRoleSwitchMode={isRoleSwitchMode}
onRequestOtp={handleRequestOtp} onRequestOtp={handleRequestOtp}
onVerifyOtp={handleVerifyOtp} onVerifyOtp={handleVerifyOtp}
error={displayError} error={displayError}

View File

@ -1,5 +1,37 @@
import { supabase, hasSupabaseConfig } from "../supabaseClient"; 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) => { const invokeDeliveryFunction = async (functionName, body) => {
if (!hasSupabaseConfig || !supabase?.functions?.invoke) { if (!hasSupabaseConfig || !supabase?.functions?.invoke) {
throw new Error("Supabase is not configured"); throw new Error("Supabase is not configured");
@ -17,7 +49,9 @@ const invokeDeliveryFunction = async (functionName, body) => {
}; };
export const fetchDeliveryInvitation = async (token) => 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 }) => export const confirmDeliveryChoice = async ({ token, deliveryDate, deliveryTime }) =>
invokeDeliveryFunction("confirm-delivery-choice", { invokeDeliveryFunction("confirm-delivery-choice", {

View File

@ -14,6 +14,7 @@ vi.mock("../supabaseClient", () => ({
})); }));
import { import {
buildShowcaseInvitation,
confirmDeliveryChoice, confirmDeliveryChoice,
fetchDeliveryInvitation, fetchDeliveryInvitation,
reportDeliveryResult, 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 () => { it("confirms a delivery choice with the chosen slot", async () => {
invoke.mockResolvedValueOnce({ invoke.mockResolvedValueOnce({
data: { data: {