Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

Profile

An account profile screen composed from touchcn components.

A presentational account screen: a large avatar header with a Pro badge, a three-up stats row, primary and secondary actions, an About card, grouped personal-info rows with icons, and a destructive delete row confirmed through a dialog. All data arrives as props; the screen emits editProfile, share and deleteAccount.

Installation

npx touchcn add profile

This also adds the components the block composes:

avatar · badge · button · card · dialog · list · navbar · page

Usage

<tcn-profile-block
  [name]="user().name"
  [handle]="user().handle"
  (editProfile)="editProfile()"
  (share)="share()"
  (deleteAccount)="deleteAccount()"
/>
import { TcnProfileBlock } from '@/components/blocks/profile';

<TcnProfileBlock
  name={user.name}
  handle={user.handle}
  onEditProfile={editProfile}
  onShare={share}
  onDeleteAccount={deleteAccount}
/>
<script setup lang="ts">
import { TcnProfileBlock } from '@/components/blocks/profile';
</script>

<template>
  <TcnProfileBlock
    :name="user.name"
    :handle="user.handle"
    @edit-profile="editProfile"
    @share="share"
    @delete-account="deleteAccount"
  />
</template>

Pass the profile fields (name, handle, avatarSrc, bio, email, phone, location) and the counters (posts, followers, following) as props. deleteAccount fires only after the user confirms the dialog.

Set showBack to render a back control in the navbar’s leading slot; it emits back (Angular) / onBack (React) when tapped — wire it to your router.

Source · Angular
export * from './tcn-profile-block';
import { booleanAttribute, Component, computed, input, output, signal } from '@angular/core';
import { TcnAvatar } from '@/components/ui/avatar';
import { TcnBadge } from '@/components/ui/badge';
import { TcnButton } from '@/components/ui/button';
import { TcnCard } from '@/components/ui/card';
import { TcnDialog } from '@/components/ui/dialog';
import { TcnList, TcnListItem } from '@/components/ui/list';
import { TcnNavbar } from '@/components/ui/navbar';
import { TcnPage } from '@/components/ui/page';

interface Stat {
  label: string;
  value: number;
}

/** Compact thousands formatting: 1200 → `1.2k`. */
function formatCount(value: number): string {
  if (value < 1000) {
    return `${value}`;
  }
  return `${(value / 1000).toFixed(value % 1000 === 0 ? 0 : 1)}k`;
}

/**
 * Profile screen — a presentational account surface. A large avatar header with
 * a Pro badge, a three-up stats row, primary/secondary actions, grouped personal
 * info, an About card and a destructive delete row confirmed through a dialog.
 * All data arrives as inputs; the screen emits `editProfile`, `share` and
 * `deleteAccount`.
 */
