1 estrella en GitHub = 1 componente nuevo.

Dar una estrella
App y Dashboardappdashboardsaasauthloginsign-inform

Auth Card

Inicio de sesión / registro a pantalla partida con SSO, mostrar contraseña, validación y panel de marca.

Vista Previa en Vivo

Lobby

Welcome back

Sign in to manage your property.

or with email

New to Lobby? Start a free trial

Uso

Copia y pega este código directamente en tu componente React:

tsx
import { AuthCard } from 'react-component-kit-ui/app';

<AuthCard mode="signin" brand="Lobby" successHref="/app/" />

Instalación Manual

Si no estás usando el paquete npm, crea un archivo nuevo y pega el siguiente código: src/components/AuthCard.tsx + AuthCard.css:

AuthCard.tsx
'use client';

import { useEffect, useRef } from 'react';
import './AuthCard.css';

/** class:list → className */
function cx(...args: unknown[]): string {
  const out: string[] = [];
  const walk = (v: unknown) => {
    if (!v) return;
    if (typeof v === 'string' || typeof v === 'number') out.push(String(v));
    else if (Array.isArray(v)) v.forEach(walk);
    else if (typeof v === 'object') for (const [k, on] of Object.entries(v as Record<string, unknown>)) if (on) out.push(k);
  };
  args.forEach(walk);
  return out.join(' ');
}

/**
 * AuthCard — Sign-in / sign-up split screen: form card (email, password with show/hide, remember
 * me, SSO buttons) next to a brand panel with a quote and key figures.
 *
 * @param {string} mode - 'signin' or 'signup'. Default 'signin'.
 * @param {string} brand - Product name. Default 'Lobby'.
 * @param {string} title - Heading. Defaults by mode.
 * @param {string} subtitle - Supporting text.
 * @param {string} action - Form action. With '#' it validates and redirects to `successHref`. Default '#'.
 * @param {string} successHref - Where to go after a demo sign-in. Default '#'.
 * @param {string[]} providers - SSO buttons. Default ['Google', 'Microsoft'].
 * @param {string} switchHref - Link to the other mode. Default '#'.
 * @param {{quote: string, author: string, role?: string}} testimonial - Brand panel quote (optional).
 * @param {Array<{value: string, label: string}>} stats - Brand panel figures.
 * @param {string} variant - Layout (split|centered). Default 'split'.
 * @param {string} className - Extra class.
 *
 * Accessibility: one h1; labelled fields with autocomplete (email, current-password /
 * new-password); errors are linked with aria-describedby and announced; the show-password
 * button is a toggle with aria-pressed.
 */
export interface AuthCardProps {
  mode?: 'signin' | 'signup';
  brand?: string;
  title?: string;
  subtitle?: string;
  action?: string;
  successHref?: string;
  providers?: string[];
  switchHref?: string;
  testimonial?: { quote: string; author: string; role?: string };
  stats?: Array<{ value: string; label: string }>;
  variant?: 'split' | 'centered';
  className?: string;
}

