Todos os componentes

AI Prompt Templates

AI Prompt Templates é um componente para React e Tailwind CSS, da biblioteca HextaUI, com licença MIT. Copia e cola no teu projeto.

HextaUI MIT other

O que é

AI Prompt Templates é um componente para React e Tailwind CSS, da biblioteca HextaUI, com licença MIT. Copia e cola no teu projeto.

Como usar

Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.

Abrir no catálogo

Cinco cópias grátis por mês. Sem cartão.

Não precisa de pacotes além do que o projeto já tem

Licença

Este componente vem de HextaUI e é redistribuído sob a licença MIT, que permite uso comercial. O aviso de copyright viaja com o código.

AI Prompt Templates

registry/new-york/blocks/ai/ai-prompt-templates.tsx
"use client";

import {
  BookOpen,
  Eye,
  Heart,
  Loader2,
  Plus,
  Search,
  Sparkles,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { Badge } from "@/registry/new-york/ui/badge";
import { Button } from "@/registry/new-york/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/registry/new-york/ui/card";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "@/registry/new-york/ui/dialog";
import { Field, FieldContent, FieldLabel } from "@/registry/new-york/ui/field";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/registry/new-york/ui/input-group";
import { Separator } from "@/registry/new-york/ui/separator";

export interface PromptTemplate {
  id: string;
  name: string;
  description: string;
  category: string;
  prompt: string;
  variables?: Array<{
    name: string;
    label: string;
    placeholder?: string;
    required?: boolean;
  }>;
  isFavorite?: boolean;
  isPopular?: boolean;
  usageCount?: number;
  author?: string;
  tags?: string[];
  icon?: React.ComponentType<{ className?: string }>;
}

export interface AIPromptTemplatesProps {
  templates?: PromptTemplate[];
  categories?: string[];
  onSelect?: (
    template: PromptTemplate,
    filledVariables?: Record<string, string>
  ) => void;
  onFavorite?: (templateId: string, isFavorite: boolean) => Promise<void>;
  onCreate?: () => void;
  className?: string;
  showSearch?: boolean;
  showCategories?: boolean;
  showFavorites?: boolean;
}

const defaultCategories = [
  "All",
  "Writing",
  "Code",
  "Analysis",
  "Creative",
  "Research",
  "Business",
];

const categoryIcons: Record<
  string,
  React.ComponentType<{ className?: string }>
> = {
  Writing: BookOpen,
  Code: Sparkles,
  Analysis: Sparkles,
  Creative: Sparkles,
  Research: BookOpen,
  Business: Sparkles,
};

function fillTemplatePrompt(
  prompt: string,
  variables: Record<string, string>
): string {
  let filled = prompt;
  Object.entries(variables).forEach(([key, value]) => {
    filled = filled.replace(new RegExp(`\\{${key}\\}`, "g"), value);
  });
  return filled;
}

interface TemplateHeaderProps {
  onCreate?: () => void;
}

function TemplateHeader({ onCreate }: TemplateHeaderProps) {
  return (
    <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
      <div className="flex flex-col gap-1">
        <CardTitle>Prompt Templates</CardTitle>
        <CardDescription>
          Browse and use pre-built prompt templates
        </CardDescription>
      </div>
      {onCreate && (
        <Button
          className="min-h-[44px] w-full min-w-[32px] sm:min-h-[32px] sm:w-auto"
          onClick={onCreate}
          type="button"
        >
          <Plus className="size-4" />
          Create Template
        </Button>
      )}
    </div>
  );
}

interface TemplateSearchProps {
  value: string;
  onChange: (value: string) => void;
  resultCount?: number;
}

function TemplateSearch({ value, onChange, resultCount }: TemplateSearchProps) {
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if (
        (e.metaKey || e.ctrlKey) &&
        e.key === "k" &&
        !e.shiftKey &&
        document.activeElement !== inputRef.current
      ) {
        e.preventDefault();
        inputRef.current?.focus();
      }
    };

    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
  }, []);

  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent<HTMLInputElement>) => {
      if (e.key === "Escape") {
        onChange("");
        inputRef.current?.blur();
      }
    },
    [onChange]
  );

  return (
    <div className="relative">
      <InputGroup>
        <InputGroupAddon>
          <Search aria-hidden="true" className="size-4" />
        </InputGroupAddon>
        <InputGroupInput
          aria-label="Search templates"
          onChange={(e) => onChange(e.target.value)}
          onKeyDown={handleKeyDown}
          placeholder="Search templates…"
          ref={inputRef}
          type="search"
          value={value}
        />
      </InputGroup>
      {value && resultCount !== undefined && (
        <p className="mt-2 text-muted-foreground text-xs" role="status">
          {resultCount} {resultCount === 1 ? "result" : "results"} found
        </p>
      )}
    </div>
  );
}

