Use i18next and extract strings

This commit is contained in:
Ben Grant 2021-05-18 21:28:14 +10:00
parent d2e5bcc4cb
commit 2534ff289e
26 changed files with 588 additions and 162 deletions

View file

@ -15,9 +15,13 @@
"axios": "^0.21.1", "axios": "^0.21.1",
"dayjs": "^1.10.4", "dayjs": "^1.10.4",
"gapi-script": "^1.2.0", "gapi-script": "^1.2.0",
"i18next": "^20.2.4",
"i18next-browser-languagedetector": "^6.1.1",
"i18next-http-backend": "^1.2.4",
"react": "^17.0.1", "react": "^17.0.1",
"react-dom": "^17.0.1", "react-dom": "^17.0.1",
"react-hook-form": "^6.15.4", "react-hook-form": "^6.15.4",
"react-i18next": "^11.8.15",
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",
"react-scripts": "4.0.3", "react-scripts": "4.0.3",
"typescript": "^4.2.2", "typescript": "^4.2.2",

View file

@ -0,0 +1,63 @@
{
"name": "Crab Fit",
"tagline": "Create your own",
"cta": "Create your own Crab Fit!",
"created": "Created",
"donate": {
"info": "Thank you for using Crab Fit. If you like it, consider donating.",
"button": "Donate",
"title": "Every amount counts :)",
"options": {
"$2": "Donate $2",
"$5": "Donate $5",
"$10": "Donate $10",
"choose": "Choose an amount"
},
"messages": {
"about": "Did you know that Crab Fit costs more that $100 per month? If it's helped you out at all, consider donating to help keep it running. 🦀",
"success": "Thank you for your donation! Without you, Crab Fit wouldn't be free, so thank you and keep being super awesome!",
"error": "Cannot make donation through Google. Please try donating through the website crab.fit 🦀"
}
},
"options": {
"name": "Options",
"weekStart": {
"label": "Week starts on",
"options": {
"Sunday": "Sunday",
"Monday": "Monday"
}
},
"timeFormat": {
"label": "Time format",
"options": {
"12h": "12h",
"24h": "24h"
}
},
"theme": {
"label": "Theme",
"options": {
"System": "System",
"Light": "Light",
"Dark": "Dark"
}
},
"highlight": {
"label": "Highlight highest availability",
"title": "Make the highest availability on the heatmap stand out",
"options": {
"Off": "Off",
"On": "On"
}
},
"language": {
"label": "Language",
"options": {
"en-US": "English (US)",
"de": "German",
"cimode": "Dev (keys)"
}
}
}
}

View file

@ -0,0 +1,62 @@
{
"available": "available",
"nav": {
"title": "Click to copy",
"copied": "Copied!",
"shareinfo": "Copy the link to this page, or share via <1>email</1>.",
"email_subject": "Scheduling {{event_name}}",
"email_body": "Visit this link to enter your availabilities:"
},
"form": {
"signed_out": "Sign in to add your availability",
"signed_in": "Signed in as {{name}}",
"name": "Your name",
"password": "Password (optional)",
"button": "Login",
"info": "These details are only for this event. Use a password to prevent others from changing your availability.",
"timezone": "Your time zone",
"errors": {
"password_incorrect": "Password is incorrect. Check your name is spelled right.",
"unknown": "Failed to login. Please try again."
},
"created_in_timezone": "This event was created in the timezone <strong>{{timezone}}</strong>. <3>Click here</3> to use it.",
"local_timezone": "Your local timezone is detected to be <strong>{{timezone}}</strong>. <3>Click here</3> to use it."
},
"offline": {
"title": "You are offline",
"body": "A Crab Fit doesn't work offline.<br />Make sure you're connected to the internet and try again."
},
"error": {
"title": "Event not found",
"body": "Check that the url you entered is correct."
},
"tabs": {
"you": "Your availability",
"you_tooltip": "Login to set your availability",
"group": "Group availability"
},
"group": {
"legend_tooltip": "Click to highlight highest availability",
"info1": "Hover or tap the calendar below to see who is available",
"info2": "Click the names below to view people individually"
},
"you": {
"info": "Click and drag the calendar below to set your availabilities",
"google_cal": {
"login": "Sync with Google Calendar",
"logout": "log out",
"select_all": "Select all",
"select_none": "Select none",
"info": "Importing will overwrite your current availability",
"button": "Import availability"
}
}
}

View file

@ -0,0 +1,23 @@
{
"name": "How to Crab Fit",
"p1": "Crab Fit is a tool that helps you when planning events with friends or coworkers. You just create an event, enter your availability, send it out, and see when everyone is free!",
"p2": "See below for detailed steps of how to Crab Fit your event.",
"s1": "Step 1",
"p3": "Use the form at <1>crab.fit</1> to make a new event. You only need to put in the rough time period for when your event occurs here, not your availability.",
"p4": "For example, we'll use \"Jenny's Birthday Lunch\". Jenny wants her birthday lunch to happen on the same week as her birthday, the 15th of April, but she knows that not all of her friends are available on the 15th. She also doesn't want to do it on the weekend.",
"p5": "Jenny also knows that since it's a lunch event, it can't start before 11am or go any later than 5pm.",
"s2": "Step 2",
"p6": "Enter your availability for the event you just created.",
"p7": "In our example, Jenny now puts in her availability for her birthday lunch. She is free all week, except after 3pm on Tuesday and Wednesday, and before 1pm on Friday.",
"s3": "Step 3",
"p8": "Send the link to everyone you want to come.",
"p9": "After Jenny has sent the link to her friends and waited for them to also fill out their availabilities, she can now easily see them all on the heatmap below and choose the darkest area for a time that suits everyone!",
"p10": "In this example, 1pm to 3pm on Friday the 16th works for all Jenny's friends."
}

View file

@ -0,0 +1,57 @@
{
"create": "CREATE A",
"recently_visited": "Recently visited",
"nav": {
"about": "About",
"donate": "Donate"
},
"form": {
"name": {
"label": "Give your event a name!",
"sublabel": "Or leave blank to generate one"
},
"dates": {
"label": "What dates might work?",
"sublabel": "Click and drag to select",
"options": {
"specific": "Specific dates",
"week": "Days of the week"
},
"tooltips": {
"previous": "Previous month",
"next": "Next month",
"today": "today"
}
},
"times": {
"label": "What times might work?",
"sublabel": "Click and drag to select a time range"
},
"timezone": {
"label": "And the timezone",
"defaultOption": "Select..."
},
"button": "Create",
"errors": {
"no_dates": "You haven't selected any dates!",
"same_times": "The start and end times can't be the same",
"no_time": "You don't have any time selected",
"unknown": "An error ocurred while creating the event. Please try again later."
}
},
"offline": "You can't create a Crab Fit when you don't have an internet connection. Please make sure you're connected.",
"about": {
"name": "About Crab Fit",
"events": "Events created",
"availabilities": "Availabilities entered",
"content": {
"p1": "Crab Fit helps you fit your event around everyone's schedules. Simply create an event above and send the link to everyone that is participating. Results update live and you will be able to see a heat-map of when everyone is free.<1/><2>Learn more about how to Crab Fit</2>.",
"p2": "Create a lot of Crab Fits? Get the <1>Chrome extension</1> or <3>Firefox extension</3> for your browser! You can also download the <5>Android app</5> to Crab Fit on the go.",
"p3": "Created by <1>Ben Grant</1>, Crab Fit is the modern-day solution to your group event planning debates.",
"p4": "The code for Crab Fit is open source, if you find any issues or want to contribute, you can visit the <1>repository</1>. By using Crab Fit you agree to the <3>privacy policy</3>.",
"p5": "Crab Fit costs more than <strong>$100 per month</strong> to run. Consider donating below if it helped you out so it can stay free for everyone. 🦀"
}
}
}

View file

@ -0,0 +1,52 @@
{
"name": "Privacy Policy",
"p1": "This SERVICE is provided by Benjamin Grant at no cost and is intended for use as is.",
"p2": "This page is used to inform visitors regarding the policies of the collection, use, and disclosure of Personal Information if using the Service.",
"p3": "If you choose to use the Service, then you agree to the collection and use of information in relation to this policy. The Personal Information that is collected is used for providing and improving the Service. Your information will not be used or shared with anyone except as described in this Privacy Policy.",
"h1": "Information Collection and Use",
"p4": "The Service uses third party services that may collect information used to identify you.",
"p5": "Links to privacy policies of the third party service providers used by the Service:",
"link": "Google Play Services",
"h2": "Log Data",
"p6": "When you use the Service, in the case of an error, data and information is collected to improve the Service, which may include your IP address, device name, operating system version, app configuration and the time and date of the error.",
"h3": "Cookies",
"p7": "Cookies are files with a small amount of data that are commonly used as anonymous unique identifiers. These are sent to your browser from the websites that you visit and are stored on your device's internal memory.",
"p8": "Cookies are used by Google Analytics to track you across the web and provide anonymous statistics to improve the Service.",
"h4": "Service Providers",
"p9": "Third-party companies may be employed for the following reasons:",
"l1": "To facilitate the Service",
"l2": "To provide the Service on our behalf",
"l3": "To perform Service-related services",
"l4": "To assist in analyzing how the Service is used",
"p10": "To perform these tasks, the third parties may have access to your Personal Information, but are obligated not to disclose or use this information for any purpose except the above.",
"h5": "Security",
"p11": "Personal Information that is shared via the Service is protected, however remember that no method of transmission over the internet, or method of electronic storage is 100% secure and reliable, so take care when sharing Personal Information.",
"h6": "Links to Other Sites",
"p12": "The Service may contain links to other sites. If you click on a third-party link, you will be directed to that site. Note that these external sites are not operated by the Service. Therefore, you are advised to review the Privacy Policy of these websites.",
"h7": "Children's Privacy",
"p13": "The Service does not address anyone under the age of 13. Personally identifiable information is not knowingly collected from children under 13. If discovered that a child under 13 has provided the Service with personal information, such information will be immediately deleted from the servers. If you are a parent or guardian and you are aware that your child has provided the Service with personal information, please <1>contact us</1> so that this information can be removed.",
"h8": "Changes to This Privacy Policy",
"p14": "This Privacy Policy may be updated from time to time. Thus, you are advised to review this page periodically for any changes.",
"p15": "This policy is effective as of 2021-04-20",
"h9": "Contact Us",
"p16": "If you have any questions or suggestions about the Privacy Policy, do not hesitate to contact us at <1>benjamin.grantGRA0007+crabfit@gmail.com</1>."
}

View file

@ -139,7 +139,9 @@ const App = () => {
)} /> )} />
</Switch> </Switch>
<Suspense fallback={<Loading />}>
<Settings /> <Settings />
</Suspense>
{eggVisible && <Egg eggKey={eggKey} onClose={() => setEggVisible(false)} />} {eggVisible && <Egg eggKey={eggKey} onClose={() => setEggVisible(false)} />}
</ThemeProvider> </ThemeProvider>