@Component({
  selector: 'tcn-profile-block',
  imports: [TcnPage, TcnNavbar, TcnAvatar, TcnBadge, TcnButton, TcnCard, TcnList, TcnListItem, TcnDialog],
  template: `
    <tcn-page>
      <tcn-navbar title="Profile">
        @if (showBack()) {
          <button navbar-leading type="button" class="tcn-navbar-back" aria-label="Back" (click)="back.emit()">
            <svg class="if-ios" width="11" height="18" viewBox="0 0 12 20" fill="none" aria-hidden="true">
              <path d="M10 2 2 10l8 8" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
            </svg>
            <svg class="if-md" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
              <path d="M20 12H4m0 0 6-6m-6 6 6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
            </svg>
          </button>
        }
      </tcn-navbar>
      <div class="mx-auto max-w-[480px] px-4 pt-20 pb-12">
        <div class="flex flex-col items-center text-center">
          <tcn-avatar size="lg" class="size-24 text-2xl" [src]="avatarSrc()" [initials]="initials()" [alt]="name()" />
          <div class="mt-3 flex items-center gap-2">
            <h1 class="text-xl font-semibold">{{ name() }}</h1>
            @if (pro()) {
              <tcn-badge variant="primary">Pro</tcn-badge>
            }
          </div>
          <p class="text-sm text-[var(--color-on-surface-variant)]">{{ handle() }}</p>
        </div>

        <div class="mt-6 grid grid-cols-3 gap-3">
          @for (stat of stats(); track stat.label) {
            <tcn-card class="text-center">
              <div class="text-xl font-bold">{{ format(stat.value) }}</div>
              <div class="mt-0.5 text-xs text-[var(--color-on-surface-variant)]">{{ stat.label }}</div>
            </tcn-card>
          }
        </div>

        <div class="mt-4 flex gap-3">
          <button tcnButton type="button" class="flex-1" (click)="editProfile.emit()">Edit profile</button>
          <button tcnButton variant="secondary" type="button" class="flex-1" (click)="share.emit()">Share</button>
        </div>

        <section class="mt-6">
          <h3 class="mb-1.5 px-1 text-xs font-semibold uppercase tracking-wide text-[var(--color-on-surface-variant)]">
            About
          </h3>
          <tcn-card>
            <p class="text-sm leading-relaxed text-[var(--color-on-surface)]">{{ bio() }}</p>
          </tcn-card>
        </section>

        <section class="mt-6">
          <h3 class="mb-1.5 px-1 text-xs font-semibold uppercase tracking-wide text-[var(--color-on-surface-variant)]">
            Personal info
          </h3>
          <tcn-list>
            <tcn-list-item>
              <span item-leading class="text-[var(--color-on-surface-variant)]">
                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
                  <rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.75" />
                  <path d="m3 7 9 6 9-6" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" />
                </svg>
              </span>
              {{ email() }}
              <span item-subtitle>Email</span>
            </tcn-list-item>
            <tcn-list-item>
              <span item-leading class="text-[var(--color-on-surface-variant)]">
                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
                  <path d="M5 4h3l2 5-2.5 1.5a11 11 0 0 0 5 5L14 13l5 2v3a2 2 0 0 1-2 2A15 15 0 0 1 3 6a2 2 0 0 1 2-2z" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round" />
                </svg>
              </span>
              {{ phone() }}
              <span item-subtitle>Phone</span>
            </tcn-list-item>
            <tcn-list-item>
              <span item-leading class="text-[var(--color-on-surface-variant)]">
                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
                  <path d="M12 21s7-5.5 7-11a7 7 0 1 0-14 0c0 5.5 7 11 7 11z" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round" />
                  <circle cx="12" cy="10" r="2.5" stroke="currentColor" stroke-width="1.75" />
                </svg>
              </span>
              {{ location() }}
              <span item-subtitle>Location</span>
            </tcn-list-item>
          </tcn-list>
        </section>

        <section class="mt-6">
          <tcn-list>
            <tcn-list-item class="cursor-pointer text-[var(--color-destructive)]" (click)="confirmDelete.set(true)">
              Delete account
            </tcn-list-item>
          </tcn-list>
        </section>
      </div>

      <tcn-dialog
        [(open)]="confirmDelete"
        heading="Delete account?"
        message="This permanently removes your account and all of its data. This action cannot be undone."
      >
        <button dialog-action tcnButton variant="ghost" type="button" (click)="confirmDelete.set(false)">Cancel</button>
        <button dialog-action tcnButton variant="destructive" type="button" (click)="onDelete()">Delete</button>
      </tcn-dialog>
    </tcn-page>
  `,
})
export class TcnProfileBlock {
  readonly name = input('Ada Lovelace');
  readonly handle = input('@ada');
  readonly avatarSrc = input('');
  readonly bio = input('Mathematician and writer. Building thoughtful software and the occasional analytical engine.');
  readonly email = input('ada@example.com');
  readonly phone = input('+1 (555) 010-0191');
  readonly location = input('London, UK');
  readonly pro = input(true);
  readonly posts = input(128);
  readonly followers = input(8400);
  readonly following = input(312);
  /** Renders a back control in the navbar's leading slot. */
  readonly showBack = input(false, { transform: booleanAttribute });

