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:
fadilkun45 2026-09-04 14:58:16 +07:00
parent cba019baed
commit 7b4b955c99
26 changed files with 697 additions and 14 deletions

15
.dockerignore Normal file
View 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
View 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
View File

@ -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
View 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"]

View File

@ -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
View 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
View 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
View 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
View 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)

View File

@ -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",

View 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

View 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

View 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
View 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]">Lets 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>
);
}

View File

@ -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>
);

View File

@ -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>

View 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>
);
}

View File

@ -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">Lets Collaborate</p>
<p className="md:text-lg">Lets 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>

View File

@ -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 (
<>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>;

View 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
View 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;

View 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;
}