Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions ui/src/components/AuthFormField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { JSXElement, Show, JSX } from 'solid-js'

type InputAttrs = JSX.InputHTMLAttributes<HTMLInputElement> & {
'data-cy'?: string
}

interface AuthFormFieldProps {
label?: string
icon: JSXElement
error?: string
inputProps: InputAttrs
}

/**
* Auth-page input field that pairs an icon, an input, and an inline error.
* Used by the Login and Register pages, which share the `register-*` styles.
*/
export function AuthFormField(props: AuthFormFieldProps): JSXElement {
return (
<div class="register-field">
<Show when={props.label}>
<label class="register-field-label">{props.label}</label>
</Show>
<div class="register-input-wrap">
<span class="register-input-icon">{props.icon}</span>
<input
{...props.inputProps}
class={props.error ? 'register-input-error' : props.inputProps.class}
/>
</div>
<Show when={props.error}>
<div class="register-field-error">{props.error}</div>
</Show>
</div>
)
}
21 changes: 21 additions & 0 deletions ui/src/components/BillingStatCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import clsx from 'clsx'
import { JSXElement } from 'solid-js'

interface BillingStatCardProps {
value: JSXElement
label: JSXElement
/** Extra classes for the value text (e.g. to colour the trial-days countdown). */
valueClass?: string
}

/** One cell of the 3-column stats grid shown inside billing plan cards. */
export function BillingStatCard(props: BillingStatCardProps): JSXElement {
return (
<div class="bg-white/70 dark:bg-base-100/50 rounded-lg p-3 text-center">
<p class={clsx('font-extrabold text-lg', props.valueClass)}>
{props.value}
</p>
<p class="text-[11px] font-semibold text-success/80">{props.label}</p>
</div>
)
}
59 changes: 59 additions & 0 deletions ui/src/components/ConfirmModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { JSXElement, Show } from 'solid-js'

import { Alert } from './Alert'
import { Button } from './Button'
import { Modal, ModalBaseProps } from './Modal'
import { useLocale } from '../context/LocaleProvider'

type DaisyUIColor = 'primary' | 'secondary' | 'error' | 'warning' | 'success'

interface ConfirmModalProps extends ModalBaseProps {
title: string
message?: JSXElement
confirmLabel: string
cancelLabel?: string
confirmColor?: DaisyUIColor
isLoading?: boolean
errorMessage?: string | null
confirmDataCy?: string
onConfirm: () => void
children?: JSXElement
}

/**
* Generic confirmation modal used for destructive/irreversible actions
* (delete, cancel subscription, leave workspace, remove member, etc.).
*/
export function ConfirmModal(props: ConfirmModalProps): JSXElement {
const { t } = useLocale()

return (
<Modal title={props.title} isOpen={props.isOpen} onClose={props.onClose}>
<Show when={props.message}>
<p class="py-4">{props.message}</p>
</Show>

{props.children}

<Show when={props.errorMessage}>
<Alert type="error" message={props.errorMessage!} />
</Show>

<div class="modal-action">
<Button
label={props.cancelLabel ?? t('cancel')}
variant="ghost"
onClick={() => props.onClose()}
disabled={props.isLoading}
/>
<Button
label={props.confirmLabel}
color={props.confirmColor ?? 'error'}
isLoading={props.isLoading}
onClick={() => props.onConfirm()}
dataCy={props.confirmDataCy}
/>
</div>
</Modal>
)
}
60 changes: 60 additions & 0 deletions ui/src/components/MarketingAuthLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { JSXElement, ParentProps } from 'solid-js'
import { A } from '@solidjs/router'

import { AppLogo } from './auth-icons'
import { useLocale } from '../context/LocaleProvider'

interface MarketingAuthLayoutProps extends ParentProps {
/** Right-side content of the topbar (e.g. "already have an account?" link, invite badge). */
topbarRight?: JSXElement
/** Optional override for the left-side logo block. */
logo?: JSXElement
/** Headline content displayed on the marketing (left) side. */
headline: JSXElement
/** Subtitle text rendered below the headline. */
subtitle: JSXElement
/** Right-side panel content (form, header, etc.). */
formPanel: JSXElement
}

/**
* Two-column marketing/auth layout used by the public Login and Register pages.
* The left column shows branding/headline, the right column hosts the form.
*/
export function MarketingAuthLayout(
props: MarketingAuthLayoutProps
): JSXElement {
const { t } = useLocale()

return (
<div class="register-page">
<div class="register-topbar">
<A href="/login" class="register-logo">
{props.logo ?? (
<>
<div class="register-logo-mark">
<AppLogo />
</div>
<span class="register-logo-text">{t('my_solid_app')}</span>
</>
)}
</A>

{props.topbarRight}
</div>

<div class="register-main">
<div class="register-split">
<div class="register-left">
<div class="register-left-inner">
<h1 class="register-left-headline">{props.headline}</h1>
<p class="register-left-sub">{props.subtitle}</p>
</div>
</div>

<div class="register-right">{props.formPanel}</div>
</div>
</div>
</div>
)
}
3 changes: 2 additions & 1 deletion ui/src/components/Pagination.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { JSXElement, Show } from 'solid-js'

import { useLocale } from '../context/LocaleProvider'
import { IconButton } from './Button'
import { Spinner } from './Spinner'

