diff --git a/apps/web/package.json b/apps/web/package.json index 7aedea9..0b5e8af 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -25,6 +25,7 @@ "iron-session": "^8.0.4", "next": "^16.0.0", "next-themes": "^0.4.4", + "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0", "tailwind-merge": "^2.6.0", @@ -32,6 +33,7 @@ "zustand": "^5.0.2" }, "devDependencies": { + "@types/qrcode": "^1.5.6", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", "autoprefixer": "^10.4.20", diff --git a/apps/web/src/app/dashboard/[guildId]/forms/page.tsx b/apps/web/src/app/dashboard/[guildId]/forms/page.tsx index 5bb93a3..5b15ce8 100644 --- a/apps/web/src/app/dashboard/[guildId]/forms/page.tsx +++ b/apps/web/src/app/dashboard/[guildId]/forms/page.tsx @@ -1,7 +1,9 @@ import { Card, StatusBadge } from "@msk-forms/ui"; import type { Route } from "next"; import Link from "next/link"; +import QRCode from "qrcode"; +import { ShareButton } from "@/components/dashboard/share-button"; import { appBaseUrl } from "@/lib/url"; import { getGuildForms } from "@/lib/guild"; import { getDict } from "@/i18n"; @@ -33,6 +35,16 @@ export default async function GuildFormsPage({ archived: dict.builder.statusArchived, }; + // Pre-render a QR code (server-side, no client dependency) for each live form. + const qrByForm: Record = {}; + await Promise.all( + forms + .filter((f) => f.status === "live") + .map(async (f) => { + qrByForm[f.id] = await QRCode.toDataURL(`${base}/f/${f.slug}`, { width: 220, margin: 1 }); + }), + ); + return (
@@ -53,41 +65,49 @@ export default async function GuildFormsPage({ ) : (
- {forms.map((form) => ( - -
-
- {form.title} - + {forms.map((form) => { + const qr = qrByForm[form.id]; + return ( + +
+
+
+ {form.title} + +
+ + {form._count.submissions} {t.countSubmissions} + {form.status === "live" && ( + <> + {" · "} + + /f/{form.slug} + + + )} + +
+ + {t.edit} +
- - {form._count.submissions} {t.countSubmissions} - {form.status === "live" && ( - <> - {" · "} - - /f/{form.slug} - - - )} - -
- - {t.edit} - - - ))} + {form.status === "live" && qr && ( + + )} + + ); + })}
)}
diff --git a/apps/web/src/components/dashboard/share-button.tsx b/apps/web/src/components/dashboard/share-button.tsx new file mode 100644 index 0000000..48b9453 --- /dev/null +++ b/apps/web/src/components/dashboard/share-button.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { Button } from "@msk-forms/ui"; +import { useState } from "react"; + +export interface ShareLabels { + share: string; + link: string; + copy: string; + copied: string; + qrCode: string; + embedCode: string; +} + +/** + * Per-form distribution panel (concept §4/§9): public link, a (server-rendered) + * QR code, and an embed snippet — each with a copy button. Toggled open inline. + */ +export function ShareButton({ + url, + qrDataUrl, + t, +}: { + url: string; + qrDataUrl: string; + t: ShareLabels; +}) { + const [open, setOpen] = useState(false); + const [copied, setCopied] = useState(null); + + const embed = ``; + + async function copy(text: string, key: string) { + try { + await navigator.clipboard.writeText(text); + setCopied(key); + setTimeout(() => setCopied((c) => (c === key ? null : c)), 1500); + } catch { + /* clipboard unavailable — ignore */ + } + } + + const CopyBtn = ({ text, k }: { text: string; k: string }) => ( + + ); + + return ( +
+ + + {open && ( +
+ + +
+ + {t.qrCode} + + {t.qrCode} +
+ +