  readonly back = output<void>();
  readonly editProfile = output<void>();
  readonly share = output<void>();
  /** Fires after the user confirms the delete dialog. */
  readonly deleteAccount = output<void>();

  protected readonly confirmDelete = signal(false);

  protected readonly stats = computed<Stat[]>(() => [
    { label: 'Posts', value: this.posts() },
    { label: 'Followers', value: this.followers() },
    { label: 'Following', value: this.following() },
  ]);

  protected readonly initials = computed(() =>
    this.name()
      .split(/\s+/)
      .filter(Boolean)
      .slice(0, 2)
      .map((part) => part[0]?.toUpperCase() ?? '')
      .join(''),
  );

  protected format(value: number): string {
    return formatCount(value);
  }

  protected onDelete(): void {
    this.confirmDelete.set(false);
    this.deleteAccount.emit();
  }
}
Source · React
export * from './tcn-profile-block';
import { useState } from 'react';
import { TcnAvatar } from '@/components/ui/avatar';
import { TcnBadge } from '@/components/ui/badge';
import { TcnButton } from '@/components/ui/button';
import { TcnCard } from '@/components/ui/card';
import { TcnDialog } from '@/components/ui/dialog';
import { TcnList, TcnListItem } from '@/components/ui/list';
import { TcnNavbar } from '@/components/ui/navbar';
import { TcnPage } from '@/components/ui/page';

export interface TcnProfileBlockProps {
  name?: string;
  handle?: string;
  avatarSrc?: string;
  bio?: string;
  email?: string;
  phone?: string;
  location?: string;
  pro?: boolean;
  posts?: number;
  followers?: number;
  following?: number;
  /** Renders a back control in the navbar's leading slot. */
  showBack?: boolean;
  /** Fires when the navbar back control is activated. */
  onBack?(): void;
  onEditProfile?(): void;
  onShare?(): void;
  /** Fires after the user confirms the delete dialog. */
  onDeleteAccount?(): void;
}

/** Compact thousands formatting: 1200 → `1.2k`. */
function formatCount(value: number): string {
  if (value < 1000) {
    return `${value}`;
  }
  return `${(value / 1000).toFixed(value % 1000 === 0 ? 0 : 1)}k`;
}

function initialsOf(name: string): string {
  return name
    .split(/\s+/)
    .filter(Boolean)
    .slice(0, 2)
    .map((part) => part[0]?.toUpperCase() ?? '')
    .join('');
}

const MailIcon = () => (
  <span className="text-[var(--color-on-surface-variant)]">
    <svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" strokeWidth="1.75" />
      <path d="m3 7 9 6 9-6" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  </span>
);

const PhoneIcon = () => (
  <span className="text-[var(--color-on-surface-variant)]">
    <svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <path d="M5 4h3l2 5-2.5 1.5a11 11 0 0 0 5 5L14 13l5 2v3a2 2 0 0 1-2 2A15 15 0 0 1 3 6a2 2 0 0 1 2-2z" stroke="currentColor" strokeWidth="1.75" strokeLinejoin="round" />
    </svg>
  </span>
);

const PinIcon = () => (
  <span className="text-[var(--color-on-surface-variant)]">
    <svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <path d="M12 21s7-5.5 7-11a7 7 0 1 0-14 0c0 5.5 7 11 7 11z" stroke="currentColor" strokeWidth="1.75" strokeLinejoin="round" />
      <circle cx="12" cy="10" r="2.5" stroke="currentColor" strokeWidth="1.75" />
    </svg>
  </span>
);

