fix chatbox
This commit is contained in:
@@ -1,6 +1,11 @@
|
|||||||
import {cookies} from 'next/headers';
|
import {NextResponse} from 'next/server';
|
||||||
import type {NextRequest} from 'next/server';
|
import type {NextRequest} from 'next/server';
|
||||||
import {verifyCredentials} from '@/features/auth/mock/users.mock';
|
import {verifyCredentials} from '@/features/auth/mock/users.mock';
|
||||||
|
import {
|
||||||
|
LOGIN_ERROR_PARAM,
|
||||||
|
type LoginErrorCode,
|
||||||
|
} from '@/features/auth/services/loginErrorCodes';
|
||||||
|
import {resolveRedirectTarget} from '@/features/auth/services/redirectTarget';
|
||||||
import {
|
import {
|
||||||
REMEMBERED_MAX_AGE_SECONDS,
|
REMEMBERED_MAX_AGE_SECONDS,
|
||||||
SESSION_COOKIE,
|
SESSION_COOKIE,
|
||||||
@@ -25,6 +30,23 @@ export const dynamic = 'force-dynamic';
|
|||||||
* • deciding how long the session lasts (rememberMe is a request, not an
|
* • deciding how long the session lasts (rememberMe is a request, not an
|
||||||
* instruction — the server sets the cookie lifetime)
|
* instruction — the server sets the cookie lifetime)
|
||||||
* • issuing the httpOnly cookie the client can never read or forge
|
* • issuing the httpOnly cookie the client can never read or forge
|
||||||
|
*
|
||||||
|
* ── Two content types, one endpoint ──────────────────────────────────────
|
||||||
|
* It answers both `application/json` (the hydrated form, via authRepository)
|
||||||
|
* and `application/x-www-form-urlencoded` (the browser posting the form
|
||||||
|
* natively, before React has hydrated or when its bundle never arrived).
|
||||||
|
*
|
||||||
|
* That second path is not a nicety, it is the fix for a real leak. The sign-in
|
||||||
|
* form has named inputs; a <form> with no method and no action submits GET to
|
||||||
|
* its own URL, so a submit landing in the pre-hydration window rewrote the
|
||||||
|
* address bar to `/login?email=…&password=…` — putting the password in the
|
||||||
|
* URL bar, in browser history, in the referrer and in every access log between
|
||||||
|
* here and the user. Declaring method="post" action="/api/auth/login" means
|
||||||
|
* the worst case is now a normal POST with the credentials in the body.
|
||||||
|
*
|
||||||
|
* The two paths differ ONLY in how the answer is shaped: JSON gets an
|
||||||
|
* envelope, a native post gets a 303 redirect, because a browser that just
|
||||||
|
* submitted a form needs somewhere to land, not a document full of braces.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface LoginRequestBody {
|
interface LoginRequestBody {
|
||||||
@@ -33,54 +55,140 @@ interface LoginRequestBody {
|
|||||||
rememberMe?: unknown;
|
rememberMe?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
function failure(error: LoginError, status: number): Response {
|
/** What the request asked for, normalised across both content types. */
|
||||||
// Shaped as the app's standard envelope so the client's error path is the
|
interface ParsedLogin {
|
||||||
// same one every other endpoint uses; `field` rides alongside for the form.
|
email: string;
|
||||||
const body: ApiFailure & {field: LoginError['field']} = {
|
password: string;
|
||||||
error: {code: status === 401 ? 'unauthorized' : 'bad_request', message: error.message},
|
rememberMe: boolean;
|
||||||
field: error.field,
|
/** Only meaningful on the native path — where to land after success. */
|
||||||
};
|
next: string | null;
|
||||||
return Response.json(body, {status, headers: {'cache-control': 'no-store'}});
|
/** True when the browser posted the form itself, so answer in redirects. */
|
||||||
|
isFormPost: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asString(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseRequest(req: NextRequest): Promise<ParsedLogin | null> {
|
||||||
|
const contentType = req.headers.get('content-type') ?? '';
|
||||||
|
|
||||||
|
if (
|
||||||
|
contentType.includes('application/x-www-form-urlencoded') ||
|
||||||
|
contentType.includes('multipart/form-data')
|
||||||
|
) {
|
||||||
|
const form = await req.formData();
|
||||||
|
return {
|
||||||
|
email: asString(form.get('email')).trim(),
|
||||||
|
password: asString(form.get('password')),
|
||||||
|
// An unchecked checkbox is absent from the payload entirely; any present
|
||||||
|
// value means checked. Never parsed as a boolean-ish string.
|
||||||
|
rememberMe: form.get('rememberMe') !== null,
|
||||||
|
next: asString(form.get('next')) || null,
|
||||||
|
isFormPost: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest): Promise<Response> {
|
|
||||||
let body: LoginRequestBody;
|
let body: LoginRequestBody;
|
||||||
try {
|
try {
|
||||||
body = (await req.json()) as LoginRequestBody;
|
body = (await req.json()) as LoginRequestBody;
|
||||||
} catch {
|
} catch {
|
||||||
return failure({field: 'form', message: 'Malformed request body.'}, 400);
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
email: asString(body.email).trim(),
|
||||||
|
password: asString(body.password),
|
||||||
|
rememberMe: body.rememberMe === true,
|
||||||
|
next: null,
|
||||||
|
isFormPost: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const email = typeof body.email === 'string' ? body.email.trim() : '';
|
/**
|
||||||
const password = typeof body.password === 'string' ? body.password : '';
|
* A failure, in whichever dialect the caller speaks.
|
||||||
const rememberMe = body.rememberMe === true;
|
*
|
||||||
|
* The redirect carries a code, never the submitted values — bouncing the email
|
||||||
|
* back through the URL to repopulate the field would reintroduce exactly the
|
||||||
|
* leak this endpoint exists to close.
|
||||||
|
*/
|
||||||
|
function failure(
|
||||||
|
req: NextRequest,
|
||||||
|
parsed: Pick<ParsedLogin, 'isFormPost' | 'next'> | null,
|
||||||
|
code: LoginErrorCode,
|
||||||
|
error: LoginError,
|
||||||
|
status: number,
|
||||||
|
): NextResponse {
|
||||||
|
if (parsed?.isFormPost) {
|
||||||
|
const target = new URL('/login', req.url);
|
||||||
|
target.searchParams.set(LOGIN_ERROR_PARAM, code);
|
||||||
|
// Preserve the deep link so a failed attempt does not cost the user the
|
||||||
|
// page they were originally trying to reach. Validated, not echoed raw.
|
||||||
|
const next = resolveRedirectTarget(parsed.next);
|
||||||
|
if (next !== '/dashboard') target.searchParams.set('next', next);
|
||||||
|
// 303: the browser must follow with GET, not repeat the POST.
|
||||||
|
return NextResponse.redirect(target, 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shaped as the app's standard envelope so the client's error path is the
|
||||||
|
// same one every other endpoint uses; `field` rides alongside for the form.
|
||||||
|
const body: ApiFailure & {field: LoginError['field']} = {
|
||||||
|
error: {
|
||||||
|
code: status === 401 ? 'unauthorized' : 'bad_request',
|
||||||
|
message: error.message,
|
||||||
|
},
|
||||||
|
field: error.field,
|
||||||
|
};
|
||||||
|
return NextResponse.json(body, {
|
||||||
|
status,
|
||||||
|
headers: {'cache-control': 'no-store'},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||||
|
const parsed = await parseRequest(req);
|
||||||
|
|
||||||
|
if (!parsed) {
|
||||||
|
return failure(
|
||||||
|
req,
|
||||||
|
null,
|
||||||
|
'malformed',
|
||||||
|
{field: 'form', message: 'Malformed request body.'},
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Server-side validation, repeated rather than trusted from the client. The
|
// Server-side validation, repeated rather than trusted from the client. The
|
||||||
// form validates too, for latency; this validates because the form is not a
|
// form validates too, for latency; this validates because the form is not a
|
||||||
// security boundary and a POST can arrive without it.
|
// security boundary and a POST can arrive without it.
|
||||||
if (!email) {
|
if (!parsed.email) {
|
||||||
return failure({field: 'email', message: 'Enter your email address.'}, 400);
|
return failure(req, parsed, 'email_required', {
|
||||||
|
field: 'email',
|
||||||
|
message: 'Enter your email address.',
|
||||||
|
}, 400);
|
||||||
}
|
}
|
||||||
if (!/^\S+@\S+\.\S+$/.test(email)) {
|
if (!/^\S+@\S+\.\S+$/.test(parsed.email)) {
|
||||||
return failure(
|
return failure(req, parsed, 'email_invalid', {
|
||||||
{field: 'email', message: 'Enter a valid email address.'},
|
field: 'email',
|
||||||
400,
|
message: 'Enter a valid email address.',
|
||||||
);
|
}, 400);
|
||||||
}
|
}
|
||||||
if (!password) {
|
if (!parsed.password) {
|
||||||
return failure({field: 'password', message: 'Enter your password.'}, 400);
|
return failure(req, parsed, 'password_required', {
|
||||||
|
field: 'password',
|
||||||
|
message: 'Enter your password.',
|
||||||
|
}, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const check = verifyCredentials(email, password);
|
const check = verifyCredentials(parsed.email, parsed.password);
|
||||||
|
|
||||||
if (check.outcome === 'unknown_email' || check.outcome === 'wrong_password') {
|
if (check.outcome === 'unknown_email' || check.outcome === 'wrong_password') {
|
||||||
return failure(
|
return failure(req, parsed, 'invalid_credentials', {
|
||||||
{field: 'form', message: 'Invalid email or password.'},
|
field: 'form',
|
||||||
401,
|
message: 'Invalid email or password.',
|
||||||
);
|
}, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxAge = rememberMe
|
const maxAge = parsed.rememberMe
|
||||||
? REMEMBERED_MAX_AGE_SECONDS
|
? REMEMBERED_MAX_AGE_SECONDS
|
||||||
: SESSION_MAX_AGE_SECONDS;
|
: SESSION_MAX_AGE_SECONDS;
|
||||||
|
|
||||||
@@ -95,23 +203,42 @@ export async function POST(req: NextRequest): Promise<Response> {
|
|||||||
maxAge,
|
maxAge,
|
||||||
);
|
);
|
||||||
|
|
||||||
// A browser-session cookie still needs a server-side expiry, or a tab left
|
|
||||||
// open for a week would hold a valid token indefinitely.
|
|
||||||
const store = await cookies();
|
|
||||||
store.set(
|
|
||||||
SESSION_COOKIE,
|
|
||||||
token,
|
|
||||||
sessionCookieOptions(rememberMe ? maxAge : undefined),
|
|
||||||
);
|
|
||||||
|
|
||||||
const session: AuthSession = {
|
const session: AuthSession = {
|
||||||
user: check.user,
|
user: check.user,
|
||||||
expiresAt: new Date(Date.now() + maxAge * 1000).toISOString(),
|
expiresAt: new Date(Date.now() + maxAge * 1000).toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const payload: ApiSuccess<AuthSession> = {
|
const response = parsed.isFormPost
|
||||||
|
? // 303 so the browser re-issues as GET. Without it, a refresh on the
|
||||||
|
// landing page would re-POST the credentials.
|
||||||
|
NextResponse.redirect(
|
||||||
|
new URL(resolveRedirectTarget(parsed.next), req.url),
|
||||||
|
303,
|
||||||
|
)
|
||||||
|
: NextResponse.json(
|
||||||
|
{
|
||||||
data: session,
|
data: session,
|
||||||
meta: {generatedAt: new Date().toISOString()},
|
meta: {generatedAt: new Date().toISOString()},
|
||||||
};
|
} satisfies ApiSuccess<AuthSession>,
|
||||||
return Response.json(payload, {headers: {'cache-control': 'no-store'}});
|
{headers: {'cache-control': 'no-store'}},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set on the response rather than through the `cookies()` store, because
|
||||||
|
* this response may be a redirect: the header has to ride along with the 303
|
||||||
|
* itself or the browser follows it to the dashboard still signed out, gets
|
||||||
|
* bounced back to /login by the proxy, and the sign-in appears to have
|
||||||
|
* silently failed.
|
||||||
|
*
|
||||||
|
* A browser-session cookie still needs a server-side expiry, or a tab left
|
||||||
|
* open for a week would hold a valid token indefinitely — hence the token's
|
||||||
|
* own `exp` regardless of whether maxAge is sent.
|
||||||
|
*/
|
||||||
|
response.cookies.set(
|
||||||
|
SESSION_COOKIE,
|
||||||
|
token,
|
||||||
|
sessionCookieOptions(parsed.rememberMe ? maxAge : undefined),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,18 +30,33 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
/* Global Data Table Inset Contract: 24px left lead-in, 32px right inset */
|
/*
|
||||||
|
* Global Data Table Inset Contract: 24px left lead-in, 32px right inset.
|
||||||
|
*
|
||||||
|
* `[data-ai-report]` is excluded, and that exclusion is a bug fix rather
|
||||||
|
* than a preference. These rules were written for full-page dashboard
|
||||||
|
* tables, but the selector is bare `table` with `!important`, so they also
|
||||||
|
* hit the report tables rendered inside the Loyaly AI panel — adding 24px +
|
||||||
|
* 32px of inset to a table that only has ~376px to begin with, on top of
|
||||||
|
* its own px-4 cells. Roughly a sixth of the panel went to padding the
|
||||||
|
* report could not opt out of, which is a large part of why its columns
|
||||||
|
* came out unreadably narrow.
|
||||||
|
*
|
||||||
|
* The AI report sets its own cell padding for its own width. Scoping by
|
||||||
|
* attribute keeps the dashboard contract exactly as it was for every table
|
||||||
|
* that is genuinely on a full page.
|
||||||
|
*/
|
||||||
.astryx-table-cell:first-child,
|
.astryx-table-cell:first-child,
|
||||||
.astryx-table-header-cell:first-child,
|
.astryx-table-header-cell:first-child,
|
||||||
table th:first-child,
|
table:not([data-ai-report]) th:first-child,
|
||||||
table td:first-child {
|
table:not([data-ai-report]) td:first-child {
|
||||||
padding-inline-start: var(--spacing-6) !important;
|
padding-inline-start: var(--spacing-6) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.astryx-table-cell:last-child,
|
.astryx-table-cell:last-child,
|
||||||
.astryx-table-header-cell:last-child,
|
.astryx-table-header-cell:last-child,
|
||||||
table th:last-child,
|
table:not([data-ai-report]) th:last-child,
|
||||||
table td:last-child {
|
table:not([data-ai-report]) td:last-child {
|
||||||
padding-inline-end: var(--spacing-8) !important;
|
padding-inline-end: var(--spacing-8) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
import {useLoginForm} from '@/features/auth/hooks/useLoginForm';
|
import {LOGIN_ENDPOINT, useLoginForm} from '@/features/auth/hooks/useLoginForm';
|
||||||
import {GoogleIcon, MicrosoftIcon} from './ProviderIcons';
|
import {GoogleIcon, MicrosoftIcon} from './ProviderIcons';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,6 +27,7 @@ export function LoginCredentialsForm() {
|
|||||||
rememberMe,
|
rememberMe,
|
||||||
errors,
|
errors,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
|
next,
|
||||||
setEmail,
|
setEmail,
|
||||||
setPassword,
|
setPassword,
|
||||||
setRememberMe,
|
setRememberMe,
|
||||||
@@ -34,7 +35,35 @@ export function LoginCredentialsForm() {
|
|||||||
} = useLoginForm();
|
} = useLoginForm();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={submit} noValidate className="space-y-4">
|
/**
|
||||||
|
* `method="post"` and `action` are the no-JavaScript safety net, and they
|
||||||
|
* are load-bearing rather than decorative.
|
||||||
|
*
|
||||||
|
* `submit` calls preventDefault, so on a hydrated page these attributes
|
||||||
|
* are never exercised. But hydration is not instantaneous: a form with
|
||||||
|
* neither attribute submits GET to its own URL, so a Continue pressed
|
||||||
|
* before React attached — or on a page whose bundle failed outright —
|
||||||
|
* navigated to `/login?email=…&password=…`, writing the password into the
|
||||||
|
* address bar, the history and every log along the way.
|
||||||
|
*
|
||||||
|
* With these set, that same early submit is an ordinary POST with the
|
||||||
|
* credentials in the body, and the endpoint answers it with a redirect.
|
||||||
|
* Sign-in therefore works with JavaScript disabled entirely.
|
||||||
|
*/
|
||||||
|
<form
|
||||||
|
onSubmit={submit}
|
||||||
|
method="post"
|
||||||
|
action={LOGIN_ENDPOINT}
|
||||||
|
noValidate
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
{/*
|
||||||
|
The deep link the proxy preserved, carried through the native path —
|
||||||
|
the hydrated path reads it from the URL instead. Without it, a no-JS
|
||||||
|
sign-in from /login?next=/settings/billing would land on the dashboard.
|
||||||
|
*/}
|
||||||
|
{next && <input type="hidden" name="next" value={next} />}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="login-email"
|
htmlFor="login-email"
|
||||||
|
|||||||
@@ -4,9 +4,20 @@ import {useCallback, useState} from 'react';
|
|||||||
import {useRouter, useSearchParams} from 'next/navigation';
|
import {useRouter, useSearchParams} from 'next/navigation';
|
||||||
import {useToast} from '@astryxdesign/core/Toast';
|
import {useToast} from '@astryxdesign/core/Toast';
|
||||||
import {useSession} from '@/features/auth/providers/SessionProvider';
|
import {useSession} from '@/features/auth/providers/SessionProvider';
|
||||||
|
import {
|
||||||
|
LOGIN_ERROR_PARAM,
|
||||||
|
loginErrorFromCode,
|
||||||
|
} from '@/features/auth/services/loginErrorCodes';
|
||||||
import {resolveRedirectTarget} from '@/features/auth/services/redirectTarget';
|
import {resolveRedirectTarget} from '@/features/auth/services/redirectTarget';
|
||||||
import type {LoginError} from '@/features/auth/types/auth';
|
import type {LoginError} from '@/features/auth/types/auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where credentials are POSTed. Exported because the form element itself needs
|
||||||
|
* it for `action` — the no-JavaScript path submits straight here, bypassing
|
||||||
|
* this hook, authService and the repository entirely.
|
||||||
|
*/
|
||||||
|
export const LOGIN_ENDPOINT = '/api/auth/login';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every behaviour of the sign-in form, with no markup attached.
|
* Every behaviour of the sign-in form, with no markup attached.
|
||||||
*
|
*
|
||||||
@@ -39,12 +50,30 @@ export function useLoginForm() {
|
|||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [rememberMe, setRememberMe] = useState(false);
|
const [rememberMe, setRememberMe] = useState(false);
|
||||||
const [errors, setErrors] = useState<LoginFormState['errors']>({});
|
/**
|
||||||
|
* Seeded from `?error=` so a sign-in that failed on the no-JavaScript path
|
||||||
|
* still shows its message once the page hydrates. The code is looked up in a
|
||||||
|
* fixed table — an unrecognised one yields nothing, so the URL cannot be
|
||||||
|
* used to write arbitrary text onto the sign-in screen.
|
||||||
|
*
|
||||||
|
* Seeding in useState rather than an effect keeps the message present in the
|
||||||
|
* first render, server and client alike, so there is no flash and no
|
||||||
|
* hydration mismatch to patch up.
|
||||||
|
*/
|
||||||
|
const [errors, setErrors] = useState<LoginFormState['errors']>(() => {
|
||||||
|
const seeded = loginErrorFromCode(searchParams.get(LOGIN_ERROR_PARAM));
|
||||||
|
return seeded ? {[seeded.field]: seeded.message} : {};
|
||||||
|
});
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Raw, for the hidden field the native form path posts back. Unvalidated
|
||||||
|
// here on purpose: the server validates it through resolveRedirectTarget,
|
||||||
|
// which is the only side that can be trusted to.
|
||||||
|
const next = searchParams.get('next');
|
||||||
|
|
||||||
// Shared with GuestGuard, which redirects on the same event — see
|
// Shared with GuestGuard, which redirects on the same event — see
|
||||||
// resolveRedirectTarget for why that matters and how `next` is validated.
|
// resolveRedirectTarget for why that matters and how `next` is validated.
|
||||||
const destination = resolveRedirectTarget(searchParams.get('next'));
|
const destination = resolveRedirectTarget(next);
|
||||||
|
|
||||||
const submit = useCallback(
|
const submit = useCallback(
|
||||||
async (event: React.FormEvent) => {
|
async (event: React.FormEvent) => {
|
||||||
@@ -106,6 +135,7 @@ export function useLoginForm() {
|
|||||||
rememberMe,
|
rememberMe,
|
||||||
errors,
|
errors,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
|
next,
|
||||||
setEmail: updateEmail,
|
setEmail: updateEmail,
|
||||||
setPassword: updatePassword,
|
setPassword: updatePassword,
|
||||||
setRememberMe,
|
setRememberMe,
|
||||||
|
|||||||
37
src/features/auth/services/loginErrorCodes.ts
Normal file
37
src/features/auth/services/loginErrorCodes.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type {LoginError} from '@/features/auth/types/auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The no-JavaScript sign-in path's error vocabulary.
|
||||||
|
*
|
||||||
|
* When the form posts natively (see LoginCredentialsForm), the server cannot
|
||||||
|
* hand the failure back in a JSON body — it has to redirect, and the only
|
||||||
|
* channel a redirect has is the URL. So failures travel as a short CODE that
|
||||||
|
* is looked up here, never as the message itself.
|
||||||
|
*
|
||||||
|
* That indirection is the point. A `?message=` parameter is attacker-authored
|
||||||
|
* text rendered inside the sign-in screen: enough to forge "Your account was
|
||||||
|
* suspended — call this number." A code that is not in this table renders
|
||||||
|
* nothing at all, so the worst an attacker can put on the page is silence.
|
||||||
|
*
|
||||||
|
* Note what is absent: the email, the password, and any echo of user input.
|
||||||
|
* The redirect carries the fact of a failure and nothing else.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const LOGIN_ERROR_PARAM = 'error';
|
||||||
|
|
||||||
|
/** Code → the field it belongs to and what the user reads. */
|
||||||
|
const LOGIN_ERRORS: Record<string, LoginError> = {
|
||||||
|
email_required: {field: 'email', message: 'Invalid email or password.'},
|
||||||
|
email_invalid: {field: 'email', message: 'Invalid email or password.'},
|
||||||
|
password_required: {field: 'password', message: 'Invalid email or password.'},
|
||||||
|
invalid_credentials: {field: 'form', message: 'Invalid email or password.'},
|
||||||
|
malformed: {field: 'form', message: 'Sign-in failed. Please try again.'},
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoginErrorCode = keyof typeof LOGIN_ERRORS;
|
||||||
|
|
||||||
|
/** Unknown or absent codes resolve to null — render nothing, never guess. */
|
||||||
|
export function loginErrorFromCode(code: string | null): LoginError | null {
|
||||||
|
if (!code) return null;
|
||||||
|
return LOGIN_ERRORS[code] ?? null;
|
||||||
|
}
|
||||||
@@ -43,7 +43,42 @@ export function MessageBubble({message}: {message: ChatMessage}) {
|
|||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ChatMessageBubble variant={isAssistant ? 'ghost' : 'filled'}>
|
<ChatMessageBubble
|
||||||
|
variant={isAssistant ? 'ghost' : 'filled'}
|
||||||
|
/**
|
||||||
|
* The assistant bubble must have a DEFINITE width. This is the fix for
|
||||||
|
* report titles rendering one character per line.
|
||||||
|
*
|
||||||
|
* ChatMessage lays its row out with `display:flex; align-items:
|
||||||
|
* flex-start`, so the bubble is a flex item on the main axis and sizes
|
||||||
|
* shrink-to-fit — its width is asked of its contents. ResponseRenderer
|
||||||
|
* then sets `container-type: inline-size` to drive the report's
|
||||||
|
* container queries, and inline-axis size containment means the report
|
||||||
|
* contributes ZERO to that intrinsic measurement. The bubble asked how
|
||||||
|
* wide its content wanted to be, containment answered "nothing", and
|
||||||
|
* the bubble collapsed to its min-content width.
|
||||||
|
*
|
||||||
|
* Astryx's own `word-break: break-word` on the bubble is what made the
|
||||||
|
* collapse land on a single GLYPH rather than a single word: that
|
||||||
|
* value is defined as `word-break: normal` plus `overflow-wrap:
|
||||||
|
* anywhere`, and `anywhere` is the one wrapping mode that lets a break
|
||||||
|
* opportunity count against min-content size. Min-content therefore
|
||||||
|
* became the widest character — "S / t / o / r / e".
|
||||||
|
*
|
||||||
|
* Stating the width breaks the cycle: the bubble no longer measures
|
||||||
|
* its contents, so containment has nothing to poison.
|
||||||
|
*
|
||||||
|
* `max-w-full` is deliberate too. `styles.content` applies
|
||||||
|
* `max-width: max(80%, 280px)` to EVERY bubble including `ghost`,
|
||||||
|
* so an assistant report in the 440px rail was being capped around
|
||||||
|
* 302px — despite this component's contract that ghost turns are
|
||||||
|
* full-width. Tailwind's utilities layer is declared after
|
||||||
|
* `astryx-base` in globals.css, so these win over StyleX's
|
||||||
|
* `:not(#\#)` specificity hack by cascade layer rather than by
|
||||||
|
* out-specifying it.
|
||||||
|
*/
|
||||||
|
className={isAssistant ? 'w-full max-w-full min-w-0' : undefined}
|
||||||
|
>
|
||||||
{isThinking ? (
|
{isThinking ? (
|
||||||
<TypingIndicator />
|
<TypingIndicator />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -14,35 +14,38 @@ export function ChartBlock({charts}: {charts: ChartPartSpec[]}) {
|
|||||||
{charts.map((c, idx) => (
|
{charts.map((c, idx) => (
|
||||||
<div
|
<div
|
||||||
key={c.title || idx}
|
key={c.title || idx}
|
||||||
className="rounded-xl border border-border bg-card p-4 shadow-sm space-y-3"
|
className="rounded-xl border border-border bg-card p-4 shadow-sm space-y-3 overflow-hidden"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<Heading level={4} className="text-sm font-semibold text-primary">
|
<Heading level={4} className="text-base font-bold text-white">
|
||||||
{c.title}
|
{c.title}
|
||||||
</Heading>
|
</Heading>
|
||||||
{c.subtitle ? (
|
{c.subtitle ? (
|
||||||
<p className="text-xs text-secondary">{c.subtitle}</p>
|
<p className="text-sm font-medium text-zinc-300 mt-0.5">{c.subtitle}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-48 sm:h-56 w-full pt-2">
|
<div className="h-56 @sm/report:h-64 w-full max-w-full pt-2">
|
||||||
{c.chartType === 'area' ? (
|
{c.chartType === 'area' ? (
|
||||||
<AreaChartView
|
<AreaChartView
|
||||||
data={c.data}
|
data={c.data}
|
||||||
xKey={c.xKey}
|
xKey={c.xKey}
|
||||||
series={c.series}
|
series={c.series}
|
||||||
|
height="100%"
|
||||||
/>
|
/>
|
||||||
) : c.chartType === 'bar' ? (
|
) : c.chartType === 'bar' ? (
|
||||||
<BarChartView
|
<BarChartView
|
||||||
data={c.data}
|
data={c.data}
|
||||||
xKey={c.xKey}
|
xKey={c.xKey}
|
||||||
series={c.series}
|
series={c.series}
|
||||||
|
height="100%"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<LineChartView
|
<LineChartView
|
||||||
data={c.data}
|
data={c.data}
|
||||||
xKey={c.xKey}
|
xKey={c.xKey}
|
||||||
series={c.series}
|
series={c.series}
|
||||||
|
height="100%"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -48,16 +48,22 @@ export function InsightCardsBlock({insights}: {insights: ReportInsight[]}) {
|
|||||||
key={insight.id || insight.title}
|
key={insight.id || insight.title}
|
||||||
className={`rounded-xl border p-4 shadow-sm space-y-2 transition-colors ${meta.borderClass}`}
|
className={`rounded-xl border p-4 shadow-sm space-y-2 transition-colors ${meta.borderClass}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2">
|
{/*
|
||||||
<div className="flex items-center gap-2">
|
`flex-wrap` so the label and the badges drop to their own lines
|
||||||
<span className="text-base">{meta.icon}</span>
|
rather than compressing each other. Without it, a narrow panel
|
||||||
<span className="text-sm font-semibold text-primary">
|
set the title and "87% confidence" against a Badge that will not
|
||||||
|
shrink, and the title absorbed the entire deficit.
|
||||||
|
*/}
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-x-2 gap-y-1.5">
|
||||||
|
<div className="flex items-start gap-2 min-w-0">
|
||||||
|
<span className="text-lg shrink-0">{meta.icon}</span>
|
||||||
|
<span className="text-base font-bold text-white whitespace-normal break-words min-w-0">
|
||||||
{insight.title}
|
{insight.title}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
{insight.confidence ? (
|
{insight.confidence ? (
|
||||||
<span className="text-xs text-secondary font-mono">
|
<span className="text-sm font-semibold text-zinc-300 font-mono tabular-nums whitespace-nowrap">
|
||||||
{insight.confidence}% confidence
|
{insight.confidence}% confidence
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -65,14 +71,16 @@ export function InsightCardsBlock({insights}: {insights: ReportInsight[]}) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-secondary leading-relaxed">
|
<p className="text-sm font-medium text-zinc-200 leading-relaxed whitespace-normal break-words">
|
||||||
{insight.explanation}
|
{insight.explanation}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{insight.recommendedAction ? (
|
{insight.recommendedAction ? (
|
||||||
<div className="pt-1.5 flex items-center gap-2 text-xs text-primary font-medium">
|
<div className="pt-2 flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm font-semibold">
|
||||||
<span className="text-secondary font-normal">Action:</span>
|
<span className="text-amber-300 font-bold shrink-0">Action:</span>
|
||||||
<span>{insight.recommendedAction}</span>
|
<span className="text-white whitespace-normal break-words min-w-0">
|
||||||
|
{insight.recommendedAction}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,7 +38,26 @@ export function KpiGridBlock({kpis}: {kpis: ReportKpi[]}) {
|
|||||||
if (!kpis || kpis.length === 0) return null;
|
if (!kpis || kpis.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 w-full">
|
/**
|
||||||
|
* Intrinsic sizing, not a column count.
|
||||||
|
*
|
||||||
|
* This was `grid-cols-2 sm:grid-cols-3`, and `sm:` is a VIEWPORT query —
|
||||||
|
* always true on desktop — so three columns were forced into a 440px
|
||||||
|
* panel and each card landed at ~117px. The grid now states the only
|
||||||
|
* thing that is actually true of a KPI card (it needs ~180px to be
|
||||||
|
* legible) and lets the browser fit as many as the panel allows: two in
|
||||||
|
* the default rail, five or six when it is dragged wide. Cards reflow to
|
||||||
|
* the next row instead of shrinking past readability.
|
||||||
|
*
|
||||||
|
* `min(180px, 100%)` rather than a bare `180px` is what keeps this from
|
||||||
|
* trading one overflow for another: at a container narrower than the
|
||||||
|
* track minimum — the mobile slide-over, or a panel dragged to its 420px
|
||||||
|
* floor with deep padding — a rigid 180px track would push the grid wider
|
||||||
|
* than its parent and reintroduce horizontal scrolling. The `min()` lets
|
||||||
|
* the track collapse to the container in exactly that case and behave as
|
||||||
|
* a fixed minimum everywhere else.
|
||||||
|
*/
|
||||||
|
<div className="grid w-full max-w-full gap-3 grid-cols-[repeat(auto-fit,minmax(min(180px,100%),1fr))]">
|
||||||
{kpis.map((kpi) => {
|
{kpis.map((kpi) => {
|
||||||
const isUp = kpi.trendDirection === 'up' || (kpi.trend && kpi.trend.startsWith('+'));
|
const isUp = kpi.trendDirection === 'up' || (kpi.trend && kpi.trend.startsWith('+'));
|
||||||
const isDown = kpi.trendDirection === 'down' || (kpi.trend && (kpi.trend.startsWith('-') || kpi.trend.startsWith('−')));
|
const isDown = kpi.trendDirection === 'down' || (kpi.trend && (kpi.trend.startsWith('-') || kpi.trend.startsWith('−')));
|
||||||
@@ -46,25 +65,47 @@ export function KpiGridBlock({kpis}: {kpis: ReportKpi[]}) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={kpi.id || kpi.title}
|
key={kpi.id || kpi.title}
|
||||||
className="rounded-xl border border-border bg-card p-3.5 flex flex-col justify-between shadow-sm hover:border-border-strong transition-colors"
|
// `min-w-0` so the card may size to its grid track. A grid item
|
||||||
|
// defaults to `min-width: auto` — its min-CONTENT width — which
|
||||||
|
// means one long unbroken value silently widens the whole track
|
||||||
|
// and pushes the row past the panel.
|
||||||
|
className="rounded-xl border border-border bg-card p-3.5 flex flex-col justify-between gap-1 min-w-0 shadow-sm hover:border-border-strong transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<span className="text-xs font-medium text-secondary truncate">
|
{/*
|
||||||
|
`truncate` used to hide the consequence of the squeeze rather
|
||||||
|
than fix it — a card too narrow for "Indiranagar footfall"
|
||||||
|
simply clipped it to "Indiran…". With a real minimum width the
|
||||||
|
label has room, and on the rare long one it wraps by WORD:
|
||||||
|
`break-words` sets overflow-wrap without touching word-break,
|
||||||
|
so a long token can still break as a last resort but ordinary
|
||||||
|
prose never breaks mid-word.
|
||||||
|
*/}
|
||||||
|
<span className="text-xs font-semibold text-zinc-300 whitespace-normal break-words min-w-0 flex-1">
|
||||||
{kpi.title}
|
{kpi.title}
|
||||||
</span>
|
</span>
|
||||||
{kpi.iconName ? (
|
{kpi.iconName ? (
|
||||||
|
<span className="shrink-0">
|
||||||
<Icon icon={kpi.iconName as any} size="xsm" color="secondary" />
|
<Icon icon={kpi.iconName as any} size="xsm" color="secondary" />
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 flex items-baseline justify-between gap-2">
|
<div className="mt-2 flex flex-wrap items-baseline justify-between gap-x-2 gap-y-1 min-w-0 w-full">
|
||||||
<span className="text-lg sm:text-xl font-semibold tracking-tight text-primary">
|
{/*
|
||||||
|
`tabular-nums` so a streaming figure does not jitter as digits
|
||||||
|
land, and the size step is a CONTAINER query — the panel's
|
||||||
|
width decides whether there is room for the larger setting,
|
||||||
|
which is the question `sm:` was answering with the window's.
|
||||||
|
*/}
|
||||||
|
<span className="text-xl @xs/report:text-2xl font-bold tracking-tight text-white tabular-nums whitespace-nowrap shrink-0">
|
||||||
{kpi.value}
|
{kpi.value}
|
||||||
</span>
|
</span>
|
||||||
{kpi.trend ? (
|
{kpi.trend ? (
|
||||||
<span
|
<span
|
||||||
className={`text-xs font-medium ${
|
title={kpi.trend}
|
||||||
isUp ? 'text-emerald-400' : isDown ? 'text-rose-400' : 'text-secondary'
|
className={`text-xs font-bold truncate max-w-full min-w-0 ${
|
||||||
|
isUp ? 'text-emerald-400' : isDown ? 'text-rose-400' : 'text-zinc-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{kpi.trend}
|
{kpi.trend}
|
||||||
|
|||||||
@@ -19,7 +19,46 @@ export function ResponseRenderer({
|
|||||||
const textContent = report.summary || report.title || '';
|
const textContent = report.summary || report.title || '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 w-full my-1">
|
/**
|
||||||
|
* `@container/report` is the fix for the squeezed-report bug, and it is
|
||||||
|
* the whole reason the blocks below can be written once.
|
||||||
|
*
|
||||||
|
* The report renders inside a resizable panel (440px by default, 420px
|
||||||
|
* minimum, draggable to 85vw) that sits inside a desktop viewport. Every
|
||||||
|
* block underneath used to size itself with VIEWPORT breakpoints — `sm:`
|
||||||
|
* and friends — which on any desktop are unconditionally true. So a report
|
||||||
|
* in a 440px rail was laid out as though it had 640px+ to work with:
|
||||||
|
* KpiGridBlock took `sm:grid-cols-3` and split ~376px of usable width into
|
||||||
|
* three ~117px cards, of which padding ate 28px. A figure like
|
||||||
|
* "₹12,45,600" cannot set in 89px, so it wrapped down the card.
|
||||||
|
*
|
||||||
|
* A container query asks the right question. `width: 100%` was never the
|
||||||
|
* problem — the blocks always filled the panel; they were choosing their
|
||||||
|
* COLUMN COUNT from the window instead of from the space they were given.
|
||||||
|
* Named (`/report`) rather than anonymous so a future nested container —
|
||||||
|
* a two-up comparison, say — cannot silently retarget these variants.
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
* The wrapping trio overrides Astryx's `word-break: break-word` on the
|
||||||
|
* chat bubble (that value means `word-break: normal` + `overflow-wrap:
|
||||||
|
* anywhere`, and `anywhere` lets break opportunities shrink an element's
|
||||||
|
* min-content width down to a single glyph). Restating it as `normal` +
|
||||||
|
* `break-word` keeps the useful half — one unbreakable token, a long ID
|
||||||
|
* or URL, still breaks rather than overflowing — while making min-content
|
||||||
|
* the longest WORD. Both properties inherit, so this covers every title,
|
||||||
|
* cell and label below.
|
||||||
|
*
|
||||||
|
* What it does NOT do is protect against a collapsed ancestor, and that
|
||||||
|
* is worth stating because it is tempting to assume otherwise. Measured
|
||||||
|
* in a headless replica of this exact chain: with the bubble left
|
||||||
|
* shrink-to-fit, adding `word-break: normal` here changed nothing — the
|
||||||
|
* title still stacked one character per line, because inline-axis size
|
||||||
|
* containment makes the report contribute ZERO to the intrinsic width no
|
||||||
|
* matter how its text wraps. Only the definite width on the bubble (see
|
||||||
|
* MessageBubble) prevents that. This declaration guards the different
|
||||||
|
* case of a merely NARROW container, not a collapsed one.
|
||||||
|
*/
|
||||||
|
<div className="@container/report space-y-4 w-full max-w-full min-w-0 my-1 whitespace-normal [word-break:normal] [overflow-wrap:break-word]">
|
||||||
{/* 1. Summary / Title */}
|
{/* 1. Summary / Title */}
|
||||||
{report.summary ? (
|
{report.summary ? (
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function SummaryCard({
|
|||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border bg-card p-4 sm:p-5 shadow-sm space-y-3">
|
<div className="rounded-xl border border-border bg-card p-4 @sm/report:p-5 shadow-sm space-y-3 w-full max-w-full">
|
||||||
{title ? (
|
{title ? (
|
||||||
<Heading level={3} className="text-base font-semibold text-primary">
|
<Heading level={3} className="text-base font-semibold text-primary">
|
||||||
{title}
|
{title}
|
||||||
|
|||||||
@@ -16,20 +16,33 @@ export function TableBlock({tables}: {tables: TablePartSpec[]}) {
|
|||||||
>
|
>
|
||||||
{t.title ? (
|
{t.title ? (
|
||||||
<div className="p-4 border-b border-border bg-card">
|
<div className="p-4 border-b border-border bg-card">
|
||||||
<Heading level={4} className="text-sm font-semibold text-primary">
|
<Heading level={4} className="text-base font-bold text-white whitespace-normal break-words">
|
||||||
{t.title}
|
{t.title}
|
||||||
</Heading>
|
</Heading>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="overflow-x-auto w-full">
|
{/*
|
||||||
<table className="w-full text-left text-xs border-collapse">
|
The scroll container is what lets the table below refuse to be
|
||||||
|
crushed. `w-full` alone made the table a prisoner of the panel: at
|
||||||
|
five columns in ~376px the browser had to honour width:100%, so it
|
||||||
|
drove every column to its min-content width and long labels came
|
||||||
|
apart. Pairing a readable floor (`min-w-[34rem]`) with horizontal
|
||||||
|
scroll means a narrow panel scrolls a legible table instead of
|
||||||
|
displaying an illegible one — and a wide panel never scrolls,
|
||||||
|
because `w-full` still stretches it past the floor.
|
||||||
|
*/}
|
||||||
|
<div className="overflow-x-auto w-full max-w-full">
|
||||||
|
<table
|
||||||
|
data-ai-report
|
||||||
|
className="w-full min-w-full table-auto text-left text-sm border-collapse"
|
||||||
|
>
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border bg-muted/40 text-secondary font-medium">
|
<tr className="border-b border-zinc-700 bg-zinc-800/80 text-white font-bold">
|
||||||
{t.columns.map((col) => (
|
{t.columns.map((col) => (
|
||||||
<th
|
<th
|
||||||
key={col.key}
|
key={col.key}
|
||||||
className={`px-4 py-3 ${
|
className={`px-3.5 py-3.5 whitespace-nowrap text-sm font-bold text-white ${
|
||||||
col.align === 'end'
|
col.align === 'end'
|
||||||
? 'text-right'
|
? 'text-right'
|
||||||
: col.align === 'center'
|
: col.align === 'center'
|
||||||
@@ -42,9 +55,9 @@ export function TableBlock({tables}: {tables: TablePartSpec[]}) {
|
|||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border/60">
|
<tbody className="divide-y divide-zinc-800/80">
|
||||||
{t.rows.map((row, rIdx) => (
|
{t.rows.map((row, rIdx) => (
|
||||||
<tr key={rIdx} className="hover:bg-white/[0.02] transition-colors">
|
<tr key={rIdx} className="hover:bg-white/[0.04] transition-colors">
|
||||||
{t.columns.map((col) => {
|
{t.columns.map((col) => {
|
||||||
const val = row[col.key];
|
const val = row[col.key];
|
||||||
const valStr = String(val ?? '');
|
const valStr = String(val ?? '');
|
||||||
@@ -53,12 +66,12 @@ export function TableBlock({tables}: {tables: TablePartSpec[]}) {
|
|||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
key={col.key}
|
key={col.key}
|
||||||
className={`px-4 py-3 text-primary ${
|
className={`px-3.5 py-3.5 text-white font-semibold align-middle text-sm ${
|
||||||
col.align === 'end'
|
col.align === 'end'
|
||||||
? 'text-right'
|
? 'text-right tabular-nums whitespace-nowrap'
|
||||||
: col.align === 'center'
|
: col.align === 'center'
|
||||||
? 'text-center'
|
? 'text-center'
|
||||||
: 'text-left'
|
: 'text-left whitespace-normal break-words'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isStatus ? (
|
{isStatus ? (
|
||||||
|
|||||||
@@ -25,10 +25,10 @@ export function buildStoresTemplate(entities: ExtractedEntities): ReportPart {
|
|||||||
{key: 'status', header: 'Status', align: 'center'},
|
{key: 'status', header: 'Status', align: 'center'},
|
||||||
],
|
],
|
||||||
rows: [
|
rows: [
|
||||||
{store: 'Indiranagar Flagship', visitors: '12,400', revenue: '₹9,10,000', conversion: '23.8%', status: 'active'},
|
{store: 'Indiranagar Flagship', visitors: '12,400', revenue: '₹9.1L', conversion: '23.8%', status: 'active'},
|
||||||
{store: 'Koramangala 80ft', visitors: '7,800', revenue: '₹5,20,000', conversion: '21.4%', status: 'active'},
|
{store: 'Koramangala 80ft', visitors: '7,800', revenue: '₹5.2L', conversion: '21.4%', status: 'active'},
|
||||||
{store: 'Jayanagar 4th Block', visitors: '6,100', revenue: '₹4,00,000', conversion: '20.9%', status: 'active'},
|
{store: 'Jayanagar 4th Block', visitors: '6,100', revenue: '₹4.0L', conversion: '20.9%', status: 'active'},
|
||||||
{store: 'Whitefield Main', visitors: '4,200', revenue: '₹3,30,000', conversion: '18.2%', status: 'warning'},
|
{store: 'Whitefield Main', visitors: '4,200', revenue: '₹3.3L', conversion: '18.2%', status: 'warning'},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ import {ChartTooltip} from './ChartTooltip';
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export const AXIS_TICK = {
|
export const AXIS_TICK = {
|
||||||
fill: CHART.axis,
|
fill: '#e4e4e7',
|
||||||
fontSize: 11,
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export function chartFurniture({
|
export function chartFurniture({
|
||||||
@@ -54,7 +55,7 @@ export function chartFurniture({
|
|||||||
tick={AXIS_TICK}
|
tick={AXIS_TICK}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
width={44}
|
width={54}
|
||||||
tickFormatter={yFormat ? (v: number) => yFormat(v) : undefined}
|
tickFormatter={yFormat ? (v: number) => yFormat(v) : undefined}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
@@ -71,7 +72,7 @@ export function ChartFrame({
|
|||||||
height = 260,
|
height = 260,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
height?: number;
|
height?: number | `${number}%`;
|
||||||
children: React.ReactElement;
|
children: React.ReactElement;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export interface ChartViewProps<T> {
|
|||||||
xKey: Extract<keyof T, string>;
|
xKey: Extract<keyof T, string>;
|
||||||
series: Series<T>[];
|
series: Series<T>[];
|
||||||
/** Workspace default 260; Loyaly AI column 180; sparkline 40. */
|
/** Workspace default 260; Loyaly AI column 180; sparkline 40. */
|
||||||
height?: number;
|
height?: number | `${number}%`;
|
||||||
yFormat?: (v: number) => string;
|
yFormat?: (v: number) => string;
|
||||||
xFormat?: (v: string | number) => string;
|
xFormat?: (v: string | number) => string;
|
||||||
reference?: {
|
reference?: {
|
||||||
|
|||||||
Reference in New Issue
Block a user