From eb2ca6c950cacbd568d97f3b473988fa5117ce62 Mon Sep 17 00:00:00 2001 From: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Date: Mon, 22 Jan 2024 12:02:19 -0500 Subject: [PATCH] feat: edit org page (part 1) (#733) * chore: lint & format Signed-off-by: InReach [bot] <108850934+InReach-svc@users.noreply.github.com> * update crowdin client * fix lockfile * chore: lint & format Signed-off-by: InReach [bot] <108850934+InReach-svc@users.noreply.github.com> * full width for form * clean up comments/dead code * move in to folder * dep for json -> csv conversion * api routes/mock data * move to dir, rework hook form * fix lockfile * add font fallbacks * ignore _queries dir * add serviceAreas, isNational * update data * custom superjson * clean up import * add countryCode column * start data migration prep * alter api route * chore: lint & format Signed-off-by: InReach [bot] <108850934+InReach-svc@users.noreply.github.com> * vercel geo * fix lockfile * chore: lint & format Signed-off-by: InReach [bot] <108850934+InReach-svc@users.noreply.github.com> * remove umami * fix provider error * mobile app stuff * chore: lint & format Signed-off-by: InReach [bot] <108850934+InReach-svc@users.noreply.github.com> * chore: lint & format Signed-off-by: InReach [bot] <108850934+InReach-svc@users.noreply.github.com> * fix types/add EditModeProvider * generated icons * fix import * downgrade @crowdin/crowdin-api-client per crowdin/crowdin-api-client-js#355 * use array in prefixedId * update prefetch * fix lint error * convert dayIndex to luxon format * remove dupe * fix data schema * update mock data * rename ContactSection file to match component name * separate out ContactInfo in to parts * clear errors * add API handler for phone numbers * chore: lint & format Signed-off-by: InReach [bot] <108850934+InReach-svc@users.noreply.github.com> * fix barrel file * update api route/mock data * create edit mode ContactSection * alter drawer/phone # entry form * update api route & mock data * add theme variants * change sort order * add api route & mock data * contact section: phone & website * create job summary * api routes/mock data * basic edit drawer * Email drawer links * fix conditional * fix storybook conf for msw addon * storybook conf tweak * add `tiny-invariant` * generate nested freetext upsert * update api routes/mocks * switch hook, fix data submission * move in to folder * api routes / mock data * stop unminifying json mockdata * api routes / mock data * api routes / mock data * update msw public script * social media update drawer * fix lockfile * chore: lint & format Signed-off-by: InReach [bot] <108850934+InReach-svc@users.noreply.github.com> * enable wdyr on app, refactor icon * remove ModalProvider & small cleanups * fix wdyr & add env trigger * i18n HMR & storybook swc * page title * chore: lint & format Signed-off-by: InReach [bot] <108850934+InReach-svc@users.noreply.github.com> * fix i18n hmr plugin loading * fix plugin loading during build * remove file --------- Signed-off-by: InReach [bot] <108850934+InReach-svc@users.noreply.github.com> Signed-off-by: Joe Karow <58997957+JoeKarow@users.noreply.github.com> Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> Co-authored-by: InReach [bot] <108850934+InReach-svc@users.noreply.github.com> --- .prettierignore | 1 + apps/app/lib/wdyr.ts | 20 ++ apps/app/next-i18next.config.mjs | 28 +- apps/app/next.config.mjs | 22 +- apps/app/package.json | 3 + apps/app/public/locales/en/common.json | 9 + apps/app/src/middleware.ts | 2 + apps/app/src/pages/_app.tsx | 36 +- apps/app/src/pages/_document.tsx | 1 - apps/app/src/pages/index.tsx | 4 +- .../pages/org/[slug]/[orgLocationId]/edit.tsx | 6 +- .../org/[slug]/[orgLocationId]/index.tsx | 34 +- apps/app/src/pages/org/[slug]/edit.tsx | 212 ++++++------ apps/app/src/pages/org/[slug]/index.tsx | 21 +- apps/app/src/pages/org/[slug]/remote.tsx | 2 +- apps/app/src/providers/index.tsx | 36 +- packages/api/lib/prismaRaw/coveredAreas.ts | 45 --- packages/api/lib/prismaRaw/index.ts | 1 - packages/api/prisma/org.ts | 134 -------- packages/api/router/misc/index.ts | 35 +- .../misc/query.forEditNavbar.handler.ts | 24 ++ .../router/misc/query.forEditNavbar.schema.ts | 9 + .../query.getCountryTranslation.handler.ts | 1 - .../query.getCountryTranslation.schema.ts | 2 +- packages/api/router/misc/schemas.ts | 1 + packages/api/router/orgEmail/index.ts | 22 ++ .../orgEmail/mutation.update.handler.ts | 55 +++- .../router/orgEmail/mutation.update.schema.ts | 34 +- .../orgEmail/query.forContactInfo.schema.ts | 2 +- .../query.forContactInfoEdit.handler.ts | 52 +++ .../query.forContactInfoEdit.schema.ts | 8 + .../orgEmail/query.forEditDrawer.handler.ts | 37 +++ .../orgEmail/query.forEditDrawer.schema.ts | 6 + packages/api/router/orgEmail/schemas.ts | 2 + .../orgHours/query.forHoursDisplay.handler.ts | 5 +- .../orgHours/query.forHoursDrawer.handler.ts | 5 +- packages/api/router/orgPhone/index.ts | 22 ++ .../orgPhone/mutation.update.handler.ts | 54 +-- .../router/orgPhone/mutation.update.schema.ts | 32 +- .../orgPhone/query.forContactInfo.schema.ts | 2 +- .../query.forContactInfoEdit.handler.ts | 66 ++++ .../query.forContactInfoEdit.schema.ts | 8 + .../orgPhone/query.forEditDrawer.handler.ts | 69 ++++ .../orgPhone/query.forEditDrawer.schema.ts | 6 + packages/api/router/orgPhone/schemas.ts | 2 + packages/api/router/orgSocialMedia/index.ts | 33 ++ .../orgSocialMedia/mutation.update.handler.ts | 38 ++- .../orgSocialMedia/mutation.update.schema.ts | 4 +- .../query.forContactInfo.handler.ts | 3 +- .../query.forContactInfo.schema.ts | 2 +- .../query.forContactInfoEdits.handler.ts | 43 +++ .../query.forContactInfoEdits.schema.ts | 8 + .../query.forEditDrawer.handler.ts | 30 ++ .../query.forEditDrawer.schema.ts | 6 + .../query.getServiceTypes.handler.ts | 23 ++ .../query.getServiceTypes.schema.ts | 7 + packages/api/router/orgSocialMedia/schemas.ts | 3 + packages/api/router/orgWebsite/index.ts | 22 ++ .../orgWebsite/mutation.update.handler.ts | 25 +- .../orgWebsite/query.forContactInfo.schema.ts | 2 +- .../query.forContactInfoEdit.handler.ts | 44 +++ .../query.forContactInfoEdit.schema.ts | 8 + .../orgWebsite/query.forEditDrawer.handler.ts | 23 ++ .../orgWebsite/query.forEditDrawer.schema.ts | 6 + packages/api/router/orgWebsite/schemas.ts | 2 + packages/api/router/organization/index.ts | 22 +- packages/api/router/organization/lib.ts | 26 -- .../mutation.updateBasic.handler.ts | 66 ++++ .../mutation.updateBasic.schema.ts | 11 + .../query.forOrgPageEdits.handler.ts | 46 +++ .../query.forOrgPageEdits.schema.ts | 4 + .../query.searchDistance.handler.ts | 8 +- packages/api/router/organization/schemas.ts | 2 + .../query.forServiceInfoCard.schema.ts | 2 +- packages/api/schemas/idPrefix.ts | 7 +- packages/db/index.ts | 6 +- packages/db/lib/generateFreeText.ts | 59 +++- .../!prep.ts | 168 ++++++++++ .../index.ts} | 18 +- packages/db/prisma/data-migrations/index.ts | 1 + .../prisma/views/public/pg_cache_hit_rate.sql | 10 + .../db/prisma/views/public/pg_index_usage.sql | 49 +++ .../views/public/pg_index_usage_rate.sql | 12 + packages/env/index.ts | 3 + packages/ui/.storybook/i18next.ts | 3 + packages/ui/.storybook/main.ts | 23 +- packages/ui/.swcrc | 6 +- packages/ui/.vscode/settings.json | 5 +- packages/ui/babel.config.json | 19 -- packages/ui/components/core/Badge.tsx | 3 +- packages/ui/components/core/Breadcrumb.tsx | 6 +- .../components/data-display/ContactInfo.tsx | 310 ------------------ .../data-display/ContactInfo/Emails.tsx | 153 +++++++++ .../data-display/ContactInfo/PhoneNumbers.tsx | 159 +++++++++ .../data-display/ContactInfo/SocialMedia.tsx | 108 ++++++ .../data-display/ContactInfo/Websites.tsx | 138 ++++++++ .../data-display/ContactInfo/common.styles.ts | 10 + .../index.stories.tsx} | 2 +- .../data-display/ContactInfo/index.tsx | 53 +++ .../data-display/ContactInfo/types.ts | 48 +++ packages/ui/components/data-display/index.ts | 4 +- .../data-portal/EmailDrawer/index.stories.tsx | 42 +++ .../data-portal/EmailDrawer/index.tsx | 191 +++++++++++ .../data-portal/InlineTextInput.stories.tsx | 29 +- .../data-portal/InlineTextInput.tsx | 55 +--- .../data-portal/PhoneDrawer/index.stories.tsx | 38 +++ .../data-portal/PhoneDrawer/index.tsx | 224 +++++++++++++ .../data-portal/PhoneNumberEntry.stories.tsx | 45 --- .../PhoneNumberEntry/CountrySelectItem.tsx | 24 ++ .../PhoneNumberEntry/index.stories.tsx | 81 +++++ .../index.tsx} | 28 +- .../data-portal/PhoneNumberEntry/lib.ts | 19 ++ .../data-portal/PhoneNumberEntry/styles.ts | 27 ++ .../PhoneNumberEntry/withHookForm.tsx | 169 ++++++++++ .../data-portal/ServiceEditDrawer.tsx | 6 +- .../SocialMediaDrawer/index.stories.tsx | 42 +++ .../data-portal/SocialMediaDrawer/index.tsx | 215 ++++++++++++ .../data-portal/TimeRange/index.tsx | 13 +- .../WebsiteDrawer/index.stories.tsx | 37 +++ .../data-portal/WebsiteDrawer/index.tsx | 174 ++++++++++ .../index.stories.tsx} | 3 +- .../{Contact.tsx => ContactSection/index.tsx} | 5 +- .../components/sections/ListingBasicInfo.tsx | 110 +++++-- packages/ui/components/sections/Navbar.tsx | 105 +++++- packages/ui/components/sections/index.ts | 6 +- packages/ui/hooks/index.ts | 1 + packages/ui/hooks/useEditMode.ts | 18 + packages/ui/icon/buildIcons.ts | 9 +- packages/ui/icon/iconCollection.ts | 5 +- packages/ui/icon/iconList.ts | 2 +- .../{Icon.stories.tsx => index.stories.tsx} | 0 packages/ui/icon/index.tsx | 28 +- .../ui/loading-states/OrgLocationPage.tsx | 25 ++ packages/ui/loading-states/OrgPage.tsx | 25 ++ .../json/orgEmail.forContactInfoEdit.json | 1 + .../mockData/json/orgEmail.forEditDrawer.json | 1 + .../json/orgPhone.forContactInfoEdit.json | 1 + .../mockData/json/orgPhone.forEditDrawer.json | 1 + .../ui/mockData/json/orgPhone.update.json | 1 + .../json/orgSocialMedia.forContactInfo.json | 2 +- .../orgSocialMedia.forContactInfoEdits.json | 1 + .../json/orgSocialMedia.forEditDrawer.json | 1 + .../json/orgSocialMedia.getServiceTypes.json | 1 + .../json/orgWebsite.forContactInfoEdit.json | 1 + .../json/orgWebsite.forEditDrawer.json | 1 + packages/ui/mockData/orgEmail.ts | 24 ++ packages/ui/mockData/orgHours.ts | 13 +- packages/ui/mockData/orgPhone.ts | 26 +- packages/ui/mockData/orgSocialMedia.ts | 32 ++ packages/ui/mockData/orgWebsite.ts | 29 ++ packages/ui/modals/Service.tsx | 3 +- packages/ui/package.json | 8 + packages/ui/providers/EditMode.tsx | 32 ++ packages/ui/public/mockServiceWorker.js | 4 +- packages/ui/store/index.ts | 19 ++ packages/ui/theme/variants/Text.ts | 37 +++ packages/ui/theme/variants/index.ts | 6 + packages/util/luxon/weekday.ts | 6 + pnpm-lock.yaml | 149 +++++++++ 159 files changed, 4189 insertions(+), 1092 deletions(-) create mode 100644 apps/app/lib/wdyr.ts delete mode 100644 packages/api/lib/prismaRaw/coveredAreas.ts delete mode 100644 packages/api/prisma/org.ts create mode 100644 packages/api/router/misc/query.forEditNavbar.handler.ts create mode 100644 packages/api/router/misc/query.forEditNavbar.schema.ts create mode 100644 packages/api/router/orgEmail/query.forContactInfoEdit.handler.ts create mode 100644 packages/api/router/orgEmail/query.forContactInfoEdit.schema.ts create mode 100644 packages/api/router/orgEmail/query.forEditDrawer.handler.ts create mode 100644 packages/api/router/orgEmail/query.forEditDrawer.schema.ts create mode 100644 packages/api/router/orgPhone/query.forContactInfoEdit.handler.ts create mode 100644 packages/api/router/orgPhone/query.forContactInfoEdit.schema.ts create mode 100644 packages/api/router/orgPhone/query.forEditDrawer.handler.ts create mode 100644 packages/api/router/orgPhone/query.forEditDrawer.schema.ts create mode 100644 packages/api/router/orgSocialMedia/query.forContactInfoEdits.handler.ts create mode 100644 packages/api/router/orgSocialMedia/query.forContactInfoEdits.schema.ts create mode 100644 packages/api/router/orgSocialMedia/query.forEditDrawer.handler.ts create mode 100644 packages/api/router/orgSocialMedia/query.forEditDrawer.schema.ts create mode 100644 packages/api/router/orgSocialMedia/query.getServiceTypes.handler.ts create mode 100644 packages/api/router/orgSocialMedia/query.getServiceTypes.schema.ts create mode 100644 packages/api/router/orgWebsite/query.forContactInfoEdit.handler.ts create mode 100644 packages/api/router/orgWebsite/query.forContactInfoEdit.schema.ts create mode 100644 packages/api/router/orgWebsite/query.forEditDrawer.handler.ts create mode 100644 packages/api/router/orgWebsite/query.forEditDrawer.schema.ts delete mode 100644 packages/api/router/organization/lib.ts create mode 100644 packages/api/router/organization/mutation.updateBasic.handler.ts create mode 100644 packages/api/router/organization/mutation.updateBasic.schema.ts create mode 100644 packages/api/router/organization/query.forOrgPageEdits.handler.ts create mode 100644 packages/api/router/organization/query.forOrgPageEdits.schema.ts create mode 100644 packages/db/prisma/data-migrations/2023-09-28_phone-number-normalization/!prep.ts rename packages/db/prisma/data-migrations/{!YYYY-MM-DD_job-template.ts => 2023-09-28_phone-number-normalization/index.ts} (76%) create mode 100644 packages/db/prisma/views/public/pg_cache_hit_rate.sql create mode 100644 packages/db/prisma/views/public/pg_index_usage.sql create mode 100644 packages/db/prisma/views/public/pg_index_usage_rate.sql delete mode 100644 packages/ui/babel.config.json delete mode 100644 packages/ui/components/data-display/ContactInfo.tsx create mode 100644 packages/ui/components/data-display/ContactInfo/Emails.tsx create mode 100644 packages/ui/components/data-display/ContactInfo/PhoneNumbers.tsx create mode 100644 packages/ui/components/data-display/ContactInfo/SocialMedia.tsx create mode 100644 packages/ui/components/data-display/ContactInfo/Websites.tsx create mode 100644 packages/ui/components/data-display/ContactInfo/common.styles.ts rename packages/ui/components/data-display/{ContactInfo.stories.tsx => ContactInfo/index.stories.tsx} (95%) create mode 100644 packages/ui/components/data-display/ContactInfo/index.tsx create mode 100644 packages/ui/components/data-display/ContactInfo/types.ts create mode 100644 packages/ui/components/data-portal/EmailDrawer/index.stories.tsx create mode 100644 packages/ui/components/data-portal/EmailDrawer/index.tsx create mode 100644 packages/ui/components/data-portal/PhoneDrawer/index.stories.tsx create mode 100644 packages/ui/components/data-portal/PhoneDrawer/index.tsx delete mode 100644 packages/ui/components/data-portal/PhoneNumberEntry.stories.tsx create mode 100644 packages/ui/components/data-portal/PhoneNumberEntry/CountrySelectItem.tsx create mode 100644 packages/ui/components/data-portal/PhoneNumberEntry/index.stories.tsx rename packages/ui/components/data-portal/{PhoneNumberEntry.tsx => PhoneNumberEntry/index.tsx} (99%) create mode 100644 packages/ui/components/data-portal/PhoneNumberEntry/lib.ts create mode 100644 packages/ui/components/data-portal/PhoneNumberEntry/styles.ts create mode 100644 packages/ui/components/data-portal/PhoneNumberEntry/withHookForm.tsx create mode 100644 packages/ui/components/data-portal/SocialMediaDrawer/index.stories.tsx create mode 100644 packages/ui/components/data-portal/SocialMediaDrawer/index.tsx create mode 100644 packages/ui/components/data-portal/WebsiteDrawer/index.stories.tsx create mode 100644 packages/ui/components/data-portal/WebsiteDrawer/index.tsx rename packages/ui/components/sections/{Contact.stories.tsx => ContactSection/index.stories.tsx} (92%) rename packages/ui/components/sections/{Contact.tsx => ContactSection/index.tsx} (82%) create mode 100644 packages/ui/hooks/useEditMode.ts rename packages/ui/icon/{Icon.stories.tsx => index.stories.tsx} (100%) create mode 100644 packages/ui/loading-states/OrgLocationPage.tsx create mode 100644 packages/ui/loading-states/OrgPage.tsx create mode 100644 packages/ui/mockData/json/orgEmail.forContactInfoEdit.json create mode 100644 packages/ui/mockData/json/orgEmail.forEditDrawer.json create mode 100644 packages/ui/mockData/json/orgPhone.forContactInfoEdit.json create mode 100644 packages/ui/mockData/json/orgPhone.forEditDrawer.json create mode 100644 packages/ui/mockData/json/orgPhone.update.json create mode 100644 packages/ui/mockData/json/orgSocialMedia.forContactInfoEdits.json create mode 100644 packages/ui/mockData/json/orgSocialMedia.forEditDrawer.json create mode 100644 packages/ui/mockData/json/orgSocialMedia.getServiceTypes.json create mode 100644 packages/ui/mockData/json/orgWebsite.forContactInfoEdit.json create mode 100644 packages/ui/mockData/json/orgWebsite.forEditDrawer.json create mode 100644 packages/ui/providers/EditMode.tsx create mode 100644 packages/ui/store/index.ts create mode 100644 packages/util/luxon/weekday.ts diff --git a/.prettierignore b/.prettierignore index 0b5e4595c6..4c6848663d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -14,3 +14,4 @@ pnpm-lock.yaml .eslintignore *.hbs .trace/*.json +packages/ui/mockData/json/*.json diff --git a/apps/app/lib/wdyr.ts b/apps/app/lib/wdyr.ts new file mode 100644 index 0000000000..8ad61c8c29 --- /dev/null +++ b/apps/app/lib/wdyr.ts @@ -0,0 +1,20 @@ +/// +import React from 'react' + +// eslint-disable-next-line node/no-process-env +if (process.env.NODE_ENV === 'development' && !!process.env.WDYR) { + if (typeof window !== 'undefined') { + const loadWdyr = async () => { + const { default: whyDidYouRender } = await import('@welldone-software/why-did-you-render') + whyDidYouRender(React, { + trackAllPureComponents: true, + include: [/.*/], + exclude: [/.*mantine.*/i], + logOnDifferentValues: false, + logOwnerReasons: true, + collapseGroups: true, + }) + } + loadWdyr() + } +} diff --git a/apps/app/next-i18next.config.mjs b/apps/app/next-i18next.config.mjs index 077a3fa03e..34ee72bb18 100644 --- a/apps/app/next-i18next.config.mjs +++ b/apps/app/next-i18next.config.mjs @@ -6,6 +6,7 @@ import HttpBackend from 'i18next-http-backend' import intervalPlural from 'i18next-intervalplural-postprocessor' // import LocalStorageBackend from 'i18next-localstorage-backend' import MultiBackend from 'i18next-multiload-backend-adapter' +import compact from 'just-compact' import path from 'path' @@ -13,7 +14,7 @@ import path from 'path' import { localeList } from '@weareinreach/db/generated/locales.mjs' const isBrowser = typeof window !== 'undefined' -const isDev = process.env.NODE_ENV !== 'production' +const isDev = process.env.NODE_ENV !== 'production' && !process.env.CI const isVerbose = !!process.env.NEXT_VERBOSE // const Keys = z.record(z.string()) @@ -42,6 +43,29 @@ const multi = new MultiBackend(null, { }, }) +const plugins = () => { + /** @type {any[]} */ + const pluginsToUse = [intervalPlural, LanguageDetector] + if (isBrowser) { + pluginsToUse.push(ChainedBackend) + } + if (process.env.NODE_ENV === 'development') { + if (isBrowser) { + // @ts-expect-error - yelling about declaration file + import('i18next-hmr/plugin').then(({ HMRPlugin }) => + pluginsToUse.push(new HMRPlugin({ webpack: { client: true } })) + ) + } else { + // @ts-expect-error - yelling about declaration file + import('i18next-hmr/plugin').then(({ HMRPlugin }) => + pluginsToUse.push(new HMRPlugin({ webpack: { server: true } })) + ) + } + } + + return compact(pluginsToUse) +} + /** @type {import('next-i18next').UserConfig} */ const config = { i18n: { @@ -76,7 +100,7 @@ const config = { backends: isBrowser ? [multi] : [], }, serializeConfig: false, - use: isBrowser ? [ChainedBackend, intervalPlural, LanguageDetector] : [intervalPlural, LanguageDetector], + use: plugins(), maxParallelReads: 20, joinArrays: '', interpolation: { diff --git a/apps/app/next.config.mjs b/apps/app/next.config.mjs index ee813f1283..db4e444868 100644 --- a/apps/app/next.config.mjs +++ b/apps/app/next.config.mjs @@ -78,10 +78,30 @@ const nextConfig = { // Automatically tree-shake Sentry logger statements to reduce bundle size disableLogger: isVercelProd || isVercelActiveDev, }, - webpack: (config, { isServer, webpack }) => { + webpack: (config, { dev, isServer, webpack }) => { if (isServer) { config.plugins = [...config.plugins, new PrismaPlugin()] } + if (dev && !isServer) { + /** WDYR */ + const origEntry = config.entry + config.entry = async () => { + const wdyrPath = path.resolve(__dirname, './lib/wdyr.ts') + const entries = await origEntry() + if (entries['main.js'] && !entries['main.js'].includes(wdyrPath)) { + entries['main.js'].push(wdyrPath) + } + return entries + } + /** I18 HMR */ + import('i18next-hmr/webpack').then(({ I18NextHMRPlugin }) => + config.plugins.push( + new I18NextHMRPlugin({ + localesDir: path.resolve(__dirname, 'public/static/locales'), + }) + ) + ) + } config.devtool = 'eval-source-map' diff --git a/apps/app/package.json b/apps/app/package.json index ab0539ccc3..7afe57ddc7 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -36,6 +36,7 @@ "@mantine/nprogress": "6.0.21", "@mantine/utils": "6.0.21", "@next/bundle-analyzer": "14.1.0", + "@next/third-parties": "14.1.0", "@opentelemetry/api": "1.7.0", "@opentelemetry/core": "1.20.0", "@opentelemetry/exporter-trace-otlp-http": "0.47.0", @@ -119,10 +120,12 @@ "@typescript-eslint/parser": "6.19.0", "@weareinreach/config": "workspace:*", "@weareinreach/eslint-config": "0.100.0", + "@welldone-software/why-did-you-render": "8.0.1", "commander": "11.1.0", "dotenv": "16.3.1", "eslint": "8.56.0", "eslint-plugin-i18next": "6.0.3", + "i18next-hmr": "3.0.4", "listr2": "8.0.1", "prettier": "3.2.4", "trpc-client-devtools-link": "0.2.1-next", diff --git a/apps/app/public/locales/en/common.json b/apps/app/public/locales/en/common.json index c0c29dcbf9..26df5b794a 100644 --- a/apps/app/public/locales/en/common.json +++ b/apps/app/public/locales/en/common.json @@ -110,6 +110,9 @@ "try-again-text": "Something went wrong! Please try again." }, "exclude": "Exclude", + "exit": { + "edit-mode": "Exit edit mode" + }, "filter-by-service": "Filter by services", "find-resources": "Find resources", "find-x": "Find {{value}}", @@ -189,6 +192,7 @@ "other-specify": "Other (please specify)", "page-title": { "base": "{{- title}} - InReach", + "edit-mode": "{{- title}} [EDIT MODE] - InReach", "search-results": "Search Results" }, "password": "Password", @@ -423,9 +427,13 @@ "please-wait": "Please wait...", "prev": "Prev", "print": "Print", + "publish": "Publish", + "restore": "Restore", + "reverify": "Reverify", "review": "Review", "reviews": "Reviews", "save": "Save", + "save-changes": "Save changes", "saved": "Saved", "search": "Search", "service-hours": "Service hours", @@ -434,6 +442,7 @@ "sign-up": "Sign up", "skip": "Skip", "support": "Support", + "unpublish": "Unpublish", "website": "Website", "yes": "Yes" } diff --git a/apps/app/src/middleware.ts b/apps/app/src/middleware.ts index 0a86929357..9d6a94e1ee 100644 --- a/apps/app/src/middleware.ts +++ b/apps/app/src/middleware.ts @@ -1,8 +1,10 @@ +import { track } from '@vercel/analytics/server' import { get } from '@vercel/edge-config' import { type NextMiddleware, type NextRequest, NextResponse } from 'next/server' export const middleware: NextMiddleware = async (req: NextRequest) => { const res = NextResponse.next() + track('geo', { ...req.geo }) if (!req.cookies.has('inreach-session')) { res.cookies.set({ diff --git a/apps/app/src/pages/_app.tsx b/apps/app/src/pages/_app.tsx index b6619613f4..29c81b8397 100644 --- a/apps/app/src/pages/_app.tsx +++ b/apps/app/src/pages/_app.tsx @@ -1,3 +1,5 @@ +import '../../lib/wdyr' + import { Space } from '@mantine/core' import { Notifications } from '@mantine/notifications' import { Analytics } from '@vercel/analytics/react' @@ -37,31 +39,11 @@ const defaultSEO = { titleTemplate: '%s | InReach', defaultTitle: 'InReach', additionalLinkTags: [ - { - rel: 'icon', - href: '/favicon-16x16.png', - sizes: '16x16', - }, - { - rel: 'icon', - href: '/favicon-32x32.png', - sizes: '32x32', - }, - { - rel: 'icon', - href: '/favicon-96x96.png', - sizes: '96x96', - }, - { - rel: 'apple-touch-icon', - href: '/apple-icon-120x120.png', - sizes: '120x120', - }, - { - rel: 'apple-touch-icon', - href: '/apple-icon-180x180.png', - sizes: '180x180', - }, + { rel: 'icon', href: '/favicon-16x16.png', sizes: '16x16' }, + { rel: 'icon', href: '/favicon-32x32.png', sizes: '32x32' }, + { rel: 'icon', href: '/favicon-96x96.png', sizes: '96x96' }, + { rel: 'apple-touch-icon', href: '/apple-icon-120x120.png', sizes: '120x120' }, + { rel: 'apple-touch-icon', href: '/apple-icon-180x180.png', sizes: '180x180' }, ], } satisfies DefaultSeoProps @@ -117,8 +99,8 @@ const MyApp = (appProps: AppPropsWithGridSwitch) => { export default api.withTRPC(appWithTranslation(MyApp, nextI18nConfig)) -export type NextPageWithoutGrid

= NextPage & { +export type NextPageWithOptions = NextPage & { omitGrid?: boolean autoResetState?: boolean } -type AppPropsWithGridSwitch = AppProps<{ session: Session }> & { Component: NextPageWithoutGrid } +type AppPropsWithGridSwitch = AppProps<{ session: Session }> & { Component: NextPageWithOptions } diff --git a/apps/app/src/pages/_document.tsx b/apps/app/src/pages/_document.tsx index c96680a2de..4599ad5527 100644 --- a/apps/app/src/pages/_document.tsx +++ b/apps/app/src/pages/_document.tsx @@ -10,7 +10,6 @@ const stylesServer = createStylesServer(appCache) export default class _Document extends Document { static async getInitialProps(ctx: DocumentContext) { const initialProps = await Document.getInitialProps(ctx) - return { ...initialProps, styles: [ diff --git a/apps/app/src/pages/index.tsx b/apps/app/src/pages/index.tsx index ba9246503e..13a0fe2a39 100644 --- a/apps/app/src/pages/index.tsx +++ b/apps/app/src/pages/index.tsx @@ -35,7 +35,7 @@ import { ResetPasswordModal } from '@weareinreach/ui/modals/ResetPassword' import { api } from '~app/utils/api' import { getServerSideTranslations } from '~app/utils/i18n' -import { type NextPageWithoutGrid } from './_app' +import { type NextPageWithOptions } from './_app' const useStyles = createStyles((theme) => ({ callout1text: { @@ -159,7 +159,7 @@ const CardTranslation = ({ i18nKey, t }: { i18nKey: string; t: TFunction }) => { ) } -const Home: NextPageWithoutGrid = () => { +const Home: NextPageWithOptions = () => { const router = useRouter() const { t } = useTranslation('landingPage') const theme = useMantineTheme() diff --git a/apps/app/src/pages/org/[slug]/[orgLocationId]/edit.tsx b/apps/app/src/pages/org/[slug]/[orgLocationId]/edit.tsx index f591dc6d11..b888e3b9ea 100644 --- a/apps/app/src/pages/org/[slug]/[orgLocationId]/edit.tsx +++ b/apps/app/src/pages/org/[slug]/[orgLocationId]/edit.tsx @@ -9,13 +9,14 @@ import { z } from 'zod' import { trpcServerClient } from '@weareinreach/api/trpc' import { checkServerPermissions } from '@weareinreach/auth' import { Toolbar } from '@weareinreach/ui/components/core/Toolbar' -import { ContactSection } from '@weareinreach/ui/components/sections/Contact' +import { ContactSection } from '@weareinreach/ui/components/sections/ContactSection' import { ListingBasicInfo } from '@weareinreach/ui/components/sections/ListingBasicInfo' // import {LocationCard } from '@weareinreach/ui/components/sections/LocationCard' import { PhotosSection } from '@weareinreach/ui/components/sections/Photos' import { ReviewSection } from '@weareinreach/ui/components/sections/Reviews' import { ServicesInfoCard } from '@weareinreach/ui/components/sections/ServicesInfo' import { VisitCard } from '@weareinreach/ui/components/sections/VisitCard' +import { OrgLocationPageLoading } from '@weareinreach/ui/loading-states/OrgLocationPage' import { api } from '~app/utils/api' import { getServerSideTranslations } from '~app/utils/i18n' @@ -36,7 +37,7 @@ const OrgLocationPage: NextPage = () => { useEffect(() => { if (data && status === 'success' && orgData && orgDataStatus === 'success') setLoading(false) }, [data, status, orgData, orgDataStatus]) - if (loading || !data || !orgData) return <>Loading + if (loading || !data || !orgData) return const { // emails, @@ -69,7 +70,6 @@ const OrgLocationPage: NextPage = () => { /> ( - <> - - {/* Toolbar */} - - - {/* Listing Basic */} - - {/* Body */} - - {/* Tab panels */} - - - - - {/* Contact Card */} - - {/* Visit Card */} - - - - -) - const useStyles = createStyles((theme) => ({ tabsList: { position: 'sticky', @@ -89,7 +65,7 @@ const OrgLocationPage: NextPage = () => { useEffect(() => { if (data && status === 'success' && orgData && orgDataStatus === 'success') setLoading(false) }, [data, status, orgData, orgDataStatus]) - if (loading || !data || !orgData || router.isFallback) return + if (loading || !data || !orgData || router.isFallback) return const { attributes, description, reviews } = data @@ -125,7 +101,6 @@ const OrgLocationPage: NextPage = () => { /> ))} { - const { t } = useTranslation() +const formSchema = z + .object({ + name: z.string(), + description: z.string(), + }) + .partial() +type FormSchema = z.infer + +const OrganizationPage: NextPageWithOptions> = () => { const router = useRouter<'/org/[slug]'>() - const { query } = router.isReady ? router : { query: { slug: '' } } - const [activeTab, setActiveTab] = useState('services') + const { + query: { slug: pageSlug }, + } = router.isReady ? router : { query: { slug: '' } } + const { data, status } = api.organization.forOrgPageEdits.useQuery( + { slug: pageSlug }, + { enabled: router.isReady } + ) + + const formMethods = useForm({ + values: { + name: data?.name, + description: data?.description?.tsKey?.text, + }, + }) const [loading, setLoading] = useState(true) - const { data, status } = api.organization.getBySlug.useQuery(query, { enabled: router.isReady }) + const { data: hasRemote } = api.service.forServiceInfoCard.useQuery( + { parentId: data?.id ?? '', remoteOnly: true }, + { + enabled: !!data?.id && data?.locations.length > 1, + select: (data) => data.length !== 0, + } + ) const { ref, width } = useElementSize() useEffect(() => { if (data && status === 'success') setLoading(false) }, [data, status]) - if (loading || !data) return <>Loading - - const { - // emails, - // phones, - // socialMedia, - // websites, - userLists, - attributes, - description, - slug, - // photos, - reviews, - locations, - isClaimed, - id: organizationId, - } = data + if (loading || !data) return - const body = - locations?.length === 1 ? ( - - - {t('services')} - {t('photo', { count: 2 })} - {t('review', { count: 2 })} - - - - - - - - - - - - ) : ( - <> - {locations.map((location) => ( - - ))} - - ) - - const sidebar = - locations?.length === 1 ? ( - <>{locations[0] && } - ) : ( - locations.length && ( - - {width && ( - id)} - width={width} - height={Math.floor(width * 1.185)} - /> - )} - - ) - ) + const { attributes, description, slug, locations, isClaimed } = data return ( <> - EDIT MODE - - router.back() }} - saved={Boolean(userLists?.length)} - organizationId={organizationId} - /> - - - {body} - - - - - - {/* */} - {/* */} - {sidebar} - - + + {t('page-title.edit-mode', { ns: 'common', title: data.name })} + + + + + + + {locations.map((location) => ( + + ))} + {hasRemote && } + + + + + + + + {!!width && ( + id)} + width={width} + height={Math.floor(width * 1.185)} + /> + )} + + + + + ) } -export const getServerSideProps: GetServerSideProps< - Record, - RoutedQuery<'/org/[slug]'> -> = async ({ locale, params, req, res }) => { +export const getServerSideProps = async ({ + locale, + params, + req, + res, +}: GetServerSidePropsContext>) => { if (!params) return { notFound: true } const { slug } = params @@ -154,17 +136,21 @@ export const getServerSideProps: GetServerSideProps< } const ssg = await trpcServerClient({ session }) + const orgId = await ssg.organization.getIdFromSlug.fetch({ slug }) - await ssg.organization.getBySlug.prefetch({ slug }) + const [i18n] = await Promise.allSettled([ + getServerSideTranslations(locale, compact(['common', 'services', 'attribute', 'phone-type', orgId?.id])), + ssg.organization.forOrgPageEdits.prefetch({ slug }), + ssg.fieldOpt.countries.prefetch({ activeForOrgs: true }), + ]) const props = { session, trpcState: ssg.dehydrate(), - ...(await getServerSideTranslations(locale, ['common', 'services', 'attribute', 'phone-type', slug])), + ...(i18n.status === 'fulfilled' ? i18n.value : {}), } return { props, } } - export default OrganizationPage diff --git a/apps/app/src/pages/org/[slug]/index.tsx b/apps/app/src/pages/org/[slug]/index.tsx index c3e35d82de..c25590c734 100644 --- a/apps/app/src/pages/org/[slug]/index.tsx +++ b/apps/app/src/pages/org/[slug]/index.tsx @@ -9,12 +9,10 @@ import { type RoutedQuery } from 'nextjs-routes' import { useEffect, useRef, useState } from 'react' import { trpcServerClient } from '@weareinreach/api/trpc' -// import { getEnv } from '@weareinreach/env' -// import { prisma } from '@weareinreach/db/client' import { AlertMessage } from '@weareinreach/ui/components/core/AlertMessage' // import { GoogleMap } from '@weareinreach/ui/components/core/GoogleMap' import { Toolbar } from '@weareinreach/ui/components/core/Toolbar' -import { ContactSection } from '@weareinreach/ui/components/sections/Contact' +import { ContactSection } from '@weareinreach/ui/components/sections/ContactSection' import { ListingBasicInfo } from '@weareinreach/ui/components/sections/ListingBasicInfo' import { LocationCard } from '@weareinreach/ui/components/sections/LocationCard' import { PhotosSection } from '@weareinreach/ui/components/sections/Photos' @@ -22,6 +20,7 @@ import { ReviewSection } from '@weareinreach/ui/components/sections/Reviews' import { ServicesInfoCard } from '@weareinreach/ui/components/sections/ServicesInfo' import { VisitCard } from '@weareinreach/ui/components/sections/VisitCard' import { useSearchState } from '@weareinreach/ui/hooks/useSearchState' +import { OrgPageLoading } from '@weareinreach/ui/loading-states/OrgPage' import { api } from '~app/utils/api' import { getServerSideTranslations } from '~app/utils/i18n' @@ -61,7 +60,10 @@ const useStyles = createStyles((theme) => ({ }, })) -const OrganizationPage = ({ slug }: InferGetStaticPropsType) => { +const OrganizationPage = ({ + slug, + organizationId: orgId, +}: InferGetStaticPropsType) => { const router = useRouter<'/org/[slug]'>() const { data, status } = api.organization.forOrgPage.useQuery({ slug }, { enabled: !!slug }) // const { query } = router @@ -69,7 +71,7 @@ const OrganizationPage = ({ slug }: InferGetStaticPropsType('services') const [loading, setLoading] = useState(true) const { data: hasRemote } = api.service.forServiceInfoCard.useQuery( @@ -104,7 +106,7 @@ const OrganizationPage = ({ slug }: InferGetStaticPropsType + if (loading || !data || router.isFallback) return const { userLists, attributes, description, reviews, locations, isClaimed, id: organizationId } = data @@ -218,7 +220,6 @@ const OrganizationPage = ({ slug }: InferGetStaticPropsType ))} { ) return ( - // - - - + + {/* */} + + {children} {/* {t('cookie-consent.intro')} */} - - - - // + + + {/* */} + ) } diff --git a/packages/api/lib/prismaRaw/coveredAreas.ts b/packages/api/lib/prismaRaw/coveredAreas.ts deleted file mode 100644 index c1153ff2fb..0000000000 --- a/packages/api/lib/prismaRaw/coveredAreas.ts +++ /dev/null @@ -1,45 +0,0 @@ -// import { type Context } from '../context' - -// export const getCoveredDists = async (coords: Coords, ctx: Context) => { -// const { lat, lon } = coords - -// const result = await ctx.prisma.$queryRaw<{ id: string }[]>` -// SELECT id FROM "GovDist" -// WHERE ST_CoveredBy(ST_SetSRID(ST_MakePoint(${lon}, ${lat}),4326), geo) -// ` -// return result -// } -// export const getCoveredCountry = async (coords: Coords, ctx: Context) => { -// const { lat, lon } = coords - -// const result = await ctx.prisma.$queryRaw<{ id: string }>` -// SELECT id FROM "Country" -// WHERE ST_CoveredBy(ST_SetSRID(ST_MakePoint(${lon}, ${lat}),4326), geo) -// ` -// return result -// } -// export const getCoveredAreas = async (coords: Coords, ctx: Context) => { -// const { lat, lon } = coords - -// const result = await ctx.prisma.$transaction(async (tx) => { -// const govDist = await tx.$queryRaw<{ id: string }[]>` -// SELECT id FROM "GovDist" -// WHERE ST_CoveredBy(ST_SetSRID(ST_MakePoint(${lon}, ${lat}),4326), geo) -// ` -// const country = await tx.$queryRaw<{ id: string }[]>` -// SELECT id FROM "Country" -// WHERE ST_CoveredBy(ST_SetSRID(ST_MakePoint(${lon}, ${lat}),4326), geo) -// ` -// return { -// country, -// govDist, -// } -// }) -// return result -// } - -// type Coords = { -// lat: number -// lon: number -// } -export {} diff --git a/packages/api/lib/prismaRaw/index.ts b/packages/api/lib/prismaRaw/index.ts index f295d4eb50..93a111c83c 100644 --- a/packages/api/lib/prismaRaw/index.ts +++ b/packages/api/lib/prismaRaw/index.ts @@ -1,5 +1,4 @@ // codegen:start {preset: barrel, include: ./*.ts} -export * from './coveredAreas' export * from './orgSearch' export * from './updateGeo' // codegen:end diff --git a/packages/api/prisma/org.ts b/packages/api/prisma/org.ts deleted file mode 100644 index ff6aa8632e..0000000000 --- a/packages/api/prisma/org.ts +++ /dev/null @@ -1,134 +0,0 @@ -// import { getDistance } from 'geolib' - -// import { type Prisma } from '@weareinreach/db' -// import { type Context } from '~api/lib' -// import { type DistSearch } from '~api/schemas/org/search' -// import { isPublic } from '~api/schemas/selects/common' -// import { orgSearchSelect } from '~api/schemas/selects/org' - -// export const prismaDistSearchDetails = async ({ ctx, input }: PrismaSearchDistance) => { -// const { resultIds, lat: latitude, lon: longitude } = input -// const results = await ctx.prisma.organization.findMany({ -// where: { -// id: { in: resultIds }, -// // ...attributeFilter(attributes), -// // ...serviceFilter(services), -// ...isPublic, -// }, -// select: orgSearchSelect, -// }) - -// const transformed = results.map(({ attributes, description, locations, services, ...rest }) => { -// const servIds = new Set() -// const attribIds = new Set() -// const cities: City[] = [] -// const serviceTagMap = new Map() -// const serviceCategoryMap = new Map() -// const attributeMap = new Map() - -// services.forEach(({ services }) => -// services.forEach(({ tag, service }) => { -// const { id, tsKey, tsNs, primaryCategory } = tag -// servIds.add(id) -// serviceCategoryMap.set(primaryCategory.id, primaryCategory) -// serviceTagMap.set(id, { id, tsKey, tsNs }) -// service.attributes.forEach(({ attribute }) => { -// const { categories, ...rest } = attribute -// attribIds.add(rest.id) -// categories.forEach(({ category }) => -// attributeMap.set(`${rest.id}${category.tag}`, { category, ...rest }) -// ) -// }) -// }) -// ) - -// locations.forEach(({ services, city, ...coords }) => { -// cities.push({ -// city, -// dist: getDistance( -// { latitude, longitude }, -// { latitude: coords.latitude ?? 0, longitude: coords.longitude ?? 0 }, -// 1000 -// ), -// }) -// services.forEach(({ service }) => -// service.services.forEach(({ tag, service }) => { -// const { id, tsKey, tsNs, primaryCategory } = tag -// servIds.add(id) -// serviceCategoryMap.set(primaryCategory.id, primaryCategory) -// serviceTagMap.set(id, { id, tsKey, tsNs }) -// service.attributes.forEach(({ attribute }) => { -// const { categories, ...rest } = attribute -// attribIds.add(rest.id) -// categories.forEach(({ category }) => -// attributeMap.set(`${rest.id}${category.tag}`, { category, ...rest }) -// ) -// }) -// }) -// ) -// }) -// attributes.forEach(({ attribute }) => { -// const { categories, ...rest } = attribute -// attribIds.add(rest.id) -// categories.forEach(({ category }) => -// attributeMap.set(`${rest.id}${category.tag}`, { category, ...rest }) -// ) -// }) - -// const desc = description -// ? { key: description.key, ns: description.ns, text: description.tsKey.text } -// : null - -// // const serviceTags = Array.from(serviceTagMap.values()) -// const serviceCategories = Array.from(serviceCategoryMap.values()) -// const allAttributes = Array.from(attributeMap.values()) - -// const orgLeader = allAttributes.filter(({ category }) => category.tag === 'organization-leadership') -// const orgFocus = allAttributes.filter( -// ({ category, _count: count }) => category.tag === 'service-focus' && count.parents === 0 -// ) -// const sortedCities = [ -// ...new Set(cities.sort(({ dist: distA }, { dist: distB }) => distA - distB).map(({ city }) => city)), -// ] - -// return { -// ...rest, -// description: desc, -// // attributes: allAttributes, -// // services: serviceTags, -// serviceCategories, -// orgLeader, -// orgFocus, -// locations: sortedCities, -// } -// }) - -// return transformed -// } - -// export type PrismaDistSearchDetailsResult = Prisma.PromiseReturnType -// interface PrismaSearchDistance { -// ctx: Context -// input: DistSearch & { resultIds: string[] } -// } -// type IdKeyNs = { -// id: string -// tsKey: string -// tsNs: string -// } -// type Attribute = { -// id: string -// tsKey: string -// icon: string | null -// iconBg: string | null -// category: { -// tag: string -// } -// _count: { -// parents: number -// } -// } -// type City = { -// city: string -// dist: number -// } diff --git a/packages/api/router/misc/index.ts b/packages/api/router/misc/index.ts index 0f90f7194a..6be4c1dcf5 100644 --- a/packages/api/router/misc/index.ts +++ b/packages/api/router/misc/index.ts @@ -1,32 +1,45 @@ -import { defineRouter, publicProcedure } from '~api/lib/trpc' +import { defineRouter, permissionedProcedure, publicProcedure } from '~api/lib/trpc' import * as schema from './schemas' -export const HandlerCache: Partial = {} +const HandlerCache: Partial = {} + +type MiscHandlerCache = { + hasContactInfo: typeof import('./query.hasContactInfo.handler').hasContactInfo + getCountryTranslation: typeof import('./query.getCountryTranslation.handler').getCountryTranslation + forEditNavbar: typeof import('./query.forEditNavbar.handler').forEditNavbar +} + export const miscRouter = defineRouter({ hasContactInfo: publicProcedure.input(schema.ZHasContactInfoSchema).query(async ({ ctx, input }) => { - if (!HandlerCache.hasContactInfo) { + if (!HandlerCache.hasContactInfo) HandlerCache.hasContactInfo = await import('./query.hasContactInfo.handler').then( (mod) => mod.hasContactInfo ) - } + if (!HandlerCache.hasContactInfo) throw new Error('Failed to load handler') return HandlerCache.hasContactInfo({ ctx, input }) }), getCountryTranslation: publicProcedure .input(schema.ZGetCountryTranslationSchema) .query(async ({ ctx, input }) => { - if (!HandlerCache.getCountryTranslation) { + if (!HandlerCache.getCountryTranslation) HandlerCache.getCountryTranslation = await import('./query.getCountryTranslation.handler').then( (mod) => mod.getCountryTranslation ) - } + if (!HandlerCache.getCountryTranslation) throw new Error('Failed to load handler') return HandlerCache.getCountryTranslation({ ctx, input }) }), -}) + forEditNavbar: permissionedProcedure('updateLocation') + .input(schema.ZForEditNavbarSchema) + .query(async ({ ctx, input }) => { + if (!HandlerCache.forEditNavbar) + HandlerCache.forEditNavbar = await import('./query.forEditNavbar.handler').then( + (mod) => mod.forEditNavbar + ) -type MiscHandlerCache = { - hasContactInfo: typeof import('./query.hasContactInfo.handler').hasContactInfo - getCountryTranslation: typeof import('./query.getCountryTranslation.handler').getCountryTranslation -} + if (!HandlerCache.forEditNavbar) throw new Error('Failed to load handler') + return HandlerCache.forEditNavbar({ ctx, input }) + }), +}) diff --git a/packages/api/router/misc/query.forEditNavbar.handler.ts b/packages/api/router/misc/query.forEditNavbar.handler.ts new file mode 100644 index 0000000000..a52bae3b14 --- /dev/null +++ b/packages/api/router/misc/query.forEditNavbar.handler.ts @@ -0,0 +1,24 @@ +import { isIdFor, prisma } from '@weareinreach/db' +import { handleError } from '~api/lib/errorHandler' +import { type TRPCHandlerParams } from '~api/types/handler' + +import { type TForEditNavbarSchema } from './query.forEditNavbar.schema' + +export const forEditNavbar = async ({ input }: TRPCHandlerParams) => { + try { + if (input.slug) { + const result = await prisma.organization.findUniqueOrThrow({ + where: { slug: input.slug }, + select: { published: true, deleted: true, lastVerified: true }, + }) + return result + } + const result = await prisma.orgLocation.findUniqueOrThrow({ + where: { id: input.orgLocationId }, + select: { published: true, deleted: true }, + }) + return { ...result, lastVerified: null } + } catch (error) { + handleError(error) + } +} diff --git a/packages/api/router/misc/query.forEditNavbar.schema.ts b/packages/api/router/misc/query.forEditNavbar.schema.ts new file mode 100644 index 0000000000..1464036d9d --- /dev/null +++ b/packages/api/router/misc/query.forEditNavbar.schema.ts @@ -0,0 +1,9 @@ +import { z } from 'zod' + +import { prefixedId } from '~api/schemas/idPrefix' + +export const ZForEditNavbarSchema = z.union([ + z.object({ slug: z.string(), orgLocationId: z.never().optional() }), + z.object({ orgLocationId: prefixedId('orgLocation'), slug: z.never().optional() }), +]) +export type TForEditNavbarSchema = z.infer diff --git a/packages/api/router/misc/query.getCountryTranslation.handler.ts b/packages/api/router/misc/query.getCountryTranslation.handler.ts index 511e5e1059..b6d28f9820 100644 --- a/packages/api/router/misc/query.getCountryTranslation.handler.ts +++ b/packages/api/router/misc/query.getCountryTranslation.handler.ts @@ -10,7 +10,6 @@ export const getCountryTranslation = async ({ input }: TRPCHandlerParams val.toLocaleUpperCase()), + .transform((str) => str.toUpperCase()), }) export type TGetCountryTranslationSchema = z.infer diff --git a/packages/api/router/misc/schemas.ts b/packages/api/router/misc/schemas.ts index d23f4da46b..7b1c664352 100644 --- a/packages/api/router/misc/schemas.ts +++ b/packages/api/router/misc/schemas.ts @@ -1,4 +1,5 @@ // codegen:start {preset: barrel, include: ./*.schema.ts} +export * from './query.forEditNavbar.schema' export * from './query.getCountryTranslation.schema' export * from './query.hasContactInfo.schema' // codegen:end diff --git a/packages/api/router/orgEmail/index.ts b/packages/api/router/orgEmail/index.ts index efb0114c8f..997a025bc9 100644 --- a/packages/api/router/orgEmail/index.ts +++ b/packages/api/router/orgEmail/index.ts @@ -9,6 +9,8 @@ type OrgEmailHandlerCache = { upsertMany: typeof import('./mutation.upsertMany.handler').upsertMany get: typeof import('./query.get.handler').get forContactInfo: typeof import('./query.forContactInfo.handler').forContactInfo + forContactInfoEdit: typeof import('./query.forContactInfoEdit.handler').forContactInfoEdit + forEditDrawer: typeof import('./query.forEditDrawer.handler').forEditDrawer } export const orgEmailRouter = defineRouter({ create: permissionedProcedure('createNewEmail') @@ -50,4 +52,24 @@ export const orgEmailRouter = defineRouter({ if (!HandlerCache.forContactInfo) throw new Error('Failed to load handler') return HandlerCache.forContactInfo({ ctx, input }) }), + forContactInfoEdit: permissionedProcedure('updateEmail') + .input(schema.ZForContactInfoEditSchema) + .query(async ({ ctx, input }) => { + if (!HandlerCache.forContactInfoEdit) + HandlerCache.forContactInfoEdit = await import('./query.forContactInfoEdit.handler').then( + (mod) => mod.forContactInfoEdit + ) + if (!HandlerCache.forContactInfoEdit) throw new Error('Failed to load handler') + return HandlerCache.forContactInfoEdit({ ctx, input }) + }), + forEditDrawer: permissionedProcedure('updateEmail') + .input(schema.ZForEditDrawerSchema) + .query(async ({ ctx, input }) => { + if (!HandlerCache.forEditDrawer) + HandlerCache.forEditDrawer = await import('./query.forEditDrawer.handler').then( + (mod) => mod.forEditDrawer + ) + if (!HandlerCache.forEditDrawer) throw new Error('Failed to load handler') + return HandlerCache.forEditDrawer({ ctx, input }) + }), }) diff --git a/packages/api/router/orgEmail/mutation.update.handler.ts b/packages/api/router/orgEmail/mutation.update.handler.ts index 456dbbda11..b97915a65a 100644 --- a/packages/api/router/orgEmail/mutation.update.handler.ts +++ b/packages/api/router/orgEmail/mutation.update.handler.ts @@ -1,27 +1,54 @@ -import { prisma } from '@weareinreach/db' -import { CreateAuditLog } from '~api/schemas/create/auditLog' +import { generateNestedFreeTextUpsert, prisma, setAuditId } from '@weareinreach/db' import { type TRPCHandlerParams } from '~api/types/handler' import { type TUpdateSchema } from './mutation.update.schema' export const update = async ({ ctx, input }: TRPCHandlerParams) => { - const { where, data } = input + const { id, orgId, description, descriptionId, titleId, ...record } = input + + const updatedDescription = description + ? generateNestedFreeTextUpsert({ + orgId, + type: 'emailDesc', + itemId: id, + freeTextId: descriptionId, + text: description, + }) + : undefined + const updatedRecord = await prisma.$transaction(async (tx) => { - const current = await tx.orgEmail.findUniqueOrThrow({ where }) - const auditLogs = CreateAuditLog({ - actorId: ctx.session.user.id, - operation: 'UPDATE', - from: current, - to: data, - }) + await setAuditId(ctx.actorId, tx) + const updated = await tx.orgEmail.update({ - where, + where: { id }, data: { - ...data, - auditLogs, + ...record, + description: updatedDescription, + title: titleId ? { connect: { id: titleId } } : undefined, + }, + select: { + id: true, + deleted: true, + description: { select: { tsKey: { select: { text: true, key: true, ns: true } } } }, + descriptionId: true, + email: true, + firstName: true, + lastName: true, + locationOnly: true, + primary: true, + published: true, + serviceOnly: true, + titleId: true, }, }) - return updated + const { description, ...rest } = updated + + const reformatted = { + ...rest, + description: description ? description.tsKey.text : null, + } + + return reformatted }) return updatedRecord } diff --git a/packages/api/router/orgEmail/mutation.update.schema.ts b/packages/api/router/orgEmail/mutation.update.schema.ts index d466057c86..e5b5500255 100644 --- a/packages/api/router/orgEmail/mutation.update.schema.ts +++ b/packages/api/router/orgEmail/mutation.update.schema.ts @@ -1,24 +1,20 @@ import { z } from 'zod' -import { Prisma } from '@weareinreach/db' import { prefixedId } from '~api/schemas/idPrefix' -export const ZUpdateSchema = z - .object({ - id: prefixedId('orgEmail'), - data: z - .object({ - firstName: z.string(), - lastName: z.string(), - primary: z.boolean(), - email: z.string(), - published: z.boolean(), - deleted: z.boolean(), - titleId: prefixedId('userTitle'), - locationOnly: z.boolean(), - serviceOnly: z.boolean(), - }) - .partial(), - }) - .transform(({ data, id }) => Prisma.validator()({ where: { id }, data })) +export const ZUpdateSchema = z.object({ + id: prefixedId('orgEmail'), + orgId: prefixedId('organization'), + firstName: z.string().nullish(), + lastName: z.string().nullish(), + primary: z.boolean().optional(), + email: z.string().optional(), + published: z.boolean().optional(), + deleted: z.boolean().optional(), + titleId: prefixedId('userTitle').nullish(), + locationOnly: z.boolean().optional(), + serviceOnly: z.boolean().optional(), + description: z.string().nullish(), + descriptionId: z.string().nullish(), +}) export type TUpdateSchema = z.infer diff --git a/packages/api/router/orgEmail/query.forContactInfo.schema.ts b/packages/api/router/orgEmail/query.forContactInfo.schema.ts index 37333e8559..f4fa0dc4db 100644 --- a/packages/api/router/orgEmail/query.forContactInfo.schema.ts +++ b/packages/api/router/orgEmail/query.forContactInfo.schema.ts @@ -3,7 +3,7 @@ import { z } from 'zod' import { prefixedId } from '~api/schemas/idPrefix' export const ZForContactInfoSchema = z.object({ - parentId: z.union([prefixedId('organization'), prefixedId('orgLocation'), prefixedId('orgService')]), + parentId: prefixedId(['organization', 'orgLocation', 'orgService']), locationOnly: z.boolean().optional(), serviceOnly: z.boolean().optional(), }) diff --git a/packages/api/router/orgEmail/query.forContactInfoEdit.handler.ts b/packages/api/router/orgEmail/query.forContactInfoEdit.handler.ts new file mode 100644 index 0000000000..ee7b8c89ea --- /dev/null +++ b/packages/api/router/orgEmail/query.forContactInfoEdit.handler.ts @@ -0,0 +1,52 @@ +import { isIdFor, type Prisma, prisma } from '@weareinreach/db' +import { handleError } from '~api/lib/errorHandler' +import { type TRPCHandlerParams } from '~api/types/handler' + +import { type TForContactInfoEditSchema } from './query.forContactInfoEdit.schema' + +const whereId = (input: TForContactInfoEditSchema): Prisma.OrgEmailWhereInput => { + switch (true) { + case isIdFor('organization', input.parentId): { + return { organization: { some: { organization: { id: input.parentId } } } } + } + case isIdFor('orgLocation', input.parentId): { + return { locations: { some: { location: { id: input.parentId } } } } + } + case isIdFor('orgService', input.parentId): { + return { services: { some: { service: { id: input.parentId } } } } + } + default: { + return {} + } + } +} + +export const forContactInfoEdit = async ({ ctx, input }: TRPCHandlerParams) => { + try { + const result = await prisma.orgEmail.findMany({ + where: { + ...whereId(input), + }, + select: { + id: true, + email: true, + primary: true, + title: { select: { key: { select: { key: true } } } }, + description: { select: { tsKey: { select: { text: true, key: true } } } }, + locationOnly: true, + serviceOnly: true, + published: true, + deleted: true, + }, + orderBy: [{ published: 'desc' }, { deleted: 'asc' }], + }) + const transformed = result.map(({ description, title, ...record }) => ({ + ...record, + title: title ? { key: title?.key.key } : null, + description: description ? { key: description?.tsKey.key, defaultText: description?.tsKey.text } : null, + })) + return transformed + } catch (error) { + handleError(error) + } +} diff --git a/packages/api/router/orgEmail/query.forContactInfoEdit.schema.ts b/packages/api/router/orgEmail/query.forContactInfoEdit.schema.ts new file mode 100644 index 0000000000..f45523ff6d --- /dev/null +++ b/packages/api/router/orgEmail/query.forContactInfoEdit.schema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod' + +import { prefixedId } from '~api/schemas/idPrefix' + +export const ZForContactInfoEditSchema = z.object({ + parentId: prefixedId(['organization', 'orgLocation', 'orgService']), +}) +export type TForContactInfoEditSchema = z.infer diff --git a/packages/api/router/orgEmail/query.forEditDrawer.handler.ts b/packages/api/router/orgEmail/query.forEditDrawer.handler.ts new file mode 100644 index 0000000000..7eb8d2aaef --- /dev/null +++ b/packages/api/router/orgEmail/query.forEditDrawer.handler.ts @@ -0,0 +1,37 @@ +import { prisma } from '@weareinreach/db' +import { handleError } from '~api/lib/errorHandler' +import { type TRPCHandlerParams } from '~api/types/handler' + +import { type TForEditDrawerSchema } from './query.forEditDrawer.schema' + +export const forEditDrawer = async ({ ctx, input }: TRPCHandlerParams) => { + try { + const result = await prisma.orgEmail.findUniqueOrThrow({ + where: input, + select: { + id: true, + deleted: true, + description: { select: { tsKey: { select: { text: true, key: true, ns: true } } } }, + descriptionId: true, + email: true, + firstName: true, + lastName: true, + locationOnly: true, + primary: true, + published: true, + serviceOnly: true, + titleId: true, + }, + }) + const { description, ...rest } = result + + const reformatted = { + ...rest, + description: description ? description.tsKey.text : null, + } + + return reformatted + } catch (error) { + handleError(error) + } +} diff --git a/packages/api/router/orgEmail/query.forEditDrawer.schema.ts b/packages/api/router/orgEmail/query.forEditDrawer.schema.ts new file mode 100644 index 0000000000..65dd6df82a --- /dev/null +++ b/packages/api/router/orgEmail/query.forEditDrawer.schema.ts @@ -0,0 +1,6 @@ +import { z } from 'zod' + +import { prefixedId } from '~api/schemas/idPrefix' + +export const ZForEditDrawerSchema = z.object({ id: prefixedId('orgEmail') }) +export type TForEditDrawerSchema = z.infer diff --git a/packages/api/router/orgEmail/schemas.ts b/packages/api/router/orgEmail/schemas.ts index 39890d6f7b..25e30dd020 100644 --- a/packages/api/router/orgEmail/schemas.ts +++ b/packages/api/router/orgEmail/schemas.ts @@ -3,5 +3,7 @@ export * from './mutation.create.schema' export * from './mutation.update.schema' export * from './mutation.upsertMany.schema' export * from './query.forContactInfo.schema' +export * from './query.forContactInfoEdit.schema' +export * from './query.forEditDrawer.schema' export * from './query.get.schema' // codegen:end diff --git a/packages/api/router/orgHours/query.forHoursDisplay.handler.ts b/packages/api/router/orgHours/query.forHoursDisplay.handler.ts index 3b1edd6421..69e44152c6 100644 --- a/packages/api/router/orgHours/query.forHoursDisplay.handler.ts +++ b/packages/api/router/orgHours/query.forHoursDisplay.handler.ts @@ -2,6 +2,7 @@ import groupBy from 'just-group-by' import { DateTime, Interval } from 'luxon' import { isIdFor, prisma, type Prisma } from '@weareinreach/db' +import { convertToLuxonWeekday } from '@weareinreach/util/luxon/weekday' import { globalWhere } from '~api/selects/global' import { type TRPCHandlerParams } from '~api/types/handler' @@ -38,12 +39,12 @@ export const forHoursDisplay = async ({ input }: TRPCHandlerParams { const interval = Interval.fromDateTimes( DateTime.fromJSDate(start, { zone: tz ?? 'America/New_York' }).set({ - weekday: dayIndex, + weekday: convertToLuxonWeekday(dayIndex), weekYear, weekNumber, }), DateTime.fromJSDate(end, { zone: tz ?? 'America/New_York' }).set({ - weekday: start > end ? dayIndex + 1 : dayIndex, + weekday: convertToLuxonWeekday(start > end ? dayIndex + 1 : dayIndex), weekYear, weekNumber, }) diff --git a/packages/api/router/orgHours/query.forHoursDrawer.handler.ts b/packages/api/router/orgHours/query.forHoursDrawer.handler.ts index cece533075..542f9a069e 100644 --- a/packages/api/router/orgHours/query.forHoursDrawer.handler.ts +++ b/packages/api/router/orgHours/query.forHoursDrawer.handler.ts @@ -1,6 +1,7 @@ import { DateTime, Interval } from 'luxon' import { isIdFor, prisma, type Prisma } from '@weareinreach/db' +import { convertToLuxonWeekday } from '@weareinreach/util/luxon/weekday' import { type TRPCHandlerParams } from '~api/types/handler' import { type TForHoursDrawerSchema } from './query.forHoursDrawer.schema' @@ -45,12 +46,12 @@ export const forHoursDrawer = async ({ input }: TRPCHandlerParams { + if (!HandlerCache.forEditDrawer) + HandlerCache.forEditDrawer = await import('./query.forEditDrawer.handler').then( + (mod) => mod.forEditDrawer + ) + if (!HandlerCache.forEditDrawer) throw new Error('Failed to load handler') + return HandlerCache.forEditDrawer({ ctx, input }) + }), + forContactInfoEdit: permissionedProcedure('updatePhone') + .input(schema.ZForContactInfoEditSchema) + .query(async ({ ctx, input }) => { + if (!HandlerCache.forContactInfoEdit) + HandlerCache.forContactInfoEdit = await import('./query.forContactInfoEdit.handler').then( + (mod) => mod.forContactInfoEdit + ) + if (!HandlerCache.forContactInfoEdit) throw new Error('Failed to load handler') + return HandlerCache.forContactInfoEdit({ ctx, input }) + }), }) diff --git a/packages/api/router/orgPhone/mutation.update.handler.ts b/packages/api/router/orgPhone/mutation.update.handler.ts index ec6e18ef0e..c8797d7a40 100644 --- a/packages/api/router/orgPhone/mutation.update.handler.ts +++ b/packages/api/router/orgPhone/mutation.update.handler.ts @@ -1,27 +1,43 @@ -import { prisma } from '@weareinreach/db' -import { CreateAuditLog } from '~api/schemas/create/auditLog' +import { generateFreeText, getAuditedClient } from '@weareinreach/db' import { type TRPCHandlerParams } from '~api/types/handler' import { type TUpdateSchema } from './mutation.update.schema' export const update = async ({ ctx, input }: TRPCHandlerParams) => { - const { where, data } = input - const updatedRecord = await prisma.$transaction(async (tx) => { - const current = await tx.orgPhone.findUniqueOrThrow({ where }) - const auditLogs = CreateAuditLog({ - actorId: ctx.session.user.id, - operation: 'UPDATE', - from: current, - to: data, - }) - const updated = await tx.orgPhone.update({ - where, - data: { - ...data, - auditLogs, - }, - }) - return updated + const prisma = getAuditedClient(ctx.actorId) + const { id, orgId, description, countryId, phoneTypeId, ...rest } = input + + const textData = description + ? generateFreeText({ orgId, type: 'phoneDesc', text: description, itemId: id }) + : undefined + + const updatedRecord = await prisma.orgPhone.update({ + where: { id }, + data: { + ...rest, + ...(textData + ? { + description: { + upsert: { + create: { + id: textData.freeText.id, + tsKey: { + connectOrCreate: { + where: { ns_key: { ns: textData.translationKey.ns, key: textData.translationKey.key } }, + create: textData.translationKey, + }, + }, + }, + update: { tsKey: { update: { text: textData.translationKey.text } } }, + }, + }, + } + : description === null + ? { description: { delete: true } } + : {}), + ...(countryId ? { country: { connect: { id: countryId } } } : {}), + ...(phoneTypeId ? { phoneType: { connect: { id: phoneTypeId } } } : {}), + }, }) return updatedRecord } diff --git a/packages/api/router/orgPhone/mutation.update.schema.ts b/packages/api/router/orgPhone/mutation.update.schema.ts index 42090693c9..6a29fa0ca8 100644 --- a/packages/api/router/orgPhone/mutation.update.schema.ts +++ b/packages/api/router/orgPhone/mutation.update.schema.ts @@ -1,23 +1,19 @@ import { z } from 'zod' -import { Prisma } from '@weareinreach/db' import { prefixedId } from '~api/schemas/idPrefix' -export const ZUpdateSchema = z - .object({ - id: prefixedId('orgPhone'), - data: z - .object({ - number: z.string(), - ext: z.string(), - primary: z.boolean(), - published: z.boolean(), - deleted: z.boolean(), - countryId: prefixedId('country'), - phoneTypeId: prefixedId('phoneType'), - locationOnly: z.boolean(), - }) - .partial(), - }) - .transform(({ data, id }) => Prisma.validator()({ where: { id }, data })) +export const ZUpdateSchema = z.object({ + id: prefixedId('orgPhone'), + orgId: prefixedId('organization'), + number: z.string().optional(), + ext: z.string().nullish(), + primary: z.boolean().optional(), + published: z.boolean().optional(), + deleted: z.boolean().optional(), + countryId: prefixedId('country').optional(), + phoneTypeId: prefixedId('phoneType').nullish(), + locationOnly: z.boolean().optional(), + serviceOnly: z.boolean().optional(), + description: z.string().nullish(), +}) export type TUpdateSchema = z.infer diff --git a/packages/api/router/orgPhone/query.forContactInfo.schema.ts b/packages/api/router/orgPhone/query.forContactInfo.schema.ts index 78411bcf3c..84b0607218 100644 --- a/packages/api/router/orgPhone/query.forContactInfo.schema.ts +++ b/packages/api/router/orgPhone/query.forContactInfo.schema.ts @@ -3,7 +3,7 @@ import { z } from 'zod' import { prefixedId } from '~api/schemas/idPrefix' export const ZForContactInfoSchema = z.object({ - parentId: z.union([prefixedId('organization'), prefixedId('orgLocation'), prefixedId('orgService')]), + parentId: prefixedId(['organization', 'orgLocation', 'orgService']), locationOnly: z.boolean().optional(), }) export type TForContactInfoSchema = z.infer diff --git a/packages/api/router/orgPhone/query.forContactInfoEdit.handler.ts b/packages/api/router/orgPhone/query.forContactInfoEdit.handler.ts new file mode 100644 index 0000000000..5ff2ebb1be --- /dev/null +++ b/packages/api/router/orgPhone/query.forContactInfoEdit.handler.ts @@ -0,0 +1,66 @@ +import { isIdFor, type Prisma, prisma } from '@weareinreach/db' +import { type TForContactInfoSchema } from '~api/router/orgEmail/query.forContactInfo.schema' +import { type TRPCHandlerParams } from '~api/types/handler' + +import { type TForContactInfoEditSchema } from './query.forContactInfoEdit.schema' + +const getWhereId = (input: TForContactInfoEditSchema, isSingleLoc?: boolean): Prisma.OrgPhoneWhereInput => { + switch (true) { + case isIdFor('organization', input.parentId): { + return isSingleLoc + ? { + OR: [ + { organization: { organization: { id: input.parentId } } }, + { locations: { some: { location: { organization: { id: input.parentId } } } } }, + ], + } + : { organization: { organization: { id: input.parentId } } } + } + case isIdFor('orgLocation', input.parentId): { + return { locations: { some: { location: { id: input.parentId } } } } + } + case isIdFor('orgService', input.parentId): { + return { services: { some: { service: { id: input.parentId } } } } + } + default: { + return {} + } + } +} + +export const forContactInfoEdit = async ({ input }: TRPCHandlerParams) => { + const locCount = isIdFor('organization', input.parentId) + ? await prisma.orgLocation.count({ + where: { organization: { id: input.parentId } }, + }) + : 0 + const isSingleLoc = locCount === 1 + + const whereId = getWhereId(input, isSingleLoc) + + const result = await prisma.orgPhone.findMany({ + where: { + ...whereId, + }, + select: { + id: true, + number: true, + ext: true, + country: { select: { cca2: true } }, + primary: true, + description: { select: { tsKey: { select: { text: true, key: true } } } }, + phoneType: { select: { key: { select: { text: true, key: true } } } }, + locationOnly: true, + published: true, + deleted: true, + }, + orderBy: [{ published: 'desc' }, { deleted: 'asc' }], + }) + const transformed = result.map(({ description, phoneType, country, ...record }) => ({ + ...record, + country: country?.cca2, + phoneType: phoneType ? { key: phoneType?.key.key, defaultText: phoneType?.key.text } : null, + description: description ? { key: description?.tsKey.key, defaultText: description?.tsKey.text } : null, + })) + return transformed +} diff --git a/packages/api/router/orgPhone/query.forContactInfoEdit.schema.ts b/packages/api/router/orgPhone/query.forContactInfoEdit.schema.ts new file mode 100644 index 0000000000..f45523ff6d --- /dev/null +++ b/packages/api/router/orgPhone/query.forContactInfoEdit.schema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod' + +import { prefixedId } from '~api/schemas/idPrefix' + +export const ZForContactInfoEditSchema = z.object({ + parentId: prefixedId(['organization', 'orgLocation', 'orgService']), +}) +export type TForContactInfoEditSchema = z.infer diff --git a/packages/api/router/orgPhone/query.forEditDrawer.handler.ts b/packages/api/router/orgPhone/query.forEditDrawer.handler.ts new file mode 100644 index 0000000000..883d98123c --- /dev/null +++ b/packages/api/router/orgPhone/query.forEditDrawer.handler.ts @@ -0,0 +1,69 @@ +import { prisma } from '@weareinreach/db' +import { handleError } from '~api/lib/errorHandler' +import { type TRPCHandlerParams } from '~api/types/handler' + +import { type TForEditDrawerSchema } from './query.forEditDrawer.schema' + +const getOrgId = async (phoneId: string) => { + const result = await prisma.orgPhone.findUniqueOrThrow({ + where: { id: phoneId }, + select: { + organization: { select: { organizationId: true } }, + locations: { select: { location: { select: { orgId: true } } } }, + services: { select: { service: { select: { organizationId: true } } } }, + }, + }) + + switch (true) { + case !!result.organization?.organizationId: { + return result.organization.organizationId + } + case result.locations.length !== 0 && + result.locations.filter((loc) => !!loc.location.orgId).length !== 0: { + const filtered = result.locations.filter((loc) => !!loc.location.orgId) + return filtered[0]!.location.orgId + } + case result.services.length !== 0 && + result.services.filter((serv) => !!serv.service.organizationId).length !== 0: { + const filtered = result.services.filter((serv) => !!serv.service.organizationId) + return filtered[0]!.service.organizationId! + } + default: { + throw new Error('Unable to get organizationId') + } + } +} + +export const forEditDrawer = async ({ input }: TRPCHandlerParams) => { + try { + const result = await prisma.orgPhone.findUniqueOrThrow({ + where: input, + select: { + id: true, + number: true, + ext: true, + primary: true, + published: true, + deleted: true, + countryId: true, + phoneTypeId: true, + description: { select: { id: true, key: true, tsKey: { select: { text: true } } } }, + locationOnly: true, + serviceOnly: true, + country: { select: { cca2: true } }, + }, + }) + const orgId = await getOrgId(input.id) + const { country, description, ...rest } = result + + const reformatted = { + ...rest, + description: description ? description?.tsKey?.text : null, + orgId, + country: country?.cca2, + } + return reformatted + } catch (error) { + handleError(error) + } +} diff --git a/packages/api/router/orgPhone/query.forEditDrawer.schema.ts b/packages/api/router/orgPhone/query.forEditDrawer.schema.ts new file mode 100644 index 0000000000..778f0f3668 --- /dev/null +++ b/packages/api/router/orgPhone/query.forEditDrawer.schema.ts @@ -0,0 +1,6 @@ +import { z } from 'zod' + +import { prefixedId } from '~api/schemas/idPrefix' + +export const ZForEditDrawerSchema = z.object({ id: prefixedId('orgPhone') }) +export type TForEditDrawerSchema = z.infer diff --git a/packages/api/router/orgPhone/schemas.ts b/packages/api/router/orgPhone/schemas.ts index 39890d6f7b..25e30dd020 100644 --- a/packages/api/router/orgPhone/schemas.ts +++ b/packages/api/router/orgPhone/schemas.ts @@ -3,5 +3,7 @@ export * from './mutation.create.schema' export * from './mutation.update.schema' export * from './mutation.upsertMany.schema' export * from './query.forContactInfo.schema' +export * from './query.forContactInfoEdit.schema' +export * from './query.forEditDrawer.schema' export * from './query.get.schema' // codegen:end diff --git a/packages/api/router/orgSocialMedia/index.ts b/packages/api/router/orgSocialMedia/index.ts index 687dfb3cdf..d68e82a9d3 100644 --- a/packages/api/router/orgSocialMedia/index.ts +++ b/packages/api/router/orgSocialMedia/index.ts @@ -7,6 +7,9 @@ type OrgSocialMediaHandlerCache = { create: typeof import('./mutation.create.handler').create update: typeof import('./mutation.update.handler').update forContactInfo: typeof import('./query.forContactInfo.handler').forContactInfo + forContactInfoEdits: typeof import('./query.forContactInfoEdits.handler').forContactInfoEdits + getServiceTypes: typeof import('./query.getServiceTypes.handler').getServiceTypes + forEditDrawer: typeof import('./query.forEditDrawer.handler').forEditDrawer } export const orgSocialMediaRouter = defineRouter({ create: permissionedProcedure('createNewSocial') @@ -33,4 +36,34 @@ export const orgSocialMediaRouter = defineRouter({ if (!HandlerCache.forContactInfo) throw new Error('Failed to load handler') return HandlerCache.forContactInfo({ ctx, input }) }), + forContactInfoEdits: permissionedProcedure('updateSocialMedia') + .input(schema.ZForContactInfoEditsSchema) + .query(async ({ ctx, input }) => { + if (!HandlerCache.forContactInfoEdits) + HandlerCache.forContactInfoEdits = await import('./query.forContactInfoEdits.handler').then( + (mod) => mod.forContactInfoEdits + ) + if (!HandlerCache.forContactInfoEdits) throw new Error('Failed to load handler') + return HandlerCache.forContactInfoEdits({ ctx, input }) + }), + getServiceTypes: permissionedProcedure('updateSocialMedia') + .input(schema.ZGetServiceTypesSchema) + .query(async ({ ctx, input }) => { + if (!HandlerCache.getServiceTypes) + HandlerCache.getServiceTypes = await import('./query.getServiceTypes.handler').then( + (mod) => mod.getServiceTypes + ) + if (!HandlerCache.getServiceTypes) throw new Error('Failed to load handler') + return HandlerCache.getServiceTypes({ ctx, input }) + }), + forEditDrawer: permissionedProcedure('updateSocialMedia') + .input(schema.ZForEditDrawerSchema) + .query(async ({ ctx, input }) => { + if (!HandlerCache.forEditDrawer) + HandlerCache.forEditDrawer = await import('./query.forEditDrawer.handler').then( + (mod) => mod.forEditDrawer + ) + if (!HandlerCache.forEditDrawer) throw new Error('Failed to load handler') + return HandlerCache.forEditDrawer({ ctx, input }) + }), }) diff --git a/packages/api/router/orgSocialMedia/mutation.update.handler.ts b/packages/api/router/orgSocialMedia/mutation.update.handler.ts index 1b2c52017a..137a13ff18 100644 --- a/packages/api/router/orgSocialMedia/mutation.update.handler.ts +++ b/packages/api/router/orgSocialMedia/mutation.update.handler.ts @@ -1,27 +1,29 @@ -import { prisma } from '@weareinreach/db' -import { CreateAuditLog } from '~api/schemas/create/auditLog' +import { getAuditedClient } from '@weareinreach/db' import { type TRPCHandlerParams } from '~api/types/handler' import { type TUpdateSchema } from './mutation.update.schema' export const update = async ({ ctx, input }: TRPCHandlerParams) => { const { where, data } = input - const updatedRecord = await prisma.$transaction(async (tx) => { - const current = await tx.orgSocialMedia.findUniqueOrThrow({ where }) - const auditLogs = CreateAuditLog({ - actorId: ctx.session.user.id, - operation: 'UPDATE', - from: current, - to: data, - }) - const updated = await tx.orgSocialMedia.update({ - where, - data: { - ...data, - auditLogs, + const prisma = getAuditedClient(ctx.actorId) + + const updated = await prisma.orgSocialMedia.update({ + where, + data, + select: { + id: true, + username: true, + url: true, + deleted: true, + published: true, + serviceId: true, + organizationId: true, + orgLocationId: true, + orgLocationOnly: true, + service: { + select: { id: true, name: true, logoIcon: true }, }, - }) - return updated + }, }) - return updatedRecord + return updated } diff --git a/packages/api/router/orgSocialMedia/mutation.update.schema.ts b/packages/api/router/orgSocialMedia/mutation.update.schema.ts index c87f08e25f..5dc8d374d9 100644 --- a/packages/api/router/orgSocialMedia/mutation.update.schema.ts +++ b/packages/api/router/orgSocialMedia/mutation.update.schema.ts @@ -13,8 +13,8 @@ export const ZUpdateSchema = z published: z.boolean(), deleted: z.boolean(), serviceId: z.string(), - organizationId: prefixedId('organization'), - orgLocationId: prefixedId('orgLocation'), + organizationId: prefixedId('organization').nullable(), + orgLocationId: prefixedId('orgLocation').nullable(), orgLocationOnly: z.boolean(), }) .partial(), diff --git a/packages/api/router/orgSocialMedia/query.forContactInfo.handler.ts b/packages/api/router/orgSocialMedia/query.forContactInfo.handler.ts index 11eb9e70e3..3567cdbaf8 100644 --- a/packages/api/router/orgSocialMedia/query.forContactInfo.handler.ts +++ b/packages/api/router/orgSocialMedia/query.forContactInfo.handler.ts @@ -44,13 +44,14 @@ export const forContactInfo = async ({ input }: TRPCHandlerParams ({ ...record, service: service?.name, + serviceIcon: service?.logoIcon, })) return transformed } diff --git a/packages/api/router/orgSocialMedia/query.forContactInfo.schema.ts b/packages/api/router/orgSocialMedia/query.forContactInfo.schema.ts index 36af3dfd1d..228ddc783f 100644 --- a/packages/api/router/orgSocialMedia/query.forContactInfo.schema.ts +++ b/packages/api/router/orgSocialMedia/query.forContactInfo.schema.ts @@ -3,7 +3,7 @@ import { z } from 'zod' import { prefixedId } from '~api/schemas/idPrefix' export const ZForContactInfoSchema = z.object({ - parentId: z.union([prefixedId('organization'), prefixedId('orgLocation')]), + parentId: prefixedId(['organization', 'orgLocation']), locationOnly: z.boolean().optional(), }) export type TForContactInfoSchema = z.infer diff --git a/packages/api/router/orgSocialMedia/query.forContactInfoEdits.handler.ts b/packages/api/router/orgSocialMedia/query.forContactInfoEdits.handler.ts new file mode 100644 index 0000000000..93f5331b9e --- /dev/null +++ b/packages/api/router/orgSocialMedia/query.forContactInfoEdits.handler.ts @@ -0,0 +1,43 @@ +import { isIdFor, prisma, type Prisma } from '@weareinreach/db' +import { handleError } from '~api/lib/errorHandler' +import { type TRPCHandlerParams } from '~api/types/handler' + +import { type TForContactInfoEditsSchema } from './query.forContactInfoEdits.schema' + +const whereId = (input: TForContactInfoEditsSchema): Prisma.OrgSocialMediaWhereInput => { + switch (true) { + case isIdFor('organization', input.parentId): { + return { organization: { id: input.parentId } } + } + case isIdFor('orgLocation', input.parentId): { + return { orgLocation: { id: input.parentId } } + } + default: { + return {} + } + } +} +export const forContactInfoEdits = async ({ input }: TRPCHandlerParams) => { + try { + const result = await prisma.orgSocialMedia.findMany({ + where: whereId(input), + select: { + id: true, + url: true, + username: true, + service: { select: { name: true, logoIcon: true } }, + orgLocationOnly: true, + published: true, + deleted: true, + }, + }) + const transformed = result.map(({ service, ...record }) => ({ + ...record, + service: service?.name, + serviceIcon: service?.logoIcon, + })) + return transformed + } catch (error) { + handleError(error) + } +} diff --git a/packages/api/router/orgSocialMedia/query.forContactInfoEdits.schema.ts b/packages/api/router/orgSocialMedia/query.forContactInfoEdits.schema.ts new file mode 100644 index 0000000000..9fe6756581 --- /dev/null +++ b/packages/api/router/orgSocialMedia/query.forContactInfoEdits.schema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod' + +import { prefixedId } from '~api/schemas/idPrefix' + +export const ZForContactInfoEditsSchema = z.object({ + parentId: prefixedId(['organization', 'orgLocation']), +}) +export type TForContactInfoEditsSchema = z.infer diff --git a/packages/api/router/orgSocialMedia/query.forEditDrawer.handler.ts b/packages/api/router/orgSocialMedia/query.forEditDrawer.handler.ts new file mode 100644 index 0000000000..3d2fcfc35c --- /dev/null +++ b/packages/api/router/orgSocialMedia/query.forEditDrawer.handler.ts @@ -0,0 +1,30 @@ +import { prisma } from '@weareinreach/db' +import { handleError } from '~api/lib/errorHandler' +import { type TRPCHandlerParams } from '~api/types/handler' + +import { type TForEditDrawerSchema } from './query.forEditDrawer.schema' + +export const forEditDrawer = async ({ ctx, input }: TRPCHandlerParams) => { + try { + const result = await prisma.orgSocialMedia.findUniqueOrThrow({ + where: input, + select: { + id: true, + username: true, + url: true, + deleted: true, + published: true, + serviceId: true, + organizationId: true, + orgLocationId: true, + orgLocationOnly: true, + service: { + select: { id: true, name: true, logoIcon: true }, + }, + }, + }) + return result + } catch (error) { + handleError(error) + } +} diff --git a/packages/api/router/orgSocialMedia/query.forEditDrawer.schema.ts b/packages/api/router/orgSocialMedia/query.forEditDrawer.schema.ts new file mode 100644 index 0000000000..36bf638f7a --- /dev/null +++ b/packages/api/router/orgSocialMedia/query.forEditDrawer.schema.ts @@ -0,0 +1,6 @@ +import { z } from 'zod' + +import { prefixedId } from '~api/schemas/idPrefix' + +export const ZForEditDrawerSchema = z.object({ id: prefixedId('orgSocialMedia') }) +export type TForEditDrawerSchema = z.infer diff --git a/packages/api/router/orgSocialMedia/query.getServiceTypes.handler.ts b/packages/api/router/orgSocialMedia/query.getServiceTypes.handler.ts new file mode 100644 index 0000000000..2c4e38e193 --- /dev/null +++ b/packages/api/router/orgSocialMedia/query.getServiceTypes.handler.ts @@ -0,0 +1,23 @@ +import { prisma } from '@weareinreach/db' +import { handleError } from '~api/lib/errorHandler' +import { type TRPCHandlerParams } from '~api/types/handler' + +import { type TGetServiceTypesSchema } from './query.getServiceTypes.schema' + +export const getServiceTypes = async ({ ctx, input }: TRPCHandlerParams) => { + try { + const result = await prisma.socialMediaService.findMany({ + where: input, + select: { + id: true, + active: true, + logoIcon: true, + name: true, + urlBase: true, + }, + }) + return result + } catch (error) { + handleError(error) + } +} diff --git a/packages/api/router/orgSocialMedia/query.getServiceTypes.schema.ts b/packages/api/router/orgSocialMedia/query.getServiceTypes.schema.ts new file mode 100644 index 0000000000..6b61901b93 --- /dev/null +++ b/packages/api/router/orgSocialMedia/query.getServiceTypes.schema.ts @@ -0,0 +1,7 @@ +import { z } from 'zod' + +export const ZGetServiceTypesSchema = z + .object({ active: z.boolean(), internal: z.boolean() }) + .partial() + .default({ active: true, internal: false }) +export type TGetServiceTypesSchema = z.infer diff --git a/packages/api/router/orgSocialMedia/schemas.ts b/packages/api/router/orgSocialMedia/schemas.ts index 57e4fa8f25..97058e6434 100644 --- a/packages/api/router/orgSocialMedia/schemas.ts +++ b/packages/api/router/orgSocialMedia/schemas.ts @@ -2,4 +2,7 @@ export * from './mutation.create.schema' export * from './mutation.update.schema' export * from './query.forContactInfo.schema' +export * from './query.forContactInfoEdits.schema' +export * from './query.forEditDrawer.schema' +export * from './query.getServiceTypes.schema' // codegen:end diff --git a/packages/api/router/orgWebsite/index.ts b/packages/api/router/orgWebsite/index.ts index c180c06f1a..9912a25e38 100644 --- a/packages/api/router/orgWebsite/index.ts +++ b/packages/api/router/orgWebsite/index.ts @@ -7,6 +7,8 @@ type OrgWebsiteHandlerCache = { create: typeof import('./mutation.create.handler').create update: typeof import('./mutation.update.handler').update forContactInfo: typeof import('./query.forContactInfo.handler').forContactInfo + forEditDrawer: typeof import('./query.forEditDrawer.handler').forEditDrawer + forContactInfoEdit: typeof import('./query.forContactInfoEdit.handler').forContactInfoEdit } export const orgWebsiteRouter = defineRouter({ create: permissionedProcedure('createOrgWebsite') @@ -33,4 +35,24 @@ export const orgWebsiteRouter = defineRouter({ if (!HandlerCache.forContactInfo) throw new Error('Failed to load handler') return HandlerCache.forContactInfo({ ctx, input }) }), + forEditDrawer: permissionedProcedure('updateOrgWebsite') + .input(schema.ZForEditDrawerSchema) + .query(async ({ ctx, input }) => { + if (!HandlerCache.forEditDrawer) + HandlerCache.forEditDrawer = await import('./query.forEditDrawer.handler').then( + (mod) => mod.forEditDrawer + ) + if (!HandlerCache.forEditDrawer) throw new Error('Failed to load handler') + return HandlerCache.forEditDrawer({ ctx, input }) + }), + forContactInfoEdit: permissionedProcedure('updateOrgWebsite') + .input(schema.ZForContactInfoEditSchema) + .query(async ({ ctx, input }) => { + if (!HandlerCache.forContactInfoEdit) + HandlerCache.forContactInfoEdit = await import('./query.forContactInfoEdit.handler').then( + (mod) => mod.forContactInfoEdit + ) + if (!HandlerCache.forContactInfoEdit) throw new Error('Failed to load handler') + return HandlerCache.forContactInfoEdit({ ctx, input }) + }), }) diff --git a/packages/api/router/orgWebsite/mutation.update.handler.ts b/packages/api/router/orgWebsite/mutation.update.handler.ts index bfa43e3f12..a12f54f8a4 100644 --- a/packages/api/router/orgWebsite/mutation.update.handler.ts +++ b/packages/api/router/orgWebsite/mutation.update.handler.ts @@ -1,27 +1,14 @@ -import { prisma } from '@weareinreach/db' -import { CreateAuditLog } from '~api/schemas/create/auditLog' +import { getAuditedClient } from '@weareinreach/db' import { type TRPCHandlerParams } from '~api/types/handler' import { type TUpdateSchema } from './mutation.update.schema' export const update = async ({ ctx, input }: TRPCHandlerParams) => { const { where, data } = input - const updatedRecord = await prisma.$transaction(async (tx) => { - const current = await tx.orgWebsite.findUniqueOrThrow({ where }) - const auditLogs = CreateAuditLog({ - actorId: ctx.session.user.id, - operation: 'UPDATE', - from: current, - to: data, - }) - const updated = await tx.orgWebsite.update({ - where, - data: { - ...data, - auditLogs, - }, - }) - return updated + const prisma = getAuditedClient(ctx.actorId) + const updated = await prisma.orgWebsite.update({ + where, + data, }) - return updatedRecord + return updated } diff --git a/packages/api/router/orgWebsite/query.forContactInfo.schema.ts b/packages/api/router/orgWebsite/query.forContactInfo.schema.ts index 36af3dfd1d..228ddc783f 100644 --- a/packages/api/router/orgWebsite/query.forContactInfo.schema.ts +++ b/packages/api/router/orgWebsite/query.forContactInfo.schema.ts @@ -3,7 +3,7 @@ import { z } from 'zod' import { prefixedId } from '~api/schemas/idPrefix' export const ZForContactInfoSchema = z.object({ - parentId: z.union([prefixedId('organization'), prefixedId('orgLocation')]), + parentId: prefixedId(['organization', 'orgLocation']), locationOnly: z.boolean().optional(), }) export type TForContactInfoSchema = z.infer diff --git a/packages/api/router/orgWebsite/query.forContactInfoEdit.handler.ts b/packages/api/router/orgWebsite/query.forContactInfoEdit.handler.ts new file mode 100644 index 0000000000..f2358ecff4 --- /dev/null +++ b/packages/api/router/orgWebsite/query.forContactInfoEdit.handler.ts @@ -0,0 +1,44 @@ +import { isIdFor, prisma, type Prisma } from '@weareinreach/db' +import { handleError } from '~api/lib/errorHandler' +import { type TRPCHandlerParams } from '~api/types/handler' + +import { type TForContactInfoEditSchema } from './query.forContactInfoEdit.schema' + +const whereId = (input: TForContactInfoEditSchema): Prisma.OrgWebsiteWhereInput => { + switch (true) { + case isIdFor('organization', input.parentId): { + return { organization: { id: input.parentId } } + } + case isIdFor('orgLocation', input.parentId): { + return { orgLocation: { id: input.parentId } } + } + + default: { + return {} + } + } +} +export const forContactInfoEdit = async ({ ctx, input }: TRPCHandlerParams) => { + try { + const result = await prisma.orgWebsite.findMany({ + where: { + ...whereId(input), + }, + select: { + id: true, + url: true, + description: { select: { tsKey: { select: { text: true, key: true } } } }, + published: true, + deleted: true, + }, + orderBy: [{ published: 'desc' }, { deleted: 'asc' }], + }) + const transformed = result.map(({ description, ...record }) => ({ + ...record, + description: description ? { key: description?.tsKey.key, defaultText: description?.tsKey.text } : null, + })) + return transformed + } catch (error) { + handleError(error) + } +} diff --git a/packages/api/router/orgWebsite/query.forContactInfoEdit.schema.ts b/packages/api/router/orgWebsite/query.forContactInfoEdit.schema.ts new file mode 100644 index 0000000000..89f8de59b6 --- /dev/null +++ b/packages/api/router/orgWebsite/query.forContactInfoEdit.schema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod' + +import { prefixedId } from '~api/schemas/idPrefix' + +export const ZForContactInfoEditSchema = z.object({ + parentId: prefixedId(['organization', 'orgLocation']), +}) +export type TForContactInfoEditSchema = z.infer diff --git a/packages/api/router/orgWebsite/query.forEditDrawer.handler.ts b/packages/api/router/orgWebsite/query.forEditDrawer.handler.ts new file mode 100644 index 0000000000..d1f868338d --- /dev/null +++ b/packages/api/router/orgWebsite/query.forEditDrawer.handler.ts @@ -0,0 +1,23 @@ +import { prisma } from '@weareinreach/db' +import { handleError } from '~api/lib/errorHandler' +import { type TRPCHandlerParams } from '~api/types/handler' + +import { type TForEditDrawerSchema } from './query.forEditDrawer.schema' + +export const forEditDrawer = async ({ input }: TRPCHandlerParams) => { + try { + const result = await prisma.orgWebsite.findUniqueOrThrow({ + where: input, + include: { + description: { include: { tsKey: true } }, + }, + }) + const reformatted = { + ...result, + description: result.description?.tsKey?.text, + } + return reformatted + } catch (error) { + handleError(error) + } +} diff --git a/packages/api/router/orgWebsite/query.forEditDrawer.schema.ts b/packages/api/router/orgWebsite/query.forEditDrawer.schema.ts new file mode 100644 index 0000000000..f62a286958 --- /dev/null +++ b/packages/api/router/orgWebsite/query.forEditDrawer.schema.ts @@ -0,0 +1,6 @@ +import { z } from 'zod' + +import { prefixedId } from '~api/schemas/idPrefix' + +export const ZForEditDrawerSchema = z.object({ id: prefixedId('orgWebsite') }) +export type TForEditDrawerSchema = z.infer diff --git a/packages/api/router/orgWebsite/schemas.ts b/packages/api/router/orgWebsite/schemas.ts index 57e4fa8f25..c0f965271c 100644 --- a/packages/api/router/orgWebsite/schemas.ts +++ b/packages/api/router/orgWebsite/schemas.ts @@ -2,4 +2,6 @@ export * from './mutation.create.schema' export * from './mutation.update.schema' export * from './query.forContactInfo.schema' +export * from './query.forContactInfoEdit.schema' +export * from './query.forEditDrawer.schema' // codegen:end diff --git a/packages/api/router/organization/index.ts b/packages/api/router/organization/index.ts index 3c27ba3648..69e0035adb 100644 --- a/packages/api/router/organization/index.ts +++ b/packages/api/router/organization/index.ts @@ -25,6 +25,7 @@ type OrganizationHandlerCache = { getIntlCrisis: typeof import('./query.getIntlCrisis.handler').getIntlCrisis getNatlCrisis: typeof import('./query.getNatlCrisis.handler').getNatlCrisis forOrganizationTable: typeof import('./query.forOrganizationTable.handler').forOrganizationTable + forOrgPageEdits: typeof import('./query.forOrgPageEdits.handler').forOrgPageEdits getAttributes: typeof import('./query.getAttributes.handler').getAttributes getAlerts: typeof import('./query.getAlerts.handler').getAlerts // #endregion @@ -36,6 +37,7 @@ type OrganizationHandlerCache = { createNewQuick: typeof import('./mutation.createNewQuick.handler').createNewQuick createNewSuggestion: typeof import('./mutation.createNewSuggestion.handler').createNewSuggestion attachAttribute: typeof import('./mutation.attachAttribute.handler').attachAttribute + updateBasic: typeof import('./mutation.updateBasic.handler').updateBasic // #endregion } @@ -176,6 +178,15 @@ export const orgRouter = defineRouter({ if (!HandlerCache.forOrganizationTable) throw new Error('Failed to load handler') return HandlerCache.forOrganizationTable({ ctx, input }) }), + forOrgPageEdits: publicProcedure.input(schema.ZForOrgPageEditsSchema).query(async ({ ctx, input }) => { + if (!HandlerCache.forOrgPageEdits) + HandlerCache.forOrgPageEdits = await import('./query.forOrgPageEdits.handler').then( + (mod) => mod.forOrgPageEdits + ) + + if (!HandlerCache.forOrgPageEdits) throw new Error('Failed to load handler') + return HandlerCache.forOrgPageEdits({ ctx, input }) + }), getAttributes: publicProcedure.input(schema.ZGetAttributesSchema).query(async ({ ctx, input }) => { if (!HandlerCache.getAttributes) HandlerCache.getAttributes = await import('./query.getAttributes.handler').then( @@ -228,6 +239,15 @@ export const orgRouter = defineRouter({ if (!HandlerCache.attachAttribute) throw new Error('Failed to load handler') return HandlerCache.attachAttribute({ ctx, input }) }), - + updateBasic: permissionedProcedure('createNewOrgQuick') + .input(schema.ZUpdateBasicSchema) + .mutation(async ({ ctx, input }) => { + if (!HandlerCache.updateBasic) + HandlerCache.updateBasic = await import('./mutation.updateBasic.handler').then( + (mod) => mod.updateBasic + ) + if (!HandlerCache.updateBasic) throw new Error('Failed to load handler') + return HandlerCache.updateBasic({ ctx, input }) + }), // #endregion }) diff --git a/packages/api/router/organization/lib.ts b/packages/api/router/organization/lib.ts deleted file mode 100644 index 1ac24328e2..0000000000 --- a/packages/api/router/organization/lib.ts +++ /dev/null @@ -1,26 +0,0 @@ -// import slugify from 'slugify' - -// import { type Context } from '~api/lib/context' - -// export const uniqueSlug = async (ctx: Context, name: string, inc?: number): Promise => { -// try { -// const check = async (slug: string) => { -// const existing = await ctx.prisma.organization.findUnique({ -// where: { -// slug, -// }, -// select: { -// slug: true, -// }, -// }) -// return !existing?.slug -// } -// const generatedSlug = slugify(inc ? `${name} ${inc}` : name, { lower: true, strict: true, trim: true }) -// const isUnique = await check(generatedSlug) -// if (isUnique) return generatedSlug -// else return await uniqueSlug(ctx, name, (inc ?? 0) + 1) -// } catch (error) { -// console.error(error) -// throw error -// } -// } diff --git a/packages/api/router/organization/mutation.updateBasic.handler.ts b/packages/api/router/organization/mutation.updateBasic.handler.ts new file mode 100644 index 0000000000..f1809df7f8 --- /dev/null +++ b/packages/api/router/organization/mutation.updateBasic.handler.ts @@ -0,0 +1,66 @@ +import { crowdinApi, getStringIdByKey, projectId } from '@weareinreach/crowdin/api' +import { + generateFreeText, + generateId, + generateUniqueSlug, + getAuditedClient, + type Prisma, +} from '@weareinreach/db' +import { isVercelProd } from '@weareinreach/env' +import { createLoggerInstance } from '@weareinreach/util/logger' +import { handleError } from '~api/lib/errorHandler' +import { type TRPCHandlerParams } from '~api/types/handler' + +import { type TUpdateBasicSchema } from './mutation.updateBasic.schema' + +const logger = createLoggerInstance('api - organization.updateBasic') +export const updateBasic = async ({ ctx, input }: TRPCHandlerParams) => { + try { + const prisma = getAuditedClient(ctx.actorId) + const data: Prisma.OrganizationUpdateInput = {} + const existing = await prisma.organization.findUniqueOrThrow({ + where: { id: input.id }, + select: { slug: true, description: { select: { tsKey: { select: { crowdinId: true, key: true } } } } }, + }) + + if (input.name) { + const newSlug = await generateUniqueSlug({ name: input.name, id: input.id }) + data.name = input.name + data.slug = newSlug + data.oldSlugs = { create: { from: existing.slug, to: newSlug, id: generateId('slugRedirect') } } + } + if (input.description) { + // TODO: [IN-920] Handle new string creation in Crowdin + const newText = generateFreeText({ orgId: input.id, type: 'orgDesc', text: input.description }) + data.description = { + upsert: { + update: { tsKey: { update: { text: input.description } } }, + create: { id: newText.freeText.id, tsKey: { create: newText.translationKey } }, + }, + } + } + const update = await prisma.organization.update({ + where: { id: input.id }, + data, + }) + if (update && input.description && existing.description) { + const stringId = + existing.description.tsKey.crowdinId || + (await getStringIdByKey(existing.description?.tsKey.key, true)) + if (stringId) { + if (isVercelProd) { + await crowdinApi.sourceStringsApi.editString(projectId, stringId, [ + { op: 'replace', path: '/text', value: input.description }, + ]) + } else { + logger.info( + `\n==========\nSkipping Crowdin Update - Not production environment.\nCrowdin String ID: ${stringId}. Updated Description: ${input.description}\n==========` + ) + } + } + } + return update + } catch (error) { + handleError(error) + } +} diff --git a/packages/api/router/organization/mutation.updateBasic.schema.ts b/packages/api/router/organization/mutation.updateBasic.schema.ts new file mode 100644 index 0000000000..9e06891231 --- /dev/null +++ b/packages/api/router/organization/mutation.updateBasic.schema.ts @@ -0,0 +1,11 @@ +import { z } from 'zod' + +import { prefixedId } from '~api/schemas/idPrefix' + +export const ZUpdateBasicSchema = z.object({ + id: prefixedId('organization'), + name: z.string().optional(), + description: z.string().optional(), +}) + +export type TUpdateBasicSchema = z.infer diff --git a/packages/api/router/organization/query.forOrgPageEdits.handler.ts b/packages/api/router/organization/query.forOrgPageEdits.handler.ts new file mode 100644 index 0000000000..7d68ca3709 --- /dev/null +++ b/packages/api/router/organization/query.forOrgPageEdits.handler.ts @@ -0,0 +1,46 @@ +import { prisma } from '@weareinreach/db' +import { attributes, freeText } from '~api/schemas/selects/common' +import { type TRPCHandlerParams } from '~api/types/handler' + +import { type TForOrgPageEditsSchema } from './query.forOrgPageEdits.schema' + +export const forOrgPageEdits = async ({ input }: TRPCHandlerParams) => { + const { slug } = input + const org = await prisma.organization.findUniqueOrThrow({ + where: { + slug, + }, + select: { + id: true, + name: true, + slug: true, + published: true, + deleted: true, + lastVerified: true, + allowedEditors: { where: { authorized: true }, select: { userId: true } }, + description: freeText, + attributes, + reviews: { + select: { id: true }, + }, + locations: { + select: { + id: true, + street1: true, + street2: true, + city: true, + postCode: true, + country: { select: { cca2: true } }, + govDist: { select: { abbrev: true, tsKey: true, tsNs: true } }, + }, + }, + }, + }) + const { allowedEditors, ...orgData } = org + const reformatted = { + ...orgData, + isClaimed: Boolean(allowedEditors.length), + } + + return reformatted +} diff --git a/packages/api/router/organization/query.forOrgPageEdits.schema.ts b/packages/api/router/organization/query.forOrgPageEdits.schema.ts new file mode 100644 index 0000000000..e508a676cc --- /dev/null +++ b/packages/api/router/organization/query.forOrgPageEdits.schema.ts @@ -0,0 +1,4 @@ +import { z } from 'zod' + +export const ZForOrgPageEditsSchema = z.object({ slug: z.string() }) +export type TForOrgPageEditsSchema = z.infer diff --git a/packages/api/router/organization/query.searchDistance.handler.ts b/packages/api/router/organization/query.searchDistance.handler.ts index b3a6d8ec28..87eb74ee1e 100644 --- a/packages/api/router/organization/query.searchDistance.handler.ts +++ b/packages/api/router/organization/query.searchDistance.handler.ts @@ -78,6 +78,7 @@ service_area as ( SELECT country."geoDataId" as "countryGeoId", district."geoDataId" as "districtGeoId", + district.slug AS "districtSlug", country.cca3 as "cca3", sa."organizationId", sa."orgLocationId" @@ -112,7 +113,9 @@ service_area as ( ST_Distance(ST_Transform(loc.geo, 3857), (SELECT meters FROM points))::int ) ) AS distance, - ARRAY_REMOVE(ARRAY_AGG(DISTINCT sa.cca3), NULL) AS "national" + ARRAY_REMOVE(ARRAY_AGG(DISTINCT sa.cca3), NULL) AS "national", + ARRAY_LENGTH(ARRAY_REMOVE(ARRAY_AGG(DISTINCT sa.cca3), NULL),1) is not NULL AS "isNational", + ARRAY_REMOVE(ARRAY_AGG(DISTINCT sa.cca3) || ARRAY_AGG( DISTINCT sa."districtSlug"), NULL) AS "serviceAreas" FROM "OrgLocation" loc INNER JOIN "Organization" org ON org.id = loc. "orgId" LEFT JOIN service_area sa ON sa. "organizationId" = loc. "orgId" @@ -165,9 +168,10 @@ type SearchResult = { matchedAttributes?: string[] distance: string national: string[] + isNational: boolean + serviceAreas: string[] total: string } - const prismaDistSearchDetails = async (input: TSearchDistanceSchema & { resultIds: string[] }) => { const { resultIds, lat: latitude, lon: longitude } = input const results = await prisma.organization.findMany({ diff --git a/packages/api/router/organization/schemas.ts b/packages/api/router/organization/schemas.ts index f0cccf2c7b..b4882ce871 100644 --- a/packages/api/router/organization/schemas.ts +++ b/packages/api/router/organization/schemas.ts @@ -2,10 +2,12 @@ export * from './mutation.attachAttribute.schema' export * from './mutation.createNewQuick.schema' export * from './mutation.createNewSuggestion.schema' +export * from './mutation.updateBasic.schema' export * from './query.checkForExisting.schema' export * from './query.forLocationPage.schema' export * from './query.forOrganizationTable.schema' export * from './query.forOrgPage.schema' +export * from './query.forOrgPageEdits.schema' export * from './query.generateSlug.schema' export * from './query.getAlerts.schema' export * from './query.getAttributes.schema' diff --git a/packages/api/router/service/query.forServiceInfoCard.schema.ts b/packages/api/router/service/query.forServiceInfoCard.schema.ts index 09f03d7ffb..38db5f94cb 100644 --- a/packages/api/router/service/query.forServiceInfoCard.schema.ts +++ b/packages/api/router/service/query.forServiceInfoCard.schema.ts @@ -3,7 +3,7 @@ import { z } from 'zod' import { prefixedId } from '~api/schemas/idPrefix' export const ZForServiceInfoCardSchema = z.object({ - parentId: prefixedId('organization').or(prefixedId('orgLocation')), + parentId: prefixedId(['organization', 'orgLocation']), remoteOnly: z.boolean().optional(), }) export type TForServiceInfoCardSchema = z.infer diff --git a/packages/api/schemas/idPrefix.ts b/packages/api/schemas/idPrefix.ts index da27f13186..82b4f0b41c 100644 --- a/packages/api/schemas/idPrefix.ts +++ b/packages/api/schemas/idPrefix.ts @@ -2,4 +2,9 @@ import { z } from 'zod' import { type IdPrefix, idPrefix } from '@weareinreach/db/lib/idGen' -export const prefixedId = (model: IdPrefix) => z.string().regex(new RegExp(`^${idPrefix[model]}_\\w+$`)) +export const prefixedId = (model: IdPrefix | IdPrefix[]) => { + const regEx = Array.isArray(model) + ? new RegExp(`^(?:${model.map((m) => idPrefix[m]).join('|')})_\\w+$`) + : new RegExp(`^${idPrefix[model]}_\\w+$`) + return z.string().regex(regEx) +} diff --git a/packages/db/index.ts b/packages/db/index.ts index d8f376a950..4651f4c117 100644 --- a/packages/db/index.ts +++ b/packages/db/index.ts @@ -5,6 +5,10 @@ export * from './zod_util' export { setAuditId } from './lib/audit' export { slug, generateUniqueSlug } from './lib/slugGen' export { createPoint, createPointOrNull, createGeoFields } from './lib/createPoint' -export { generateFreeText, generateNestedFreeText } from './lib/generateFreeText' +export { + generateFreeText, + generateNestedFreeText, + generateNestedFreeTextUpsert, +} from './lib/generateFreeText' export { generateId, getIdPrefixRegex, isIdFor } from './lib/idGen' export { PrismaInstrumentation } from '@prisma/instrumentation' diff --git a/packages/db/lib/generateFreeText.ts b/packages/db/lib/generateFreeText.ts index 4390412970..65aabeb1ab 100644 --- a/packages/db/lib/generateFreeText.ts +++ b/packages/db/lib/generateFreeText.ts @@ -39,7 +39,7 @@ export const generateFreeText = ({ } })() const ns = namespaces.orgData - if (!key) throw new Error('Error creating key') + invariant(key, 'Error creating key') return { translationKey: Prisma.validator()({ key, text, ns }), freeText: Prisma.validator()({ @@ -78,7 +78,7 @@ export const generateNestedFreeText = ({ } } })() - invariant(key) + invariant(key, 'Error creating key') const ns = namespaces.orgData return { create: { @@ -88,11 +88,64 @@ export const generateNestedFreeText = ({ } } +export const generateNestedFreeTextUpsert = ({ + orgId: orgSlug, + itemId, + text, + type, + freeTextId, +}: GenerateFreeTextParams) => { + const key = (() => { + switch (type) { + case 'orgDesc': { + return createKey([orgSlug, 'description']) + } + case 'attSupp': { + invariant(itemId) + return createKey([orgSlug, 'attribute', itemId]) + } + case 'svcName': { + invariant(itemId) + return createKey([orgSlug, itemId, 'name']) + } + case 'websiteDesc': + case 'phoneDesc': + case 'emailDesc': + case 'svcDesc': { + invariant(itemId) + return createKey([orgSlug, itemId, 'description']) + } + } + })() + invariant(key, 'Error creating key') + const ns = namespaces.orgData + + const id = freeTextId ?? generateId('freeText') + return { + upsert: { + where: { id }, + create: { + id, + tsKey: { create: { key, text, namespace: { connect: { name: ns } } } }, + }, + update: { + tsKey: { + upsert: { + where: { key }, + create: { key, text, namespace: { connect: { name: ns } } }, + update: { text }, + }, + }, + }, + }, + } +} + type GenerateFreeTextParams = GenerateFreeTextWithItem interface GenerateFreeTextBase { text: string orgId: string - freeTextId?: string + freeTextId?: string | null } type GenerateFreeTextType = diff --git a/packages/db/prisma/data-migrations/2023-09-28_phone-number-normalization/!prep.ts b/packages/db/prisma/data-migrations/2023-09-28_phone-number-normalization/!prep.ts new file mode 100644 index 0000000000..1cd8777a00 --- /dev/null +++ b/packages/db/prisma/data-migrations/2023-09-28_phone-number-normalization/!prep.ts @@ -0,0 +1,168 @@ +import { + findPhoneNumbersInText, + isSupportedCountry, + parsePhoneNumber, + parsePhoneNumberFromString, + searchPhoneNumbersInText, +} from 'libphonenumber-js' + +import fs from 'fs' +import path from 'path' + +import { prisma } from '~db/client' + +function lettersToPhoneNumber(input: string) { + // convert lowercase string to uppercase since we compare against uppercase; + // then split string into an array of letters; + const searchStr = input.toUpperCase().split('') + + // create virtual keypad, associating alpha characters with a single digit; + const keyPads = [ + { + alpha: ['A', 'B', 'C'], + digit: 2, + }, + { + alpha: ['D', 'E', 'F'], + digit: 3, + }, + { + alpha: ['G', 'H', 'I'], + digit: 4, + }, + { + alpha: ['J', 'K', 'L'], + digit: 5, + }, + { + alpha: ['M', 'N', 'O'], + digit: 6, + }, + { + alpha: ['P', 'Q', 'R', 'S'], + digit: 7, + }, + { + alpha: ['T', 'U', 'V'], + digit: 8, + }, + { + alpha: ['W', 'X', 'Y', 'Z'], + digit: 9, + }, + // { + // alpha: [' '], + // digit: 0, + // }, + ] + + // declare our results as an array; + const result: (number | undefined)[] = [] + + // iterate over each letter of the string + searchStr.forEach((letter) => { + // filters the virtual keypads for the current letter; + // since we're creating a single digit array, + // we immediately select [0].digit to return the digit corresponding with the letter; + const testDigit = !isNaN(parseInt(letter)) + if (testDigit) { + result.push(parseInt(letter)) + return + } else if (['-', '(', ')'].includes(letter)) { + return + } + const digit = keyPads.filter((keyPad) => keyPad.alpha.indexOf(letter) > -1)[0]?.digit + + // using the spread operator, we concatenate a new array; + result.push(digit) + }) + + // we return the numbers as a number using the Array.prototype.join method nested within parseInt; + return parseInt(result.join('')) +} + +const run = async () => { + const output: Output[] = [] + const rejected: Exception[] = [] + const exceptions: Exception[] = [] + const data = await prisma.orgPhone.findMany({ + include: { + country: { select: { cca2: true } }, + organization: { select: { organization: { select: { name: true, id: true } } } }, + locations: { select: { location: { select: { country: { select: { cca2: true } } } } } }, + }, + }) + + for (const item of data) { + const country = isSupportedCountry(item.country.cca2) ? item.country.cca2 : undefined + const letters2digits = lettersToPhoneNumber(item.number) + try { + const parsed = + // parsePhoneNumber(item.number, country) || + findPhoneNumbersInText(letters2digits.toString(), { defaultCountry: country }) + if (parsed.length) { + for (const phone of parsed) { + if (phone.number.isValid()) { + output.push({ + id: item.id, + number: phone.number.nationalNumber, + countryCode: phone.number.countryCallingCode, + }) + } else { + rejected.push({ id: item.id, number: phone.number.number, country: item.country.cca2 }) + } + } + } else { + const parsed = parsePhoneNumber(item.number, country) + const isValid = parsed.isValid() //isValidNumberForRegion(parsed.nationalNumber, parsed.country) + if (parsed && isValid) { + output.push({ id: item.id, number: parsed.nationalNumber, countryCode: parsed.countryCallingCode }) + } else { + rejected.push({ + id: item.id, + number: item.number, + country: item.country.cca2, + orgName: item.organization?.organization.name, + orgId: item.organization?.organization.id, + locations: item.locations.map((l) => l.location.country.cca2), + }) + } + } + } catch (e) { + console.error(e) + const x = findPhoneNumbersInText(item.number, { defaultCountry: country }) + const letters2digits = lettersToPhoneNumber(item.number) + rejected.push({ + id: item.id, + number: item.number, + country: item.country.cca2, + orgName: item.organization?.organization.name, + orgId: item.organization?.organization.id, + locations: item.locations.map((l) => l.location.country.cca2), + }) + } + } + for (const item of rejected) { + console.log(item) + } + + fs.writeFileSync(path.resolve(__dirname, './output.json'), JSON.stringify(output)) + fs.writeFileSync(path.resolve(__dirname, './exceptions.json'), JSON.stringify(rejected)) +} + +run() + +type Output = { + id: string + number: string + countryCode: string +} + +type Exception = { + id: string + number: string + country: string + orgName?: string + orgId?: string + locations?: string[] +} diff --git a/packages/db/prisma/data-migrations/!YYYY-MM-DD_job-template.ts b/packages/db/prisma/data-migrations/2023-09-28_phone-number-normalization/index.ts similarity index 76% rename from packages/db/prisma/data-migrations/!YYYY-MM-DD_job-template.ts rename to packages/db/prisma/data-migrations/2023-09-28_phone-number-normalization/index.ts index fd0c4b91a3..60f5fa94bb 100644 --- a/packages/db/prisma/data-migrations/!YYYY-MM-DD_job-template.ts +++ b/packages/db/prisma/data-migrations/2023-09-28_phone-number-normalization/index.ts @@ -5,22 +5,14 @@ import { createLogger, type JobDef, jobPostRunner } from '~db/prisma/jobPreRun' /** Define the job metadata here. */ const jobDef: JobDef = { - jobId: 'yyyy-mm-dd-shortDescription', - title: 'Descriptive Title', - createdBy: 'Your Name', + jobId: '2023-09-28-phone-number-normalization', + title: 'phone number normalization', + createdBy: 'Joe Karow', /** Optional: Longer description for the job */ description: undefined, } -/** - * Job export - this variable MUST be UNIQUE - * - * Use the format `jobYYYYMMDD` and append a letter afterwards if there is already a job with this name. - * - * @example `job20230404` - * - * @example `job20230404b` - */ -export const jobYYYYmmDD = { +/** Job export - this variable MUST be UNIQUE */ +export const job20230928_phone_number_normalization = { title: `[${jobDef.jobId}] ${jobDef.title}`, task: async (_ctx, task) => { /** Create logging instance */ diff --git a/packages/db/prisma/data-migrations/index.ts b/packages/db/prisma/data-migrations/index.ts index 2aab52e39c..f1a5da56d3 100644 --- a/packages/db/prisma/data-migrations/index.ts +++ b/packages/db/prisma/data-migrations/index.ts @@ -53,6 +53,7 @@ export * from './2023-09-20_fix-access-instructions/index' export * from './2023-09-20_update-services' export * from './2023-09-22_service-areas' export * from './2023-09-22_update-alerts/index' +export * from './2023-09-28_phone-number-normalization/index' export * from './2023-10-23_add-suggested-orgs/index' export * from './2023-10-25_service-tag-updates' export * from './2023-10-27_tlc-service-updates' diff --git a/packages/db/prisma/views/public/pg_cache_hit_rate.sql b/packages/db/prisma/views/public/pg_cache_hit_rate.sql new file mode 100644 index 0000000000..9eb8e150ee --- /dev/null +++ b/packages/db/prisma/views/public/pg_cache_hit_rate.sql @@ -0,0 +1,10 @@ +SELECT + sum(pg_statio_user_tables.heap_blks_read) AS heap_read, + sum(pg_statio_user_tables.heap_blks_hit) AS heap_hit, + ( + ( + sum(pg_statio_user_tables.heap_blks_hit) - sum(pg_statio_user_tables.heap_blks_read) + ) / sum(pg_statio_user_tables.heap_blks_hit) + ) AS ratio +FROM + pg_statio_user_tables; \ No newline at end of file diff --git a/packages/db/prisma/views/public/pg_index_usage.sql b/packages/db/prisma/views/public/pg_index_usage.sql new file mode 100644 index 0000000000..309375a916 --- /dev/null +++ b/packages/db/prisma/views/public/pg_index_usage.sql @@ -0,0 +1,49 @@ +SELECT + t.tablename AS relation, + foo.indexname, + c.reltuples AS num_rows, + pg_size_pretty( + pg_relation_size((quote_ident((t.tablename) :: text)) :: regclass) + ) AS table_size, + pg_size_pretty( + pg_relation_size( + (quote_ident((foo.indexrelname) :: text)) :: regclass + ) + ) AS index_size, + foo.idx_scan AS number_of_scans, + foo.idx_tup_read AS tuples_read, + foo.idx_tup_fetch AS tuples_fetched +FROM + ( + ( + pg_tables t + LEFT JOIN pg_class c ON ((t.tablename = c.relname)) + ) + LEFT JOIN ( + SELECT + c_1.relname AS ctablename, + ipg.relname AS indexname, + x.indnatts AS number_of_columns, + psai.idx_scan, + psai.idx_tup_read, + psai.idx_tup_fetch, + psai.indexrelname, + x.indisunique + FROM + ( + ( + ( + pg_index x + JOIN pg_class c_1 ON ((c_1.oid = x.indrelid)) + ) + JOIN pg_class ipg ON ((ipg.oid = x.indexrelid)) + ) + JOIN pg_stat_all_indexes psai ON ((x.indexrelid = psai.indexrelid)) + ) + ) foo ON ((t.tablename = foo.ctablename)) + ) +WHERE + (t.schemaname = 'public' :: name) +ORDER BY + t.tablename, + foo.indexname; \ No newline at end of file diff --git a/packages/db/prisma/views/public/pg_index_usage_rate.sql b/packages/db/prisma/views/public/pg_index_usage_rate.sql new file mode 100644 index 0000000000..2dd4f1aecc --- /dev/null +++ b/packages/db/prisma/views/public/pg_index_usage_rate.sql @@ -0,0 +1,12 @@ +SELECT + pg_stat_user_tables.relname, + ( + (100 * pg_stat_user_tables.idx_scan) / ( + pg_stat_user_tables.seq_scan + pg_stat_user_tables.idx_scan + ) + ) AS percent_of_times_index_used, + pg_stat_user_tables.n_live_tup AS rows_in_table +FROM + pg_stat_user_tables +ORDER BY + pg_stat_user_tables.n_live_tup DESC; \ No newline at end of file diff --git a/packages/env/index.ts b/packages/env/index.ts index 0dd6092a7b..d27a30ebf7 100644 --- a/packages/env/index.ts +++ b/packages/env/index.ts @@ -89,3 +89,6 @@ export const env = createEnv({ }) export const getEnv = (envVar: T): (typeof env)[T] => env[envVar] + +export const isDev = process.env.NODE_ENV === 'development' +export const isVercelProd = process.env.VERCEL_ENV === 'production' diff --git a/packages/ui/.storybook/i18next.ts b/packages/ui/.storybook/i18next.ts index 2e6c32a836..6d4d03215d 100644 --- a/packages/ui/.storybook/i18next.ts +++ b/packages/ui/.storybook/i18next.ts @@ -1,5 +1,7 @@ import i18n from 'i18next' import LanguageDetector from 'i18next-browser-languagedetector' +// @ts-expect-error It is a valid package.. +import { HMRPlugin } from 'i18next-hmr/plugin' import HttpApi, { type HttpBackendOptions } from 'i18next-http-backend' import intervalPlural from 'i18next-intervalplural-postprocessor' import { initReactI18next } from 'react-i18next' @@ -15,6 +17,7 @@ i18n .use(LanguageDetector) .use(HttpApi) .use(initReactI18next) + .use(new HMRPlugin({ webpack: { client: true } })) .init({ ...config, debug: false, diff --git a/packages/ui/.storybook/main.ts b/packages/ui/.storybook/main.ts index 9212ba4197..e069661de1 100644 --- a/packages/ui/.storybook/main.ts +++ b/packages/ui/.storybook/main.ts @@ -2,6 +2,8 @@ import { type StorybookConfig } from '@storybook/nextjs' import isChromatic from 'chromatic/isChromatic' import dotenv from 'dotenv' +// @ts-expect-error It is a valid package.. +import { I18NextHMRPlugin } from 'i18next-hmr/webpack' import { mergeAndConcat } from 'merge-anything' import { type PropItem } from 'react-docgen-typescript' @@ -38,6 +40,7 @@ const config: StorybookConfig = { getAbsolutePath('@storybook/addon-designs'), getAbsolutePath('storybook-addon-pseudo-states'), getAbsolutePath('@storybook/addon-interactions'), + '@storybook/addon-webpack5-compiler-swc', ], framework: { name: '@storybook/nextjs', @@ -89,16 +92,25 @@ const config: StorybookConfig = { 'mockAuthStates.ts' ), 'next-i18next': 'react-i18next', + 'msw/native': path.resolve(__dirname, '../node_modules/msw/lib/native/index.mjs'), }, - roots: Array.isArray(config.resolve?.roots) - ? [...config.resolve.roots, publicStatic] - : [publicStatic], + roots: [publicStatic], }, stats: { colors: true, }, devtool: options.configType === 'DEVELOPMENT' ? 'eval-source-map' : undefined, } + + /** I18 HMR */ + if (options.configType === 'DEVELOPMENT') { + const plugin = new I18NextHMRPlugin({ + localesDir: path.resolve(__dirname, '../../../apps/app/public/locales'), + }) + + Array.isArray(config.plugins) ? config.plugins.push(plugin) : (config.plugins = [plugin]) + } + const mergedConfig = mergeAndConcat(config, configAdditions) return mergedConfig }, @@ -109,6 +121,9 @@ const config: StorybookConfig = { ? { SKIP_ENV_VALIDATION: 'true', } - : { NEXT_PUBLIC_GOOGLE_MAPS_API: process.env.NEXT_PUBLIC_GOOGLE_MAPS_API as string }, + : { + NEXT_PUBLIC_GOOGLE_MAPS_API: process.env.NEXT_PUBLIC_GOOGLE_MAPS_API as string, + STORYBOOK_PROJECT_ROOT: path.resolve(__dirname, '../'), + }, } export default config diff --git a/packages/ui/.swcrc b/packages/ui/.swcrc index bfa87d4f85..fa29756c88 100644 --- a/packages/ui/.swcrc +++ b/packages/ui/.swcrc @@ -12,10 +12,10 @@ "refresh": true } }, - "target": "es2020", "loose": false, - "externalHelpers": false, + "externalHelpers": true, "keepClassNames": false }, - "minify": false + "minify": false, + "sourceMaps": true } diff --git a/packages/ui/.vscode/settings.json b/packages/ui/.vscode/settings.json index 53e471d98d..a2c3de751c 100644 --- a/packages/ui/.vscode/settings.json +++ b/packages/ui/.vscode/settings.json @@ -1,3 +1,6 @@ { - "i18n-ally.localesPaths": "../../apps/app/public/locales" + "i18n-ally.localesPaths": "../../apps/app/public/locales", + "[json]": { + "editor.codeActionsOnSave": { "source.fixAll": "never" } + } } diff --git a/packages/ui/babel.config.json b/packages/ui/babel.config.json deleted file mode 100644 index 962d9e03e8..0000000000 --- a/packages/ui/babel.config.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "sourceType": "unambiguous", - "presets": [ - "@babel/preset-env", - "@babel/preset-typescript", - [ - "@babel/preset-react", - { - "runtime": "automatic", - "useSpread": true - } - ] - ], - "plugins": [], - "targets": { - "chrome": 100, - "esmodules": true - } -} diff --git a/packages/ui/components/core/Badge.tsx b/packages/ui/components/core/Badge.tsx index b9d193f6ac..9578c8996e 100644 --- a/packages/ui/components/core/Badge.tsx +++ b/packages/ui/components/core/Badge.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/display-name */ import { type BadgeProps, type BadgeStylesNames, @@ -476,7 +475,7 @@ export const Badge = forwardRef { const variants = useCustomVariant() const theme = useMantineTheme() diff --git a/packages/ui/components/core/Breadcrumb.tsx b/packages/ui/components/core/Breadcrumb.tsx index 6f3005bbca..0962b3d1d5 100644 --- a/packages/ui/components/core/Breadcrumb.tsx +++ b/packages/ui/components/core/Breadcrumb.tsx @@ -35,7 +35,7 @@ const useStyles = createStyles((theme) => ({ const isString = (...args: unknown[]) => args.every((val) => typeof val === 'string') export const Breadcrumb = (props: BreadcrumbProps) => { - const { option, backTo, backToText, onClick } = props + const { option, backTo, backToText, onClick, children } = props const { classes } = useStyles() const theme = useMantineTheme() const { t } = useTranslation('common') @@ -125,7 +125,7 @@ export const Breadcrumb = (props: BreadcrumbProps) => { className={classes.icon} /> - {childrenRender} + {children || childrenRender} @@ -150,7 +150,7 @@ type PossibleBreadcrumbProps = { export type ModalTitleBreadcrumb = Omit & { onClick?: MouseEventHandler } -export type BreadcrumbProps = Close | Back | BackToDynamic +export type BreadcrumbProps = (Close | Back | BackToDynamic) & { children?: React.ReactNode } interface Close { option: 'close' onClick: MouseEventHandler diff --git a/packages/ui/components/data-display/ContactInfo.tsx b/packages/ui/components/data-display/ContactInfo.tsx deleted file mode 100644 index cd30b4432c..0000000000 --- a/packages/ui/components/data-display/ContactInfo.tsx +++ /dev/null @@ -1,310 +0,0 @@ -import { Stack, Text, Title } from '@mantine/core' -import { useTranslation } from 'next-i18next' -import { type JSX } from 'react' - -import { type ApiOutput } from '@weareinreach/api' -import { isExternal, Link } from '~ui/components/core/Link' -import { isSocialIcon, SocialLink, type SocialLinkProps } from '~ui/components/core/SocialLink' -import { parsePhoneNumber, useCustomVariant, useSlug } from '~ui/hooks' -import { trpc as api } from '~ui/lib/trpcClient' - -const PhoneNumbers = ({ parentId = '', passedData, direct, locationOnly }: PhoneNumbersProps) => { - const output: JSX.Element[] = [] - const slug = useSlug() - const { data: orgId } = api.organization.getIdFromSlug.useQuery({ slug }) - const { t } = useTranslation(orgId?.id ? ['common', 'phone-type', orgId.id] : ['common', 'phone-type']) - const variants = useCustomVariant() - const { data } = api.orgPhone.forContactInfo.useQuery({ parentId, locationOnly }, { enabled: !passedData }) - let k = 0 - - const componentData = passedData ? passedData : data - - if (!componentData?.length) return null - - for (const phone of componentData) { - const { country, ext, locationOnly: showLocationOnly, number, phoneType, primary, description } = phone - const parsedPhone = parsePhoneNumber(number, country) - if (!parsedPhone) continue - if (ext) parsedPhone.setExt(ext) - const dialURL = parsedPhone.getURI() - const phoneNumber = parsedPhone.formatNational() - if (direct) { - return ( - - {t('direct.phone')} - {isExternal(dialURL) ? ( - - {phoneNumber} - - ) : ( - {phoneNumber} - )} - - ) - } - if (locationOnly && !showLocationOnly) continue - const desc = description - ? t(description.key, { ns: orgId?.id, defaultValue: description.defaultText }) - : phoneType?.key - ? t(phoneType.key, { ns: 'phone-type' }) - : undefined - - const item = ( - - {isExternal(dialURL) ? ( - - {phoneNumber} - - ) : ( - {phoneNumber} - )} - {desc && {desc}} - - ) - primary ? output.unshift(item) : output.push(item) - k++ - } - return ( - - {t('words.phone')} - {output} - - ) -} - -const Emails = ({ parentId = '', passedData, direct, locationOnly, serviceOnly }: EmailsProps) => { - const output: JSX.Element[] = [] - const slug = useSlug() - const { data: orgId } = api.organization.getIdFromSlug.useQuery({ slug }) - const { t } = useTranslation(orgId?.id ? ['common', orgId.id, 'user-title'] : ['common', 'user-title']) - const variants = useCustomVariant() - const { data } = api.orgEmail.forContactInfo.useQuery( - { parentId, locationOnly, serviceOnly }, - { enabled: !passedData } - ) - let k = 0 - - const componentData = passedData ? passedData : data - - if (!componentData?.length) return null - - for (const email of componentData) { - const { - primary, - title, - description, - email: address, - locationOnly: showLocOnly, - serviceOnly: showServOnly, - } = email - if ((locationOnly && !showLocOnly) || (serviceOnly && !showServOnly)) continue - - const href = `mailto:${address}` - if (!isExternal(href)) continue - if (direct) { - return ( - - {t('direct.email')} - - {address} - - - ) - } - const desc = title - ? t(title.key, { ns: 'user-title' }) - : description?.key - ? t(description.key, { defaultValue: description.defaultText, ns: orgId?.id }) - : undefined - - const item = ( - - - {address} - - {desc && {desc}} - - ) - primary ? output.unshift(item) : output.push(item) - k++ - } - return ( - - {t('words.email')} - {output} - - ) -} - -const Websites = ({ parentId = '', passedData, direct, locationOnly, websiteDesc }: WebsitesProps) => { - const output: JSX.Element[] = [] - const slug = useSlug() - const { data: orgId } = api.organization.getIdFromSlug.useQuery({ slug }) - const { t } = useTranslation(orgId?.id ? ['common', orgId.id] : ['common']) - const variants = useCustomVariant() - const { data } = api.orgWebsite.forContactInfo.useQuery( - { parentId, locationOnly }, - { enabled: !passedData } - ) - // eslint-disable-next-line no-useless-escape - const domainExtract = /https?:\/\/([^:\/\n?]+)/ - - const componentData = passedData ? passedData : data - - if (!componentData?.length) return null - - for (const website of componentData) { - const { id, url, orgLocationOnly, description, isPrimary } = website - const urlMatch = url.match(domainExtract) - const urlBase = urlMatch?.length ? urlMatch[1] : undefined - if (!isExternal(url)) continue - if (!urlBase) continue - if (locationOnly && !orgLocationOnly) continue - - if (direct) { - return ( - - {t('direct.website')} - - {urlBase} - - - ) - } - - const desc = websiteDesc - ? description?.key - ? t(description.key, { ns: orgId?.id, defaultText: description.defaultText }) - : urlBase - : urlBase - const item = ( - - {desc} - - ) - isPrimary ? output.unshift(item) : output.push(item) - } - - if (!output.length) return null - - return ( - - {t('website', { count: output.length })} - {output} - - ) -} - -const SocialMedia = ({ parentId = '', passedData, locationOnly }: SocialMediaProps) => { - const { data } = api.orgSocialMedia.forContactInfo.useQuery( - { parentId, locationOnly }, - { enabled: !passedData } - ) - - const componentData = passedData ? passedData : data - - if (!componentData?.length) return null - const items: SocialLinkProps[] = [] - - for (const item of componentData) { - const icon = item.service.toLowerCase() - if (!isSocialIcon(icon)) continue - items.push({ - icon, - href: item.url, - title: item.username, - }) - } - if (!items.length) return null - return -} - -export const ContactInfo = ({ - passedData, - parentId, - order = ['website', 'phone', 'email', 'socialMedia'], - gap = 24, - ...commonProps -}: ContactInfoProps) => { - const sections: ContactMap = { - website: ( - - ), - phone: ( - - ), - email: ( - - ), - socialMedia: ( - - ), - } - const items = order.map((item) => sections[item]) - return {items} -} - -export const hasContactInfo = (data: PassedDataObject) => { - const { websites, phones, emails, socialMedia } = data - return Boolean(websites.length || phones.length || emails.length || socialMedia.length) -} - -type ContactSections = 'phone' | 'email' | 'website' | 'socialMedia' - -type ContactMap = { - [K in ContactSections]: JSX.Element -} - -export type ContactInfoProps = CommonProps & { - order?: ContactSections[] - gap?: number -} & (ApiData | PassedDataProps) - -interface CommonProps { - direct?: boolean - locationOnly?: boolean - serviceOnly?: boolean - websiteDesc?: boolean -} - -type PhoneNumbersProps = CommonProps & (ApiData | PassedData<'orgPhone', 'forContactInfo'>) -type WebsitesProps = CommonProps & (ApiData | PassedData<'orgWebsite', 'forContactInfo'>) - -type EmailsProps = CommonProps & (ApiData | PassedData<'orgEmail', 'forContactInfo'>) -type SocialMediaProps = CommonProps & (ApiData | PassedData<'orgSocialMedia', 'forContactInfo'>) - -type PassedData = { - passedData: ApiOutput[K1][K2] - parentId?: never -} - -export interface PassedDataObject { - phones: ApiOutput['orgPhone']['forContactInfo'] - emails: ApiOutput['orgEmail']['forContactInfo'] - websites: ApiOutput['orgWebsite']['forContactInfo'] - socialMedia: ApiOutput['orgSocialMedia']['forContactInfo'] -} -type ApiData = { - parentId: string - passedData?: never -} -type PassedDataProps = { - passedData: PassedDataObject - parentId?: never -} diff --git a/packages/ui/components/data-display/ContactInfo/Emails.tsx b/packages/ui/components/data-display/ContactInfo/Emails.tsx new file mode 100644 index 0000000000..64dda8b119 --- /dev/null +++ b/packages/ui/components/data-display/ContactInfo/Emails.tsx @@ -0,0 +1,153 @@ +import { Group, Stack, Text, Title, useMantineTheme } from '@mantine/core' +import { useTranslation } from 'next-i18next' +import { type ReactElement } from 'react' + +import { isExternal, Link } from '~ui/components/core/Link' +import { EmailDrawer } from '~ui/components/data-portal/EmailDrawer' +import { useCustomVariant } from '~ui/hooks/useCustomVariant' +import { useOrgInfo } from '~ui/hooks/useOrgInfo' +import { useSlug } from '~ui/hooks/useSlug' +import { Icon } from '~ui/icon' +import { trpc as api } from '~ui/lib/trpcClient' + +import { useCommonStyles } from './common.styles' +import { type EmailsProps } from './types' + +export const Emails = ({ edit, ...props }: EmailsProps) => + edit ? : + +const EmailsDisplay = ({ parentId = '', passedData, direct, locationOnly, serviceOnly }: EmailsProps) => { + const output: ReactElement[] = [] + const { id: orgId } = useOrgInfo() + const { t } = useTranslation(orgId ? ['common', orgId, 'user-title'] : ['common', 'user-title']) + const variants = useCustomVariant() + const { data } = api.orgEmail.forContactInfo.useQuery( + { parentId, locationOnly, serviceOnly }, + { enabled: !passedData } + ) + let k = 0 + + const componentData = passedData ? passedData : data + + if (!componentData?.length) return null + + for (const email of componentData) { + const { + primary, + title, + description, + email: address, + locationOnly: showLocOnly, + serviceOnly: showServOnly, + } = email + if ((locationOnly && !showLocOnly) || (serviceOnly && !showServOnly)) continue + + const href = `mailto:${address}` + if (!isExternal(href)) continue + if (direct) { + return ( + + {t('direct.email')} + + {address} + + + ) + } + const desc = title + ? t(title.key, { ns: 'user-title' }) + : description?.key + ? t(description.key, { defaultValue: description.defaultText, ns: orgId }) + : undefined + + const item = ( + + + {address} + + {desc && {desc}} + + ) + primary ? output.unshift(item) : output.push(item) + k++ + } + return ( + + {t('words.email')} + {output} + + ) +} + +const EmailsEdit = ({ parentId = '' }: EmailsProps) => { + const output: ReactElement[] = [] + const slug = useSlug() + const { data: orgId } = api.organization.getIdFromSlug.useQuery({ slug }) + const { t } = useTranslation(orgId?.id ? ['common', orgId.id, 'user-title'] : ['common', 'user-title']) + const variants = useCustomVariant() + const theme = useMantineTheme() + const { classes } = useCommonStyles() + const { data } = api.orgEmail.forContactInfoEdit.useQuery({ parentId }) + + if (!data?.length) return null + + for (const email of data) { + const { primary, title, description, email: address, published, deleted, id } = email + + const desc = title + ? t(title.key, { ns: 'user-title' }) + : description?.key + ? t(description.key, { defaultValue: description.defaultText, ns: orgId?.id }) + : undefined + + const renderItem = () => { + switch (true) { + case deleted: { + return { + email: ( + + {address} + + ), + desc: desc ? {desc} : null, + } + } + case !published: { + return { + email: ( + + + {address} + + ), + desc: desc ? {desc} : null, + } + } + default: { + return { + email: {address}, + desc: desc ? {desc} : null, + } + } + } + } + + const item = ( + + + {renderItem().email} + + {renderItem().desc} + + ) + output.push(item) + } + return ( + + {t('words.email')} + + {output} + + + ) +} diff --git a/packages/ui/components/data-display/ContactInfo/PhoneNumbers.tsx b/packages/ui/components/data-display/ContactInfo/PhoneNumbers.tsx new file mode 100644 index 0000000000..6a92531697 --- /dev/null +++ b/packages/ui/components/data-display/ContactInfo/PhoneNumbers.tsx @@ -0,0 +1,159 @@ +import { Group, Stack, Text, Title, useMantineTheme } from '@mantine/core' +import { useTranslation } from 'next-i18next' +import { type ReactElement } from 'react' + +import { isExternal, Link } from '~ui/components/core/Link' +import { PhoneDrawer } from '~ui/components/data-portal/PhoneDrawer' +import { useCustomVariant } from '~ui/hooks/useCustomVariant' +import { parsePhoneNumber } from '~ui/hooks/usePhoneNumber' +import { useSlug } from '~ui/hooks/useSlug' +import { Icon } from '~ui/icon' +import { trpc as api } from '~ui/lib/trpcClient' + +import { useCommonStyles } from './common.styles' +import { type PhoneNumbersProps } from './types' + +export const PhoneNumbers = ({ edit, ...props }: PhoneNumbersProps) => + edit ? : + +const PhoneNumbersDisplay = ({ parentId = '', passedData, direct, locationOnly }: PhoneNumbersProps) => { + const output: ReactElement[] = [] + const slug = useSlug() + const { data: orgId } = api.organization.getIdFromSlug.useQuery({ slug }) + const { t } = useTranslation(orgId?.id ? ['common', 'phone-type', orgId.id] : ['common', 'phone-type']) + const variants = useCustomVariant() + const { data } = api.orgPhone.forContactInfo.useQuery({ parentId, locationOnly }, { enabled: !passedData }) + + const componentData = passedData ? passedData : data + + if (!componentData?.length) return null + + for (const phone of componentData) { + const { country, ext, locationOnly: showLocationOnly, number, phoneType, primary, description } = phone + const parsedPhone = parsePhoneNumber(number, country) + if (!parsedPhone) continue + if (ext) parsedPhone.setExt(ext) + const dialURL = parsedPhone.getURI() + const phoneNumber = parsedPhone.formatNational() + if (direct) { + return ( + + {t('direct.phone')} + {isExternal(dialURL) ? ( + + {phoneNumber} + + ) : ( + {phoneNumber} + )} + + ) + } + if (locationOnly && !showLocationOnly) continue + const desc = description + ? t(description.key, { ns: orgId?.id, defaultValue: description.defaultText }) + : phoneType?.key + ? t(phoneType.key, { ns: 'phone-type' }) + : undefined + + const item = ( + + {isExternal(dialURL) ? ( + + {phoneNumber} + + ) : ( + {phoneNumber} + )} + {desc && {desc}} + + ) + primary ? output.unshift(item) : output.push(item) + } + return ( + + {t('words.phone')} + {output} + + ) +} + +const PhoneNumbersEdit = ({ parentId = '' }: PhoneNumbersProps) => { + const output: ReactElement[] = [] + const slug = useSlug() + const { data: orgId } = api.organization.getIdFromSlug.useQuery({ slug }) + const { t } = useTranslation(orgId?.id ? ['common', 'phone-type', orgId.id] : ['common', 'phone-type']) + const variants = useCustomVariant() + const { data } = api.orgPhone.forContactInfoEdit.useQuery({ parentId }) + const theme = useMantineTheme() + const { classes } = useCommonStyles() + + if (!data?.length) return null + + for (const phone of data) { + const { country, ext, number, phoneType, primary, description } = phone + const parsedPhone = parsePhoneNumber(number, country) + if (!parsedPhone) continue + if (ext) parsedPhone.setExt(ext) + + const phoneNumber = parsedPhone.formatNational() + + const desc = description + ? t(description.key, { ns: orgId?.id, defaultValue: description.defaultText }) + : phoneType?.key + ? t(phoneType.key, { ns: 'phone-type' }) + : undefined + + const renderItem = () => { + switch (true) { + case phone.deleted: { + return { + number: ( + + {phoneNumber} + + ), + desc: desc ? {desc} : null, + } + } + case !phone.published: { + return { + number: ( + + + {phoneNumber} + + ), + desc: desc ? {desc} : null, + } + } + default: { + return { + number: {phoneNumber}, + desc: desc ? {desc} : null, + } + } + } + } + + const itemDisplay = renderItem() + + const item = ( + + + {itemDisplay.number} + + {itemDisplay.desc} + + ) + primary ? output.unshift(item) : output.push(item) + } + return ( + + {t('words.phone')} + + {output} + + + ) +} diff --git a/packages/ui/components/data-display/ContactInfo/SocialMedia.tsx b/packages/ui/components/data-display/ContactInfo/SocialMedia.tsx new file mode 100644 index 0000000000..220381e02b --- /dev/null +++ b/packages/ui/components/data-display/ContactInfo/SocialMedia.tsx @@ -0,0 +1,108 @@ +import { Group, List, Stack, Text, Title, useMantineTheme } from '@mantine/core' +import { useTranslation } from 'next-i18next' + +import { Link } from '~ui/components/core/Link' +import { isSocialIcon, SocialLink, type SocialLinkProps } from '~ui/components/core/SocialLink' +import { SocialMediaDrawer } from '~ui/components/data-portal/SocialMediaDrawer' +import { useCustomVariant } from '~ui/hooks/useCustomVariant' +import { Icon } from '~ui/icon' +import { trpc as api } from '~ui/lib/trpcClient' + +import { useCommonStyles } from './common.styles' +import { type SocialMediaProps } from './types' + +export const SocialMedia = ({ edit, ...props }: SocialMediaProps) => + edit ? : + +const SocialMediaDisplay = ({ parentId = '', passedData, locationOnly }: SocialMediaProps) => { + const { data } = api.orgSocialMedia.forContactInfo.useQuery( + { parentId, locationOnly }, + { enabled: !passedData } + ) + + const componentData = passedData ? passedData : data + + if (!componentData?.length) return null + const items: SocialLinkProps[] = [] + + for (const item of componentData) { + const icon = item.service.toLowerCase() + if (!isSocialIcon(icon)) continue + items.push({ + icon, + href: item.url, + title: item.username, + }) + } + if (!items.length) return null + return +} + +const SocialMediaEdit = ({ parentId = '' }: SocialMediaProps) => { + const { data } = api.orgSocialMedia.forContactInfoEdits.useQuery({ parentId }) + const { t } = useTranslation(['common']) + const { classes } = useCommonStyles() + const variants = useCustomVariant() + const theme = useMantineTheme() + if (!data?.length) return null + + return ( + + {t('social.group-header')} + + + {data.map((link) => { + const renderItem = () => { + switch (true) { + case link.deleted: { + return ( + + + {link.service} + + ) + } + case !link.published: { + return ( + + + + + {link.service} + + + ) + } + default: { + return ( + + + {link.service} + + ) + } + } + } + + return ( + + + {renderItem()} + + + ) + })} + + + + ) +} diff --git a/packages/ui/components/data-display/ContactInfo/Websites.tsx b/packages/ui/components/data-display/ContactInfo/Websites.tsx new file mode 100644 index 0000000000..813e0551fe --- /dev/null +++ b/packages/ui/components/data-display/ContactInfo/Websites.tsx @@ -0,0 +1,138 @@ +import { Group, Stack, Text, Title, useMantineTheme } from '@mantine/core' +import { useTranslation } from 'next-i18next' +import { type ReactElement } from 'react' + +import { isExternal, Link } from '~ui/components/core/Link' +import { WebsiteDrawer } from '~ui/components/data-portal/WebsiteDrawer' +import { useCustomVariant } from '~ui/hooks/useCustomVariant' +import { useSlug } from '~ui/hooks/useSlug' +import { Icon } from '~ui/icon' +import { trpc as api } from '~ui/lib/trpcClient' + +import { useCommonStyles } from './common.styles' +import { type WebsitesProps } from './types' + +export const Websites = ({ edit, ...props }: WebsitesProps) => + edit ? : + +const WebsitesDisplay = ({ parentId = '', passedData, direct, locationOnly, websiteDesc }: WebsitesProps) => { + const output: ReactElement[] = [] + const slug = useSlug() + const { data: orgId } = api.organization.getIdFromSlug.useQuery({ slug }) + const { t } = useTranslation(orgId?.id ? ['common', orgId.id] : ['common']) + const variants = useCustomVariant() + const { data } = api.orgWebsite.forContactInfo.useQuery( + { parentId, locationOnly }, + { enabled: !passedData } + ) + // eslint-disable-next-line no-useless-escape + const domainExtract = /https?:\/\/([^:\/\n?]+)/ + + const componentData = passedData ? passedData : data + + if (!componentData?.length) return null + + for (const website of componentData) { + const { id, url, orgLocationOnly, description, isPrimary } = website + const urlMatch = url.match(domainExtract) + const urlBase = urlMatch?.length ? urlMatch[1] : undefined + if (!isExternal(url)) continue + if (!urlBase) continue + if (locationOnly && !orgLocationOnly) continue + + if (direct) { + return ( + + {t('direct.website')} + + {urlBase} + + + ) + } + + const desc = websiteDesc + ? description?.key + ? t(description.key, { ns: orgId?.id, defaultText: description.defaultText }) + : urlBase + : urlBase + const item = ( + + {desc} + + ) + isPrimary ? output.unshift(item) : output.push(item) + } + + if (!output.length) return null + + return ( + + {t('website', { count: output.length })} + {output} + + ) +} + +const WebsitesEdit = ({ parentId = '' }: WebsitesProps) => { + const output: ReactElement[] = [] + const slug = useSlug() + const { data: orgId } = api.organization.getIdFromSlug.useQuery({ slug }) + const { t } = useTranslation(orgId?.id ? ['common', orgId.id] : ['common']) + const variants = useCustomVariant() + const theme = useMantineTheme() + const { classes } = useCommonStyles() + const { data } = api.orgWebsite.forContactInfoEdit.useQuery({ parentId }) + // eslint-disable-next-line no-useless-escape + const domainExtract = /https?:\/\/([^:\/\n?]+)/ + + if (!data?.length) return null + + for (const website of data) { + const { id, url, description, published, deleted } = website + const urlMatch = url.match(domainExtract) + const urlBase = urlMatch?.length ? urlMatch[1] : undefined + if (!isExternal(url)) continue + if (!urlBase) continue + const desc = description?.key + ? t(description.key, { ns: orgId?.id, defaultText: description.defaultText }) + : urlBase + + const renderItem = () => { + switch (true) { + case deleted: { + return {desc} + } + case !published: { + return ( + + + {desc} + + ) + } + default: { + return {desc} + } + } + } + + const item = ( + + {renderItem()} + + ) + output.push(item) + } + + if (!output.length) return null + + return ( + + {t('website', { count: output.length })} + + {output} + + + ) +} diff --git a/packages/ui/components/data-display/ContactInfo/common.styles.ts b/packages/ui/components/data-display/ContactInfo/common.styles.ts new file mode 100644 index 0000000000..c112a1fc42 --- /dev/null +++ b/packages/ui/components/data-display/ContactInfo/common.styles.ts @@ -0,0 +1,10 @@ +import { createStyles, rem } from '@mantine/core' + +export const useCommonStyles = createStyles((theme) => ({ + overlay: { + backgroundColor: theme.fn.lighten(theme.other.colors.secondary.teal, 0.9), + borderRadius: rem(16), + margin: rem(-8), + padding: rem(8), + }, +})) diff --git a/packages/ui/components/data-display/ContactInfo.stories.tsx b/packages/ui/components/data-display/ContactInfo/index.stories.tsx similarity index 95% rename from packages/ui/components/data-display/ContactInfo.stories.tsx rename to packages/ui/components/data-display/ContactInfo/index.stories.tsx index 8387cb9a8e..175ca2a4cb 100644 --- a/packages/ui/components/data-display/ContactInfo.stories.tsx +++ b/packages/ui/components/data-display/ContactInfo/index.stories.tsx @@ -6,7 +6,7 @@ import { orgPhone } from '~ui/mockData/orgPhone' import { orgSocialMedia } from '~ui/mockData/orgSocialMedia' import { orgWebsite } from '~ui/mockData/orgWebsite' -import { ContactInfo } from './ContactInfo' +import { ContactInfo } from './index' export default { title: 'Data Display/Contact Info - Individual API', diff --git a/packages/ui/components/data-display/ContactInfo/index.tsx b/packages/ui/components/data-display/ContactInfo/index.tsx new file mode 100644 index 0000000000..d5d22b9c09 --- /dev/null +++ b/packages/ui/components/data-display/ContactInfo/index.tsx @@ -0,0 +1,53 @@ +import { Stack } from '@mantine/core' + +import { Emails } from './Emails' +import { PhoneNumbers } from './PhoneNumbers' +import { SocialMedia } from './SocialMedia' +import { type ContactInfoProps, type ContactMap, type PassedDataObject } from './types' +import { Websites } from './Websites' + +export const ContactInfo = ({ + passedData, + parentId, + order = ['website', 'phone', 'email', 'socialMedia'], + gap = 24, + ...commonProps +}: ContactInfoProps) => { + const sections: ContactMap = { + website: ( + + ), + phone: ( + + ), + email: ( + + ), + socialMedia: ( + + ), + } + const items = order.map((item) => sections[item]) + return {items} +} + +export const hasContactInfo = (data: PassedDataObject) => { + const { websites, phones, emails, socialMedia } = data + return Boolean(websites.length || phones.length || emails.length || socialMedia.length) +} diff --git a/packages/ui/components/data-display/ContactInfo/types.ts b/packages/ui/components/data-display/ContactInfo/types.ts new file mode 100644 index 0000000000..3020041dbf --- /dev/null +++ b/packages/ui/components/data-display/ContactInfo/types.ts @@ -0,0 +1,48 @@ +import { type ReactElement } from 'react' + +import { type ApiOutput } from '@weareinreach/api' + +type ContactSections = 'phone' | 'email' | 'website' | 'socialMedia' + +export type ContactMap = { + [K in ContactSections]: ReactElement +} + +export type ContactInfoProps = CommonProps & { + order?: ContactSections[] + gap?: number +} & (ApiData | PassedDataProps) + +interface CommonProps { + direct?: boolean + locationOnly?: boolean + serviceOnly?: boolean + websiteDesc?: boolean + edit?: boolean +} + +export type PhoneNumbersProps = CommonProps & (ApiData | PassedData<'orgPhone', 'forContactInfo'>) +export type WebsitesProps = CommonProps & (ApiData | PassedData<'orgWebsite', 'forContactInfo'>) + +export type EmailsProps = CommonProps & (ApiData | PassedData<'orgEmail', 'forContactInfo'>) +export type SocialMediaProps = CommonProps & (ApiData | PassedData<'orgSocialMedia', 'forContactInfo'>) + +type PassedData = { + passedData: ApiOutput[K1][K2] + parentId?: never +} + +export interface PassedDataObject { + phones: ApiOutput['orgPhone']['forContactInfo'] + emails: ApiOutput['orgEmail']['forContactInfo'] + websites: ApiOutput['orgWebsite']['forContactInfo'] + socialMedia: ApiOutput['orgSocialMedia']['forContactInfo'] +} +type ApiData = { + parentId: string + passedData?: never +} +type PassedDataProps = { + passedData: PassedDataObject + parentId?: never +} diff --git a/packages/ui/components/data-display/index.ts b/packages/ui/components/data-display/index.ts index f19984c9c7..5c16923778 100644 --- a/packages/ui/components/data-display/index.ts +++ b/packages/ui/components/data-display/index.ts @@ -1,5 +1,5 @@ -// codegen:start {preset: barrel, include: ./*.ts*, exclude: "*.stories.*"} +// codegen:start {preset: barrel, include: ["./*.tsx", "./**/index.tsx"], exclude: "*.stories.*"} export * from './AccessInfo' -export * from './ContactInfo' +export * from './ContactInfo/index' export * from './Hours' // codegen:end diff --git a/packages/ui/components/data-portal/EmailDrawer/index.stories.tsx b/packages/ui/components/data-portal/EmailDrawer/index.stories.tsx new file mode 100644 index 0000000000..691b571ee4 --- /dev/null +++ b/packages/ui/components/data-portal/EmailDrawer/index.stories.tsx @@ -0,0 +1,42 @@ +import { type Meta, type StoryObj } from '@storybook/react' + +import { Button } from '~ui/components/core/Button' +import { organization } from '~ui/mockData/organization' +import { orgEmail } from '~ui/mockData/orgEmail' + +import { EmailDrawer } from './index' + +export default { + title: 'Data Portal/Drawers/Email', + component: EmailDrawer, + parameters: { + layout: 'fullscreen', + rqDevtools: true, + // wdyr: true, + nextjs: { + router: { + pathname: '/org/[slug]/edit', + asPath: '/org/mock-org-slug', + query: { + slug: 'mock-org-slug', + }, + }, + }, + msw: [organization.getIdFromSlug, orgEmail.forContactInfoEdit, orgEmail.forEditDrawer], + }, + args: { + component: Button, + children: 'Open Drawer', + variant: 'inlineInvertedUtil1', + id: 'oeml_01GVH3VEVDX7QVQ4QA4C1XXVN3', + }, +} satisfies Meta + +type StoryDef = StoryObj + +export const WithData = { + args: { + locationId: 'oloc_01GVH3VEVBERFNA9PHHJYEBGA3', + }, +} satisfies StoryDef +export const WithoutData = {} satisfies StoryDef diff --git a/packages/ui/components/data-portal/EmailDrawer/index.tsx b/packages/ui/components/data-portal/EmailDrawer/index.tsx new file mode 100644 index 0000000000..75f586cf0a --- /dev/null +++ b/packages/ui/components/data-portal/EmailDrawer/index.tsx @@ -0,0 +1,191 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { + Box, + createPolymorphicComponent, + createStyles, + Drawer, + Group, + LoadingOverlay, + Modal, + rem, + Stack, + Text, + Title, +} from '@mantine/core' +import { useDisclosure } from '@mantine/hooks' +import { useRouter } from 'next/router' +import { forwardRef, useEffect, useState } from 'react' +import { useForm } from 'react-hook-form' +import { Checkbox, TextInput } from 'react-hook-form-mantine' +import invariant from 'tiny-invariant' +import { z } from 'zod' + +import { Breadcrumb } from '~ui/components/core/Breadcrumb' +import { Button } from '~ui/components/core/Button' +import { Icon } from '~ui/icon' +import { trpc as api } from '~ui/lib/trpcClient' + +const FormSchema = z.object({ + firstName: z.string().nullish(), + lastName: z.string().nullish(), + primary: z.boolean(), + email: z.string().email(), + description: z.string().nullish(), + titleId: z.string().nullish(), + published: z.boolean(), + deleted: z.boolean(), + locationOnly: z.boolean(), + serviceOnly: z.boolean(), + id: z.string(), + orgId: z.string(), + descriptionId: z.string().nullish(), +}) +type FormSchema = z.infer +const useStyles = createStyles(() => ({ + drawerContent: { + borderRadius: `${rem(32)} 0 0 0`, + minWidth: '40vw', + }, +})) +export const _EmailDrawer = forwardRef(({ id, ...props }, ref) => { + const router = useRouter<'/org/[slug]/edit'>() + const { isFetching } = api.orgEmail.forEditDrawer.useQuery({ id }) + const [drawerOpened, drawerHandler] = useDisclosure(false) + const [modalOpened, modalHandler] = useDisclosure(false) + const { classes } = useStyles() + const apiUtils = api.useUtils() + const { control, handleSubmit, formState, reset, getValues } = useForm({ + resolver: zodResolver(FormSchema), + defaultValues: async () => { + const data = await apiUtils.orgEmail.forEditDrawer.fetch({ id }) + const { id: orgId } = await apiUtils.organization.getIdFromSlug.fetch({ slug: router.query.slug ?? '' }) + invariant(data, 'No data returned') + invariant(orgId, 'No orgId') + return { ...data, orgId } + }, + }) + + const { isDirty: formIsDirty } = formState + const [isSaved, setIsSaved] = useState(formIsDirty) + + const emailUpdate = api.orgEmail.update.useMutation({ + onSettled: (data) => { + apiUtils.orgEmail.forEditDrawer.invalidate() + apiUtils.orgEmail.forContactInfoEdit.invalidate() + reset(data) + }, + onSuccess: () => { + setIsSaved(true) + }, + }) + useEffect(() => { + if (isSaved && formIsDirty) { + setIsSaved(false) + } + }, [formIsDirty, isSaved]) + const handleClose = () => { + if (formState.isDirty) { + return modalHandler.open() + } else { + return drawerHandler.close() + } + } + return ( + <> + + + +

{ + emailUpdate.mutate(data) + }, + (error) => console.error(error) + )} + > + + + + + + + + + + Edit Email + + + + + + + + + + + + + + + + + + You have unsaved changes + + + + + + +
+ + + + + + + ) +}) +_EmailDrawer.displayName = 'EmailDrawer' + +export const EmailDrawer = createPolymorphicComponent<'button', EmailDrawerProps>(_EmailDrawer) +interface EmailDrawerProps { + id: string +} diff --git a/packages/ui/components/data-portal/InlineTextInput.stories.tsx b/packages/ui/components/data-portal/InlineTextInput.stories.tsx index 183b81fe09..4c1a3e751d 100644 --- a/packages/ui/components/data-portal/InlineTextInput.stories.tsx +++ b/packages/ui/components/data-portal/InlineTextInput.stories.tsx @@ -1,14 +1,14 @@ -import { Box } from '@mantine/core' -import { type Meta } from '@storybook/react' +import { Textarea } from '@mantine/core' +import { type Meta, type StoryObj } from '@storybook/react' -import { InlineTextarea, InlineTextInput } from './InlineTextInput' +import { InlineTextInput } from './InlineTextInput' export default { title: 'Data Portal/Fields/Inline Text Field', component: InlineTextInput, parameters: { layout: 'fullscreen', - layoutWrapper: 'centeredHalf', + layoutWrapper: 'gridDouble', }, argTypes: { fontSize: { @@ -21,25 +21,16 @@ export default { value: 'Test value', }, } satisfies Meta - -export const TextInput = { +type StoryDef = StoryObj +export const SingleLine = { args: { fontSize: 'h1', }, - render: (args) => ( - - - - ), -} satisfies Meta +} satisfies StoryDef -export const Textarea = { +export const MultiLine = { args: { fontSize: 'utility1', + component: Textarea, }, - render: (args) => ( - - - - ), -} satisfies Meta +} satisfies StoryDef diff --git a/packages/ui/components/data-portal/InlineTextInput.tsx b/packages/ui/components/data-portal/InlineTextInput.tsx index 06da4cef2f..69aeb73646 100644 --- a/packages/ui/components/data-portal/InlineTextInput.tsx +++ b/packages/ui/components/data-portal/InlineTextInput.tsx @@ -1,11 +1,12 @@ import { + Box, + createPolymorphicComponent, createStyles, rem, - Textarea, - type TextareaProps, TextInput, type TextInputProps, } from '@mantine/core' +import { forwardRef } from 'react' const useStyles = createStyles((theme) => ({ ...theme.other.utilityFonts, @@ -22,59 +23,39 @@ const useBaseStyles = createStyles((theme) => ({ '&:focus, &:focus-within': { borderColor: theme.other.colors.secondary.black, borderWidth: rem(1), + backgroundColor: theme.other.colors.secondary.white, }, '&[data-isDirty=true]': { - backgroundColor: theme.other.colors.primary.lightGray, + backgroundColor: theme.fn.lighten(theme.other.colors.secondary.teal, 0.6), }, }, })) -const useFontSize = ({ fontSize, classNames }: SingleLineTextProps | MultiLineTextProps) => { +const useFontSize = ({ fontSize, classNames }: InlineEditProps) => { const { classes } = useStyles() const { classes: baseClasses, cx } = useBaseStyles() return { ...classNames, - input: fontSize ? cx(classes[fontSize], baseClasses.input) : baseClasses.input, + input: fontSize + ? cx(classNames?.input, classes[fontSize], baseClasses.input) + : cx(classNames?.input, baseClasses.input), } } -/** - * Components works like TextInput. To use one of the variant fonts pass a string like 'h1', 'h2', 'utility1', - * etc to the fontSize prop. - * - * @param props - TextInputProps - * @param props.fontSize - HeadingSizes | utilitySizes - * @returns JSX.Element - */ -export const InlineTextInput = ({ fontSize, ...props }: SingleLineTextProps) => { - const variant = useFontSize({ fontSize, ...props }) +const _InlineTextInput = forwardRef( + ({ fontSize, classNames, ...rest }, ref) => { + const variant = useFontSize({ fontSize, ...rest }) - return -} - -/** - * Component works like Textarea. To use one of the variant fonts pass a string like 'h1', 'h2', 'utility1', - * etc to the fontSize prop. - * - * @param props - TextareaProps - * @param props.fontSize - HeadingSizes | utilitySizes - * @returns JSX.Element - */ -export const InlineTextarea = ({ fontSize, ...props }: MultiLineTextProps) => { - const variant = useFontSize({ fontSize, ...props }) - - return