interface PaginationProps {
page: number
Expand Down Expand Up @@ -30,7 +31,7 @@ export function Pagination(props: PaginationProps): JSXElement {
<p class="w-2">
<Show
when={props.totalPages !== undefined}
fallback={<span class="loading loading-ball loading-xs" />}
fallback={<Spinner size="xs" />}
>
{props.totalPages}
</Show>
Expand Down
31 changes: 31 additions & 0 deletions ui/src/components/PlanBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import clsx from 'clsx'
import { JSXElement } from 'solid-js'

type PlanBadgeColor = 'success' | 'primary'

const COLOR_CLASSES: Record<PlanBadgeColor, string> = {
success: 'text-success bg-success/15',
primary: 'text-primary bg-primary/15',
}

interface PlanBadgeProps {
label?: string
color?: PlanBadgeColor
size?: 'sm' | 'md'
}

/** Small "Pro" pill shown next to paid/exempt workspaces. */
export function PlanBadge(props: PlanBadgeProps): JSXElement {
return (
<span
class={clsx(
'inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider rounded-full',
props.size === 'sm' ? 'px-1.5 py-0.5' : 'px-2.5 py-1',
COLOR_CLASSES[props.color ?? 'success']
)}
>
<i class="fa-solid fa-star text-[8px]" />
{props.label ?? 'Pro'}
</span>
)
}
15 changes: 3 additions & 12 deletions ui/src/components/RegisterModal.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { JSXElement, Show } from 'solid-js'
import { useNavigate } from '@solidjs/router'

import { minLength, pattern, email, required } from '@modular-forms/solid'
import { email, required } from '@modular-forms/solid'

import { register, RegisterUserData } from '../api'

Expand All @@ -14,7 +14,7 @@ import { Modal, ModalBaseProps } from './Modal'
import { Alert } from './Alert'
import { Button } from './Button'
import { createFormState } from '../form_helpers'
import { mustMatch } from '../validators'
import { mustMatch, passwordRules } from '../validators'

export function RegisterModal(props: ModalBaseProps): JSXElement {
const { t } = useLocale()
Expand Down Expand Up @@ -68,16 +68,7 @@ export function RegisterModal(props: ModalBaseProps): JSXElement {
)}
</Field>

<Field
name="password"
validate={[
minLength(8, t('your_password_must_have_8_characters_or_more')),
pattern(/[A-Z]/, t('your_password_must_have_1_uppercase_letter')),
pattern(/[a-z]/, t('your_password_must_have_1_lowercase_letter')),
pattern(/[0-9]/, t('your_password_must_have_1_digit')),
pattern(/[\W]/, t('your_password_must_have_1_special_character')),
]}
>
<Field name="password" validate={passwordRules(t)}>
{(field, props) => (
<TextInput
{...props}
Expand Down
47 changes: 47 additions & 0 deletions ui/src/components/Spinner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import clsx from 'clsx'
import { JSXElement } from 'solid-js'

type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
type SpinnerStyle = 'ball' | 'spinner'

interface SpinnerProps {
size?: SpinnerSize
variant?: SpinnerStyle
color?: 'primary' | 'success' | 'error' | 'warning' | 'info'
class?: string
}

export function Spinner(props: SpinnerProps): JSXElement {
return (
<span
class={clsx(
'loading',
`loading-${props.variant ?? 'ball'}`,
`loading-${props.size ?? 'md'}`,
props.color && `text-${props.color}`,
props.class
)}
/>
)
}

export function FullScreenSpinner(props: SpinnerProps): JSXElement {
return (
<div class="flex items-center justify-center min-h-screen">
<Spinner
size={props.size ?? 'lg'}
color={props.color ?? 'primary'}
variant={props.variant}
class={props.class}
/>
</div>
)
}

export function CenteredSpinner(props: SpinnerProps): JSXElement {
return (
<div class={clsx('flex justify-center', props.class)}>
<Spinner size={props.size} color={props.color} variant={props.variant} />
</div>
)
}
47 changes: 47 additions & 0 deletions ui/src/components/StatusBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import clsx from 'clsx'
import { JSXElement } from 'solid-js'

type BannerType = 'info' | 'success' | 'warning' | 'error'

const BANNER_CONFIG: Record<BannerType, { tint: string; icon: string }> = {
info: { tint: 'bg-info/10 border-info/20 text-info', icon: 'fa-circle-info' },
success: {
tint: 'bg-primary/10 border-primary/20 text-primary',
icon: 'fa-circle-check',
},
warning: {
tint: 'bg-warning/10 border-warning/20 text-warning',
icon: 'fa-circle-exclamation',
},
error: {
tint: 'bg-error/10 border-error/20 text-error',
icon: 'fa-circle-xmark',
},
}

interface StatusBannerProps {
type: BannerType
message: JSXElement
class?: string
}

/**
* Compact inline status pill (border + tinted background) used for
* billing/checkout feedback messages.
*/
export function StatusBanner(props: StatusBannerProps): JSXElement {
const cfg = () => BANNER_CONFIG[props.type]

return (
<div
class={clsx(
'flex items-center gap-2 px-3 py-2.5 border rounded-lg text-sm font-medium',
cfg().tint,
props.class
)}
>
<i class={clsx('fa-solid text-xs', cfg().icon)} />
<span>{props.message}</span>
</div>
)
}
Loading
Loading