/**
 * Profile screen — a presentational account surface. A large avatar header with
 * a Pro badge, a three-up stats row, primary/secondary actions, grouped personal
 * info, an About card and a destructive delete row confirmed through a dialog.
 * All data arrives as props; the screen emits `onEditProfile`, `onShare` and
 * `onDeleteAccount`.
 */
export function TcnProfileBlock({
  name = 'Ada Lovelace',
  handle = '@ada',
  avatarSrc,
  bio = 'Mathematician and writer. Building thoughtful software and the occasional analytical engine.',
  email = 'ada@example.com',
  phone = '+1 (555) 010-0191',
  location = 'London, UK',
  pro = true,
  posts = 128,
  followers = 8400,
  following = 312,
  showBack = false,
  onBack,
  onEditProfile,
  onShare,
  onDeleteAccount,
}: TcnProfileBlockProps) {
  const [confirmDelete, setConfirmDelete] = useState(false);

  const stats = [
    { label: 'Posts', value: posts },
    { label: 'Followers', value: followers },
    { label: 'Following', value: following },
  ];

  const handleDelete = () => {
    setConfirmDelete(false);
    onDeleteAccount?.();
  };

  return (
    <TcnPage>
      <TcnNavbar
        title="Profile"
        leading={
          showBack ? (
            <button type="button" className="tcn-navbar-back" aria-label="Back" onClick={() => onBack?.()}>
              <svg className="if-ios" width="11" height="18" viewBox="0 0 12 20" fill="none" aria-hidden="true">
                <path d="M10 2 2 10l8 8" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
              </svg>
              <svg className="if-md" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
                <path d="M20 12H4m0 0 6-6m-6 6 6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
              </svg>
            </button>
          ) : undefined
        }
      />
      <div className="mx-auto max-w-[480px] px-4 pt-20 pb-12">
        <div className="flex flex-col items-center text-center">
          <TcnAvatar size="lg" className="size-24 text-2xl" src={avatarSrc} initials={initialsOf(name)} alt={name} />
          <div className="mt-3 flex items-center gap-2">
            <h1 className="text-xl font-semibold">{name}</h1>
            {pro && <TcnBadge variant="primary">Pro</TcnBadge>}
          </div>
          <p className="text-sm text-[var(--color-on-surface-variant)]">{handle}</p>
        </div>

        <div className="mt-6 grid grid-cols-3 gap-3">
          {stats.map((stat) => (
            <TcnCard key={stat.label} className="text-center">
              <div className="text-xl font-bold">{formatCount(stat.value)}</div>
              <div className="mt-0.5 text-xs text-[var(--color-on-surface-variant)]">{stat.label}</div>
            </TcnCard>
          ))}
        </div>

        <div className="mt-4 flex gap-3">
          <TcnButton className="flex-1" onClick={() => onEditProfile?.()}>
            Edit profile
          </TcnButton>
          <TcnButton variant="secondary" className="flex-1" onClick={() => onShare?.()}>
            Share
          </TcnButton>
        </div>

        <section className="mt-6">
          <h3 className="mb-1.5 px-1 text-xs font-semibold uppercase tracking-wide text-[var(--color-on-surface-variant)]">
            About
          </h3>
          <TcnCard>
            <p className="text-sm leading-relaxed text-[var(--color-on-surface)]">{bio}</p>
          </TcnCard>
        </section>

        <section className="mt-6">
          <h3 className="mb-1.5 px-1 text-xs font-semibold uppercase tracking-wide text-[var(--color-on-surface-variant)]">
            Personal info
          </h3>
          <TcnList>
            <TcnListItem leading={<MailIcon />} subtitle="Email">
              {email}
            </TcnListItem>
            <TcnListItem leading={<PhoneIcon />} subtitle="Phone">
              {phone}
            </TcnListItem>
            <TcnListItem leading={<PinIcon />} subtitle="Location">
              {location}
            </TcnListItem>
          </TcnList>
        </section>

        <section className="mt-6">
          <TcnList>
            <TcnListItem
              className="cursor-pointer text-[var(--color-destructive)]"
              onClick={() => setConfirmDelete(true)}
            >
              Delete account
            </TcnListItem>
          </TcnList>
        </section>
      </div>

      <TcnDialog
        open={confirmDelete}
        onOpenChange={setConfirmDelete}
        heading="Delete account?"
        message="This permanently removes your account and all of its data. This action cannot be undone."
        actions={
          <>
            <TcnButton variant="ghost" onClick={() => setConfirmDelete(false)}>
              Cancel
            </TcnButton>
            <TcnButton variant="destructive" onClick={handleDelete}>
              Delete
            </TcnButton>
          </>
        }
      />
    </TcnPage>
  );
}
Source · Vue
<script setup lang="ts">
import { computed, ref } from 'vue';
import { TcnAvatar } from '@/components/ui/avatar';
import { TcnBadge } from '@/components/ui/badge';
import { TcnButton } from '@/components/ui/button';
import { TcnCard } from '@/components/ui/card';
import { TcnDialog } from '@/components/ui/dialog';
import { TcnList, TcnListItem } from '@/components/ui/list';
import { TcnNavbar } from '@/components/ui/navbar';
import { TcnPage } from '@/components/ui/page';

