From 7b4b955c99de0ae7c71cd135f673b6ae495fee09 Mon Sep 17 00:00:00 2001 From: fadilkun45 Date: Fri, 4 Sep 2026 14:58:16 +0700 Subject: [PATCH] feat: add contact form and API integration - Implemented a contact form with validation and submission functionality. - Added a dummy API endpoint for contact form submissions. - Integrated Axios for API requests and error handling. - Updated UI components for input handling and error display. - Added toast notifications for user feedback on form submission. - Included WhatsApp bubble component for enhanced user interaction. - Updated layout to include new contact page and components. - Added Docker support for development and production environments. - Created Makefile for easier deployment and management of Docker containers. --- .dockerignore | 15 ++++ .env.example | 10 +++ .gitignore | 1 + Dockerfile | 30 +++++++ bun.lock | 30 +++++++ docker-compose.dev.yml | 12 +++ docker-compose.local.yml | 8 ++ docker-compose.yml | 12 +++ makefile | 94 ++++++++++++++++++++ package.json | 4 +- public/left_contact_ascent.svg | 5 ++ public/right_contact_ascent.svg | 4 + src/app/api/contact/route.ts | 17 ++++ src/app/contact/page.tsx | 36 ++++++++ src/app/layout.tsx | 5 ++ src/app/page.tsx | 5 +- src/components/contact/contact-form.tsx | 103 ++++++++++++++++++++++ src/components/footer.tsx | 5 +- src/components/navbar.tsx | 23 +++-- src/components/ui/InputGroup.tsx | 42 +++++++++ src/components/ui/TextAreaInput.tsx | 45 ++++++++++ src/components/ui/TextInput.tsx | 45 ++++++++++ src/components/ui/types.ts | 21 +++++ src/services/contact.apis.ts | 18 ++++ src/utils/api.tsx | 110 ++++++++++++++++++++++++ src/utils/getErrorMessage.ts | 11 +++ 26 files changed, 697 insertions(+), 14 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.local.yml create mode 100644 docker-compose.yml create mode 100644 makefile create mode 100644 public/left_contact_ascent.svg create mode 100644 public/right_contact_ascent.svg create mode 100644 src/app/api/contact/route.ts create mode 100644 src/app/contact/page.tsx create mode 100644 src/components/contact/contact-form.tsx create mode 100644 src/components/ui/InputGroup.tsx create mode 100644 src/components/ui/TextAreaInput.tsx create mode 100644 src/components/ui/TextInput.tsx create mode 100644 src/components/ui/types.ts create mode 100644 src/services/contact.apis.ts create mode 100644 src/utils/api.tsx create mode 100644 src/utils/getErrorMessage.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7c77572 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +.vscode +.claude +node_modules +.next +out +coverage +*.tsbuildinfo +*.log +*.md +Dockerfile +.dockerignore +docker-compose*.yml +makefile +# .env TIDAK diignore: NEXT_PUBLIC_* di-inline saat build diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3c7a502 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +NEXT_PUBLIC_API_ENDPOINT= +NEXT_PUBLIC_API_KEY= + +# Docker / deploy +PORT_PROD=3010 +PORT_DEV=3011 +PORT_LOCAL=3000 +GITLAB_USERNAME= +GITLAB_PASSWORD= +DEPLOY_LOG_DIR= diff --git a/.gitignore b/.gitignore index 5ef6a52..7b8da95 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8e2aeb0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +# syntax=docker/dockerfile:1 + +# ---- deps ---- +FROM imbios/bun-node:20-slim AS deps +WORKDIR /app +COPY package.json bun.lock* ./ +RUN bun install + +# ---- build ---- +FROM imbios/bun-node:20-slim AS builder +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN bun run build + +# ---- runtime (next standalone) ---- +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + PORT=3000 \ + HOSTNAME=0.0.0.0 +RUN addgroup -S nodejs -g 1001 && adduser -S nextjs -u 1001 -G nodejs +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/public ./public +USER nextjs +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/bun.lock b/bun.lock index fea44cf..a1dfded 100644 --- a/bun.lock +++ b/bun.lock @@ -5,10 +5,12 @@ "name": "compro-mit", "dependencies": { "@phosphor-icons/react": "^2.1.10", + "axios": "^1.16.1", "next": "16.2.6", "react": "19.2.4", "react-dom": "19.2.4", "react-fast-marquee": "^1.6.5", + "react-toastify": "^11.1.0", }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -299,6 +301,8 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -327,10 +331,14 @@ "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], "axe-core": ["axe-core@4.11.4", "", {}, "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA=="], + "axios": ["axios@1.20.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg=="], + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], @@ -357,10 +365,14 @@ "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], @@ -385,6 +397,8 @@ "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], @@ -471,8 +485,12 @@ "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], + "follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + "form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="], @@ -519,6 +537,8 @@ "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], @@ -651,6 +671,10 @@ "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -713,6 +737,8 @@ "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], @@ -725,6 +751,8 @@ "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "react-toastify": ["react-toastify@11.1.0", "", { "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { "react": "^18 || ^19", "react-dom": "^18 || ^19" } }, "sha512-e9h23x3phN0wbFeB6yovmWp7lobzV4CaCH0LO8nVP6H7Y+3GbcLpIzMm9dJhcp1RXbpyfvjgpfXqO80QAmn7sg=="], + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], @@ -889,6 +917,8 @@ "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "form-data/hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + "is-bun-module/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..d501602 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,12 @@ +# Development +services: + app: + container_name: compro-mit-dev + image: compro-mit-dev + build: . + restart: unless-stopped + ports: + - "${PORT_DEV:-3011}:3000" + logging: + driver: json-file + options: { max-size: "100m", max-file: "5" } diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..e09a6e6 --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,8 @@ +# Local +services: + app: + container_name: compro-mit-local + image: compro-mit-local + build: . + ports: + - "${PORT_LOCAL:-3000}:3000" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5c566bb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +# Production +services: + app: + container_name: compro-mit-prod + image: compro-mit-prod + build: . + restart: unless-stopped + ports: + - "${PORT_PROD:-3010}:3000" + logging: + driver: json-file + options: { max-size: "100m", max-file: "5" } diff --git a/makefile b/makefile new file mode 100644 index 0000000..85ca288 --- /dev/null +++ b/makefile @@ -0,0 +1,94 @@ +SHELL := /bin/bash +.DEFAULT_GOAL := help + +PROJECT := compro-mit +REPO := repository.promas.id/ComproMIT/compro-mit +ENV_FILE := .env + +ifneq (,$(wildcard $(ENV_FILE))) + include $(ENV_FILE) + export $(shell sed -n 's/^\([A-Za-z_][A-Za-z0-9_]*\)=.*/\1/p' $(ENV_FILE)) +endif + +# ---- per-environment (env = prod | dev | local) ---- +WORK_DIR_prod := /var/www/compro-mit-prod +WORK_DIR_dev := /var/www/compro-mit +WORK_DIR_local := $(CURDIR) +COMPOSE_prod := docker-compose.yml +COMPOSE_dev := docker-compose.dev.yml +COMPOSE_local := docker-compose.local.yml +BRANCH_prod := production +BRANCH_dev := main + +WORK_DIR = $(WORK_DIR_$*) +COMPOSE = $(COMPOSE_$*) +BRANCH = $(BRANCH_$*) +DC = sudo docker compose -f $(COMPOSE) + +# ---- deploy log ---- +LOG_DIR := $(or $(DEPLOY_LOG_DIR),/var/lib/deploy-log) +LOG_IMAGE := deploy-log:1 +OUT_FILE = $(LOG_DIR)/$(PROJECT)/.last-deploy.out +HIST_FILE = $(LOG_DIR)/$(PROJECT)/$*.jsonl +LOG = sudo docker run --rm \ + -v $(LOG_DIR):/logs -v $(WORK_DIR):/repo:ro \ + -e DEPLOY_ACTOR="$$(id -un 2>/dev/null)" $(LOG_IMAGE) + +.PHONY: help install run-local log-dir status ps + +help: ## Tampilkan daftar perintah + @echo "make (env = prod | dev | local)" + @grep -hE '^[a-zA-Z%-]+:.*##' $(MAKEFILE_LIST) \ + | sed -e 's/%//' -e 's/:.*## /|/' \ + | awk -F'|' '{printf " %-16s %s\n", $$1, $$2}' + +# ---- deploy ---- +deploy-%: log-dir ## Pull + build + up (prod/dev) + @$(LOG) start $(PROJECT) $* || true + @cd $(WORK_DIR) || exit 1; \ + ( \ + sudo git pull https://$(GITLAB_USERNAME):$(GITLAB_PASSWORD)@$(REPO) $(BRANCH) && \ + $(DC) build --no-cache --progress=plain && \ + $(DC) up -d --remove-orphans && \ + sudo docker system prune -f \ + ) 2>&1 | tee $(OUT_FILE); \ + RC=$${PIPESTATUS[0]}; \ + if [ "$$RC" = "0" ]; then $(LOG) end $(PROJECT) $* ok || true; \ + else $(LOG) end $(PROJECT) $* fail || true; fi; \ + exit $$RC + +# ---- operasional ---- +up-%: ## Build + start container + @cd $(WORK_DIR) && $(DC) up -d --build --remove-orphans + +stop-%: ## Stop container + @cd $(WORK_DIR) && $(DC) stop + +restart-%: ## Restart container + @cd $(WORK_DIR) && $(DC) restart + +logs-%: ## Tail log container + @cd $(WORK_DIR) && $(DC) logs -f --tail=200 + +clean-%: ## Down + hapus image + prune + @cd $(WORK_DIR) && $(DC) down -v --rmi local && sudo docker system prune -f + +history-%: ## 10 deploy terakhir + @test -s $(HIST_FILE) && tail -n 10 $(HIST_FILE) | tac || echo "belum ada entri" + +# ---- lokal & utilitas ---- +install: ## Install dependency + @bun install + +run-local: ## Jalankan dev server (tanpa docker) + @bun run dev + +status: ## Status container project ini + @sudo docker ps -a --filter "name=$(PROJECT)" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" + +ps: ## Status semua container + @sudo docker ps -a --format "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}" + +log-dir: + @sudo mkdir -p $(LOG_DIR)/$(PROJECT) && sudo touch $(OUT_FILE) \ + && sudo chown -R $$(id -u):$$(id -g) $(LOG_DIR)/$(PROJECT) diff --git a/package.json b/package.json index ae7e893..24331a8 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,12 @@ }, "dependencies": { "@phosphor-icons/react": "^2.1.10", + "axios": "^1.16.1", "next": "16.2.6", "react": "19.2.4", "react-dom": "19.2.4", - "react-fast-marquee": "^1.6.5" + "react-fast-marquee": "^1.6.5", + "react-toastify": "^11.1.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/public/left_contact_ascent.svg b/public/left_contact_ascent.svg new file mode 100644 index 0000000..1bc7df0 --- /dev/null +++ b/public/left_contact_ascent.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/right_contact_ascent.svg b/public/right_contact_ascent.svg new file mode 100644 index 0000000..53be747 --- /dev/null +++ b/public/right_contact_ascent.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/app/api/contact/route.ts b/src/app/api/contact/route.ts new file mode 100644 index 0000000..42a3173 --- /dev/null +++ b/src/app/api/contact/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server"; + +// Dummy endpoint sementara, ganti dengan API asli saat sudah tersedia. +export async function POST(request: Request) { + const body = await request.json(); + + const required = ["name", "email", "subject", "message"]; + const missing = required.filter((field) => !String(body?.[field] ?? "").trim()); + + if (missing.length > 0) { + return NextResponse.json({ status: 422, message: "Data belum lengkap", errors: Object.fromEntries(missing.map((field) => [field, [`${field} harus diisi`]])) }, { status: 422 }); + } + + console.log("[contact] dummy submission:", body); + + return NextResponse.json({ status: 200, message: "Pesan berhasil dikirim", data: body }); +} diff --git a/src/app/contact/page.tsx b/src/app/contact/page.tsx new file mode 100644 index 0000000..daecd8a --- /dev/null +++ b/src/app/contact/page.tsx @@ -0,0 +1,36 @@ +import Corner1 from "@/right_contact_ascent.svg"; +import Corner2 from "@/left_contact_ascent.svg"; +import Image from "next/image"; +import ContactForm from "@src/components/contact/contact-form"; +import WhatsAppBubble from "@src/components/WhatsAppBubble"; +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Contact Us | Mulia Informasi Teknologi", + description: "Have a project in mind or looking for the right technology solution? Tell us what you need, and our team will get back to you.", +}; + +export default function Contact() { + return ( +
+
+
+
+
+

CONTACT US

+

Let’s Build Something Great Together

+
+

Have a project in mind or looking for the right technology solution? Tell us what you need, and our team will get back to you.

+
+ + +
+ + corner-1 + corner-2 +
+ + +
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e05919c..86a43d5 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,9 @@ import "@/style/globals.css"; import Navbar from "@src/components/navbar"; import localFont from "next/font/local"; import Footer from "@src/components/footer"; +import { Slide, ToastContainer } from "react-toastify"; +import "react-toastify/dist/ReactToastify.css"; +import WhatsAppBubble from "@src/components/WhatsAppBubble"; export const metadata: Metadata = { title: "Mulia Informasi Teknologi", @@ -94,9 +97,11 @@ export default function RootLayout({ return ( +
{children}