@proca/widget
Version:
Proca is an open-source campaign toolkit designed to empower activists and organisations in their digital advocacy efforts. It provides a flexible and customisable platform for creating and managing online petitions, email campaigns, and other forms of di
880 lines (813 loc) • 26.3 kB
JavaScript
import React, {
useState,
useEffect,
useCallback,
useRef,
useMemo,
} from "react";
import {
Button,
Collapse,
List,
FilledInput,
FormHelperText,
FormControl,
} from "@material-ui/core";
import Alert from "@material-ui/lab/Alert";
import EmailAction from "@components/email/Action";
import SkeletonListItem from "@components/layout/SkeletonListItem";
import ProgressCounter from "@components/ProgressCounter";
import Filter from "@components/filter/Filter";
import useData from "@hooks/useData";
import useToken, { extractTokens } from "@hooks/useToken";
import { useIsMobile } from "@hooks/useDevice";
import { sample } from "@lib/array";
import Register from "@components/Register";
import { useTranslation } from "react-i18next";
import { useCampaignConfig, useSetCampaignConfig } from "@hooks/useConfig";
import { useForm } from "react-hook-form";
import { Grid, Container } from "@material-ui/core";
import TextField from "@components/TextField";
import { makeStyles } from "@material-ui/core/styles";
import { mainLanguage } from "@lib/i18n";
import { getCountryName } from "@lib/i18n";
import { pickOne } from "@lib/text";
const useStyles = makeStyles(() => ({
list: {
position: "relative",
overflow: "auto",
maxHeight: 300,
},
}));
const EmailComponent = props => {
const classes = useStyles();
const config = useCampaignConfig();
const setConfig = useSetCampaignConfig();
const [profiles, setProfiles] = useState(props.targets || []);
const [selection, _setSelection] = useState(
config.component.email?.selectable ? [] : false
);
const groups = useRef(new Set()); //if selection by group, add them here
let onlySelected = groups.current.size;
const listRef = useRef(null);
const setSelection = selection => {
_setSelection(selection);
};
const selectAll = () => {
const r = profiles
.filter(target => !target.disabled)
.map(target => target.procaid);
_setSelection(r);
};
const [data, setData] = useData();
// const [filter, setFilter] = useState({country:null});
const [allProfiles, setAllProfiles] = useState(props.targets || []);
const [languages, setLanguages] = useState([]);
// need cleanup, alswaysUpdate and blockUpdate seems to be handling the same issue (decide if the subject/message needs to be loaded or not)
const [alwaysUpdate, setAlwaysUpdate] = useState(
config.component.email?.multilingual === true
);
const [blockUpdate, setBlock] = useState(false);
const isMobile = useIsMobile();
const { t } = useTranslation();
const emailProvider = useRef(undefined); // we don't know the email provider
const paramEmail = useMemo(
() => ({
subject: pickOne(t(["campaign:email.subject", "email.subject"], "")),
message: t(["campaign:email.body", "email.body"], ""),
}),
[t]
);
//this is not a "real" MTT, ie. we aren't sending individual emails to the targets but - for instance - weekly digests
const mttProcessing =
config.component.email?.server !== false &&
config.component.email?.server?.processing !== false;
const fallbackRandom = config.component.email?.fallbackRandom;
const fallbackArea = config.component.email?.fallbackArea;
const countryFiltered = config.component.email?.filter?.includes("country");
const postcodeFiltered = config.component.email?.filter?.includes("postcode");
const localeFiltered =
config.component.email?.filter?.includes("multilingual");
const sampleSize = config.component.email?.sample || 1;
const locale = config.locale;
useEffect(() => {
if (!props.targets) return;
setProfiles(props.targets);
}, [props.targets]);
useEffect(() => {
if (!data.subject && (paramEmail.subject || paramEmail.message)) {
setData(paramEmail);
}
}, [paramEmail, setData, data.subject]);
const form = useForm({
mode: "onBlur",
// nativeValidation: true,
defaultValues: Object.assign({}, paramEmail, data, {
language: config.locale,
}),
});
const {
watch,
getValues,
setValue,
setError,
clearErrors,
formState: { errors },
} = form;
const country = watch("country");
const fields = getValues(["subject", "message"]);
const [constituency, postcode, area] = watch([
"constituency",
"postcode",
"area",
]);
const tokenKeys = extractTokens(data["message"] || paramEmail.message);
const tokens = watch(tokenKeys);
useEffect(() => {
if (!alwaysUpdate) {
return;
}
if (fields.message === "" && paramEmail.message) {
setValue("message", paramEmail.message);
}
}, [paramEmail, alwaysUpdate]);
const handleMerging = text => {
if (!alwaysUpdate) {
return;
}
setValue("message", text);
};
if (tokenKeys.includes("targets")) tokens.targets = profiles;
useToken(data["message"], tokens, handleMerging);
// # todo more reacty, use the returned value instead of the handleMerging callback
const checkUpdated = () => {
// we do not automatically update the message as soon as the user starts changing it
setAlwaysUpdate(false);
};
useEffect(() => {
if (blockUpdate) return;
["subject", "message"].map(k => {
if (data[k] && (!fields[k] || alwaysUpdate)) {
const defaultValue = form.getValues();
if (tokenKeys.length) {
// there are token in the message
// const empty = { defaultValue: data[k], nsSeparator: false };
const empty = {
// undo d6d36b51e6a554ed045dde4687c92a1b24a4c9e1 in next line (letters can't be edited)
defaultValue: data[k] || defaultValue[k],
nsSeparator: false,
};
tokenKeys.forEach(d => (empty[d] = defaultValue[d] || ""));
empty.name = defaultValue.firstname
? `${defaultValue.firstname} ${defaultValue.lastname}`
: "";
if (empty.locality) {
empty.name += `\n${empty.locality}`;
}
form.setValue(k, t(data[k], empty));
} else {
form.setValue(k, data[k]);
}
}
return undefined;
});
}, [
{
firstname: tokens.firstname,
country: tokens.country ? getCountryName(tokens.country) : "",
},
fields,
tokenKeys,
data,
t,
form,
alwaysUpdate,
blockUpdate,
]);
// todo: clean the dependency
//
useEffect(() => {
const fetchData = async url => {
await fetch(url)
.then(res => {
if (!res.ok) {
throw res.statusText || res.status + " error";
}
return res.json();
})
.then(d => {
const languages = [];
if (config.hook && typeof config.hook["target:load"] === "function") {
d = config.hook["target:load"](d);
}
d.forEach(c => {
if (c.locale && !languages.includes(c.locale)) {
languages.push(c.locale);
}
if (c.country) c.country = c.country.toLowerCase();
});
setAllProfiles(d);
setLanguages(languages);
if (!config.component.email?.filter?.includes("country")) {
setProfiles(d);
}
if (postcodeFiltered) {
setProfiles([]);
}
if (config.component.email?.filter?.includes("random")) {
if (!config.component.email.sample) {
const i = d[Math.floor(Math.random() * d.length)];
setProfiles([i]);
} else {
const shuffled = d.sort(() => 0.5 - Math.random()); // biased, but good enough
setProfiles(shuffled.slice(0, config.component.email.sample));
}
}
})
.catch(error => {
const placeholder = {
name: error.toString(),
description: "Please check your internet connection and try later",
disabled: true,
};
setProfiles([placeholder]);
setAllProfiles([placeholder]);
});
};
if (!config.component.email?.to) {
const url =
typeof config.component.email?.listUrl === "string"
? config.component.email.listUrl
: `https://widget.proca.app/t/${config.campaign.name}.json`;
fetchData(url);
} else {
const emails =
typeof config.component.email?.to === "string"
? config.component.email.to?.split(",")
: [];
const to = [];
emails.forEach(d => {
return to.push({ email: d.trim() });
});
if (to.length == 0) return;
setAllProfiles(to);
setProfiles(to);
}
}, [config.component, config.hook, setAllProfiles]);
const filterLocale = useCallback(
locale => {
if (!locale) {
return [];
}
const d = allProfiles.filter(d => {
// console.log(d.area === area && d.constituency === -1,d.area,d.constituency,area);
return d.locale === locale;
});
setProfiles(d);
setData("targets", d);
setConfig(current => {
console.log("set lang", locale);
const next = { ...current };
next.lang = locale;
return next;
});
return d;
},
[allProfiles, setConfig, setData]
);
const filterArea = useCallback(
area => {
if (!area) {
return [];
}
let d = allProfiles.filter(d => {
// console.log(d.area === area && d.constituency === -1,d.area,d.constituency,area);
return d.area === area && d.constituency === -1;
});
if (d.length === 0) {
d = sample(allProfiles, sampleSize || fallbackRandom);
}
return d;
},
[sampleSize, fallbackRandom, allProfiles]
);
const filterProfiles = useCallback(
(country, constituency, area) => {
if (constituency || area) {
country = country || "?";
}
if (!country || typeof country !== "string") return;
if (allProfiles.length === 0) return; //profiles aren't loaded yet
country = country.toLowerCase();
let lang = undefined;
let d = allProfiles.filter(d => {
if (constituency) {
if (typeof constituency === "object") {
return constituency.includes(d.constituency);
}
return d.constituency === constituency;
}
if (d.lang && d.country === country) {
if (lang === undefined) {
lang = d.lang;
} else {
if (d.lang !== lang) {
lang = false;
}
}
}
return (
d.country === country ||
d.country === "" ||
d.constituency?.country === country
);
});
if (!lang && localeFiltered) {
// more than one lang in the country
if (languages.includes(locale)) {
lang = [locale];
} else {
lang = mainLanguage(country, false);
}
d = allProfiles.filter(
d =>
(d.locale ? lang.includes(d.locale) : true) && d.country === country
);
console.log(
"not lang",
`${lang}?`,
locale,
lang?.includes("fr"),
country
);
}
if (d.length === 0 && fallbackArea) {
console.log("fallback area");
d = filterArea(area);
}
if (d.length === 0 && fallbackRandom && !fallbackArea) {
d = sample(allProfiles, sampleSize || fallbackRandom);
}
if (d.length === 0 && postcodeFiltered && (!!constituency || !!area)) {
setError("postcode", {
message: t("target.postcode.empty", {
defaultValue: "there is no-one to contact in {{area}}",
area: area || postcode,
}),
type: "no_empty",
});
}
if (d.length === 0 && countryFiltered) {
setError("country", {
message: t("target.country.empty", {
country: getCountryName(country),
}),
type: "warning",
});
}
if (d.length > 0) {
clearErrors("country");
clearErrors("postcode");
}
//if (lang && config.lang !== lang) {
if (lang && typeof lang === "string") {
setConfig(current => {
const next = { ...current };
next.lang = lang || "en";
return next;
});
}
setProfiles(d);
setData("targets", d);
if (countryFiltered) {
setData("country", country);
}
},
[
allProfiles,
setProfiles,
setError,
clearErrors,
t,
setData,
fallbackRandom,
fallbackArea,
filterArea,
sampleSize,
setConfig,
localeFiltered,
countryFiltered,
postcodeFiltered,
postcode,
languages,
locale,
]
);
useEffect(() => {
if (!countryFiltered) return;
filterProfiles(country);
}, [country, filterProfiles, countryFiltered]);
useEffect(() => {
if (!postcodeFiltered) return;
filterProfiles(country, constituency, area);
}, [country, constituency, area, filterProfiles, postcodeFiltered]);
useEffect(() => {
if (!localeFiltered) return;
filterLocale(locale);
}, [filterLocale, localeFiltered, locale]);
const send = data => {
const hrefGmail = message => {
return `https://mail.google.com/mail/?view=cm&fs=1&to=${message.to}&su=${message.subject}${message.cc ? `&cc=${message.cc}` : ""}${message.bcc ? `&bcc=${message.bcc}` : ""}&body=${message.body}`;
};
let to = [];
let cc = null;
let bcc = null;
if (config.component.email?.bcc) {
bcc = config.component.email.bcc;
if (
config.component.email?.bccOptout === false &&
getValues("privacy") === "opt-out"
) {
bcc = null;
}
if (data.bcc === false) {
bcc = null;
}
}
//const paramEmail = {subject:t("campaign:email.subject",""),message:t("campaign:email.body","")};
let [subject, message, comment] = getValues([
"subject",
"message",
"comment",
]);
if (!message) message = paramEmail.message;
if (comment) message += `\n${comment}`;
const profiles = getTargets();
for (let i = 0; i < profiles.length; i++) {
if (profiles[i].email) to.push(profiles[i].email);
}
to = to.join(";");
if (config.component.email?.cc === true) {
cc = to;
to = null;
}
if (
config.component.email?.to &&
typeof config.component.email.to === "string"
) {
to = config.component.email.to;
}
const url = //link to gmail compose instead of the default mailto to avoid misconfiguration if we can
!isMobile &&
(data.email.includes("@gmail") || emailProvider.current === "google.com")
? hrefGmail({
to: to,
subject: encodeURIComponent(subject),
cc: cc,
bcc: bcc,
body: encodeURIComponent(message),
})
: `mailto:${to}?subject=${encodeURIComponent(subject)}${cc ? `&cc=${cc}` : ""}${bcc ? `&bcc=${bcc}` : ""}&body=${encodeURIComponent(message)}`;
if (!to) {
console.warn("no target, skip sending");
return false;
}
//var win = window.open(url, "_blank");
window.location.href = url;
/*
//TODO: display fallback using Clipboard.writeText()
var timer = setInterval(() => {
if (!win) {
addAction(config.actionPage, "email_blocked", { uuid: uuid() });
clearInterval(timer);
return;
}
if (win.closed) {
addAction(config.actionPage, "email_close", {
uuid: uuid(),
// tracking: Url.utm(),
payload: [],
});
clearInterval(timer);
props.done();
}
}, 1000);
*/
};
// <TwitterText text={actionText} handleChange={handleChange} label="Your message to them"/>
//
const ExtraFields = props => {
return (
<>
{config.component.email?.field?.subject ? (
<Grid item xs={12} className={props.classes.field}>
<TextField
form={props.form}
name="subject"
disabled={!!config.component.email.field.subject.disabled}
required={config.component.email?.field?.subject?.required}
label={t("Subject")}
onChange={checkUpdated}
onClick={() => {
setBlock(true);
}}
/>
</Grid>
) : (
<input type="hidden" {...props.form.register("subject")} />
)}
{config.component.email?.field?.message ? (
<Grid item xs={12} className={props.classes.field}>
{config.component.email.salutation && (
<FormControl fullWidth>
<FilledInput
fullWidth={true}
placeholder={`${t("email.salutation_placeholder")},`}
readOnly
/>
<FormHelperText>{t("email.salutation_info")}</FormHelperText>
</FormControl>
)}
<TextField
form={props.form}
name="message"
multiline
maxRows={config.component.email.field.message.disabled ? 4 : 10}
disabled={!!config.component.email.field.message.disabled}
required={config.component.email.field.message.required}
onChange={checkUpdated}
onClick={() => {
setBlock(true);
}}
label={t("Your message")}
/>
</Grid>
) : (
<input type="hidden" {...props.form.register("message")} />
)}
{config.component.email?.field?.comment && (
<Grid item xs={12}>
<TextField
form={props.form}
name="comment"
multiline
maxRows={3}
required={config.component.email.field.comment.required}
label={t("Comment")}
/>
</Grid>
)}
</>
);
};
const onClick = config.component.email?.server !== false ? null : send;
const prepareData = data => {
if (!data.privacy) data.privacy = getValues("privacy");
if (!data.message) data.message = getValues("message");
if (data.comment) data.message += `\n${data.comment}`;
if (config.component.email?.salutation) {
data.message = `{{target.fields.salutation}},\n${data.message}`;
}
if (mttProcessing === false) {
data.mttProcessing = false;
}
if (props.prepareData) data = props.prepareData(data);
//add external id for prefilled widgets
if (config.component?.email?.replace) {
for (const key in config.component.email.replace) {
const value = config.component.email.replace[key];
if (data.message.includes(key))
data.message = data.message.replace(key, value);
}
}
return data;
};
const getTargets = () => {
if (!selection) return profiles;
const filtered = profiles.filter(d => selection.includes(d.procaid));
if (filtered.length === 0 && selection.length > 0) {
// edge case: the postcode changed without properly resetting the selection
setSelection([]);
}
return filtered;
};
const filterTarget = useCallback(
(key, value) => {
//const filterTarget = (key, value) => {
if (typeof key === "function") {
// filter done from the filter component, eg. filter/Profile
const d = key(allProfiles);
if (typeof d === "object" && d.filter === "description") {
if (d.value) {
groups.current.add(d.key);
} else {
groups.current.delete(d.key);
}
onlySelected = groups.current.size; // update right away without waiting for a redraw
_setSelection(prev => {
let first = null;
const selection = new Set(prev);
profiles
.filter(target => target.description === d.key)
.forEach(target => {
if (d.value) {
if (!first) first = target.procaid;
// Add the procaid to the selection if the profile matches the filter
selection.add(target.procaid);
} else {
// Remove the procaid from the selection if the profile does not match the filter
selection.delete(target.procaid);
}
});
console.log(groups.current.size, onlySelected);
if (first) {
scrollToItem(first);
} else {
scrollToFirst(selection);
}
return Array.from(selection);
});
}
if (Array.isArray(d)) {
console.log("filter from array");
// setProfiles (d);
_setSelection(prev => {
const selection = [...prev];
console.log(prev);
//return prev; debug
const index = 0;
// select(index === -1);
if (index > -1) {
selection.splice(index, 1);
} else {
selection.push(key);
}
return selection;
});
}
return;
}
const d = allProfiles.filter(d => {
return d[key] === value;
});
if (d.length === 0) {
setError(key, {
message: t("target.country.empty", {
country: value,
}),
type: "no_empty",
});
} else {
clearErrors(key);
}
console.log("filter profiles");
setProfiles(d);
},
[allProfiles, profiles, setError]
);
if (
selection.length === 0 &&
profiles.length === 1 &&
!profiles[0].disabled
) {
// if only one, select it. needs to be put in an useEffect?
selectAll();
}
let selectAllEnabled = true;
if (
config.import &&
config.import.find(d => d.startsWith("filter")) &&
profiles.length > 30
) {
selectAllEnabled = false;
}
const scrollToItem = key => {
console.log(onlySelected);
if (onlySelected) return;
if (!listRef.current) return;
const itemElement = listRef.current.querySelector(`[data-key="${key}"]`);
if (!itemElement) return;
const listRect = listRef.current.getBoundingClientRect();
const itemRect = itemElement.getBoundingClientRect();
const scrollTop = itemRect.top - listRect.top + listRef.current.scrollTop;
// Use smoothscroll to scroll the list element
listRef.current.scrollTo({
top: scrollTop,
behavior: "smooth",
});
};
const scrollToFirst = selection => {
const first = profiles.find(d => {
return selection.has(d.procaid);
});
if (first) {
scrollToItem(first.procaid);
} else {
scrollToItem(profiles[0]?.procaid);
}
};
const displayed = profile => {
if (profile.display === false) return false;
return onlySelected
? selection && selection.includes(profile.procaid)
: true;
};
return (
<Container maxWidth="sm">
{config.component.email?.counter && (
<ProgressCounter actionPage={props.actionPage} />
)}
<Filter
profiles={profiles}
form={form}
selecting={filterTarget}
country={country}
constituency={constituency}
languages={languages}
filterLocale={filterLocale}
/>
{selection && (
<>
<input
type="hidden"
{...form.register("selection", {
validate: () => {
selection.length > 0 && setValue("selection", selection.length);
return selection !== 0;
},
})}
/>
{selection.length === 0 && profiles.length > 0 ? (
<Alert
severity={errors?.selection ? "error" : "info"}
action={
selectAllEnabled && (
<Button
color="primary"
size="small"
variant="contained"
onClick={() => selectAll()}
>
{t("select_all", { defaultValue: "Select all" })}
</Button>
)
}
>
{t("target.missing", {
defaultValue: "Select at least one out of {{total}}",
total: profiles.length,
})}
</Alert>
) : (
selection.length >= 1 && (
<Alert severity="success">
{t("target.selected", {
defaultValue: "{{total}} selected",
total: selection.length,
})}
</Alert>
)
)}
</>
)}
{config.component.email?.showTo !== false && (
<List className={classes.list} dense ref={listRef}>
{profiles.length === 0 &&
!config.component.email?.filter?.includes("postcode") &&
!constituency && <SkeletonListItem />}
{profiles.map((d, i) => (
<EmailAction
key={d.procaid || d.id || i}
actionPage={config.actionPage}
done={props.done}
display={displayed(d)}
disabled={d.disabled}
actionUrl={props.actionUrl || data.actionUrl}
actionText={t(["campaign:share.twitter", "campaign:share"])}
profile={d}
selection={selection}
setSelection={setSelection}
/>
))}
</List>
)}
<Collapse
in={profiles.length > 0 || config.component.email?.server !== false}
>
<Register
form={form}
emailProvider={emailProvider}
done={props.done}
buttonText={t(config.component.register?.button || "action.email")}
targets={
config.component.email?.server !== false ? getTargets() : null
}
beforeSubmit={prepareData}
onClick={onClick}
extraFields={ExtraFields}
/>
</Collapse>
</Container>
);
};
export default EmailComponent;