/**
 * Profile screen — a presentational account surface. A large avatar header with
 * a Pro badge, a three-up stats row, primary/secondary actions, grouped personal
 * info, an About card and a destructive delete row confirmed through a dialog.
 * All data arrives as props; the screen emits `editProfile`, `share` and
 * `deleteAccount`.
 */
const props = withDefaults(
  defineProps<{
    name?: string;
    handle?: string;
    avatarSrc?: string;
    bio?: string;
    email?: string;
    phone?: string;
    location?: string;
    pro?: boolean;
    posts?: number;
    followers?: number;
    following?: number;
    /** Renders a back control in the navbar's leading slot. */
    showBack?: boolean;
  }>(),
  {
    name: 'Ada Lovelace',
    handle: '@ada',
    bio: 'Mathematician and writer. Building thoughtful software and the occasional analytical engine.',
    email: 'ada@example.com',
    phone: '+1 (555) 010-0191',
    location: 'London, UK',
    pro: true,
    posts: 128,
    followers: 8400,
    following: 312,
    showBack: false,
  },
);

const emit = defineEmits<{
  /** Fires when the navbar back control is activated. */
  back: [];
  editProfile: [];
  share: [];
  /** Fires after the user confirms the delete dialog. */
  deleteAccount: [];
}>();

const confirmDelete = ref(false);

/** Compact thousands formatting: 1200 → `1.2k`. */
const formatCount = (value: number): string =>
  value < 1000 ? `${value}` : `${(value / 1000).toFixed(value % 1000 === 0 ? 0 : 1)}k`;

const initials = computed(() =>
  props.name
    .split(/\s+/)
    .filter(Boolean)
    .slice(0, 2)
    .map((part) => part[0]?.toUpperCase() ?? '')
    .join(''),
);

const stats = computed(() => [
  { label: 'Posts', value: props.posts },
  { label: 'Followers', value: props.followers },
  { label: 'Following', value: props.following },
]);

const handleDelete = (): void => {
  confirmDelete.value = false;
  emit('deleteAccount');
};
</script>

