Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 51 additions & 19 deletions components/landing/copy-embed-code-button.tsx
Original file line number Diff line number Diff line change
@@ -1,51 +1,83 @@
"use client";

import { useEffect, useState } from "react";
import { Copy, Check } from "lucide-react";
import { Copy, Check, TriangleAlert } from "lucide-react";

type CopyStatus = "idle" | "copied" | "failed";

/** Durée d'affichage du retour visuel avant retour à l'état initial. */
const FEEDBACK_DURATION_MS = 2500;

const STATUS_ICONS: Record<CopyStatus, React.ReactNode> = {
idle: <Copy className="w-3.5 h-3.5" />,
copied: <Check className="w-3.5 h-3.5 text-primary" />,
failed: <TriangleAlert className="w-3.5 h-3.5" />,
};

const STATUS_CLASSNAMES: Record<CopyStatus, string> = {
idle: "border-border text-muted-foreground hover:border-primary/40 hover:text-foreground",
copied: "border-border text-muted-foreground hover:border-primary/40 hover:text-foreground",
failed: "border-destructive/40 text-destructive",
};

interface CopyEmbedCodeButtonProps {
code: string;
copyLabel: string;
copiedLabel: string;
copyFailedLabel: string;
}

/**
* Bouton de copie du snippet d'intégration du widget. Le libellé revient à son
* état initial après deux secondes, et le timer est nettoyé au démontage.
* Bouton de copie du snippet d'intégration du widget.
*
* Le presse-papiers est indisponible hors contexte sécurisé ou si l'utilisateur
* refuse la permission : l'échec est alors affiché explicitement, avec une
* consigne de sélection manuelle, plutôt que de laisser le clic sans effet.
*/
export function CopyEmbedCodeButton({ code, copyLabel, copiedLabel }: CopyEmbedCodeButtonProps) {
const [hasCopied, setHasCopied] = useState(false);
export function CopyEmbedCodeButton({
code,
copyLabel,
copiedLabel,
copyFailedLabel,
}: CopyEmbedCodeButtonProps) {
const [copyStatus, setCopyStatus] = useState<CopyStatus>("idle");

useEffect(() => {
if (!hasCopied) return;
if (copyStatus === "idle") return;

const resetTimeout = setTimeout(() => setHasCopied(false), 2000);
const resetTimeout = setTimeout(() => setCopyStatus("idle"), FEEDBACK_DURATION_MS);
return () => clearTimeout(resetTimeout);
}, [hasCopied]);
}, [copyStatus]);

const copyEmbedCode = async () => {
if (!navigator.clipboard) {
setCopyStatus("failed");
return;
}

try {
await navigator.clipboard.writeText(code);
setHasCopied(true);
setCopyStatus("copied");
} catch {
// Clipboard indisponible (contexte non sécurisé ou permission refusée) :
// on laisse le code visible à l'écran pour une sélection manuelle.
setHasCopied(false);
setCopyStatus("failed");
}
};

const statusLabels: Record<CopyStatus, string> = {
idle: copyLabel,
copied: copiedLabel,
failed: copyFailedLabel,
};

return (
<button
type="button"
onClick={copyEmbedCode}
className="inline-flex items-center gap-2 rounded-xl border border-border bg-background px-4 py-2.5 font-mono text-[11px] uppercase tracking-[0.16em] text-muted-foreground hover:border-primary/40 hover:text-foreground transition-colors"
aria-live="polite"
className={`inline-flex items-center gap-2 rounded-xl border bg-background px-4 py-2.5 font-mono text-[11px] uppercase tracking-[0.16em] transition-colors ${STATUS_CLASSNAMES[copyStatus]}`}
>
{hasCopied ? (
<Check className="w-3.5 h-3.5 text-primary" />
) : (
<Copy className="w-3.5 h-3.5" />
)}
{hasCopied ? copiedLabel : copyLabel}
{STATUS_ICONS[copyStatus]}
{statusLabels[copyStatus]}
</button>
);
}
40 changes: 37 additions & 3 deletions components/landing/embed-widget-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,43 @@ import { SectionLabel } from "@/components/landing/section-label";
import { CopyEmbedCodeButton } from "@/components/landing/copy-embed-code-button";