export function AuthCard({
  mode = 'signin',
  brand = 'Lobby',
  title = mode === 'signin' ? 'Welcome back' : 'Create your workspace',
  subtitle = mode === 'signin' ? 'Sign in to manage your property.' : 'Start your 14-day free trial — no card required.',
  action = '#',
  successHref = '#',
  providers = ['Google', 'Microsoft'],
  switchHref = '#',
  testimonial = { quote: 'We replaced three tools with Lobby. Check-in takes half the time and our occupancy is up 9% this season.', author: 'Lucía Ortega', role: 'GM, Hotel Mirador' },
  stats = [{ value: '2,400+', label: 'properties' }, { value: '38%', label: 'less admin time' }, { value: '4.9/5', label: 'support rating' }],
  variant = 'split',
  className = '',
}: AuthCardProps) {
  const isIn = mode === 'signin';

  // Ported from the Astro <script>: runs once after mount (DOM logic kept as in the Astro kit).
  const mounted = useRef(false);
  useEffect(() => {
    if (mounted.current) return;
    mounted.current = true;
    document.querySelectorAll<HTMLFormElement>('form[data-auth]:not([data-bound])').forEach((form) => {
        form.dataset.bound = '';
        const eye = form.querySelector<HTMLButtonElement>('[data-eye]');
        const pass = form.querySelector<HTMLInputElement>('#auth-pass');
        const error = form.querySelector<HTMLElement>('[data-error]')!;
        eye?.addEventListener('click', () => {
          const show = eye.getAttribute('aria-pressed') !== 'true';
          eye.setAttribute('aria-pressed', String(show));
          eye.setAttribute('aria-label', show ? 'Hide password' : 'Show password');
          if (pass) pass.type = show ? 'text' : 'password';
        });
        form.addEventListener('submit', (e) => {
          form.querySelectorAll('[aria-invalid]').forEach((n) => n.removeAttribute('aria-invalid'));
          const bad = Array.from(form.querySelectorAll<HTMLInputElement>('input[required]')).filter((i) => !i.checkValidity());
          if (bad.length) {
            e.preventDefault();
            bad.forEach((i) => i.setAttribute('aria-invalid', 'true'));
            error.textContent = bad[0].type === 'password' ? 'Your password needs at least 8 characters.' : 'Please check your email address.';
            bad[0].focus();
            return;
          }
          if (form.getAttribute('action') === '#') {
            e.preventDefault();
            const btn = form.querySelector<HTMLButtonElement>('[data-submit]')!;
            btn.disabled = true;
            btn.textContent = 'Signing in…';
            window.setTimeout(() => { window.location.href = form.dataset.success || '#'; }, 700);
          }
        });
      });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <>
      <div className="auth-cq" data-rk-149787="">
      <section className={cx(['auth', `auth--${variant}`, className])} data-rk-149787="">
        <div className="auth__form-side" data-rk-149787="">
          <form className="auth__card" action={action} method="post" noValidate data-auth data-success={successHref} data-rk-149787="">
            <p className="auth__brand" data-rk-149787=""><span aria-hidden="true" data-rk-149787="">{brand.slice(0, 1)}</span>{brand}</p>
            <h1 className="auth__title" data-rk-149787="">{title}</h1>
            {subtitle && <p className="auth__sub" data-rk-149787="">{subtitle}</p>}
      
            {providers.length > 0 && (
              <>
                <div className="auth__sso" data-rk-149787="">
                  {providers.map((p, __i: number) => <button key={__i} type="button" className="auth__sso-btn" data-rk-149787="">Continue with {p}</button>)}
                </div>
                <p className="auth__or" data-rk-149787=""><span data-rk-149787="">or with email</span></p>
              </>
            )}
      
            {!isIn && (
              <div className="auth__field" data-rk-149787="">
                <label htmlFor="auth-name" data-rk-149787="">Full name</label>
                <input id="auth-name" name="name" type="text" autoComplete="name" required data-rk-149787="" />
              </div>
            )}
            <div className="auth__field" data-rk-149787="">
              <label htmlFor="auth-email" data-rk-149787="">Work email</label>
              <input id="auth-email" name="email" type="email" autoComplete="email" required aria-describedby="auth-error" data-rk-149787="" />
            </div>
            <div className="auth__field" data-rk-149787="">
              <div className="auth__label-row" data-rk-149787="">
                <label htmlFor="auth-pass" data-rk-149787="">Password</label>
                {isIn && <a href="#" data-rk-149787="">Forgot password?</a>}
              </div>
              <div className="auth__pass" data-rk-149787="">
                <input id="auth-pass" name="password" type="password" autoComplete={isIn ? 'current-password' : 'new-password'} required minLength={8} aria-describedby="auth-error" data-rk-149787="" />
                <button type="button" className="auth__eye" aria-pressed="false" aria-label="Show password" data-eye data-rk-149787="">
                  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" data-rk-149787=""><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z" data-rk-149787="" /><circle cx="12" cy="12" r="3" data-rk-149787="" /></svg>
                </button>
              </div>
            </div>
            {isIn && (
              <label className="auth__remember" data-rk-149787=""><input type="checkbox" name="remember" defaultChecked data-rk-149787="" /> Keep me signed in</label>
            )}
            <p className="auth__error" id="auth-error" role="alert" data-error data-rk-149787=""></p>
            <button type="submit" className="auth__submit" data-submit data-rk-149787="">{isIn ? 'Sign in' : 'Create account'}</button>
            <p className="auth__switch" data-rk-149787="">{isIn ? 'New to ' + brand + '?' : 'Already have an account?'} <a href={switchHref} data-rk-149787="">{isIn ? 'Start a free trial' : 'Sign in'}</a></p>
          </form>
        </div>
      
        {variant === 'split' && (
          <aside className="auth__brand-side" aria-label={`About ${brand}`} data-rk-149787="">
            {testimonial && (
              <figure className="auth__quote" data-rk-149787="">
                <blockquote data-rk-149787=""><p data-rk-149787="">“{testimonial.quote}”</p></blockquote>
                <figcaption data-rk-149787=""><strong data-rk-149787="">{testimonial.author}</strong>{testimonial.role && <span data-rk-149787=""> · {testimonial.role}</span>}</figcaption>
              </figure>
            )}
            {stats.length > 0 && (
              <dl className="auth__stats" data-rk-149787="">{stats.map((s, __i: number) => <div key={__i} data-rk-149787=""><dt data-rk-149787="">{s.label}</dt><dd data-rk-149787="">{s.value}</dd></div>)}</dl>
            )}
          </aside>
        )}
      </section>
      </div>
    </>
  );
}

export default AuthCard;
AuthCard.css
/* AuthCard — ported from astro-component-kit (app/AuthCard.astro) */

.auth-cq:where([data-rk-149787]) { container-type: inline-size; }
.auth:where([data-rk-149787]) { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); min-height: min(100dvh, 860px); color: var(--c-text-2, #a3a3a3); }
.auth--centered:where([data-rk-149787]) { grid-template-columns: 1fr; }
.auth__form-side:where([data-rk-149787]) { display: grid; place-items: center; padding: clamp(1.5rem, 5vw, 4rem) var(--sp-5, 1.25rem); }
.auth__card:where([data-rk-149787]) { display: grid; gap: var(--sp-4, 1rem); width: 100%; max-width: 400px; }
.auth__brand:where([data-rk-149787]) { display: flex; align-items: center; gap: var(--sp-2, 0.5rem); margin: 0 0 var(--sp-4, 1rem); font-weight: 800; font-size: 1.05rem; color: var(--c-text-1, #fff); }
.auth__brand:where([data-rk-149787]) span:where([data-rk-149787]) { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 9px; background: var(--app-accent, var(--c-primary-light, #d4a017)); color: var(--c-on-primary, #050505); }
.auth__title:where([data-rk-149787]) { margin: 0; font-size: 1.8rem; font-weight: 800; letter-spacing: -0.03em; color: var(--c-text-1, #fff); }
.auth__sub:where([data-rk-149787]) { margin: -8px 0 0; font-size: 0.92rem; }
.auth__sso:where([data-rk-149787]) { display: grid; gap: var(--sp-2, 0.5rem); }
.auth__sso-btn:where([data-rk-149787]) { min-height: 44px; border-radius: 10px; border: 1px solid var(--c-border, rgba(255, 255, 255, 0.12)); background: var(--c-bg-1, #0a0a0a); color: var(--c-text-1, #fff); font: inherit; font-size: 0.88rem; font-weight: 600; cursor: pointer; }
.auth__sso-btn:hover:where([data-rk-149787]) { background: rgb(var(--ink-rgb, 255 255 255) / 0.05); }
.auth__or:where([data-rk-149787]) { display: flex; align-items: center; gap: var(--sp-3, 0.75rem); margin: 0; font-size: 0.75rem; color: var(--c-text-3, #737373); }
.auth__or:where([data-rk-149787])::before, .auth__or:where([data-rk-149787])::after { content: ''; flex: 1; height: 1px; background: var(--c-border, rgba(255, 255, 255, 0.1)); }
.auth__field:where([data-rk-149787]) { display: grid; gap: 6px; }
.auth__field:where([data-rk-149787]) label:where([data-rk-149787]), .auth__label-row:where([data-rk-149787]) label:where([data-rk-149787]) { font-size: 0.82rem; font-weight: 600; color: var(--c-text-1, #fff); }
.auth__label-row:where([data-rk-149787]) { display: flex; justify-content: space-between; align-items: baseline; }
.auth__label-row:where([data-rk-149787]) a:where([data-rk-149787]), .auth__switch:where([data-rk-149787]) a:where([data-rk-149787]) { font-size: 0.8rem; font-weight: 600; color: var(--app-accent, var(--c-primary-light, #d4a017)); }
.auth__field:where([data-rk-149787]) input:where([data-rk-149787]) { width: 100%; min-height: 46px; padding: 0 0.85rem; border-radius: 10px; border: 1px solid var(--c-border, rgba(255, 255, 255, 0.14)); background: var(--c-bg-1, #0a0a0a); color: var(--c-text-1, #fff); font: inherit; font-size: 0.95rem; }
.auth__field:where([data-rk-149787]) input:focus:where([data-rk-149787]) { outline: none; border-color: var(--app-accent, var(--c-primary-light, #d4a017)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--app-accent, var(--c-primary-light, #d4a017)) 20%, transparent); }
.auth__field:where([data-rk-149787]) input[aria-invalid='true']:where([data-rk-149787]) { border-color: var(--c-danger, #f87171); }
.auth__pass:where([data-rk-149787]) { position: relative; }
.auth__pass:where([data-rk-149787]) input:where([data-rk-149787]) { padding-right: 44px; }
.auth__eye:where([data-rk-149787]) { position: absolute; right: 4px; top: 50%; transform: translateY(-50%); display: grid; place-items: center; width: 38px; height: 38px; border: 0; border-radius: 8px; background: transparent; color: var(--c-text-3, #737373); cursor: pointer; }
.auth__eye[aria-pressed='true']:where([data-rk-149787]) { color: var(--app-accent, var(--c-primary-light, #d4a017)); }
.auth__remember:where([data-rk-149787]) { display: flex; align-items: center; gap: var(--sp-2, 0.5rem); font-size: 0.85rem; cursor: pointer; }
.auth__remember:where([data-rk-149787]) input:where([data-rk-149787]) { width: 16px; height: 16px; accent-color: var(--app-accent, var(--c-primary-light, #d4a017)); }
.auth__error:where([data-rk-149787]) { min-height: 1.2em; margin: -6px 0 0; font-size: 0.82rem; color: var(--c-danger, #f87171); }
.auth__error:empty:where([data-rk-149787]) { display: none; }
.auth__submit:where([data-rk-149787]) { min-height: 48px; border: 0; border-radius: 10px; background: var(--app-accent, var(--c-primary-light, #d4a017)); color: var(--c-on-primary, #050505); font: inherit; font-size: 0.95rem; font-weight: 700; cursor: pointer; }
.auth__submit:disabled:where([data-rk-149787]) { opacity: 0.7; cursor: progress; }
.auth__switch:where([data-rk-149787]) { margin: 0; font-size: 0.85rem; text-align: center; }
.auth:where([data-rk-149787]) :is(a, button, input):focus-visible:where([data-rk-149787]) { outline: 2px solid var(--app-accent, var(--c-primary-light, #d4a017)); outline-offset: 2px; }

.auth__brand-side:where([data-rk-149787]) {
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  gap: var(--sp-8, 2rem);
  padding: clamp(2rem, 5vw, 4rem);
  background:
    radial-gradient(circle at 20% 20%, color-mix(in srgb, var(--app-accent, var(--c-primary-light, #d4a017)) 45%, transparent), transparent 55%),
    radial-gradient(circle at 90% 80%, color-mix(in srgb, var(--chart-3, #199e70) 35%, transparent), transparent 50%),
    #0b1020;
  color: #fff;
}
.auth__quote:where([data-rk-149787]) { margin: 0; }
.auth__quote:where([data-rk-149787]) p:where([data-rk-149787]) { margin: 0; font-size: clamp(1.3rem, 2.4vw, 1.9rem); font-weight: 600; line-height: 1.35; letter-spacing: -0.02em; }
.auth__quote:where([data-rk-149787]) figcaption:where([data-rk-149787]) { margin-top: var(--sp-4, 1rem); font-size: 0.9rem; color: rgba(255, 255, 255, 0.75); }
.auth__quote:where([data-rk-149787]) strong:where([data-rk-149787]) { color: #fff; }
.auth__stats:where([data-rk-149787]) { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--sp-4, 1rem); margin: 0; padding-top: var(--sp-6, 1.5rem); border-top: 1px solid rgba(255, 255, 255, 0.2); }
.auth__stats:where([data-rk-149787]) div:where([data-rk-149787]) { display: flex; flex-direction: column-reverse; }
.auth__stats:where([data-rk-149787]) dt:where([data-rk-149787]) { font-size: 0.8rem; color: rgba(255, 255, 255, 0.75); }
.auth__stats:where([data-rk-149787]) dd:where([data-rk-149787]) { margin: 0; font-size: 1.5rem; font-weight: 800; }

@container (max-width: 860px) {
  .auth:where([data-rk-149787]) { grid-template-columns: 1fr; }
  .auth__brand-side:where([data-rk-149787]) { display: none; }
}

Información Rápida

Categoría
App y Dashboard
Nombre de archivo
AuthCard.tsx
Dependencias
Ninguna — React + CSS puro
Etiquetas
appdashboardsaasauthloginsign-inform