1 звезда на GitHub = 1 новый компонент.

Поставить звезду
Приложение и дашбордappdashboardsaasprofilecrmcustomercard

Guest Profile Card

Карточка CRM: аватар, уровень лояльности, контакты, статистика, предпочтения, ближайший заезд и заметки.

Живое превью

Elena Duarte

Gold memberGuest since Mar 2022

Stays
7
Nights
19
Lifetime spend
€4,820
Avg. rating
4.9

Upcoming stay

204 · Deluxe KingNov 4 – Nov 7Confirmed

Preferences

  • High floor
  • Feather-free pillows
  • Late check-out
  • Sparkling water

Staff notes

  • Celebrating anniversary — prepare welcome card.
  • Prefers WhatsApp for contact.

Использование

Скопируйте и вставьте этот код в ваш React-компонент:

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

<GuestProfileCard guest={{ name: 'Elena Duarte', email: 'elena@mail.com' }} tier="Gold" />

Ручная установка

Если вы не используете пакет npm, создайте файл и вставьте следующий код: src/components/GuestProfileCard.tsx + GuestProfileCard.css:

GuestProfileCard.tsx
'use client';

import type { CSSProperties } from 'react';
import { appIcon, initials, hueFrom } from './app-icons';
import './GuestProfileCard.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(' ');
}

