diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6d988c2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,49 @@ +# Dependencies +node_modules +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Next.js build output +.next +out + +# Production +build +dist + +# Environment files +.env*.local +.env.local + +# Version control +.git +.gitignore + +# IDE +.vscode +.idea +*.swp +*.swo +*.swn +.DS_Store + +# Testing +coverage +.nyc_output + +# Misc +.dockerignore +Dockerfile +docker-compose.yml +README.md +.eslintrc* +.prettierrc* + +# Temporary files +*.log +tmp +temp + +# Serena files +.serena \ No newline at end of file diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..4e68443 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,26 @@ +name: Build And Push Image + +on: [push] + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - name: Login To Registry + uses: docker/login-action@v3 + with: + registry: ${{ vars.REGISTRY_HOST }} + username: ${{ vars.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Set Up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build And Push + uses: docker/build-push-action@v6 + with: + platforms: linux/amd64 + push: true + tags: | + ${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_USERNAME }}/${{ vars.IMAGE_NAME }}:latest + ${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_USERNAME }}/${{ vars.IMAGE_NAME }}:${{ github.sha }} \ No newline at end of file diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..14d86ad --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1 @@ +/cache diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..7461983 --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,67 @@ +# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby) +# * For C, use cpp +# * For JavaScript, use typescript +# Special requirements: +# * csharp: Requires the presence of a .sln file in the project folder. +language: typescript + +# whether to use the project's gitignore file to ignore files +# Added on 2025-04-07 +ignore_all_files_in_gitignore: true +# list of additional paths to ignore +# same syntax as gitignore, so you can use * and ** +# Was previously called `ignored_dirs`, please update your config if you are using that. +# Added (renamed) on 2025-04-07 +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. +# Below is the complete list of tools for convenience. +# To make sure you have the latest list of tools, and to view their descriptions, +# execute `uv run scripts/print_tool_overview.py`. +# +# * `activate_project`: Activates a project by name. +# * `check_onboarding_performed`: Checks whether project onboarding was already performed. +# * `create_text_file`: Creates/overwrites a file in the project directory. +# * `delete_lines`: Deletes a range of lines within a file. +# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. +# * `execute_shell_command`: Executes a shell command. +# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. +# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). +# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). +# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. +# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. +# * `initial_instructions`: Gets the initial instructions for the current project. +# Should only be used in settings where the system prompt cannot be set, +# e.g. in clients you have no control over, like Claude Desktop. +# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. +# * `insert_at_line`: Inserts content at a given line in a file. +# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. +# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). +# * `list_memories`: Lists memories in Serena's project-specific memory store. +# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). +# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). +# * `read_file`: Reads a file within the project directory. +# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. +# * `remove_project`: Removes a project from the Serena configuration. +# * `replace_lines`: Replaces a range of lines within a file with new content. +# * `replace_symbol_body`: Replaces the full definition of a symbol. +# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. +# * `search_for_pattern`: Performs a search for a pattern in the project. +# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. +# * `switch_modes`: Activates modes by providing a list of their names +# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. +# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. +# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. +# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +excluded_tools: [] + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +project_name: "port-amador" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a896e65 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,48 @@ +# Multi-stage build for Next.js application + +# Stage 1: Dependencies +FROM node:20-alpine AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# Copy package files +COPY package.json package-lock.json ./ +RUN npm ci + +# Stage 2: Builder +FROM node:20-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Build the Next.js application +ENV NEXT_TELEMETRY_DISABLED=1 +ENV NODE_ENV=production +RUN npm run build + +# Stage 3: Runner +FROM node:20-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +# Create a non-root user +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +# Copy built application +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +# Next.js runs on port 3000 internally +EXPOSE 3000 + +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +# Start the application +CMD ["node", "server.js"] \ No newline at end of file diff --git a/components.json b/components.json new file mode 100644 index 0000000..edcaef2 --- /dev/null +++ b/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/next.config.ts b/next.config.ts index e9ffa30..f7b60bb 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + output: 'standalone', /* config options here */ }; diff --git a/package-lock.json b/package-lock.json index fd21019..0d44e13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,20 @@ "name": "port-amador", "version": "0.1.0", "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-slot": "^1.2.3", + "@react-hook/media-query": "^1.1.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "framer-motion": "^12.23.16", + "lucide-react": "^0.544.0", "next": "15.5.3", "react": "19.1.0", - "react-dom": "19.1.0" + "react-dom": "19.1.0", + "react-hook-form": "^7.63.0", + "tailwind-merge": "^3.3.1", + "zod": "^4.1.11" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -21,6 +32,7 @@ "eslint": "^9", "eslint-config-next": "15.5.3", "tailwindcss": "^4", + "tw-animate-css": "^1.3.8", "typescript": "^5" } }, @@ -211,6 +223,18 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -959,6 +983,94 @@ "node": ">=12.4.0" } }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@react-hook/media-query": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@react-hook/media-query/-/media-query-1.1.1.tgz", + "integrity": "sha512-VM14wDOX5CW5Dn6b2lTiMd79BFMTut9AZj2+vIRT3LCKgMCYmdqruTtzDPSnIVDQdtxdPgtOzvU9oK20LopuOw==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -973,6 +1085,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1304,7 +1422,7 @@ "version": "19.1.13", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz", "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -1314,7 +1432,7 @@ "version": "19.1.9", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz", "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.0.0" @@ -2301,12 +2419,33 @@ "node": ">=18" } }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2353,7 +2492,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -3294,6 +3433,33 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/framer-motion": { + "version": "12.23.16", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.16.tgz", + "integrity": "sha512-N81A8hiHqVsexOzI3wzkibyLURW1nEJsZaRuctPhG4AdbbciYu+bKJq9I2lQFzAO4Bx3h4swI6pBbF/Hu7f7BA==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.23.12", + "motion-utils": "^12.23.6", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -4453,6 +4619,15 @@ "loose-envify": "cli.js" } }, + "node_modules/lucide-react": { + "version": "0.544.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.544.0.tgz", + "integrity": "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.19", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", @@ -4559,6 +4734,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/motion-dom": { + "version": "12.23.12", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.12.tgz", + "integrity": "sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.23.6" + } + }, + "node_modules/motion-utils": { + "version": "12.23.6", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", + "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5050,6 +5240,22 @@ "react": "^19.1.0" } }, + "node_modules/react-hook-form": { + "version": "7.63.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.63.0.tgz", + "integrity": "sha512-ZwueDMvUeucovM2VjkCf7zIHcs1aAlDimZu2Hvel5C5907gUzMpm4xCrQXtRzCvsBqFjonB4m3x4LzCFI1ZKWA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -5657,6 +5863,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tailwind-merge": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz", + "integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.13.tgz", @@ -5789,6 +6005,16 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tw-animate-css": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.3.8.tgz", + "integrity": "sha512-Qrk3PZ7l7wUcGYhwZloqfkWCmaXZAoqjkdbIDvzfGshwGtexa/DAs9koXxIkrpEasyevandomzCBAV1Yyop5rw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -6102,6 +6328,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz", + "integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 5b647ce..005cc37 100644 --- a/package.json +++ b/package.json @@ -3,25 +3,37 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev -H 0.0.0.0", "build": "next build", "start": "next start", "lint": "eslint" }, "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-slot": "^1.2.3", + "@react-hook/media-query": "^1.1.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "framer-motion": "^12.23.16", + "lucide-react": "^0.544.0", + "next": "15.5.3", "react": "19.1.0", "react-dom": "19.1.0", - "next": "15.5.3" + "react-hook-form": "^7.63.0", + "tailwind-merge": "^3.3.1", + "zod": "^4.1.11" }, "devDependencies": { - "typescript": "^5", + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", - "@tailwindcss/postcss": "^4", - "tailwindcss": "^4", "eslint": "^9", "eslint-config-next": "15.5.3", - "@eslint/eslintrc": "^3" + "tailwindcss": "^4", + "tw-animate-css": "^1.3.8", + "typescript": "^5" } } diff --git a/public/fonts/Bill corporate medium book.woff b/public/fonts/Bill corporate medium book.woff new file mode 100644 index 0000000..ba4339a Binary files /dev/null and b/public/fonts/Bill corporate medium book.woff differ diff --git a/public/fonts/Bill corporate medium roman.woff b/public/fonts/Bill corporate medium roman.woff new file mode 100644 index 0000000..cacaf51 Binary files /dev/null and b/public/fonts/Bill corporate medium roman.woff differ diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000..6ade610 Binary files /dev/null and b/public/logo.png differ diff --git a/public/marina-big.png b/public/marina-big.png new file mode 100644 index 0000000..c4439d9 Binary files /dev/null and b/public/marina-big.png differ diff --git a/public/marina.png b/public/marina.png new file mode 100644 index 0000000..4e64d85 Binary files /dev/null and b/public/marina.png differ diff --git a/src/app/api/contact/route.ts b/src/app/api/contact/route.ts new file mode 100644 index 0000000..09dc3e5 --- /dev/null +++ b/src/app/api/contact/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from 'next/server'; + +export async function POST(request: Request) { + try { + const data = await request.json(); + + // Validate required fields + const { firstName, lastName, email, phone, message } = data; + + if (!firstName || !lastName || !email || !phone) { + return NextResponse.json( + { error: 'Missing required fields' }, + { status: 400 } + ); + } + + // Email validation regex + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return NextResponse.json( + { error: 'Invalid email address' }, + { status: 400 } + ); + } + + // Log the submission (in production, you would: + // 1. Send an email notification using SendGrid, Postmark, or Resend + // 2. Store in a database like Supabase or PostgreSQL + // 3. Integrate with CRM like HubSpot or Salesforce) + + console.log('Contact form submission received:', { + firstName, + lastName, + email, + phone, + message: message || '(No message provided)', + timestamp: new Date().toISOString() + }); + + // Simulate email sending (in production, replace with actual email service) + // Example with Resend: + // await resend.emails.send({ + // from: 'onboarding@portamador.com', + // to: 'am@portamador.com', + // subject: `New Contact Form Submission from ${firstName} ${lastName}`, + // html: ` + //

