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
10 changes: 2 additions & 8 deletions pkgs/frontend/app/components/composite/treat-emoji-slider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,14 +124,8 @@ function TreatEmojiSlider({
return true;
}, [max, onChange]);

const decrementPress = useRepeatingPress(
decrement,
!disabled && value > 0,
);
const incrementPress = useRepeatingPress(
increment,
!disabled && value < max,
);
const decrementPress = useRepeatingPress(decrement, !disabled && value > 0);
const incrementPress = useRepeatingPress(increment, !disabled && value < max);

const { index: emojiKey, emoji } = useMemo(
() => getTreatEmojiByRatio(value, max),
Expand Down
31 changes: 21 additions & 10 deletions pkgs/frontend/app/components/members/MemberDetailContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useGetBalanceOfFractionTokens } from "hooks/useFractionToken";
import { useGetMintThanksTokens } from "hooks/useThanksToken";
import { useActiveWallet } from "hooks/useWallet";
import { type FC, useMemo, useState } from "react";
import { SiDiscord } from "react-icons/si";
import { Link } from "react-router";
import type { HatsDetailSchama } from "types/hats";
import { ipfs2https } from "utils/ipfs";
Expand Down Expand Up @@ -181,16 +182,26 @@ export const MemberDetailContent: FC<MemberDetailContentProps> = ({
</Typography>
)}
{isMe && (
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => setEditOpen(true)}
className="mt-1"
>
<Icon name="edit" size={14} />
プロフィールを編集
</Button>
<div className="mt-1 flex flex-wrap items-center justify-center gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => setEditOpen(true)}
>
<Icon name="edit" size={14} />
プロフィールを編集
</Button>
{/* Personal wallet↔Discord binding. The verifier_token can't be
carried in-app, so /connect/discord shows a paste field when it
arrives without one; treeId lets it route to the bot page after. */}
<Button asChild variant="secondary" size="sm">
<Link to={`/connect/discord?treeId=${treeId}`}>
<SiDiscord size={14} className="text-[#5865F2]" />
Discord を連携
</Link>
</Button>
</div>
)}
</div>

Expand Down
43 changes: 43 additions & 0 deletions pkgs/frontend/app/routes/$treeId_.settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { useGetWorkspace } from "hooks/useWorkspace";
import type { NameData } from "namestone-sdk";
import { type FC, useCallback, useEffect, useMemo, useState } from "react";
import { LuCheck } from "react-icons/lu";
import { SiDiscord } from "react-icons/si";
import { useNavigate, useParams } from "react-router";
import { toast } from "sonner";
import type { HatsDetailSchama } from "types/hats";
Expand Down Expand Up @@ -356,6 +357,47 @@ const OtherSection: FC<OtherSectionProps> = ({ treeId }) => {
);
};

interface ExternalIntegrationSectionProps {
treeId: string;
}

// Workspace-level external-service links. The Discord bot page needs only the
// treeId (already in scope here), so it can be opened directly — no token /
// query param to prompt for. The personal wallet↔account binding
// (/connect/discord) lives on the member's own profile instead, since it needs
// a per-user verifier_token this admin surface can't supply.
const ExternalIntegrationSection: FC<ExternalIntegrationSectionProps> = ({
treeId,
}) => {
const navigate = useNavigate();
return (
<>
<SectionLabel>外部サービス連携</SectionLabel>
<div className="px-4">
<Card className="gap-0 p-0">
<Row
left={
<span className="flex size-9 items-center justify-center rounded-full bg-[#F0EBE0]">
<SiDiscord size={18} className="text-[#5865F2]" />
</span>
}
title="Discord bot 連携"
subtitle="/thx の mint 許可・Quest 代理申請を設定"
right={
<Icon
name="chevron-right"
size={16}
className="text-text-secondary"
/>
}
onClick={() => navigate(`/${treeId}/discord-bot`)}
/>
</Card>
</div>
</>
);
};

const authorityWearersQueryKey = (hatId: string | undefined) =>
["authorityWearers", hatId ?? null] as const;

Expand Down Expand Up @@ -786,6 +828,7 @@ const WorkspaceSettings: FC = () => {
<div className="flex flex-col gap-2">
<BasicInfoSection wallet={wallet} treeId={treeId} topHat={topHat} />
<OtherSection treeId={treeId} />
<ExternalIntegrationSection treeId={treeId} />
<AuthoritiesSection wallet={wallet} treeId={treeId} topHat={topHat} />
</div>
</div>
Expand Down
88 changes: 80 additions & 8 deletions pkgs/frontend/app/routes/connect.discord.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card";
import { Heading } from "~/components/ui/heading";
import { Icon } from "~/components/ui/icon";
import { Textarea } from "~/components/ui/textarea";
import { Typography } from "~/components/ui/typography";
import { withBigIntJSON } from "~/lib/bigint-json";

Expand All @@ -45,6 +46,29 @@ function decodeVerifierClaims(token: string): VerifierClaims | null {
}
}

