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
66 changes: 65 additions & 1 deletion frontend/src/components/ShareDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,22 @@ vi.mock('../lib/publications-client', () => ({
DisplayNameRequiredError: MockDisplayNameRequiredError,
}));

const fireSealMock = vi.fn();
vi.mock('./shared/SealMoment', () => ({
useSealMoment: () => ({ fire: fireSealMock, moment: null }),
}));

import { ShareDialog } from './ShareDialog';

// ShareDialog renders <Link> (the confirmed-public "Your profile" row, the
// no-friends "Friends page" link, and the guest sign-in prompt) — all need a
// Router context, mirroring this codebase's established MemoryRouter wrap.
function renderDialog(props: { resourceId?: string; resourceLabel: string; onClose: () => void }) {
function renderDialog(props: {
resourceId?: string;
resourceLabel: string;
colorIdentity?: string[];
onClose: () => void;
}) {
return render(
<MemoryRouter>
<ShareDialog kind="deck" {...props} />
Expand Down Expand Up @@ -81,6 +91,7 @@ beforeEach(() => {
revokedAt: null,
});
getPublicationMock.mockResolvedValue(null);
fireSealMock.mockClear();
useAuth.setState({
user: { id: 'u1', username: 'alice', role: 'user' },
status: 'authed',
Expand Down Expand Up @@ -213,6 +224,59 @@ describe('ShareDialog — going Public', () => {
});
});

describe('ShareDialog — first-publish seal (E150)', () => {
it('fires the seal with the deck colour identity on a genuine first publish', async () => {
publishDeckMock.mockResolvedValue({
slug: 'seal-deck',
url: 'https://spellcontrol.com/d/seal-deck',
publishedAt: 1,
updatedAt: 1,
unpublishedAt: null,
viewCount: 0,
copyCount: 0,
isFirstPublish: true,
});

renderDialog({
resourceId: 'd-seal-first',
resourceLabel: 'Test Deck',
colorIdentity: ['G', 'U'],
onClose: () => {},
});

fireEvent.click(screen.getByRole('radio', { name: 'Public' }));
fireEvent.click(
await screen.findByRole('button', { name: 'Make it public — anyone can view' })
);

await waitFor(() => expect(publishDeckMock).toHaveBeenCalledWith('d-seal-first'));
await waitFor(() => expect(fireSealMock).toHaveBeenCalledWith(['G', 'U']));
});

it('never fires the seal on a republish (isFirstPublish: false)', async () => {
publishDeckMock.mockResolvedValue({
slug: 'seal-deck-2',
url: 'https://spellcontrol.com/d/seal-deck-2',
publishedAt: 1,
updatedAt: 1,
unpublishedAt: null,
viewCount: 3,
copyCount: 1,
isFirstPublish: false,
});

renderDialog({ resourceId: 'd-seal-republish', resourceLabel: 'Test Deck', onClose: () => {} });

fireEvent.click(screen.getByRole('radio', { name: 'Public' }));
fireEvent.click(
await screen.findByRole('button', { name: 'Make it public — anyone can view' })
);

await waitFor(() => expect(publishDeckMock).toHaveBeenCalledWith('d-seal-republish'));
expect(fireSealMock).not.toHaveBeenCalled();
});
});

describe('ShareDialog — reopening an already-published deck', () => {
it('pre-selects Public with no extraneous createShare call', async () => {
getPublicationMock.mockResolvedValue({
Expand Down
18 changes: 17 additions & 1 deletion frontend/src/components/ShareDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect, useId, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { Modal } from './Modal';
import { ShareQrCode } from './shared/ShareQrCode';
import { useSealMoment } from './shared/SealMoment';
import { createShare, listShares, revokeShare, shareUrl } from '../lib/share-client';
import {
DisplayNameRequiredError,
Expand All @@ -11,6 +12,7 @@ import {
unpublishDeck,
type Publication,
} from '../lib/publications-client';
import { shouldCelebrateFirstPublish } from '../lib/first-publish-celebration';
import { updateProfile } from '../lib/auth-api';
import { listFriends, type Friend } from '../lib/friends-client';
import { isNativePlatform } from '../lib/platform';
Expand All @@ -34,6 +36,10 @@ interface Props {
resourceId?: string;
/** Display title for what's being shared, e.g. "Edric Combo" or "your collection". */
resourceLabel: string;
/** Deck-only: the commander(s)' color identity, for the first-publish seal
* moment's motes (E150). Omit for identity-less / non-deck shares — the
* seal falls back to gold, per STYLE_GUIDE's "colours are honest" ruling. */
colorIdentity?: string[];
onClose: () => void;
}

Expand All @@ -50,7 +56,7 @@ interface Props {
* PLAN.md §A1). The two systems compose: a link, a friends, a direct-to-
* Alice share, and a public listing of one resource can all coexist.
*/
export function ShareDialog({ kind, resourceId, resourceLabel, onClose }: Props) {
export function ShareDialog({ kind, resourceId, resourceLabel, colorIdentity, onClose }: Props) {
// Share links are per-account (they stay tied to the owner and are
// revocable), so a guest can't mint one — prompt them to sign in instead.
const isGuest = useAuth((s) => s.status === 'guest');
Expand Down Expand Up @@ -95,6 +101,12 @@ export function ShareDialog({ kind, resourceId, resourceLabel, onClose }: Props)
const previousLadderRef = useRef<LadderValue>('link');
const confirmBlockRef = useRef<HTMLDivElement>(null);
const displayNameId = useId();
// First-publish seal (E150): this dialog never navigates away on publish
// (it stays open showing the live link), so — unlike the creation-time
// fieldsets — it's safe to fire directly here rather than handing off to a
// landing page. Covers both entry surfaces that reuse this dialog as-is:
// the deck-editor visibility chip and the post-create DeckPublishNudge.
const { fire: fireSealMoment, moment: sealMoment } = useSealMoment();

// A direct share can't mint until a recipient is chosen — hold off until then.
const awaitingRecipient = audience === 'direct' && !addresseeId;
Expand Down Expand Up @@ -240,6 +252,9 @@ export function ShareDialog({ kind, resourceId, resourceLabel, onClose }: Props)
setPublication(pub);
setPendingPublicConfirm(false);
setNeedsDisplayName(false);
if (shouldCelebrateFirstPublish(resourceId, pub.isFirstPublish)) {
fireSealMoment(colorIdentity);
}
} catch (err) {
// Defense in depth: even if the client's cached displayName looked
// set, a display_name_required 400 re-shows the same sub-step rather
Expand Down Expand Up @@ -431,6 +446,7 @@ export function ShareDialog({ kind, resourceId, resourceLabel, onClose }: Props)
dismissable={!working}
className="choice-dialog share-dialog"
>
{sealMoment}
<h2 id="share-dialog-title" className="choice-dialog-title">
Share {resourceLabel}
</h2>
Expand Down
73 changes: 73 additions & 0 deletions frontend/src/components/deck/DeckPublishNudge.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/* DeckPublishNudge.css — post-create visibility nudge (E150).
Layout mirrors NavMigrationTip.css (in-flow row, not sticky — this is
page-content, not global chrome); the action + dismiss cluster mirrors
.auto-link-banner-actions (modals-dialogs.css). Same --info tint as
NavMigrationTip: informational, not a warning or an identity-confirmation. */

.deck-publish-nudge {
display: flex;
align-items: center;
gap: var(--space-4);
flex-wrap: wrap;
padding: var(--space-3) var(--space-4);
margin-bottom: var(--space-4);
background: color-mix(in srgb, var(--info) 12%, var(--surface-raised));
border: 1px solid var(--info);
border-radius: var(--radius-lg);
font-size: var(--text-sm);
color: var(--text-primary);
}

.deck-publish-nudge p {
flex: 1 1 220px;
margin: 0;
line-height: 1.45;
}

.deck-publish-nudge-actions {
display: flex;
align-items: center;
gap: var(--space-2);
flex-shrink: 0;
}

.deck-publish-nudge-dismiss {
position: relative;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
}

@media (hover: hover) and (pointer: fine) {
.deck-publish-nudge-dismiss:hover {
background: var(--surface);
color: var(--text-primary);
}
}

.deck-publish-nudge-dismiss:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}

/* 44px touch target without inflating the visible glyph — mirrors
.nav-migration-tip-dismiss / .set-filter-chip-x. */
@media (pointer: coarse) {
.deck-publish-nudge-dismiss::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 44px;
height: 44px;
transform: translate(-50%, -50%);
}
}
87 changes: 87 additions & 0 deletions frontend/src/components/deck/DeckPublishNudge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// @vitest-environment happy-dom
import { fireEvent, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuth } from '../../store/auth';

vi.mock('../../lib/platform', () => ({ isNativePlatform: vi.fn(() => false) }));
vi.mock('@capacitor/share', () => ({ Share: { share: vi.fn() } }));
vi.mock('../../lib/publications-client', () => ({
getPublication: () => Promise.resolve(null),
publishDeck: () => Promise.reject(new Error('not used in this suite')),
unpublishDeck: () => Promise.resolve(),
publicationUrl: (slug: string) => `https://spellcontrol.com/d/${slug}`,
DisplayNameRequiredError: class extends Error {},
}));
vi.mock('../../lib/share-client', () => ({
createShare: () => Promise.resolve({ token: 'tok', audience: 'link' }),
listShares: () => Promise.resolve([]),
revokeShare: () => Promise.resolve(),
shareUrl: (token: string) => `https://spellcontrol.com/s/${token}`,
}));

import { DeckPublishNudge } from './DeckPublishNudge';

function renderNudge() {
return render(
<MemoryRouter>
<DeckPublishNudge deckId="d1" deckName="Test Deck" />
</MemoryRouter>
);
}

beforeEach(() => {
useAuth.setState({
user: { id: 'u1', username: 'alice', role: 'user' },
status: 'authed',
error: null,
autoLinkedAt: null,
profile: {
displayName: 'Alice',
bio: null,
avatarCardId: null,
avatarCardName: null,
avatarImageUrl: null,
},
});
});

describe('DeckPublishNudge', () => {
it('renders the nudge with a Share action for an authed user', () => {
renderNudge();
expect(screen.getByRole('status').textContent).toContain('Only you can see this deck');
expect(screen.getByRole('button', { name: 'Share…' })).toBeTruthy();
});

it('renders nothing for a guest — ShareDialog would just hit a sign-in wall', () => {
useAuth.setState({
user: null,
status: 'guest',
error: null,
autoLinkedAt: null,
profile: null,
});
const { container } = renderNudge();
expect(container.firstChild).toBeNull();
});

it('dismiss hides it immediately, for the rest of this render', () => {
const { container } = renderNudge();
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }));
expect(container.firstChild).toBeNull();
});

it('opens ShareDialog on Share…, and closing it (for any reason) retires the nudge', async () => {
const { container } = renderNudge();
fireEvent.click(screen.getByRole('button', { name: 'Share…' }));

const dialogTitle = await screen.findByText('Share Test Deck');
expect(dialogTitle).toBeTruthy();

fireEvent.click(screen.getByRole('radio', { name: 'Private' }));
// Private on an already-private deck is a no-op that still lets Done close it.
fireEvent.click(await screen.findByRole('button', { name: 'Done' }));

expect(container.querySelector('.deck-publish-nudge')).toBeNull();
});
});
71 changes: 71 additions & 0 deletions frontend/src/components/deck/DeckPublishNudge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { useState } from 'react';
import { X } from 'lucide-react';
import { ShareDialog } from '../ShareDialog';
import { useAuth } from '../../store/auth';
import './DeckPublishNudge.css';

interface Props {
deckId: string;
deckName: string;
colorIdentity?: string[];
}

/**
* One-shot, dismissible post-create nudge toward the persistent
* DeckVisibilityChip above it (E150) — for the two entry surfaces that skip
* the creation-time visibility fieldset entirely (CopyDeckButton's one-tap
* copy; a multi-file import that lands on a single deck). The caller
* mounts this only when it's confirmed the deck was JUST created by one of
* those flows (a router-state one-shot flag, mirroring `justGenerated`) —
* decks created via /decks/new or the single-deck import path already
* surfaced the same choice inline (see `usePublishOnCreate`), so nudging
* again here would be redundant noise, not discoverability.
*
* "One-row, dismissible, non-displacing" per STYLE_GUIDE's insight-surface
* ruling — shape mirrors NavMigrationTip (in-flow, not sticky) crossed with
* AutoLinkBanner's action-button row. Local `dismissed` state only: this is
* scoped to a single page visit (the one-shot flag never survives a fresh
* navigation), so there's nothing to persist across sessions.
*/
export function DeckPublishNudge({ deckId, deckName, colorIdentity }: Props) {
const isGuest = useAuth((s) => s.status === 'guest');
const [dismissed, setDismissed] = useState(false);
const [shareOpen, setShareOpen] = useState(false);

// A guest can't publish — ShareDialog would just show its sign-in prompt,
// which isn't what "share it when you're ready" promises.
if (isGuest || dismissed) return null;

return (
<div className="deck-publish-nudge" role="status" aria-live="polite">
<p>Only you can see this deck. Share it when you&apos;re ready.</p>
<div className="deck-publish-nudge-actions">
<button type="button" className="btn btn-primary" onClick={() => setShareOpen(true)}>
Share…
</button>
<button
type="button"
className="deck-publish-nudge-dismiss"
aria-label="Dismiss"
onClick={() => setDismissed(true)}
>
<X width={16} height={16} strokeWidth={2} aria-hidden />
</button>
</div>
{shareOpen && (
<ShareDialog
kind="deck"
resourceId={deckId}
resourceLabel={deckName}
colorIdentity={colorIdentity}
onClose={() => {
setShareOpen(false);
// Engaging with Share at all — publish or not — is enough
// intent to retire the nudge for the rest of this visit.
setDismissed(true);
}}
/>
)}
</div>
);
}
Loading
Loading