interface CategoryFilterProps {
  categories: string[];
  selectedCategory: string;
  onCategoryChange: (category: string) => void;
  showFavorites: boolean;
  showFavoritesOnly: boolean;
  onToggleFavorites: () => void;
}

function CategoryFilter({
  categories,
  selectedCategory,
  onCategoryChange,
  showFavorites,
  showFavoritesOnly,
  onToggleFavorites,
}: CategoryFilterProps) {
  return (
    <div
      aria-label="Filter templates by category"
      className="flex flex-wrap items-center gap-2"
      role="tablist"
    >
      {showFavorites && (
        <Button
          aria-label={
            showFavoritesOnly ? "Show all templates" : "Show favorites only"
          }
          className={cn(
            "min-h-[32px] gap-2 px-3 py-1.5 sm:h-auto sm:min-h-[10px]",
            showFavoritesOnly
              ? "border border-border bg-accent text-accent-foreground"
              : "bg-muted text-muted-foreground hover:bg-muted/80"
          )}
          onClick={onToggleFavorites}
          type="button"
          variant="ghost"
        >
          <Heart aria-hidden="true" className="size-3.5" />
          <span className="text-xs">Favorites</span>
        </Button>
      )}
      {categories.map((category) => {
        const Icon = categoryIcons[category] || Sparkles;
        const isSelected = selectedCategory === category;
        return (
          <Button
            aria-label={`Filter by ${category} category`}
            aria-pressed={undefined}
            className={cn(
              "min-h-[32px] gap-2 px-3 py-1.5 sm:h-auto sm:min-h-[10px]",
              isSelected
                ? "border border-border bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground"
                : "bg-muted text-muted-foreground hover:bg-muted/80"
            )}
            key={category}
            onClick={() => onCategoryChange(category)}
            role={undefined}
            type="button"
            variant="ghost"
          >
            {category !== "All" && (
              <Icon aria-hidden="true" className="size-3.5" />
            )}
            <span className="text-xs">{category}</span>
          </Button>
        );
      })}
    </div>
  );
}

interface EmptyStateProps {
  message: string;
  icon?: React.ComponentType<{ className?: string }>;
  action?: React.ReactNode;
}

function EmptyState({
  message,
  icon: Icon = BookOpen,
  action,
}: EmptyStateProps) {
  return (
    <div className="flex flex-col items-center justify-center gap-4 py-12 text-center">
      <div className="flex size-12 items-center justify-center rounded-full bg-muted">
        <Icon aria-hidden="true" className="size-6 text-muted-foreground" />
      </div>
      <p className="text-muted-foreground text-sm">{message}</p>
      {action}
    </div>
  );
}

interface TemplateCardProps {
  template: PromptTemplate;
  onSelect: (template: PromptTemplate) => void;
  onFavorite?: (templateId: string, isFavorite: boolean) => Promise<void>;
  isFavoriting: boolean;
  optimisticFavorite?: boolean;
  index: number;
  isFocused: boolean;
  onFocus: () => void;
}

