Noqta
  • Accueil
  • Services
  • À propos
  • Écrits
  • Se connecter
écrits/tutorial/2026/05
● Tutorial8 mai 2026·28 min

Vercel AI Elements : composants React prêts à l'emploi pour les interfaces de chat IA dans Next.js

Arrêtez de réécrire à la main les bulles de chat, les boutons de défilement et les volets de raisonnement. Vercel AI Elements livre toutes les primitives nécessaires à une interface IA en production, sous forme de composants façon shadcn que vous possédez. Ce tutoriel branche Conversation, Message, PromptInput, Reasoning et Tool dans un chat Next.js 15 avec AI SDK 5 — pièces jointes, sélecteur de modèle et raisonnement en streaming inclus.

Équipe Noqta
Équipe Noqta
Author
·EN · FR · AR

Chaque application IA livrée en 2025 a fini par réinventer la même interface de chat : un conteneur de défilement qui s'épingle automatiquement en bas, des bulles de message qui changent selon le rôle, un textarea qui grandit, un bouton d'envoi qui se transforme en bouton stop pendant le streaming, un panneau de raisonnement repliable, des chips de pièces jointes. Rien de tout cela n'est difficile, mais l'ensemble dévore un sprint et le résultat reste légèrement bancal.

Vercel AI Elements est la réponse à ce problème. C'est une bibliothèque de composants — bâtie sur shadcn/ui et accordée pour AI SDK — qui livre chacune de ces primitives sous forme de composants React installables que vous possédez dans votre dépôt. Vous lancez une commande CLI, le code source atterrit dans components/ai-elements, et vous le branchez à useChat. À la fin de ce tutoriel, vous aurez une application de chat Next.js 15 avec réponses en streaming, volets de raisonnement, pièces jointes, sélecteur de modèle et bouton de recherche web — sans écrire la moindre ligne d'UI à partir de zéro.

AI Elements n'est pas une boîte noire. Contrairement à la plupart des bibliothèques de chat, AI Elements ne se livre pas via npm install. Il utilise le pattern shadcn : la CLI copie de vrais fichiers source dans votre projet. Vous pouvez tout éditer, tout restyler, supprimer ce dont vous n'avez pas besoin. Vercel maintient le registry, vous possédez le code.

Ce que vous allez apprendre

À la fin de ce tutoriel, vous saurez :

  • Installer les composants AI Elements dans un projet Next.js 15 App Router via la CLI du registry
  • Construire une route /api/chat avec les helpers streamText et toUIMessageStreamResponse de AI SDK 5
  • Brancher Conversation, Message et MessageResponse pour rendre la sortie IA en streaming
  • Utiliser PromptInput avec PromptInputTextarea, PromptInputSubmit et PromptInputBody pour un input soigné
  • Ajouter des pièces jointes avec PromptInputActionAddAttachments et les primitives Attachments
  • Afficher le raisonnement du modèle avec Reasoning, ReasoningTrigger et ReasoningContent
  • Afficher les appels d'outils et les sources web avec Tool, Source et Sources

Prérequis