// Accepts what the user pastes when they arrive without a token in the URL
// (e.g. from an in-app "Discord を連携" link that can't carry the credential):
// either the full /toban-setup DM link (…/connect/discord#token=<jwt> or
// ?token=<jwt>) or a bare verifier_token JWT. Returns the extracted token, or
// null when nothing usable is found.
function extractVerifierToken(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
const inUrl = trimmed.match(/[#&?]token=([^&\s]+)/);
if (inUrl) {
try {
return decodeURIComponent(inUrl[1]);
} catch {
return inUrl[1];
}
}
// Bare JWT: three base64url segments separated by dots.
if (/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(trimmed)) {
return trimmed;
}
return null;
}

// Maps the stable ConnectErrorCode set returned by `@toban/identity`'s
// /api/connect handler. Anything not listed falls back to a generic
// message; the codes themselves are the long-term API contract.
Expand Down Expand Up @@ -108,9 +132,16 @@ const ConnectDiscord: FC = () => {
// the wild.
const tokenFromHash = useVerifierTokenFromHash();
const tokenFromQuery = params.get("token");
const token = tokenFromHash ?? tokenFromQuery;
// Fallback for in-app entry points (e.g. a member's own profile) that link
// here but can't carry the verifier_token: the user pastes the DM link or the
// token itself and we promote it to the active token via `manualToken`.
const [manualToken, setManualToken] = useState<string | null>(null);
const token = tokenFromHash ?? tokenFromQuery ?? manualToken;
const treeId = params.get("treeId");

const [pasteValue, setPasteValue] = useState("");
const [pasteError, setPasteError] = useState(false);

const claims = useMemo(
() => (token ? decodeVerifierClaims(token) : null),
[token],
Expand Down Expand Up @@ -272,15 +303,56 @@ const ConnectDiscord: FC = () => {
if (!token) {
return (
<PageContainer className="flex flex-col gap-6 pt-8 pb-12 md:pt-12">
<Heading variant="h2" level={1}>
Discord 連携
</Heading>
<header className="flex flex-col gap-1">
<Typography variant="bodySm" tone="secondary">
外部サービス連携
</Typography>
<Heading variant="h2" level={1}>
Discord を連携
</Heading>
</header>
<Card>
<CardContent>
<Typography variant="body" tone="danger">
URL に verifier_token が含まれていません。Discord で
`/toban-setup` を実行してから、案内された URL を開いてください。
<CardContent className="flex flex-col gap-4">
<Typography variant="bodySm" tone="secondary">
Discord で <code>/toban-setup</code> を実行すると、連携用リンクが
DM
で届きます。そのリンクを開くと、この画面に自動で読み込まれます。
</Typography>
<Typography variant="bodySm" tone="secondary">
手動で進める場合は、DM に届いたリンク(または token
文字列)を下に貼り付けてください。
</Typography>
<Textarea
rows={3}
placeholder="https://…/connect/discord#token=… または token 文字列"
value={pasteValue}
onChange={(e) => {
setPasteValue(e.target.value);
if (pasteError) setPasteError(false);
}}
/>
{pasteError && (
<Typography variant="caption" tone="danger">
token を読み取れませんでした。DM
のリンクをそのまま貼り付けてください。
</Typography>
)}
<Button
full
disabled={!pasteValue.trim()}
onClick={() => {
const extracted = extractVerifierToken(pasteValue);
if (!extracted) {
setPasteError(true);
return;
}
setManualToken(extracted);
setPasteError(false);
}}
>
続ける
<Icon name="arrow-right" size={18} />
</Button>
</CardContent>
</Card>
</PageContainer>
Expand Down
4 changes: 1 addition & 3 deletions pkgs/frontend/hooks/useThanksToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,7 @@ const allRecipientsIndexed = (
.filter((m) => mintMatchesSend(m, criteria))
.map((m) => m.to.toLowerCase()),
);
return criteria.toAddresses.every((to) =>
indexedTos.has(to.toLowerCase()),
);
return criteria.toAddresses.every((to) => indexedTos.has(to.toLowerCase()));
};

/**
Expand Down
Loading