function TemplateCard({
  template,
  onSelect,
  onFavorite,
  isFavoriting,
  optimisticFavorite,
  index,
  isFocused,
  onFocus,
}: TemplateCardProps) {
  const cardRef = useRef<HTMLDivElement>(null);
  const Icon = template.icon || categoryIcons[template.category] || Sparkles;
  const hasVariables = template.variables && template.variables.length > 0;
  const isFavorite = optimisticFavorite ?? template.isFavorite ?? false;

  useEffect(() => {
    if (isFocused && cardRef.current) {
      cardRef.current.scrollIntoView({ block: "nearest", behavior: "smooth" });
    }
  }, [isFocused]);

  const handleFavorite = useCallback(
    async (e: React.MouseEvent) => {
      e.stopPropagation();
      if (!onFavorite) return;
      await onFavorite(template.id, !isFavorite);
    },
    [onFavorite, template.id, isFavorite]
  );

  const handleSelect = useCallback(() => {
    onSelect(template);
  }, [onSelect, template]);

  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent) => {
      if (e.key === "Enter" || e.key === " ") {
        e.preventDefault();
        handleSelect();
      }
    },
    [handleSelect]
  );

  return (
    <article
      aria-label={`Template: ${template.name}`}
      className={cn(
        "group flex flex-col gap-4 rounded-lg border bg-card p-5 transition-all focus-within:border-border focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 hover:border-border hover:shadow-sm",
        isFocused && "border-border ring-2 ring-ring ring-offset-2"
      )}
      onKeyDown={handleKeyDown}
      ref={cardRef}
      role="listitem"
      tabIndex={0}
    >
      <div className="flex items-center gap-3">
        <div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground transition-colors group-hover:bg-muted/80">
          <Icon aria-hidden="true" className="size-5" />
        </div>
        <div className="flex min-w-0 flex-1 flex-col justify-center">
          <div className="flex flex-wrap items-center justify-between gap-2">
            <h3 className="wrap-break-word font-semibold text-base leading-tight">
              {template.name}
            </h3>
            <div className="flex shrink-0 items-center gap-1.5">
              {template.isPopular && (
                <Badge
                  aria-label="Popular template"
                  className="text-xs"
                  variant="secondary"
                >
                  Popular
                </Badge>
              )}
              {onFavorite && (
                <button
                  aria-label={`${isFavorite ? "Remove from" : "Add to"} favorites`}
                  className="min-h-[44px] min-w-[32px] rounded-md p-1.5 transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:min-h-[32px]"
                  disabled={isFavoriting}
                  onClick={handleFavorite}
                  type="button"
                >
                  {isFavoriting ? (
                    <Loader2
                      aria-hidden="true"
                      className="size-4 animate-spin text-muted-foreground"
                    />
                  ) : (
                    <Heart
                      aria-hidden="true"
                      className={cn(
                        "size-4 transition-colors",
                        isFavorite
                          ? "fill-red-500 text-red-500"
                          : "text-muted-foreground group-hover:text-foreground"
                      )}
                    />
                  )}
                </button>
              )}
            </div>
          </div>
          <p className="wrap-break-word line-clamp-2 text-muted-foreground text-sm">
            {template.description}
          </p>
        </div>
      </div>

      <div className="flex flex-wrap items-center gap-2">
        {template.tags && template.tags.length > 0 && (
          <>
            {template.tags.slice(0, 2).map((tag) => (
              <Badge className="text-xs" key={tag} variant="outline">
                {tag}
              </Badge>
            ))}
            {template.tags.length > 2 && (
              <span
                aria-label={`${template.tags.length - 2} more tags`}
                className="text-muted-foreground text-xs"
              >
                +{template.tags.length - 2}
              </span>
            )}
          </>
        )}
        {template.usageCount !== undefined && (
          <span className="font-tabular-nums text-muted-foreground text-xs">
            {template.usageCount} {template.usageCount === 1 ? "use" : "uses"}
          </span>
        )}
        {hasVariables && (
          <Badge className="text-xs" variant="secondary">
            {template.variables!.length} variable
            {template.variables!.length !== 1 ? "s" : ""}
          </Badge>
        )}
      </div>

      <div className="flex items-center gap-2">
        <Button
          aria-label={`Use template: ${template.name}`}
          className="min-h-[44px] flex-1 sm:min-h-[32px]"
          onClick={handleSelect}
          type="button"
          variant="outline"
        >
          {hasVariables ? "Configure & Use" : "Use Template"}
        </Button>
      </div>
    </article>
  );
}

