From 47df3a78dc2d75a26bf018efc2f7738fcc36d4c8 Mon Sep 17 00:00:00 2001 From: Liutenko Oleksandr Date: Fri, 25 Sep 2026 15:21:11 +0300 Subject: [PATCH 1/2] Redesign login page (#5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename header to "Assistant System", subtitle to "Вхід у систему" - Glassmorphism card over animated gradient backdrop, logo mark - Password visibility toggle, larger inputs, focus rings Co-Authored-By: Claude Opus 5.5 --- frontend/src/features/auth/LoginPage.css | 235 ++++++++++++++++++++--- frontend/src/features/auth/LoginPage.tsx | 52 +++-- 2 files changed, 247 insertions(+), 40 deletions(-) diff --git a/frontend/src/features/auth/LoginPage.css b/frontend/src/features/auth/LoginPage.css index 58cc62a..23b51e0 100644 --- a/frontend/src/features/auth/LoginPage.css +++ b/frontend/src/features/auth/LoginPage.css @@ -1,68 +1,230 @@ .login-page { + --login-accent: #7c3aed; + --login-glass: rgba(255, 255, 255, 0.72); + --login-glass-border: rgba(255, 255, 255, 0.6); + --login-input-bg: rgba(255, 255, 255, 0.85); + + position: relative; min-height: 100svh; display: flex; align-items: center; justify-content: center; - padding: 24px; + padding: 24px 16px; + overflow: hidden; + isolation: isolate; + background: + radial-gradient(1200px 600px at 10% -10%, rgba(37, 99, 235, 0.14), transparent 60%), + radial-gradient(900px 500px at 110% 110%, rgba(124, 58, 237, 0.14), transparent 60%), + var(--color-bg); +} + +@media (prefers-color-scheme: dark) { + .login-page { + --login-accent: #a78bfa; + --login-glass: rgba(23, 26, 33, 0.7); + --login-glass-border: rgba(255, 255, 255, 0.08); + --login-input-bg: rgba(15, 17, 21, 0.7); + } +} + +/* Декоративные размытые пятна на фоне */ +.login-backdrop { + position: absolute; + inset: 0; + z-index: -1; + pointer-events: none; +} + +.login-blob { + position: absolute; + border-radius: 50%; + filter: blur(70px); + opacity: 0.55; + animation: login-float 18s ease-in-out infinite alternate; +} + +.login-blob--a { + width: 380px; + height: 380px; + top: -80px; + left: -60px; + background: var(--color-primary); +} + +.login-blob--b { + width: 320px; + height: 320px; + bottom: -90px; + right: -40px; + background: var(--login-accent); + animation-delay: -6s; +} + +.login-blob--c { + width: 220px; + height: 220px; + top: 55%; + left: 60%; + background: #06b6d4; + opacity: 0.3; + animation-delay: -12s; +} + +@keyframes login-float { + from { + transform: translate3d(0, 0, 0) scale(1); + } + to { + transform: translate3d(40px, 30px, 0) scale(1.1); + } +} + +@media (prefers-reduced-motion: reduce) { + .login-blob { + animation: none; + } } .login-card { width: 100%; - max-width: 380px; - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: 12px; - box-shadow: var(--shadow-card); - padding: 32px; + max-width: 400px; + background: var(--login-glass); + border: 1px solid var(--login-glass-border); + border-radius: 20px; + box-shadow: + 0 20px 50px -12px rgba(16, 24, 40, 0.25), + 0 1px 2px rgba(16, 24, 40, 0.06); + backdrop-filter: blur(18px) saturate(140%); + -webkit-backdrop-filter: blur(18px) saturate(140%); + padding: 40px 32px 32px; + animation: login-enter 0.45s cubic-bezier(0.2, 0.8, 0.2, 1) both; +} + +@keyframes login-enter { + from { + opacity: 0; + transform: translateY(12px) scale(0.98); + } +} + +@media (max-width: 420px) { + .login-card { + padding: 32px 20px 24px; + border-radius: 16px; + } } .login-header { - margin-bottom: 24px; + margin-bottom: 28px; text-align: center; } +.login-logo { + width: 56px; + height: 56px; + margin: 0 auto 16px; + display: grid; + place-items: center; + border-radius: 16px; + color: #fff; + background: linear-gradient(135deg, var(--color-primary), var(--login-accent)); + box-shadow: 0 10px 24px -8px var(--color-primary); +} + .login-header h1 { - font-size: 22px; - margin-bottom: 4px; + font-size: 26px; + font-weight: 700; + letter-spacing: -0.02em; + margin-bottom: 6px; + background: linear-gradient(135deg, var(--color-text) 30%, var(--color-primary)); + -webkit-background-clip: text; + background-clip: text; + color: transparent; } .login-header p { color: var(--color-text-muted); - font-size: 14px; + font-size: 15px; } .login-field { - margin-bottom: 16px; + margin-bottom: 18px; } .login-field label { display: block; font-size: 13px; - font-weight: 500; - margin-bottom: 6px; + font-weight: 600; + margin-bottom: 8px; color: var(--color-text); } .login-field input { width: 100%; - padding: 10px 12px; + height: 46px; + padding: 0 14px; border: 1px solid var(--color-border); - border-radius: 8px; - background: var(--color-bg); + border-radius: 12px; + background: var(--login-input-bg); color: var(--color-text); - font-size: 14px; + font-size: 15px; outline: none; - transition: border-color 0.15s ease; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; +} + +.login-field input::placeholder { + color: var(--color-text-muted); + opacity: 0.7; } .login-field input:focus { border-color: var(--color-primary); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-primary) 18%, transparent); } .login-field input:disabled { opacity: 0.6; } +.login-password { + position: relative; +} + +.login-password input { + padding-right: 46px; +} + +.login-toggle { + position: absolute; + top: 50%; + right: 6px; + transform: translateY(-50%); + width: 34px; + height: 34px; + display: grid; + place-items: center; + border: none; + border-radius: 8px; + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + transition: + color 0.15s ease, + background-color 0.15s ease; +} + +.login-toggle:hover { + color: var(--color-text); + background: color-mix(in srgb, var(--color-text) 8%, transparent); +} + +.login-toggle:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 1px; +} + .login-error { display: flex; gap: 8px; @@ -70,23 +232,29 @@ background: var(--color-danger-bg); border: 1px solid var(--color-danger-border); color: var(--color-danger); - border-radius: 8px; - padding: 10px 12px; + border-radius: 12px; + padding: 10px 14px; font-size: 13px; - margin-bottom: 16px; + margin-bottom: 18px; } .login-submit { width: 100%; - padding: 11px 16px; + height: 48px; + margin-top: 8px; + padding: 0 16px; border: none; - border-radius: 8px; - background: var(--color-primary); + border-radius: 12px; + background: linear-gradient(135deg, var(--color-primary), var(--login-accent)); color: #fff; - font-size: 14px; + font-size: 15px; font-weight: 600; cursor: pointer; - transition: background-color 0.15s ease; + box-shadow: 0 10px 24px -10px var(--color-primary); + transition: + transform 0.15s ease, + box-shadow 0.15s ease, + filter 0.15s ease; display: flex; align-items: center; justify-content: center; @@ -94,7 +262,18 @@ } .login-submit:hover:not(:disabled) { - background: var(--color-primary-hover); + transform: translateY(-1px); + filter: brightness(1.06); + box-shadow: 0 14px 28px -10px var(--color-primary); +} + +.login-submit:active:not(:disabled) { + transform: translateY(0); +} + +.login-submit:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; } .login-submit:disabled { diff --git a/frontend/src/features/auth/LoginPage.tsx b/frontend/src/features/auth/LoginPage.tsx index c058bec..c6bd175 100644 --- a/frontend/src/features/auth/LoginPage.tsx +++ b/frontend/src/features/auth/LoginPage.tsx @@ -19,6 +19,7 @@ export function LoginPage() { const [email, setEmail] = useState('') const [password, setPassword] = useState('') + const [showPassword, setShowPassword] = useState(false) const [submitting, setSubmitting] = useState(false) const [formError, setFormError] = useState(null) @@ -45,10 +46,22 @@ export function LoginPage() { return (
+ +
-

lux_fiscal

-

Вход в систему фискализации заказов

+ +

Assistant System

+

Вхід у систему

@@ -74,16 +87,31 @@ export function LoginPage() {
- setPassword(e.target.value)} - placeholder="••••••••" - /> +
+ setPassword(e.target.value)} + placeholder="••••••••" + /> + +
diff --git a/frontend/src/features/auth/ProtectedRoute.tsx b/frontend/src/features/auth/ProtectedRoute.tsx index 864d94d..e31c613 100644 --- a/frontend/src/features/auth/ProtectedRoute.tsx +++ b/frontend/src/features/auth/ProtectedRoute.tsx @@ -10,7 +10,7 @@ export function ProtectedRoute() { if (status === 'loading') { return (
- +
) } diff --git a/frontend/src/features/auth/useAuth.ts b/frontend/src/features/auth/useAuth.ts index f868fd9..b4a7c31 100644 --- a/frontend/src/features/auth/useAuth.ts +++ b/frontend/src/features/auth/useAuth.ts @@ -5,7 +5,7 @@ import { AuthContext } from '@/features/auth/AuthContext' export function useAuth() { const ctx = useContext(AuthContext) if (!ctx) { - throw new Error('useAuth должен вызываться внутри ') + throw new Error('useAuth має викликатися всередині ') } return ctx } diff --git a/frontend/src/features/orders/OrderDetailModal.tsx b/frontend/src/features/orders/OrderDetailModal.tsx index 67a257e..97a6983 100644 --- a/frontend/src/features/orders/OrderDetailModal.tsx +++ b/frontend/src/features/orders/OrderDetailModal.tsx @@ -96,18 +96,18 @@ function orderDiscountOf(draft: Draft): number { /** Проверяет черновик и собирает тело PATCH; строка — текст ошибки для кассира. */ function toPayload(draft: Draft): OrderUpdate | string { - if (draft.goods.length === 0) return 'Добавьте хотя бы один товар' + if (draft.goods.length === 0) return 'Додайте хоча б один товар' const goods = [] for (const good of draft.goods) { - const label = good.name.trim() || 'без названия' - if (!good.name.trim()) return 'У товара не указано наименование' + const label = good.name.trim() || 'без назви' + if (!good.name.trim()) return 'У товару не вказано найменування' const price = toKopecks(good.price) const quantity = toThousandths(good.quantity) const discount = toKopecks(good.discount) - if (price === null) return `Товар «${label}»: некорректная цена` - if (quantity === null || quantity <= 0) return `Товар «${label}»: некорректное количество` - if (discount === null) return `Товар «${label}»: некорректная скидка` - if ((lineAmount(good) ?? 0) < 0) return `Товар «${label}»: скидка больше суммы строки` + if (price === null) return `Товар «${label}»: некоректна ціна` + if (quantity === null || quantity <= 0) return `Товар «${label}»: некоректна кількість` + if (discount === null) return `Товар «${label}»: некоректна знижка` + if ((lineAmount(good) ?? 0) < 0) return `Товар «${label}»: знижка більша за суму рядка` goods.push({ id: good.id, sku: good.sku.trim(), @@ -118,9 +118,9 @@ function toPayload(draft: Draft): OrderUpdate | string { }) } const total = toKopecks(draft.total) - if (total === null) return 'Некорректная сумма заказа' + if (total === null) return 'Некоректна сума замовлення' const sum = goodsSum(draft.goods) ?? 0 - if (total > sum) return `Сумма заказа больше суммы товаров (${formatKopecks(sum)} ₴)` + if (total > sum) return `Сума замовлення більша за суму товарів (${formatKopecks(sum)} ₴)` return { recipient_name: draft.recipient_name, @@ -134,7 +134,7 @@ function toPayload(draft: Draft): OrderUpdate | string { } const TEXT_FIELDS: { field: TextField; label: string; type?: string }[] = [ - { field: 'recipient_name', label: 'Клиент' }, + { field: 'recipient_name', label: 'Клієнт' }, { field: 'recipient_phone', label: 'Телефон', type: 'tel' }, { field: 'recipient_email', label: 'Email', type: 'email' }, { field: 'waybill_number', label: 'Номер ТТН' }, @@ -153,7 +153,7 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr const editable = canEdit && !order.has_receipt function requestClose() { - if (dirty && !window.confirm('Есть несохранённые изменения. Закрыть без сохранения?')) return + if (dirty && !window.confirm('Є незбережені зміни. Закрити без збереження?')) return onClose() } @@ -203,10 +203,10 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr setDraft(next) setSaved(next) setOrderDiscount(orderDiscountOf(next)) - setMessage({ tone: 'ok', text: 'Изменения сохранены' }) + setMessage({ tone: 'ok', text: 'Зміни збережено' }) await queryClient.invalidateQueries({ queryKey: ['orders'] }) } catch (err) { - setMessage({ tone: 'error', text: err instanceof Error ? err.message : 'Не удалось сохранить заказ' }) + setMessage({ tone: 'error', text: err instanceof Error ? err.message : 'Не вдалося зберегти замовлення' }) } finally { setSaving(false) } @@ -217,10 +217,10 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr

- Заказ {order.id} - {order.edited && изменён вручную} + Замовлення {order.id} + {order.edited && змінено вручну}

-
@@ -232,19 +232,19 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
Статус ТТН - {order.np_status || 'Нет данных'} + {order.np_status || 'Немає даних'}
- Наложенный платёж + Накладений платіж {order.np_cod_amount ? `${order.np_cod_amount} ₴` : '—'}
Оплачено {order.np_payment_status === 'Payed' - ? 'Да' + ? 'Так' : order.np_payment_status === 'NeedPayment' - ? 'Нет' + ? 'Ні' : '—'}
@@ -272,7 +272,7 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr {editable ? (