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.
This commit is contained in:
parent
cba019baed
commit
7b4b955c99
15
.dockerignore
Normal file
15
.dockerignore
Normal file
@ -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
|
||||
10
.env.example
Normal file
10
.env.example
Normal file
@ -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=
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -32,6 +32,7 @@ yarn-error.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
30
Dockerfile
Normal file
30
Dockerfile
Normal file
@ -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"]
|
||||
30
bun.lock
30
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=="],
|
||||
|
||||
12
docker-compose.dev.yml
Normal file
12
docker-compose.dev.yml
Normal file
@ -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" }
|
||||
8
docker-compose.local.yml
Normal file
8
docker-compose.local.yml
Normal file
@ -0,0 +1,8 @@
|
||||
# Local
|
||||
services:
|
||||
app:
|
||||
container_name: compro-mit-local
|
||||
image: compro-mit-local
|
||||
build: .
|
||||
ports:
|
||||
- "${PORT_LOCAL:-3000}:3000"
|
||||
12
docker-compose.yml
Normal file
12
docker-compose.yml
Normal file
@ -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" }
|
||||
94
makefile
Normal file
94
makefile
Normal file
@ -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 <target> (env = prod | dev | local)"
|
||||
@grep -hE '^[a-zA-Z%-]+:.*##' $(MAKEFILE_LIST) \
|
||||
| sed -e 's/%/<env>/' -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)
|
||||
@ -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",
|
||||
|
||||
5
public/left_contact_ascent.svg
Normal file
5
public/left_contact_ascent.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg width="120" height="119" viewBox="0 0 120 119" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="40" height="40" transform="matrix(-1 0 0 1 120 0)" fill="#DEEDFB"/>
|
||||
<rect width="40" height="40" transform="matrix(-1 0 0 1 80 40)" fill="#DEEDFB"/>
|
||||
<rect width="40" height="40" transform="matrix(-1 0 0 1 40 40)" fill="#DEEDFB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 350 B |
4
public/right_contact_ascent.svg
Normal file
4
public/right_contact_ascent.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="120" height="119" viewBox="0 0 120 119" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="40" height="40" transform="matrix(-1 0 0 1 80 0)" fill="#FDEBC8"/>
|
||||
<rect width="40" height="40" transform="matrix(-1 0 0 1 120 40)" fill="#FBD78C"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 269 B |
17
src/app/api/contact/route.ts
Normal file
17
src/app/api/contact/route.ts
Normal file
@ -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 });
|
||||
}
|
||||
36
src/app/contact/page.tsx
Normal file
36
src/app/contact/page.tsx
Normal file
@ -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 (
|
||||
<div className="flex flex-col">
|
||||
<div id="kontak" className="relative bg-subtle">
|
||||
<div className="flex flex-col gap-6 px-4 py-6 z-10 relative md:py-16 md:px-20 md:grid md:grid-cols-2 md:gap-10 md:items-start">
|
||||
<div className="flex flex-col gap-4 md:gap-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-[13px] text-secondary-400 md:text-sm">CONTACT US</p>
|
||||
<p className="text-title font-semibold text-[32px] md:text-[40px]">Let’s Build Something Great Together</p>
|
||||
</div>
|
||||
<p className="text-sm text-body md:text-base">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.</p>
|
||||
</div>
|
||||
|
||||
<ContactForm />
|
||||
</div>
|
||||
|
||||
<Image src={Corner1} alt="corner-1" className="w-20 md:w-30 absolute top-0 right-0 z-0" />
|
||||
<Image src={Corner2} alt="corner-2" className="w-20 md:w-30 absolute bottom-0 left-0 z-0" />
|
||||
</div>
|
||||
|
||||
<WhatsAppBubble />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<html lang="en" className={`${openSauceOne.variable} scroll-smooth`}>
|
||||
<body className="min-h-screen w-full flex flex-col">
|
||||
<ToastContainer theme="colored" position="top-right" autoClose={5000} transition={Slide} />
|
||||
<Navbar />
|
||||
<div>{children}</div>
|
||||
<Footer />
|
||||
<WhatsAppBubble />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@ -12,6 +12,7 @@ import Corner1 from "@/image/portofolio/corner-why-us-1.svg";
|
||||
import Corner2 from "@/image/portofolio/corner-why-us-2.svg";
|
||||
import { ArrowUpRightIcon } from "@phosphor-icons/react/dist/ssr";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import ClientImage from "../components/home/client-image";
|
||||
import WhatsAppBubble from "@src/components/WhatsAppBubble";
|
||||
|
||||
@ -59,12 +60,12 @@ export default function Home() {
|
||||
<div className="py-2 px-3 backdrop-blur-[19px] bg-[#FFFFFF1F] font-medium w-fit">Part of Serba Mulia Group</div>
|
||||
<p className="font-semibold text-[40px] md:text-5xl">Build Fast, Scale Smart</p>
|
||||
<p className="md:text-lg">Software & AI Solutions for Modern Business</p>
|
||||
<a className="primary-button pl-3" href="mailto:product@muliainformasi.co.id">
|
||||
<Link className="primary-button pl-3" href="/contact">
|
||||
Contact Us
|
||||
<div className="p-2 bg-secondary-400">
|
||||
<ArrowUpRightIcon className="size-5 text-white" />
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
103
src/components/contact/contact-form.tsx
Normal file
103
src/components/contact/contact-form.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
import { ArrowUpRightIcon } from "@phosphor-icons/react/dist/ssr";
|
||||
import { useState } from "react";
|
||||
import ContactApis from "@src/services/contact.apis";
|
||||
import { getErrorMessage } from "@src/utils/getErrorMessage";
|
||||
import { toast } from "react-toastify";
|
||||
import InputGroup from "@src/components/ui/InputGroup";
|
||||
import type { FieldConfig, FormErrors, FormValues } from "@src/components/ui/types";
|
||||
|
||||
const fields: FieldConfig[] = [
|
||||
{ type: "text", name: "name", label: "Nama Lengkap", placeholder: "Masukkan nama Anda", isRequired: true, requiredMessage: "Nama harus diisi" },
|
||||
{
|
||||
type: "email",
|
||||
name: "email",
|
||||
label: "Alamat Email Aktif",
|
||||
placeholder: "Masukkan alamat email",
|
||||
isRequired: true,
|
||||
requiredMessage: "Email harus diisi",
|
||||
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||
patternMessage: "Format alamat email tidak valid",
|
||||
},
|
||||
{ type: "text", name: "subject", label: "Subjek", placeholder: "Masukkan subjek", isRequired: true, requiredMessage: "Subjek perlu diisi", colSpan: 2 },
|
||||
{ type: "textarea", name: "message", label: "Isi pesan", placeholder: "Masukkan isi pesan", isRequired: true, requiredMessage: "Isi pesan perlu diisi", colSpan: 2, rows: 5 },
|
||||
];
|
||||
|
||||
const initialForm: FormValues = Object.fromEntries(fields.map((field) => [field.name, ""]));
|
||||
|
||||
export default function ContactForm() {
|
||||
const [form, setForm] = useState<FormValues>(initialForm);
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const isEmpty = Object.values(form).every((value) => value.trim() === "");
|
||||
const isDisabled = isEmpty || loading;
|
||||
|
||||
const validate = (values: FormValues) => {
|
||||
const nextErrors: FormErrors = {};
|
||||
|
||||
fields.forEach((field) => {
|
||||
const value = values[field.name]?.trim() ?? "";
|
||||
|
||||
if (field.isRequired && !value) {
|
||||
nextErrors[field.name] = field.requiredMessage || `${field.label} harus diisi`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (field.pattern && value && !field.pattern.test(value)) {
|
||||
nextErrors[field.name] = field.patternMessage || `Format ${field.label} tidak valid`;
|
||||
}
|
||||
});
|
||||
|
||||
return nextErrors;
|
||||
};
|
||||
|
||||
const handleChange = (value: string, name: string) => {
|
||||
const next = { ...form, [name]: value };
|
||||
setForm(next);
|
||||
if (errors[name]) setErrors(validate(next));
|
||||
};
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const nextErrors = validate(form);
|
||||
setErrors(nextErrors);
|
||||
if (Object.keys(nextErrors).length > 0) return;
|
||||
|
||||
setLoading(true);
|
||||
ContactApis.postSendMessage({
|
||||
name: form.name,
|
||||
email: form.email,
|
||||
subject: form.subject,
|
||||
message: form.message,
|
||||
})
|
||||
.then(() => {
|
||||
setForm(initialForm);
|
||||
setErrors({});
|
||||
toast.success("Your message has been sent.");
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
toast.error(getErrorMessage(err, "An error occurred while sending the message"));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<form noValidate onSubmit={handleSubmit} className="flex flex-col gap-4 bg-white p-4 border border-border md:p-6 md:gap-6">
|
||||
<p className="text-title text-xl font-semibold md:text-2xl">Send Us a Message</p>
|
||||
|
||||
<InputGroup fields={fields} formData={form} onChange={handleChange} cols={2} formError={errors} />
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isDisabled}
|
||||
className="flex gap-3 items-center justify-center p-2 pl-3 font-semibold w-full cursor-pointer bg-primary-800 text-white md:w-fit md:self-end disabled:bg-subtle disabled:text-body disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? "Sending..." : "Send Message"}
|
||||
<div className={`p-2 ${isDisabled ? "bg-body" : "bg-secondary-400"}`}>
|
||||
<ArrowUpRightIcon className="size-5 text-white" />
|
||||
</div>
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@ -5,6 +5,7 @@ import LogoWhite from "@/image/logo-white.png";
|
||||
import { EnvelopeIcon, PhoneIcon } from "@phosphor-icons/react";
|
||||
import { ArrowUpRightIcon } from "@phosphor-icons/react/dist/ssr";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
@ -32,12 +33,12 @@ export default function Footer() {
|
||||
<p className="text-[32px] md:text-[40px] font-semibold">Let’s Collaborate</p>
|
||||
<p className="md:text-lg">Let’s build something that works and grows.</p>
|
||||
</div>
|
||||
<a className="primary-button pl-3" href="mailto:product@muliainformasi.co.id">
|
||||
<Link className="primary-button pl-3" href="/contact">
|
||||
Contact Us
|
||||
<div className="p-2 bg-secondary-400">
|
||||
<ArrowUpRightIcon className="size-5 text-white" />
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<p className="text-center z-10 pt-4 border-t border-primary-600 flex w-full md:hidden">Mulia Informasi Teknologi @2026. All Right Reserved</p>
|
||||
|
||||
@ -4,33 +4,38 @@ import Logo from "@/image/Logo.svg";
|
||||
import { ListIcon, XIcon } from "@phosphor-icons/react/dist/ssr";
|
||||
import Link from "next/link";
|
||||
import { useState, useEffect } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
export default function Navbar() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [active, setActive] = useState("about-us");
|
||||
const [activeSection, setActiveSection] = useState("about-us");
|
||||
const pathname = usePathname();
|
||||
const isContact = pathname === "/contact";
|
||||
const active = isContact ? "contact" : activeSection;
|
||||
|
||||
const menu = [
|
||||
{ label: "About Us", url: "#about-us", id: "about-us" },
|
||||
{ label: "Services", url: "#services", id: "services" },
|
||||
{ label: "Portfolio", url: "#portfolio", id: "portfolio" },
|
||||
{ label: "Process", url: "#process", id: "process" },
|
||||
{ label: "Kontak", url: "#kontak", id: "kontak" },
|
||||
{ label: "About Us", url: "/#about-us", id: "about-us" },
|
||||
{ label: "Services", url: "/#services", id: "services" },
|
||||
{ label: "Portfolio", url: "/#portfolio", id: "portfolio" },
|
||||
{ label: "Process", url: "/#process", id: "process" },
|
||||
{ label: "Contact", url: "/contact", id: "contact" },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const ids = ["about-us", "services", "portfolio", "process", "kontak"];
|
||||
if (isContact) return;
|
||||
const ids = ["about-us", "services", "portfolio", "process"];
|
||||
const sections = ids.map((id) => document.getElementById(id)).filter(Boolean) as HTMLElement[];
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) setActive(entry.target.id);
|
||||
if (entry.isIntersecting) setActiveSection(entry.target.id);
|
||||
});
|
||||
},
|
||||
{ rootMargin: "-50% 0px -50% 0px" }
|
||||
);
|
||||
sections.forEach((section) => observer.observe(section));
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
}, [isContact]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
42
src/components/ui/InputGroup.tsx
Normal file
42
src/components/ui/InputGroup.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import TextInput from "./TextInput";
|
||||
import TextareaInput from "./TextAreaInput";
|
||||
import type { FieldConfig, FormErrors, FormValues } from "./types";
|
||||
|
||||
const colClasses: Record<number, string> = {
|
||||
1: "md:grid-cols-1",
|
||||
2: "md:grid-cols-2",
|
||||
3: "md:grid-cols-3",
|
||||
4: "md:grid-cols-4",
|
||||
};
|
||||
|
||||
export default function InputGroup({
|
||||
fields,
|
||||
formData,
|
||||
onChange,
|
||||
cols = 1,
|
||||
formError,
|
||||
}: {
|
||||
fields: FieldConfig[];
|
||||
formData: FormValues;
|
||||
onChange: (value: string, name: string) => void;
|
||||
cols?: 1 | 2 | 3 | 4;
|
||||
formError?: FormErrors;
|
||||
}) {
|
||||
return (
|
||||
<div className={`grid grid-cols-1 ${colClasses[cols] || "md:grid-cols-1"} gap-4 md:gap-6`}>
|
||||
{fields.map((field, idx) => {
|
||||
const error = formError?.[field.name];
|
||||
|
||||
switch (field.type) {
|
||||
case "text":
|
||||
case "email":
|
||||
return <TextInput key={idx} {...field} value={formData[field.name]} onChange={(value: string) => onChange(value, field.name)} isError={!!error} textFoot={error} />;
|
||||
case "textarea":
|
||||
return <TextareaInput key={idx} {...field} value={formData[field.name]} onChange={(value: string) => onChange(value, field.name)} isError={!!error} textFoot={error} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
src/components/ui/TextAreaInput.tsx
Normal file
45
src/components/ui/TextAreaInput.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
const spanClasses: Record<number, string> = {
|
||||
1: "md:col-span-1",
|
||||
2: "md:col-span-2",
|
||||
};
|
||||
|
||||
const fieldClass = (isError?: boolean) =>
|
||||
`w-full rounded-lg p-3 text-sm border outline-none text-title placeholder:text-body md:text-base resize-none md:min-h-35 ${isError ? "border-red-500 bg-red-50/40" : "border-border focus:border-primary-600"}`;
|
||||
|
||||
export default function TextareaInput({
|
||||
label,
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
isDisable,
|
||||
isError,
|
||||
textFoot,
|
||||
colSpan = 1,
|
||||
rows = 5,
|
||||
}: {
|
||||
label?: string;
|
||||
name: string;
|
||||
value?: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
isDisable?: boolean;
|
||||
isError?: boolean;
|
||||
textFoot?: string;
|
||||
colSpan?: number;
|
||||
rows?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className={`flex flex-col gap-1 ${spanClasses[colSpan] || "md:col-span-1"}`}>
|
||||
{label && (
|
||||
<label htmlFor={name} className="text-sm text-subtitle md:text-base">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<textarea id={name} name={name} rows={rows} value={value || ""} onChange={(evt) => onChange(evt?.target?.value)} placeholder={placeholder} disabled={isDisable} className={fieldClass(isError)} />
|
||||
|
||||
{textFoot && <p className="text-[13px] text-red-500">{textFoot}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
src/components/ui/TextInput.tsx
Normal file
45
src/components/ui/TextInput.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
const spanClasses: Record<number, string> = {
|
||||
1: "md:col-span-1",
|
||||
2: "md:col-span-2",
|
||||
};
|
||||
|
||||
const fieldClass = (isError?: boolean) =>
|
||||
`w-full rounded-lg p-3 text-sm border outline-none text-title placeholder:text-body md:text-base ${isError ? "border-red-500 bg-red-50/40" : "border-border focus:border-primary-600"}`;
|
||||
|
||||
export default function TextInput({
|
||||
label,
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
type = "text",
|
||||
isDisable,
|
||||
isError,
|
||||
textFoot,
|
||||
colSpan = 1,
|
||||
}: {
|
||||
label?: string;
|
||||
name: string;
|
||||
value?: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
type?: string;
|
||||
isDisable?: boolean;
|
||||
isError?: boolean;
|
||||
textFoot?: string;
|
||||
colSpan?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className={`flex flex-col gap-1 ${spanClasses[colSpan] || "md:col-span-1"}`}>
|
||||
{label && (
|
||||
<label htmlFor={name} className="text-sm text-subtitle md:text-base">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<input id={name} name={name} type={type} value={value || ""} onChange={(evt) => onChange(evt?.target?.value)} placeholder={placeholder} disabled={isDisable} className={fieldClass(isError)} />
|
||||
|
||||
{textFoot && <p className="text-[13px] text-red-500">{textFoot}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
src/components/ui/types.ts
Normal file
21
src/components/ui/types.ts
Normal file
@ -0,0 +1,21 @@
|
||||
export type FieldType = "text" | "email" | "textarea";
|
||||
|
||||
export type ColSpan = 1 | 2;
|
||||
|
||||
export interface FieldConfig {
|
||||
type: FieldType;
|
||||
name: string;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
isRequired?: boolean;
|
||||
requiredMessage?: string;
|
||||
pattern?: RegExp;
|
||||
patternMessage?: string;
|
||||
colSpan?: ColSpan;
|
||||
rows?: number;
|
||||
isDisable?: boolean;
|
||||
}
|
||||
|
||||
export type FormValues = Record<string, string>;
|
||||
|
||||
export type FormErrors = Record<string, string>;
|
||||
18
src/services/contact.apis.ts
Normal file
18
src/services/contact.apis.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import Api from "@src/utils/api";
|
||||
|
||||
export interface ContactPayload {
|
||||
name: string;
|
||||
email: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const ContactApis = {
|
||||
postSendMessage: function (data: ContactPayload) {
|
||||
return Api()
|
||||
.postRaw("/api/contact", data, { rawJSON: true })
|
||||
.then((res) => res?.data);
|
||||
},
|
||||
};
|
||||
|
||||
export default ContactApis;
|
||||
110
src/utils/api.tsx
Normal file
110
src/utils/api.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
import axios, { AxiosRequestConfig } from "axios";
|
||||
|
||||
axios.interceptors.response.use(
|
||||
function (res) {
|
||||
return res;
|
||||
},
|
||||
|
||||
function (err) {
|
||||
if (err?.response === undefined) {
|
||||
return Promise.reject(err);
|
||||
}
|
||||
|
||||
return Promise.reject(err.response);
|
||||
}
|
||||
);
|
||||
|
||||
interface Headers {
|
||||
"Content-Type"?: string;
|
||||
"api-key"?: string;
|
||||
Accept?: string;
|
||||
}
|
||||
|
||||
interface ConfigInterface {
|
||||
multipartForm?: boolean;
|
||||
rawJSON?: boolean;
|
||||
body?: unknown;
|
||||
url?: string;
|
||||
auth?: number;
|
||||
}
|
||||
|
||||
const Api = () => ({
|
||||
Config: ({ multipartForm = false, rawJSON, auth = 1 }: ConfigInterface) => {
|
||||
const headers: Headers = {
|
||||
"api-key": auth === 1 ? process.env.NEXT_PUBLIC_API_KEY || "" : "",
|
||||
};
|
||||
|
||||
if (multipartForm) {
|
||||
headers["Content-Type"] = "multipart/form-data";
|
||||
} else if (rawJSON) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
} else {
|
||||
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
||||
}
|
||||
|
||||
const client: AxiosRequestConfig = {
|
||||
// Kosong => request relatif ke origin sendiri (dummy route /api/contact).
|
||||
baseURL: process.env.NEXT_PUBLIC_API_ENDPOINT || "",
|
||||
headers: headers as AxiosRequestConfig["headers"],
|
||||
timeout: 60000,
|
||||
};
|
||||
|
||||
return client;
|
||||
},
|
||||
|
||||
Get: (url: string = "", config?: ConfigInterface) => {
|
||||
return axios.get(url, Api().Config({ body: {}, url, auth: config?.auth })).then((res) => res?.data);
|
||||
},
|
||||
|
||||
postRaw: (url: string, body: unknown, config: ConfigInterface) => {
|
||||
return axios
|
||||
.post(
|
||||
url,
|
||||
body,
|
||||
Api().Config({
|
||||
body,
|
||||
url,
|
||||
rawJSON: config?.rawJSON,
|
||||
auth: config?.auth,
|
||||
})
|
||||
)
|
||||
.then((res) => res?.data);
|
||||
},
|
||||
|
||||
putRaw: (url: string, body: unknown, config: ConfigInterface) => {
|
||||
return axios
|
||||
.put(
|
||||
url,
|
||||
body,
|
||||
Api().Config({
|
||||
body,
|
||||
url,
|
||||
rawJSON: config?.rawJSON,
|
||||
auth: config?.auth,
|
||||
})
|
||||
)
|
||||
.then((res) => res?.data);
|
||||
},
|
||||
|
||||
delete: (url: string, config?: ConfigInterface) => {
|
||||
return axios.delete(url, Api().Config({ url, rawJSON: config?.rawJSON, auth: config?.auth })).then((res) => res?.data);
|
||||
},
|
||||
|
||||
postFile: (url: string, body: unknown, config: ConfigInterface) => {
|
||||
return axios
|
||||
.post(
|
||||
url,
|
||||
body,
|
||||
Api().Config({
|
||||
body,
|
||||
url,
|
||||
multipartForm: config?.multipartForm,
|
||||
rawJSON: config?.rawJSON,
|
||||
auth: config?.auth,
|
||||
})
|
||||
)
|
||||
.then((res) => (res?.data?.data ? res?.data?.data : res));
|
||||
},
|
||||
});
|
||||
|
||||
export default Api;
|
||||
11
src/utils/getErrorMessage.ts
Normal file
11
src/utils/getErrorMessage.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export function getErrorMessage(err: unknown, fallback: string) {
|
||||
const response = err as { data?: { errors?: Record<string, string[]>; message?: string }; response?: { data?: { message?: string } } };
|
||||
const errors = response?.data?.errors;
|
||||
|
||||
if (errors) {
|
||||
const firstField = Object.values(errors)[0];
|
||||
if (Array.isArray(firstField) && firstField[0]) return firstField[0];
|
||||
}
|
||||
|
||||
return response?.data?.message || response?.response?.data?.message || fallback;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user