New Contact Form Submission

+ //

Name: ${firstName} ${lastName}

+ //

Email: ${email}

+ //

Phone: ${phone}

+ //

Message: ${message || 'No message provided'}

+ // ` + // }); + + // Return success response + return NextResponse.json( + { + success: true, + message: 'Thank you for your submission. We will contact you soon.', + data: { + firstName, + lastName, + email + } + }, + { status: 200 } + ); + + } catch (error) { + console.error('Error processing contact form:', error); + return NextResponse.json( + { error: 'Internal server error. Please try again later.' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/contact/page.tsx b/src/app/contact/page.tsx new file mode 100644 index 0000000..b38a900 --- /dev/null +++ b/src/app/contact/page.tsx @@ -0,0 +1,596 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import Image from 'next/image'; +import { useMediaQuery } from '@react-hook/media-query'; +import { ChevronDown, Phone, Mail } from 'lucide-react'; +import { z } from 'zod'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Button } from '@/components/ui/button'; +import { + Form, + FormControl, + FormField, + FormItem, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; + +const formSchema = z.object({ + firstName: z.string().min(1, 'First name is required'), + lastName: z.string().min(1, 'Last name is required'), + email: z.string().email('Invalid email address'), + phone: z.string().min(1, 'Phone is required'), + message: z.string().optional(), +}); + +export default function ContactPage() { + const [mounted, setMounted] = useState(false); + const [logoPosition, setLogoPosition] = useState('center'); + const [logoStyle, setLogoStyle] = useState({}); + const [buttonOpacity, setButtonOpacity] = useState(1); + const [chevronOpacity, setChevronOpacity] = useState(1); + const [contactTop, setContactTop] = useState(0); + const [windowHeight, setWindowHeight] = useState(0); + + const contactSectionRef = useRef(null); + const logoRef = useRef(null); + const animationFrameRef = useRef(null); + const lastScrollY = useRef(0); + + const isMobile = useMediaQuery("(max-width: 768px)"); + const isDesktop = useMediaQuery("(min-width: 1280px)"); + + // Manage form state + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + firstName: "", + lastName: "", + email: "", + phone: "", + message: "", + }, + }); + + async function onSubmit(values: z.infer) { + console.log(values); + } + + // Logo dimensions based on screen size + const logoWidth = isMobile ? 240 : isDesktop ? 316 : 280; + const logoHeight = isMobile ? 115 : isDesktop ? 151 : 134; + + useEffect(() => { + setMounted(true); + // Set initial dimensions on mount + setWindowHeight(window.innerHeight); + if (contactSectionRef.current) { + setContactTop(contactSectionRef.current.offsetTop); + } + }, []); + + const updateLogoPosition = () => { + if (!contactSectionRef.current) return; + + const scrollY = window.scrollY; + const currentWindowHeight = window.innerHeight; + const currentContactTop = contactSectionRef.current.offsetTop; + + // Update cached values if needed + if (currentContactTop !== contactTop) { + setContactTop(currentContactTop); + } + if (currentWindowHeight !== windowHeight) { + setWindowHeight(currentWindowHeight); + } + + // Calculate positions - adjusted for scaled logo + const targetTopPosition = isMobile ? 10 : 20; // Adjusted for smaller scaled logo + + if (isMobile) { + // For mobile, calculate where the logo should end up + const startY = currentWindowHeight * 0.35; // Logo starts higher - 35% from top + const endY = targetTopPosition + (logoHeight * 0.5) / 2; // Account for 50% scale + const totalDistance = startY - endY; + + // Keep animation ending at the full contact section position + const animationEndScroll = currentContactTop; + + if (scrollY >= animationEndScroll) { + // Logo has reached destination - keep it fixed but move with scroll + setLogoPosition('top'); + + // Calculate position to simulate being part of the page + const scrollPastEnd = scrollY - animationEndScroll; + const fixedY = endY - scrollPastEnd; + const mobileScale = 0.5; // Final scale for mobile + + setLogoStyle({ + position: 'fixed', + top: `${fixedY}px`, + left: '50%', + transform: `translate3d(-50%, 0, 0) scale3d(${mobileScale}, ${mobileScale}, 1)`, + transformOrigin: 'center', + willChange: 'transform', + transition: 'none', + zIndex: 50 + }); + } else if (scrollY > 0) { + // Animate logo from center to destination - starts immediately at any scroll + const progress = scrollY / animationEndScroll; + const currentY = startY - (totalDistance * progress); + const mobileScale = 1 - (0.5 * progress); // Scale from 1.0 to 0.5 + + setLogoPosition('animating'); + setLogoStyle({ + position: 'fixed', + top: `${currentY}px`, + left: '50%', + transform: `translate3d(-50%, 0, 0) scale3d(${mobileScale}, ${mobileScale}, 1)`, + transformOrigin: 'center', + willChange: 'transform', + transition: 'none', + zIndex: 50 + }); + } else { + // At the top - logo at starting position + setLogoPosition('center'); + setLogoStyle({ + position: 'fixed', + top: `${startY}px`, + left: '50%', + transform: 'translate3d(-50%, 0, 0) scale3d(1, 1, 1)', + transformOrigin: 'center', + willChange: 'transform', + transition: 'none', + zIndex: 50 + }); + } + } else { + // Desktop - standard animation with scaling + const logoSpeed = 0.4; + const centerY = currentWindowHeight / 2; + const targetTopPosition = 20; // Reduced from 100px to account for smaller logo + const totalDistance = centerY - targetTopPosition - logoHeight / 2; + const logoYPosition = -(scrollY * logoSpeed); + const maxUpwardMovement = -totalDistance; + const animatedY = Math.max(logoYPosition, maxUpwardMovement); + + // Calculate scale based on scroll progress + const maxScroll = 500; // Scroll distance at which scaling completes + const scrollProgress = Math.min(scrollY / maxScroll, 1); + const scale = 1 - (0.6 * scrollProgress); // Scale from 1.0 to 0.4 + + // Update state based on scroll + if (scrollY > 10) { + setLogoPosition('animating'); + } else { + setLogoPosition('center'); + } + + // Fixed positioning with animation and scaling + setLogoStyle({ + position: 'fixed', + top: '50%', + left: '50%', + transform: `translate3d(-50%, calc(-50% + ${animatedY}px), 0) scale3d(${scale}, ${scale}, 1)`, + transformOrigin: 'center', + willChange: 'transform', + transition: 'none', + zIndex: 50 + }); + } + + // Hide button and chevron - faster on mobile + const fadeThreshold = isMobile ? 5 : 10; // Fade out at just 5px scroll on mobile + if (scrollY > fadeThreshold) { + setButtonOpacity(0); + setChevronOpacity(0); + } else { + setButtonOpacity(1); + setChevronOpacity(1); + } + }; + + // Animated scroll to form + const scrollToForm = () => { + if (!contactSectionRef.current) { + console.error('Contact section ref not found'); + return; + } + + console.log('Starting scroll animation to:', contactSectionRef.current.offsetTop); + + // Use native smooth scrolling + window.scrollTo({ + top: contactSectionRef.current.offsetTop, + behavior: 'smooth' + }); + }; + + // Add scroll listener for bidirectional animation with RAF + useEffect(() => { + let ticking = false; + + const handleScroll = () => { + lastScrollY.current = window.scrollY; + + if (!ticking) { + animationFrameRef.current = requestAnimationFrame(() => { + updateLogoPosition(); + ticking = false; + }); + ticking = true; + } + }; + + const handleResize = () => { + setWindowHeight(window.innerHeight); + if (contactSectionRef.current) { + setContactTop(contactSectionRef.current.offsetTop); + } + updateLogoPosition(); + }; + + // Initial position update + updateLogoPosition(); + + window.addEventListener('scroll', handleScroll, { passive: true }); + window.addEventListener('resize', handleResize); + return () => { + window.removeEventListener('scroll', handleScroll); + window.removeEventListener('resize', handleResize); + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, [isMobile, isDesktop, logoHeight, windowHeight]); + + // Don't render until mounted to avoid hydration mismatch + if (!mounted) { + return null; + } + + return ( +
+ {/* Hero Section - Full Viewport Height */} +
+ {/* Single Port Amador Logo with dynamic positioning - Always rendered */} +
+ Port Amador +
+ + {/* Button with fade out on scroll */} + + + {/* Chevron Down - with fade out on scroll */} +
+ +
+
+ + {/* Contact Section - Desktop Layout with Marina Image */} +
+ {isMobile ? ( + // Mobile Layout - Stacked with Image +
+ {/* Form Section */} +
+

+ Connect with us +

+
+ + ( + + + + + + )} + /> + ( + + + + + + )} + /> + ( + + + + + + )} + /> + ( + + + + + + )} + /> + ( + + +