View file

@ -1,4 +1,5 @@
import { useState, useRef, Fragment } from 'react'; import { useState, useRef, Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import localeData from 'dayjs/plugin/localeData'; import localeData from 'dayjs/plugin/localeData';
import customParseFormat from 'dayjs/plugin/customParseFormat'; import customParseFormat from 'dayjs/plugin/customParseFormat';
@ -20,7 +21,7 @@ import {
} from 'components/AvailabilityViewer/availabilityViewerStyle'; } from 'components/AvailabilityViewer/availabilityViewerStyle';
import { Time } from './availabilityEditorStyle'; import { Time } from './availabilityEditorStyle';
import { GoogleCalendar } from 'components'; import { GoogleCalendar, Center } from 'components';
dayjs.extend(localeData); dayjs.extend(localeData);
dayjs.extend(customParseFormat); dayjs.extend(customParseFormat);
@ -36,6 +37,7 @@ const AvailabilityEditor = ({
onChange, onChange,
...props ...props
}) => { }) => {
const { t } = useTranslation('event');
const [selectingTimes, _setSelectingTimes] = useState([]); const [selectingTimes, _setSelectingTimes] = useState([]);
const staticSelectingTimes = useRef([]); const staticSelectingTimes = useRef([]);
const setSelectingTimes = newTimes => { const setSelectingTimes = newTimes => {
@ -53,6 +55,9 @@ const AvailabilityEditor = ({
return ( return (
<> <>
<StyledMain>
<Center>{t('event:you.info')}</Center>
</StyledMain>
{isSpecificDates && ( {isSpecificDates && (
<StyledMain> <StyledMain>
<GoogleCalendar <GoogleCalendar

View file

@ -1,4 +1,5 @@
import { useState, useEffect, useRef, Fragment } from 'react'; import { useState, useEffect, useRef, Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import localeData from 'dayjs/plugin/localeData'; import localeData from 'dayjs/plugin/localeData';
import customParseFormat from 'dayjs/plugin/customParseFormat'; import customParseFormat from 'dayjs/plugin/customParseFormat';
@ -51,6 +52,7 @@ const AvailabilityViewer = ({
const [touched, setTouched] = useState(false); const [touched, setTouched] = useState(false);
const [tempFocus, setTempFocus] = useState(null); const [tempFocus, setTempFocus] = useState(null);
const [focusCount, setFocusCount] = useState(null); const [focusCount, setFocusCount] = useState(null);
const { t } = useTranslation('event');
const wrapper = useRef(); const wrapper = useRef();
@ -68,10 +70,10 @@ const AvailabilityViewer = ({
total={people.filter(p => p.availability.length > 0).length} total={people.filter(p => p.availability.length > 0).length}
onSegmentFocus={count => setFocusCount(count)} onSegmentFocus={count => setFocusCount(count)}
/> />
<Center>Hover or tap the calendar below to see who is available</Center> <Center>{t('event:group.info1')}</Center>
{people.length > 1 && ( {people.length > 1 && (
<> <>
<Center>Click the names below to view people individually</Center> <Center>{t('event:group.info2')}</Center>
<People> <People>
{people.map((person, i) => {people.map((person, i) =>
<Person <Person
@ -152,7 +154,7 @@ const AvailabilityViewer = ({
setTooltip({ setTooltip({
x: Math.round(cellBox.x-wrapperBox.x + cellBox.width/2), x: Math.round(cellBox.x-wrapperBox.x + cellBox.width/2),
y: Math.round(cellBox.y-wrapperBox.y + cellBox.height)+6, y: Math.round(cellBox.y-wrapperBox.y + cellBox.height)+6,
available: `${peopleHere.length} / ${people.length} available`, available: `${peopleHere.length} / ${people.length} ${t('event:available')}`,
date: parsedDate.hour(time.slice(0, 2)).minute(time.slice(2, 4)).format(isSpecificDates ? `${timeText} ddd, D MMM YYYY` : `${timeText} ddd`), date: parsedDate.hour(time.slice(0, 2)).minute(time.slice(2, 4)).format(isSpecificDates ? `${timeText} ddd, D MMM YYYY` : `${timeText} ddd`),
people: peopleHere, people: peopleHere,
}); });

View file

@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import isToday from 'dayjs/plugin/isToday'; import isToday from 'dayjs/plugin/isToday';
import localeData from 'dayjs/plugin/localeData'; import localeData from 'dayjs/plugin/localeData';
@ -54,6 +55,7 @@ const CalendarField = ({
...props ...props
}) => { }) => {
const weekStart = useSettingsStore(state => state.weekStart); const weekStart = useSettingsStore(state => state.weekStart);
const { t } = useTranslation('home');
const [type, setType] = useState(0); const [type, setType] = useState(0);
@ -110,9 +112,12 @@ const CalendarField = ({
<ToggleField <ToggleField
id="calendarMode" id="calendarMode"
name="calendarMode" name="calendarMode"
options={['Specific dates', 'Days of the week']} options={{
value={type ? 'Days of the week' : 'Specific dates'} 'specific': t('form.dates.options.specific'),
onChange={value => setType(value === 'Specific dates' ? 0 : 1)} 'week': t('form.dates.options.week'),
}}
value={type === 0 ? 'specific' : 'week'}
onChange={value => setType(value === 'specific' ? 0 : 1)}
/> />
{type === 0 ? ( {type === 0 ? (
@ -121,7 +126,7 @@ const CalendarField = ({
<Button <Button
buttonHeight="30px" buttonHeight="30px"
buttonWidth="30px" buttonWidth="30px"
title="Previous month" title={t('form.dates.tooltips.previous')}
type="button" type="button"
onClick={() => { onClick={() => {
if (month-1 < 0) { if (month-1 < 0) {
@ -136,7 +141,7 @@ const CalendarField = ({
<Button <Button
buttonHeight="30px" buttonHeight="30px"
buttonWidth="30px" buttonWidth="30px"
title="Next month" title={t('form.dates.tooltips.next')}
type="button" type="button"
onClick={() => { onClick={() => {
if (month+1 > 11) { if (month+1 > 11) {
@ -161,7 +166,7 @@ const CalendarField = ({
key={y+x} key={y+x}
otherMonth={date.month() !== month} otherMonth={date.month() !== month}
isToday={date.isToday()} isToday={date.isToday()}
title={`${date.date()} ${dayjs.months()[date.month()]}${date.isToday() ? ' (today)' : ''}`} title={`${date.date()} ${dayjs.months()[date.month()]}${date.isToday() ? ` (${t('form.dates.tooltips.today')})` : ''}`}
selected={selectedDates.includes(date.format('DDMMYYYY'))} selected={selectedDates.includes(date.format('DDMMYYYY'))}
selecting={selectingDates.includes(date)} selecting={selectingDates.includes(date)}
mode={mode} mode={mode}
@ -203,7 +208,7 @@ const CalendarField = ({
<Date <Date
key={name} key={name}
isToday={dayjs.weekdaysShort()[dayjs().day()-weekStart] === name} isToday={dayjs.weekdaysShort()[dayjs().day()-weekStart] === name}
title={dayjs.weekdaysShort()[dayjs().day()-weekStart] === name ? 'Today' : ''} title={dayjs.weekdaysShort()[dayjs().day()-weekStart] === name ? t('form.dates.tooltips.today') : ''}
selected={selectedDays.includes(((i + weekStart) % 7 + 7) % 7)} selected={selectedDays.includes(((i + weekStart) % 7 + 7) % 7)}
selecting={selectingDays.includes(((i + weekStart) % 7 + 7) % 7)} selecting={selectingDays.includes(((i + weekStart) % 7 + 7) % 7)}
mode={mode} mode={mode}

View file

@ -1,12 +1,14 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Button } from 'components'; import { Button } from 'components';
import { useTWAStore } from 'stores'; import { useTWAStore } from 'stores';
import { useTranslation } from 'react-i18next';
const PAYMENT_METHOD = 'https://play.google.com/billing'; const PAYMENT_METHOD = 'https://play.google.com/billing';
const SKU = 'crab_donation'; const SKU = 'crab_donation';
const Donate = ({ onDonate = null }) => { const Donate = ({ onDonate = null }) => {
const store = useTWAStore(); const store = useTWAStore();
const { t } = useTranslation('common');
useEffect(() => { useEffect(() => {
if (store.TWA === undefined) { if (store.TWA === undefined) {
@ -53,18 +55,18 @@ const Donate = ({ onDonate = null }) => {
if (response.details && response.details.token) { if (response.details && response.details.token) {
const token = response.details.token; const token = response.details.token;
console.log(`Read Token: ${token.substring(0, 6)}...`); console.log(`Read Token: ${token.substring(0, 6)}...`);
alert('Thank you for your donation! Without you, Crab Fit wouldn\'t be free, so thank you and keep being super awesome!'); alert(t('donate.messages.success'));
acknowledge(token); acknowledge(token);
} }
}) })
.catch(e => { .catch(e => {
console.error(e.message); console.error(e.message);
alert('Cannot make donation through Google. Please try donating through the website crab.fit 🦀'); alert(t('donate.messages.error'));
}); });
}) })
.catch(e => { .catch(e => {
console.error(e); console.error(e);
alert('Cannot make donation through Google. Please try donating through the website crab.fit 🦀'); alert(t('donate.messages.error'));
}); });
}; };
@ -75,9 +77,9 @@ const Donate = ({ onDonate = null }) => {
gtag('event', 'donate', { 'event_category': 'donate' }); gtag('event', 'donate', { 'event_category': 'donate' });
if (store.TWA) { if (store.TWA) {
event.preventDefault(); event.preventDefault();
if (window.confirm('Did you know that Crab Fit costs more that $100 per month? If it\'s helped you out at all, consider donating to help keep it running. 🦀')) { if (window.confirm(t('donate.messages.about'))) {
if (purchase() === false) { if (purchase() === false) {
alert('Cannot make donation through Google. Please try donating through the website crab.fit 🦀'); alert(t('donate.messages.error'));
} }
} }
} else if (onDonate !== null) { } else if (onDonate !== null) {
@ -94,8 +96,8 @@ const Donate = ({ onDonate = null }) => {
buttonWidth="90px" buttonWidth="90px"
type="button" type="button"
tabIndex="-1" tabIndex="-1"
title="Every amount counts :)" title={t('donate.title')}
>Donate</Button> >{t('donate.button')}</Button>
</a> </a>
</div> </div>
); );

View file

@ -1,23 +1,25 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Donate } from 'components'; import { Donate } from 'components';
import { Wrapper, Link } from './footerStyle'; import { Wrapper, Link } from './footerStyle';
const Footer = () => { const Footer = () => {
const [donateMode, setDonateMode] = useState(false); const [donateMode, setDonateMode] = useState(false);
const { t } = useTranslation('common');
return ( return (
<Wrapper id="donate" donateMode={donateMode}> <Wrapper id="donate" donateMode={donateMode}>
{donateMode ? ( {donateMode ? (
<> <>
<Link href="https://www.paypal.com/donate?business=N89X6YXRT5HKW&item_name=Crab+Fit+Donation&currency_code=AUD&amount=2" target="_blank">Donate $2</Link> <Link href="https://www.paypal.com/donate?business=N89X6YXRT5HKW&item_name=Crab+Fit+Donation&currency_code=AUD&amount=2" target="_blank">{t('donate.options.$2')}</Link>
<Link href="https://www.paypal.com/donate?business=N89X6YXRT5HKW&item_name=Crab+Fit+Donation&currency_code=AUD&amount=5" target="_blank"><strong>Donate $5</strong></Link> <Link href="https://www.paypal.com/donate?business=N89X6YXRT5HKW&item_name=Crab+Fit+Donation&currency_code=AUD&amount=5" target="_blank"><strong>{t('donate.options.$5')}</strong></Link>
<Link href="https://www.paypal.com/donate?business=N89X6YXRT5HKW&item_name=Crab+Fit+Donation&currency_code=AUD&amount=10" target="_blank">Donate $10</Link> <Link href="https://www.paypal.com/donate?business=N89X6YXRT5HKW&item_name=Crab+Fit+Donation&currency_code=AUD&amount=10" target="_blank">{t('donate.options.$10')}</Link>
<Link href="https://www.paypal.com/donate?business=N89X6YXRT5HKW&item_name=Crab+Fit+Donation&currency_code=AUD" target="_blank">Choose an amount</Link> <Link href="https://www.paypal.com/donate?business=N89X6YXRT5HKW&item_name=Crab+Fit+Donation&currency_code=AUD" target="_blank">{t('donate.options.choose')}</Link>
</> </>
) : ( ) : (
<> <>
<span>Thank you for using Crab Fit. If you like it, consider donating.</span> <span>{t('donate.info')}</span>
<Donate onDonate={() => setDonateMode(true)} /> <Donate onDonate={() => setDonateMode(true)} />
</> </>
)} )}

View file

@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { loadGapiInsideDOM } from 'gapi-script'; import { loadGapiInsideDOM } from 'gapi-script';
import { useTranslation } from 'react-i18next';
import { Button, Center } from 'components'; import { Button, Center } from 'components';
import { Loader } from '../Loading/loadingStyle'; import { Loader } from '../Loading/loadingStyle';
@ -23,6 +24,7 @@ const GoogleCalendar = ({ timeZone, timeMin, timeMax, onImport }) => {
const [signedIn, setSignedIn] = useState(undefined); const [signedIn, setSignedIn] = useState(undefined);
const [calendars, setCalendars] = useState(undefined); const [calendars, setCalendars] = useState(undefined);
const [freeBusyLoading, setFreeBusyLoading] = useState(false); const [freeBusyLoading, setFreeBusyLoading] = useState(false);
const { t } = useTranslation('event');
const calendarLogin = async () => { const calendarLogin = async () => {
const gapi = await loadGapiInsideDOM(); const gapi = await loadGapiInsideDOM();
@ -101,7 +103,7 @@ const GoogleCalendar = ({ timeZone, timeMin, timeMax, onImport }) => {
secondaryColor="#3367BD"> secondaryColor="#3367BD">
<LoginButton> <LoginButton>
<img src={googleLogo} alt="" /> <img src={googleLogo} alt="" />
<span>Sync with Google Calendar</span> <span>{t('event:you.google_cal.login')}</span>
</LoginButton> </LoginButton>
</Button> </Button>
</Center> </Center>
@ -109,10 +111,10 @@ const GoogleCalendar = ({ timeZone, timeMin, timeMax, onImport }) => {
<CalendarList> <CalendarList>
<p> <p>
{/* eslint-disable-next-line */} {/* eslint-disable-next-line */}
<strong>Sync with Google Calendar</strong> (<a href="#" onClick={e => { <strong>{t('event:you.google_cal.login')}</strong> (<a href="#" onClick={e => {
e.preventDefault(); e.preventDefault();
signOut(); signOut();
}}>log out</a>) }}>{t('event:you.google_cal.logout')}</a>)
</p> </p>
<Options> <Options>
{calendars !== undefined && !calendars.every(c => c.checked) && ( {calendars !== undefined && !calendars.every(c => c.checked) && (
@ -120,14 +122,14 @@ const GoogleCalendar = ({ timeZone, timeMin, timeMax, onImport }) => {
<a href="#" onClick={e => { <a href="#" onClick={e => {
e.preventDefault(); e.preventDefault();
setCalendars(calendars.map(c => ({...c, checked: true}))); setCalendars(calendars.map(c => ({...c, checked: true})));
}}>Select all</a> }}>{t('event:you.google_cal.select_all')}</a>
)} )}
{calendars !== undefined && calendars.every(c => c.checked) && ( {calendars !== undefined && calendars.every(c => c.checked) && (
/* eslint-disable-next-line */ /* eslint-disable-next-line */
<a href="#" onClick={e => { <a href="#" onClick={e => {
e.preventDefault(); e.preventDefault();
setCalendars(calendars.map(c => ({...c, checked: false}))); setCalendars(calendars.map(c => ({...c, checked: false})));
}}>Select none</a> }}>{t('event:you.google_cal.select_none')}</a>
)} )}
</Options> </Options>
{calendars !== undefined ? calendars.map(calendar => ( {calendars !== undefined ? calendars.map(calendar => (
@ -148,14 +150,14 @@ const GoogleCalendar = ({ timeZone, timeMin, timeMax, onImport }) => {
)} )}
{calendars !== undefined && ( {calendars !== undefined && (
<> <>
<Info>Importing will overwrite your current availability</Info> <Info>{t('event:you.google_cal.info')}</Info>
<Button <Button
buttonWidth="170px" buttonWidth="170px"
buttonHeight="35px" buttonHeight="35px"
isLoading={freeBusyLoading} isLoading={freeBusyLoading}
disabled={freeBusyLoading} disabled={freeBusyLoading}
onClick={() => importAvailability()} onClick={() => importAvailability()}
>Import availability</Button> >{t('event:you.google_cal.button')}</Button>
</> </>
)} )}
</CalendarList> </CalendarList>

View file

@ -1,5 +1,6 @@
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import { useSettingsStore } from 'stores'; import { useSettingsStore } from 'stores';
import { useTranslation } from 'react-i18next';
import { import {
Wrapper, Wrapper,
@ -16,17 +17,18 @@ const Legend = ({
...props ...props
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const { t } = useTranslation('event');
const highlight = useSettingsStore(state => state.highlight); const highlight = useSettingsStore(state => state.highlight);
const setHighlight = useSettingsStore(state => state.setHighlight); const setHighlight = useSettingsStore(state => state.setHighlight);
return ( return (
<Wrapper> <Wrapper>
<Label>{min}/{total} available</Label> <Label>{min}/{total} {t('event:available')}</Label>
<Bar <Bar
onMouseOut={() => onSegmentFocus(null)} onMouseOut={() => onSegmentFocus(null)}
onClick={() => setHighlight(!highlight)} onClick={() => setHighlight(!highlight)}
title="Click to highlight highest availability" title={t('event:group.legend_tooltip')}
> >
{[...Array(max+1-min).keys()].map(i => i+min).map(i => {[...Array(max+1-min).keys()].map(i => i+min).map(i =>
<Grade <Grade
@ -38,7 +40,7 @@ const Legend = ({
)} )}
</Bar> </Bar>
<Label>{max}/{total} available</Label> <Label>{max}/{total} {t('event:available')}</Label>
</Wrapper> </Wrapper>
); );
}; };

View file

@ -11,22 +11,30 @@ const SelectField = ({
id, id,
options = [], options = [],
inline = false, inline = false,
small = false,
defaultOption, defaultOption,
register, register,
...props ...props
}) => ( }) => (
<Wrapper inline={inline}> <Wrapper inline={inline} small={small}>
{label && <StyledLabel htmlFor={id} inline={inline}>{label}</StyledLabel>} {label && <StyledLabel htmlFor={id} inline={inline} small={small}>{label}</StyledLabel>}
{subLabel && <StyledSubLabel htmlFor={id}>{subLabel}</StyledSubLabel>} {subLabel && <StyledSubLabel htmlFor={id}>{subLabel}</StyledSubLabel>}
<StyledSelect <StyledSelect
id={id} id={id}
ref={register} ref={register}
small={small}
{...props} {...props}
> >
{defaultOption && <option value="">{defaultOption}</option>} {defaultOption && <option value="">{defaultOption}</option>}
{options.map((value, i) => {Array.isArray(options) ? (
<option key={i} value={value}>{value}</option> options.map(value =>
<option key={value} value={value}>{value}</option>
)
) : (
Object.entries(options).map(([key, value]) =>
<option key={key} value={key}>{value}</option>
)
)} )}
</StyledSelect> </StyledSelect>
</Wrapper> </Wrapper>

View file

@ -6,6 +6,9 @@ export const Wrapper = styled.div`
${props => props.inline && ` ${props => props.inline && `
margin: 0; margin: 0;
`} `}
${props => props.small && `
margin: 10px 0;
`}
`; `;
export const StyledLabel = styled.label` export const StyledLabel = styled.label`
@ -16,6 +19,9 @@ export const StyledLabel = styled.label`
${props => props.inline && ` ${props => props.inline && `
font-size: 16px; font-size: 16px;
`} `}
${props => props.small && `
font-size: .9rem;
`}
`; `;
export const StyledSubLabel = styled.label` export const StyledSubLabel = styled.label`
@ -42,4 +48,8 @@ export const StyledSelect = styled.select`
border: 1px solid ${props => props.theme.primary}; border: 1px solid ${props => props.theme.primary};
box-shadow: inset 0 -3px 0 0 ${props => props.theme.primary}; box-shadow: inset 0 -3px 0 0 ${props => props.theme.primary};
} }
${props => props.small && `
padding: 6px 8px;
`}
`; `;

View file

@ -1,7 +1,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import { useTranslation } from 'react-i18next';
import { ToggleField } from 'components'; import { ToggleField, SelectField } from 'components';
import { useSettingsStore } from 'stores'; import { useSettingsStore } from 'stores';
@ -16,6 +17,7 @@ const Settings = () => {
const theme = useTheme(); const theme = useTheme();
const store = useSettingsStore(); const store = useSettingsStore();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { t, i18n } = useTranslation('common');
return ( return (
<> <>
@ -23,51 +25,77 @@ const Settings = () => {
isOpen={isOpen} isOpen={isOpen}
tabIndex="1" tabIndex="1"
type="button" type="button"
onClick={() => setIsOpen(!isOpen)} title="Options" onClick={() => setIsOpen(!isOpen)} title={t('options.name')}
> >
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke={theme.text} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke={theme.text} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
</OpenButton> </OpenButton>
<Cover isOpen={isOpen} onClick={() => setIsOpen(false)} /> <Cover isOpen={isOpen} onClick={() => setIsOpen(false)} />
<Modal isOpen={isOpen}> <Modal isOpen={isOpen}>
<Heading>Options</Heading> <Heading>{t('options.name')}</Heading>
<ToggleField <ToggleField
label="Week starts on" label={t('options.weekStart.label')}
name="weekStart" name="weekStart"
id="weekStart" id="weekStart"
options={['Sunday', 'Monday']} options={{
value={store.weekStart === 1 ? 'Monday' : 'Sunday'} 'Sunday': t('options.weekStart.options.Sunday'),
onChange={value => store.setWeekStart(value === 'Monday' ? 1 : 0)} 'Monday': t('options.weekStart.options.Monday'),
}}
value={store.weekStart === 0 ? 'Sunday' : 'Monday'}
onChange={value => store.setWeekStart(value === 'Sunday' ? 0 : 1)}
/> />
<ToggleField <ToggleField
label="Time format" label={t('options.timeFormat.label')}
name="timeFormat" name="timeFormat"
id="timeFormat" id="timeFormat"
options={['12h', '24h']} options={{
'12h': t('options.timeFormat.options.12h'),
'24h': t('options.timeFormat.options.24h'),
}}
value={store.timeFormat} value={store.timeFormat}
onChange={value => store.setTimeFormat(value)} onChange={value => store.setTimeFormat(value)}
/> />
<ToggleField <ToggleField
label="Theme" label={t('options.theme.label')}
name="theme" name="theme"
id="theme" id="theme"
options={['System', 'Light', 'Dark']} options={{
'System': t('options.theme.options.System'),
'Light': t('options.theme.options.Light'),
'Dark': t('options.theme.options.Dark'),
}}
value={store.theme} value={store.theme}
onChange={value => store.setTheme(value)} onChange={value => store.setTheme(value)}
/> />
<ToggleField <ToggleField
label="Highlight highest availability" label={t('options.highlight.label')}
name="highlight" name="highlight"
id="highlight" id="highlight"
title="Make the highest availability on the heatmap stand out" title={t('options.highlight.title')}
options={['Off', 'On']} options={{
'Off': t('options.highlight.options.Off'),
'On': t('options.highlight.options.On'),
}}
value={store.highlight ? 'On' : 'Off'} value={store.highlight ? 'On' : 'Off'}
onChange={value => store.setHighlight(value === 'On')} onChange={value => store.setHighlight(value === 'On')}
/> />
<SelectField
label={t('options.language.label')}
name="language"
id="language"
options={i18n.language === 'cimode' ? {
cimode: 'DEV',
english: 'en-US'
} : t('options.language.options', { returnObjects: true })}
small
value={i18n.language}
onChange={event => i18n.changeLanguage(event.target.value)}
/>
</Modal> </Modal>
</> </>
); );

View file

@ -21,17 +21,17 @@ const ToggleField = ({
{label && <StyledLabel title={title}>{label}</StyledLabel>} {label && <StyledLabel title={title}>{label}</StyledLabel>}
<ToggleContainer> <ToggleContainer>
{options.map(option => {Object.entries(options).map(([key, label]) =>
<Option key={option}> <Option key={label}>
<HiddenInput <HiddenInput
type="radio" type="radio"
name={name} name={name}
value={option} value={label}
id={`${name}-${option}`} id={`${name}-${label}`}
checked={value === option} checked={value === key}
onChange={() => onChange(option)} onChange={() => onChange(key)}
/> />
<LabelButton htmlFor={`${name}-${option}`}>{option}</LabelButton> <LabelButton htmlFor={`${name}-${label}`}>{label}</LabelButton>
</Option> </Option>
)} )}
</ToggleContainer> </ToggleContainer>

View file

@ -0,0 +1,28 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import Backend from 'i18next-http-backend';
i18n
.use(LanguageDetector)
.use(Backend)
.use(initReactI18next)
.init({
fallbackLng: 'en-US',
debug: true,
load: 'currentOnly',
interpolation: {
escapeValue: false,
},
backend: {
loadPath: '/i18n/{{lng}}/{{ns}}.json',
requestOptions: {
cache: 'no-cache'
},
customHeaders: {
pragma: 'no-cache',
},
},
});
export default i18n;

View file

@ -3,6 +3,7 @@ import ReactDOM from 'react-dom';
import App from './App'; import App from './App';
import * as serviceWorkerRegistration from './serviceWorkerRegistration'; import * as serviceWorkerRegistration from './serviceWorkerRegistration';
import reportWebVitals from './reportWebVitals'; import reportWebVitals from './reportWebVitals';
import 'i18n';
ReactDOM.render( ReactDOM.render(
<React.StrictMode> <React.StrictMode>

View file

@ -1,6 +1,7 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useTranslation, Trans } from 'react-i18next';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc'; import utc from 'dayjs/plugin/utc';
@ -50,6 +51,8 @@ const Event = (props) => {
const addRecent = useRecentsStore(state => state.addRecent); const addRecent = useRecentsStore(state => state.addRecent);
const { t } = useTranslation(['common', 'event']);
const { register, handleSubmit } = useForm(); const { register, handleSubmit } = useForm();
const { id } = props.match.params; const { id } = props.match.params;
const { offline } = props; const { offline } = props;
@ -244,7 +247,7 @@ const Event = (props) => {
setTab('you'); setTab('you');
} catch (e) { } catch (e) {
if (e.status === 401) { if (e.status === 401) {
setError('Password is incorrect. Check your name is spelled right.'); setError(t('event:form.errors.password_incorrect'));
} else if (e.status === 404) { } else if (e.status === 404) {
// Create user // Create user
try { try {
@ -261,7 +264,7 @@ const Event = (props) => {
}); });
setTab('you'); setTab('you');
} catch (e) { } catch (e) {
setError('Failed to create user. Please try again.'); setError(t('event:form.errors.unknown'));
} }
} }
} finally { } finally {
@ -280,17 +283,17 @@ const Event = (props) => {
<Logo src={logo} alt="" /> <Logo src={logo} alt="" />
<Title>CRAB FIT</Title> <Title>CRAB FIT</Title>
</Center> </Center>
<Center style={{ textDecoration: 'underline', fontSize: 14, paddingTop: 6 }}>Create your own</Center> <Center style={{ textDecoration: 'underline', fontSize: 14, paddingTop: 6 }}>{t('common:tagline')}</Center>
</Link> </Link>
{(!!event || isLoading) ? ( {(!!event || isLoading) ? (
<> <>
<EventName isLoading={isLoading}>{event?.name}</EventName> <EventName isLoading={isLoading}>{event?.name}</EventName>
<EventDate isLoading={isLoading} title={event?.created && dayjs.unix(event?.created).format('D MMMM, YYYY')}>{event?.created && `Created ${dayjs.unix(event?.created).fromNow()}`}</EventDate> <EventDate isLoading={isLoading} title={event?.created && dayjs.unix(event?.created).format('D MMMM, YYYY')}>{event?.created && `${t('common:created')} ${dayjs.unix(event?.created).fromNow()}`}</EventDate>
<ShareInfo <ShareInfo
onClick={() => navigator.clipboard?.writeText(`https://crab.fit/${id}`) onClick={() => navigator.clipboard?.writeText(`https://crab.fit/${id}`)
.then(() => { .then(() => {
setCopied('Copied!'); setCopied(t('event:nav.copied'));
setTimeout(() => setCopied(null), 1000); setTimeout(() => setCopied(null), 1000);
gtag('event', 'copy_link', { gtag('event', 'copy_link', {
'event_category': 'event', 'event_category': 'event',
@ -298,24 +301,24 @@ const Event = (props) => {
}) })
.catch((e) => console.error('Failed to copy', e)) .catch((e) => console.error('Failed to copy', e))
} }
title={!!navigator.clipboard ? 'Click to copy' : ''} title={!!navigator.clipboard ? t('event:nav.title') : ''}
>{copied ?? `https://crab.fit/${id}`}</ShareInfo> >{copied ?? `https://crab.fit/${id}`}</ShareInfo>
<ShareInfo isLoading={isLoading}> <ShareInfo isLoading={isLoading}>
{!!event?.name && {!!event?.name &&
<>Copy the link to this page, or share via <a onClick={() => gtag('event', 'send_email', { 'event_category': 'event' })} href={`mailto:?subject=${encodeURIComponent(`Scheduling ${event?.name}`)}&body=${encodeURIComponent(`Visit this link to enter your availabilities: https://crab.fit/${id}`)}`}>email</a>.</> <Trans i18nKey="event:nav.shareinfo">Copy the link to this page, or share via <a onClick={() => gtag('event', 'send_email', { 'event_category': 'event' })} href={`mailto:?subject=${encodeURIComponent(t('event:nav.email_subject', { event_name: event?.name }))}&body=${encodeURIComponent(`${t('event:nav.email_body')} https://crab.fit/${id}`)}`}>email</a>.</Trans>
} }
</ShareInfo> </ShareInfo>
</> </>
) : ( ) : (
offline ? ( offline ? (
<div style={{ margin: '100px 0' }}> <div style={{ margin: '100px 0' }}>
<EventName>You are offline</EventName> <EventName>{t('event:offline.title')}</EventName>
<ShareInfo>A Crab Fit doesn't work offline.<br />Make sure you're connected to the internet and try again.</ShareInfo> <ShareInfo><Trans i18nKey="event:offline.body" /></ShareInfo>
</div> </div>
) : ( ) : (
<div style={{ margin: '100px 0' }}> <div style={{ margin: '100px 0' }}>
<EventName>Event not found</EventName> <EventName>{t('event:error.title')}</EventName>
<ShareInfo>Check that the url you entered is correct.</ShareInfo> <ShareInfo>{t('event:error.body')}</ShareInfo>
</div> </div>
) )
)} )}
@ -326,13 +329,13 @@ const Event = (props) => {
<LoginSection id="login"> <LoginSection id="login">
<StyledMain> <StyledMain>
{user ? ( {user ? (
<h2>Signed in as {user.name}</h2> <h2>{t('event:form.signed_in', { name: user.name })}</h2>
) : ( ) : (
<> <>
<h2>Sign in to add your availability</h2> <h2>{t('event:form.signed_out')}</h2>
<LoginForm onSubmit={handleSubmit(onSubmit)}> <LoginForm onSubmit={handleSubmit(onSubmit)}>
<TextField <TextField
label="Your name" label={t('event:form.name')}
type="text" type="text"
name="name" name="name"
id="name" id="name"
@ -342,7 +345,7 @@ const Event = (props) => {
/> />
<TextField <TextField
label="Password (optional)" label={t('event:form.password')}
type="password" type="password"
name="password" name="password"
id="password" id="password"
@ -354,15 +357,15 @@ const Event = (props) => {
type="submit" type="submit"
isLoading={isLoginLoading} isLoading={isLoginLoading}
disabled={isLoginLoading || isLoading} disabled={isLoginLoading || isLoading}
>Login</Button> >{t('event:form.button')}</Button>
</LoginForm> </LoginForm>
{error && <Error onClose={() => setError(null)}>{error}</Error>} {error && <Error onClose={() => setError(null)}>{error}</Error>}
<Info>These details are only for this event. Use a password to prevent others from changing your availability.</Info> <Info>{t('event:form.info')}</Info>
</> </>
)} )}
<SelectField <SelectField
label="Your time zone" label={t('event:form.timezone')}
name="timezone" name="timezone"
id="timezone" id="timezone"
inline inline
@ -371,10 +374,10 @@ const Event = (props) => {
options={timezones} options={timezones}
/> />
{/* eslint-disable-next-line */} {/* eslint-disable-next-line */}
{event?.timezone && event.timezone !== timezone && <p>This event was created in the timezone <strong>{event.timezone}</strong>. <a href="#" onClick={e => { {event?.timezone && event.timezone !== timezone && <p><Trans i18nKey="event:form.created_in_timezone">This event was created in the timezone <strong>{{timezone: event.timezone}}</strong>. <a href="#" onClick={e => {
e.preventDefault(); e.preventDefault();
setTimezone(event.timezone); setTimezone(event.timezone);
}}>Click here</a> to use it.</p>} }}>Click here</a> to use it.</Trans></p>}
{(( {((
Intl.DateTimeFormat().resolvedOptions().timeZone !== timezone Intl.DateTimeFormat().resolvedOptions().timeZone !== timezone
&& (event?.timezone && event.timezone !== Intl.DateTimeFormat().resolvedOptions().timeZone) && (event?.timezone && event.timezone !== Intl.DateTimeFormat().resolvedOptions().timeZone)
@ -383,10 +386,10 @@ const Event = (props) => {
&& Intl.DateTimeFormat().resolvedOptions().timeZone !== timezone && Intl.DateTimeFormat().resolvedOptions().timeZone !== timezone
)) && ( )) && (
/* eslint-disable-next-line */ /* eslint-disable-next-line */
<p>Your local timezone is detected to be <strong>{Intl.DateTimeFormat().resolvedOptions().timeZone}</strong>. <a href="#" onClick={e => { <p><Trans i18nKey="event:form.local_timezone">Your local timezone is detected to be <strong>{{timezone: Intl.DateTimeFormat().resolvedOptions().timeZone}}</strong>. <a href="#" onClick={e => {
e.preventDefault(); e.preventDefault();
setTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone); setTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
}}>Click here</a> to use it.</p> }}>Click here</a> to use it.</Trans></p>
)} )}
</StyledMain> </StyledMain>
</LoginSection> </LoginSection>
@ -403,8 +406,8 @@ const Event = (props) => {
}} }}
selected={tab === 'you'} selected={tab === 'you'}
disabled={!user} disabled={!user}
title={user ? '' : 'Login to set your availability'} title={user ? '' : t('event:tabs.you_tooltip')}
>Your availability</Tab> >{t('event:tabs.you')}</Tab>
<Tab <Tab
href="#group" href="#group"
onClick={e => { onClick={e => {
@ -412,7 +415,7 @@ const Event = (props) => {
setTab('group'); setTab('group');
}} }}
selected={tab === 'group'} selected={tab === 'group'}
>Group availability</Tab> >{t('event:tabs.group')}</Tab>
</Tabs> </Tabs>
</StyledMain> </StyledMain>
@ -430,9 +433,6 @@ const Event = (props) => {
</section> </section>
) : ( ) : (
<section id="you"> <section id="you">
<StyledMain>
<Center>Click and drag the calendar below to set your availabilities</Center>
</StyledMain>
<AvailabilityEditor <AvailabilityEditor
times={times} times={times}
timeLabels={timeLabels} timeLabels={timeLabels}

View file

@ -1,5 +1,6 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Link, useHistory } from 'react-router-dom'; import { Link, useHistory } from 'react-router-dom';
import { useTranslation, Trans } from 'react-i18next';
import { import {
Button, Button,
@ -23,9 +24,10 @@ import logo from 'res/logo.svg';
const Help = () => { const Help = () => {
const { push } = useHistory(); const { push } = useHistory();
const { t } = useTranslation(['common', 'help']);
useEffect(() => { useEffect(() => {
document.title = 'How to Crab Fit'; document.title = t('help:name');
}, []); }, []);
return ( return (
@ -36,31 +38,31 @@ const Help = () => {
<Logo src={logo} alt="" /> <Logo src={logo} alt="" />
<Title>CRAB FIT</Title> <Title>CRAB FIT</Title>
</Center> </Center>
<Center style={{ textDecoration: 'underline', fontSize: 14, paddingTop: 6 }}>Create your own</Center> <Center style={{ textDecoration: 'underline', fontSize: 14, paddingTop: 6 }}>{t('common:tagline')}</Center>
</Link> </Link>
</StyledMain> </StyledMain>
<StyledMain> <StyledMain>
<h1>How to Crab Fit</h1> <h1>{t('help:name')}</h1>
<P>Crab Fit is a tool that helps you when planning events with friends or coworkers. You just create an event, enter your availability, send it out, and see when everyone is free!</P> <P>{t('help:p1')}</P>
<P>See below for detailed steps of how to Crab Fit your event.</P> <P>{t('help:p2')}</P>
<Step>Step 1</Step> <Step>{t('help:s1')}</Step>
<P>Use the form at <Link to="/">crab.fit</Link> to make a new event. You only need to put in the rough time period for when your event occurs here, not your availability.</P> <P><Trans i18nKey="help:p3">Use the form at <Link to="/">crab.fit</Link> to make a new event. You only need to put in the rough time period for when your event occurs here, not your availability.</Trans></P>
<P>For example, we'll use "Jenny's Birthday Lunch". Jenny wants her birthday lunch to happen on the same week as her birthday, the 15th of April, but she knows that not all of her friends are available on the 15th. She also doesn't want to do it on the weekend.</P> <P>{t('help:p4')}</P>
<FakeCalendar> <FakeCalendar>
<div className="days"><span>Sun</span><span>Mon</span><span>Tue</span><span>Wed</span><span>Thu</span><span>Fri</span><span>Sat</span></div> <div className="days"><span>Sun</span><span>Mon</span><span>Tue</span><span>Wed</span><span>Thu</span><span>Fri</span><span>Sat</span></div>
<div className="dates"><span>11</span><span className="selected">12</span><span className="selected">13</span><span className="selected">14</span><span className="selected">15</span><span className="selected">16</span><span>17</span></div> <div className="dates"><span>11</span><span className="selected">12</span><span className="selected">13</span><span className="selected">14</span><span className="selected">15</span><span className="selected">16</span><span>17</span></div>
</FakeCalendar> </FakeCalendar>
<P>Jenny also knows that since it's a lunch event, it can't start before 11am or go any later than 5pm.</P> <P>{t('help:p5')}</P>
<FakeTimeRange> <FakeTimeRange>
<div className="start" data-label="11am"></div> <div className="start" data-label="11am"></div>
<div className="end" data-label="5pm"></div> <div className="end" data-label="5pm"></div>
</FakeTimeRange> </FakeTimeRange>
<Step>Step 2</Step> <Step>{t('help:s2')}</Step>
<P>Enter your availability for the event you just created.</P> <P>{t('help:p6')}</P>
<P>In our example, Jenny now puts in her availability for her birthday lunch. She is free all week, except after 3pm on Tuesday and Wednesday, and before 1pm on Friday.</P> <P>{t('help:p7')}</P>
<AvailabilityViewer <AvailabilityViewer
times={["1100-12042021","1115-12042021","1130-12042021","1145-12042021","1200-12042021","1215-12042021","1230-12042021","1245-12042021","1300-12042021","1315-12042021","1330-12042021","1345-12042021","1400-12042021","1415-12042021","1430-12042021","1445-12042021","1500-12042021","1515-12042021","1530-12042021","1545-12042021","1600-12042021","1615-12042021","1630-12042021","1645-12042021","1100-13042021","1115-13042021","1130-13042021","1145-13042021","1200-13042021","1215-13042021","1230-13042021","1245-13042021","1300-13042021","1315-13042021","1330-13042021","1345-13042021","1400-13042021","1415-13042021","1430-13042021","1445-13042021","1500-13042021","1515-13042021","1530-13042021","1545-13042021","1600-13042021","1615-13042021","1630-13042021","1645-13042021","1100-14042021","1115-14042021","1130-14042021","1145-14042021","1200-14042021","1215-14042021","1230-14042021","1245-14042021","1300-14042021","1315-14042021","1330-14042021","1345-14042021","1400-14042021","1415-14042021","1430-14042021","1445-14042021","1500-14042021","1515-14042021","1530-14042021","1545-14042021","1600-14042021","1615-14042021","1630-14042021","1645-14042021","1100-15042021","1115-15042021","1130-15042021","1145-15042021","1200-15042021","1215-15042021","1230-15042021","1245-15042021","1300-15042021","1315-15042021","1330-15042021","1345-15042021","1400-15042021","1415-15042021","1430-15042021","1445-15042021","1500-15042021","1515-15042021","1530-15042021","1545-15042021","1600-15042021","1615-15042021","1630-15042021","1645-15042021","1100-16042021","1115-16042021","1130-16042021","1145-16042021","1200-16042021","1215-16042021","1230-16042021","1245-16042021","1300-16042021","1315-16042021","1330-16042021","1345-16042021","1400-16042021","1415-16042021","1430-16042021","1445-16042021","1500-16042021","1515-16042021","1530-16042021","1545-16042021","1600-16042021","1615-16042021","1630-16042021","1645-16042021"]} times={["1100-12042021","1115-12042021","1130-12042021","1145-12042021","1200-12042021","1215-12042021","1230-12042021","1245-12042021","1300-12042021","1315-12042021","1330-12042021","1345-12042021","1400-12042021","1415-12042021","1430-12042021","1445-12042021","1500-12042021","1515-12042021","1530-12042021","1545-12042021","1600-12042021","1615-12042021","1630-12042021","1645-12042021","1100-13042021","1115-13042021","1130-13042021","1145-13042021","1200-13042021","1215-13042021","1230-13042021","1245-13042021","1300-13042021","1315-13042021","1330-13042021","1345-13042021","1400-13042021","1415-13042021","1430-13042021","1445-13042021","1500-13042021","1515-13042021","1530-13042021","1545-13042021","1600-13042021","1615-13042021","1630-13042021","1645-13042021","1100-14042021","1115-14042021","1130-14042021","1145-14042021","1200-14042021","1215-14042021","1230-14042021","1245-14042021","1300-14042021","1315-14042021","1330-14042021","1345-14042021","1400-14042021","1415-14042021","1430-14042021","1445-14042021","1500-14042021","1515-14042021","1530-14042021","1545-14042021","1600-14042021","1615-14042021","1630-14042021","1645-14042021","1100-15042021","1115-15042021","1130-15042021","1145-15042021","1200-15042021","1215-15042021","1230-15042021","1245-15042021","1300-15042021","1315-15042021","1330-15042021","1345-15042021","1400-15042021","1415-15042021","1430-15042021","1445-15042021","1500-15042021","1515-15042021","1530-15042021","1545-15042021","1600-15042021","1615-15042021","1630-15042021","1645-15042021","1100-16042021","1115-16042021","1130-16042021","1145-16042021","1200-16042021","1215-16042021","1230-16042021","1245-16042021","1300-16042021","1315-16042021","1330-16042021","1345-16042021","1400-16042021","1415-16042021","1430-16042021","1445-16042021","1500-16042021","1515-16042021","1530-16042021","1545-16042021","1600-16042021","1615-16042021","1630-16042021","1645-16042021"]}
timeLabels={[{"label":"11 AM","time":"1100"},{"label":"","time":"1115"},{"label":"","time":"1130"},{"label":"","time":"1145"},{"label":"12 PM","time":"1200"},{"label":"","time":"1215"},{"label":"","time":"1230"},{"label":"","time":"1245"},{"label":"1 PM","time":"1300"},{"label":"","time":"1315"},{"label":"","time":"1330"},{"label":"","time":"1345"},{"label":"2 PM","time":"1400"},{"label":"","time":"1415"},{"label":"","time":"1430"},{"label":"","time":"1445"},{"label":"3 PM","time":"1500"},{"label":"","time":"1515"},{"label":"","time":"1530"},{"label":"","time":"1545"},{"label":"4 PM","time":"1600"},{"label":"","time":"1615"},{"label":"","time":"1630"},{"label":"","time":"1645"},{"label":"5 PM","time":null}]} timeLabels={[{"label":"11 AM","time":"1100"},{"label":"","time":"1115"},{"label":"","time":"1130"},{"label":"","time":"1145"},{"label":"12 PM","time":"1200"},{"label":"","time":"1215"},{"label":"","time":"1230"},{"label":"","time":"1245"},{"label":"1 PM","time":"1300"},{"label":"","time":"1315"},{"label":"","time":"1330"},{"label":"","time":"1345"},{"label":"2 PM","time":"1400"},{"label":"","time":"1415"},{"label":"","time":"1430"},{"label":"","time":"1445"},{"label":"3 PM","time":"1500"},{"label":"","time":"1515"},{"label":"","time":"1530"},{"label":"","time":"1545"},{"label":"4 PM","time":"1600"},{"label":"","time":"1615"},{"label":"","time":"1630"},{"label":"","time":"1645"},{"label":"5 PM","time":null}]}
@ -71,10 +73,10 @@ const Help = () => {
max={1} max={1}
/> />
<Step>Step 3</Step> <Step>{t('help:s3')}</Step>
<P>Send the link to everyone you want to come.</P> <P>{t('help:p8')}</P>
<P>After Jenny has sent the link to her friends and waited for them to also fill out their availabilities, she can now easily see them all on the heatmap below and choose the darkest area for a time that suits everyone!</P> <P>{t('help:p9')}</P>
<P>In this example, 1pm to 3pm on Friday the 16th works for all Jenny's friends.</P> <P>{t('help:p10')}</P>
<AvailabilityViewer <AvailabilityViewer
times={["1100-12042021","1115-12042021","1130-12042021","1145-12042021","1200-12042021","1215-12042021","1230-12042021","1245-12042021","1300-12042021","1315-12042021","1330-12042021","1345-12042021","1400-12042021","1415-12042021","1430-12042021","1445-12042021","1500-12042021","1515-12042021","1530-12042021","1545-12042021","1600-12042021","1615-12042021","1630-12042021","1645-12042021","1100-13042021","1115-13042021","1130-13042021","1145-13042021","1200-13042021","1215-13042021","1230-13042021","1245-13042021","1300-13042021","1315-13042021","1330-13042021","1345-13042021","1400-13042021","1415-13042021","1430-13042021","1445-13042021","1500-13042021","1515-13042021","1530-13042021","1545-13042021","1600-13042021","1615-13042021","1630-13042021","1645-13042021","1100-14042021","1115-14042021","1130-14042021","1145-14042021","1200-14042021","1215-14042021","1230-14042021","1245-14042021","1300-14042021","1315-14042021","1330-14042021","1345-14042021","1400-14042021","1415-14042021","1430-14042021","1445-14042021","1500-14042021","1515-14042021","1530-14042021","1545-14042021","1600-14042021","1615-14042021","1630-14042021","1645-14042021","1100-15042021","1115-15042021","1130-15042021","1145-15042021","1200-15042021","1215-15042021","1230-15042021","1245-15042021","1300-15042021","1315-15042021","1330-15042021","1345-15042021","1400-15042021","1415-15042021","1430-15042021","1445-15042021","1500-15042021","1515-15042021","1530-15042021","1545-15042021","1600-15042021","1615-15042021","1630-15042021","1645-15042021","1100-16042021","1115-16042021","1130-16042021","1145-16042021","1200-16042021","1215-16042021","1230-16042021","1245-16042021","1300-16042021","1315-16042021","1330-16042021","1345-16042021","1400-16042021","1415-16042021","1430-16042021","1445-16042021","1500-16042021","1515-16042021","1530-16042021","1545-16042021","1600-16042021","1615-16042021","1630-16042021","1645-16042021"]} times={["1100-12042021","1115-12042021","1130-12042021","1145-12042021","1200-12042021","1215-12042021","1230-12042021","1245-12042021","1300-12042021","1315-12042021","1330-12042021","1345-12042021","1400-12042021","1415-12042021","1430-12042021","1445-12042021","1500-12042021","1515-12042021","1530-12042021","1545-12042021","1600-12042021","1615-12042021","1630-12042021","1645-12042021","1100-13042021","1115-13042021","1130-13042021","1145-13042021","1200-13042021","1215-13042021","1230-13042021","1245-13042021","1300-13042021","1315-13042021","1330-13042021","1345-13042021","1400-13042021","1415-13042021","1430-13042021","1445-13042021","1500-13042021","1515-13042021","1530-13042021","1545-13042021","1600-13042021","1615-13042021","1630-13042021","1645-13042021","1100-14042021","1115-14042021","1130-14042021","1145-14042021","1200-14042021","1215-14042021","1230-14042021","1245-14042021","1300-14042021","1315-14042021","1330-14042021","1345-14042021","1400-14042021","1415-14042021","1430-14042021","1445-14042021","1500-14042021","1515-14042021","1530-14042021","1545-14042021","1600-14042021","1615-14042021","1630-14042021","1645-14042021","1100-15042021","1115-15042021","1130-15042021","1145-15042021","1200-15042021","1215-15042021","1230-15042021","1245-15042021","1300-15042021","1315-15042021","1330-15042021","1345-15042021","1400-15042021","1415-15042021","1430-15042021","1445-15042021","1500-15042021","1515-15042021","1530-15042021","1545-15042021","1600-15042021","1615-15042021","1630-15042021","1645-15042021","1100-16042021","1115-16042021","1130-16042021","1145-16042021","1200-16042021","1215-16042021","1230-16042021","1245-16042021","1300-16042021","1315-16042021","1330-16042021","1345-16042021","1400-16042021","1415-16042021","1430-16042021","1445-16042021","1500-16042021","1515-16042021","1530-16042021","1545-16042021","1600-16042021","1615-16042021","1630-16042021","1645-16042021"]}
timeLabels={[{"label":"11 AM","time":"1100"},{"label":"","time":"1115"},{"label":"","time":"1130"},{"label":"","time":"1145"},{"label":"12 PM","time":"1200"},{"label":"","time":"1215"},{"label":"","time":"1230"},{"label":"","time":"1245"},{"label":"1 PM","time":"1300"},{"label":"","time":"1315"},{"label":"","time":"1330"},{"label":"","time":"1345"},{"label":"2 PM","time":"1400"},{"label":"","time":"1415"},{"label":"","time":"1430"},{"label":"","time":"1445"},{"label":"3 PM","time":"1500"},{"label":"","time":"1515"},{"label":"","time":"1530"},{"label":"","time":"1545"},{"label":"4 PM","time":"1600"},{"label":"","time":"1615"},{"label":"","time":"1630"},{"label":"","time":"1645"},{"label":"5 PM","time":null}]} timeLabels={[{"label":"11 AM","time":"1100"},{"label":"","time":"1115"},{"label":"","time":"1130"},{"label":"","time":"1145"},{"label":"12 PM","time":"1200"},{"label":"","time":"1215"},{"label":"","time":"1230"},{"label":"","time":"1245"},{"label":"1 PM","time":"1300"},{"label":"","time":"1315"},{"label":"","time":"1330"},{"label":"","time":"1345"},{"label":"2 PM","time":"1400"},{"label":"","time":"1415"},{"label":"","time":"1430"},{"label":"","time":"1445"},{"label":"3 PM","time":"1500"},{"label":"","time":"1515"},{"label":"","time":"1530"},{"label":"","time":"1545"},{"label":"4 PM","time":"1600"},{"label":"","time":"1615"},{"label":"","time":"1630"},{"label":"","time":"1645"},{"label":"5 PM","time":null}]}
@ -88,7 +90,7 @@ const Help = () => {
<AboutSection id="about"> <AboutSection id="about">
<StyledMain> <StyledMain>
<Center><Button buttonWidth="230px" onClick={() => push('/')}>Create your own Crab Fit!</Button></Center> <Center><Button buttonWidth="230px" onClick={() => push('/')}>{t('common:cta')}</Button></Center>
</StyledMain> </StyledMain>
</AboutSection> </AboutSection>

View file

@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useHistory, Link } from 'react-router-dom'; import { useHistory, Link } from 'react-router-dom';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useTranslation, Trans } from 'react-i18next';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc'; import utc from 'dayjs/plugin/utc';
@ -60,6 +61,7 @@ const Home = ({ offline }) => {
}); });
const { push } = useHistory(); const { push } = useHistory();
const recentsStore = useRecentsStore(); const recentsStore = useRecentsStore();
const { t } = useTranslation(['common', 'home']);
useEffect(() => { useEffect(() => {
const fetch = async () => { const fetch = async () => {
@ -152,17 +154,17 @@ const Home = ({ offline }) => {
<Center> <Center>
<Logo src={logo} alt="" /> <Logo src={logo} alt="" />
</Center> </Center>
<TitleSmall>CREATE A</TitleSmall> <TitleSmall>{t('home:create')}</TitleSmall>
<TitleLarge>CRAB FIT</TitleLarge> <TitleLarge>CRAB FIT</TitleLarge>
<Links> <Links>
<a href="#about">About</a> / <a href="#donate">Donate</a> <a href="#about">{t('home:nav.about')}</a> / <a href="#donate">{t('home:nav.donate')}</a>
</Links> </Links>
</StyledMain> </StyledMain>
{!!recentsStore.recents.length && ( {!!recentsStore.recents.length && (
<AboutSection id="recents"> <AboutSection id="recents">
<StyledMain> <StyledMain>
<h2>Recently visited</h2> <h2>{t('home:recently_visited')}</h2>
{recentsStore.recents.map(event => ( {recentsStore.recents.map(event => (
<Recent href={`/${event.id}`} key={event.id}> <Recent href={`/${event.id}`} key={event.id}>
<span className="name">{event.name}</span> <span className="name">{event.name}</span>
@ -177,13 +179,13 @@ const Home = ({ offline }) => {
{offline ? ( {offline ? (
<OfflineMessage> <OfflineMessage>
<h1>🦀📵</h1> <h1>🦀📵</h1>
<P>You can't create a Crab Fit when you don't have an internet connection. Please make sure you're connected.</P> <P>{t('home:offline')}</P>
</OfflineMessage> </OfflineMessage>
) : ( ) : (
<CreateForm onSubmit={handleSubmit(onSubmit)} id="create"> <CreateForm onSubmit={handleSubmit(onSubmit)} id="create">
<TextField <TextField
label="Give your event a name!" label={t('home:form.name.label')}
subLabel="Or leave blank to generate one" subLabel={t('home:form.name.sublabel')}
type="text" type="text"
name="name" name="name"
id="name" id="name"
@ -191,8 +193,8 @@ const Home = ({ offline }) => {
/> />
<CalendarField <CalendarField
label="What dates might work?" label={t('home:form.dates.label')}
subLabel="Click and drag to select" subLabel={t('home:form.dates.sublabel')}
name="dates" name="dates"
id="dates" id="dates"
required required
@ -200,8 +202,8 @@ const Home = ({ offline }) => {
/> />
<TimeRangeField <TimeRangeField
label="What times might work?" label={t('home:form.times.label')}
subLabel="Click and drag to select a time range" subLabel={t('home:form.times.sublabel')}
name="times" name="times"
id="times" id="times"
required required
@ -209,13 +211,13 @@ const Home = ({ offline }) => {
/> />
<SelectField <SelectField
label="And the timezone" label={t('home:form.timezone.label')}
name="timezone" name="timezone"
id="timezone" id="timezone"
register={register} register={register}
options={timezones} options={timezones}
required required
defaultOption="Select..." defaultOption={t('home:form.timezone.defaultOption')}
/> />
{error && ( {error && (
@ -223,7 +225,7 @@ const Home = ({ offline }) => {
)} )}
<Center> <Center>
<Button type="submit" isLoading={isLoading} disabled={isLoading}>Create</Button> <Button type="submit" isLoading={isLoading} disabled={isLoading}>{t('home:form.button')}</Button>
</Center> </Center>
</CreateForm> </CreateForm>
)} )}
@ -231,24 +233,24 @@ const Home = ({ offline }) => {
<AboutSection id="about"> <AboutSection id="about">
<StyledMain> <StyledMain>
<h2>About Crab Fit</h2> <h2>{t('home:about.name')}</h2>
<Stats> <Stats>
<Stat> <Stat>
<StatNumber>{stats.eventCount ?? '100+'}</StatNumber> <StatNumber>{stats.eventCount ?? '300+'}</StatNumber>
<StatLabel>Events created</StatLabel> <StatLabel>{t('home:about.events')}</StatLabel>
</Stat> </Stat>
<Stat> <Stat>
<StatNumber>{stats.personCount ?? '100+'}</StatNumber> <StatNumber>{stats.personCount ?? '400+'}</StatNumber>
<StatLabel>Availabilities entered</StatLabel> <StatLabel>{t('home:about.availabilities')}</StatLabel>
</Stat> </Stat>
</Stats> </Stats>
<P>Crab Fit helps you fit your event around everyone's schedules. Simply create an event above and send the link to everyone that is participating. Results update live and you will be able to see a heat-map of when everyone is free.<br /><Link to="/how-to">Learn more about how to Crab Fit</Link>.</P> <P><Trans i18nKey="home:about.content.p1">Crab Fit helps you fit your event around everyone's schedules. Simply create an event above and send the link to everyone that is participating. Results update live and you will be able to see a heat-map of when everyone is free.<br /><Link to="/how-to">Learn more about how to Crab Fit</Link>.</Trans></P>
{/* eslint-disable-next-line */} {/* eslint-disable-next-line */}
<P>Create a lot of Crab Fits? Get the <a href="https://chrome.google.com/webstore/detail/crab-fit/pnafiibmjbiljofcpjlbonpgdofjhhkj" target="_blank">Chrome extension</a> or <a href="https://addons.mozilla.org/en-US/firefox/addon/crab-fit/" target="_blank">Firefox extension</a> for your browser! You can also download the <a href="https://play.google.com/store/apps/details?id=fit.crab" target="_blank">Android app</a> to Crab Fit on the go.</P> <P><Trans i18nKey="home:about.content.p2">Create a lot of Crab Fits? Get the <a href="https://chrome.google.com/webstore/detail/crab-fit/pnafiibmjbiljofcpjlbonpgdofjhhkj" target="_blank">Chrome extension</a> or <a href="https://addons.mozilla.org/en-US/firefox/addon/crab-fit/" target="_blank">Firefox extension</a> for your browser! You can also download the <a href="https://play.google.com/store/apps/details?id=fit.crab" target="_blank">Android app</a> to Crab Fit on the go.</Trans></P>
{/* eslint-disable-next-line */} {/* eslint-disable-next-line */}
<P>Created by <a href="https://bengrant.dev" target="_blank">Ben Grant</a>, Crab Fit is the modern-day solution to your group event planning debates.</P> <P><Trans i18nKey="home:about.content.p3">Created by <a href="https://bengrant.dev" target="_blank">Ben Grant</a>, Crab Fit is the modern-day solution to your group event planning debates.</Trans></P>
<P>The code for Crab Fit is open source, if you find any issues or want to contribute, you can visit the <a href="https://github.com/GRA0007/crab.fit" target="_blank" rel="noreferrer">repository</a>. By using Crab Fit you agree to the <Link to="/privacy">privacy policy</Link>.</P> <P><Trans i18nKey="home:about.content.p4">The code for Crab Fit is open source, if you find any issues or want to contribute, you can visit the <a href="https://github.com/GRA0007/crab.fit" target="_blank" rel="noreferrer">repository</a>. By using Crab Fit you agree to the <Link to="/privacy">privacy policy</Link>.</Trans></P>
<P>Crab Fit costs more than <strong>$100 per month</strong> to run. Consider donating below if it helped you out so it can stay free for everyone. 🦀</P> <P><Trans i18nKey="home:about.content.p5">Crab Fit costs more than <strong>$100 per month</strong> to run. Consider donating below if it helped you out so it can stay free for everyone. 🦀</Trans></P>
</StyledMain> </StyledMain>
</AboutSection> </AboutSection>

View file

@ -19,6 +19,7 @@ export const TitleSmall = styled.span`
font-weight: 400; font-weight: 400;
color: ${props => props.theme.primaryDark}; color: ${props => props.theme.primaryDark};
line-height: 1em; line-height: 1em;
text-transform: uppercase;
`; `;
export const TitleLarge = styled.h1` export const TitleLarge = styled.h1`
@ -30,6 +31,7 @@ export const TitleLarge = styled.h1`
font-weight: 400; font-weight: 400;
text-shadow: 0 4px 0 ${props => props.theme.primaryDark}; text-shadow: 0 4px 0 ${props => props.theme.primaryDark};
line-height: 1em; line-height: 1em;
text-transform: uppercase;
@media (max-width: 350px) { @media (max-width: 350px) {
font-size: 3.5rem; font-size: 3.5rem;

View file

@ -1,5 +1,6 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Link, useHistory } from 'react-router-dom'; import { Link, useHistory } from 'react-router-dom';
import { useTranslation, Trans } from 'react-i18next';
import { import {
Button, Button,
@ -19,9 +20,10 @@ import logo from 'res/logo.svg';
const Privacy = () => { const Privacy = () => {
const { push } = useHistory(); const { push } = useHistory();
const { t } = useTranslation(['common', 'privacy']);
useEffect(() => { useEffect(() => {
document.title = 'Privacy Policy - Crab Fit'; document.title = `${t('privacy:name')} - Crab Fit`;
}, []); }, []);
return ( return (
@ -32,65 +34,65 @@ const Privacy = () => {
<Logo src={logo} alt="" /> <Logo src={logo} alt="" />
<Title>CRAB FIT</Title> <Title>CRAB FIT</Title>
</Center> </Center>
<Center style={{ textDecoration: 'underline', fontSize: 14, paddingTop: 6 }}>Create your own</Center> <Center style={{ textDecoration: 'underline', fontSize: 14, paddingTop: 6 }}>{t('common:tagline')}</Center>
</Link> </Link>
</StyledMain> </StyledMain>
<StyledMain> <StyledMain>
<h1>Privacy Policy</h1> <h1>{t('privacy:name')}</h1>
<h3>Crab Fit</h3> <h3>Crab Fit</h3>
<P>This SERVICE is provided by Benjamin Grant at no cost and is intended for use as is.</P> <P>{t('privacy:p1')}</P>
<P>This page is used to inform visitors regarding the policies of the collection, use, and disclosure of Personal Information if using the Service.</P> <P>{t('privacy:p2')}</P>
<P>If you choose to use the Service, then you agree to the collection and use of information in relation to this policy. The Personal Information that is collected is used for providing and improving the Service. Your information will not be used or shared with anyone except as described in this Privacy Policy.</P> <P>{t('privacy:p3')}</P>
<h2>Information Collection and Use</h2> <h2>{t('privacy:h1')}</h2>
<P>The Service uses third party services that may collect information used to identify you.</P> <P>{t('privacy:p4')}</P>
<P>Links to privacy policies of the third party service providers used by the Service:</P> <P>{t('privacy:p5')}</P>
<P> <P>
<ul> <ul>
<li><a href="https://www.google.com/policies/privacy/" target="blank">Google Play Services</a></li> <li><a href="https://www.google.com/policies/privacy/" target="blank">{t('privacy:link')}</a></li>
</ul> </ul>
</P> </P>
<h2>Log Data</h2> <h2>{t('privacy:h2')}</h2>
<P>When you use the Service, in the case of an error, data and information is collected to improve the Service, which may include your IP address, device name, operating system version, app configuration and the time and date of the error.</P> <P>{t('privacy:p6')}</P>
<h2>Cookies</h2> <h2>{t('privacy:h3')}</h2>
<P>Cookies are files with a small amount of data that are commonly used as anonymous unique identifiers. These are sent to your browser from the websites that you visit and are stored on your device's internal memory.</P> <P>{t('privacy:p7')}</P>
<P>Cookies are used by Google Analytics to track you across the web and provide anonymous statistics to improve the Service.</P> <P>{t('privacy:p8')}</P>
<h2>Service Providers</h2> <h2>{t('privacy:h4')}</h2>
<P>Third-party companies may be employed for the following reasons:</P> <P>{t('privacy:p9')}</P>
<P> <P>
<ul> <ul>
<li>To facilitate the Service</li> <li>{t('privacy:l1')}</li>
<li>To provide the Service on our behalf</li> <li>{t('privacy:l2')}</li>
<li>To perform Service-related services</li> <li>{t('privacy:l3')}</li>
<li>To assist in analyzing how the Service is used</li> <li>{t('privacy:l4')}</li>
</ul> </ul>
</P> </P>
<P>To perform these tasks, the third parties may have access to your Personal Information, but are obligated not to disclose or use this information for any purpose except the above.</P> <P>{t('privacy:p10')}</P>
<h2>Security</h2> <h2>{t('privacy:h5')}</h2>
<P>Personal Information that is shared via the Service is protected, however remember that no method of transmission over the internet, or method of electronic storage is 100% secure and reliable, so take care when sharing Personal Information.</P> <P>{t('privacy:p11')}</P>
<h2>Links to Other Sites</h2> <h2>{t('privacy:h6')}</h2>
<P>The Service may contain links to other sites. If you click on a third-party link, you will be directed to that site. Note that these external sites are not operated by the Service. Therefore, you are advised to review the Privacy Policy of these websites.</P> <P>{t('privacy:p12')}</P>
<h2>Children's Privacy</h2> <h2>{t('privacy:h7')}</h2>
<P>The Service does not address anyone under the age of 13. Personally identifiable information is not knowingly collected from children under 13. If discovered that a child under 13 has provided the Service with personal information, such information will be immediately deleted from the servers. If you are a parent or guardian and you are aware that your child has provided the Service with personal information, please <a href="mailto:benjamin.grantGRA0007+crabfit@gmail.com">contact us</a> so that this information can be removed.</P> <P><Trans i18nKey="privacy:p13">The Service does not address anyone under the age of 13. Personally identifiable information is not knowingly collected from children under 13. If discovered that a child under 13 has provided the Service with personal information, such information will be immediately deleted from the servers. If you are a parent or guardian and you are aware that your child has provided the Service with personal information, please <a href="mailto:benjamin.grantGRA0007+crabfit@gmail.com">contact us</a> so that this information can be removed.</Trans></P>
<h2>Changes to This Privacy Policy</h2> <h2>{t('privacy:h8')}</h2>
<P>This Privacy Policy may be updated from time to time. Thus, you are advised to review this page periodically for any changes.</P> <P>{t('privacy:p14')}</P>
<P>This policy is effective as of 2021-04-20</P> <P>{t('privacy:p15')}</P>
<h2>Contact Us</h2> <h2>{t('privacy:h9')}</h2>
<P>If you have any questions or suggestions about the Privacy Policy, do not hesitate to contact us at <a href="mailto:benjamin.grantGRA0007+crabfit@gmail.com">benjamin.grantGRA0007+crabfit@gmail.com</a>.</P> <P><Trans i18nKey="privacy:p16">If you have any questions or suggestions about the Privacy Policy, do not hesitate to contact us at <a href="mailto:benjamin.grantGRA0007+crabfit@gmail.com">benjamin.grantGRA0007+crabfit@gmail.com</a>.</Trans></P>
</StyledMain> </StyledMain>
<AboutSection id="about"> <AboutSection id="about">
<StyledMain> <StyledMain>
<Center><Button buttonWidth="230px" onClick={() => push('/')}>Create your own Crab Fit!</Button></Center> <Center><Button buttonWidth="230px" onClick={() => push('/')}>{t('common:cta')}</Button></Center>
</StyledMain> </StyledMain>
</AboutSection> </AboutSection>

View file

@ -1105,6 +1105,13 @@
dependencies: dependencies:
regenerator-runtime "^0.13.4" regenerator-runtime "^0.13.4"
"@babel/runtime@^7.12.0", "@babel/runtime@^7.13.6":
version "7.14.0"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.14.0.tgz#46794bc20b612c5f75e62dd071e24dfd95f1cbe6"
integrity sha512-JELkvo/DlpNdJ7dlyw/eY7E0suy5i5GQH+Vlxaq1nsNJ+H7f4Vtv3jMeCEgRhZZQFXTjldYfQgv2qmM6M1v5wA==
dependencies:
regenerator-runtime "^0.13.4"
"@babel/template@^7.10.4", "@babel/template@^7.12.13", "@babel/template@^7.3.3": "@babel/template@^7.10.4", "@babel/template@^7.12.13", "@babel/template@^7.3.3":
version "7.12.13" version "7.12.13"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.12.13.tgz#530265be8a2589dbb37523844c5bcb55947fb327" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.12.13.tgz#530265be8a2589dbb37523844c5bcb55947fb327"
@ -3677,6 +3684,13 @@ create-hmac@^1.1.0, create-hmac@^1.1.4, create-hmac@^1.1.7:
safe-buffer "^5.0.1" safe-buffer "^5.0.1"
sha.js "^2.4.8" sha.js "^2.4.8"
cross-fetch@3.1.4:
version "3.1.4"
resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.4.tgz#9723f3a3a247bf8b89039f3a380a9244e8fa2f39"
integrity sha512-1eAtFWdIubi6T4XPy6ei9iUFoKpUkIF971QLN8lIvvvwueI65+Nw5haMNKUwfJxabqlIIDODJKGrQ66gxC0PbQ==
dependencies:
node-fetch "2.6.1"
cross-spawn@7.0.3, cross-spawn@^7.0.0, cross-spawn@^7.0.2: cross-spawn@7.0.3, cross-spawn@^7.0.0, cross-spawn@^7.0.2:
version "7.0.3" version "7.0.3"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
@ -5637,6 +5651,13 @@ html-minifier-terser@^5.0.1:
relateurl "^0.2.7" relateurl "^0.2.7"
terser "^4.6.3" terser "^4.6.3"
html-parse-stringify@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz#dfc1017347ce9f77c8141a507f233040c59c55d2"
integrity sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==
dependencies:
void-elements "3.1.0"
html-webpack-plugin@4.5.0: html-webpack-plugin@4.5.0:
version "4.5.0" version "4.5.0"
resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-4.5.0.tgz#625097650886b97ea5dae331c320e3238f6c121c" resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-4.5.0.tgz#625097650886b97ea5dae331c320e3238f6c121c"
@ -5744,6 +5765,27 @@ human-signals@^1.1.1:
resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3" resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3"
integrity sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw== integrity sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==
i18next-browser-languagedetector@^6.1.1:
version "6.1.1"
resolved "https://registry.yarnpkg.com/i18next-browser-languagedetector/-/i18next-browser-languagedetector-6.1.1.tgz#fc4c6606bb3f7afc331737cf7c41e50919d55542"
integrity sha512-hckgbBdCpJPhkGUANe6tsvD52k9R7GuYskG0EaIw89pZz3owUvUEwXHqM5pX1Pn93jz+O65Y09ikwJrMkqtq2Q==
dependencies:
"@babel/runtime" "^7.5.5"
i18next-http-backend@^1.2.4:
version "1.2.4"
resolved "https://registry.yarnpkg.com/i18next-http-backend/-/i18next-http-backend-1.2.4.tgz#2be7d5e569557c22e4dd50df92425ee6c73f3296"
integrity sha512-ewvodowF2oBP0/vVAerpVF6aaIdAqH594K/ThA4Kl2A5Gm4QvUQuakvrFV5KMaKOggykGd9MuQ4xMcTFayVF1w==
dependencies:
cross-fetch "3.1.4"
i18next@^20.2.4:
version "20.2.4"
resolved "https://registry.yarnpkg.com/i18next/-/i18next-20.2.4.tgz#972220f19dfef0075a70890d3e8b1f7cf64c5bd6"
integrity sha512-goE1LCA/IZOGG26PkkqoOl2KWR7YP606SvokVQZ29J6QwE02KycrzNetoMUJeqYrTxs4rmiiZgZp+q8qofQL6Q==
dependencies:
"@babel/runtime" "^7.12.0"
iconv-lite@0.4.24: iconv-lite@0.4.24:
version "0.4.24" version "0.4.24"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
@ -7523,6 +7565,11 @@ no-case@^3.0.4:
lower-case "^2.0.2" lower-case "^2.0.2"
tslib "^2.0.3" tslib "^2.0.3"
node-fetch@2.6.1:
version "2.6.1"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052"
integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==
node-forge@^0.10.0: node-forge@^0.10.0:
version "0.10.0" version "0.10.0"
resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.10.0.tgz#32dea2afb3e9926f02ee5ce8794902691a676bf3" resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.10.0.tgz#32dea2afb3e9926f02ee5ce8794902691a676bf3"
@ -9153,6 +9200,14 @@ react-hook-form@^6.15.4:
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-6.15.4.tgz#328003e1ccc096cd158899ffe7e3b33735a9b024" resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-6.15.4.tgz#328003e1ccc096cd158899ffe7e3b33735a9b024"
integrity sha512-K+Sw33DtTMengs8OdqFJI3glzNl1wBzSefD/ksQw/hJf9CnOHQAU6qy82eOrh0IRNt2G53sjr7qnnw1JDjvx1w== integrity sha512-K+Sw33DtTMengs8OdqFJI3glzNl1wBzSefD/ksQw/hJf9CnOHQAU6qy82eOrh0IRNt2G53sjr7qnnw1JDjvx1w==
react-i18next@^11.8.15:
version "11.8.15"
resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-11.8.15.tgz#89450d585298f18d4a8eb1628b0868863f3a4767"
integrity sha512-ZbKcbYYKukgDL0MiUWKJTEsEftjSTNVZv67/V+SjPqTRwuF/aL4NbUtuEcb4WjHk0HyZ1M+2wGd07Fp0RUNHKA==
dependencies:
"@babel/runtime" "^7.13.6"
html-parse-stringify "^3.0.1"
react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1: react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1:
version "16.13.1" version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
@ -11127,6 +11182,11 @@ vm-browserify@^1.0.1:
resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0" resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0"
integrity sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ== integrity sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==
void-elements@3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-3.1.0.tgz#614f7fbf8d801f0bb5f0661f5b2f5785750e4f09"
integrity sha1-YU9/v42AHwu18GYfWy9XhXUOTwk=
w3c-hr-time@^1.0.2: w3c-hr-time@^1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz#0a89cdf5cc15822df9c360543676963e0cc308cd" resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz#0a89cdf5cc15822df9c360543676963e0cc308cd"