From a01f762e0da20871b2e7ac2a6819b19d9054396b Mon Sep 17 00:00:00 2001 From: Ricardo Dahis Date: Thu, 13 Aug 2026 09:05:15 +1000 Subject: [PATCH 1/4] docs: correct CLAUDE.md Git Flow model (branch from main, same commits) --- CLAUDE.md | 45 +++++++++++++++++++++++---------------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1347af30..08fd9b1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,33 +6,34 @@ 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. - -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. +- `development` — integration / testing. Note: the integration branch here is named `development` (full word), not `dev`. -### Feature workflow — promote the feature, not the environment -1. Cut your feature branch off `development` (the branch you integrate and test in first). - 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. +### How work flows +Cut every feature off `main`, then merge the **same commits** into `development`, `staging`, +and `main` — one PR per target. The three branches are meant to hold identical history for +each feature. They drift apart as changes land at different times, so the team periodically +**resets `staging` and `development` back to `main`** to re-align them. 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 — same commits into every branch +1. Cut your feature branch off `main` (ideally just after a reset, when the branches are aligned). + Name it by intent: `feat/…`, `fix/…`, `chore/…`, `docs/…`, `refactor/…`. One logical change per branch. +2. Open a PR from that branch into each target branch: `development`, `staging`, and `main`. +3. Merge with a **merge commit or fast-forward — never squash, never cherry-pick** — so the + identical commit SHA lands on all three branches. +4. If a target has drifted far from `main`, realign it via the periodic reset rather than + forcing a noisy cross-branch 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. +- Cut features off `main` so the same commit can enter every environment. +- Merge as merge-commit/FF, **never squash** (squash mints a new SHA per branch) and + **never cherry-pick** (also a new SHA) — both break the "same commits everywhere" rule. +- 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. From 4765a2474346a7f801258cd61fbaceb37187c9d1 Mon Sep 17 00:00:00 2001 From: AldemirLucas Date: Mon, 17 Aug 2026 14:39:48 -0300 Subject: [PATCH 2/4] feat: add Meta Pixel tracking code for analytics integration - Implemented Meta Pixel code for tracking page views on the production environment. - Added noscript fallback for Meta Pixel to ensure tracking in cases where JavaScript is disabled. --- next/pages/_app.js | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) 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) */} + ); } From 1cd5fe771194e8621a4ff662e7502b8e3f0812d8 Mon Sep 17 00:00:00 2001 From: Ricardo Dahis Date: Wed, 19 Aug 2026 13:27:23 +1000 Subject: [PATCH 3/4] docs: clarify one-branch/multi-PR flow and reset cadence --- CLAUDE.md | 44 +++++++++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 08fd9b1e..762876aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,30 +10,36 @@ Canonical reference: https://github.com/basedosdados/backend/wiki/Boas-Pr%C3%A1t - `staging` — pre-production / QA. - `development` — integration / testing. -Note: the integration branch here is named `development` (full word), not `dev`. - ### How work flows -Cut every feature off `main`, then merge the **same commits** into `development`, `staging`, -and `main` — one PR per target. The three branches are meant to hold identical history for -each feature. They drift apart as changes land at different times, so the team periodically -**resets `staging` and `development` back to `main`** to re-align them. 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. +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. + +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 — same commits into every branch -1. Cut your feature branch off `main` (ideally just after a reset, when the branches are aligned). - Name it by intent: `feat/…`, `fix/…`, `chore/…`, `docs/…`, `refactor/…`. One logical change per branch. -2. Open a PR from that branch into each target branch: `development`, `staging`, and `main`. -3. Merge with a **merge commit or fast-forward — never squash, never cherry-pick** — so the - identical commit SHA lands on all three branches. -4. If a target has drifted far from `main`, realign it via the periodic reset rather than - forcing a noisy cross-branch merge. +### 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/…`. + 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 — always a feature branch + PR. -- Cut features off `main` so the same commit can enter every environment. -- Merge as merge-commit/FF, **never squash** (squash mints a new SHA per branch) and - **never cherry-pick** (also a new SHA) — both break the "same commits everywhere" rule. +- 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. From 91d64c89437594a88bf4dade47ace12e49097adf Mon Sep 17 00:00:00 2001 From: AldemirLucas Date: Thu, 27 Aug 2026 10:06:51 -0300 Subject: [PATCH 4/4] feat(user): enhance user management with phone input and display features - Introduced a new PhoneInput component for handling phone number input with country code selection. - Updated user-related components to utilize phone numbers instead of usernames for identification. - Refactored user page routing to use a unified UserPagePath for consistency. - Implemented phone number validation and formatting functions to ensure proper input handling. - Adjusted API endpoints to accommodate phone number storage and retrieval. - Enhanced user experience by displaying user names based on first and last names instead of usernames. --- next/components/molecules/ImgCrop.js | 4 +- next/components/molecules/Menu.js | 16 +- next/components/molecules/PhoneInput.js | 89 ++++++ .../organisms/componentsUserPage/Account.js | 80 +++--- .../componentsUserPage/PlansAndPayment.js | 10 +- .../ProfileConfiguration.js | 1 - next/cypress/e2e/payment.cy.js | 12 +- next/cypress/e2e/register.cy.js | 25 +- next/cypress/fixtures/registerUsers.json | 13 +- next/cypress/support/commands.js | 4 +- next/pages/api/user/getUser.js | 2 +- next/pages/api/user/getUserTestCypress.js | 2 +- next/pages/api/user/registerAccount.js | 17 +- next/pages/api/user/updateUser.js | 24 +- next/pages/chatbot-lp.js | 10 +- next/pages/prices.js | 18 +- next/pages/user/[username].js | 248 ++-------------- next/pages/user/index.js | 264 ++++++++++++++++++ next/pages/user/login.js | 9 +- next/pages/user/register.js | 69 +++-- next/public/locales/en/user.json | 33 +-- next/public/locales/es/user.json | 33 +-- next/public/locales/pt/user.json | 33 +-- next/utils.js | 188 ++++++++++++- 24 files changed, 760 insertions(+), 444 deletions(-) create mode 100644 next/components/molecules/PhoneInput.js create mode 100644 next/pages/user/index.js 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/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, }) }