diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 4b19f4877..000000000 --- a/.eslintignore +++ /dev/null @@ -1,7 +0,0 @@ -node_modules -dist -/scripts/* -yarn.lock -.eslintrc.cjs -.netlify -commitlint.config.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..2de7cdc18 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @fbuireu diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 352688671..2d6575391 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,3 +1,3 @@ github: [fbuireu] -patreon: fbuireu ko_fi: ferranbuireu +patreon: fbuireu diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..ff0a04506 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + day: saturday + assignees: [fbuireu] + labels: [dependencies, dependabot, bot] + - package-ecosystem: npm + directory: "/" + schedule: + interval: weekly + day: saturday + assignees: [fbuireu] + labels: [dependencies, dependabot, bot] diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index b980eb9f0..ad4b49f08 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -3,13 +3,11 @@ concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true on: + workflow_dispatch: push: branches: [main] pull_request: branches: [main] - schedule: - - cron: '0 0 * * 0' - workflow_dispatch: jobs: analyze: name: Analyze @@ -32,5 +30,5 @@ jobs: queries: security-extended, security-and-quality - name: Autobuild uses: github/codeql-action/autobuild@v3 - - name: Perform CodeQL Analysis + - name: Run CodeQL Analysis uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 7aba4842c..ec2488a11 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -4,6 +4,7 @@ permissions: contents: read jobs: dependency-review: + name: Dependency Review runs-on: ubuntu-latest steps: - name: Checkout Repository diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 000000000..a2eb73156 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,46 @@ +name: E2E Tests +on: + workflow_dispatch: + pull_request: + branches: [main] +env: + CI: true + E2E_URL: ${{ github.event_name == 'workflow_dispatch' && vars.E2E_PRODUCTION_URL || vars.E2E_SELF_HOSTED_URL }} + E2E_COMMAND: ${{ github.event_name == 'workflow_dispatch' && 'yarn test:e2e' || 'yarn test:e2e:changed' }} +jobs: + e2e-tests: + name: Run E2E tests + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Use Node.js version from .nvmrc + id: nvmrc + run: echo "NODE_VERSION=$(cat .nvmrc)" >> $GITHUB_ENV + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + - name: Enable Corepack + run: corepack enable + - name: Get Yarn version from package.json + id: yarn_version + run: | + YARN_VERSION=$(jq -r '.packageManager' package.json | grep -o 'yarn@[0-9]*\.[0-9]*\.[0-9]*') + echo "YARN_VERSION=${YARN_VERSION}" >> $GITHUB_ENV + - name: Install correct Yarn version + run: corepack prepare ${{ env.YARN_VERSION }} --activate + - name: Install dependencies + run: yarn install --frozen-lockfile + - name: Install Playwright Browsers + run: yarn playwright install --with-deps + - name: Run E2E Tests + run: ${{ env.E2E_COMMAND }} + env: + CI: ${{ env.CI }} + E2E_URL: ${{ env.E2E_URL }} + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..4d7bd8df4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,106 @@ +name: Release +run-name: Release ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag_name || github.ref_name }} +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true +on: + workflow_dispatch: + inputs: + tag_name: + description: 'Tag name for the release (semver format required: x.x.x)' + required: true + push: + tags: + - '*' + workflow_run: + workflows: ["E2E Tests", "Unit Tests"] + types: [completed] +env: + TAG_NAME: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag_name || github.ref_name }} +jobs: + validate-tag: + name: Validate tag + runs-on: ubuntu-latest + if: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }} + steps: + - name: Validate tag Format + run: | + if [[ ! "${{ env.TAG_NAME }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: Invalid tag format. Please use semver format (x.x.x)." + exit 1 + fi + check-tag: + name: Check tag + runs-on: ubuntu-latest + needs: validate-tag + if: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }} + steps: + - name: Check out the repository + uses: actions/checkout@v4 + - name: Check if tag exists + id: check_tag + run: | + git fetch --tags + if git rev-parse "${{ env.TAG_NAME }}" >/dev/null 2>&1; then + echo "Tag already exists. Cancelling the release." + exit 1 + fi + check-version: + name: Check Version + runs-on: ubuntu-latest + needs: check-tag + steps: + - name: Check out the repository + uses: actions/checkout@v4 + - name: Install semver + run: npm install -g semver + - name: Verify new version + run: | + CURRENT_VERSION=$(jq -r '.version' package.json) + echo "Current version: $CURRENT_VERSION" + if ! semver -r ">$CURRENT_VERSION" "${{ env.TAG_NAME }}"; then + echo "Error: The new version ${{ env.TAG_NAME }} is not greater than the current version $CURRENT_VERSION." + exit 1 + fi + shell: bash + update-version: + name: Update version + runs-on: ubuntu-latest + needs: check-version + steps: + - name: Check out the repository + uses: actions/checkout@v4 + - name: Update package.json version to match tag + run: | + VERSION="${{ env.TAG_NAME }}" + jq --arg version "$VERSION" '.version = $version' package.json > package.json.tmp && mv package.json.tmp package.json + + - name: Commit version update + run: | + git config user.name "github-actions" + git config user.email "github-actions@github.com" + git commit -am "chore: update package.json version to ${{ env.TAG_NAME }}" + git push origin HEAD + create-release: + name: Create Release + runs-on: ubuntu-latest + needs: update-version + steps: + - name: Check out the repository + uses: actions/checkout@v4 + - name: Create tag (only for workflow_dispatch) + if: ${{ github.event_name == 'workflow_dispatch' }} + run: | + git config user.name "github-actions" + git config user.email "github-actions@github.com" + git tag ${{ env.TAG_NAME }} + git push origin ${{ env.TAG_NAME }} + - name: Set up GitHub CLI + run: | + sudo apt-get install -y gh + echo "${{ secrets.GITHUB_TOKEN }}" | gh auth login --with-token + - name: Create Release with GitHub CLI + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create ${{ env.TAG_NAME }} --title "Release ${{ env.TAG_NAME }}" --notes "Release notes generated automatically." --generate-notes diff --git a/.github/workflows/ut.yml b/.github/workflows/ut.yml new file mode 100644 index 000000000..538963726 --- /dev/null +++ b/.github/workflows/ut.yml @@ -0,0 +1,32 @@ +name: Unit Tests +on: + workflow_dispatch: + push: + branches: [main] + pull_request: + branches: [main] +jobs: + ut-tests: + name: Run unit tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Use Node.js version from .nvmrc + id: nvmrc + run: echo "NODE_VERSION=$(cat .nvmrc)" >> $GITHUB_ENV + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + - name: Enable Corepack + run: corepack enable + - name: Get Yarn version from package.json + id: yarn_version + run: | + YARN_VERSION=$(jq -r '.packageManager' package.json | grep -o 'yarn@[0-9]*\.[0-9]*\.[0-9]*') + echo "YARN_VERSION=${YARN_VERSION}" >> $GITHUB_ENV + - name: Install correct Yarn version + run: corepack prepare ${{ env.YARN_VERSION }} --activate + - name: Install dependencies + run: yarn install --frozen-lockfile + - name: Execute Unit tests + run: yarn test:ut diff --git a/.gitignore b/.gitignore index 0874f9b76..0fe615a55 100644 --- a/.gitignore +++ b/.gitignore @@ -6,11 +6,12 @@ logs/ *.log yarn-debug.log* yarn-error.log* -# eslint cache -.eslintcache -# Coverage directory used by testing tools -coverage/ +# Test-related directories +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ # Dependency directories node_modules/ diff --git a/.imgbotconfig b/.imgbotconfig new file mode 100644 index 000000000..fcb60d34c --- /dev/null +++ b/.imgbotconfig @@ -0,0 +1,4 @@ +{ + "schedule": "weekly", + "compressWiki": "true" +} diff --git a/.lintstagedrc.json b/.lintstagedrc.json index b0e661a04..544158c8c 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -1,6 +1,4 @@ { - "src/**/*.{ts,tsx,css,astro}": ["yarn format:changed"], - "src/**/*.{ts,tsx,astro}": ["yarn lint:ts:changed"], - "src/**/*.css": ["yarn lint:styles"], - "src/**/*.{test,spec}.{ts,tsx}": ["yarn test --bail --findRelatedTests"] + "src/**/*.{ts,tsx,css,astro}": ["yarn format:changed", "yarn lint:changed"], + "src/**/*.{test,spec}.{ts,tsx}": ["yarn test:ut:changed"] } diff --git a/.nvmrc b/.nvmrc index 64e0a585b..9d673278d 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22.7.0 \ No newline at end of file +22.8.0 diff --git a/.stylelintignore b/.stylelintignore deleted file mode 100644 index bd964538c..000000000 --- a/.stylelintignore +++ /dev/null @@ -1,4 +0,0 @@ -src/ui/styles/**/* -node_modules -dist -.netlify diff --git a/.stylelintrc.cjs b/.stylelintrc.cjs deleted file mode 100644 index dda339a92..000000000 --- a/.stylelintrc.cjs +++ /dev/null @@ -1,12 +0,0 @@ -/** @type {import('stylelint').Config} */ -module.exports = { - extends: "stylelint-config-recommended", - plugins: ["stylelint-order"], - allowEmptyInput: true, - rules: { - "order/properties-alphabetical-order": true, - "selector-class-pattern": null, - "value-keyword-case": null, - "custom-property-pattern": null, - }, -}; diff --git a/astro.config.ts b/astro.config.ts index 305ead7b2..f7ea5e7c9 100644 --- a/astro.config.ts +++ b/astro.config.ts @@ -10,8 +10,8 @@ const isProd = import.meta.env.PROD; export default defineConfig({ experimental: { - actions: true, contentLayer: true, + contentCollectionCache: true, env: { validateSecrets: true, schema: { @@ -114,6 +114,7 @@ export default defineConfig({ }, }, site: "https://biancafiore.me", + prefetch: true, vite: { ssr: { external: ["firebase-admin", "node:async_hooks", "contentful"], @@ -130,6 +131,12 @@ export default defineConfig({ }, }), ], + redirects: { + "/home": { + status: 301, + destination: "/", + }, + }, output: "hybrid", adapter: cloudflare({ platformProxy: { diff --git a/biome.json b/biome.json index dbc70358e..8fcf486c5 100644 --- a/biome.json +++ b/biome.json @@ -1,20 +1,29 @@ { - "$schema": "https://biomejs.dev/schemas/1.6.4/schema.json", + "$schema": "https://biomejs.dev/schemas/1.9.1/schema.json", "files": { "ignore": ["src/data/*"] }, "vcs": { "enabled": true, + "useIgnoreFile": true, "clientKind": "git", "defaultBranch": "main" }, "formatter": { "lineWidth": 120 }, + "css": { + "formatter": { + "enabled": true + } + }, "linter": { "ignore": ["*.d.ts*"], "rules": { - "recommended": true + "recommended": true, + "suspicious": { + "noConsole": "error" + } } } } diff --git a/commitlint.config.ts b/commitlint.config.ts index 5367bd1a0..facfdb3e2 100644 --- a/commitlint.config.ts +++ b/commitlint.config.ts @@ -1,6 +1,6 @@ import type { UserConfig } from "@commitlint/types"; -const Configuration: UserConfig = { +const configuration: UserConfig = { extends: ["@commitlint/config-conventional"], parserPreset: "conventional-changelog-atom", formatter: "@commitlint/format", @@ -11,4 +11,4 @@ const Configuration: UserConfig = { }, }; -export default Configuration; +export default configuration; diff --git a/package.json b/package.json index 140724ee6..2fc59aecd 100644 --- a/package.json +++ b/package.json @@ -1,105 +1,101 @@ { - "name": "biancafiore", - "description": "A portfolio for the best content writer ever", - "keywords": [ - "astro", - "typescript", - "css", - "cloudflare", - "NGO", - "blog", - "portfolio", - "marketing", - "content writing", - "seo" - ], - "author": "Ferran Buireu ", - "version": "2.0.0", - "type": "module", - "packageManager": "yarn@4.4.0", - "engines": { - "node": ">=22.6.0" - }, - "scripts": { - "prepare": "husky", - "dev": "astro dev", - "start": "yarn dev", - "build": "yarn check && astro build", - "preview": "astro preview", - "sync": "astro sync", - "check": "astro check", - "test": "vitest --passWithNoTests", - "test:changed": "yarn test --bail --findRelatedTests", - "test:all": "yarn test", - "test:ut": "yarn test", - "test:e2e": "yarn test", - "format": "biome check --write --no-errors-on-unmatched", - "format:all": "yarn format .", - "format:changed": "yarn format --changed", - "lint": "biome lint --no-errors-on-unmatched", - "lint:ts": "yarn lint .", - "lint:ts:fix": "yarn lint:ts --write", - "lint:ts:changed": "yarn lint:ts --write --changed", - "lint:styles": "stylelint --fix \"src/**/*.css\" --config ./.stylelintrc.cjs --max-warnings=0", - "lint:styles:fix": "yarn lint:styles --fix", - "lint:typecheck": "tsc --project . --noEmit" - }, - "dependencies": { - "@astrojs/check": "^0.9.3", - "@astrojs/cloudflare": "^11.0.4", - "@astrojs/mdx": "^3.1.4", - "@astrojs/partytown": "^2.1.1", - "@astrojs/react": "^3.6.2", - "@astrojs/rss": "^4.0.7", - "@astrojs/sitemap": "^3.1.6", - "@contentful/rich-text-html-renderer": "^16.6.8", - "@fontsource-variable/nunito-sans": "^5.0.15", - "@fontsource/baskervville": "^5.0.21", - "@hookform/resolvers": "^3.9.0", - "@million/lint": "1.0.0-rc.84", - "algoliasearch": "^5.1.1", - "astro": "^4.14.5", - "clsx": "^2.1.1", - "contentful": "^10.15.0", - "firebase": "^10.13.0", - "firebase-admin": "^12.4.0", - "gsap": "^3.12.5", - "instantsearch.css": "^8.5.0", - "markdown-it": "^14.1.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-globe.gl": "^2.27.2", - "react-google-recaptcha-v3": "^1.10.1", - "react-hook-form": "^7.53.0", - "react-instantsearch": "^7.13.0", - "react-router-dom": "^6.26.1", - "resend": "^4.0.0", - "swiper": "^11.1.10", - "three": "^0.167.1", - "typescript": "^5.5.4", - "vanilla-cookieconsent": "^3.0.1", - "zod": "^3.23.8" - }, - "devDependencies": { - "@astrojs/ts-plugin": "^1.10.1", - "@biomejs/biome": "1.8.3", - "@commitlint/cli": "^19.4.0", - "@commitlint/config-conventional": "^19.2.2", - "@commitlint/format": "^19.3.0", - "@testing-library/react": "^16.0.0", - "@testing-library/react-hooks": "^8.0.1", - "@types/add": "^2.0.3", - "@types/markdown-it": "^14.1.2", - "@types/node": "^22.5.0", - "@types/react": "^18.3.4", - "@types/react-dom": "^18.3.0", - "@types/three": "^0.167.2", - "conventional-changelog-atom": "^5.0.0", - "husky": "^9.1.5", - "lint-staged": "^15.2.9", - "stylelint": "^16.8.2", - "stylelint-config-recommended": "^14.0.1", - "stylelint-order": "^6.0.4", - "vitest": "^2.0.5" - } + "name": "biancafiore", + "description": "A portfolio for the best content writer ever", + "keywords": [ + "astro", + "typescript", + "css", + "cloudflare", + "NGO", + "blog", + "portfolio", + "marketing", + "content writing", + "seo" + ], + "author": "Ferran Buireu ", + "version": "2.0.2", + "type": "module", + "packageManager": "yarn@4.5.0", + "engines": { + "node": ">=22.6.0" + }, + "scripts": { + "prepare": "husky", + "start": "astro dev --open", + "build": "yarn check && astro build", + "preview": "astro preview", + "sync": "astro sync", + "check": "astro check", + "test:all": "yarn test:ut && yarn test:e2e", + "test:ut": "vitest --passWithNoTests --logHeapUsage", + "test:ut:changed": "yarn test:ut --bail --findRelatedTests", + "test:e2e": "playwright test --pass-with-no-tests", + "test:e2e:ui": "yarn test:e2e --ui", + "test:e2e:changed": "yarn test:e2e --only-changed", + "format": "biome check --write --no-errors-on-unmatched", + "format:changed": "yarn format --changed", + "format:all": "yarn format .", + "lint": "biome lint --no-errors-on-unmatched", + "lint:all": "yarn lint .", + "lint:all:fix": "yarn lint:all --fix", + "lint:changed": "yarn lint --write --changed", + "lint:ts:typecheck": "tsc --project . --noEmit" + }, + "dependencies": { + "@astrojs/check": "^0.9.3", + "@astrojs/cloudflare": "^11.0.5", + "@astrojs/mdx": "^3.1.6", + "@astrojs/partytown": "^2.1.2", + "@astrojs/react": "^3.6.2", + "@astrojs/rss": "^4.0.7", + "@astrojs/sitemap": "^3.1.6", + "@contentful/rich-text-html-renderer": "^16.6.10", + "@fontsource-variable/nunito-sans": "^5.1.0", + "@fontsource/baskervville": "^5.1.0", + "@hookform/resolvers": "^3.9.0", + "@million/lint": "1.0.0-rc.84", + "algoliasearch": "^5.4.3", + "astro": "^4.15.7", + "clsx": "^2.1.1", + "contentful": "^10.15.1", + "firebase": "^10.13.1", + "firebase-admin": "^12.5.0", + "gsap": "^3.12.5", + "instantsearch.css": "^8.5.1", + "markdown-it": "^14.1.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-globe.gl": "^2.27.2", + "react-google-recaptcha-v3": "^1.10.1", + "react-hook-form": "^7.53.0", + "react-instantsearch": "^7.13.1", + "react-router-dom": "^6.26.2", + "resend": "^4.0.0", + "swiper": "^11.1.14", + "three": "^0.168.0", + "typescript": "^5.6.2", + "vanilla-cookieconsent": "^3.0.1", + "zod": "^3.23.8" + }, + "devDependencies": { + "@astrojs/ts-plugin": "^1.10.2", + "@biomejs/biome": "1.9.1", + "@commitlint/cli": "^19.5.0", + "@commitlint/config-conventional": "^19.5.0", + "@commitlint/format": "^19.5.0", + "@playwright/test": "^1.47.1", + "@testing-library/react": "^16.0.1", + "@testing-library/react-hooks": "^8.0.1", + "@types/add": "^2.0.3", + "@types/markdown-it": "^14.1.2", + "@types/node": "^22.5.5", + "@types/react": "^18.3.7", + "@types/react-dom": "^18.3.0", + "@types/three": "^0.168.0", + "conventional-changelog-atom": "^5.0.0", + "husky": "^9.1.6", + "lint-staged": "^15.2.10", + "vitest": "^2.1.1" + } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 000000000..0a77c3c5d --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,32 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + webServer: { + command: "yarn start", + reuseExistingServer: !process.env.CI, + }, + testDir: "./e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: "html", + use: { + trace: "on-first-retry", + baseURL: `${process.env.E2E_URL ?? "http://localhost:4321"}`, + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + { + name: "webkit", + use: { ...devices["Desktop Safari"] }, + }, + ], +}); diff --git a/src/actions/index.ts b/src/actions/index.ts index 1b810fac9..6dec2d312 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -1,58 +1,54 @@ // @ts-ignore -import { ActionError, defineAction, z } from "astro:actions"; -import { DEFAULT_LOCALE_STRING } from "@const/index.ts"; -import { app } from "@infrastructure/database/server.ts"; -import { sendEmail } from "@infrastructure/email/server.ts"; -import type { FormData } from "@modules/contact/components/contactForm"; +import { ActionError, defineAction } from "astro:actions"; +import { contactFormSchema } from "@application/entities/contact/schema"; +import { Exception } from "@domain/errors"; +import { app } from "@infrastructure/database/server"; +import { checkDuplicatedEntries } from "@infrastructure/utils/checkDuplicatedEntries"; +import { saveContact } from "@infrastructure/utils/saveContact"; +import { sendEmail } from "@infrastructure/utils/sendEmail"; +import { validateContact } from "@infrastructure/utils/validateContact"; +import type { ContactFormData } from "@shared/ui/types"; import { getFirestore } from "firebase-admin/firestore"; -type ContactDetails = Omit; - -const contactFormSchema = z.object({ - name: z.string(), - email: z.string().email(), -}); - +type ActionHandlerParams = Omit; const database = getFirestore(app); export const server = { contact: defineAction({ accept: "form", input: contactFormSchema, - handler: async ({ name, email, message }: ContactDetails) => { + handler: async ({ name, email, message }: ActionHandlerParams) => { try { - const contactValidation = contactFormSchema.safeParse({ + const data = validateContact({ name, email, message, }); - if (!contactValidation.success) throw new Error(contactValidation.error?.errors.join(", ") || "Invalid data"); - const { data } = contactValidation; const databaseRef = database.collection("contacts"); - await databaseRef.add({ - id: crypto.randomUUID(), - name: data.name, - email: data.email, - message: data.message, - date: new Date().toLocaleString(DEFAULT_LOCALE_STRING), + await checkDuplicatedEntries({ databaseRef, data }); + + const { id: mailId } = await sendEmail(data); + + await saveContact({ + contactData: { id: mailId, ...data }, + databaseRef, }); - const { data: emailData, error: emailError } = await sendEmail(data); - const success = emailData && !emailError; - if (!success && emailError) { - throw new Error(`Something went wrong sending the email. Error: ${emailError.message} (${emailError.name})`); - } - return { - ok: success, - }; + return { ok: !!mailId }; } catch (error: unknown) { - const actionError = error as ActionError; - - const message = actionError.message || "Something went wrong"; - const code = actionError.status ?? 500; + if (error instanceof Exception) { + throw new ActionError({ + code: error.code, + message: error.message, + }); + } - return new ActionError({ code, message }); + throw new ActionError({ + code: "INTERNAL_SERVER_ERROR", + message: + "Whoopsie! Something went wrong. It's my fault (or actually my boyfriend's). Please try again in a few minutes after refreshing the page.", + }); } }, }), diff --git a/src/application/dto/article/articleDTO.ts b/src/application/dto/article/articleDTO.ts index a14e46eed..454dc956c 100644 --- a/src/application/dto/article/articleDTO.ts +++ b/src/application/dto/article/articleDTO.ts @@ -1,10 +1,11 @@ import type { ArticleDTO, RawArticle } from "@application/dto/article/types"; import { ArticleType } from "@application/dto/article/types"; -import { getRelatedArticles } from "@application/dto/article/utils/getRelatedArticles/getRelatedArticles.ts"; -import { DEFAULT_DATE_FORMAT } from "@const/index.ts"; +import { createRelatedArticles } from "@application/dto/article/utils/createRelatedArticles"; +import { getRelatedArticles } from "@application/dto/article/utils/getRelatedArticles/getRelatedArticles"; +import { DEFAULT_DATE_FORMAT } from "@const/index"; import { documentToHtmlString } from "@contentful/rich-text-html-renderer"; import type { Document } from "@contentful/rich-text-types"; -import type { BaseDTO } from "@shared/application/dto/baseDTO.ts"; +import type { BaseDTO } from "@shared/application/dto/baseDTO"; import { createImage } from "@shared/application/dto/utils/createImage"; import MarkdownIt from "markdown-it"; import { createTags } from "./utils/createTags"; @@ -16,20 +17,20 @@ import { renderOptions } from "./utils/renderOptions"; const PARSER: MarkdownIt = new MarkdownIt(); export const articleDTO: BaseDTO = { - render: (raw) => { + create: (raw) => { return raw.map((rawArticle) => { const description = rawArticle.fields.description ?? generateExcerpt({ parser: PARSER, content: documentToHtmlString(rawArticle.fields.content as unknown as Document), - }).excerpt; + }); const tags = createTags(rawArticle.fields.tags); const relatedArticles = rawArticle.fields.relatedArticles - ? articleDTO.render(rawArticle.fields.relatedArticles as unknown as RawArticle[]) + ? createRelatedArticles(rawArticle.fields.relatedArticles) : getRelatedArticles({ rawArticle, allRawArticles: raw }); - const featuredImage = createImage(rawArticle.fields.featuredImage); + const featuredImage = rawArticle.fields.featuredImage && createImage(rawArticle.fields.featuredImage); const content = documentToHtmlString(rawArticle.fields.content as unknown as Document, renderOptions(rawArticle)); return { diff --git a/src/application/dto/article/types.ts b/src/application/dto/article/types.ts index 2aea20c89..b62893d71 100644 --- a/src/application/dto/article/types.ts +++ b/src/application/dto/article/types.ts @@ -1,5 +1,5 @@ import type { RawAuthor } from "@application/dto/author/types"; -import type { BaseTagDTO } from "@application/dto/tag/types.ts"; +import type { BaseTagDTO } from "@application/dto/tag/types"; import type { articleSchema } from "@application/entities/articles"; import type { ContentfulImageAsset } from "@shared/application/types"; import type { Entry, EntryFieldTypes, EntrySkeletonType } from "contentful"; @@ -7,9 +7,6 @@ import type { z } from "zod"; export interface RawArticle { contentTypeId: "article"; - sys: { - id: string; - }; fields: { title: EntryFieldTypes.Text; slug: EntryFieldTypes.Text; diff --git a/src/application/dto/article/utils/createRelatedArticles/createRelatedArticles.ts b/src/application/dto/article/utils/createRelatedArticles/createRelatedArticles.ts new file mode 100644 index 000000000..b3f8174c8 --- /dev/null +++ b/src/application/dto/article/utils/createRelatedArticles/createRelatedArticles.ts @@ -0,0 +1,9 @@ +import type { Reference } from "@shared/application/types"; +import type { Entry, EntrySkeletonType } from "contentful"; + +export function createRelatedArticles(relatedArticles: Array>): Reference<"articles">[] { + return relatedArticles.map((relatedArticle) => ({ + id: relatedArticle.fields.slug as unknown as string, + collection: "articles", + })); +} diff --git a/src/application/dto/article/utils/createRelatedArticles/index.ts b/src/application/dto/article/utils/createRelatedArticles/index.ts new file mode 100644 index 000000000..fb1af166e --- /dev/null +++ b/src/application/dto/article/utils/createRelatedArticles/index.ts @@ -0,0 +1 @@ +export * from "./createRelatedArticles"; diff --git a/src/application/dto/article/utils/createTags/createTags.ts b/src/application/dto/article/utils/createTags/createTags.ts index ce6a16fce..415468d78 100644 --- a/src/application/dto/article/utils/createTags/createTags.ts +++ b/src/application/dto/article/utils/createTags/createTags.ts @@ -1,4 +1,4 @@ -import type { BaseTagDTO } from "@application/dto/tag/types.ts"; +import type { BaseTagDTO } from "@application/dto/tag/types"; import type { Entry, EntrySkeletonType } from "contentful"; export function createTags(tags: Array>>): BaseTagDTO[] { diff --git a/src/application/dto/article/utils/generateExcerpt/generateExcerpt.ts b/src/application/dto/article/utils/generateExcerpt/generateExcerpt.ts index 1444d6c78..15259ce43 100644 --- a/src/application/dto/article/utils/generateExcerpt/generateExcerpt.ts +++ b/src/application/dto/article/utils/generateExcerpt/generateExcerpt.ts @@ -6,14 +6,10 @@ interface ExcerptParams { limit?: number; } -interface ExcerptReturnType { - excerpt: string; -} - const EXCERPT_LIMIT = 140; const HTML_TAG_REGEX = /<\/?[^>]+(>|$)/g; -export function generateExcerpt({ parser, content, limit = EXCERPT_LIMIT }: ExcerptParams): ExcerptReturnType { +export function generateExcerpt({ parser, content, limit = EXCERPT_LIMIT }: ExcerptParams) { const excerpt = parser .render(content) .split("\n") @@ -23,5 +19,5 @@ export function generateExcerpt({ parser, content, limit = EXCERPT_LIMIT }: Exce .substring(0, limit) .trim(); - return { excerpt: `${excerpt}...` }; + return `${excerpt}...`; } diff --git a/src/application/dto/article/utils/generateExcerpt/index.ts b/src/application/dto/article/utils/generateExcerpt/index.ts index c559b0824..7d19f646e 100644 --- a/src/application/dto/article/utils/generateExcerpt/index.ts +++ b/src/application/dto/article/utils/generateExcerpt/index.ts @@ -1 +1 @@ -export * from "src/application/dto/article/utils/generateExcerpt/generateExcerpt.ts"; +export * from "./generateExcerpt"; diff --git a/src/application/dto/article/utils/getAuthor/getAuthor.ts b/src/application/dto/article/utils/getAuthor/getAuthor.ts index fdf960076..df97174b1 100644 --- a/src/application/dto/article/utils/getAuthor/getAuthor.ts +++ b/src/application/dto/article/utils/getAuthor/getAuthor.ts @@ -1,4 +1,4 @@ -import type { AuthorDTO, RawAuthor } from "@application/dto/author/types.ts"; +import type { AuthorDTO, RawAuthor } from "@application/dto/author/types"; import { createImage } from "@shared/application/dto/utils/createImage"; import type { Entry, EntrySkeletonType } from "contentful"; diff --git a/src/application/dto/article/utils/getAuthor/index.ts b/src/application/dto/article/utils/getAuthor/index.ts index 26892df49..d4237f211 100644 --- a/src/application/dto/article/utils/getAuthor/index.ts +++ b/src/application/dto/article/utils/getAuthor/index.ts @@ -1 +1 @@ -export * from "./getAuthor.ts"; +export * from "./getAuthor"; diff --git a/src/application/dto/article/utils/getReadingTime/index.ts b/src/application/dto/article/utils/getReadingTime/index.ts index 4d0345d52..723b71942 100644 --- a/src/application/dto/article/utils/getReadingTime/index.ts +++ b/src/application/dto/article/utils/getReadingTime/index.ts @@ -1 +1 @@ -export * from "./getReadingTime.ts"; +export * from "./getReadingTime"; diff --git a/src/application/dto/article/utils/getRelatedArticles/getRelatedArticles.ts b/src/application/dto/article/utils/getRelatedArticles/getRelatedArticles.ts index 9033bea2b..7be3b8c30 100644 --- a/src/application/dto/article/utils/getRelatedArticles/getRelatedArticles.ts +++ b/src/application/dto/article/utils/getRelatedArticles/getRelatedArticles.ts @@ -1,55 +1,25 @@ -import type { CollectionEntry } from "astro:content"; import type { RawArticle } from "@application/dto/article/types"; -import { ArticleType } from "@application/dto/article/types"; -import { DEFAULT_DATE_FORMAT, MAX_RELATED_ARTICLES } from "@const/index"; -import { documentToHtmlString } from "@contentful/rich-text-html-renderer"; -import type { Document } from "@contentful/rich-text-types"; -import { createImage } from "@shared/application/dto/utils/createImage"; -import { getAuthor } from "../getAuthor"; -import { getReadingTime } from "../getReadingTime"; +import { MAX_RELATED_ARTICLES } from "@const/index"; +import type { Reference } from "@shared/application/types"; -interface GetRelatedArticlesProps { +interface GetRelatedArticlesParams { rawArticle: RawArticle; allRawArticles: RawArticle[]; } -export function getRelatedArticles({ - rawArticle, - allRawArticles, -}: GetRelatedArticlesProps): CollectionEntry<"articles">[] { - const articleTagsSlugs = rawArticle.fields.tags.map((tag) => tag.fields.slug); +export function getRelatedArticles({ rawArticle, allRawArticles }: GetRelatedArticlesParams): Reference<"articles">[] { + const articleTags = new Set(rawArticle.fields.tags.map((tag) => tag.fields.slug)); return allRawArticles .filter(({ fields }) => { - const allTagsSlugs = fields.tags?.map((tag) => tag.fields.slug); + if (fields.title === rawArticle.fields.title) return; - return fields.title !== rawArticle.fields.title && allTagsSlugs?.some((slug) => articleTagsSlugs.includes(slug)); + const allTags = fields.tags?.map((tag) => tag.fields.slug) || []; + return allTags.some((slug) => articleTags.has(slug)); }) .slice(0, MAX_RELATED_ARTICLES) - .map((relatedArticle) => { - const content = documentToHtmlString(relatedArticle.fields.content as unknown as Document); - - return { - data: { - title: relatedArticle.fields.title, - slug: relatedArticle.fields.slug, - content, - description: relatedArticle.fields.description, - publishDate: new Date(String(relatedArticle.fields.publishDate)).toLocaleDateString( - "en", - DEFAULT_DATE_FORMAT, - ), - featuredImage: createImage(relatedArticle.fields.featuredImage), - variant: relatedArticle.fields.featuredImage ? ArticleType.DEFAULT : ArticleType.NO_IMAGE, - isFeaturedArticle: relatedArticle.fields.featuredArticle, - author: getAuthor(relatedArticle.fields.author), - readingTime: getReadingTime(content), - tags: relatedArticle.fields.tags.map((tag) => ({ - name: tag.fields.name, - slug: tag.fields.slug, - })), - relatedArticles: [], - }, - }; - }) as unknown as CollectionEntry<"articles">[]; + .map((relatedArticle) => ({ + id: String(relatedArticle.fields.slug), + collection: "articles", + })); } diff --git a/src/application/dto/article/utils/getRelatedArticles/index.ts b/src/application/dto/article/utils/getRelatedArticles/index.ts index 380247d00..3e1f4861f 100644 --- a/src/application/dto/article/utils/getRelatedArticles/index.ts +++ b/src/application/dto/article/utils/getRelatedArticles/index.ts @@ -1 +1 @@ -export * from "./getRelatedArticles.ts"; +export * from "./getRelatedArticles"; diff --git a/src/application/dto/article/utils/renderOptions/renderOptions.ts b/src/application/dto/article/utils/renderOptions/renderOptions.ts index ba9af0f3f..77dfda309 100644 --- a/src/application/dto/article/utils/renderOptions/renderOptions.ts +++ b/src/application/dto/article/utils/renderOptions/renderOptions.ts @@ -1,38 +1,39 @@ -import type { RawArticle } from "@application/dto/article/types.ts"; +import type { RawArticle } from "@application/dto/article/types"; import type { Block, Inline } from "@contentful/rich-text-types"; import { BLOCKS, INLINES } from "@contentful/rich-text-types"; type Node = Block | Inline; -export const renderOptions = (rawArticle: RawArticle) => ({ - renderNode: { - [INLINES.EMBEDDED_ENTRY]: (node: Node) => { - const contentTypeId = node.data.target.sys.contentType.sys.id; - const { slug, title } = node.data.target.fields; +export function renderOptions(rawArticle: RawArticle) { + return { + renderNode: { + [INLINES.EMBEDDED_ENTRY]: (node: Node) => { + const contentTypeId = node.data.target.sys.contentType.sys.id; + const { slug, title } = node.data.target.fields; - if (contentTypeId === "article" && slug && title) { - return ` + if (contentTypeId === "article" && slug && title) { + return ` ${title} `; - } - return ""; - }, - [BLOCKS.EMBEDDED_ENTRY]: (node: Node) => { - const contentTypeId = node.data.target.sys.contentType.sys.id; - const { code, embedUrl, title } = node.data.target.fields; + } + return ""; + }, + [BLOCKS.EMBEDDED_ENTRY]: (node: Node) => { + const contentTypeId = node.data.target.sys.contentType.sys.id; + const { code, embedUrl, title } = node.data.target.fields; - if (contentTypeId === "codeBlock" && code) { - return ` + if (contentTypeId === "codeBlock" && code) { + return `
                         ${code}
                     
