Skip to content

Commit a420102

Browse files
Map auto-submit: portal-started maps prompt to join the gallery
The map-from-portal pathway, end to end: - Map-create buttons on a portal page carry the portal's slug (injected by the CMS content API alongside the form config, only when the portal has one); creating a map there sends portal_id, and the backend's draft submission comes back as submission_id — stored client-side in localStorage (draftSubmissions.ts) as the finalize capability, next to the document edit UUID it accompanies. - Flipping the map to ready-to-share (any of the three status controls — they all flow through useMetadataChange) opens SubmitToPortalModal: the abbreviated form (the portal's required fields only + one acknowledgement + Turnstile), since the map and portal tag are implicit. Submitting finalizes the draft — the backend clones the plan and the gallery entry is frozen; the user keeps editing their own map. - "Not now" suppresses the prompt for that map; the Save & Share menu keeps a "Submit to the portal" button while the draft exists. The plan's query-param handoff turned out unnecessary: map creation happens on the portal page itself, so the portal threads straight through the create call. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent b5af32d commit a420102

14 files changed

Lines changed: 322 additions & 23 deletions

File tree

app/src/app/components/Cms/RichTextEditor/extensions/MapCreateButtons/MapCreateButtons.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@ import Image from 'next/image';
77
export interface MapCreateButtonsProps {
88
views: Array<Pick<DistrictrMap, 'name' | 'districtr_map_slug'>>;
99
type: 'simple' | 'megaphone' | 'cards';
10-
/** Stored in created maps' metadata so tag-filtered galleries pick them up. */
11-
createTag?: string | null;
10+
/** Injected by the CMS content API on portal pages: maps started here get
11+
* a draft submission for this portal. */
12+
portalId?: string | null;
1213
}
13-
export const MapCreateButtons = ({views, type, createTag}: MapCreateButtonsProps) => {
14+
export const MapCreateButtons = ({views, type, portalId}: MapCreateButtonsProps) => {
1415
switch (type) {
1516
// Same start cards the place pages render, for visual consistency.
1617
case 'cards':
@@ -22,7 +23,7 @@ export const MapCreateButtons = ({views, type, createTag}: MapCreateButtonsProps
2223
view={view}
2324
isCommunity={false}
2425
showOutcome={false}
25-
createTag={createTag}
26+
portalId={portalId}
2627
/>
2728
))}
2829
</CardGrid>
@@ -31,7 +32,7 @@ export const MapCreateButtons = ({views, type, createTag}: MapCreateButtonsProps
3132
return (
3233
<CardGrid>
3334
{views.map(view => (
34-
<CreateButton key={view.districtr_map_slug} view={view} createTag={createTag} />
35+
<CreateButton key={view.districtr_map_slug} view={view} portalId={portalId} />
3536
))}
3637
</CardGrid>
3738
);
@@ -57,7 +58,7 @@ export const MapCreateButtons = ({views, type, createTag}: MapCreateButtonsProps
5758
<CreateButton
5859
key={view.districtr_map_slug}
5960
view={view}
60-
createTag={createTag}
61+
portalId={portalId}
6162
extraClasses="bg-districtrBlue text-white text-xl px-8 py-3 rounded-md font-bold hover:bg-blue-700 transition-colors cursor-pointer m-2"
6263
/>
6364
))}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
'use client';
2+
import {useEffect, useMemo, useState} from 'react';
3+
import {Blockquote, Box, Button, Checkbox, Dialog, Flex, Text, TextArea} from '@radix-ui/themes';
4+
import {useMapStore} from '@/app/store/mapStore';
5+
import {useDraftSubmissionStore} from '@/app/store/draftSubmissionStore';
6+
import {getDraftSubmission, updateDraftSubmission} from '@/app/utils/draftSubmissions';
7+
import {
8+
finalizeSubmission,
9+
getFormConfig,
10+
type FormConfigPublic,
11+
} from '@/app/utils/api/apiHandlers/postSubmission';
12+
import {FIELD_ORDER, FIELD_REGISTRY} from '@/app/components/Forms/fieldRegistry';
13+
import {useTurnstile} from '@/app/hooks/useTurnstile';
14+
15+
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
16+
17+
/**
18+
* The abbreviated submission form for maps started from a portal: shown when
19+
* the user flips their map to ready-to-share (useMetadataChange opens it via
20+
* draftSubmissionStore), or from the Save & Share menu while the draft
21+
* exists. Only the portal's required fields are asked — the map and the
22+
* portal tag are implicit; finalizing clones the plan server-side so the
23+
* gallery entry is frozen.
24+
*/
25+
export const SubmitToPortalModal: React.FC = () => {
26+
const promptDocumentId = useDraftSubmissionStore(state => state.promptDocumentId);
27+
const closePrompt = useDraftSubmissionStore(state => state.closePrompt);
28+
const setNotification = useMapStore(state => state.setNotification);
29+
30+
const draft = useMemo(() => getDraftSubmission(promptDocumentId), [promptDocumentId]);
31+
const [config, setConfig] = useState<FormConfigPublic | null>(null);
32+
const [values, setValues] = useState<Record<string, string>>({});
33+
const [acknowledged, setAcknowledged] = useState(false);
34+
const [isSubmitting, setIsSubmitting] = useState(false);
35+
const [error, setError] = useState('');
36+
const {TurnstileComponent, captchaToken} = useTurnstile();
37+
38+
useEffect(() => {
39+
setConfig(null);
40+
setValues({});
41+
setAcknowledged(false);
42+
setError('');
43+
if (!draft) return;
44+
getFormConfig(draft.portalId).then(response => {
45+
if (response.ok) setConfig(response.response);
46+
else setError('Could not load the portal form. Please try again later.');
47+
});
48+
}, [draft?.portalId, promptDocumentId]);
49+
50+
if (!promptDocumentId || !draft || draft.submitted) return null;
51+
52+
const requiredFields = FIELD_ORDER.filter(name => config?.required_fields?.includes(name));
53+
const isValid =
54+
!!config &&
55+
acknowledged &&
56+
!!captchaToken &&
57+
requiredFields.every(name => {
58+
const value = (values[name] ?? '').trim();
59+
if (!value) return false;
60+
if (name === 'email') return EMAIL_RE.test(value);
61+
const spec = FIELD_REGISTRY[name];
62+
return !spec.validator || spec.validator(value);
63+
});
64+
65+
const dismiss = () => {
66+
updateDraftSubmission(promptDocumentId, {suppressed: true});
67+
closePrompt();
68+
};
69+
70+
const submit = async () => {
71+
if (!isValid || isSubmitting) return;
72+
setIsSubmitting(true);
73+
const response = await finalizeSubmission(draft.submissionId, {
74+
fields: values,
75+
tags: [],
76+
turnstile_token: captchaToken,
77+
});
78+
setIsSubmitting(false);
79+
if (response.ok) {
80+
updateDraftSubmission(promptDocumentId, {submitted: true});
81+
closePrompt();
82+
setNotification({
83+
message:
84+
'Your map was submitted to the portal gallery. A frozen copy of the current plan was submitted — you can keep editing your own map.',
85+
importance: 2,
86+
type: 'success',
87+
});
88+
} else {
89+
setError(response.error);
90+
}
91+
};
92+
93+
return (
94+
<Dialog.Root open onOpenChange={open => !open && dismiss()}>
95+
<Dialog.Content maxWidth="480px">
96+
<Dialog.Title>Submit your map to the portal?</Dialog.Title>
97+
<Dialog.Description size="2" color="gray">
98+
Your map is marked ready to share. Submit a snapshot of the current plan to the{' '}
99+
{draft.portalId} gallery — you can keep editing your own map afterwards.
100+
</Dialog.Description>
101+
{error && (
102+
<Blockquote color="red" className="my-2">
103+
{error}
104+
</Blockquote>
105+
)}
106+
<Flex direction="column" gap="3" mt="3">
107+
{requiredFields.map(name => {
108+
const spec = FIELD_REGISTRY[name];
109+
const isTextArea = spec.component === TextArea;
110+
return (
111+
<Flex key={name} direction="column" gap="1">
112+
<Text as="label" size="2" weight="medium">
113+
{spec.label} *
114+
</Text>
115+
{isTextArea ? (
116+
<TextArea
117+
value={values[name] ?? ''}
118+
placeholder={spec.label}
119+
onChange={e => setValues(v => ({...v, [name]: e.target.value}))}
120+
/>
121+
) : (
122+
<input
123+
className="rt-TextFieldInput rt-r-size-2 border border-slate-300 rounded p-2"
124+
type={spec.type ?? 'text'}
125+
value={values[name] ?? ''}
126+
placeholder={spec.label}
127+
autoComplete={spec.autoComplete}
128+
onChange={e => setValues(v => ({...v, [name]: e.target.value}))}
129+
/>
130+
)}
131+
</Flex>
132+
);
133+
})}
134+
<Text as="label" size="2">
135+
<Flex gap="2" align="center">
136+
<Checkbox
137+
checked={acknowledged}
138+
onCheckedChange={checked => setAcknowledged(checked === true)}
139+
/>
140+
I understand that my submission will be made available to the Commission and other
141+
members of the public.
142+
</Flex>
143+
</Text>
144+
{TurnstileComponent}
145+
<Flex gap="3" justify="end">
146+
<Button variant="soft" color="gray" onClick={dismiss} disabled={isSubmitting}>
147+
Not now
148+
</Button>
149+
<Button onClick={submit} disabled={!isValid} loading={isSubmitting}>
150+
Submit to portal
151+
</Button>
152+
</Flex>
153+
</Flex>
154+
</Dialog.Content>
155+
</Dialog.Root>
156+
);
157+
};

app/src/app/components/Static/Interactions/CreateButton.tsx

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ import {MAP_TYPES} from '@constants/document/types';
88
import {MAP_ROUTES} from '@constants/document/routes';
99
import {Button} from '@radix-ui/themes';
1010
import {PlusIcon} from '@radix-ui/react-icons';
11-
import {useRouter, useSearchParams} from 'next/navigation';
11+
import {useRouter} from 'next/navigation';
1212
import {useEffect, useState} from 'react';
13-
import {handleCreateBlankMetadataObject} from '@/app/utils/metadata/handleCreateBlankMetadataObject';
13+
import {setDraftSubmission} from '@/app/utils/draftSubmissions';
1414

1515
/**
1616
* Creates a new map document from a DistrictrMap and routes to the editor.
@@ -19,19 +19,14 @@ import {handleCreateBlankMetadataObject} from '@/app/utils/metadata/handleCreate
1919
export const useCreateMapDocument = (
2020
view: Partial<DistrictrMap>,
2121
isCommunity?: boolean,
22-
createTag?: string | null
22+
portalId?: string | null
2323
) => {
2424
const router = useRouter();
2525
const userID = useMapStore(stat => stat.userID);
2626
const setUserID = useMapStore(stat => stat.setUserID);
2727
const setNotification = useMapStore(stat => stat.setNotification);
2828
const [isCreating, setIsCreating] = useState(false);
2929
const shouldMakeCommunity = isCommunity ?? routeManager.mapUrlRoute === MAP_ROUTES.COI;
30-
// The tag is stored in the map's metadata so tag-filtered galleries pick the
31-
// map up once its draft status moves past scratch. A CMS-configured tag wins;
32-
// otherwise a ?tag=... on the hosting page (e.g. a workshop portal) applies.
33-
const urlTag = useSearchParams().get('tag');
34-
const tag = createTag ?? urlTag;
3530

3631
useEffect(() => {
3732
!userID && setUserID();
@@ -43,9 +38,17 @@ export const useCreateMapDocument = (
4338
const r = await createMapDocument({
4439
districtr_map_slug: view.districtr_map_slug,
4540
map_type: shouldMakeCommunity ? MAP_TYPES.COMMUNITY : view.map_type,
46-
...(tag ? {metadata: {...handleCreateBlankMetadataObject(), tags: [tag]}} : {}),
41+
portal_id: portalId ?? undefined,
4742
});
4843
if (r.ok) {
44+
if (portalId && r.response.submission_id) {
45+
// Remember the finalize capability so flipping the map to
46+
// ready-to-share can offer submitting it to the portal.
47+
setDraftSubmission(r.response.document_id, {
48+
submissionId: r.response.submission_id,
49+
portalId,
50+
});
51+
}
4952
router.push(
5053
editPath(
5154
shouldMakeCommunity ? MAP_ROUTES.COI : MAP_ROUTES.DISTRICTS,
@@ -70,9 +73,10 @@ export const CreateButton: React.FC<{
7073
view: Partial<DistrictrMap>;
7174
extraClasses?: string;
7275
isCommunity?: boolean;
73-
createTag?: string | null;
74-
}> = ({view, extraClasses, isCommunity, createTag}) => {
75-
const {createPlan, isCreating} = useCreateMapDocument(view, isCommunity, createTag);
76+
/** Portal slug: new maps get a draft submission for this portal. */
77+
portalId?: string | null;
78+
}> = ({view, extraClasses, isCommunity, portalId}) => {
79+
const {createPlan, isCreating} = useCreateMapDocument(view, isCommunity, portalId);
7680

7781
return (
7882
<Button

app/src/app/components/Static/Interactions/PlaceMapGrid.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ export const MapStartCard: React.FC<{
1111
/** When false, omits the "Draw N districts" line — e.g. a portal grid of
1212
* same-kind maps where it would repeat on every card. */
1313
showOutcome?: boolean;
14-
createTag?: string | null;
15-
}> = ({view, isCommunity, showOutcome = true, createTag}) => {
16-
const {createPlan, isCreating} = useCreateMapDocument(view, isCommunity, createTag);
14+
/** Portal slug: new maps get a draft submission for this portal. */
15+
portalId?: string | null;
16+
}> = ({view, isCommunity, showOutcome = true, portalId}) => {
17+
const {createPlan, isCreating} = useCreateMapDocument(view, isCommunity, portalId);
1718
const outcome = isCommunity
1819
? 'Draw and describe your communities'
1920
: view.num_districts

app/src/app/components/Toolbar/SaveShareModal/SaveShareModal.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {editPath} from '@/app/utils/map/editUrl';
1414
import {useRouter} from 'next/navigation';
1515
import {createMapDocument} from '@/app/utils/api/apiHandlers/createMapDocument';
1616
import {ACCESS_STATES} from '@constants/document/state';
17+
import {getDraftSubmission} from '@/app/utils/draftSubmissions';
18+
import {useDraftSubmissionStore} from '@/app/store/draftSubmissionStore';
1719

1820
export const SaveShareModal: React.FC<{
1921
open: boolean;
@@ -36,6 +38,8 @@ export const SaveShareModal: React.FC<{
3638
// view-only users).
3739
const canShareAsOwner = !isEditing && !!editableDocId;
3840
const generateLink = useSaveShareStore(state => state.generateLink);
41+
const openSubmitPrompt = useDraftSubmissionStore(state => state.openPrompt);
42+
const draftSubmission = getDraftSubmission(mapDocument?.document_id);
3943
const sharingMode = useSaveShareStore(state => state.sharingMode);
4044
const sharePassword = useSaveShareStore(state => state.password);
4145
// Without a password, the editable share link contains the secret UUID.
@@ -112,6 +116,22 @@ export const SaveShareModal: React.FC<{
112116
/>
113117
<hr className="my-4" />
114118
<ShareMapSection isEditing={isEditing} />
119+
{isEditing && draftSubmission && !draftSubmission.submitted && (
120+
<Button
121+
variant="soft"
122+
color="violet"
123+
size="3"
124+
className="mt-2"
125+
onClick={() => {
126+
if (mapDocument?.document_id) {
127+
onClose();
128+
openSubmitPrompt(mapDocument.document_id);
129+
}
130+
}}
131+
>
132+
Submit to the {draftSubmission.portalId} portal
133+
</Button>
134+
)}
115135
{isEditing ? (
116136
<Flex direction="column" gap="2" className="mt-4">
117137
<Flex direction="row" gap="2" justify="between">

app/src/app/components/Topbar/Topbar.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {ArrowLeftIcon, HamburgerMenuIcon} from '@radix-ui/react-icons';
77
import {DocumentMetadata} from '@utils/api/apiHandlers/types';
88
import {defaultPanels} from '@components/sidebar/DataPanelUtils';
99
import {PasswordPromptModal} from '../Toolbar/PasswordPromptModal';
10+
import {SubmitToPortalModal} from '../MapPage/SubmitToPortalModal';
1011
import {UploaderModal} from '../Toolbar/UploaderModal';
1112
import {MapHeader} from './MapHeader';
1213
import {useMetadataChange} from '@/app/hooks/useMetadataChange';
@@ -111,6 +112,7 @@ export const Topbar: React.FC = () => {
111112
{isAutoSaving && <SavingPill message="Auto-saving your map…" />}
112113
<UploaderModal open={modalOpen === 'upload'} onClose={() => setModalOpen(null)} />
113114
<PasswordPromptModal />
115+
<SubmitToPortalModal />
114116
</>
115117
);
116118
};

app/src/app/constants/cms.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ type AnyMapCreateButtonsAttrSpec = {
3333
export const MAP_CREATE_BUTTONS_ATTRIBUTES = [
3434
{name: 'views', default: []},
3535
{name: 'type', default: 'simple'},
36-
{name: 'createTag', default: null},
36+
// Injected by the CMS content API on portal pages.
37+
{name: 'portalId', default: null},
3738
] as const satisfies readonly AnyMapCreateButtonsAttrSpec[];
3839

3940
type PlanGalleryAttrSpec<K extends keyof PlanGalleryProps> = {

app/src/app/hooks/useMetadataChange.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,17 @@ import {useMapStore} from '@/app/store/mapStore';
22
import {DocumentMetadata} from '@utils/api/apiHandlers/types';
33
import {saveMapDocumentMetadata} from '@utils/api/apiHandlers/saveMapDocumentMetadata';
44
import {idb} from '@utils/idb/idb';
5+
import {DRAFT_STATUSES} from '@constants/document/draftStatus';
6+
import {getDraftSubmission} from '@utils/draftSubmissions';
7+
import {useDraftSubmissionStore} from '@store/draftSubmissionStore';
58

69
/** Persist a metadata change (server + idb + store), notifying on failure.
710
* Shared by the topbar title/actions and the draft-status helper box. */
811
export function useMetadataChange() {
912
const mapDocument = useMapStore(state => state.mapDocument);
1013
const setNotification = useMapStore(state => state.setNotification);
1114
const updateMetadata = useMapStore(state => state.updateMetadata);
15+
const openPrompt = useDraftSubmissionStore(state => state.openPrompt);
1216

1317
return async (updates: Partial<DocumentMetadata>) => {
1418
if (!mapDocument?.document_id) return;
@@ -19,6 +23,15 @@ export function useMetadataChange() {
1923
if (response.ok) {
2024
idb.updateIdbMetadata(mapDocument.document_id, updates);
2125
updateMetadata(updates);
26+
// Map-from-portal pathway: flipping to ready-to-share offers
27+
// submitting the plan to the portal's gallery (once — "Not now"
28+
// suppresses the prompt; the Save & Share menu keeps a manual button).
29+
if (updates.draft_status === DRAFT_STATUSES.READY_TO_SHARE) {
30+
const draft = getDraftSubmission(mapDocument.document_id);
31+
if (draft && !draft.submitted && !draft.suppressed) {
32+
openPrompt(mapDocument.document_id);
33+
}
34+
}
2235
} else {
2336
setNotification({
2437
message: 'Failed to save metadata',
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import {create} from 'zustand';
2+
3+
/** UI state for the submit-to-portal prompt (SubmitToPortalModal). */
4+
interface DraftSubmissionPromptState {
5+
/** Document whose draft submission the modal is offering to finalize. */
6+
promptDocumentId: string | null;
7+
openPrompt: (documentId: string) => void;
8+
closePrompt: () => void;
9+
}
10+
11+
export const useDraftSubmissionStore = create<DraftSubmissionPromptState>(set => ({
12+
promptDocumentId: null,
13+
openPrompt: documentId => set({promptDocumentId: documentId}),
14+
closePrompt: () => set({promptDocumentId: null}),
15+
}));

0 commit comments

Comments
 (0)