Threema QR: on-demand modal + auto-open on first add
All checks were successful
Build and Push / build (push) Successful in 1m29s
All checks were successful
Build and Push / build (push) Successful in 1m29s
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ThreemaQrModal } from "./threema-qr-modal";
|
||||
|
||||
/** Maps channel IDs to the instructions for finding the user ID. */
|
||||
const CHANNEL_ID_HELP: Record<string, string> = {
|
||||
@@ -51,6 +52,14 @@ export function ChannelUsers({
|
||||
const [inputValues, setInputValues] = useState<Record<string, string>>({});
|
||||
const [channelUsers, setChannelUsers] =
|
||||
useState<Record<string, string[]>>(initialChannelUsers);
|
||||
/** Which channel's QR helper modal is open, if any. */
|
||||
const [showQrFor, setShowQrFor] = useState<string | null>(null);
|
||||
/**
|
||||
* Tracks channels for which we've already auto-opened the helper
|
||||
* modal on this page load. Prevents the modal from re-popping every
|
||||
* time the user refocuses the input after dismissing it.
|
||||
*/
|
||||
const [autoOpened, setAutoOpened] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const updateChannelUsers = useCallback(
|
||||
async (updated: Record<string, string[]>) => {
|
||||
@@ -216,9 +225,19 @@ export function ChannelUsers({
|
||||
className="bg-surface-2 border border-border rounded-lg p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-medium text-text-primary capitalize">
|
||||
{channel}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-medium text-text-primary capitalize">
|
||||
{channel}
|
||||
</h4>
|
||||
{channel === "threema" && (
|
||||
<button
|
||||
onClick={() => setShowQrFor("threema")}
|
||||
className="text-xs font-medium text-accent hover:text-accent-dim cursor-pointer underline underline-offset-2"
|
||||
>
|
||||
{t("threemaSetup.showQr")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-text-muted tabular-nums">
|
||||
{users.length} {t("users")}
|
||||
</span>
|
||||
@@ -266,6 +285,17 @@ export function ChannelUsers({
|
||||
[channel]: e.target.value,
|
||||
}))
|
||||
}
|
||||
onFocus={() => {
|
||||
// For threema specifically, open the QR helper the
|
||||
// first time the user clicks into the input on this
|
||||
// page load. We don't repeat after dismiss — the
|
||||
// "Show QR" button next to the channel name covers
|
||||
// re-opens on demand.
|
||||
if (channel === "threema" && !autoOpened.has("threema")) {
|
||||
setShowQrFor("threema");
|
||||
setAutoOpened((prev) => new Set(prev).add("threema"));
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleAdd(channel);
|
||||
}}
|
||||
@@ -284,6 +314,11 @@ export function ChannelUsers({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<ThreemaQrModal
|
||||
open={showQrFor === "threema"}
|
||||
onClose={() => setShowQrFor(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
src/components/channel-users/threema-qr-modal.tsx
Normal file
82
src/components/channel-users/threema-qr-modal.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect } from "react";
|
||||
import { THREEMA_GATEWAY } from "@/lib/threema-gateway-config";
|
||||
|
||||
interface ThreemaQrModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* On-demand modal showing the QR for adding the assistant on Threema.
|
||||
* Triggered by the "Show QR" button in the threema channel card and
|
||||
* closes on overlay click, ESC, or the close button.
|
||||
*
|
||||
* Uses a plain <img> not next/image — image optimization adds nothing
|
||||
* for a 57KB static PNG and removes a potential source of rendering
|
||||
* bugs in the Next.js standalone build.
|
||||
*/
|
||||
export function ThreemaQrModal({ open, onClose }: ThreemaQrModalProps) {
|
||||
const t = useTranslations("channelUsers.threemaSetup");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-full max-w-md bg-surface-1 border border-border rounded-2xl p-6 shadow-2xl shadow-black/40 space-y-4"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<h3 className="text-base font-semibold text-text-primary">
|
||||
{t("title")}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-text-muted hover:text-text-primary text-xl leading-none"
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<div className="bg-white p-3 rounded-md">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={THREEMA_GATEWAY.qrCodePath}
|
||||
alt={t("qrAlt", { gateway: THREEMA_GATEWAY.displayName })}
|
||||
width={220}
|
||||
height={220}
|
||||
style={{ display: "block" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-text-muted font-mono">
|
||||
{THREEMA_GATEWAY.displayName}
|
||||
</p>
|
||||
|
||||
<ol className="list-decimal list-inside text-xs text-text-secondary space-y-1.5">
|
||||
<li>{t("step1")}</li>
|
||||
<li>{t("step2")}</li>
|
||||
<li>{t("step3")}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
src/lib/threema-gateway-config.ts
Normal file
33
src/lib/threema-gateway-config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Threema central gateway info shown to customers.
|
||||
*
|
||||
* Today PieCed runs exactly one Threema Gateway account (*AIAGENT) and
|
||||
* every tenant talks to that. The constants below are hardcoded for
|
||||
* that account. The values are intentionally kept here (and not split
|
||||
* across i18n / env / runtime config) so when we move to multiple
|
||||
* gateway accounts there's a single file to refactor.
|
||||
*
|
||||
* To go dynamic (future):
|
||||
* 1. Replace `THREEMA_GATEWAY` constant with a runtime lookup —
|
||||
* either per-tenant from the relay's admin API, or from an
|
||||
* env var that lists the active account.
|
||||
* 2. Move the QR PNG into a server-rendered route that takes a
|
||||
* gateway ID query param.
|
||||
* 3. Update consumers (today only ThreemaSetup) to accept the
|
||||
* gateway info as a prop and pass it from a server component.
|
||||
*
|
||||
* In display contexts we strip the leading asterisk from the Threema
|
||||
* ID — customers don't understand the `*X` prefix convention used for
|
||||
* Gateway accounts, and the QR code carries the real value anyway. We
|
||||
* keep the asterisk only for places where the technical value matters
|
||||
* (server-side message routing, debug logs).
|
||||
*/
|
||||
|
||||
export const THREEMA_GATEWAY = {
|
||||
/** Technical Threema Gateway ID, with leading asterisk. */
|
||||
id: "*AIAGENT",
|
||||
/** Display name shown to customers (no asterisk). */
|
||||
displayName: "AIAGENT",
|
||||
/** Public path to the QR code PNG served from `public/`. */
|
||||
qrCodePath: "/threema/qr_code_AIAGENT.png",
|
||||
} as const;
|
||||
@@ -402,7 +402,8 @@
|
||||
"step1": "Öffnen Sie Threema auf Ihrem Telefon.",
|
||||
"step2": "Tippen Sie auf das Scan-Symbol und scannen Sie diesen QR-Code, um den Assistenten als Kontakt hinzuzufügen.",
|
||||
"step3": "Fügen Sie anschliessend unten Ihre eigene Threema-ID hinzu.",
|
||||
"qrAlt": "QR-Code, um {gateway} als Threema-Kontakt hinzuzufügen"
|
||||
"qrAlt": "QR-Code, um {gateway} als Threema-Kontakt hinzuzufügen",
|
||||
"showQr": "QR anzeigen"
|
||||
}
|
||||
},
|
||||
"team": {
|
||||
|
||||
@@ -402,7 +402,8 @@
|
||||
"step1": "Open Threema on your phone.",
|
||||
"step2": "Tap the scan icon and scan this QR code to add the assistant as a contact.",
|
||||
"step3": "Then add your own Threema ID below.",
|
||||
"qrAlt": "QR code to add {gateway} as a Threema contact"
|
||||
"qrAlt": "QR code to add {gateway} as a Threema contact",
|
||||
"showQr": "Show QR"
|
||||
}
|
||||
},
|
||||
"team": {
|
||||
|
||||
@@ -402,7 +402,8 @@
|
||||
"step1": "Ouvrez Threema sur votre téléphone.",
|
||||
"step2": "Appuyez sur l'icône de scan et scannez ce QR code pour ajouter l'assistant comme contact.",
|
||||
"step3": "Puis ajoutez votre propre identifiant Threema ci-dessous.",
|
||||
"qrAlt": "QR code pour ajouter {gateway} comme contact Threema"
|
||||
"qrAlt": "QR code pour ajouter {gateway} comme contact Threema",
|
||||
"showQr": "Afficher le QR"
|
||||
}
|
||||
},
|
||||
"team": {
|
||||
|
||||
@@ -402,7 +402,8 @@
|
||||
"step1": "Apri Threema sul tuo telefono.",
|
||||
"step2": "Tocca l'icona di scansione e scansiona questo QR code per aggiungere l'assistente ai contatti.",
|
||||
"step3": "Quindi aggiungi il tuo ID Threema qui sotto.",
|
||||
"qrAlt": "QR code per aggiungere {gateway} come contatto Threema"
|
||||
"qrAlt": "QR code per aggiungere {gateway} come contatto Threema",
|
||||
"showQr": "Mostra QR"
|
||||
}
|
||||
},
|
||||
"team": {
|
||||
|
||||
Reference in New Issue
Block a user