diff --git a/CLAUDE.md b/CLAUDE.md
index 1347af30..762876aa 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -6,33 +6,40 @@
Canonical reference: https://github.com/basedosdados/backend/wiki/Boas-Pr%C3%A1ticas#segue-o-fluxo
### The environment branches — never commit or push directly
-- `main` — production (basedosdados.org).
+- `main` — production (basedosdados.org). Source of truth: resets flow *from* `main`.
- `staging` — pre-production / QA.
-- `development` — integration / testing. First target for new work.
+- `development` — integration / testing.
-These three are **parallel, independently maintained** branches, not a linear chain.
-Their histories have diverged, so you never merge one environment branch into another to
-move a feature — that would drag the whole environment forward. Each feature is promoted
-into each environment **selectively**, via its own PR carrying only that feature.
+### How work flows
+Every feature starts from `main` and is promoted back into the other environments using
+**one branch and three PRs** — the same head branch is PR'd into `development`, `staging`, and
+`main`. Merging the same branch into each target puts the *same commit objects* into all
+three, so the feature's own commits share SHAs everywhere. Only the merge commits differ,
+which is expected and fine.
-Note: the integration branch here is named `development` (full word), not `dev`.
+The environments still drift apart as those merge commits accumulate at different times, so
+the team **resets `staging` and `development` back to `main` roughly every two weeks**. Because
+resets flow *from* `main`, a change must reach `main` to survive — anything living only on
+`staging` or `development` is discarded at the next reset.
-### Feature workflow — promote the feature, not the environment
-1. Cut your feature branch off `development` (the branch you integrate and test in first).
+### Feature workflow — one branch, three PRs
+1. Cut your feature branch off `main` — never off `staging` or `development`.
Name it by intent: `feat/…`, `fix/…`, `chore/…`, `docs/…`, `refactor/…`.
- Keep one logical change per branch, with tidy commits — you will cherry-pick them.
-2. Open a PR from that branch into `development`.
-3. To promote the same feature to `staging`, cut a new branch off `staging` and
- cherry-pick only this feature's commit(s) onto it, then open a PR into `staging`.
-4. To promote to `main`, repeat: cut a branch off `main`, cherry-pick the same commit(s),
- open a PR into `main`.
-5. Result: one clean PR per environment, each carrying only this feature.
+ One logical change per branch.
+2. From that **same branch**, open three PRs: one into `development`, one into `staging`, one
+ into `main`. Do not cut a separate branch per target, and do not cherry-pick.
+3. Merge with a **merge commit or fast-forward — never squash**. A squash mints a new,
+ unrelated commit on each branch and breaks the shared history the resets rely on.
+4. Timing: a `main`-based branch merges cleanly into `development`/`staging` when those are
+ aligned with `main` — in practice, shortly after a reset. The longer since the last
+ reset, the more of `main`'s accumulated commits the PR will drag along. If a target has
+ drifted far, wait for the reset rather than forcing a noisy merge.
### Rules for agents working in this repo
-- Never commit or push to `main`, `staging`, or `development` directly.
-- Move a feature between environments by cherry-picking it onto a branch cut off the
- target — never by merging `development → staging` or `staging → main`.
-- Each promotion branch is cut off its own target, so the PR diff is only this feature.
-- One logical change per branch; one PR at a time per target; keep commits clean for cherry-picking.
+- Never commit or push to `main`, `staging`, or `development` directly — always a feature branch + PR.
+- Always cut features off `main`, never off `staging` or `development`.
+- Use **one branch for all three PRs**. Never a branch-per-target, never cherry-pick.
+- **Never squash-merge.** Merge commit or fast-forward only.
+- Never merge one environment branch into another to promote a feature.
- Before committing, verify you are on a feature branch: `git branch --show-current`.
- Do not push without explicit permission.
diff --git a/next/components/molecules/ImgCrop.js b/next/components/molecules/ImgCrop.js
index be29962c..4204552f 100644
--- a/next/components/molecules/ImgCrop.js
+++ b/next/components/molecules/ImgCrop.js
@@ -53,7 +53,7 @@ export default function CropImage ({
onClose,
src,
id,
- username,
+ email,
}) {
const { t } = useTranslation('user');
const imgRef = useRef(null)
@@ -147,7 +147,7 @@ export default function CropImage ({
})
})
- const filePic = new File([picture], `${username}.jpeg`, {type: "image/jpeg"})
+ const filePic = new File([picture], `${email || id}.jpeg`, {type: "image/jpeg"})
const reg = new RegExp("(?<=:).*")
const [ uid ] = reg.exec(id)
diff --git a/next/components/molecules/Menu.js b/next/components/molecules/Menu.js
index d5ce4a16..0131b538 100644
--- a/next/components/molecules/Menu.js
+++ b/next/components/molecules/Menu.js
@@ -32,7 +32,7 @@ import { ControlledInputSimple } from "../atoms/ControlledInput";
import Link from "../atoms/Link";
import Button from "../atoms/Button";
import HelpWidget from "../atoms/HelpWidget";
-import { triggerGAEvent, triggerGAEventWithData, hasBDProSubscription, hasChatbotSubscription, trackNavigateToChatbotLp, clearClientSession, getDiscordUrl } from "../../utils";
+import { triggerGAEvent, triggerGAEventWithData, hasBDProSubscription, hasChatbotSubscription, trackNavigateToChatbotLp, clearClientSession, getDiscordUrl, getUserDisplayName, getUserPageHref, UserPagePath } from "../../utils";
import LabelText from "../atoms/Text/LabelText";
import BodyText from "../atoms/Text/BodyText";
@@ -346,7 +346,7 @@ function MenuDrawerUser({ userData, isOpen, onClose, isUserPro, haveInterprisePl
src={userData?.picture ? userData?.picture : "https://storage.googleapis.com/basedosdados-website/equipe/sem_foto.png"}
/>
- {userData?.username || ""}
+ {getUserDisplayName(userData)}
{
onClose()
- router.push({ pathname: `/user/${userData.username}`, query: elm.value })
+ router.push(getUserPageHref(elm.value))
}}
>
{elm.name}
@@ -593,7 +593,7 @@ function MenuUser ({ userData, onOpen, onClose, isUserPro }) {
/>
- {userData?.username ? userData?.username : ""}
+ {getUserDisplayName(userData)}
router.push(`/user/${userData.username}`)}
+ onClick={() => router.push(UserPagePath)}
>
@@ -647,7 +647,7 @@ function MenuUser ({ userData, onOpen, onClose, isUserPro }) {
_hover={{ backgroundColor: "transparent", opacity: "0.7" }}
onClick={async () => {
await clearClientSession()
- if(window.location.pathname.includes('/user/')) return window.location.href = "/"
+ if(window.location.pathname === "/user" || window.location.pathname.includes("/user/")) return window.location.href = "/"
window.location.reload()
}}
>
@@ -914,6 +914,7 @@ function DesktopLinks({
{(path === "/search" ||
path === "/dataset/[dataset]" ||
+ path === "/user" ||
path === "/user/[username]") && (
onChange(handlePhoneInputChange(value, e.target.value, callingCode))}
+ placeholder={placeholder}
+ inputGroupStyle={{ width: "100%" }}
+ {...props}
+ />
+ )
+
+ if (!showCallingCodeSelect) return PhoneField
+
+ return (
+
+
+ {PhoneField}
+
+ )
+}
diff --git a/next/components/organisms/componentsUserPage/Account.js b/next/components/organisms/componentsUserPage/Account.js
index b42ce736..5f6aaea6 100644
--- a/next/components/organisms/componentsUserPage/Account.js
+++ b/next/components/organisms/componentsUserPage/Account.js
@@ -14,7 +14,7 @@ import Link from "../../atoms/Link";
import TitleText from "../../atoms/Text/TitleText";
import CheckIcon from "../../../public/img/icons/checkIcon";
import WarningIcon from "../../../public/img/icons/warningIcon";
-import { hasBDProSubscription, clearClientSession } from "../../../utils";
+import { hasBDProSubscription, clearClientSession, getUserPageHref, normalizePhone, isValidE164Phone, formatPhoneInput, formatPhoneDisplay, splitStoredPhone, getDefaultCallingCode } from "../../../utils";
import {
LabelTextForm,
@@ -22,15 +22,16 @@ import {
ExtraInfoTextForm,
ModalGeneral,
Button,
- InputForm,
ErrorMessage
} from "../../molecules/uiUserPage";
+import PhoneInput from "../../molecules/PhoneInput";
export default function Account({ userInfo }) {
const { t } = useTranslation('user');
const router = useRouter();
+ const { locale } = router;
const toast = useToast();
- const usernameModal = useDisclosure();
+ const phoneModal = useDisclosure();
const eraseModalAccount = useDisclosure();
const sucessEraseModalAccount = useDisclosure();
const errorEraseModalAccount = useDisclosure();
@@ -39,38 +40,34 @@ export default function Account({ userInfo }) {
const [hasCancelSubscription, setHasCancelSubscription] = useState(false);
const [hasMembers, setHasMembers] = useState(false);
- const [formData, setFormData] = useState({username: ""});
+ const [formData, setFormData] = useState({
+ phone: "",
+ phoneCallingCode: getDefaultCallingCode(locale),
+ });
const [errors, setErrors] = useState({});
- const handleInputChange = (e) => {
- setFormData((prevState) => ({
- ...prevState,
- [e.target.name]: e.target.value,
- }))
- }
-
async function submitUpdate() {
setErrors({})
- if(formData.username === "") return setErrors({username: t('username.invalidUsername')})
- if(formData.username.includes(" ")) return setErrors({username: t('username.noSpacesInUsername')})
+ const phone = normalizePhone(formData.phone, formData.phoneCallingCode)
+ if(phone && !isValidE164Phone(phone)) return setErrors({phone: t('username.invalidPhone')})
setIsLoading(true)
const reg = new RegExp("(?<=:).*")
const [ id ] = reg.exec(userInfo?.id)
- const form = {id: id, username: formData.username}
- const result = await fetch(`/api/user/updateUser?p=${btoa(id)}&q=${btoa(form.username)}`, {method: "GET"})
+ const result = await fetch(`/api/user/updateUser?p=${btoa(id)}&q=${btoa(phone)}`, {method: "GET"})
.then(res => res.json())
if(result?.errors?.length === 0) {
const userData = await fetch(`/api/user/getUser?p=${btoa(id)}`, {method: "GET"})
.then(res => res.json())
cookies.set('userBD', JSON.stringify(userData))
- window.open(`/user/${formData.username}?account`, "_self")
+ window.open(getUserPageHref("account"), "_self")
}
if(result?.errors?.length > 0) {
- setErrors({username: t('username.usernameAlreadyExists')})
+ const hasUniqueError = result.errors.some((elm) => elm.field === "phone")
+ setErrors({phone: hasUniqueError ? t('username.phoneAlreadyExists') : t('username.invalidPhone')})
setIsLoading(false)
}
}
@@ -249,11 +246,11 @@ export default function Account({ userInfo }) {
- {t('username.changeUsername')}
+ {userInfo.phone ? t('username.changePhone') : t('username.addPhone')}
-
-
-
+
+ setFormData((prevState) => ({
+ ...prevState,
+ phoneCallingCode: callingCode,
+ phone: formatPhoneInput(prevState.phone, callingCode),
+ }))}
+ value={formData.phone}
+ onChange={(phone) => setFormData((prevState) => ({
+ ...prevState,
+ phone,
+ }))}
+ optional
/>
- {errors.username}
+ {errors.phone}
@@ -283,7 +287,7 @@ export default function Account({ userInfo }) {
isLoading={isLoading}
pointerEvents={isLoading ? "none" : "default"}
>
- {t('username.updateUsername')}
+ {t('username.updatePhone')}
@@ -480,12 +484,20 @@ export default function Account({ userInfo }) {
- {t('username.username')}
- {userInfo.username}
+ {t('username.phone')}
+ {userInfo.phone ? formatPhoneDisplay(userInfo.phone, locale) : t('username.phoneNotSet')}
+ onClick={() => {
+ const parsed = splitStoredPhone(userInfo.phone || "", locale)
+ setFormData({
+ phone: formatPhoneInput(parsed.localNumber, parsed.callingCode),
+ phoneCallingCode: parsed.callingCode,
+ })
+ setErrors({})
+ phoneModal.onOpen()
+ }}
+ >{userInfo.phone ? t('username.changePhone') : t('username.addPhone')}
diff --git a/next/components/organisms/componentsUserPage/PlansAndPayment.js b/next/components/organisms/componentsUserPage/PlansAndPayment.js
index a705a76f..af867f8d 100644
--- a/next/components/organisms/componentsUserPage/PlansAndPayment.js
+++ b/next/components/organisms/componentsUserPage/PlansAndPayment.js
@@ -23,7 +23,7 @@ import BodyText from "../../atoms/Text/BodyText";
import Toggle from "../../atoms/Toggle";
import { SectionPrice } from "../../../pages/prices";
import PaymentSystem from "../../organisms/PaymentSystem";
-import { triggerGAEvent, triggerGAEventWithData, hasBDProSubscription, hasChatbotSubscription, getChatbotStreamlitAppUrl, getSubscriptionStatusKey, isSubscriptionTrialing } from "../../../utils";
+import { triggerGAEvent, triggerGAEventWithData, hasBDProSubscription, hasChatbotSubscription, getChatbotStreamlitAppUrl, getSubscriptionStatusKey, isSubscriptionTrialing, getUserPageHref } from "../../../utils";
const SubscriptionBadgeStyles = {
active: { backgroundColor: "#D5E8DB", color: "#2B8C4D" },
@@ -571,7 +571,7 @@ export default function PlansAndPayment ({ userData }) {
const user = await fetch(`/api/user/getUser?p=${btoa(id)}`, {method: "GET"})
.then(res => res.json())
cookies.set('userBD', JSON.stringify(user))
- window.open(`/user/${userData.username}?plans_and_payment`, "_self")
+ window.open(getUserPageHref("plans_and_payment"), "_self")
}
async function closeModalSucess() {
@@ -604,7 +604,7 @@ export default function PlansAndPayment ({ userData }) {
setIsChatbotTrialSuccess(false)
if(isLoadingH === true) return window.open("/", "_self")
- window.open(`/user/${userData.username}?plans_and_payment`, "_self")
+ window.open(getUserPageHref("plans_and_payment"), "_self")
}
function formatTimeStamp (value) {
@@ -793,7 +793,7 @@ export default function PlansAndPayment ({ userData }) {
resetCheckoutState();
if (query.i)
return window.open(
- `/user/${userData.username}?plans_and_payment`,
+ `/user?plans_and_payment`,
"_self",
);
PaymentModal.onClose();
@@ -1348,7 +1348,7 @@ export default function PlansAndPayment ({ userData }) {
setIsLoading(false);
setIsLoadingH(false);
SucessPaymentModal.onClose();
- window.open(`/user/${userData?.username}?big_query`, "_self");
+ window.open(getUserPageHref("big_query"), "_self");
}}
isLoading={isLoading}
>
diff --git a/next/components/organisms/componentsUserPage/ProfileConfiguration.js b/next/components/organisms/componentsUserPage/ProfileConfiguration.js
index e0eae800..00d1d9f3 100644
--- a/next/components/organisms/componentsUserPage/ProfileConfiguration.js
+++ b/next/components/organisms/componentsUserPage/ProfileConfiguration.js
@@ -202,7 +202,6 @@ export default function ProfileConfiguration({ userInfo }) {
onClose={pictureModal.onClose}
src={picture}
id={userInfo.id}
- username={userInfo.username}
email={userInfo.email}
/>
diff --git a/next/cypress/e2e/payment.cy.js b/next/cypress/e2e/payment.cy.js
index 3f0de787..0f4f91d8 100644
--- a/next/cypress/e2e/payment.cy.js
+++ b/next/cypress/e2e/payment.cy.js
@@ -1,6 +1,4 @@
describe('Área do Usuário e Sistema de pagamento', () => {
- const username = 'cypress_test';
-
function getSafeUserBdCookie() {
return cy.getCookie('userBD').then(cookie => {
if (!cookie?.value) throw new Error('Cookie userBD não encontrado');
@@ -28,19 +26,19 @@ describe('Área do Usuário e Sistema de pagamento', () => {
cy.intercept('GET', '/api/stripe/getPlans').as('getPlans');
- cy.visit(`/user/${username}?plans_and_payment`);
+ cy.visit(`/user?plans_and_payment`);
cy.wait('@getPlans', { timeout: 15000 });
});
it('Não deve acessar sem autenticação', () => {
cy.clearCookies();
- cy.visit(`/user/${username}?plans_and_payment`);
+ cy.visit(`/user?plans_and_payment`);
cy.url().should('include', '/user/login');
});
it('Deve acessar a página do usuário com autenticação', () => {
- cy.url().should('include', `/user/${username}`);
+ cy.location('pathname').should('eq', '/user');
cy.url().should('include', 'plans_and_payment');
});
@@ -317,7 +315,7 @@ describe('Área do Usuário e Sistema de pagamento', () => {
expect(response.body).to.have.property('success', true);
cy.wait(60000);
- cy.visit(`/user/${username}?plans_and_payment`);
+ cy.visit(`/user?plans_and_payment`);
cy.contains('p', 'BD Grátis', { timeout: 10000 })
.should('be.visible');
@@ -443,7 +441,7 @@ describe('Área do Usuário e Sistema de pagamento', () => {
expect(response.body).to.have.property('success', true);
cy.wait(60000);
- cy.visit(`/user/${username}?plans_and_payment`);
+ cy.visit(`/user?plans_and_payment`);
cy.contains('p', 'BD Grátis', { timeout: 10000 })
.should('be.visible');
diff --git a/next/cypress/e2e/register.cy.js b/next/cypress/e2e/register.cy.js
index 626b2986..45424a06 100644
--- a/next/cypress/e2e/register.cy.js
+++ b/next/cypress/e2e/register.cy.js
@@ -7,8 +7,8 @@ describe('Fluxo de Registro', () => {
cy.contains('h1', 'Cadastre-se').should('be.visible');
cy.get('input[name="firstName"]').should('exist');
cy.get('input[name="lastName"]').should('exist');
- cy.get('input[name="user"]').should('exist');
cy.get('input[name="username"]').should('exist');
+ cy.get('input[name="phone"]').should('exist');
cy.get('input[id="password"]').should('exist');
cy.get('input[id="confirmPassword"]').should('exist');
cy.contains('button', 'Cadastrar').should('be.visible');
@@ -19,7 +19,6 @@ describe('Fluxo de Registro', () => {
cy.contains('Por favor, insira seu nome.').should('be.visible');
cy.contains('Endereço de e-mail inválido ou já existe uma conta com este e-mail.').should('be.visible');
- cy.contains('Nome de usuário inválido ou já existe uma conta com este nome de usuário.').should('be.visible');
cy.contains('Por favor, insira a senha.').should('be.visible');
});
@@ -102,24 +101,10 @@ describe('Fluxo de Registro', () => {
});
});
- it('Deve mostrar erro para username já existente', () => {
- cy.fixture('registerUsers').then((users) => {
- const existingUser = users.existingUsername;
-
- cy.mockRegisterApi({
- statusCode: 200,
- body: {
- success: false,
- errors: [{ field: "username" }]
- }
- });
-
- cy.fillRegisterForm(existingUser);
- cy.contains('button', 'Cadastrar').click();
-
- cy.contains('Conta com este nome de usuário já existe.').should('be.visible');
- cy.contains('Erro ao tentar se cadastrar: o nome de usuário já existe!').should('be.visible');
- });
+ it('Deve validar formato de celular quando informado', () => {
+ cy.get('input[name="phone"]').type('123');
+ cy.contains('button', 'Cadastrar').click();
+ cy.contains('Informe um número de celular válido.').should('be.visible');
});
it('Deve alternar visibilidade da senha', () => {
diff --git a/next/cypress/fixtures/registerUsers.json b/next/cypress/fixtures/registerUsers.json
index 13c93da1..919621cb 100644
--- a/next/cypress/fixtures/registerUsers.json
+++ b/next/cypress/fixtures/registerUsers.json
@@ -2,21 +2,14 @@
"validUser": {
"firstName": "Test",
"lastName": "User",
- "username": "testuser",
+ "phone": "11999999999",
"email": "test@example.com",
"password": "ValidPass123!",
"confirmPassword": "ValidPass123!"
},
"existingEmail": {
"firstName": "Test",
- "username": "testuser",
- "email": "exists@example.com",
- "password": "AnyPass123!",
- "confirmPassword": "AnyPass123!"
- },
- "existingUsername": {
- "firstName": "Test",
- "username": "takenusername",
+ "phone": "11988888888",
"email": "exists@example.com",
"password": "AnyPass123!",
"confirmPassword": "AnyPass123!"
@@ -24,4 +17,4 @@
"invalidPassword": {
"password": "weak"
}
-}
\ No newline at end of file
+}
diff --git a/next/cypress/support/commands.js b/next/cypress/support/commands.js
index a1a70287..bbfd204a 100644
--- a/next/cypress/support/commands.js
+++ b/next/cypress/support/commands.js
@@ -35,8 +35,8 @@ Cypress.Commands.add('fillRegisterForm', (userData) => {
if (userData.email) {
cy.get('input[name="username"]').clear().type(userData.email);
}
- if (userData.username) {
- cy.get('input[name="user"]').type(userData.username);
+ if (userData.phone) {
+ cy.get('input[name="phone"]').type(userData.phone);
}
if (userData.password) {
cy.get('input[id="password"]').type(userData.password);
diff --git a/next/pages/_app.js b/next/pages/_app.js
index fed13464..36dcd51b 100644
--- a/next/pages/_app.js
+++ b/next/pages/_app.js
@@ -132,6 +132,25 @@ function MyApp({ Component, pageProps }) {
}
{/* */}
{/* FIM DA TAG DEVELOPMENT */}
+
+ {/* Meta Pixel Code */}
+ {local === "https://basedosdados.org" && (
+
+ )}
+ {/* End Meta Pixel Code */}
@@ -166,6 +185,20 @@ function MyApp({ Component, pageProps }) {
{/* */}
{/* FIM DA TAG DEVELOPMENT */}
+ {/* Meta Pixel Code (noscript) */}
+ {local === "https://basedosdados.org" && (
+
+ )}
+ {/* End Meta Pixel Code (noscript) */}
+
);
}
diff --git a/next/pages/api/user/getUser.js b/next/pages/api/user/getUser.js
index 9d31d93f..b06610dc 100644
--- a/next/pages/api/user/getUser.js
+++ b/next/pages/api/user/getUser.js
@@ -22,7 +22,7 @@ async function getUser(id, token) {
isEmailVisible
gcpEmail
picture
- username
+ phone
firstName
lastName
email
diff --git a/next/pages/api/user/getUserTestCypress.js b/next/pages/api/user/getUserTestCypress.js
index 89b0ad37..0d7def32 100644
--- a/next/pages/api/user/getUserTestCypress.js
+++ b/next/pages/api/user/getUserTestCypress.js
@@ -47,7 +47,7 @@ async function getUserData(id, token) {
isEmailVisible
gcpEmail
picture
- username
+ phone
firstName
lastName
email
diff --git a/next/pages/api/user/registerAccount.js b/next/pages/api/user/registerAccount.js
index 9371d55b..171e4aab 100644
--- a/next/pages/api/user/registerAccount.js
+++ b/next/pages/api/user/registerAccount.js
@@ -5,11 +5,16 @@ const API_URL= `${process.env.NEXT_PUBLIC_API_URL}/api/v1/graphql`
async function registerAccount({
firstName,
lastName = "",
- username,
email,
password,
+ phone = "",
}) {
try {
+ const input = {
+ email, firstName, lastName, password
+ }
+ if (phone) input.phone = phone
+
const res = await axios({
url: API_URL,
method: "POST",
@@ -28,9 +33,7 @@ async function registerAccount({
}
}`,
variables: {
- input: {
- username, email, firstName, lastName, password
- }
+ input
}
}
})
@@ -43,14 +46,14 @@ async function registerAccount({
}
export default async function handler(req, res) {
- const { f, l, u, e, p } = req.query
+ const { f, l, e, p, c } = req.query
const object = {
firstName: Buffer.from(f, 'base64').toString('utf-8'),
lastName: Buffer.from(l, 'base64').toString('utf-8'),
- username: Buffer.from(u, 'base64').toString('utf-8'),
email: Buffer.from(e, 'base64').toString('utf-8'),
- password: Buffer.from(p, 'base64').toString('utf-8')
+ password: Buffer.from(p, 'base64').toString('utf-8'),
+ phone: c ? Buffer.from(c, 'base64').toString('utf-8') : "",
}
function replaceNullsWithEmpty(obj) {
diff --git a/next/pages/api/user/updateUser.js b/next/pages/api/user/updateUser.js
index 88852a0b..987f3605 100644
--- a/next/pages/api/user/updateUser.js
+++ b/next/pages/api/user/updateUser.js
@@ -4,7 +4,7 @@ const API_URL= `${process.env.NEXT_PUBLIC_API_URL}/api/v1/graphql`
async function updateUser({
id,
- username = "",
+ phone = "",
}, token
) {
try {
@@ -16,20 +16,20 @@ async function updateUser({
},
data: {
query: `
- mutation {
- CreateUpdateAccount (input:
- {
- id: "${id}"
- ${username === "" ? "" : `username: "${username}"`}
- }
- )
- {
+ mutation CreateUpdateAccount($input: CreateUpdateAccountInput!) {
+ CreateUpdateAccount(input: $input) {
errors {
- field,
+ field
messages
}
}
- }`
+ }`,
+ variables: {
+ input: {
+ id,
+ phone: phone === "" ? null : phone
+ }
+ }
}
})
@@ -45,7 +45,7 @@ export default async function handler(req, res) {
const object = {
id: atob(req.query.p),
- username: atob(req.query.q)
+ phone: req.query.q ? atob(req.query.q) : ""
}
const result = await updateUser(object, token)
diff --git a/next/pages/chatbot-lp.js b/next/pages/chatbot-lp.js
index 49fbeef0..30b19615 100644
--- a/next/pages/chatbot-lp.js
+++ b/next/pages/chatbot-lp.js
@@ -14,7 +14,7 @@ import { useTranslation } from 'next-i18next';
import { MainPageTemplate } from "../components/templates/main";
import { withPages } from "../hooks/pages.hook";
import { isMobileMod } from "../hooks/useCheckMobile.hook";
-import { triggerGAEventWithData, getUserFromCookie, hasChatbotSubscription } from "../utils";
+import { triggerGAEventWithData, getUserFromCookie, hasChatbotSubscription, getUserPageHref, isUserLoggedIn } from "../utils";
import { getAllFAQs } from "./api/faqs";
@@ -204,7 +204,7 @@ function ChatbotPricingCard() {
const [toggleAnual, setToggleAnual] = useState(true);
const [plans, setPlans] = useState(null);
- const [username, setUsername] = useState(null);
+ const [isLoggedIn, setIsLoggedIn] = useState(false);
const [hasSubscribed, setHasSubscribed] = useState(true);
const [isBDChatbot, setIsBDChatbot] = useState({ isCurrentPlan: false });
const [isLoading, setIsLoading] = useState(true);
@@ -255,7 +255,7 @@ function ChatbotPricingCard() {
(n?.stripeSubscription || "").toLowerCase().includes("chatbot")
);
- setUsername(user?.username);
+ setIsLoggedIn(isUserLoggedIn(user));
setIsBDChatbot({
isCurrentPlan: !!chatbotNode,
planInterval: chatbotNode?.planInterval,
@@ -283,12 +283,12 @@ function ChatbotPricingCard() {
cookies.set("plan_selected", selectedPlan._id, { expires: 1, path: "/" });
}
- if (username === null) {
+ if (!isLoggedIn) {
router.push("/user/login");
return;
}
- router.push(`/user/${username}?plans_and_payment`);
+ router.push(getUserPageHref("plans_and_payment"));
};
const buttonLabel = isCurrentPlan
diff --git a/next/pages/prices.js b/next/pages/prices.js
index 5f332e59..90eaa136 100644
--- a/next/pages/prices.js
+++ b/next/pages/prices.js
@@ -23,7 +23,7 @@ import BodyText from "../components/atoms/Text/BodyText";
import CheckIcon from "../public/img/icons/checkIcon";
import InfoIcon from '../public/img/icons/infoIcon';
-import { triggerGAEvent, triggerGAEventWithData } from "../utils";
+import { triggerGAEvent, triggerGAEventWithData, getUserPageHref, isUserLoggedIn } from "../utils";
export async function getStaticProps({ locale }) {
const pagesProps = await withPages();
@@ -336,7 +336,7 @@ export function SectionPrice({
const { locale } = useRouter();
const [toggleAnual, setToggleAnual] = useState(true)
const [plans, setPlans] = useState(null)
- const [username, setUsername] = useState(null)
+ const [isLoggedIn, setIsLoggedIn] = useState(false)
const [isBDPro, setIsBDPro] = useState({isCurrentPlan: false})
const [isBDEmp, setIsBDEmp] = useState({isCurrentPlan: false})
const [isBDChatbot, setIsBDChatbot] = useState({isCurrentPlan: false})
@@ -439,7 +439,7 @@ export function SectionPrice({
)
const planIntervalLegacy = user?.subscriptionSet?.edges?.[0]?.node?.planInterval
- setUsername(user?.username)
+ setIsLoggedIn(isUserLoggedIn(user))
setIsBDPro({
isCurrentPlan: user?.proSubscription === "bd_pro",
planInterval: (nodeBDPro?.planInterval ?? planIntervalLegacy),
@@ -641,9 +641,9 @@ export function SectionPrice({
? t("subscribe")
: t("startFreeTrial"),
href:
- username === null
- ? `/user/login`
- : `/user/${username}?plans_and_payment`,
+ isLoggedIn
+ ? getUserPageHref("plans_and_payment")
+ : `/user/login`,
onClick: () => {
triggerGAEventWithData("bd_chatbot_card_price", {
plan_interval: toggleAnual ? "year" : "month",
@@ -682,9 +682,9 @@ export function SectionPrice({
? t("subscribe")
: t("startFreeTrial"),
href:
- username === null
- ? `/user/login`
- : `/user/${username}?plans_and_payment`,
+ isLoggedIn
+ ? getUserPageHref("plans_and_payment")
+ : `/user/login`,
onClick: () => {
triggerGAEventWithData("bd_pro_card_price", {
plan_interval: toggleAnual ? "year" : "month",
diff --git a/next/pages/user/[username].js b/next/pages/user/[username].js
index 28e70a55..ee8fdfb5 100644
--- a/next/pages/user/[username].js
+++ b/next/pages/user/[username].js
@@ -1,242 +1,26 @@
-import {
- Stack,
- Box,
- Divider,
-} from "@chakra-ui/react";
-import { useState, useEffect } from "react";
-import { useRouter } from "next/router";
-import { serialize } from 'cookie';
-import { useTranslation } from "react-i18next";
-import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
-import { MainPageTemplate } from "../../components/templates/main";
-import { hasBDProSubscription } from "../../utils";
-import { serializeClearedTokenCookie } from "../../lib/authCookie";
-import TitleText from "../../components/atoms/Text/TitleText";
-import LabelText from "../../components/atoms/Text/LabelText";
-
-import {
- ProfileConfiguration,
- Account,
- NewPassword,
- PlansAndPayment,
- BigQuery,
- Accesses
-} from "../../components/organisms/componentsUserPage";
-
export async function getServerSideProps(context) {
- const { req, res, locale } = context
- let user = null
-
- if(req.cookies.userBD) user = JSON.parse(req.cookies.userBD)
-
- if (user === null || Object.keys(user).length < 0) {
- res.setHeader('Set-Cookie', serializeClearedTokenCookie())
-
- return {
- redirect: {
- destination: "/user/login",
- permanent: false,
- }
+ const { query } = context
+ const params = new URLSearchParams()
+
+ for (const [key, value] of Object.entries(query)) {
+ if (key === "username") continue
+ if (Array.isArray(value)) {
+ value.forEach((item) => params.append(key, item))
+ } else {
+ params.append(key, value)
}
}
- const cookieHeader = req.headers.cookie || ''
- const validateTokenResponse = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL_FRONTEND}/api/user/validateToken`, {
- method: "GET",
- headers: { Cookie: cookieHeader },
- })
- const validateToken = await validateTokenResponse.json()
-
- if(validateToken.error || !validateToken.success) {
- const refreshTokenResponse = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL_FRONTEND}/api/user/refreshToken`, {
- method: "GET",
- headers: { Cookie: cookieHeader },
- })
- const refreshToken = await refreshTokenResponse.json()
-
- const refreshedCookie = refreshTokenResponse.headers.get('set-cookie')
- if (refreshedCookie) {
- res.setHeader('Set-Cookie', refreshedCookie)
- }
-
- if(refreshToken.error || !refreshToken.success) {
- res.setHeader('Set-Cookie', [
- serializeClearedTokenCookie(),
- serialize('userBD', '', { maxAge: -1, path: '/' }),
- ])
-
- return {
- redirect: {
- destination: "/user/login",
- permanent: false,
- }
- }
- }
- }
-
- const reg = new RegExp("(?<=:).*")
- const [ id ] = reg.exec(user.id)
-
- const getUserResponse = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL_FRONTEND}/api/user/getUser?p=${btoa(id)}`, {
- method: "GET",
- headers: { Cookie: cookieHeader },
- })
- const getUser = await getUserResponse.json()
-
- if(getUser.errors) {
- res.setHeader('Set-Cookie', [
- serializeClearedTokenCookie(),
- serialize('userBD', '', { maxAge: -1, path: '/' }),
- ])
-
- return {
- redirect: {
- destination: "/user/login",
- permanent: false,
- }
- }
- }
-
- const userDataString = JSON.stringify(getUser)
- res.setHeader('Set-Cookie', serialize('userBD', userDataString, { maxAge: 60 * 60 * 24 * 7, path: '/'}))
-
- const isUserPro = hasBDProSubscription(getUser);
- const haveInterprisePlan = getUser?.proSubscription === "bd_pro_empresas"
+ const qs = params.toString()
return {
- props: {
- ...(await serverSideTranslations(locale, ['menu', 'user', 'prices', 'common'])),
- getUser,
- isUserPro,
- haveInterprisePlan
- }
+ redirect: {
+ destination: qs ? `/user?${qs}` : "/user",
+ permanent: true,
+ },
}
}
-export default function UserPage({ getUser, isUserPro, haveInterprisePlan }) {
- const { t, ready } = useTranslation('user')
- const router = useRouter()
- const { query } = router
- const [userInfo, setUserInfo] = useState({})
- const [sectionSelected, setSectionSelected] = useState(0)
-
- if (!ready) return null
-
- useEffect(() => {
- if (getUser) {
- setUserInfo(getUser)
- }
- }, [getUser])
-
- const choices = [
- {bar: t('username.publicProfile'), title: t('username.publicProfile'), value: "profile", index: 0},
- {bar: t('username.account'), title: t('username.account'), value: "account", index: 1},
- {bar: t('username.changePassword'), title: t('username.changePassword'), value: "new_password", index: 2},
- {bar: t('username.plansAndPayment'), title: t('username.plansAndPayment'), value: "plans_and_payment", index: 3},
- isUserPro && {bar: "BigQuery", title: "BigQuery", value: "big_query", index: 4},
- haveInterprisePlan && {bar: t('username.access'), title: t('username.access'), value: "accesses", index: 5}
- ].filter(Boolean)
-
- useEffect(() => {
- const key = Object.keys(query)
- const removeElem = key.indexOf("username")
- if (removeElem !== -1) key.splice(removeElem, 1)
-
- if (key.length === 0) return
-
- for (const elements of choices) {
- if (elements && elements.value === key[0]) {
- setSectionSelected(elements.index)
- }
- }
- }, [query])
-
- return (
-
-
-
-
- {t('username.settings')}
-
-
-
- {choices.map((section, index) => (
-
-
- router.replace(
- { pathname: `/user/${userInfo.username}`, query: section.value },
- undefined,
- { shallow: true }
- )}
- >
- { section.bar }
-
-
- ))}
-
-
-
-
-
- {choices[sectionSelected].title}
-
-
-
- {sectionSelected === 0 && }
- {sectionSelected === 1 && }
- {sectionSelected === 2 && }
- {sectionSelected === 3 && }
- {sectionSelected === 4 && }
- {sectionSelected === 5 && }
-
-
-
- )
+export default function UserPageRedirect() {
+ return null
}
diff --git a/next/pages/user/index.js b/next/pages/user/index.js
new file mode 100644
index 00000000..4b224be9
--- /dev/null
+++ b/next/pages/user/index.js
@@ -0,0 +1,264 @@
+import {
+ Stack,
+ Box,
+ Divider,
+} from "@chakra-ui/react";
+import { useState, useEffect } from "react";
+import { useRouter } from "next/router";
+import { serialize } from 'cookie';
+import { useTranslation } from "react-i18next";
+import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
+import { MainPageTemplate } from "../../components/templates/main";
+import { hasBDProSubscription, UserPagePath } from "../../utils";
+import { serializeClearedTokenCookie } from "../../lib/authCookie";
+import TitleText from "../../components/atoms/Text/TitleText";
+import LabelText from "../../components/atoms/Text/LabelText";
+
+import {
+ ProfileConfiguration,
+ Account,
+ NewPassword,
+ PlansAndPayment,
+ BigQuery,
+ Accesses
+} from "../../components/organisms/componentsUserPage";
+
+function getRequestOrigin(req) {
+ const forwardedHost = req.headers["x-forwarded-host"]
+ const host = (typeof forwardedHost === "string"
+ ? forwardedHost.split(",")[0].trim()
+ : req.headers.host)
+ const forwardedProto = req.headers["x-forwarded-proto"]
+ const proto = (typeof forwardedProto === "string"
+ ? forwardedProto.split(",")[0].trim()
+ : "http")
+
+ if (host) return `${proto}://${host}`
+ return process.env.NEXT_PUBLIC_BASE_URL_FRONTEND
+}
+
+async function fetchInternalJson(url, cookieHeader) {
+ const response = await fetch(url, {
+ method: "GET",
+ headers: {
+ Cookie: cookieHeader,
+ Accept: "application/json",
+ },
+ })
+ const contentType = response.headers.get("content-type") || ""
+
+ if (!contentType.includes("application/json")) {
+ return { response, data: { error: true, success: false } }
+ }
+
+ try {
+ return { response, data: await response.json() }
+ } catch {
+ return { response, data: { error: true, success: false } }
+ }
+}
+
+export async function getServerSideProps(context) {
+ const { req, res, locale } = context
+ let user = null
+
+ if(req.cookies.userBD) user = JSON.parse(req.cookies.userBD)
+
+ if (user === null || Object.keys(user).length < 0) {
+ res.setHeader('Set-Cookie', serializeClearedTokenCookie())
+
+ return {
+ redirect: {
+ destination: "/user/login",
+ permanent: false,
+ }
+ }
+ }
+
+ const origin = getRequestOrigin(req)
+ const cookieHeader = req.headers.cookie || ''
+ const { data: validateToken } = await fetchInternalJson(`${origin}/api/user/validateToken`, cookieHeader)
+
+ if(validateToken.error || !validateToken.success) {
+ const { response: refreshTokenResponse, data: refreshToken } = await fetchInternalJson(`${origin}/api/user/refreshToken`, cookieHeader)
+
+ const refreshedCookie = refreshTokenResponse.headers.get('set-cookie')
+ if (refreshedCookie) {
+ res.setHeader('Set-Cookie', refreshedCookie)
+ }
+
+ if(refreshToken.error || !refreshToken.success) {
+ res.setHeader('Set-Cookie', [
+ serializeClearedTokenCookie(),
+ serialize('userBD', '', { maxAge: -1, path: '/' }),
+ ])
+
+ return {
+ redirect: {
+ destination: "/user/login",
+ permanent: false,
+ }
+ }
+ }
+ }
+
+ const reg = new RegExp("(?<=:).*")
+ const [ id ] = reg.exec(user.id)
+
+ const { data: getUser } = await fetchInternalJson(`${origin}/api/user/getUser?p=${btoa(id)}`, cookieHeader)
+
+ if(getUser.errors || getUser.error) {
+ res.setHeader('Set-Cookie', [
+ serializeClearedTokenCookie(),
+ serialize('userBD', '', { maxAge: -1, path: '/' }),
+ ])
+
+ return {
+ redirect: {
+ destination: "/user/login",
+ permanent: false,
+ }
+ }
+ }
+
+ const userDataString = JSON.stringify(getUser)
+ res.setHeader('Set-Cookie', serialize('userBD', userDataString, { maxAge: 60 * 60 * 24 * 7, path: '/'}))
+
+ const isUserPro = hasBDProSubscription(getUser);
+ const haveInterprisePlan = getUser?.proSubscription === "bd_pro_empresas"
+
+ return {
+ props: {
+ ...(await serverSideTranslations(locale, ['menu', 'user', 'prices', 'common'])),
+ getUser,
+ isUserPro,
+ haveInterprisePlan
+ }
+ }
+}
+
+export default function UserPage({ getUser, isUserPro, haveInterprisePlan }) {
+ const { t, ready } = useTranslation('user')
+ const router = useRouter()
+ const { query } = router
+ const [userInfo, setUserInfo] = useState({})
+ const [sectionSelected, setSectionSelected] = useState(0)
+
+ if (!ready) return null
+
+ useEffect(() => {
+ if (getUser) {
+ setUserInfo(getUser)
+ }
+ }, [getUser])
+
+ const choices = [
+ {bar: t('username.publicProfile'), title: t('username.publicProfile'), value: "profile", index: 0},
+ {bar: t('username.account'), title: t('username.account'), value: "account", index: 1},
+ {bar: t('username.changePassword'), title: t('username.changePassword'), value: "new_password", index: 2},
+ {bar: t('username.plansAndPayment'), title: t('username.plansAndPayment'), value: "plans_and_payment", index: 3},
+ isUserPro && {bar: "BigQuery", title: "BigQuery", value: "big_query", index: 4},
+ haveInterprisePlan && {bar: t('username.access'), title: t('username.access'), value: "accesses", index: 5}
+ ].filter(Boolean)
+
+ useEffect(() => {
+ const key = Object.keys(query)
+
+ if (key.length === 0) return
+
+ for (const elements of choices) {
+ if (elements && elements.value === key[0]) {
+ setSectionSelected(elements.index)
+ }
+ }
+ }, [query])
+
+ return (
+
+
+
+
+ {t('username.settings')}
+
+
+
+ {choices.map((section, index) => (
+
+
+ router.replace(
+ `${UserPagePath}?${section.value}`,
+ undefined,
+ { shallow: true }
+ )}
+ >
+ { section.bar }
+
+
+ ))}
+
+
+
+
+
+ {choices[sectionSelected].title}
+
+
+
+ {sectionSelected === 0 && }
+ {sectionSelected === 1 && }
+ {sectionSelected === 2 && }
+ {sectionSelected === 3 && }
+ {sectionSelected === 4 && }
+ {sectionSelected === 5 && }
+
+
+
+ )
+}
diff --git a/next/pages/user/login.js b/next/pages/user/login.js
index 619ca366..ea68efff 100644
--- a/next/pages/user/login.js
+++ b/next/pages/user/login.js
@@ -29,6 +29,7 @@ import { EyeIcon, EyeOffIcon } from "../../public/img/icons/eyeIcon";
import GoogleIcon from "../../public/img/icons/googleIcon";
import { withPages } from "../../hooks/pages.hook";
+import { getUserPageHref } from "../../utils";
export async function getStaticProps({ locale }) {
const pages = await withPages();
@@ -144,13 +145,7 @@ export default function Login() {
const postAuthPlanId = cookies.get('plan_selected');
if(postAuthPlanId) {
- return router.push({
- pathname: '/user/[username]',
- query: {
- username: userData.username,
- plans_and_payment: '',
- }
- })
+ return router.push(getUserPageHref("plans_and_payment"))
}
if(userData.workDataTool === null) {
diff --git a/next/pages/user/register.js b/next/pages/user/register.js
index 72faee18..3e976692 100644
--- a/next/pages/user/register.js
+++ b/next/pages/user/register.js
@@ -9,7 +9,7 @@ import {
import { useState, useEffect } from "react";
import { useTranslation } from 'next-i18next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
-import { triggerGAEvent } from "../../utils";
+import { triggerGAEvent, cleanString, normalizePhone, isValidE164Phone, getDefaultCallingCode, formatPhoneInput } from "../../utils";
import { useRouter } from 'next/router';
import cookies from 'js-cookie';
@@ -25,12 +25,12 @@ import Link from "../../components/atoms/Link";
import Display from "../../components/atoms/Text/Display";
import BodyText from "../../components/atoms/Text/BodyText";
import { MainPageTemplate } from "../../components/templates/main";
-import { cleanString } from "../../utils";
import { EyeIcon, EyeOffIcon } from "../../public/img/icons/eyeIcon";
import Exclamation from "../../public/img/icons/exclamationIcon";
import GoogleIcon from "../../public/img/icons/googleIcon";
+import PhoneInput from "../../components/molecules/PhoneInput";
import { withPages } from "../../hooks/pages.hook";
export async function getStaticProps({ locale }) {
@@ -44,20 +44,22 @@ export async function getStaticProps({ locale }) {
export default function Register() {
const router = useRouter();
+ const { locale } = router;
const { t } = useTranslation('user');
const [formData, setFormData] = useState({
firstName: "",
lastName: "",
- username: "",
email: "",
+ phone: "",
+ phoneCallingCode: getDefaultCallingCode(locale),
password: "",
confirmPassword: ""
})
const [errors, setErrors] = useState({
firstName: "",
- username: "",
email: "",
+ phone: "",
password: "",
regexPassword: {},
confirmPassword: "",
@@ -67,10 +69,15 @@ export default function Register() {
const [showConfirmPassword, setShowConfirmPassword] = useState(true)
const [isLoading, setIsLoading] = useState(false)
+ useEffect(() => {
+ setFormData((prevState) => ({
+ ...prevState,
+ phoneCallingCode: getDefaultCallingCode(locale),
+ phone: formatPhoneInput(prevState.phone, getDefaultCallingCode(locale)),
+ }))
+ }, [locale])
+
const handleGoogleLogin = () => {
- // Tell the backend which domain to return to after Google OAuth, so a login
- // started on data-basis.org (en) or basedelosdatos.org (es) comes back to
- // the same domain instead of the pt default. The backend allowlists it.
const redirectOrigin =
typeof window !== "undefined" ? window.location.origin : "";
window.location.href = `${process.env.NEXT_PUBLIC_API_URL}/account/google/login/?redirect_origin=${encodeURIComponent(redirectOrigin)}`;
@@ -93,21 +100,18 @@ export default function Register() {
if (!formData.firstName) {
validationErrors.firstName = t('signup.errors.firstName')
}
- if (!formData.username) {
- validationErrors.username = t('signup.errors.username.invalid')
- }
- if(/\s/.test(formData.username)) {
- validationErrors.username = t('signup.errors.username.noSpaces')
- }
- if(/[À-ÿ]/.test(formData.username)) {
- validationErrors.username = t('signup.errors.username.noAccents')
- }
if (!formData.email) {
validationErrors.email = t('signup.errors.email.invalid')
}
if (!/^\S+@\S+$/.test(formData.email)) {
validationErrors.email = t('signup.errors.email.invalid')
}
+ if (formData.phone) {
+ const phone = normalizePhone(formData.phone, formData.phoneCallingCode)
+ if (!isValidE164Phone(phone)) {
+ validationErrors.phone = t('signup.errors.phone.invalid')
+ }
+ }
if(!/^.{8,}$/.test(formData.password)) {
regexPassword = {...regexPassword, amount: true}
}
@@ -142,9 +146,9 @@ export default function Register() {
createRegister({
firstName: cleanString(formData.firstName),
lastName: cleanString(formData?.lastName) || "null",
- username: formData.username,
email: formData.email,
password: formData.password,
+ phone: normalizePhone(formData.phone, formData.phoneCallingCode),
})
} else {
triggerGAEvent("user_register", "register_err")
@@ -159,12 +163,13 @@ export default function Register() {
);
}
- const createRegister = async ({ firstName, lastName, username, email, password }) => {
+ const createRegister = async ({ firstName, lastName, email, password, phone }) => {
try {
const b64FirstName = b64EncodeUnicode(firstName);
const b64LastName = b64EncodeUnicode(lastName);
+ const phoneParam = phone ? `&c=${btoa(phone)}` : ""
- const result = await fetch(`/api/user/registerAccount?f=${b64FirstName}&l=${b64LastName}&u=${btoa(username)}&e=${btoa(email)}&p=${btoa(password)}`, { method: "GET" })
+ const result = await fetch(`/api/user/registerAccount?f=${b64FirstName}&l=${b64LastName}&e=${btoa(email)}&p=${btoa(password)}${phoneParam}`, { method: "GET" })
.then(res => res.json())
let arrayErrors = {}
@@ -174,7 +179,7 @@ export default function Register() {
if(result?.errors?.length > 0) {
result.errors.map((elm) => {
if(elm.field === "email") arrayErrors = ({...arrayErrors, email: t('signup.errors.email.exists'), register: t('signup.errors.registerEmail')})
- if(elm.field === "username") arrayErrors = ({...arrayErrors, username: t('signup.errors.username.exists'), register: t('signup.errors.registerUsername')})
+ if(elm.field === "phone") arrayErrors = ({...arrayErrors, phone: t('signup.errors.phone.exists'), register: t('signup.errors.registerPhone')})
})
}
setErrors(arrayErrors)
@@ -300,19 +305,21 @@ export default function Register() {
-
-
- handleInputChange(e, "username")}
- placeholder={t('signup.placeholders.username')}
+
+
+ setFormData((prevState) => ({
+ ...prevState,
+ phoneCallingCode: callingCode,
+ phone: formatPhoneInput(prevState.phone, callingCode),
+ }))}
+ value={formData.phone}
+ onChange={(phone) => handleInputChange({ target: { value: phone } }, "phone")}
+ optional
/>
- {errors.username}
+ {errors.phone}
diff --git a/next/public/locales/en/user.json b/next/public/locales/en/user.json
index 550e2f00..29fdd9ac 100644
--- a/next/public/locales/en/user.json
+++ b/next/public/locales/en/user.json
@@ -24,7 +24,9 @@
"firstName": "First name",
"lastName": "Last name",
"email": "Email",
- "username": "Username",
+ "phone": "Mobile number",
+ "phoneCallingCode": "Country code",
+ "optionalParenthetical": "(optional)",
"password": "Password",
"confirmPassword": "Confirm password",
"register": "Sign up",
@@ -41,17 +43,14 @@
"firstName": "Enter your first name",
"lastName": "Enter your last name (optional)",
"email": "Enter your email",
- "username": "Enter your username",
"password": "Create a password",
"confirmPassword": "Enter password again"
},
"errors": {
"firstName": "Please enter your first name.",
- "username": {
- "invalid": "Invalid username or an account with this username already exists.",
- "noSpaces": "Username cannot contain spaces.",
- "noAccents": "Username cannot contain accents.",
- "exists": "An account with this username already exists."
+ "phone": {
+ "invalid": "Enter a valid mobile number.",
+ "exists": "An account with this mobile number already exists."
},
"email": {
"invalid": "Invalid email address or an account with this email already exists.",
@@ -72,7 +71,7 @@
},
"register": "Error while trying to register, please try again later!",
"registerEmail": "Error while trying to register: user already exists!",
- "registerUsername": "Error while trying to register: username already exists!"
+ "registerPhone": "Error while trying to register: this mobile number is already in use!"
}
},
"survey": {
@@ -193,7 +192,7 @@
"removeAllMembers": "Remove all members",
"removeAllMembersInfo": "By removing all members, all accesses will be removed and you will need to register again to access the platform.",
"modalRemoveMember": "Are you sure you want to remove the user?",
- "invalidUsername": "Invalid username",
+ "invalidPhone": "Enter a valid mobile number.",
"noSpacesInUsername": "Username cannot contain spaces",
"invalidOrExistingUsername": "Invalid or already existing username",
"deleteAccount": "Delete account",
@@ -202,8 +201,11 @@
"confirmEmail": "Confirm your email",
"emailSentTo": "We've sent an email to:",
"checkInbox": "Check your inbox and follow the instructions sent in the email to complete the change.",
- "username": "Username",
- "enterUsername": "Enter your username",
+ "phone": "Mobile number",
+ "enterPhone": "(11) 9 9999-9999",
+ "phoneHint": "International format, for example +5511999999999. Optional field.",
+ "phoneNotSet": "No mobile number registered",
+ "addPhone": "Add mobile number",
"currentPassword": "Current password",
"enterCurrentPassword": "Enter your current password",
"newPassword": "New password",
@@ -228,11 +230,10 @@
"planChange": "Plan change",
"planChangeInstructions": "To change your plan, contact us:",
"contactUs": "Contact us",
- "changeUsername": "Change username",
- "newUsername": "New username",
- "changeUsernameInput": "Enter your username",
- "updateUsername": "Update username",
- "usernameAlreadyExists": "Username already exists",
+ "changePhone": "Change mobile number",
+ "newPhone": "New mobile number",
+ "updatePhone": "Update mobile number",
+ "phoneAlreadyExists": "This mobile number is already in use",
"confirmDeleteAccount": "Are you sure you want to delete your account?",
"deleteAccountWarning": "This action cannot be undone. This will permanently delete your account and remove all your data from our servers.",
"confirmDeleteInstructions": "Type 'delete account' to confirm",
diff --git a/next/public/locales/es/user.json b/next/public/locales/es/user.json
index bece0220..32e70461 100644
--- a/next/public/locales/es/user.json
+++ b/next/public/locales/es/user.json
@@ -24,7 +24,9 @@
"firstName": "Nombre",
"lastName": "Apellido",
"email": "Correo electrónico",
- "username": "Nombre de usuario",
+ "phone": "Celular",
+ "phoneCallingCode": "Código de país",
+ "optionalParenthetical": "(opcional)",
"password": "Contraseña",
"confirmPassword": "Confirme la contraseña",
"register": "Registrarse",
@@ -41,17 +43,14 @@
"firstName": "Ingrese su nombre",
"lastName": "Ingrese su apellido (opcional)",
"email": "Ingrese su correo electrónico",
- "username": "Ingrese su nombre de usuario",
"password": "Cree una contraseña",
"confirmPassword": "Ingrese la contraseña nuevamente"
},
"errors": {
"firstName": "Por favor, ingrese su nombre.",
- "username": {
- "invalid": "Nombre de usuario inválido o ya existe una cuenta con este nombre de usuario.",
- "noSpaces": "El nombre de usuario no puede tener espacios.",
- "noAccents": "El nombre de usuario no puede contener acentos.",
- "exists": "Ya existe una cuenta con este nombre de usuario."
+ "phone": {
+ "invalid": "Ingrese un número de celular válido.",
+ "exists": "Ya existe una cuenta con este celular."
},
"email": {
"invalid": "Dirección de correo electrónico inválida o ya existe una cuenta con este correo electrónico.",
@@ -72,7 +71,7 @@
},
"register": "Error al intentar registrarse, ¡intente nuevamente más tarde!",
"registerEmail": "Error al intentar registrarse: ¡el usuario ya existe!",
- "registerUsername": "Error al intentar registrarse: ¡el nombre de usuario ya existe!"
+ "registerPhone": "Error al intentar registrarse: ¡el celular ya está en uso!"
}
},
"username": {
@@ -105,7 +104,7 @@
"removeAllMembers": "Eliminar todos los miembros",
"removeAllMembersInfo": "Al eliminar todos los miembros, todos los accesos serán eliminados.",
"modalRemoveMember": "¿Está seguro de que desea eliminar el usuario?",
- "invalidUsername": "Nombre de usuario inválido",
+ "invalidPhone": "Ingrese un número de celular válido.",
"noSpacesInUsername": "El nombre de usuario no puede contener espacios",
"invalidOrExistingUsername": "Nombre de usuario inválido o ya existente",
"deleteAccount": "Eliminar cuenta",
@@ -114,8 +113,11 @@
"confirmEmail": "Confirme su correo electrónico",
"emailSentTo": "Hemos enviado un correo electrónico a:",
"checkInbox": "Revise su bandeja de entrada y siga las instrucciones enviadas en el correo electrónico para completar el cambio.",
- "username": "Nombre de usuario",
- "enterUsername": "Ingrese su nombre de usuario",
+ "phone": "Celular",
+ "enterPhone": "(11) 9 9999-9999",
+ "phoneHint": "Formato internacional, por ejemplo +5511999999999. Campo opcional.",
+ "phoneNotSet": "Ningún celular registrado",
+ "addPhone": "Agregar celular",
"currentPassword": "Contraseña actual",
"enterCurrentPassword": "Ingrese su contraseña actual",
"newPassword": "Nueva contraseña",
@@ -141,13 +143,12 @@
"planChangeInstructions": "Para cambiar su plan, contáctenos:",
"contactUs": "Contáctenos",
"errorEraseAccountTitle": "Error al eliminar la cuenta",
- "changeUsername": "Cambiar nombre de usuario",
+ "changePhone": "Cambiar celular",
"errorEraseAccountText": "No se pudo eliminar su cuenta, ya que tiene un plan activo con suscripción recurrente.",
"errorEraseAccountMembers": "No se puede eliminar la cuenta mientras haya miembros vinculados a su suscripción.",
- "newUsername": "Nuevo nombre de usuario",
- "changeUsernameInput": "Ingrese su nombre de usuario",
- "updateUsername": "Actualizar nombre de usuario",
- "usernameAlreadyExists": "El nombre de usuario ya existe",
+ "newPhone": "Nuevo celular",
+ "updatePhone": "Actualizar celular",
+ "phoneAlreadyExists": "Este celular ya está en uso",
"confirmDeleteAccount": "¿Está seguro de que desea eliminar su cuenta?",
"deleteAccountWarning": "Esta acción no se puede deshacer. Esto eliminará permanentemente su cuenta y eliminará todos sus datos de nuestros servidores.",
"confirmDeleteInstructions": "Escriba 'eliminar cuenta' para confirmar",
diff --git a/next/public/locales/pt/user.json b/next/public/locales/pt/user.json
index 519d884a..5300b70e 100644
--- a/next/public/locales/pt/user.json
+++ b/next/public/locales/pt/user.json
@@ -24,7 +24,9 @@
"firstName": "Nome",
"lastName": "Sobrenome",
"email": "E-mail",
- "username": "Nome de usuário",
+ "phone": "Celular",
+ "phoneCallingCode": "Código do país",
+ "optionalParenthetical": "(opcional)",
"password": "Senha",
"confirmPassword": "Confirme a senha",
"register": "Cadastrar",
@@ -41,17 +43,14 @@
"firstName": "Insira seu nome",
"lastName": "Insira seu sobrenome (opcional)",
"email": "Insira seu e-mail",
- "username": "Insira seu nome de usuário",
"password": "Crie uma senha",
"confirmPassword": "Insira a senha novamente"
},
"errors": {
"firstName": "Por favor, insira seu nome.",
- "username": {
- "invalid": "Nome de usuário inválido ou já existe uma conta com este nome de usuário.",
- "noSpaces": "Nome de usuário não pode haver espaçamento.",
- "noAccents": "O nome de usuário não pode conter acentos.",
- "exists": "Conta com este nome de usuário já existe."
+ "phone": {
+ "invalid": "Informe um número de celular válido.",
+ "exists": "Já existe uma conta com este celular."
},
"email": {
"invalid": "Endereço de e-mail inválido ou já existe uma conta com este e-mail.",
@@ -72,7 +71,7 @@
},
"register": "Erro ao tentar se cadastrar, tente novamente mais tarde!",
"registerEmail": "Erro ao tentar se cadastrar: o usuário já existe!",
- "registerUsername": "Erro ao tentar se cadastrar: o nome de usuário já existe!"
+ "registerPhone": "Erro ao tentar se cadastrar: o celular já está em uso!"
}
},
"survey": {
@@ -200,7 +199,7 @@
"removeAllMembers": "Remover todos os membros",
"removeAllMembersInfo": "Ao remover todos os membros, todos os acessos serão removidos.",
"modalRemoveMember": "Tem certeza que deseja remover o usuário?",
- "invalidUsername": "Nome de usuário inválido",
+ "invalidPhone": "Informe um número de celular válido.",
"noSpacesInUsername": "O nome de usuário não pode conter espaços",
"invalidOrExistingUsername": "Nome de usuário inválido ou já existente",
"deleteAccount": "Deletar conta",
@@ -209,8 +208,11 @@
"confirmEmail": "Confirme seu e-mail",
"emailSentTo": "Enviamos um e-mail para:",
"checkInbox": "Verifique sua caixa de entrada e siga as instruções enviadas no e-mail para completar a alteração.",
- "username": "Nome de usuário",
- "enterUsername": "Digite seu nome de usuário",
+ "phone": "Celular",
+ "enterPhone": "(11) 9 9999-9999",
+ "phoneHint": "Formato internacional, por exemplo +5511999999999. Campo opcional.",
+ "phoneNotSet": "Nenhum celular cadastrado",
+ "addPhone": "Adicionar celular",
"currentPassword": "Senha atual",
"enterCurrentPassword": "Insira a senha atual",
"newPassword": "Nova senha",
@@ -236,13 +238,12 @@
"planChangeInstructions": "Para alterar seu plano, entre em contato conosco:",
"contactUs": "Entrar em contato",
"errorEraseAccountTitle": "Erro ao excluir conta",
- "changeUsername": "Alterar nome de usuário",
+ "changePhone": "Alterar celular",
"errorEraseAccountText": "Não foi possível excluir sua conta, pois há um plano ativo com assinatura recorrente.",
"errorEraseAccountMembers": "Não é possível excluir a conta enquanto houver membros vinculados à sua assinatura. Remova todos os membros antes de continuar.",
- "newUsername": "Novo nome de usuário",
- "changeUsernameInput": "Insira o nome de usuário",
- "updateUsername": "Atualizar nome de usuário",
- "usernameAlreadyExists": "Nome de usuário já existe",
+ "newPhone": "Novo celular",
+ "updatePhone": "Atualizar celular",
+ "phoneAlreadyExists": "Este celular já está em uso",
"confirmDeleteAccount": "Tem certeza que deseja excluir sua conta?",
"deleteAccountWarning": "Essa ação não pode ser desfeita. Isso excluirá permanentemente sua conta e removerá todos os seus dados de nossos servidores.",
"confirmDeleteInstructions": "Digite 'excluir conta' para confirmar",
diff --git a/next/utils.js b/next/utils.js
index d03faefb..9c23ca6f 100644
--- a/next/utils.js
+++ b/next/utils.js
@@ -289,7 +289,7 @@ export function trackNavigateToChatbotLp({
value,
menu_placement: placement,
is_mobile: isMobile ?? !CHATBOT_LP_DESKTOP_PLACEMENTS.has(placement),
- is_logged_in: Boolean(user?.username),
+ is_logged_in: isUserLoggedIn(user),
is_bd_pro: hasBDProSubscription(user),
page_path: pagePath || window.location.pathname,
});
@@ -302,6 +302,188 @@ export function cleanString(string) {
return returnString
}
+export const UserPagePath = "/user"
+
+export function isUserLoggedIn(user) {
+ return Boolean(user?.id || user?.email)
+}
+
+export function getUserDisplayName(user) {
+ return [user?.firstName, user?.lastName].filter(Boolean).join(" ").trim()
+}
+
+export function getUserPageHref(section) {
+ if (!section) return UserPagePath
+ return `${UserPagePath}?${section}`
+}
+
+const PhoneE164Pattern = /^\+[1-9]\d{7,14}$/
+
+export const PhoneCountries = [
+ { callingCode: "55", maxDigits: 11, placeholder: "(11) 9 9999-9999", iso: "BR" },
+ { callingCode: "1", maxDigits: 10, placeholder: "(555) 123-4567", iso: "US" },
+ { callingCode: "52", maxDigits: 10, placeholder: "55 1234 5678", iso: "MX" },
+ { callingCode: "54", maxDigits: 10, placeholder: "11 1234-5678", iso: "AR" },
+ { callingCode: "57", maxDigits: 10, placeholder: "300 123 4567", iso: "CO" },
+ { callingCode: "56", maxDigits: 9, placeholder: "9 1234 5678", iso: "CL" },
+ { callingCode: "51", maxDigits: 9, placeholder: "912 345 678", iso: "PE" },
+ { callingCode: "34", maxDigits: 9, placeholder: "612 34 56 78", iso: "ES" },
+ { callingCode: "351", maxDigits: 9, placeholder: "912 345 678", iso: "PT" },
+ { callingCode: "44", maxDigits: 10, placeholder: "7400 123456", iso: "GB" },
+]
+
+const PhoneCountryByCode = Object.fromEntries(
+ PhoneCountries.map((country) => [country.callingCode, country])
+)
+
+const DefaultCallingCodeByLocale = {
+ pt: "55",
+ en: "1",
+ es: "52",
+}
+
+export function getDefaultCallingCode(locale) {
+ return DefaultCallingCodeByLocale[locale] || DefaultCallingCodeByLocale.pt
+}
+
+export function getPhoneCountry(callingCode) {
+ return PhoneCountryByCode[callingCode] || PhoneCountryByCode[getDefaultCallingCode("pt")]
+}
+
+export function sanitizePhoneInput(value) {
+ return String(value || "").replace(/\D/g, "")
+}
+
+function localPhoneDigits(value, callingCode) {
+ const country = getPhoneCountry(callingCode)
+ let digits = sanitizePhoneInput(value)
+
+ if (digits.startsWith(callingCode) && digits.length > country.maxDigits) {
+ digits = digits.slice(callingCode.length)
+ }
+
+ return digits.slice(0, country.maxDigits)
+}
+
+function formatGroupedPhone(digits, groups, joiner = " ") {
+ if (!digits) return ""
+
+ const parts = []
+ let index = 0
+
+ for (const size of groups) {
+ if (index >= digits.length) break
+ parts.push(digits.slice(index, index + size))
+ index += size
+ }
+
+ if (index < digits.length) parts.push(digits.slice(index))
+ return parts.join(joiner)
+}
+
+function formatBrazilLocal(digits) {
+ if (digits.length === 0) return ""
+ if (digits.length < 2) return `(${digits}`
+ if (digits.length === 2) return `(${digits})`
+
+ const ddd = digits.slice(0, 2)
+ const subscriber = digits.slice(2)
+
+ if (subscriber.length === 1) return `(${ddd}) ${subscriber}`
+ if (subscriber.length <= 5) {
+ return `(${ddd}) ${subscriber.slice(0, 1)} ${subscriber.slice(1)}`
+ }
+
+ return `(${ddd}) ${subscriber.slice(0, 1)} ${subscriber.slice(1, 5)}-${subscriber.slice(5, 9)}`
+}
+
+function formatUsLocal(digits) {
+ if (digits.length === 0) return ""
+ if (digits.length < 3) return `(${digits}`
+ if (digits.length === 3) return `(${digits})`
+ if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`
+ return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6, 10)}`
+}
+
+function formatArLocal(digits) {
+ if (digits.length <= 2) return digits
+ if (digits.length <= 6) return `${digits.slice(0, 2)} ${digits.slice(2)}`
+ return `${digits.slice(0, 2)} ${digits.slice(2, 6)}-${digits.slice(6, 10)}`
+}
+
+export function formatPhoneInput(value, callingCode) {
+ const digits = localPhoneDigits(value, callingCode)
+
+ if (callingCode === "55") return formatBrazilLocal(digits)
+ if (callingCode === "1") return formatUsLocal(digits)
+ if (callingCode === "52") return formatGroupedPhone(digits, [2, 4, 4])
+ if (callingCode === "54") return formatArLocal(digits)
+ if (callingCode === "57") return formatGroupedPhone(digits, [3, 3, 4])
+ if (callingCode === "56") return formatGroupedPhone(digits, [1, 4, 4])
+ if (callingCode === "51" || callingCode === "351") return formatGroupedPhone(digits, [3, 3, 3])
+ if (callingCode === "34") return formatGroupedPhone(digits, [3, 2, 2, 2])
+ if (callingCode === "44") return formatGroupedPhone(digits, [4, 6])
+
+ return digits
+}
+
+export function handlePhoneInputChange(previousValue, nextValue, callingCode) {
+ const nextDigits = sanitizePhoneInput(nextValue)
+ const prevDigits = sanitizePhoneInput(previousValue)
+
+ if (
+ String(nextValue).length < String(previousValue || "").length &&
+ nextDigits === prevDigits
+ ) {
+ return formatPhoneInput(prevDigits.slice(0, -1), callingCode)
+ }
+
+ return formatPhoneInput(nextValue, callingCode)
+}
+
+export function splitStoredPhone(value, locale) {
+ const defaultCallingCode = getDefaultCallingCode(locale)
+ const digits = sanitizePhoneInput(value)
+
+ if (!digits) {
+ return { callingCode: defaultCallingCode, localNumber: "" }
+ }
+
+ const sortedCodes = PhoneCountries
+ .map((country) => country.callingCode)
+ .sort((a, b) => b.length - a.length)
+
+ for (const callingCode of sortedCodes) {
+ const country = getPhoneCountry(callingCode)
+ if (digits.startsWith(callingCode) && digits.length > callingCode.length) {
+ const localNumber = digits.slice(callingCode.length)
+ if (localNumber.length <= country.maxDigits) {
+ return { callingCode, localNumber }
+ }
+ }
+ }
+
+ return { callingCode: defaultCallingCode, localNumber: digits }
+}
+
+export function formatPhoneDisplay(value, locale) {
+ if (!value) return ""
+ const { callingCode, localNumber } = splitStoredPhone(value, locale)
+ const formattedLocal = formatPhoneInput(localNumber, callingCode)
+ return formattedLocal ? `+${callingCode} ${formattedLocal}` : `+${callingCode}`
+}
+
+export function normalizePhone(value, callingCode = getDefaultCallingCode("pt")) {
+ const country = getPhoneCountry(callingCode)
+ const digits = localPhoneDigits(value, callingCode)
+ if (!digits) return ""
+ return `+${country.callingCode}${digits}`
+}
+
+export function isValidE164Phone(value) {
+ return PhoneE164Pattern.test(value)
+}
+
export function formatBytes(bytes) {
if (bytes < 1024) {
return `${bytes} B`
@@ -410,7 +592,7 @@ export async function redirectToChatbotCheckout(router, { interval = "year" } =
const user = getUserFromCookie()
- if (!user?.username) {
+ if (!isUserLoggedIn(user)) {
if (typeof window !== "undefined") {
localStorage.setItem("previousPath", window.location.href)
}
@@ -423,7 +605,7 @@ export async function redirectToChatbotCheckout(router, { interval = "year" } =
}
return router.push({
- pathname: `/user/${user.username}`,
+ pathname: UserPagePath,
query,
})
}