const DEMO_CLINIC_SLUG = "cabinet-dr-martin";
const DEFAULT_APP_URL = "https://docflow.ia";
const ALLOWED_APP_URL_PROTOCOLS = ["http:", "https:"];

/**
* Résout l'URL publique servant de base au snippet.
*
* Ce snippet quitte le produit pour être collé sur le site du praticien : une
* valeur vide donnerait une URL relative inexploitable chez lui, et un schéma
* inattendu s'y retrouverait tel quel. On retombe donc sur l'URL par défaut dès
* que la variable n'est pas une URL http(s) exploitable. `http` reste accepté
* car l'environnement de développement tourne sur `http://localhost:3000`.
*/
function resolvePublicAppUrl(): string {
const configuredUrl = process.env.NEXT_PUBLIC_APP_URL?.trim();
if (!configuredUrl) return DEFAULT_APP_URL;

try {
const parsedUrl = new URL(configuredUrl);
return ALLOWED_APP_URL_PROTOCOLS.includes(parsedUrl.protocol)
? configuredUrl
: DEFAULT_APP_URL;
} catch {
return DEFAULT_APP_URL;
}
}

/**
* Construit le snippet d'intégration copié par le praticien sur son propre site.
*
* L'URL est normalisée : `NEXT_PUBLIC_APP_URL` peut finir par une barre oblique
* et produirait alors `https://exemple.fr//widget/…`. L'attribut `frameborder`
* ayant disparu du standard HTML, la bordure est retirée en CSS.
*/
function buildEmbedCode(appUrl: string): string {
return `<iframe src="${appUrl}/widget/${DEMO_CLINIC_SLUG}" width="100%" height="600" frameborder="0"></iframe>`;
const normalizedAppUrl = appUrl.replace(/\/+$/, "");

return `<iframe src="${normalizedAppUrl}/widget/${DEMO_CLINIC_SLUG}" width="100%" height="600" style="border:0"></iframe>`;
Comment on lines 40 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate file and validation/env references =="
git ls-files | rg '(^components/landing/embed-widget-section\.tsx$|^lib/validations\.ts$|validations|NEXT_PUBLIC_APP_URL|buildEmbedCode|DEMO_CLINIC_SLUG)$' || true

echo
echo "== target file =="
if [ -f components/landing/embed-widget-section.tsx ]; then
  cat -n components/landing/embed-widget-section.tsx
fi

echo
echo "== validations and env usage =="
if [ -f lib/validations.ts ]; then
  cat -n lib/validations.ts
fi
rg -n "NEXT_PUBLIC_APP_URL|buildEmbedCode|DEMO_CLINIC_SLUG|validations|env" -S components lib app pages .env* 2>/dev/null || true

echo
echo "== deterministic string behavior for sample values =="
node - <<'JS'
function buildEmbedCode(appUrl, slug = 'demo-clinic-slug') {
  const normalizedAppUrl = appUrl.replace(/\/+$/, "");
  return `<iframe src="${normalizedAppUrl}/widget/${slug}" width="100%" height="600" style="border:0"></iframe>`;
}
for (const input of ['', 'invalid', 'https://example.com?q="<img src=x>'] ) {
  console.log(JSON.stringify(buildEmbedCode(input)));
}
JS

Repository: Zoubeir23/DocFlowAI

Length of output: 26356


Validez NEXT_PUBLIC_APP_URL avec Zod avant de générer le snippet.

buildEmbedCode accepte toute valeur de process.env.NEXT_PUBLIC_APP_URL, ce qui peut produire un src vide, invalide ou mal échappé. Ajoutez un schéma lib/validations.ts, autorisez uniquement https et une URL propre, et passez cette valeur validée à buildEmbedCode.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/landing/embed-widget-section.tsx` around lines 15 - 18, Ajoutez
dans lib/validations.ts un schéma Zod pour NEXT_PUBLIC_APP_URL qui n’accepte que
des URL HTTPS propres et valides. Validez cette variable d’environnement avant
d’appeler buildEmbedCode, puis transmettez uniquement la valeur validée afin
d’éviter de générer un src vide, invalide ou mal échappé.

Source: Coding guidelines

}

/**
Expand All @@ -15,8 +49,7 @@ function buildEmbedCode(appUrl: string): string {
*/
export async function EmbedWidgetSection() {
const t = await getTranslations("landing.widget");
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "https://docflow.ia";
const embedCode = buildEmbedCode(appUrl);
const embedCode = buildEmbedCode(resolvePublicAppUrl());
const bullets = [t("bulletRateLimit"), t("bulletBilingual"), t("bulletBranding")];

return (
Expand Down Expand Up @@ -49,6 +82,7 @@ export async function EmbedWidgetSection() {
code={embedCode}
copyLabel={t("copy")}
copiedLabel={t("copied")}
copyFailedLabel={t("copyFailed")}
/>
</div>
</div>
Expand Down
14 changes: 7 additions & 7 deletions components/landing/medical-record-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,25 +42,25 @@ export async function MedicalRecordSection() {
</h2>
<p className="mt-5 text-lg text-muted-foreground leading-relaxed">{t("subtitle")}</p>

<dl className="mt-10 space-y-7">
<ul className="mt-10 space-y-7">
{highlights.map((highlight) => {
const Icon = highlight.icon;

return (
<div key={highlight.key} className="flex items-start gap-5">
<li key={highlight.key} className="flex items-start gap-5">
<div className="w-11 h-11 shrink-0 rounded-2xl bg-primary/10 border border-primary/20 flex items-center justify-center text-primary">
<Icon className="w-5 h-5" strokeWidth={1.75} />
</div>
<div>
<dt className="font-bold text-foreground">{highlight.title}</dt>
<dd className="mt-1 text-muted-foreground leading-relaxed">
<h3 className="font-bold text-foreground">{highlight.title}</h3>
<p className="mt-1 text-muted-foreground leading-relaxed">
{highlight.description}
</dd>
</p>
</div>
</div>
</li>
);
})}
</dl>
</ul>
</div>

<div className="lg:col-span-6 fade-in-up" style={{ animationDelay: "0.15s" }}>
Expand Down
14 changes: 7 additions & 7 deletions components/landing/security-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,24 +34,24 @@ export async function SecuritySection() {
<p className="mt-5 text-lg text-muted-foreground leading-relaxed">{t("subtitle")}</p>
</div>

<dl className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-px bg-border border border-border rounded-3xl overflow-hidden">
<ul className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-px bg-border border border-border rounded-3xl overflow-hidden">
{guarantees.map((guarantee) => {
const Icon = guarantee.icon;

return (
<div
<li
key={guarantee.key}
className="bg-card p-8 hover:bg-primary/[0.03] transition-colors"
>
<Icon className="w-6 h-6 text-primary mb-6" strokeWidth={1.5} />
<dt className="font-bold text-foreground">{guarantee.title}</dt>
<dd className="mt-2 text-sm text-muted-foreground leading-relaxed">
<h3 className="font-bold text-foreground">{guarantee.title}</h3>
<p className="mt-2 text-sm text-muted-foreground leading-relaxed">
{guarantee.description}
</dd>
</div>
</p>
</li>
);
})}
</dl>
</ul>
</div>
</section>
);
Expand Down
3 changes: 2 additions & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -970,7 +970,8 @@
"title": "Booking 24/7",
"desc": "The AI reads your real availability and writes the booking straight into your calendar.",
"cta": "Start a simulation"
}
},
"copyFailed": "Copy failed, select the code"
},
"pricingPreview": {
"label": "Pricing",
Expand Down
3 changes: 2 additions & 1 deletion messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -970,7 +970,8 @@
"title": "Rendez-vous 24h/24",
"desc": "L'IA consulte vos créneaux réels et enregistre la réservation directement dans votre agenda.",
"cta": "Démarrer une simulation"
}
},
"copyFailed": "Copie impossible, sélectionnez le code"
},
"pricingPreview": {
"label": "Tarification",
Expand Down
Loading