Avant de commencer, assurez-vous d'avoir :

  • Node.js 20 ou plus récent (le build ESM moderne de AI SDK 5 refuse les versions antérieures)
  • Une familiarité avec Next.js 15 App Router (composants serveur vs composants client)
  • Une clé OpenAI ou Anthropic API — nous utiliserons OpenAI mais le swap tient en une ligne
  • shadcn/ui déjà initialisé (ou laissez la CLI AI Elements l'initialiser)
  • De l'aisance avec les hooks React et TypeScript

Ce que vous allez construire

Une application de chat à page unique appelée Noqta Chat, avec un fil de conversation réel, un streaming texte, un raisonnement extensible, des pièces jointes images et PDF, un sélecteur de modèle entre GPT-4o et Claude Sonnet 4.6, et un bouton "Rechercher sur le web" qui change le comportement côté serveur. Toute l'interface sera composée à partir des primitives AI Elements vivant dans votre dossier components/ai-elements/.

Étape 1 : configuration du projet

Lancez un nouveau projet Next.js 15 avec TypeScript et Tailwind :

npx create-next-app@latest noqta-chat \
  --typescript --tailwind --app --eslint --src-dir
cd noqta-chat

Initialisez shadcn/ui (AI Elements en a besoin comme couche de base) :

npx shadcn@latest init

Choisissez Neutral comme couleur de base et CSS variables lors de l'invite. Cela configure globals.css, lib/utils.ts et components.json.

Maintenant, installez AI Elements. Le chemin le plus rapide est la CLI dédiée, qui ajoute tous les composants d'un coup :

npx ai-elements@latest

De nouveaux fichiers devraient apparaître dans components/ai-elements/ : conversation.tsx, message.tsx, prompt-input.tsx, reasoning.tsx, tool.tsx, source.tsx, attachments.tsx, code-block.tsx et quelques autres. Ce sont de vrais fichiers source — ouvrez-en un et vous verrez des composants standards façon shadcn bâtis sur des primitives Radix.

Vous préférez installer un composant à la fois ? Utilisez npx ai-elements@latest add conversation pour n'ajouter que les primitives de conversation, ou utilisez directement le registry shadcn sous-jacent : npx shadcn@latest add https://elements.ai-sdk.dev/api/registry/all.json. Les deux produisent un résultat identique.

Étape 2 : installer AI SDK 5

AI Elements est conçu pour se brancher au hook useChat de AI SDK 5. Installez le runtime, les bindings React et au moins un provider :

npm install ai @ai-sdk/react @ai-sdk/openai @ai-sdk/anthropic zod

Ajoutez vos clés à .env.local :

OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...

Redémarrez npm run dev pour que Next.js prenne en compte les nouvelles variables d'environnement.

Étape 3 : la route API du chat

AI SDK 5 streame des messages d'interface — un format structuré où chaque message est une liste de parties typées (texte, raisonnement, outil, source, fichier). Les composants AI Elements lisent directement ces parties, donc le serveur n'a qu'à transférer le stream du modèle en tant que stream de messages d'interface.

Créez src/app/api/chat/route.ts :

import { openai } from "@ai-sdk/openai";
import { anthropic } from "@ai-sdk/anthropic";
import {
  convertToModelMessages,
  streamText,
  type UIMessage,
} from "ai";
 
export const maxDuration = 30;
 
type ChatBody = {
  messages: UIMessage[];
  model?: string;
  webSearch?: boolean;
};
 
export async function POST(req: Request) {
  const { messages, model = "gpt-4o", webSearch = false }: ChatBody =
    await req.json();
 
  const provider = model.startsWith("claude")
    ? anthropic(model)
    : openai(model);
 
  const result = streamText({
    model: provider,
    system: webSearch
      ? "You can search the web. Cite sources you used."
      : "You are a helpful assistant. Be concise and accurate.",
    messages: convertToModelMessages(messages),
  });
 
  return result.toUIMessageStreamResponse({
    sendReasoning: true,
    sendSources: true,
  });
}

Deux points comptent ici :

  1. convertToModelMessages transforme le format de message d'interface (tableau de parties) en format de chat completion attendu par chaque provider.
  2. toUIMessageStreamResponse produit le stream encadré que useChat et AI Elements consomment. Passer sendReasoning: true est ce qui fait apparaître le raisonnement étendu de Claude ; sendSources: true laisse les résultats de recherche web circuler comme parties source.

Étape 4 : la coque de conversation minimale

Remplacez src/app/page.tsx par un Client Component qui rend le squelette de la conversation. Nous démarrons au minimum et empilons les fonctionnalités dans les étapes suivantes.

"use client";
 
import { useState } from "react";
import { useChat } from "@ai-sdk/react";
import {
  Conversation,
  ConversationContent,
  ConversationEmptyState,
  ConversationScrollButton,
} from "@/components/ai-elements/conversation";
import {
  Message,
  MessageContent,
  MessageResponse,
} from "@/components/ai-elements/message";
import {
  PromptInput,
  type PromptInputMessage,
  PromptInputTextarea,
  PromptInputSubmit,
} from "@/components/ai-elements/prompt-input";
import { MessageSquare } from "lucide-react";
 
export default function ChatPage() {
  const [input, setInput] = useState("");
  const { messages, sendMessage, status } = useChat();
 
  const handleSubmit = (message: PromptInputMessage) => {
    if (!message.text.trim()) return;
    sendMessage({ text: message.text });
    setInput("");
  };
 
  return (
    <main className="mx-auto flex h-dvh max-w-3xl flex-col p-6">
      <Conversation className="flex-1">
        <ConversationContent>
          {messages.length === 0 ? (
            <ConversationEmptyState
              icon={<MessageSquare className="size-12" />}
              title="Start a conversation"
              description="Ask Noqta Chat anything"
            />
          ) : (
            messages.map((message) => (
              <Message from={message.role} key={message.id}>
                <MessageContent>
                  {message.parts.map((part, i) =>
                    part.type === "text" ? (
                      <MessageResponse key={`${message.id}-${i}`}>
                        {part.text}
                      </MessageResponse>
                    ) : null
                  )}
                </MessageContent>
              </Message>
            ))
          )}
        </ConversationContent>
        <ConversationScrollButton />
      </Conversation>
 
      <PromptInput onSubmit={handleSubmit} className="mt-4">
        <PromptInputTextarea
          value={input}
          placeholder="Send a message..."
          onChange={(e) => setInput(e.currentTarget.value)}
        />
        <PromptInputSubmit
          status={status === "streaming" ? "streaming" : "ready"}
          disabled={!input.trim()}
        />
      </PromptInput>
    </main>
  );
}

Lancez npm run dev, ouvrez http://localhost:3000, et vous avez déjà un chat fonctionnel. Conversation gère le défilement automatique, ConversationScrollButton affiche une pastille "aller au plus récent" quand vous remontez, et MessageResponse gère le rendu markdown en streaming. La coque entière fait moins de 60 lignes de JSX.

Étape 5 : itérer sur les parties de message correctement

Le pattern de l'étape 4 ne rend que les parties text. Les vraies réponses incluent aussi des parties reasoning, tool-*, source-url et file. Refactorez le rendu de message dans un composant dédié pour qu'il grandisse proprement à chaque étape.

Créez src/components/chat-message-parts.tsx :

"use client";
 
import type { UIMessage } from "ai";
import {
  MessageResponse,
} from "@/components/ai-elements/message";
 
export function ChatMessageParts({ message }: { message: UIMessage }) {
  return (
    <>
      {message.parts.map((part, i) => {
        const key = `${message.id}-${i}`;
 
        switch (part.type) {
          case "text":
            return <MessageResponse key={key}>{part.text}</MessageResponse>;
          default:
            return null;
        }
      })}
    </>
  );
}

Remplacez le .map inline dans page.tsx par <ChatMessageParts message={message} />. Désormais, chaque nouveau type de partie qu'on branche ajoute un case ici au lieu de ramifier la page.

Étape 6 : streaming du raisonnement

Claude Sonnet 4.6 et les modèles GPT à raisonnement émettent un stream de partie reasoning séparé avant le texte final. AI Elements vous donne une révélation repliable pour cela.

Mettez à jour src/components/chat-message-parts.tsx :

"use client";
 
import type { UIMessage } from "ai";
import { MessageResponse } from "@/components/ai-elements/message";
import {
  Reasoning,
  ReasoningContent,
  ReasoningTrigger,
} from "@/components/ai-elements/reasoning";
 
type Props = {
  message: UIMessage;
  isLastMessage: boolean;
  isStreaming: boolean;
};
 
export function ChatMessageParts({ message, isLastMessage, isStreaming }: Props) {
  const reasoningParts = message.parts.filter((p) => p.type === "reasoning");
  const reasoningText = reasoningParts.map((p) => p.text).join("\n\n");
  const lastPart = message.parts.at(-1);
  const isReasoningStreaming =
    isLastMessage && isStreaming && lastPart?.type === "reasoning";
 
  return (
    <>
      {reasoningParts.length > 0 && (
        <Reasoning className="w-full" isStreaming={isReasoningStreaming}>
          <ReasoningTrigger />
          <ReasoningContent>{reasoningText}</ReasoningContent>
        </Reasoning>
      )}
 
      {message.parts.map((part, i) => {
        const key = `${message.id}-${i}`;
        if (part.type === "text") {
          return <MessageResponse key={key}>{part.text}</MessageResponse>;
        }
        return null;
      })}
    </>
  );
}

Passez les drapeaux de streaming depuis page.tsx :

{messages.map((message, index) => (
  <Message from={message.role} key={message.id}>
    <MessageContent>
      <ChatMessageParts
        message={message}
        isLastMessage={index === messages.length - 1}
        isStreaming={status === "streaming"}
      />
    </MessageContent>
  </Message>
))}

Reasoning s'ouvre automatiquement pendant le streaming, anime un scintillement de tokens et se replie quand le modèle a fini de réfléchir — ce comportement de fermeture automatique est la partie que tout le monde rate en faisant l'UI à la main.

Étape 7 : un PromptInput soigné avec barre d'outils

Le PromptInput minimal de l'étape 4 est correct, mais la vraie valeur d'AI Elements ce sont les primitives de mise en page qui se composent en barre d'outils façon Claude. Remplacez votre input par un layout en-tête-corps-pied :

import {
  PromptInput,
  PromptInputBody,
  PromptInputButton,
  PromptInputFooter,
  type PromptInputMessage,
  PromptInputSelect,
  PromptInputSelectContent,
  PromptInputSelectItem,
  PromptInputSelectTrigger,
  PromptInputSelectValue,
  PromptInputSubmit,
  PromptInputTextarea,
  PromptInputTools,
} from "@/components/ai-elements/prompt-input";
import { GlobeIcon } from "lucide-react";
 
const models = [
  { id: "gpt-4o", name: "GPT-4o" },
  { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
];
 
// inside ChatPage:
const [model, setModel] = useState(models[0].id);
const [webSearch, setWebSearch] = useState(false);
 
const handleSubmit = (message: PromptInputMessage) => {
  if (!message.text.trim() && !message.files?.length) return;
  sendMessage(
    { text: message.text, files: message.files },
    { body: { model, webSearch } }
  );
  setInput("");
};
 
return (
  // ...Conversation au-dessus...
  <PromptInput onSubmit={handleSubmit} className="mt-4" globalDrop multiple>
    <PromptInputBody>
      <PromptInputTextarea
        value={input}
        onChange={(e) => setInput(e.currentTarget.value)}
        placeholder="Ask anything..."
      />
    </PromptInputBody>
    <PromptInputFooter>
      <PromptInputTools>
        <PromptInputButton
          onClick={() => setWebSearch(!webSearch)}
          variant={webSearch ? "default" : "ghost"}
          tooltip={{ content: "Search the web" }}
        >
          <GlobeIcon size={16} />
          <span>Search</span>
        </PromptInputButton>
 
        <PromptInputSelect value={model} onValueChange={setModel}>
          <PromptInputSelectTrigger>
            <PromptInputSelectValue />
          </PromptInputSelectTrigger>
          <PromptInputSelectContent>
            {models.map((m) => (
              <PromptInputSelectItem key={m.id} value={m.id}>
                {m.name}
              </PromptInputSelectItem>
            ))}
          </PromptInputSelectContent>
        </PromptInputSelect>
      </PromptInputTools>
      <PromptInputSubmit
        status={status === "streaming" ? "streaming" : "ready"}
      />
    </PromptInputFooter>
  </PromptInput>
);

Trois choses viennent de se produire :

  • Le paramètre body sur sendMessage transmet { model, webSearch } à votre route handler — c'est ainsi que les champs body de l'étape 3 se remplissent réellement.
  • PromptInputSubmit bascule automatiquement entre une icône d'avion en papier et un carré stop quand status === "streaming". Cliquer la variante stop appelle l'abort de useChat sous le capot.
  • globalDrop multiple active le glisser-déposer de pièces jointes n'importe où sur la page, ce que nous activons ensuite.

Étape 8 : pièces jointes

AI Elements fournit une primitive Attachments qui se marie avec PromptInputActionAddAttachments. Le hook usePromptInputAttachments expose l'état vivant des pièces jointes.

Créez src/components/attachments-display.tsx :

"use client";
 
import {
  Attachment,
  AttachmentPreview,
  AttachmentRemove,
  Attachments,
} from "@/components/ai-elements/attachments";
import { usePromptInputAttachments } from "@/components/ai-elements/prompt-input";
 
export function AttachmentsDisplay() {
  const attachments = usePromptInputAttachments();
 
  if (attachments.files.length === 0) return null;
 
  return (
    <Attachments variant="inline">
      {attachments.files.map((attachment) => (
        <Attachment
          data={attachment}
          key={attachment.id}
          onRemove={() => attachments.remove(attachment.id)}
        >
          <AttachmentPreview />
          <AttachmentRemove />
        </Attachment>
      ))}
    </Attachments>
  );
}

Étendez maintenant le bloc PromptInput dans page.tsx :

import {
  PromptInputActionAddAttachments,
  PromptInputActionMenu,
  PromptInputActionMenuContent,
  PromptInputActionMenuTrigger,
  PromptInputHeader,
} from "@/components/ai-elements/prompt-input";
import { AttachmentsDisplay } from "@/components/attachments-display";
 
// dans <PromptInput>:
<PromptInputHeader>
  <AttachmentsDisplay />
</PromptInputHeader>
<PromptInputBody>
  <PromptInputTextarea ... />
</PromptInputBody>
<PromptInputFooter>
  <PromptInputTools>
    <PromptInputActionMenu>
      <PromptInputActionMenuTrigger />
      <PromptInputActionMenuContent>
        <PromptInputActionAddAttachments />
      </PromptInputActionMenuContent>
    </PromptInputActionMenu>
    {/* ...bouton de recherche web, sélecteur de modèle... */}
  </PromptInputTools>
  <PromptInputSubmit ... />
</PromptInputFooter>

Déposez un PNG ou un PDF sur la page. Vous verrez le fichier rendu comme une chip au-dessus du textarea, et à la soumission il atterrit dans message.files — que AI SDK 5 transmet au modèle en tant que partie file.

Étape 9 : afficher les appels d'outils

Quand le modèle invoque un outil (outil custom, recherche web, interpréteur de code), le stream émet des parties de type tool-<toolName>. AI Elements fournit un composant Tool qui rend le nom de l'appel, l'état, l'entrée et la sortie sous forme de carte repliée nette.

Ajoutez à chat-message-parts.tsx :

import {
  Tool,
  ToolContent,
  ToolHeader,
  ToolInput,
  ToolOutput,
} from "@/components/ai-elements/tool";
 
// dans le switch parts.map:
default: {
  if (part.type.startsWith("tool-")) {
    const toolPart = part as Extract<typeof part, { type: `tool-${string}` }>;
    return (
      <Tool key={key} defaultOpen={toolPart.state !== "output-available"}>
        <ToolHeader type={toolPart.type} state={toolPart.state} />
        <ToolContent>
          <ToolInput input={toolPart.input} />
          {toolPart.output !== undefined && (
            <ToolOutput
              output={toolPart.output}
              errorText={toolPart.errorText}
            />
          )}
        </ToolContent>
      </Tool>
    );
  }
  return null;
}

ToolHeader affiche une pastille d'état — "input-streaming", "input-available", "output-available", "output-error" — pour que l'utilisateur voie l'agent réfléchir, pas une UI gelée.

Étape 10 : sources de recherche web

Dès que sendSources: true est paramétré sur la réponse de la route (étape 3), les modèles capables de chercher sur le web streament des parties source-url que vous pouvez rendre comme un pied de citation.

import {
  Source,
  Sources,
  SourcesContent,
  SourcesTrigger,
} from "@/components/ai-elements/source";
 
// en haut du return de ChatMessageParts:
const sourceParts = message.parts.filter((p) => p.type === "source-url");
 
{sourceParts.length > 0 && (
  <Sources>
    <SourcesTrigger count={sourceParts.length} />
    <SourcesContent>
      {sourceParts.map((s, i) => (
        <Source
          key={`${message.id}-src-${i}`}
          href={s.url}
          title={s.title ?? s.url}
        />
      ))}
    </SourcesContent>
  </Sources>
)}

Sources est un panneau repliable sous la réponse de l'assistant avec une entrée par URL citée. Cliquer chaque chip ouvre la source dans un nouvel onglet — le pattern standard popularisé par Perplexity.

Tester votre implémentation

  • Envoyez "Quelle est la capitale de la Tunisie ?" — confirmez que l'assistant streame le texte de haut en bas sans à-coups de défilement.
  • Basculez le sélecteur de modèle vers Claude Sonnet 4.6 et posez une question de maths — confirmez que le panneau de raisonnement s'ouvre, s'anime pendant la réflexion et se replie à la fin.
  • Activez le bouton Search et posez une question d'actualité — confirmez que le menu déroulant Sources apparaît sous la réponse.
  • Glissez une petite image sur la page — confirmez qu'une chip de pièce jointe apparaît, soumettez et vérifiez que le modèle réfère à l'image dans sa réponse.
  • En plein streaming, cliquez le bouton stop sur PromptInputSubmit — confirmez que le streaming s'arrête et que la réponse partielle reste en place.

Dépannage

Cannot find module '@/components/ai-elements/conversation' — la CLI AI Elements n'a pas tourné. Relancez npx ai-elements@latest depuis la racine de votre projet et confirmez que les fichiers apparaissent dans src/components/ai-elements/.

Le panneau de raisonnement ne s'ouvre jamais — votre route oublie sendReasoning: true sur toUIMessageStreamResponse, ou le modèle sélectionné n'émet pas de raisonnement. Testez avec claude-sonnet-4-6.

Les pièces jointes échouent avec une erreur 413 — les routes Next.js acceptent par défaut des corps de requête jusqu'à 1 Mo. Ajoutez export const runtime = "nodejs" et augmentez la limite dans next.config.ts sous experimental.serverActions.bodySizeLimit.

Erreurs de type UIMessage après mise à niveau d'AI SDK — AI SDK 5 a renommé le champ content en parts. Assurez-vous que votre version de @ai-sdk/react est au moins 2.x ; les composants AI Elements dépendent de la forme à parties.

Prochaines étapes

  • Couplez cette interface au tutoriel Mastra AI agents pour donner au chat de vrais outils, de la mémoire et un moteur de workflow.
  • Ajoutez Resend + React Email pour envoyer une transcription à la fin de la conversation.
  • Enveloppez le chat dans une sidebar CopilotKit pour qu'il vive à côté de votre application au lieu d'occuper toute la page.
  • Persistez les conversations dans Drizzle + Neon clés par utilisateur — AI Elements n'a rien à voir avec la persistance, le choix vous appartient.

Conclusion

Le travail intéressant dans une application IA, c'est la boucle d'agent, la conception de prompts, les outils, les évaluations. L'UI est résolue — mais seulement si vous arrêtez de la résoudre vous-même. AI Elements n'est pas un framework qu'on adopte, c'est un registry depuis lequel on copie. Une fois les composants dans votre dépôt ils sont à vous : restylez-les avec vos tokens, échangez les icônes, supprimez les parties de PromptInput dont vous n'avez pas besoin, et passez aux parties de votre produit qui vous différencient vraiment. Dépensez votre sprint sur l'agent, pas sur le comportement de défilement automatique.

● Tags
#ai-elements#vercel-ai-sdk#nextjs#react#typescript#shadcn#ai-chat#generative-ui#2026#intermediate#28 min de lecture
● Partage
● Une question ?

Discutez de cet article avec un agent Noqta.

Équipe Noqta
Équipe Noqta
Author · noqta
Suivre ↗

● À lire ensuite

Construire un Agent IA Autonome avec Agentic RAG et Next.js
● Tutorial

Construire un Agent IA Autonome avec Agentic RAG et Next.js

11 févr. 2026
Construire des agents IA from scratch avec TypeScript : maîtriser le pattern ReAct avec le Vercel AI SDK
● Tutorial

Construire des agents IA from scratch avec TypeScript : maîtriser le pattern ReAct avec le Vercel AI SDK

12 févr. 2026
Guide d'Integration de Chatbot IA : Construire des Interfaces Conversationnelles Intelligentes
● Tutorial

Guide d'Integration de Chatbot IA : Construire des Interfaces Conversationnelles Intelligentes

25 janv. 2026
Noqta
Conditions générales · Politique de Confidentialité
Services
  • Automatisation IA
  • Agents IA
  • Automatisation CX
  • Vibe Coding
  • Gestion de Projet
  • Assurance Qualité
  • Développement Web
  • Intégration API
  • Applications Métier
  • Maintenance
  • Low-Code/No-Code
Liens
  • À propos de nous
  • Comment ça marche?
  • Actualités
  • Tutoriels
  • Blog
  • Contact
  • FAQ
  • Ressources
Régions
  • Arabie Saoudite
  • Émirats Arabes Unis
  • Qatar
  • Bahreïn
  • Oman
  • Libye
  • Tunisie
  • Algérie
  • Maroc
Entreprise
  • Noqta, Tunisie, Tunis, téléphone +216 40 385 594
© Noqta. Tous droits réservés.