/** "a: b; --c: d" → style object */
function st(css: string | undefined): CSSProperties {
  const o: Record<string, string> = {};
  for (const decl of String(css ?? '').split(';')) {
    const i = decl.indexOf(':');
    if (i < 0) continue;
    const k = decl.slice(0, i).trim();
    if (k) o[k.startsWith('--') ? k : k.replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = decl.slice(i + 1).trim();
  }
  return o as CSSProperties;
}

/**
 * GuestProfileCard — CRM profile for a guest or customer: avatar, loyalty tier, contact details,
 * lifetime stats, preference tags, upcoming stay and internal notes.
 *
 * @param {{name: string, email?: string, phone?: string, country?: string, since?: string}} guest - Identity and contact.
 * @param {string} tier - Loyalty tier label (optional). Default 'Gold'.
 * @param {Array<{label: string, value: string}>} stats - Lifetime figures (stays, nights, spend…).
 * @param {string[]} preferences - Preference tags.
 * @param {{room: string, dates: string, status?: string}} upcoming - Next stay (optional).
 * @param {string[]} notes - Staff notes (optional).
 * @param {Array<{label: string, href?: string, primary?: boolean}>} actions - Buttons in the header.
 * @param {string} variant - Style (card|plain). Default 'card'.
 * @param {string} className - Extra class.
 *
 * Accessibility: h2 with the guest name; contact items are real mailto/tel links; stats are a
 * description list; tags are a plain list.
 */
export interface GuestProfileCardProps {
  guest?: { name: string; email?: string; phone?: string; country?: string; since?: string };
  tier?: string;
  stats?: Array<{ label: string; value: string }>;
  preferences?: string[];
  upcoming?: { room: string; dates: string; status?: string };
  notes?: string[];
  actions?: Array<{ label: string; href?: string; primary?: boolean }>;
  variant?: 'card' | 'plain';
  className?: string;
}

export function GuestProfileCard({
  guest = { name: 'Elena Duarte', email: 'elena.duarte@mail.com', phone: '+34 612 345 678', country: 'Spain', since: 'Mar 2022' },
  tier = 'Gold',
  stats = [{ label: 'Stays', value: '7' }, { label: 'Nights', value: '19' }, { label: 'Lifetime spend', value: '€4,820' }, { label: 'Avg. rating', value: '4.9' }],
  preferences = ['High floor', 'Feather-free pillows', 'Late check-out', 'Sparkling water'],
  upcoming = { room: '204 · Deluxe King', dates: 'Nov 4 – Nov 7', status: 'Confirmed' },
  notes = ['Celebrating anniversary — prepare welcome card.', 'Prefers WhatsApp for contact.'],
  actions = [{ label: 'Message' }, { label: 'New booking', primary: true }],
  variant = 'card',
  className = '',
}: GuestProfileCardProps) {
  return (
    <>
      <article className={cx(['gp', `gp--${variant}`, className])} aria-labelledby="gp-name" data-rk-333a1a="">
        <header className="gp__head" data-rk-333a1a="">
          <span className="gp__avatar" style={st(`--h: ${hueFrom(guest.name)}`)} aria-hidden="true" data-rk-333a1a="">{initials(guest.name)}</span>
          <div className="gp__id" data-rk-333a1a="">
            <h2 className="gp__name" id="gp-name" data-rk-333a1a="">{guest.name}</h2>
            <p className="gp__sub" data-rk-333a1a="">
              {tier && <span className="gp__tier" data-rk-333a1a=""><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" data-rk-333a1a=""><g dangerouslySetInnerHTML={{ __html: appIcon('star') }} data-rk-333a1a="" /></svg>{tier} member</span>}
              {guest.since && <span data-rk-333a1a="">Guest since {guest.since}</span>}
            </p>
          </div>
          <div className="gp__actions" data-rk-333a1a="">
            {actions.map((a) => {
              const Tag = a.href ? 'a' : 'button';
              return <Tag className={cx(['gp__btn', { 'gp__btn--primary': a.primary }])} href={a.href} type={a.href ? undefined : 'button'} data-rk-333a1a="">{a.label}</Tag>;
            })}
          </div>
        </header>
      
        <ul className="gp__contact" data-rk-333a1a="">
          {guest.email && <li data-rk-333a1a=""><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" data-rk-333a1a=""><g dangerouslySetInnerHTML={{ __html: appIcon('mail') }} data-rk-333a1a="" /></svg><a href={`mailto:${guest.email}`} data-rk-333a1a="">{guest.email}</a></li>}
          {guest.phone && <li data-rk-333a1a=""><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" data-rk-333a1a=""><g dangerouslySetInnerHTML={{ __html: appIcon('phone') }} data-rk-333a1a="" /></svg><a href={`tel:${guest.phone.replace(/\s/g, '')}`} data-rk-333a1a="">{guest.phone}</a></li>}
          {guest.country && <li data-rk-333a1a=""><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" data-rk-333a1a=""><g dangerouslySetInnerHTML={{ __html: appIcon('plane') }} data-rk-333a1a="" /></svg>{guest.country}</li>}
        </ul>
      
        <dl className="gp__stats" data-rk-333a1a="">
          {stats.map((s, __i: number) => <div key={__i} data-rk-333a1a=""><dt data-rk-333a1a="">{s.label}</dt><dd data-rk-333a1a="">{s.value}</dd></div>)}
        </dl>
      
        {upcoming && (
          <div className="gp__block" data-rk-333a1a="">
            <h3 data-rk-333a1a="">Upcoming stay</h3>
            <p className="gp__stay" data-rk-333a1a=""><strong data-rk-333a1a="">{upcoming.room}</strong><span data-rk-333a1a="">{upcoming.dates}</span>{upcoming.status && <em data-rk-333a1a="">{upcoming.status}</em>}</p>
          </div>
        )}
        {preferences.length > 0 && (
          <div className="gp__block" data-rk-333a1a="">
            <h3 data-rk-333a1a="">Preferences</h3>
            <ul className="gp__tags" data-rk-333a1a="">{preferences.map((p, __i: number) => <li key={__i} data-rk-333a1a="">{p}</li>)}</ul>
          </div>
        )}
        {notes.length > 0 && (
          <div className="gp__block" data-rk-333a1a="">
            <h3 data-rk-333a1a="">Staff notes</h3>
            <ul className="gp__notes" data-rk-333a1a="">{notes.map((n, __i: number) => <li key={__i} data-rk-333a1a="">{n}</li>)}</ul>
          </div>
        )}
      </article>
    </>
  );
}

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

.gp:where([data-rk-333a1a]) { container-type: inline-size; display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--sp-5, 1.25rem); min-width: 0; }
.gp--card:where([data-rk-333a1a]) { padding: var(--sp-6, 1.5rem); border-radius: var(--r-lg, 16px); border: 1px solid var(--c-border, rgba(255, 255, 255, 0.08)); background: var(--c-bg-1, #0a0a0a); }
.gp__head:where([data-rk-333a1a]) { display: flex; align-items: center; gap: var(--sp-4, 1rem); }
.gp__avatar:where([data-rk-333a1a]) { display: grid; place-items: center; flex-shrink: 0; width: 56px; height: 56px; border-radius: 16px; background: hsl(var(--h) 50% 45%); color: #fff; font-size: 1.05rem; font-weight: 800; }
.gp__id:where([data-rk-333a1a]) { flex: 1; min-width: 0; }
.gp__name:where([data-rk-333a1a]) { margin: 0; font-size: 1.25rem; font-weight: 800; letter-spacing: -0.02em; color: var(--c-text-1, #fff); }
.gp__sub:where([data-rk-333a1a]) { display: flex; flex-wrap: wrap; gap: 4px 12px; margin: 4px 0 0; font-size: 0.8rem; color: var(--c-text-3, #737373); }
.gp__tier:where([data-rk-333a1a]) { display: inline-flex; align-items: center; gap: 4px; color: var(--c-warning, #fbbf24); font-weight: 700; }
.gp__actions:where([data-rk-333a1a]) { display: flex; gap: var(--sp-2, 0.5rem); }
.gp__btn:where([data-rk-333a1a]) { display: inline-flex; align-items: center; min-height: 38px; padding: 0 0.85rem; border-radius: 10px; border: 1px solid var(--c-border, rgba(255, 255, 255, 0.12)); background: transparent; color: var(--c-text-1, #fff); font: inherit; font-size: 0.82rem; font-weight: 600; text-decoration: none; cursor: pointer; }
.gp__btn:hover:where([data-rk-333a1a]) { background: rgb(var(--ink-rgb, 255 255 255) / 0.05); color: var(--c-text-1, #fff); }
.gp__btn--primary:where([data-rk-333a1a]) { border-color: transparent; background: var(--app-accent, var(--c-primary-light, #d4a017)); color: var(--c-on-primary, #050505); }
.gp__btn--primary:hover:where([data-rk-333a1a]) { background: var(--app-accent, var(--c-primary-light, #d4a017)); color: var(--c-on-primary, #050505); filter: brightness(1.08); }
.gp:where([data-rk-333a1a]) :is(a, button):focus-visible:where([data-rk-333a1a]) { outline: 2px solid var(--app-accent, var(--c-primary-light, #d4a017)); outline-offset: 2px; }

.gp__contact:where([data-rk-333a1a]) { display: flex; flex-wrap: wrap; gap: var(--sp-2, 0.5rem) var(--sp-5, 1.25rem); margin: 0; padding: 0; list-style: none; font-size: 0.85rem; color: var(--c-text-2, #a3a3a3); }
.gp__contact:where([data-rk-333a1a]) li:where([data-rk-333a1a]) { display: inline-flex; align-items: center; gap: 6px; }
.gp__contact:where([data-rk-333a1a]) a:where([data-rk-333a1a]) { color: var(--c-text-1, #fff); text-decoration: none; }
.gp__contact:where([data-rk-333a1a]) a:hover:where([data-rk-333a1a]) { text-decoration: underline; }
.gp__stats:where([data-rk-333a1a]) { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; margin: 0; border-radius: 12px; overflow: hidden; background: var(--c-border, rgba(255, 255, 255, 0.08)); border: 1px solid var(--c-border, rgba(255, 255, 255, 0.08)); }
.gp__stats:where([data-rk-333a1a]) div:where([data-rk-333a1a]) { display: flex; flex-direction: column-reverse; padding: var(--sp-3, 0.75rem) var(--sp-4, 1rem); background: var(--c-bg-1, #0a0a0a); }
.gp__stats:where([data-rk-333a1a]) dt:where([data-rk-333a1a]) { font-size: 0.72rem; color: var(--c-text-3, #737373); }
.gp__stats:where([data-rk-333a1a]) dd:where([data-rk-333a1a]) { margin: 0; font-size: 1.2rem; font-weight: 800; color: var(--c-text-1, #fff); font-variant-numeric: tabular-nums; }
.gp__block:where([data-rk-333a1a]) h3:where([data-rk-333a1a]) { margin: 0 0 var(--sp-2, 0.5rem); font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--c-text-3, #737373); }
.gp__stay:where([data-rk-333a1a]) { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; margin: 0; padding: var(--sp-3, 0.75rem) var(--sp-4, 1rem); border-radius: 10px; background: color-mix(in srgb, var(--app-accent, var(--c-primary-light, #d4a017)) 8%, transparent); font-size: 0.85rem; color: var(--c-text-2, #a3a3a3); }
.gp__stay:where([data-rk-333a1a]) strong:where([data-rk-333a1a]) { color: var(--c-text-1, #fff); }
.gp__stay:where([data-rk-333a1a]) em:where([data-rk-333a1a]) { margin-left: auto; font-style: normal; font-size: 0.75rem; font-weight: 700; color: var(--app-accent, var(--c-primary-light, #d4a017)); }
.gp__tags:where([data-rk-333a1a]) { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; list-style: none; }
.gp__tags:where([data-rk-333a1a]) li:where([data-rk-333a1a]) { padding: 4px 10px; border-radius: 999px; border: 1px solid var(--c-border, rgba(255, 255, 255, 0.1)); font-size: 0.78rem; color: var(--c-text-1, #fff); }
.gp__notes:where([data-rk-333a1a]) { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }
.gp__notes:where([data-rk-333a1a]) li:where([data-rk-333a1a]) { padding: var(--sp-2, 0.5rem) var(--sp-3, 0.75rem); border-left: 2px solid var(--c-warning, #fbbf24); background: rgb(var(--ink-rgb, 255 255 255) / 0.03); font-size: 0.82rem; color: var(--c-text-2, #a3a3a3); }

@container (max-width: 560px) {
  .gp__head:where([data-rk-333a1a]) { flex-wrap: wrap; }
  .gp__actions:where([data-rk-333a1a]) { width: 100%; }
  .gp__btn:where([data-rk-333a1a]) { flex: 1; justify-content: center; }
  .gp__stats:where([data-rk-333a1a]) { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

Краткая информация

Категория
Приложение и дашборд
Имя файла
GuestProfileCard.tsx
Зависимости
Нет — чистый React + CSS
Теги
appdashboardsaasprofilecrmcustomercard