<template>
  <TcnPage>
    <TcnNavbar title="Profile">
      <template v-if="showBack" #leading>
        <button type="button" class="tcn-navbar-back" aria-label="Back" @click="emit('back')">
          <svg class="if-ios" width="11" height="18" viewBox="0 0 12 20" fill="none" aria-hidden="true">
            <path d="M10 2 2 10l8 8" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
          </svg>
          <svg class="if-md" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
            <path d="M20 12H4m0 0 6-6m-6 6 6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
          </svg>
        </button>
      </template>
    </TcnNavbar>
    <div class="mx-auto max-w-[480px] px-4 pt-20 pb-12">
      <div class="flex flex-col items-center text-center">
        <TcnAvatar size="lg" class="size-24 text-2xl" :src="avatarSrc" :initials="initials" :alt="name" />
        <div class="mt-3 flex items-center gap-2">
          <h1 class="text-xl font-semibold">{{ name }}</h1>
          <TcnBadge v-if="pro" variant="primary">Pro</TcnBadge>
        </div>
        <p class="text-sm text-[var(--color-on-surface-variant)]">{{ handle }}</p>
      </div>

      <div class="mt-6 grid grid-cols-3 gap-3">
        <TcnCard v-for="stat in stats" :key="stat.label" class="text-center">
          <div class="text-xl font-bold">{{ formatCount(stat.value) }}</div>
          <div class="mt-0.5 text-xs text-[var(--color-on-surface-variant)]">{{ stat.label }}</div>
        </TcnCard>
      </div>

      <div class="mt-4 flex gap-3">
        <TcnButton class="flex-1" @click="emit('editProfile')">Edit profile</TcnButton>
        <TcnButton variant="secondary" class="flex-1" @click="emit('share')">Share</TcnButton>
      </div>

      <section class="mt-6">
        <h3 class="mb-1.5 px-1 text-xs font-semibold uppercase tracking-wide text-[var(--color-on-surface-variant)]">
          About
        </h3>
        <TcnCard>
          <p class="text-sm leading-relaxed text-[var(--color-on-surface)]">{{ bio }}</p>
        </TcnCard>
      </section>

      <section class="mt-6">
        <h3 class="mb-1.5 px-1 text-xs font-semibold uppercase tracking-wide text-[var(--color-on-surface-variant)]">
          Personal info
        </h3>
        <TcnList>
          <TcnListItem>
            <template #leading>
              <span class="text-[var(--color-on-surface-variant)]">
                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
                  <rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.75" />
                  <path d="m3 7 9 6 9-6" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" />
                </svg>
              </span>
            </template>
            <template #subtitle>Email</template>
            {{ email }}
          </TcnListItem>
          <TcnListItem>
            <template #leading>
              <span class="text-[var(--color-on-surface-variant)]">
                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
                  <path d="M5 4h3l2 5-2.5 1.5a11 11 0 0 0 5 5L14 13l5 2v3a2 2 0 0 1-2 2A15 15 0 0 1 3 6a2 2 0 0 1 2-2z" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round" />
                </svg>
              </span>
            </template>
            <template #subtitle>Phone</template>
            {{ phone }}
          </TcnListItem>
          <TcnListItem>
            <template #leading>
              <span class="text-[var(--color-on-surface-variant)]">
                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
                  <path d="M12 21s7-5.5 7-11a7 7 0 1 0-14 0c0 5.5 7 11 7 11z" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round" />
                  <circle cx="12" cy="10" r="2.5" stroke="currentColor" stroke-width="1.75" />
                </svg>
              </span>
            </template>
            <template #subtitle>Location</template>
            {{ location }}
          </TcnListItem>
        </TcnList>
      </section>

      <section class="mt-6">
        <TcnList>
          <TcnListItem class="cursor-pointer text-[var(--color-destructive)]" @click="confirmDelete = true">
            Delete account
          </TcnListItem>
        </TcnList>
      </section>
    </div>

    <TcnDialog
      v-model:open="confirmDelete"
      heading="Delete account?"
      message="This permanently removes your account and all of its data. This action cannot be undone."
    >
      <template #actions>
        <TcnButton variant="ghost" @click="confirmDelete = false">Cancel</TcnButton>
        <TcnButton variant="destructive" @click="handleDelete">Delete</TcnButton>
      </template>
    </TcnDialog>
  </TcnPage>
</template>
export { default as TcnProfileBlock } from './TcnProfileBlock.vue';

Last updated on July 24, 2026

Was this page helpful?