interface TemplateDialogProps {
  template: PromptTemplate | null;
  variables: Record<string, string>;
  onVariablesChange: (variables: Record<string, string>) => void;
  onClose: () => void;
  onUse: () => void;
  canUse: boolean;
}

function TemplateDialog({
  template,
  variables,
  onVariablesChange,
  onClose,
  onUse,
  canUse,
}: TemplateDialogProps) {
  const [showPreview, setShowPreview] = useState(false);
  const firstInputRef = useRef<HTMLInputElement>(null);

  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent) => {
      if (e.key === "Escape") {
        onClose();
      }
    },
    [onClose]
  );

  useEffect(() => {
    if (template && template.variables && template.variables.length > 0) {
      setTimeout(() => {
        firstInputRef.current?.focus();
      }, 100);
    }
  }, [template]);

  if (!template) return null;

  const Icon = template.icon || categoryIcons[template.category] || Sparkles;
  const previewPrompt = fillTemplatePrompt(template.prompt, variables);

  return (
    <Dialog onOpenChange={(open) => !open && onClose()} open={!!template}>
      <DialogContent
        className="flex flex-col gap-4 p-4 sm:max-w-2xl sm:gap-6 sm:p-6"
        onKeyDown={handleKeyDown}
      >
        <DialogHeader className="shrink-0">
          <div className="flex items-start gap-3">
            <div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
              <Icon aria-hidden="true" className="size-5" />
            </div>
            <div className="flex min-w-0 flex-1 flex-col gap-2 text-left">
              <DialogTitle className="wrap-break-word">
                {template.name}
              </DialogTitle>
              <DialogDescription className="wrap-break-word">
                {template.description}
              </DialogDescription>
            </div>
          </div>
        </DialogHeader>

        <div className="flex flex-1 flex-col gap-6 overflow-y-auto">
          {template.variables && template.variables.length > 0 ? (
            <div className="flex flex-col gap-4">
              <h3 className="font-medium text-sm">Fill in the details</h3>
              <div className="flex flex-col gap-4">
                {template.variables.map((variable, index) => (
                  <Field key={variable.name}>
                    <FieldLabel htmlFor={`${template.id}-${variable.name}`}>
                      {variable.label}
                      {variable.required && (
                        <span
                          aria-label="required"
                          className="text-destructive"
                        >
                          {" "}
                          *
                        </span>
                      )}
                    </FieldLabel>
                    <FieldContent>
                      <InputGroup>
                        <InputGroupInput
                          id={`${template.id}-${variable.name}`}
                          onChange={(e) => {
                            onVariablesChange({
                              ...variables,
                              [variable.name]: e.target.value,
                            });
                          }}
                          placeholder={
                            variable.placeholder ||
                            `Enter ${variable.label.toLowerCase()}…`
                          }
                          ref={index === 0 ? firstInputRef : undefined}
                          type="text"
                          value={variables[variable.name] || ""}
                        />
                      </InputGroup>
                    </FieldContent>
                  </Field>
                ))}
              </div>
            </div>
          ) : null}

          <Separator />

          <div className="flex flex-col gap-3">
            <div className="flex items-center justify-between">
              <h3 className="font-medium text-sm">Preview</h3>
              <Button
                aria-expanded={showPreview}
                aria-label={showPreview ? "Hide preview" : "Show preview"}
                className="h-auto min-h-[44px] gap-2 px-2 py-1 sm:min-h-[32px]"
                onClick={() => setShowPreview(!showPreview)}
                type="button"
                variant="ghost"
              >
                <Eye aria-hidden="true" className="size-4" />
                <span className="text-xs">
                  {showPreview ? "Hide" : "Show"} Preview
                </span>
              </Button>
            </div>
            {showPreview && (
              <div className="rounded-lg border bg-muted/30 p-4">
                <p className="wrap-break-word whitespace-pre-wrap text-sm">
                  {previewPrompt || template.prompt}
                </p>
              </div>
            )}
          </div>
        </div>

        <DialogFooter className="shrink-0">
          <Button
            className="min-h-[44px] sm:min-h-[32px]"
            onClick={onClose}
            type="button"
            variant="outline"
          >
            Cancel
          </Button>
          <Button
            className="min-h-[44px] sm:min-h-[32px]"
            disabled={!canUse}
            onClick={onUse}
            type="button"
          >
            Use Template
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

export default function AIPromptTemplates({
  templates = [],
  categories = defaultCategories,
  onSelect,
  onFavorite,
  onCreate,
  className,
  showSearch = true,
  showCategories = true,
  showFavorites = true,
}: AIPromptTemplatesProps) {
  const [searchQuery, setSearchQuery] = useState("");
  const [selectedCategory, setSelectedCategory] = useState<string>("All");
  const [showFavoritesOnly, setShowFavoritesOnly] = useState(false);
  const [selectedTemplate, setSelectedTemplate] =
    useState<PromptTemplate | null>(null);
  const [templateVariables, setTemplateVariables] = useState<
    Record<string, string>
  >({});
  const [isFavoriting, setIsFavoriting] = useState<string | null>(null);
  const [focusedIndex, setFocusedIndex] = useState<number>(-1);
  const [optimisticFavorites, setOptimisticFavorites] = useState<
    Record<string, boolean>
  >({});

  const filteredTemplates = useMemo(() => {
    let filtered = templates;

    if (showFavoritesOnly) {
      filtered = filtered.filter((t) => {
        const isFavorite = optimisticFavorites[t.id] ?? t.isFavorite ?? false;
        return isFavorite;
      });
    }

    if (selectedCategory !== "All") {
      filtered = filtered.filter((t) => t.category === selectedCategory);
    }

    if (searchQuery.trim()) {
      const query = searchQuery.toLowerCase();
      filtered = filtered.filter(
        (t) =>
          t.name.toLowerCase().includes(query) ||
          t.description.toLowerCase().includes(query) ||
          t.prompt.toLowerCase().includes(query) ||
          t.tags?.some((tag) => tag.toLowerCase().includes(query))
      );
    }

    return filtered.sort((a, b) => {
      if (a.isPopular && !b.isPopular) return -1;
      if (!a.isPopular && b.isPopular) return 1;
      const aFavorite = optimisticFavorites[a.id] ?? a.isFavorite ?? false;
      const bFavorite = optimisticFavorites[b.id] ?? b.isFavorite ?? false;
      if (aFavorite && !bFavorite) return -1;
      if (!aFavorite && bFavorite) return 1;
      return (b.usageCount || 0) - (a.usageCount || 0);
    });
  }, [
    templates,
    selectedCategory,
    searchQuery,
    showFavoritesOnly,
    optimisticFavorites,
  ]);

  useEffect(() => {
    if (focusedIndex >= filteredTemplates.length) {
      setFocusedIndex(-1);
    }
  }, [focusedIndex, filteredTemplates.length]);

  const handleSelect = useCallback(
    (template: PromptTemplate) => {
      if (template.variables && template.variables.length > 0) {
        setSelectedTemplate(template);
        setTemplateVariables({});
      } else {
        onSelect?.(template);
      }
    },
    [onSelect]
  );

  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if (
        e.target instanceof HTMLInputElement ||
        e.target instanceof HTMLTextAreaElement
      ) {
        return;
      }

      if (e.key === "ArrowDown") {
        e.preventDefault();
        setFocusedIndex((prev) =>
          prev < filteredTemplates.length - 1 ? prev + 1 : 0
        );
      } else if (e.key === "ArrowUp") {
        e.preventDefault();
        setFocusedIndex((prev) =>
          prev > 0 ? prev - 1 : filteredTemplates.length - 1
        );
      } else if (e.key === "Enter" && focusedIndex >= 0) {
        e.preventDefault();
        const template = filteredTemplates[focusedIndex];
        if (template) {
          handleSelect(template);
        }
      }
    };

    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
  }, [filteredTemplates, focusedIndex, handleSelect]);

  const handleUseTemplate = useCallback(() => {
    if (!selectedTemplate) return;
    onSelect?.(selectedTemplate, templateVariables);
    setSelectedTemplate(null);
    setTemplateVariables({});
  }, [selectedTemplate, templateVariables, onSelect]);

  const handleFavorite = useCallback(
    async (templateId: string, isFavorite: boolean) => {
      if (!onFavorite) return;

      setOptimisticFavorites((prev) => ({
        ...prev,
        [templateId]: isFavorite,
      }));

      setIsFavoriting(templateId);
      try {
        await onFavorite(templateId, isFavorite);
      } catch (error) {
        setOptimisticFavorites((prev) => {
          const next = { ...prev };
          delete next[templateId];
          return next;
        });
      } finally {
        setIsFavoriting(null);
      }
    },
    [onFavorite]
  );

  const canUseTemplate =
    !selectedTemplate?.variables ||
    selectedTemplate.variables.every(
      (v) => !v.required || templateVariables[v.name]
    );

  if (templates.length === 0) {
    return (
      <Card className={cn("w-full shadow-xs", className)}>
        <CardHeader>
          <TemplateHeader onCreate={onCreate} />
        </CardHeader>
        <CardContent>
          <EmptyState
            action={
              onCreate ? (
                <Button
                  className="min-h-[44px] sm:min-h-[32px]"
                  onClick={onCreate}
                  type="button"
                >
                  <Plus className="size-4" />
                  Create Template
                </Button>
              ) : undefined
            }
            message="No templates available"
          />
        </CardContent>
      </Card>
    );
  }

  return (
    <>
      <Card className={cn("w-full shadow-xs", className)}>
        <CardHeader>
          <div className="flex flex-col gap-4">
            <TemplateHeader onCreate={onCreate} />
            {showSearch && (
              <TemplateSearch
                onChange={setSearchQuery}
                resultCount={filteredTemplates.length}
                value={searchQuery}
              />
            )}
            {showCategories && (
              <CategoryFilter
                categories={categories}
                onCategoryChange={setSelectedCategory}
                onToggleFavorites={() =>
                  setShowFavoritesOnly(!showFavoritesOnly)
                }
                selectedCategory={selectedCategory}
                showFavorites={showFavorites}
                showFavoritesOnly={showFavoritesOnly}
              />
            )}
          </div>
        </CardHeader>
        <CardContent>
          {filteredTemplates.length === 0 ? (
            <EmptyState
              message={
                searchQuery.trim()
                  ? "No templates match your search"
                  : showFavoritesOnly
                    ? "No favorite templates"
                    : "No templates match your filters"
              }
            />
          ) : (
            <ul
              aria-label="Prompt templates"
              className="grid gap-4"
              role="list"
            >
              {filteredTemplates.map((template, index) => (
                <TemplateCard
                  index={index}
                  isFavoriting={isFavoriting === template.id}
                  isFocused={focusedIndex === index}
                  key={template.id}
                  onFavorite={onFavorite ? handleFavorite : undefined}
                  onFocus={() => setFocusedIndex(index)}
                  onSelect={handleSelect}
                  optimisticFavorite={optimisticFavorites[template.id]}
                  template={template}
                />
              ))}
            </ul>
          )}
        </CardContent>
      </Card>

      <TemplateDialog
        canUse={canUseTemplate}
        onClose={() => {
          setSelectedTemplate(null);
          setTemplateVariables({});
        }}
        onUse={handleUseTemplate}
        onVariablesChange={setTemplateVariables}
        template={selectedTemplate}
        variables={templateVariables}
      />
    </>
  );
}

Componentes parecidos