Phase8: Auto bill credit card
All checks were successful
Build and Push / build (push) Successful in 1m45s

This commit is contained in:
2026-05-28 23:27:32 +02:00
parent 1741574eb2
commit a6ed74b1be
12 changed files with 288 additions and 69 deletions

View File

@@ -255,6 +255,14 @@ export function OnboardingWizard({
const [disclaimerAccepted, setDisclaimerAccepted] = useState<
Record<string, boolean>
>({});
// Phase 9b: per-channel customer user id collected at onboarding.
// Keyed by package id (e.g. "telegram" → "1234567"). Applied on
// admin approval — see /api/admin/requests/[id]/approve. Optional
// per channel; the customer can also leave it blank and add their
// id later from the tenant's channel-users page.
const [channelUserIds, setChannelUserIds] = useState<Record<string, string>>(
{}
);
// Fetch DB-stored defaults on mount
useEffect(() => {
@@ -474,6 +482,20 @@ export function OnboardingWizard({
})()
: config;
// Phase 9b: build the channelUsers payload from the per-package
// ids collected during onboarding. Only include channels that
// (a) are enabled in the wizard's packages list AND
// (b) have a non-empty id entered.
// Shape matches PiecedTenantSpec.channelUsers — { channel: [id] }
// — so the approve handler can pass it straight through.
const channelUsersPayload: Record<string, string[]> = {};
for (const [pkgId, rawId] of Object.entries(channelUserIds)) {
const trimmed = (rawId ?? "").trim();
if (!trimmed) continue;
if (!config.packages.includes(pkgId)) continue;
channelUsersPayload[pkgId] = [trimmed];
}
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
@@ -483,6 +505,10 @@ export function OnboardingWizard({
Object.keys(secretsPayload).length > 0
? secretsPayload
: undefined,
channelUsers:
Object.keys(channelUsersPayload).length > 0
? channelUsersPayload
: undefined,
}),
});
@@ -877,6 +903,46 @@ export function OnboardingWizard({
</label>
))}
{/* Phase 9b: channel-user-id capture
during onboarding. For channels
where the customer's own user id
is needed for routing (Telegram,
Discord, Threema), collect it here
so the assistant is usable
immediately on provisioning. The
help text comes from the existing
channelUsers.<id>IdHelp keys
(same copy as the post-provisioning
page uses). Field is optional —
blank means "I'll add it later". */}
{pkg.collectsChannelUserId && (
<label className="block">
<span className="text-xs text-text-secondary mb-1 block">
{t(`yourChannelIdLabel.${pkg.id}`)}{" "}
<span className="text-text-muted normal-case">
({t("optional")})
</span>
</span>
<input
type="text"
placeholder={t(
`yourChannelIdPlaceholder.${pkg.id}`
)}
value={channelUserIds[pkg.id] ?? ""}
onChange={(e) =>
setChannelUserIds((prev) => ({
...prev,
[pkg.id]: e.target.value,
}))
}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted font-mono focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
<p className="text-[11px] text-text-muted mt-1 leading-relaxed whitespace-pre-line">
{t(`yourChannelIdHelp.${pkg.id}`)}
</p>
</label>
)}
{pkg.disclaimerKey && (
<label className="flex items-start gap-2 text-xs text-text-secondary">
<input

View File

@@ -297,17 +297,33 @@ export function PackageCard({
</button>
</div>
) : canEdit ? (
<button
onClick={enabled ? handleDisable : handleEnable}
disabled={saving}
className={`ml-auto rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
enabled
? "bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2"
: "bg-accent text-surface-0 hover:bg-accent-dim shadow-lg shadow-accent/20"
} disabled:opacity-50`}
>
{saving ? "…" : enabled ? t("packages.disable") : t("packages.enable")}
</button>
<div className="ml-auto flex items-center gap-2">
{/* Phase 9b: re-open the Threema info popup at any time
while Threema is enabled. The popup auto-opens after
a fresh enable; this button lets the customer see the
QR + bot ID again without having to disable + re-enable. */}
{pkg.id === "threema" && enabled && (
<button
onClick={() => setShowThreemaInfo(true)}
className="rounded-lg px-2 py-1.5 text-xs font-medium bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2 transition-colors cursor-pointer"
title={t("packages.showInfoTitle")}
aria-label={t("packages.showInfoTitle")}
>
{t("packages.showInfo")}
</button>
)}
<button
onClick={enabled ? handleDisable : handleEnable}
disabled={saving}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
enabled
? "bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2"
: "bg-accent text-surface-0 hover:bg-accent-dim shadow-lg shadow-accent/20"
} disabled:opacity-50`}
>
{saving ? "…" : enabled ? t("packages.disable") : t("packages.enable")}
</button>
</div>
) : (
// Slice 5: read-only viewers see a static badge instead of a
// toggle. The status badge above the divider already conveys