`; - } + } - if (contentTypeId === "videoEmbed" && embedUrl && title) { - return ` + if (contentTypeId === "videoEmbed" && embedUrl && title) { + return ` `; - } - return ""; - }, - [BLOCKS.EMBEDDED_ASSET]: (node: Node) => { - const { file, description } = node.data.target.fields; - const { url, details } = file || {}; - const { image } = details || {}; - const { height, width } = image || {}; + } + return ""; + }, + [BLOCKS.EMBEDDED_ASSET]: (node: Node) => { + const { file, description } = node.data.target.fields; + const { url, details } = file || {}; + const { image } = details || {}; + const { height, width } = image || {}; - if (url) { - return ` + if (url) { + return `
({ ${description ? `
${description}
` : ""}
`; - } - return ""; + } + return ""; + }, }, - }, -}); + }; +} diff --git a/src/application/dto/author/authorDTO.ts b/src/application/dto/author/authorDTO.ts index 3702b03bb..dda7ecd7c 100644 --- a/src/application/dto/author/authorDTO.ts +++ b/src/application/dto/author/authorDTO.ts @@ -1,15 +1,13 @@ -import { getCollection } from "astro:content"; import type { AuthorDTO, RawAuthor } from "@application/dto/author/types"; -import type { BaseDTO } from "@shared/application/dto/baseDTO.ts"; +import type { BaseDTO } from "@shared/application/dto/baseDTO"; import { createImage } from "@shared/application/dto/utils/createImage"; +import { getArticlesByAuthor } from "./utils"; export const authorDTO: BaseDTO> = { - render: async (raw) => { + create: async (raw) => { return Promise.all( raw.map(async (rawAuthor) => { - const articles = (await getCollection("articles")).filter( - (article) => article.data.author?.name === String(rawAuthor.fields.name), - ); + const articlesByAuthor = await getArticlesByAuthor(rawAuthor); return { name: rawAuthor.fields.name, @@ -19,8 +17,8 @@ export const authorDTO: BaseDTO> = { currentCompany: rawAuthor.fields.currentCompany, profileImage: createImage(rawAuthor.fields.profileImage), socialNetworks: rawAuthor.fields.socialNetworks, - articles, - latestArticle: articles[0], + articles: articlesByAuthor, + latestArticle: articlesByAuthor[0], } as unknown as AuthorDTO; }), ); diff --git a/src/application/dto/author/utils/getArticlesByAuthor.ts b/src/application/dto/author/utils/getArticlesByAuthor.ts new file mode 100644 index 000000000..6aa39628c --- /dev/null +++ b/src/application/dto/author/utils/getArticlesByAuthor.ts @@ -0,0 +1,14 @@ +import { getCollection } from "astro:content"; +import type { RawAuthor } from "@application/dto/author/types"; +import type { Reference } from "@shared/application/types"; + +export async function getArticlesByAuthor(rawAuthor: RawAuthor): Promise[]> { + const articles = await getCollection("articles"); + + return articles + .filter((article) => article.data.author.name === String(rawAuthor.fields.name)) + .map((article) => ({ + id: article.data.slug, + collection: "articles", + })); +} diff --git a/src/application/dto/author/utils/index.ts b/src/application/dto/author/utils/index.ts new file mode 100644 index 000000000..ad8ad455e --- /dev/null +++ b/src/application/dto/author/utils/index.ts @@ -0,0 +1 @@ +export * from "./getArticlesByAuthor"; diff --git a/src/application/dto/breadcrumb/breadcrumbDTO.ts b/src/application/dto/breadcrumb/breadcrumbDTO.ts index 6815c4126..f72d58700 100644 --- a/src/application/dto/breadcrumb/breadcrumbDTO.ts +++ b/src/application/dto/breadcrumb/breadcrumbDTO.ts @@ -1,11 +1,11 @@ import type { BreadcrumbDTOItem, RawBreadcrumb } from "@application/dto/breadcrumb/types"; import { deSlugify } from "@modules/core/utils/deSlugify"; -import type { BaseDTO } from "@shared/application/dto/baseDTO.ts"; +import type { BaseDTO } from "@shared/application/dto/baseDTO"; export type BreadcrumbDTO = BreadcrumbDTOItem[]; export const breadcrumbDTO: BaseDTO = { - render: ({ currentPath }): BreadcrumbDTO => { + create: ({ currentPath }): BreadcrumbDTO => { const pathSegments = currentPath.split("/").filter((segment) => segment.trim() !== ""); const breadcrumbs: BreadcrumbDTOItem[] = pathSegments.map((_, index) => { const link = `/${pathSegments.slice(0, index + 1).join("/")}`; diff --git a/src/application/dto/city/cityDTO.ts b/src/application/dto/city/cityDTO.ts index 509e2a91f..c96ad9fd4 100644 --- a/src/application/dto/city/cityDTO.ts +++ b/src/application/dto/city/cityDTO.ts @@ -1,11 +1,11 @@ -import type { CityDTO, RawCity } from "@application/dto/city/types.ts"; -import type { BaseDTO } from "@shared/application/dto/baseDTO.ts"; +import type { CityDTO, RawCity } from "@application/dto/city/types"; +import type { BaseDTO } from "@shared/application/dto/baseDTO"; import { createImage } from "@shared/application/dto/utils/createImage"; import type { ContenfulLocation } from "@shared/application/types"; import { createDate } from "./utils/createDate"; export const cityDTO: BaseDTO = { - render: (raw) => { + create: (raw) => { return raw.map((rawCity) => { const coordinates = { latitude: (rawCity.fields.coordinates as unknown as ContenfulLocation["fields"]["coordinates"]).lat, diff --git a/src/application/dto/city/utils/createDate/createDate.ts b/src/application/dto/city/utils/createDate/createDate.ts index 21b77c2a7..8979772d2 100644 --- a/src/application/dto/city/utils/createDate/createDate.ts +++ b/src/application/dto/city/utils/createDate/createDate.ts @@ -1,9 +1,9 @@ -interface ParseDatesProps { +interface ParseDatesParams { startDate: string; endDate?: string; } -export function createDate({ startDate, endDate }: ParseDatesProps) { +export function createDate({ startDate, endDate }: ParseDatesParams) { return { startDate: new Date(startDate).getFullYear(), endDate: endDate ? new Date(endDate).getFullYear() : "Present", diff --git a/src/application/dto/project/index.ts b/src/application/dto/project/index.ts index 3d10b6a07..c0ff0febb 100644 --- a/src/application/dto/project/index.ts +++ b/src/application/dto/project/index.ts @@ -1 +1 @@ -export * from "./projectDTO.ts"; +export * from "./projectDTO"; diff --git a/src/application/dto/project/projectDTO.ts b/src/application/dto/project/projectDTO.ts index 15662dd9a..540ab0f1b 100644 --- a/src/application/dto/project/projectDTO.ts +++ b/src/application/dto/project/projectDTO.ts @@ -1,14 +1,17 @@ -import type { ProjectDTO, RawProject } from "@application/dto/project/types.ts"; +import type { ProjectDTO, RawProject } from "@application/dto/project/types"; import { documentToHtmlString } from "@contentful/rich-text-html-renderer"; import type { Document } from "@contentful/rich-text-types"; -import type { BaseDTO } from "@shared/application/dto/baseDTO.ts"; +import { slugify } from "@modules/core/utils/slugify"; +import type { BaseDTO } from "@shared/application/dto/baseDTO"; import { createImage } from "@shared/application/dto/utils/createImage"; export const projectDTO: BaseDTO = { - render: (raw) => { + create: (raw) => { return raw.map((rawProject) => { + const id = rawProject.fields.id ? rawProject.fields.id : slugify(rawProject.fields.name as unknown as string); + return { - id: rawProject.fields.id, + id, name: rawProject.fields.name, description: documentToHtmlString(rawProject.fields.description as unknown as Document), image: createImage(rawProject.fields.image), diff --git a/src/application/dto/tag/tagDTO.ts b/src/application/dto/tag/tagDTO.ts index fd0b9691a..44c69bd95 100644 --- a/src/application/dto/tag/tagDTO.ts +++ b/src/application/dto/tag/tagDTO.ts @@ -1,12 +1,12 @@ import { getCollection } from "astro:content"; import { TagType } from "@application/dto/tag/types"; import type { RawTag, TagDTO } from "@application/dto/tag/types"; -import type { BaseDTO } from "@shared/application/dto/baseDTO.ts"; +import type { BaseDTO } from "@shared/application/dto/baseDTO"; import { getArticlesByTag } from "./utils/getArticlesByTag"; import { groupBy } from "./utils/groupBy"; export const tagDTO: BaseDTO> = { - render: async (raw) => { + create: async (raw) => { const articles = await getCollection("articles"); const tags = await Promise.all( diff --git a/src/application/dto/tag/types.ts b/src/application/dto/tag/types.ts index c3b4a47f6..09880d984 100644 --- a/src/application/dto/tag/types.ts +++ b/src/application/dto/tag/types.ts @@ -1,4 +1,4 @@ -import type { tagSchema } from "@application/entities/tags/schema.ts"; +import type { tagSchema } from "@application/entities/tags/schema"; import type { EntryFieldTypes } from "contentful"; import type { z } from "zod"; diff --git a/src/application/dto/tag/utils/getArticlesByTag/getArticlesByTag.ts b/src/application/dto/tag/utils/getArticlesByTag/getArticlesByTag.ts index 13f77f840..a423ebaaa 100644 --- a/src/application/dto/tag/utils/getArticlesByTag/getArticlesByTag.ts +++ b/src/application/dto/tag/utils/getArticlesByTag/getArticlesByTag.ts @@ -1,13 +1,17 @@ import type { CollectionEntry } from "astro:content"; -import type { RawTag } from "@application/dto/tag/types.ts"; +import type { RawTag } from "@application/dto/tag/types"; +import type { Reference } from "@shared/application/types"; -interface GetArticlesProps { +interface GetArticlesParams { rawTag: RawTag; articles: CollectionEntry<"articles">[]; } -export function getArticlesByTag({ rawTag, articles }: GetArticlesProps) { - return articles.filter((article) => - article.data.tags.map((tag) => tag.slug).includes(rawTag.fields.slug as unknown as string), - ); +export function getArticlesByTag({ rawTag, articles }: GetArticlesParams): Reference<"articles">[] { + return articles + .filter(({ data: { tags } }) => tags?.some(({ slug }) => slug === String(rawTag.fields.slug))) + .map(({ data: { slug } }) => ({ + id: slug, + collection: "articles", + })); } diff --git a/src/application/dto/tag/utils/getArticlesByTag/index.ts b/src/application/dto/tag/utils/getArticlesByTag/index.ts index 55c25a7e6..8877fe4b9 100644 --- a/src/application/dto/tag/utils/getArticlesByTag/index.ts +++ b/src/application/dto/tag/utils/getArticlesByTag/index.ts @@ -1 +1 @@ -export * from "./getArticlesByTag.ts"; +export * from "./getArticlesByTag"; diff --git a/src/application/dto/tag/utils/groupBy/groupBy.ts b/src/application/dto/tag/utils/groupBy/groupBy.ts index b274602f2..d9bb319b8 100644 --- a/src/application/dto/tag/utils/groupBy/groupBy.ts +++ b/src/application/dto/tag/utils/groupBy/groupBy.ts @@ -1,9 +1,9 @@ -interface GroupByProps { +interface GroupByParams { array: T[]; keyFn: (item: T) => K; } -export function groupBy({ array, keyFn }: GroupByProps): Record { +export function groupBy({ array, keyFn }: GroupByParams): Record { const grouped = array.reduce( (acc, currentItem) => { const key = keyFn(currentItem); diff --git a/src/application/dto/testimonial/testimonialDTO.ts b/src/application/dto/testimonial/testimonialDTO.ts index 0eea064fe..caab4e4e8 100644 --- a/src/application/dto/testimonial/testimonialDTO.ts +++ b/src/application/dto/testimonial/testimonialDTO.ts @@ -1,9 +1,9 @@ -import type { RawTestimonial, TestimonialDTO } from "@application/dto/testimonial/types.ts"; -import type { BaseDTO } from "@shared/application/dto/baseDTO.ts"; +import type { RawTestimonial, TestimonialDTO } from "@application/dto/testimonial/types"; +import type { BaseDTO } from "@shared/application/dto/baseDTO"; import { createImage } from "@shared/application/dto/utils/createImage"; export const testimonialDTO: BaseDTO = { - render: (raw) => { + create: (raw) => { return raw.map((rawTestimonial) => { return { author: rawTestimonial.fields.author, diff --git a/src/application/entities/articles/articles.ts b/src/application/entities/articles/articles.ts index 3ade6dfeb..fb246e53f 100644 --- a/src/application/entities/articles/articles.ts +++ b/src/application/entities/articles/articles.ts @@ -1,8 +1,8 @@ import { defineCollection } from "astro:content"; import { articleDTO } from "@application/dto/article"; -import type { RawArticle } from "@application/dto/article/types.ts"; -import { articleSchema } from "@application/entities/articles/schema.ts"; -import { client } from "@infrastructure/cms/client.ts"; +import type { RawArticle } from "@application/dto/article/types"; +import { articleSchema } from "@application/entities/articles/schema"; +import { client } from "@infrastructure/cms/client"; export const articles = defineCollection({ loader: async () => { @@ -10,8 +10,8 @@ export const articles = defineCollection({ content_type: "article", order: ["-fields.publishDate"], }); - const articles = articleDTO.render(rawArticles as unknown as RawArticle[]); + const articles = articleDTO.create(rawArticles as unknown as RawArticle[]); return articles.map((article) => ({ id: article.slug, ...article, diff --git a/src/application/entities/articles/schema.ts b/src/application/entities/articles/schema.ts index dd6d8a4a9..685ff82ec 100644 --- a/src/application/entities/articles/schema.ts +++ b/src/application/entities/articles/schema.ts @@ -1,7 +1,7 @@ -import { z } from "astro:content"; -import { ArticleType } from "@application/dto/article/types.ts"; +import { reference, z } from "astro:content"; +import { ArticleType } from "@application/dto/article/types"; import { authorSchema } from "@application/entities/authors"; -import { tagSchema } from "@application/entities/tags/schema.ts"; +import { tagSchema } from "@application/entities/tags/schema"; import { imageSchema } from "@shared/application/entities"; export const articleSchema = z.object({ @@ -10,11 +10,11 @@ export const articleSchema = z.object({ slug: z.string(), description: z.string(), publishDate: z.string(), - featuredImage: imageSchema, + featuredImage: imageSchema.optional(), + isFeaturedArticle: z.boolean(), variant: z.enum([ArticleType.NO_IMAGE, ArticleType.DEFAULT]), content: z.string(), - isFeaturedArticle: z.boolean(), readingTime: z.number(), - tags: z.array(tagSchema), - relatedArticles: z.array(z.any()).default([]), + tags: z.array(tagSchema).optional(), + relatedArticles: z.array(reference("articles")).default([]), }); diff --git a/src/application/entities/authors/authors.ts b/src/application/entities/authors/authors.ts index 036a791ae..8bdef0a3c 100644 --- a/src/application/entities/authors/authors.ts +++ b/src/application/entities/authors/authors.ts @@ -1,15 +1,15 @@ import { defineCollection, reference, z } from "astro:content"; import { authorDTO } from "@application/dto/author"; -import type { RawAuthor } from "@application/dto/author/types.ts"; -import { authorSchema } from "@application/entities/authors/schema.ts"; -import { client } from "@infrastructure/cms/client.ts"; +import type { RawAuthor } from "@application/dto/author/types"; +import { authorSchema } from "@application/entities/authors/schema"; +import { client } from "@infrastructure/cms/client"; export const authors = defineCollection({ loader: async () => { const { items: rawAuthors } = await client.getEntries({ content_type: "author", }); - const authors = await authorDTO.render(rawAuthors as unknown as RawAuthor[]); + const authors = await authorDTO.create(rawAuthors as unknown as RawAuthor[]); return authors.map((author) => ({ id: author.name, @@ -18,6 +18,6 @@ export const authors = defineCollection({ }, schema: authorSchema.extend({ articles: z.array(reference("articles")), - latestArticle: reference("articles"), + latestArticle: reference("articles").default(""), }), }); diff --git a/src/application/entities/authors/schema.ts b/src/application/entities/authors/schema.ts index 45de0d423..ec60ad9f0 100644 --- a/src/application/entities/authors/schema.ts +++ b/src/application/entities/authors/schema.ts @@ -8,5 +8,5 @@ export const authorSchema = z.object({ jobTitle: z.string(), currentCompany: z.string(), profileImage: imageSchema, - socialNetworks: z.array(z.string()), + socialNetworks: z.array(z.string().url()), }); diff --git a/src/application/entities/cities/cities.ts b/src/application/entities/cities/cities.ts index bf0b0e9a4..5719965e8 100644 --- a/src/application/entities/cities/cities.ts +++ b/src/application/entities/cities/cities.ts @@ -1,8 +1,8 @@ import { defineCollection } from "astro:content"; import { cityDTO } from "@application/dto/city"; -import type { RawCity } from "@application/dto/city/types.ts"; -import { citiesSchema } from "@application/entities/cities/schema.ts"; -import { client } from "@infrastructure/cms/client.ts"; +import type { RawCity } from "@application/dto/city/types"; +import { citiesSchema } from "@application/entities/cities/schema"; +import { client } from "@infrastructure/cms/client"; export const cities = defineCollection({ loader: async () => { @@ -10,7 +10,7 @@ export const cities = defineCollection({ content_type: "city", order: ["fields.startDate"], }); - const cities = cityDTO.render(rawCities as unknown as RawCity[]); + const cities = cityDTO.create(rawCities as unknown as RawCity[]); return cities.map((city) => ({ id: city.name, diff --git a/src/application/entities/contact/schema.ts b/src/application/entities/contact/schema.ts new file mode 100644 index 000000000..d0cd50db4 --- /dev/null +++ b/src/application/entities/contact/schema.ts @@ -0,0 +1,7 @@ +import { z } from "astro:schema"; + +export const contactFormSchema = z.object({ + name: z.string().trim().min(1, "Please insert your name"), + email: z.string().trim().min(1, "Please insert a valid email").email({ message: "Still not a valid email fella" }), + message: z.string().trim().min(1, "Please insert a valid message"), +}); diff --git a/src/application/entities/projects/projects.ts b/src/application/entities/projects/projects.ts index efe5c1f27..a7e32b0ab 100644 --- a/src/application/entities/projects/projects.ts +++ b/src/application/entities/projects/projects.ts @@ -1,8 +1,8 @@ import { defineCollection } from "astro:content"; import { projectDTO } from "@application/dto/project"; -import type { RawProject } from "@application/dto/project/types.ts"; -import { projectsSchema } from "@application/entities/projects/schema.ts"; -import { client } from "@infrastructure/cms/client.ts"; +import type { RawProject } from "@application/dto/project/types"; +import { projectsSchema } from "@application/entities/projects/schema"; +import { client } from "@infrastructure/cms/client"; export const projects = defineCollection({ loader: async () => { @@ -10,7 +10,7 @@ export const projects = defineCollection({ content_type: "project", }); - return projectDTO.render(rawProjects as unknown as RawProject[]); + return projectDTO.create(rawProjects as unknown as RawProject[]); }, schema: projectsSchema, }); diff --git a/src/application/entities/tags/tags.ts b/src/application/entities/tags/tags.ts index f290379f0..e8744bef2 100644 --- a/src/application/entities/tags/tags.ts +++ b/src/application/entities/tags/tags.ts @@ -1,9 +1,9 @@ import { defineCollection, reference, z } from "astro:content"; import { tagDTO } from "@application/dto/tag"; -import type { RawTag } from "@application/dto/tag/types.ts"; -import { TagType } from "@application/dto/tag/types.ts"; -import { tagSchema } from "@application/entities/tags/schema.ts"; -import { client } from "@infrastructure/cms/client.ts"; +import type { RawTag } from "@application/dto/tag/types"; +import { TagType } from "@application/dto/tag/types"; +import { tagSchema } from "@application/entities/tags/schema"; +import { client } from "@infrastructure/cms/client"; export const tags = defineCollection({ loader: async () => { @@ -11,7 +11,7 @@ export const tags = defineCollection({ content_type: "tag", }); - const tags = await tagDTO.render(rawTags as unknown as RawTag[]); + const tags = await tagDTO.create(rawTags as unknown as RawTag[]); return Object.keys(tags).map((letter) => ({ id: letter, diff --git a/src/application/entities/testimonials/testimonials.ts b/src/application/entities/testimonials/testimonials.ts index 79b9bd8e9..2c3cc2034 100644 --- a/src/application/entities/testimonials/testimonials.ts +++ b/src/application/entities/testimonials/testimonials.ts @@ -1,15 +1,15 @@ import { defineCollection } from "astro:content"; import { testimonialDTO } from "@application/dto/testimonial"; -import type { RawTestimonial } from "@application/dto/testimonial/types.ts"; -import { testimonialsSchema } from "@application/entities/testimonials/schema.ts"; -import { client } from "@infrastructure/cms/client.ts"; +import type { RawTestimonial } from "@application/dto/testimonial/types"; +import { testimonialsSchema } from "@application/entities/testimonials/schema"; +import { client } from "@infrastructure/cms/client"; export const testimonials = defineCollection({ loader: async () => { const { items: rawTestimonials } = await client.getEntries({ content_type: "testimonial", }); - const testimonials = testimonialDTO.render(rawTestimonials as unknown as RawTestimonial[]); + const testimonials = testimonialDTO.create(rawTestimonials as unknown as RawTestimonial[]); return testimonials.map((testimonial) => ({ id: testimonial.author, diff --git a/src/const/const.ts b/src/const/const.ts index 99c0f0d88..054fc8ebb 100644 --- a/src/const/const.ts +++ b/src/const/const.ts @@ -1,32 +1,62 @@ import { BIANCA_EMAIL } from "astro:env/client"; import biancaImage from "@assets/images/jpg/bianca-fiore.jpg"; +import { capitalizeKeys } from "@const/utils/capitalizeKeys"; +import { lowercaseKeys } from "@const/utils/lowercaseKeys"; import { A11y, Autoplay, Keyboard, Navigation, Virtual } from "swiper/modules"; import type { SwiperOptions } from "swiper/types"; -import type { CapitalizeKeys, SeoMetadata, WorldGlobeConfig } from "./types.ts"; +import type { CapitalizeKeys, SeoMetadata, WorldGlobeConfig } from "./types"; + +export enum Pages { + HOME = "home", + PROJECTS = "projects", + ABOUT = "about", + ARTICLES = "articles", + ARTICLE = "article", + CONTACT = "contact", + TAGS = "tags", + TAG = "tag", + TERMS_AND_CONDITIONS = "terms-and-conditions", + PRIVACY_POLICY = "privacy-policy", +} + +const pagesRoutes = { + [Pages.ARTICLE]: "/articles/", + [Pages.ARTICLES]: "/articles", + [Pages.ABOUT]: "/about", + [Pages.TAGS]: "/tags", + [Pages.TAG]: "/tags/", + [Pages.CONTACT]: "/contact", + [Pages.PROJECTS]: "/projects", + [Pages.TERMS_AND_CONDITIONS]: "/terms-and-conditions", + [Pages.PRIVACY_POLICY]: "/privacy-policy", + [Pages.HOME]: "/", +} as const; + +export const PAGES_ROUTES: CapitalizeKeys = capitalizeKeys(pagesRoutes); export const DEFAULT_SEO_PARAMS: CapitalizeKeys = { TITLE: "Bianca Fiore", SITE: "biancafiore.me", DESCRIPTION: "Welcome to my website!", ROBOTS: { - index: true, - follow: true, + INDEX: true, + FOLLOW: true, }, IMAGE: (biancaImage as unknown as ProtoImage).src, -}; +} as unknown as CapitalizeKeys; -export const CONTACT_DETAILS: Record = { +export const CONTACT_DETAILS: Record, string> = { NAME: "Bianca Fiore", - EMAIL_SUBJECT: "Contact form submission", + EMAIL_SUBJECT: "Web contact form submission", ENCODED_EMAIL_FROM: btoa("hello@biancafiore.me"), ENCODED_EMAIL_BIANCA: btoa(BIANCA_EMAIL), }; -export const SOCIAL_NETWORKS: Record = { +export const SOCIAL_NETWORKS: Record, string> = { LINKEDIN: "https://www.linkedin.com/in/bianca-fiore-88b83199", }; -export const WORLD_GLOBE_CONFIG: WorldGlobeConfig = { +export const WORLD_GLOBE_CONFIG: CapitalizeKeys = { ANIMATION_DURATION: 500, MOVEMENT_OFFSET: 20, ZOOM_OFFSET: 0.1, @@ -42,17 +72,20 @@ export const WORLD_GLOBE_CONFIG: WorldGlobeConfig = { }, }; -export const DEFAULT_SWIPER_CONFIG: SwiperOptions = { - modules: [Navigation, Keyboard, Virtual, Autoplay, A11y], - loop: true, +const defaultSwiperConfig: CapitalizeKeys = { + MODULES: [Navigation, Keyboard, Virtual, Autoplay, A11y], + LOOP: true, }; -export const DEFAULT_DATE_FORMAT: Intl.DateTimeFormatOptions = { - weekday: "long", - year: "numeric", - month: "long", - day: "numeric", +export const DEFAULT_SWIPER_CONFIG: SwiperOptions = lowercaseKeys(defaultSwiperConfig); + +const defaultDateFormat: CapitalizeKeys = { + WEEKDAY: "long", + YEAR: "numeric", + MONTH: "long", + DAY: "numeric", }; +export const DEFAULT_DATE_FORMAT: Intl.DateTimeFormatOptions = lowercaseKeys(defaultDateFormat); export const THEME_STORAGE_KEY = "theme"; diff --git a/src/const/types.ts b/src/const/types.ts index b8f7b435f..c9c078e8a 100644 --- a/src/const/types.ts +++ b/src/const/types.ts @@ -2,22 +2,26 @@ export type CapitalizeKeys = { [K in keyof T as Uppercase]: T[K] extends object ? CapitalizeKeys : T[K]; }; +export type LowercaseKeys = { + [K in keyof T as Lowercase]: T[K] extends object ? LowercaseKeys : T[K]; +}; + interface MeshPhongMaterialConfig { - TRANSPARENT: boolean; - COLOR: string; - OPACITY: number; + transparent: boolean; + color: string; + opacity: number; } export interface WorldGlobeConfig { - ANIMATION_DURATION: number; - MOVEMENT_OFFSET: number; - ZOOM_OFFSET: number; - POINTS_MERGE: boolean; - ANIMATE_IN: boolean; - SHOW_ATMOSPHERE: boolean; - BACKGROUND_COLOR: string; - HEXAGON_POLYGON_COLOR: string; - MESH_PHONG_MATERIAL_CONFIG: MeshPhongMaterialConfig; + animation_duration: number; + movement_offset: number; + zoom_offset: number; + points_merge: boolean; + animate_in: boolean; + show_atmosphere: boolean; + background_color: string; + hexagon_polygon_color: string; + mesh_phong_material_config: MeshPhongMaterialConfig; } export interface SeoMetadata { diff --git a/src/const/utils/capitalizeKeys/capitalizeKeys.ts b/src/const/utils/capitalizeKeys/capitalizeKeys.ts new file mode 100644 index 000000000..ae954016e --- /dev/null +++ b/src/const/utils/capitalizeKeys/capitalizeKeys.ts @@ -0,0 +1,12 @@ +import type { CapitalizeKeys } from "../../types"; + +export function capitalizeKeys>(object: T): CapitalizeKeys { + const result = {} as CapitalizeKeys; + for (const key in object) { + if (Object.hasOwn(object, key)) { + const capitalizedKey = key.toUpperCase() as Uppercase; + result[capitalizedKey] = object[key as keyof T] as CapitalizeKeys[Uppercase]; + } + } + return result; +} diff --git a/src/const/utils/capitalizeKeys/index.ts b/src/const/utils/capitalizeKeys/index.ts new file mode 100644 index 000000000..3ed89b6c0 --- /dev/null +++ b/src/const/utils/capitalizeKeys/index.ts @@ -0,0 +1 @@ +export * from "./capitalizeKeys"; diff --git a/src/const/utils/lowercaseKeys/index.ts b/src/const/utils/lowercaseKeys/index.ts new file mode 100644 index 000000000..dd3730f22 --- /dev/null +++ b/src/const/utils/lowercaseKeys/index.ts @@ -0,0 +1 @@ +export * from "src/const/utils/lowercaseKeys/lowecaseKeys"; diff --git a/src/const/utils/lowercaseKeys/lowecaseKeys.ts b/src/const/utils/lowercaseKeys/lowecaseKeys.ts new file mode 100644 index 000000000..35aeb975b --- /dev/null +++ b/src/const/utils/lowercaseKeys/lowecaseKeys.ts @@ -0,0 +1,12 @@ +import type { LowercaseKeys } from "../../types"; + +export function lowercaseKeys>(object: T): LowercaseKeys { + const result = {} as LowercaseKeys; + for (const key in object) { + if (Object.hasOwn(object, key)) { + const lowercasedKey = key.toLowerCase() as Lowercase; + result[lowercasedKey] = object[key as keyof T] as LowercaseKeys[Lowercase]; + } + } + return result; +} diff --git a/src/domain/errors/Exception.ts b/src/domain/errors/Exception.ts new file mode 100644 index 000000000..c42e2fe29 --- /dev/null +++ b/src/domain/errors/Exception.ts @@ -0,0 +1,16 @@ +import type { ActionErrorCode } from "astro/dist/actions/runtime/virtual/shared"; + +interface ExceptionParams { + message: string; + code?: ActionErrorCode; +} + +export class Exception extends Error { + code: ActionErrorCode; + + constructor({ message, code = "INTERNAL_SERVER_ERROR" }: ExceptionParams) { + super(message); + this.name = "Exception"; + this.code = code; + } +} diff --git a/src/domain/errors/index.ts b/src/domain/errors/index.ts new file mode 100644 index 000000000..c8822c5d3 --- /dev/null +++ b/src/domain/errors/index.ts @@ -0,0 +1 @@ +export * from "./Exception"; diff --git a/src/infrastructure/cms/client.ts b/src/infrastructure/cms/client.ts index d05b01085..ffac538e9 100644 --- a/src/infrastructure/cms/client.ts +++ b/src/infrastructure/cms/client.ts @@ -2,9 +2,9 @@ import { getSecret } from "astro:env/server"; import contentful from "contentful"; export const client = contentful.createClient({ - space: getSecret("CONTENTFUL_SPACE_ID") as string, - accessToken: import.meta.env.DEV - ? (getSecret("CONTENTFUL_PREVIEW_TOKEN") as string) - : (getSecret("CONTENTFUL_DELIVERY_TOKEN") as string), - host: import.meta.env.DEV ? "preview.contentful.com" : "cdn.contentful.com", + space: getSecret("CONTENTFUL_SPACE_ID") as string, + accessToken: import.meta.env.DEV + ? (getSecret("CONTENTFUL_PREVIEW_TOKEN") as string) + : (getSecret("CONTENTFUL_DELIVERY_TOKEN") as string), + host: import.meta.env.DEV ? "preview.contentful.com" : "cdn.contentful.com", }); diff --git a/src/infrastructure/database/server.ts b/src/infrastructure/database/server.ts index 812538109..61e06a648 100644 --- a/src/infrastructure/database/server.ts +++ b/src/infrastructure/database/server.ts @@ -3,6 +3,7 @@ import type { ServiceAccount } from "firebase-admin"; import { cert, getApps, initializeApp } from "firebase-admin/app"; const activeApps = getApps(); + const SERVICE_ACCOUNT = { type: "service_account", project_id: getSecret("FIREBASE_PROJECT_ID"), diff --git a/src/infrastructure/email/server.ts b/src/infrastructure/email/server.ts index 287c2b788..239521199 100644 --- a/src/infrastructure/email/server.ts +++ b/src/infrastructure/email/server.ts @@ -1,24 +1,4 @@ import { getSecret } from "astro:env/server"; -import { CONTACT_DETAILS } from "@const/index.ts"; -import type { FormData } from "@modules/contact/components/contactForm/ContactForm.tsx"; import { Resend } from "resend"; -type ContactDetails = Omit; - -const { emails } = new Resend(getSecret("RESEND_API_KEY")); - -export async function sendEmail({ name, message, email }: ContactDetails) { - const { data, error } = await emails.send({ - from: `${name} <${atob(CONTACT_DETAILS.ENCODED_EMAIL_FROM)}>`, - to: atob(CONTACT_DETAILS.ENCODED_BIANCA_EMAIL), - subject: `${CONTACT_DETAILS.EMAIL_SUBJECT} from ${name} (${email})`, - html: `Hello sweetheart!

-

${name} with email: ${email} has sent you the following message through the contact form of the web.

- ${message}

- Reply directly by clicking: Reply`, - }); - - return { data, error }; -} +export const { emails } = new Resend(getSecret("RESEND_API_KEY")); diff --git a/src/infrastructure/utils/checkDuplicatedEntries/checkDuplicatedEntries.ts b/src/infrastructure/utils/checkDuplicatedEntries/checkDuplicatedEntries.ts new file mode 100644 index 000000000..419deccf7 --- /dev/null +++ b/src/infrastructure/utils/checkDuplicatedEntries/checkDuplicatedEntries.ts @@ -0,0 +1,20 @@ +import { Exception } from "@domain/errors"; +import type { ContactFormData } from "@shared/ui/types.ts"; + +interface IsDuplicatedContactParams { + databaseRef: FirebaseFirestore.CollectionReference; + data: Omit; +} + +const ALIAS_REGEX = /(\+.*?)(?=@)/; + +export async function checkDuplicatedEntries({ databaseRef, data }: IsDuplicatedContactParams): Promise { + const { empty } = await databaseRef.where("email", "==", data.email.replace(ALIAS_REGEX, "")).limit(1).get(); + + if (!empty) { + throw new Exception({ + message: "You already contacted. Please be patient, I will get back to you ASAP.", + code: "UNAUTHORIZED", + }); + } +} diff --git a/src/infrastructure/utils/checkDuplicatedEntries/index.ts b/src/infrastructure/utils/checkDuplicatedEntries/index.ts new file mode 100644 index 000000000..e2510a3b5 --- /dev/null +++ b/src/infrastructure/utils/checkDuplicatedEntries/index.ts @@ -0,0 +1 @@ +export * from "./checkDuplicatedEntries"; diff --git a/src/infrastructure/utils/createEmail/createEmail.ts b/src/infrastructure/utils/createEmail/createEmail.ts new file mode 100644 index 000000000..4ea5d7fb2 --- /dev/null +++ b/src/infrastructure/utils/createEmail/createEmail.ts @@ -0,0 +1,109 @@ +import { CONTACT_DETAILS, DEFAULT_LOCALE_STRING } from "@const/const.ts"; +import type { ContactFormData } from "@shared/ui/types.ts"; + +type GenerateHtmlParams = Omit; + +const URL_ENCODED_SPACE_REGEX = /%20/g; + +export function createEmail({ name, email, message }: GenerateHtmlParams): string { + const date = new Date().toLocaleString(DEFAULT_LOCALE_STRING); + const mailTo = + `mailto:${email}?subject=Re: ${encodeURIComponent(CONTACT_DETAILS.EMAIL_SUBJECT)} from biancafiore.me`.replace( + URL_ENCODED_SPACE_REGEX, + " ", + ); + + return ` + + + + New web contact form submission + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

+ BiancaFiore +

+
+

+ It's all about details innit? +

+
+ Name: + ${name}
+ Email: + ${email}
+ Date: + ${date}
Message:
${message}
+

+ Reply directly by clicking the following button: +

+ + Reply + +
+ biancafiore.me +
+ Sent with 🖤 by Ciccino Pastino +
+
+
+ +; +`; +} diff --git a/src/infrastructure/utils/createEmail/index.ts b/src/infrastructure/utils/createEmail/index.ts new file mode 100644 index 000000000..4dfe3d087 --- /dev/null +++ b/src/infrastructure/utils/createEmail/index.ts @@ -0,0 +1 @@ +export * from "./createEmail"; diff --git a/src/infrastructure/utils/saveContact/index.ts b/src/infrastructure/utils/saveContact/index.ts new file mode 100644 index 000000000..bae0a75c2 --- /dev/null +++ b/src/infrastructure/utils/saveContact/index.ts @@ -0,0 +1 @@ +export * from "./saveContact"; diff --git a/src/infrastructure/utils/saveContact/saveContact.ts b/src/infrastructure/utils/saveContact/saveContact.ts new file mode 100644 index 000000000..45a4ac927 --- /dev/null +++ b/src/infrastructure/utils/saveContact/saveContact.ts @@ -0,0 +1,18 @@ +import { DEFAULT_LOCALE_STRING } from "@const/index"; +import type { ContactFormData } from "@shared/ui/types"; +import type { CreateEmailResponseSuccess } from "resend"; + +interface SaveContactParams { + contactData: Omit & CreateEmailResponseSuccess; + databaseRef: FirebaseFirestore.CollectionReference; +} + +export async function saveContact({ databaseRef, contactData }: SaveContactParams): Promise { + await databaseRef.add({ + id: contactData.id, + name: contactData.name, + email: contactData.email, + message: contactData.message, + date: new Date().toLocaleString(DEFAULT_LOCALE_STRING), + }); +} diff --git a/src/infrastructure/utils/sendEmail/index.ts b/src/infrastructure/utils/sendEmail/index.ts new file mode 100644 index 000000000..079277d10 --- /dev/null +++ b/src/infrastructure/utils/sendEmail/index.ts @@ -0,0 +1 @@ +export * from "./sendEmail"; diff --git a/src/infrastructure/utils/sendEmail/sendEmail.ts b/src/infrastructure/utils/sendEmail/sendEmail.ts new file mode 100644 index 000000000..22bb68348 --- /dev/null +++ b/src/infrastructure/utils/sendEmail/sendEmail.ts @@ -0,0 +1,33 @@ +import { CONTACT_DETAILS } from "@const/index"; +import { Exception } from "@domain/errors"; +import { emails } from "@infrastructure/email/server"; +import { createEmail } from "@infrastructure/utils/createEmail"; +import type { ContactFormData } from "@shared/ui/types"; +import type { CreateEmailResponseSuccess } from "resend"; + +type SendEmailParams = Omit; + +export async function sendEmail(params: SendEmailParams): Promise { + const email = createEmail({ ...params }); + + const { data, error } = await emails.send({ + from: `${params.name} <${atob(CONTACT_DETAILS.ENCODED_EMAIL_FROM)}>`, + to: atob(CONTACT_DETAILS.ENCODED_EMAIL_BIANCA), + subject: `${CONTACT_DETAILS.EMAIL_SUBJECT} from ${params.name} (${params.email})`, + tags: [ + { + name: "category", + value: "web_contact_form", + }, + ], + html: email, + }); + + if (error || !data) { + throw new Exception({ + message: "Something went wrong while sending the email", + }); + } + + return { id: data.id }; +} diff --git a/src/infrastructure/utils/validateContact/index.ts b/src/infrastructure/utils/validateContact/index.ts new file mode 100644 index 000000000..dc2bea0b2 --- /dev/null +++ b/src/infrastructure/utils/validateContact/index.ts @@ -0,0 +1 @@ +export * from "./validateContact"; diff --git a/src/infrastructure/utils/validateContact/validateContact.ts b/src/infrastructure/utils/validateContact/validateContact.ts new file mode 100644 index 000000000..3ae3e9c20 --- /dev/null +++ b/src/infrastructure/utils/validateContact/validateContact.ts @@ -0,0 +1,18 @@ +import { contactFormSchema } from "@application/entities/contact/schema"; +import { Exception } from "@domain/errors"; +import type { ContactFormData } from "@shared/ui/types"; + +type ValidateContact = Omit; + +export function validateContact(contact: ValidateContact) { + const { success, data, error } = contactFormSchema.safeParse(contact); + + if (!success) { + throw new Exception({ + message: error?.errors.join(", ") || "Invalid data", + code: "BAD_REQUEST", + }); + } + + return data; +} diff --git a/src/pages/404.astro b/src/pages/404.astro index cf9414a89..abff73915 100644 --- a/src/pages/404.astro +++ b/src/pages/404.astro @@ -15,7 +15,6 @@ const metadata: Partial = { }, }; --- -

{title}

diff --git a/src/pages/500.astro b/src/pages/500.astro index 66610cb18..6b2cfec17 100644 --- a/src/pages/500.astro +++ b/src/pages/500.astro @@ -19,7 +19,6 @@ const metadata: Partial = { }, }; --- -

{title}

diff --git a/src/pages/about.astro b/src/pages/about.astro index d0dfe599a..e3cc5afce 100644 --- a/src/pages/about.astro +++ b/src/pages/about.astro @@ -1,15 +1,15 @@ --- import { getCollection, getEntry } from "astro:content"; import type { CollectionEntry } from "astro:content"; -import { CONTACT_DETAILS } from "@const/index"; +import { CONTACT_DETAILS, PAGES_ROUTES } from "@const/index"; +import type { SeoMetadata } from "@const/types"; import AboutIntro from "@modules/about/components/aboutIntro/AboutIntro.astro"; import AboutLatestArticles from "@modules/about/components/aboutLatestArticles/AboutLatestArticles.astro"; -import { LittleMoreOfMe } from "@modules/about/components/littleMoreOfMe/LittleMoreOfMe.tsx"; +import { LittleMoreOfMe } from "@modules/about/components/littleMoreOfMe/LittleMoreOfMe"; import BaseLayout from "@modules/core/components/baseLayout/BaseLayout.astro"; import Breadcrumbs from "@modules/core/components/breadcrumbs/Breadcrumbs.astro"; -const cities = await getCollection("cities"); -const authors = await getCollection("authors"); +const [cities, authors] = await Promise.all([getCollection("cities"), getCollection("authors")]); const author = authors .filter((author) => author.data.name === CONTACT_DETAILS.NAME) @@ -22,9 +22,11 @@ const bianca = { latestArticle: await getEntry(author.data.latestArticle.collection, author.data.latestArticle.id), }, }; +const metadata: Partial = { + title: "About me", +}; --- - - + @@ -48,7 +50,7 @@ const bianca = { { '@type': 'Article', 'headline': bianca.data.latestArticle.data.title, - 'url': new URL(`articles/${bianca.data.latestArticle.data.slug}`, Astro.url).href, + 'url': new URL(`${PAGES_ROUTES.ARTICLES}/${bianca.data.latestArticle.data.slug}`, Astro.url).href, 'datePublished': bianca.data.latestArticle.data.publishDate, 'author': { '@id': '#bianca' }, }], diff --git a/src/pages/articles/[...slug].astro b/src/pages/articles/[...slug].astro index f4ad9a65e..110dcce72 100644 --- a/src/pages/articles/[...slug].astro +++ b/src/pages/articles/[...slug].astro @@ -2,6 +2,9 @@ import { Image } from "astro:assets"; import { getCollection } from "astro:content"; import { SITE_URL } from "astro:env/client"; +import { PAGES_ROUTES } from "@const/index"; +import type { SeoMetadata } from "@const/types"; +import ArticleDetails from "@modules/article/components/articleDetails/ArticleDetails.astro"; import ReadingProgress from "@modules/articles/components/readingProgress/ReadingProgress.astro"; import BaseLayout from "@modules/core/components/baseLayout/BaseLayout.astro"; import Breadcrumbs from "@modules/core/components/breadcrumbs/Breadcrumbs.astro"; @@ -30,12 +33,11 @@ export async function getStaticPaths() { const { pathname } = Astro.url; const { article } = Astro.props as ArticleProps; -const metadata = { +const metadata: Partial = { ...article, ...(article.data.featuredImage && { image: article.data.featuredImage.url }), }; --- - { article.data.featuredImage && ( @@ -53,26 +55,7 @@ const metadata = { } -
-

- {article.data.title} -

- - - { - article.data.tags?.length > 0 && ( - - ) - } -
+