feat: GTSIT 사이트 구축

This commit is contained in:
박성필
2026-08-10 21:19:10 +09:00
commit dbb35d35c4
87 changed files with 48246 additions and 0 deletions

81
app/Auth/AdminAuth.php Normal file
View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Auth;
use App\Database;
use PDO;
final class AdminAuth
{
private PDO $db;
public function __construct()
{
$this->db = Database::connection();
}
public function attempt(string $username, string $password): bool
{
$statement = $this->db->prepare(
'SELECT id, username, display_name, password_hash, is_active, failed_attempts, locked_until
FROM admins WHERE username = :username LIMIT 1'
);
$statement->execute(['username' => $username]);
$admin = $statement->fetch();
if (!$admin) {
password_verify($password, password_hash('invalid-login', PASSWORD_DEFAULT));
return false;
}
if (!(bool) $admin['is_active']) {
return false;
}
if ($admin['locked_until'] && strtotime($admin['locked_until']) > time()) {
return false;
}
if (!password_verify($password, $admin['password_hash'])) {
$failed = (int) $admin['failed_attempts'] + 1;
$lockedUntil = $failed >= 5 ? date('Y-m-d H:i:s', time() + 900) : null;
$update = $this->db->prepare('UPDATE admins SET failed_attempts = :failed, locked_until = :locked WHERE id = :id');
$update->execute(['failed' => $failed >= 5 ? 0 : $failed, 'locked' => $lockedUntil, 'id' => $admin['id']]);
return false;
}
$update = $this->db->prepare('UPDATE admins SET failed_attempts = 0, locked_until = NULL, last_login_at = NOW() WHERE id = :id');
$update->execute(['id' => $admin['id']]);
session_regenerate_id(true);
$_SESSION['admin'] = [
'id' => (int) $admin['id'],
'username' => $admin['username'],
'display_name' => $admin['display_name'],
'authenticated_at' => time(),
];
return true;
}
public static function check(): bool
{
return isset($_SESSION['admin']['id'], $_SESSION['admin']['authenticated_at'])
&& time() - (int) $_SESSION['admin']['authenticated_at'] < 7200;
}
public static function requireLogin(): void
{
if (!self::check()) {
unset($_SESSION['admin']);
redirect('/admin/login');
}
$_SESSION['admin']['authenticated_at'] = time();
}
public static function logout(): void
{
unset($_SESSION['admin']);
session_regenerate_id(true);
}
}

41
app/Database.php Normal file
View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App;
use PDO;
use PDOException;
final class Database
{
private static ?PDO $connection = null;
public static function connection(): PDO
{
if (self::$connection instanceof PDO) {
return self::$connection;
}
$config = config('database');
if (empty($config['name']) || empty($config['user'])) {
throw new PDOException('Database configuration is unavailable.');
}
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$config['host'],
$config['port'],
$config['name'],
$config['charset']
);
self::$connection = new PDO($dsn, $config['user'], $config['password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return self::$connection;
}
}

View File

@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Database;
use PDO;
final class AdminRepository
{
private PDO $db;
public function __construct()
{
$this->db = Database::connection();
}
public function dashboardCounts(): array
{
return [
'posts' => (int) $this->db->query("SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL")->fetchColumn(),
'published' => (int) $this->db->query("SELECT COUNT(*) FROM posts WHERE status = 'published' AND deleted_at IS NULL")->fetchColumn(),
'inquiries' => (int) $this->db->query("SELECT COUNT(*) FROM inquiries WHERE deleted_at IS NULL")->fetchColumn(),
'pending' => (int) $this->db->query("SELECT COUNT(*) FROM inquiries WHERE status IN ('pending','in_progress') AND deleted_at IS NULL")->fetchColumn(),
];
}
public function adminCount(): int
{
return (int) $this->db->query('SELECT COUNT(*) FROM admins')->fetchColumn();
}
public function createAdmin(string $username, string $displayName, string $password): void
{
$statement = $this->db->prepare(
'INSERT INTO admins (username, password_hash, display_name)
VALUES (:username, :password_hash, :display_name)'
);
$statement->execute([
'username' => $username,
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
'display_name' => $displayName,
]);
}
public function posts(): array
{
return $this->db->query(
"SELECT id, title, slug, category, status, published_at, updated_at
FROM posts WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT 100"
)->fetchAll();
}
public function inquiries(): array
{
return $this->db->query(
"SELECT id, ticket, name, phone, inquiry_type, title, status, created_at
FROM inquiries WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT 100"
)->fetchAll();
}
public function findPost(int $id): ?array
{
$statement = $this->db->prepare(
'SELECT posts.*,
(SELECT file_path FROM post_images WHERE post_id = posts.id ORDER BY sort_order, id LIMIT 1) AS image_path,
(SELECT alt_text FROM post_images WHERE post_id = posts.id ORDER BY sort_order, id LIMIT 1) AS image_alt
FROM posts WHERE posts.id = :id AND posts.deleted_at IS NULL LIMIT 1'
);
$statement->execute(['id' => $id]);
$post = $statement->fetch();
return $post ?: null;
}
public function savePost(array $data, ?int $id = null): int
{
$publishedAt = $data['status'] === 'published'
? ($data['published_at'] ?: date('Y-m-d H:i:s'))
: null;
if ($id === null) {
$statement = $this->db->prepare(
'INSERT INTO posts (title, slug, category, excerpt, body, author, status, published_at)
VALUES (:title, :slug, :category, :excerpt, :body, :author, :status, :published_at)'
);
$statement->execute([
'title'=>$data['title'],'slug'=>$data['slug'],'category'=>$data['category'],
'excerpt'=>$data['excerpt'],'body'=>$data['body'],'author'=>$data['author'],
'status'=>$data['status'],'published_at'=>$publishedAt,
]);
return (int) $this->db->lastInsertId();
}
$statement = $this->db->prepare(
'UPDATE posts SET title=:title, slug=:slug, category=:category, excerpt=:excerpt,
body=:body, author=:author, status=:status, published_at=:published_at WHERE id=:id AND deleted_at IS NULL'
);
$statement->execute([
'title'=>$data['title'],'slug'=>$data['slug'],'category'=>$data['category'],
'excerpt'=>$data['excerpt'],'body'=>$data['body'],'author'=>$data['author'],
'status'=>$data['status'],'published_at'=>$publishedAt,'id'=>$id,
]);
return $id;
}
public function savePostWithImage(array $data, ?int $id, ?string $imagePath, string $imageAlt): array
{
$oldPaths = [];
$this->db->beginTransaction();
try {
$postId = $this->savePost($data, $id);
if ($imagePath !== null) {
$select = $this->db->prepare('SELECT file_path FROM post_images WHERE post_id = :post_id FOR UPDATE');
$select->execute(['post_id' => $postId]);
$oldPaths = array_column($select->fetchAll(), 'file_path');
$delete = $this->db->prepare('DELETE FROM post_images WHERE post_id = :post_id');
$delete->execute(['post_id' => $postId]);
$insert = $this->db->prepare(
'INSERT INTO post_images (post_id, file_path, alt_text, sort_order)
VALUES (:post_id, :file_path, :alt_text, 0)'
);
$insert->execute(['post_id'=>$postId, 'file_path'=>$imagePath, 'alt_text'=>$imageAlt]);
} elseif ($id !== null) {
$update = $this->db->prepare(
'UPDATE post_images SET alt_text = :alt_text WHERE post_id = :post_id AND sort_order = 0'
);
$update->execute(['post_id'=>$postId, 'alt_text'=>$imageAlt]);
}
$this->db->commit();
return ['id' => $postId, 'old_paths' => $oldPaths];
} catch (\Throwable $exception) {
if ($this->db->inTransaction()) {
$this->db->rollBack();
}
throw $exception;
}
}
public function slugExists(string $slug, ?int $exceptId = null): bool
{
$sql = 'SELECT COUNT(*) FROM posts WHERE slug = :slug AND deleted_at IS NULL';
$params = ['slug' => $slug];
if ($exceptId !== null) {
$sql .= ' AND id <> :id';
$params['id'] = $exceptId;
}
$statement = $this->db->prepare($sql);
$statement->execute($params);
return (int) $statement->fetchColumn() > 0;
}
public function deletePost(int $id): void
{
$statement = $this->db->prepare('UPDATE posts SET deleted_at = NOW() WHERE id = :id');
$statement->execute(['id' => $id]);
}
public function findInquiry(int $id): ?array
{
$statement = $this->db->prepare('SELECT * FROM inquiries WHERE id = :id AND deleted_at IS NULL LIMIT 1');
$statement->execute(['id' => $id]);
$inquiry = $statement->fetch();
return $inquiry ?: null;
}
public function updateInquiry(int $id, string $status, string $reply): void
{
$statement = $this->db->prepare(
'UPDATE inquiries SET status = :new_status, reply = :reply,
replied_at = CASE WHEN :has_reply = 1 THEN NOW() ELSE replied_at END,
closed_at = CASE
WHEN :closed_status = \'closed\' AND closed_at IS NULL THEN NOW()
WHEN :open_status <> \'closed\' THEN NULL
ELSE closed_at
END
WHERE id = :id AND deleted_at IS NULL'
);
$statement->execute([
'new_status' => $status,
'closed_status' => $status,
'open_status' => $status,
'reply' => $reply === '' ? null : $reply,
'has_reply' => $reply === '' ? 0 : 1,
'id' => $id,
]);
}
}

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Database;
use PDO;
final class InquiryRepository
{
private PDO $db;
public function __construct()
{
$this->db = Database::connection();
}
public function create(array $data): string
{
do {
$ticket = 'GTS-' . date('Ymd') . '-' . strtoupper(bin2hex(random_bytes(3)));
$check = $this->db->prepare('SELECT COUNT(*) FROM inquiries WHERE ticket = :ticket');
$check->execute(['ticket' => $ticket]);
} while ((int) $check->fetchColumn() > 0);
$statement = $this->db->prepare(
"INSERT INTO inquiries
(ticket, name, phone, email, inquiry_type, title, body, password_hash, status, consent_at)
VALUES (:ticket, :name, :phone, :email, :inquiry_type, :title, :body, :password_hash, 'pending', NOW())"
);
$statement->execute([
'ticket' => $ticket,
'name' => $data['name'],
'phone' => $data['phone'],
'email' => $data['email'] ?: null,
'inquiry_type' => $data['type'],
'title' => $data['title'],
'body' => $data['body'],
'password_hash' => password_hash($data['password'], PASSWORD_DEFAULT),
]);
return $ticket;
}
public function findForLookup(string $ticket): ?array
{
$statement = $this->db->prepare(
'SELECT id, ticket, inquiry_type, title, body, status, reply, replied_at, created_at,
password_hash, lookup_failed_attempts, lookup_locked_until
FROM inquiries WHERE ticket = :ticket AND deleted_at IS NULL LIMIT 1'
);
$statement->execute(['ticket' => strtoupper($ticket)]);
$inquiry = $statement->fetch();
return $inquiry ?: null;
}
public function resetLookupLimit(int $id): void
{
$statement = $this->db->prepare(
'UPDATE inquiries
SET lookup_failed_attempts = 0, lookup_locked_until = NULL
WHERE id = :id AND deleted_at IS NULL'
);
$statement->execute(['id' => $id]);
}
public function recordLookupFailure(int $id): bool
{
$this->db->beginTransaction();
try {
$select = $this->db->prepare(
'SELECT lookup_failed_attempts, lookup_locked_until
FROM inquiries WHERE id = :id AND deleted_at IS NULL FOR UPDATE'
);
$select->execute(['id' => $id]);
$limit = $select->fetch();
if (!$limit) {
$this->db->rollBack();
return false;
}
$attempts = (int) $limit['lookup_failed_attempts'];
$lockedUntil = $limit['lookup_locked_until'];
if (is_string($lockedUntil) && strtotime($lockedUntil) <= time()) {
$attempts = 0;
}
$attempts++;
$locked = $attempts >= 5;
$update = $this->db->prepare(
'UPDATE inquiries
SET lookup_failed_attempts = :attempts,
lookup_locked_until = CASE WHEN :locked = 1 THEN DATE_ADD(NOW(), INTERVAL 15 MINUTE) ELSE NULL END
WHERE id = :id'
);
$update->execute([
'attempts' => $attempts,
'locked' => $locked ? 1 : 0,
'id' => $id,
]);
$this->db->commit();
return $locked;
} catch (\Throwable $error) {
if ($this->db->inTransaction()) {
$this->db->rollBack();
}
throw $error;
}
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Database;
use PDO;
final class PostRepository
{
private PDO $db;
public function __construct()
{
$this->db = Database::connection();
}
public function latest(int $limit = 3): array
{
$statement = $this->db->prepare(
"SELECT posts.id, posts.title, posts.slug, posts.category, posts.excerpt, posts.published_at,
(SELECT file_path FROM post_images WHERE post_id = posts.id ORDER BY sort_order, id LIMIT 1) AS image_path,
(SELECT alt_text FROM post_images WHERE post_id = posts.id ORDER BY sort_order, id LIMIT 1) AS image_alt
FROM posts
WHERE posts.status = 'published' AND posts.deleted_at IS NULL AND posts.published_at <= NOW()
ORDER BY published_at DESC, id DESC
LIMIT :limit"
);
$statement->bindValue(':limit', $limit, PDO::PARAM_INT);
$statement->execute();
return $statement->fetchAll();
}
public function paginate(int $page, int $perPage, ?string $category = null): array
{
$where = "posts.status = 'published' AND posts.deleted_at IS NULL AND posts.published_at <= NOW()";
$params = [];
if ($category !== null && $category !== '') {
$where .= ' AND posts.category = :category';
$params['category'] = $category;
}
$count = $this->db->prepare("SELECT COUNT(*) FROM posts WHERE {$where}");
$count->execute($params);
$total = (int) $count->fetchColumn();
$offset = max(0, ($page - 1) * $perPage);
$query = $this->db->prepare(
"SELECT posts.id, posts.title, posts.slug, posts.category, posts.excerpt, posts.published_at,
(SELECT file_path FROM post_images WHERE post_id = posts.id ORDER BY sort_order, id LIMIT 1) AS image_path,
(SELECT alt_text FROM post_images WHERE post_id = posts.id ORDER BY sort_order, id LIMIT 1) AS image_alt
FROM posts WHERE {$where}
ORDER BY published_at DESC, id DESC
LIMIT :limit OFFSET :offset"
);
foreach ($params as $key => $value) {
$query->bindValue(':' . $key, $value);
}
$query->bindValue(':limit', $perPage, PDO::PARAM_INT);
$query->bindValue(':offset', $offset, PDO::PARAM_INT);
$query->execute();
return ['items' => $query->fetchAll(), 'total' => $total];
}
public function findPublishedBySlug(string $slug): ?array
{
$statement = $this->db->prepare(
"SELECT posts.id, posts.title, posts.slug, posts.category, posts.excerpt, posts.body, posts.author, posts.published_at,
(SELECT file_path FROM post_images WHERE post_id = posts.id ORDER BY sort_order, id LIMIT 1) AS image_path,
(SELECT alt_text FROM post_images WHERE post_id = posts.id ORDER BY sort_order, id LIMIT 1) AS image_alt
FROM posts
WHERE posts.slug = :slug AND posts.status = 'published' AND posts.deleted_at IS NULL AND posts.published_at <= NOW()
LIMIT 1"
);
$statement->execute(['slug' => $slug]);
$post = $statement->fetch();
return $post ?: null;
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Database;
use PDO;
final class SiteSettingRepository
{
private PDO $db;
public function __construct()
{
$this->db = Database::connection();
}
public function all(): array
{
$rows = $this->db->query('SELECT setting_key, setting_value FROM site_settings')->fetchAll();
$settings = [];
foreach ($rows as $row) $settings[$row['setting_key']] = $row['setting_value'];
return $settings;
}
public function save(array $settings): void
{
$statement = $this->db->prepare(
'INSERT INTO site_settings (setting_key, setting_value) VALUES (:key, :value)
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)'
);
$this->db->beginTransaction();
try {
foreach ($settings as $key => $value) $statement->execute(['key'=>$key, 'value'=>$value]);
$this->db->commit();
} catch (\Throwable $exception) {
$this->db->rollBack();
throw $exception;
}
}
}

134
app/Support/helpers.php Normal file
View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
function config(string $key, mixed $default = null): mixed
{
static $configs = [];
[$file, $path] = array_pad(explode('.', $key, 2), 2, null);
if (!array_key_exists($file, $configs)) {
$configFile = dirname(__DIR__, 2) . '/config/' . $file . '.php';
$configs[$file] = is_file($configFile) ? require $configFile : [];
}
$value = $configs[$file];
if ($path === null) {
return $value;
}
foreach (explode('.', $path) as $segment) {
if (!is_array($value) || !array_key_exists($segment, $value)) {
return $default;
}
$value = $value[$segment];
}
return $value;
}
function e(mixed $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function url(string $path = ''): string
{
$basePath = (string) config('app.base_path', '');
$path = '/' . ltrim($path, '/');
return ($basePath !== '' ? $basePath : '') . $path;
}
function asset(string $path): string
{
return url('assets/' . ltrim($path, '/'));
}
function csrf_token(): string
{
if (empty($_SESSION['_csrf'])) {
$_SESSION['_csrf'] = bin2hex(random_bytes(32));
}
return $_SESSION['_csrf'];
}
function csrf_field(): string
{
return '<input type="hidden" name="_csrf" value="' . e(csrf_token()) . '">';
}
function verify_csrf(): void
{
$submitted = $_POST['_csrf'] ?? '';
if (!is_string($submitted) || !hash_equals(csrf_token(), $submitted)) {
http_response_code(403);
render('errors/419', ['title' => '요청을 다시 확인해 주세요']);
exit;
}
}
function redirect(string $path, int $status = 302): never
{
header('Location: ' . url($path), true, $status);
exit;
}
function render(string $view, array $data = [], string $layout = 'layouts/site'): void
{
$viewFile = dirname(__DIR__) . '/Views/' . $view . '.php';
$layoutFile = dirname(__DIR__) . '/Views/' . $layout . '.php';
if (!is_file($viewFile) || !is_file($layoutFile)) {
throw new RuntimeException('View not found.');
}
extract($data, EXTR_SKIP);
ob_start();
require $viewFile;
$content = (string) ob_get_clean();
ob_start();
require $layoutFile;
$html = (string) ob_get_clean();
$basePath = (string) config('app.base_path', '');
if ($basePath !== '') {
$html = (string) preg_replace_callback(
'/\b(href|src|action)="(\/(?!\/)[^"]*)"/',
static function (array $matches) use ($basePath): string {
$path = $matches[2];
if ($path === $basePath || str_starts_with($path, $basePath . '/')) return $matches[0];
return $matches[1] . '="' . $basePath . $path . '"';
},
$html
);
}
echo $html;
}
function old(string $key, string $default = ''): string
{
return e($_POST[$key] ?? $default);
}
function is_active_path(string $path): bool
{
$current = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$basePath = (string) config('app.base_path', '');
if ($basePath !== '' && ($current === $basePath || str_starts_with($current, $basePath . '/'))) {
$current = substr($current, strlen($basePath)) ?: '/';
}
return $path === '/' ? $current === '/' : str_starts_with($current, $path);
}
function site_setting(string $key, mixed $default = ''): mixed
{
static $settings = null;
if ($settings === null) {
try {
$settings = (new \App\Repositories\SiteSettingRepository())->all();
} catch (\Throwable) {
$settings = [];
}
}
return $settings[$key] ?? $default;
}

View File

@@ -0,0 +1 @@
<header class="admin-page-heading"><div><span>SETTINGS</span><h1>회사 정보</h1><p>저장한 정보는 공개 화면의 연락처와 사업자 정보에 반영됩니다.</p></div></header><?php if ($errors): ?><div class="admin-error" role="alert"><ul><?php foreach($errors as $error): ?><li><?= e($error) ?></li><?php endforeach; ?></ul></div><?php endif; ?><form class="admin-form" method="post" action="/admin/company"><?= csrf_field() ?><div class="admin-field-grid"><label>법인명<input name="legal_name" value="<?= old('legal_name',$settings['legal_name']) ?>" required maxlength="120"></label><label>대표자<input name="ceo" value="<?= old('ceo',$settings['ceo']) ?>" maxlength="100"></label></div><div class="admin-field-grid"><label>대표번호<input name="phone" value="<?= old('phone',$settings['phone']) ?>" required maxlength="30"></label><label>지점번호<input name="phone_sub" value="<?= old('phone_sub',$settings['phone_sub']) ?>" maxlength="30"></label></div><div class="admin-field-grid"><label>FAX<input name="fax" value="<?= old('fax',$settings['fax']) ?>" maxlength="30"></label><label>이메일<input type="email" name="email" value="<?= old('email',$settings['email']) ?>" maxlength="190"></label></div><label>주소<input name="address" value="<?= old('address',$settings['address']) ?>" maxlength="255"></label><div class="admin-field-grid"><label>사업자등록번호<input name="bizno" value="<?= old('bizno',$settings['bizno']) ?>" maxlength="30"></label><label>영업시간<input name="business_hours" value="<?= old('business_hours',$settings['business_hours']) ?>" maxlength="120"></label></div><button class="button button-primary" type="submit">회사 정보 저장</button></form>

View File

@@ -0,0 +1 @@
<header class="admin-page-heading"><div><span>OVERVIEW</span><h1>대시보드</h1><p>사이트 콘텐츠와 문의 현황입니다.</p></div></header><section class="metric-grid" aria-label="운영 현황"><a href="/admin/posts"><span>전체 </span><strong><?= e($counts['posts']) ?></strong></a><a href="/admin/posts"><span>공개 글</span><strong><?= e($counts['published']) ?></strong></a><a href="/admin/inquiries"><span>전체 문의</span><strong><?= e($counts['inquiries']) ?></strong></a><a href="/admin/inquiries"><span>처리 대기</span><strong><?= e($counts['pending']) ?></strong></a></section>

View File

@@ -0,0 +1 @@
<?php $labels=['pending'=>'대기','in_progress'=>'확인 중','replied'=>'답변 완료','closed'=>'종료']; ?><header class="admin-page-heading"><div><span>INQUIRIES</span><h1>문의 관리</h1><p>접수된 상담과 처리 상태를 확인합니다.</p></div></header><?php if ($inquiries): ?><div class="inquiry-admin-list"><?php foreach ($inquiries as $item): ?><article><div><span class="status status-<?= e($item['status']) ?>"><?= e($labels[$item['status']] ?? $item['status']) ?></span><small><?= e($item['ticket']) ?></small></div><h2><a href="/admin/inquiries/<?= e($item['id']) ?>"><?= e($item['title']) ?></a></h2><p><?= e($item['name']) ?> · <?= e($item['phone']) ?> · <?= e(date('Y.m.d H:i', strtotime($item['created_at']))) ?></p></article><?php endforeach; ?></div><?php else: ?><div class="empty-state"><h2>접수된 문의가 없습니다</h2><p>온라인 상담 문의가 등록되면 이곳에 표시됩니다.</p></div><?php endif; ?>

View File

@@ -0,0 +1 @@
<?php $labels=['pending'=>'대기','in_progress'=>'확인 중','replied'=>'답변 완료','closed'=>'종료']; ?><a class="back-link" href="/admin/inquiries">← 문의 목록</a><header class="admin-page-heading inquiry-heading"><div><span><?= e($inquiry['ticket']) ?></span><h1><?= e($inquiry['title']) ?></h1><p><?= e(date('Y.m.d H:i', strtotime($inquiry['created_at']))) ?> 접수</p></div></header><div class="inquiry-detail-grid"><article class="inquiry-content"><div class="contact-summary"><span><?= e($labels[$inquiry['status']] ?? $inquiry['status']) ?></span><p><strong><?= e($inquiry['name']) ?></strong><br><?= e($inquiry['phone']) ?><?php if ($inquiry['email']): ?><br><?= e($inquiry['email']) ?><?php endif; ?></p></div><h2>문의 내용</h2><p><?= nl2br(e($inquiry['body'])) ?></p></article><form class="admin-form reply-form" method="post" action="/admin/inquiries/<?= e($inquiry['id']) ?>"><?= csrf_field() ?><h2>처리 및 답변</h2><label>처리 상태<select name="status"><?php foreach($labels as $value=>$label): ?><option value="<?= e($value) ?>"<?= $inquiry['status']===$value?' selected':'' ?>><?= e($label) ?></option><?php endforeach; ?></select></label><label>고객 공개 답변<textarea name="reply" rows="10" maxlength="5000"><?= e($inquiry['reply'] ?? '') ?></textarea><small>저장한 답변은 고객의 문의 조회 화면에 표시됩니다.</small></label><button class="button button-primary" type="submit">처리 내용 저장</button></form></div>

15
app/Views/admin/login.php Normal file
View File

@@ -0,0 +1,15 @@
<section class="login-panel">
<div class="login-heading">
<img class="login-logo" src="<?= e(asset('images/company-logo-v3.png')) ?>" alt="" width="112" height="52">
<h1>관리자 로그인</h1>
<p>콘텐츠와 문의를 관리하려면 로그인해 주세요.</p>
</div>
<?php if (!empty($notice)): ?><p class="admin-notice" role="status"><?= e($notice) ?></p><?php endif; ?>
<?php if ($error): ?><p class="admin-error" role="alert"><?= e($error) ?></p><?php endif; ?>
<form method="post" action="/admin/login">
<?= csrf_field() ?>
<label>아이디<input name="username" value="<?= e($username) ?>" required maxlength="80" autocomplete="username"></label>
<label>비밀번호<input type="password" name="password" required maxlength="200" autocomplete="current-password"></label>
<button class="button button-primary button-full" type="submit">로그인</button>
</form>
</section>

View File

@@ -0,0 +1,23 @@
<header class="admin-page-heading">
<div><span>CONTENTS</span><h1><?= $post ? '글 수정' : '새 글 작성' ?></h1><p>초안으로 저장하거나 공개 상태로 게시합니다.</p></div>
</header>
<?php if ($errors): ?><div class="admin-error" role="alert"><strong>입력 내용을 확인해 주세요.</strong><ul><?php foreach ($errors as $error): ?><li><?= e($error) ?></li><?php endforeach; ?></ul></div><?php endif; ?>
<form class="admin-form" method="post" enctype="multipart/form-data" action="<?= $post ? '/admin/posts/' . e($post['id']) . '/edit' : '/admin/posts/new' ?>">
<?= csrf_field() ?>
<label>제목 <span>*</span><input name="title" value="<?= old('title', $post['title'] ?? '') ?>" required maxlength="200"></label>
<div class="admin-field-grid">
<label>URL slug <span>*</span><input name="slug" value="<?= old('slug', $post['slug'] ?? '') ?>" required maxlength="220" pattern="[a-z0-9-]+" placeholder="office-network-checklist"><small>영문 소문자, 숫자, 하이픈만 사용</small></label>
<label>카테고리<select name="category"><?php $currentCategory=$_POST['category']??$post['category']??'네트워크'; foreach(['네트워크','키폰시스템','CCTV','시공사례'] as $option): ?><option<?= $currentCategory===$option?' selected':'' ?>><?= e($option) ?></option><?php endforeach; ?></select></label>
</div>
<label>목록 요약<textarea name="excerpt" rows="3" maxlength="500"><?= old('excerpt', $post['excerpt'] ?? '') ?></textarea></label>
<fieldset class="admin-upload-field">
<legend>대표 이미지</legend>
<?php if (!empty($post['image_path'])): ?><img src="<?= e($post['image_path']) ?>" alt="<?= e($post['image_alt']) ?>"><p>새 파일을 선택하면 현재 대표 이미지가 교체됩니다.</p><?php endif; ?>
<label>이미지 파일<input type="file" name="image" accept="image/jpeg,image/png,image/webp"><small>JPG, PNG, WebP · 최대 8MB · 최대 6000×6000px</small></label>
<label>대체 텍스트<input name="image_alt" value="<?= old('image_alt', $post['image_alt'] ?? '') ?>" maxlength="255"><small>이미지 내용을 간단히 설명합니다. 이미지를 등록할 때 필수입니다.</small></label>
</fieldset>
<label>본문 <span>*</span><textarea name="body" rows="16" required><?= old('body', $post['body'] ?? '') ?></textarea><small>빈 줄을 기준으로 문단이 구분됩니다.</small></label>
<div class="admin-field-grid"><label>작성자<input name="author" value="<?= old('author', $post['author'] ?? config('app.legal_name')) ?>" maxlength="100"></label><label>공개 상태<select name="status"><option value="draft"<?= ($_POST['status']??$post['status']??'draft')==='draft'?' selected':'' ?>>초안</option><option value="published"<?= ($_POST['status']??$post['status']??'draft')==='published'?' selected':'' ?>>공개</option></select></label></div>
<div class="admin-form-actions"><button class="button button-primary" type="submit">저장하기</button><a class="button button-secondary" href="/admin/posts">취소</a></div>
</form>
<?php if ($post): ?><form class="danger-zone" method="post" action="/admin/posts/<?= e($post['id']) ?>/delete" onsubmit="return confirm('이 글을 삭제할까요? 삭제 후 목록에 표시되지 않습니다.');"><?= csrf_field() ?><div><strong>글 삭제</strong><p>게시글을 복구 가능한 삭제 상태로 전환합니다.</p></div><button type="submit">삭제</button></form><?php endif; ?>

View File

@@ -0,0 +1 @@
<header class="admin-page-heading"><div><span>CONTENTS</span><h1> 관리</h1><p>블로그 글을 작성하고 공개 상태를 관리합니다.</p></div><a class="button button-primary" href="/admin/posts/new"> 작성</a></header><?php if ($posts): ?><div class="admin-table-wrap"><table class="admin-table"><thead><tr><th>제목</th><th>분류</th><th>상태</th><th>공개일</th><th>관리</th></tr></thead><tbody><?php foreach ($posts as $post): ?><tr><td data-label="제목"><strong><?= e($post['title']) ?></strong><small>/blog/<?= e($post['slug']) ?></small></td><td data-label="분류"><?= e($post['category']) ?></td><td data-label="상태"><span class="status status-<?= e($post['status']) ?>"><?= $post['status'] === 'published' ? '공개' : '초안' ?></span></td><td data-label="공개일"><?= $post['published_at'] ? e(date('Y.m.d', strtotime($post['published_at']))) : '-' ?></td><td data-label="관리"><a href="/admin/posts/<?= e($post['id']) ?>/edit">수정</a></td></tr><?php endforeach; ?></tbody></table></div><?php else: ?><div class="empty-state"><h2>등록된 글이 없습니다</h2><p>새 글을 작성해 콘텐츠를 추가해 주세요.</p></div><?php endif; ?>

16
app/Views/admin/setup.php Normal file
View File

@@ -0,0 +1,16 @@
<section class="login-panel">
<div class="login-heading">
<img class="login-logo" src="<?= e(asset('images/company-logo-v3.png')) ?>" alt="" width="112" height="52">
<h1>관리자 계정 등록</h1>
<p>비밀번호는 서버에 안전하게 해시하여 저장됩니다.</p>
</div>
<?php if ($errors): ?><div class="admin-error" role="alert"><ul><?php foreach ($errors as $error): ?><li><?= e($error) ?></li><?php endforeach; ?></ul></div><?php endif; ?>
<form method="post" action="/admin/setup">
<?= csrf_field() ?>
<label>관리자 아이디<input value="<?= e($username) ?>" readonly></label>
<label>표시 이름<input name="display_name" value="<?= old('display_name', $displayName) ?>" required maxlength="100" autocomplete="name"></label>
<label>비밀번호<input type="password" name="password" required minlength="12" maxlength="72" autocomplete="new-password"><small>영문과 숫자를 포함한 12자 이상</small></label>
<label>비밀번호 확인<input type="password" name="password_confirmation" required minlength="12" maxlength="72" autocomplete="new-password"></label>
<button class="button button-primary button-full" type="submit">관리자 계정 만들기</button>
</form>
</section>

6
app/Views/blog/index.php Normal file
View File

@@ -0,0 +1,6 @@
<section class="page-section"><div class="container"><header class="page-heading"><span>BLOG · 자료실</span><h1>시공 이야기 &amp; 알아두면 좋은 정보</h1><p>네트워크·키폰·CCTV 시공 사례와 장비 선택 정보를 정리했습니다.</p></header>
<nav class="category-filter" aria-label="게시글 분류"><a href="/blog"<?= $category === '' ? ' aria-current="page"' : '' ?>>전체</a><?php foreach (['네트워크','키폰시스템','CCTV','시공사례'] as $item): ?><a href="/blog?category=<?= rawurlencode($item) ?>"<?= $category === $item ? ' aria-current="page"' : '' ?>><?= e($item) ?></a><?php endforeach; ?></nav>
<?php if ($posts): ?><div class="post-grid"><?php foreach ($posts as $post): ?><article class="post-card"><div class="post-thumbnail"><?php if (!empty($post['image_path'])): ?><img src="<?= e($post['image_path']) ?>" alt="<?= e($post['image_alt']) ?>" loading="lazy"><?php else: ?><div class="post-placeholder" data-category="<?= e($post['category']) ?>" aria-hidden="true"><img src="<?= e(asset('images/company-logo-v3.png')) ?>" alt="" width="136" height="63"><small>TECH FIELD NOTE</small></div><?php endif; ?><span class="post-category"><?= e($post['category']) ?></span></div><div class="post-body"><h2><a href="/blog/<?= e($post['slug']) ?>"><?= e($post['title']) ?></a></h2><p><?= e($post['excerpt']) ?></p><time datetime="<?= e(date('Y-m-d', strtotime($post['published_at']))) ?>"><?= e(date('Y.m.d', strtotime($post['published_at']))) ?></time></div></article><?php endforeach; ?></div>
<?php else: ?><div class="empty-state"><h2>등록된 게시글이 없습니다</h2><p>선택한 분류의 콘텐츠를 준비하고 있습니다.</p></div><?php endif; ?>
<?php $pageCount = max(1, (int) ceil($total / $perPage)); if ($pageCount > 1): ?><nav class="pagination" aria-label="페이지 이동"><?php for ($index = 1; $index <= $pageCount; $index++): ?><a href="/blog?page=<?= $index ?><?= $category ? '&amp;category=' . rawurlencode($category) : '' ?>"<?= $index === $page ? ' aria-current="page"' : '' ?>><?= $index ?></a><?php endfor; ?></nav><?php endif; ?>
</div></section>

1
app/Views/blog/show.php Normal file
View File

@@ -0,0 +1 @@
<article class="article"><a class="back-link" href="/blog"> 블로그 목록</a><header><span class="badge"><?= e($post['category']) ?></span><time datetime="<?= e(date('Y-m-d', strtotime($post['published_at']))) ?>"><?= e(date('Y.m.d', strtotime($post['published_at']))) ?></time><h1><?= e($post['title']) ?></h1><p><?= e($post['author']) ?> 기술팀</p></header><?php if (!empty($post['image_path'])): ?><figure class="article-image"><img src="<?= e($post['image_path']) ?>" alt="<?= e($post['image_alt']) ?>"></figure><?php endif; ?><div class="article-body"><?php foreach (preg_split('/\R{2,}/', trim($post['body'])) ?: [] as $paragraph): ?><p><?= nl2br(e($paragraph)) ?></p><?php endforeach; ?></div><aside class="article-cta"><div><strong>비슷한 시공이 필요하신가요?</strong><p>현장 조건에 맞는 구성을 상담해 드립니다.</p></div><a class="button button-primary" href="/contact">상담 신청</a></aside></article>

38
app/Views/contact.php Normal file
View File

@@ -0,0 +1,38 @@
<section class="page-section"><div class="container"><header class="page-heading"><span>CONTACT</span><h1>1:1 비공개 상담 신청</h1><p>접수 내용은 담당자만 확인하며 연락처로 회신드립니다.</p></header><div class="contact-layout"><form class="contact-form" method="post" action="/contact" novalidate><?= csrf_field() ?><h2>상담 신청서</h2><p class="required-note"><span>*</span> 표시는 필수 입력 항목입니다.</p>
<?php if (!empty($errors)): ?><div class="form-errors" role="alert"><strong>입력 내용을 확인해 주세요.</strong><ul><?php foreach ($errors as $error): ?><li><?= e($error) ?></li><?php endforeach; ?></ul></div><?php endif; ?>
<div class="field-grid"><label>이름 / 회사명 <span>*</span><input name="name" value="<?= old('name') ?>" required maxlength="120" autocomplete="name"></label><label>연락처 <span>*</span><input name="phone" value="<?= old('phone') ?>" required maxlength="14" inputmode="numeric" autocomplete="tel" placeholder="010-0000-0000" data-phone-input></label></div>
<div class="field-grid">
<fieldset class="form-field email-field" data-email-field>
<legend>이메일</legend>
<div class="email-template" data-email-template hidden>
<label class="sr-only" for="email-local">이메일 아이디</label><input id="email-local" maxlength="64" autocomplete="off" inputmode="email" placeholder="이메일 아이디" data-email-local>
<span aria-hidden="true">@</span>
<label class="sr-only" for="email-domain">이메일 도메인</label><select id="email-domain" aria-label="이메일 도메인 선택" data-email-domain><option value="naver.com">naver.com</option><option value="gmail.com">gmail.com</option><option value="daum.net">daum.net</option><option value="hanmail.net">hanmail.net</option><option value="kakao.com">kakao.com</option><option value="nate.com">nate.com</option><option value="direct">직접입력</option></select>
</div>
<div class="email-direct-row" data-email-direct><input type="email" name="email" value="<?= old('email') ?>" maxlength="190" autocomplete="email" placeholder="example@domain.com" aria-label="전체 이메일 주소" data-email-full><button class="email-template-return" type="button" data-email-return hidden>주소 선택</button></div>
</fieldset>
<label>문의 유형<select name="type"><?php $selectedType = $_POST['type'] ?? $_GET['type'] ?? '네트워크 구축'; foreach (['네트워크 구축','키폰시스템','CCTV 설치','시공·유지보수','기타 문의'] as $type): ?><option<?= $selectedType === $type ? ' selected' : '' ?>><?= e($type) ?></option><?php endforeach; ?></select></label>
</div>
<label>제목 <span>*</span><input name="title" value="<?= old('title') ?>" required maxlength="200"></label><label>문의 내용 <span>*</span><textarea name="body" required maxlength="5000" rows="8" placeholder="현장 위치, 규모, 희망 일정 등을 적어주세요."><?= old('body') ?></textarea></label><label>조회 PIN <span>*</span><input type="password" name="password" required minlength="4" maxlength="4" inputmode="numeric" pattern="[0-9]{4}" autocomplete="new-password" aria-describedby="password-help" data-pin-input><small id="password-help">접수 결과와 답변 확인에 사용할 숫자 4자리를 입력해 주세요.</small></label>
<section class="consent-notice" aria-labelledby="consent-notice-title"><h3 id="consent-notice-title">개인정보 수집·이용 안내</h3><dl><div><dt>수집 목적</dt><dd>상담 접수, 문의 확인 및 답변 제공</dd></div><div><dt>필수 항목</dt><dd>이름 또는 회사명, 연락처, 문의 유형, 제목, 내용, 조회 PIN</dd></div><div><dt>선택 항목</dt><dd>이메일</dd></div><div><dt>보유 기간</dt><dd>문의 처리 종료 후 1년</dd></div></dl><p>동의를 거부할 권리가 있으나, 필수 항목 수집에 동의하지 않으면 온라인 문의를 접수할 수 없습니다.</p></section>
<label class="consent"><input type="checkbox" name="consent" value="1" required<?= isset($_POST['consent']) ? ' checked' : '' ?>><span>위 개인정보 수집·이용 안내를 확인하고 동의합니다. <a href="/privacy" target="_blank" rel="noopener">개인정보처리방침 보기</a></span></label><button class="button button-primary button-full" type="submit">1:1 문의 접수하기</button></form>
<?php
$address = (string) site_setting('address', config('app.contact.address'));
$mapQuery = (string) config('app.naver_maps.query', $address);
$mapUrl = 'https://map.naver.com/p/search/' . rawurlencode($mapQuery);
?>
<aside class="contact-side">
<div class="contact-card primary"><small>전화로 바로 상담</small><a href="tel:<?= e(site_setting('phone', config('app.contact.phone'))) ?>"><?= e(site_setting('phone', config('app.contact.phone'))) ?></a><p>경기지점 <?= e(site_setting('phone_sub', config('app.contact.phone_sub'))) ?><br>FAX <?= e(site_setting('fax', config('app.contact.fax'))) ?></p></div>
<div class="contact-card"><h2>영업시간</h2><p><?= e(site_setting('business_hours', config('app.contact.business_hours'))) ?><br>주말·공휴일 휴무</p></div>
<section class="contact-card contact-map-card" aria-labelledby="contact-map-title">
<h2 id="contact-map-title">오시는 길</h2>
<p><?= e($address) ?></p>
<div class="naver-map-frame" data-naver-map data-client-id="<?= e(config('app.naver_maps.client_id')) ?>" data-latitude="<?= e(config('app.naver_maps.latitude')) ?>" data-longitude="<?= e(config('app.naver_maps.longitude')) ?>" data-label="<?= e(config('app.legal_name')) ?>">
<div class="naver-map" aria-label="<?= e(config('app.legal_name')) ?> 위치 지도"></div>
<p class="naver-map-status" role="status" data-naver-map-status>지도를 불러오는 중입니다.</p>
<button class="naver-map-fullscreen" type="button" aria-label="지도를 전체 화면으로 보기" title="전체 화면" data-naver-map-fullscreen hidden><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 3H3v5M16 3h5v5M21 16v5h-5M8 21H3v-5"/></svg></button>
</div>
<a class="naver-map-external" href="<?= e($mapUrl) ?>" target="_blank" rel="noopener" data-naver-map-external>네이버 지도에서 확인</a>
</section>
</aside>
</div></div></section>

1
app/Views/errors/404.php Normal file
View File

@@ -0,0 +1 @@
<section class="error-page"><span>404</span><h1><?= e($title) ?></h1><p>주소를 다시 확인하거나 홈으로 이동해 주세요.</p><a class="button button-primary" href="/">홈으로 이동</a></section>

1
app/Views/errors/419.php Normal file
View File

@@ -0,0 +1 @@
<section class="error-page"><span>403</span><h1><?= e($title) ?></h1><p>페이지를 새로고침한 뒤 다시 시도해 주세요.</p><a class="button button-primary" href="/">홈으로 이동</a></section>

66
app/Views/home.php Normal file
View File

@@ -0,0 +1,66 @@
<section class="hero">
<div class="container hero-grid">
<div class="hero-copy">
<span class="eyebrow">20 현장 경험 · 통신 인프라 전문</span>
<h1>사무실 네트워크,<br>처음부터 끝까지 책임집니다</h1>
<p>LAN 구축부터 키폰·CCTV 시공과 유지보수까지 직접 시공팀이 안정적인 통신 환경을 책임집니다.</p>
<div class="hero-actions"><a class="button button-primary" href="tel:<?= e(site_setting('phone', config('app.contact.phone'))) ?>">전화상담 <?= e(site_setting('phone', config('app.contact.phone'))) ?></a><a class="button button-secondary" href="/contact">1:1 온라인 문의</a></div>
<small><?= e(site_setting('business_hours', config('app.contact.business_hours'))) ?> · 견적 상담 무료</small>
</div>
<div class="hero-visual hero-slider" data-hero-slider aria-label="주요 시공 현장 사진">
<div class="hero-slides" aria-live="off">
<figure class="hero-slide is-active" aria-hidden="false"><img src="<?= e(asset('images/field/keyphone-network-rack.jpg')) ?>" alt="키폰과 네트워크 장비를 구성한 통신 랙" width="1200" height="900" fetchpriority="high"><figcaption>키폰·네트워크 통합 장비 구성</figcaption></figure>
<figure class="hero-slide" aria-hidden="true"><img src="<?= e(asset('images/field/lan-cabling-rack.jpg')) ?>" alt="포트별로 정리하고 라벨링한 네트워크 배선 랙" width="1200" height="900" loading="lazy"><figcaption>네트워크 배선 정리와 라벨링</figcaption></figure>
<figure class="hero-slide" aria-hidden="true"><img src="<?= e(asset('images/field/cctv-control-rack.jpg')) ?>" alt="여러 CCTV 화면을 확인하는 녹화장치와 관제 랙" width="1200" height="900" loading="lazy"><figcaption>CCTV 녹화장치·관제 환경 구성</figcaption></figure>
<figure class="hero-slide" aria-hidden="true"><img src="<?= e(asset('images/field/outdoor-wireless-link.jpg')) ?>" alt="건물 옥상에 설치한 옥외 무선 통신 장비" width="1200" height="900" loading="lazy"><figcaption>옥외 무선 통신 구간 설치</figcaption></figure>
</div>
<div class="hero-slider-controls">
<button class="hero-slider-arrow" type="button" data-slider-prev aria-label="이전 시공 사진"></button>
<div class="hero-slider-dots" role="group" aria-label="시공 사진 선택">
<button class="is-active" type="button" data-slider-dot="0" aria-label="1번째 사진 보기" aria-current="true"></button>
<button type="button" data-slider-dot="1" aria-label="2번째 사진 보기"></button>
<button type="button" data-slider-dot="2" aria-label="3번째 사진 보기"></button>
<button type="button" data-slider-dot="3" aria-label="4번째 사진 보기"></button>
</div>
<button class="hero-slider-arrow" type="button" data-slider-next aria-label="다음 시공 사진"></button>
<button class="hero-slider-pause" type="button" data-slider-pause aria-pressed="false">자동재생 일시정지</button>
</div>
</div>
</div>
</section>
<section class="stats" aria-label="주요 실적">
<div class="container stats-grid">
<div><strong>20<small>년</small></strong><span>통신 인프라 업력</span></div><div><strong>1,200<small>+</small></strong><span>누적 시공 현장</span></div><div><strong>24<small>시간</small></strong><span>견적 회신 기준</span></div><div><strong>A/S</strong><span>시공 후 사후관리</span></div>
</div>
</section>
<section class="section services" aria-labelledby="services-title">
<div class="container"><header class="section-heading centered"><span>SERVICES</span><h2 id="services-title">전문 시공 서비스</h2><p>한 곳에서 설계·시공·유지보수를 책임집니다.</p></header>
<div class="service-grid">
<?php
$serviceIcons = [
'network' => '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="2" y="14" width="20" height="8" rx="2"/><path d="M6 18h.01M10 18h.01M15 10v4M17.8 7.2a4 4 0 0 0-5.6 0M20.7 4.3a8 8 0 0 0-11.4 0"/></svg>',
'keyphone' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6 19.8 19.8 0 0 1-3.1-8.6A2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7c.1 1 .4 1.9.7 2.8a2 2 0 0 1-.4 2.1L8.1 9.9a16 16 0 0 0 6 6l1.3-1.3a2 2 0 0 1 2.1-.4c.9.3 1.9.6 2.8.7a2 2 0 0 1 1.7 2Z"/></svg>',
'cctv' => '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="2" y="6" width="14" height="12" rx="2"/><path d="m16 10 6-3v10l-6-3zM6 10h4"/></svg>',
'maintenance' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.8-3.8a6 6 0 0 1-7.9 8l-7 6.9a2.1 2.1 0 0 1-3-3l7-6.9a6 6 0 0 1 7.9-8z"/></svg>',
];
$services = [
['네트워크(LAN) 구축', '사무실과 건물의 유선·무선 네트워크를 안정적으로 설계하고 시공합니다.', '네트워크 구축', 'network'],
['키폰시스템', '사업장 규모에 맞는 주장치와 내선 구성을 제안하고 설치합니다.', '키폰시스템', 'keyphone'],
['CCTV 보안', '현장에 맞는 카메라와 저장장치, 통합 관제 환경을 구축합니다.', 'CCTV 설치', 'cctv'],
['시공·유지보수', '정기 점검과 장애 대응까지 통신 인프라를 지속적으로 관리합니다.', '시공·유지보수', 'maintenance'],
];
foreach ($services as [$name, $text, $type, $icon]): ?>
<article class="service-card"><span class="service-icon type-<?= e($icon) ?>" aria-hidden="true"><?= $serviceIcons[$icon] ?></span><h3><?= e($name) ?></h3><p><?= e($text) ?></p><a href="/contact?type=<?= rawurlencode($type) ?>">상담 요청</a></article>
<?php endforeach; ?>
</div>
</div>
</section>
<section class="section process" aria-labelledby="process-title"><div class="container"><header class="section-heading centered"><span>PROCESS</span><h2 id="process-title">상담부터 사후관리까지</h2><p>확인 가능한 5단계 과정으로 진행합니다.</p></header><ol class="process-grid"><li><b>1</b><strong>상담 신청</strong><span>전화 또는 온라인 요청</span></li><li><b>2</b><strong>현장 조사</strong><span>환경과 요구사항 확인</span></li><li><b>3</b><strong>견적 제안</strong><span>항목별 구성 안내</span></li><li><b>4</b><strong>시공</strong><span>안전한 설치와 검수</span></li><li><b>5</b><strong>사후관리</strong><span>점검과 장애 대응</span></li></ol></div></section>
<section class="section latest" aria-labelledby="latest-title"><div class="container"><header class="section-heading row"><div><span>BLOG</span><h2 id="latest-title">시공 이야기와 알아두면 좋은 정보</h2></div><a class="button button-secondary button-small" href="/blog">블로그 더보기</a></header>
<?php if ($posts): ?><div class="post-grid"><?php foreach ($posts as $post): ?><article class="post-card"><div class="post-thumbnail"><?php if (!empty($post['image_path'])): ?><img src="<?= e($post['image_path']) ?>" alt="<?= e($post['image_alt']) ?>" loading="lazy"><?php else: ?><div class="post-placeholder" data-category="<?= e($post['category']) ?>" aria-hidden="true"><img src="<?= e(asset('images/company-logo-v3.png')) ?>" alt="" width="136" height="63"><small>TECH FIELD NOTE</small></div><?php endif; ?><span class="post-category"><?= e($post['category']) ?></span></div><div class="post-body"><h3><a href="/blog/<?= e($post['slug']) ?>"><?= e($post['title']) ?></a></h3><p><?= e($post['excerpt']) ?></p><time datetime="<?= e(date('Y-m-d', strtotime($post['published_at']))) ?>"><?= e(date('Y.m.d', strtotime($post['published_at']))) ?></time></div></article><?php endforeach; ?></div>
<?php else: ?><div class="empty-state"><h3>콘텐츠를 준비하고 있습니다</h3><p>시공 사례와 유용한 정보를 순차적으로 등록하겠습니다.</p></div><?php endif; ?>
</div></section>

View File

@@ -0,0 +1 @@
<section class="page-section"><div class="container narrow"><header class="page-heading"><span>INQUIRY</span><h1>문의 접수 확인</h1><p>접수번호와 작성 입력한 숫자 4자리 조회 PIN을 입력해 주세요.</p></header><form class="lookup-form" method="post" action="/inquiry/lookup"><?= csrf_field() ?><?php if ($error): ?><p class="form-errors" role="alert"><?= e($error) ?></p><?php endif; ?><label>접수번호<input name="ticket" value="<?= e($ticket) ?>" required maxlength="32" autocomplete="off" placeholder="GTS-20260804-ABC123"></label><label>조회 PIN<input type="password" name="password" required minlength="4" maxlength="4" inputmode="numeric" pattern="[0-9]{4}" autocomplete="current-password" data-pin-input></label><button class="button button-primary button-full" type="submit">문의 확인</button></form></div></section>

View File

@@ -0,0 +1,2 @@
<?php $statusLabels = ['pending'=>'접수 대기','in_progress'=>'확인 중','replied'=>'답변 완료','closed'=>'처리 종료']; ?>
<section class="page-section"><div class="container narrow"><a class="back-link" href="/inquiry/lookup">← 다른 문의 확인</a><article class="inquiry-result"><header><span class="badge"><?= e($statusLabels[$inquiry['status']] ?? '처리 중') ?></span><small><?= e($inquiry['ticket']) ?></small><h1><?= e($inquiry['title']) ?></h1><time><?= e(date('Y.m.d H:i', strtotime($inquiry['created_at']))) ?></time></header><section><h2>문의 내용</h2><p><?= nl2br(e($inquiry['body'])) ?></p></section><section class="reply"><h2>담당자 답변</h2><?php if (!empty($inquiry['reply'])): ?><p><?= nl2br(e($inquiry['reply'])) ?></p><time><?= e(date('Y.m.d H:i', strtotime($inquiry['replied_at']))) ?></time><?php else: ?><p>담당자가 문의를 확인하고 있습니다. 답변 등록 후 이 화면에서 확인할 수 있습니다.</p><?php endif; ?></section></article></div></section>

View File

@@ -0,0 +1 @@
<section class="page-section"><div class="container narrow"><div class="success-panel"><span aria-hidden="true"></span><h1>문의가 접수되었습니다</h1><p>담당자가 확인 입력하신 연락처로 회신드리겠습니다.</p><dl><div><dt>접수번호</dt><dd><?= e($ticket) ?></dd></div><div><dt>접수일시</dt><dd><?= e(date('Y.m.d H:i')) ?></dd></div><div><dt>예상 회신</dt><dd>영업일 기준 24시간 이내</dd></div></dl><p class="success-help">접수번호와 작성할 때 입력한 숫자 4자리 조회 PIN으로 처리 상태와 답변을 확인할 수 있습니다.</p><div class="success-actions"><a class="button button-primary" href="/inquiry/lookup?ticket=<?= rawurlencode($ticket) ?>">문의 확인</a><a class="button button-secondary" href="/">홈으로 이동</a></div></div></div></section>

View File

@@ -0,0 +1,19 @@
<?php $loggedIn = \App\Auth\AdminAuth::check(); ?>
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow,noarchive">
<title><?= e($title ?? '관리자') ?> | <?= e(config('app.legal_name')) ?> 관리자</title>
<link rel="stylesheet" href="<?= e(asset('css/site.css')) ?>">
<link rel="stylesheet" href="<?= e(asset('css/content.css')) ?>">
<link rel="stylesheet" href="<?= e(asset('css/admin.css')) ?>">
</head>
<body class="admin-body">
<a class="skip-link" href="#admin-main">본문 바로가기</a>
<header class="admin-header"><div class="admin-header-inner"><a class="admin-brand" href="/admin"><img class="brand-logo" src="<?= e(asset('images/company-logo-v3.png')) ?>" alt="" width="60" height="28"><strong><?= e(config('app.legal_name')) ?> 관리자 콘솔</strong></a><?php if ($loggedIn): ?><div class="admin-account"><span><?= e($_SESSION['admin']['display_name']) ?></span><a class="button button-secondary button-small" href="/">사이트 보기</a><form method="post" action="/admin/logout"><?= csrf_field() ?><button type="submit">로그아웃</button></form></div><?php endif; ?></div></header>
<?php if ($loggedIn): ?><nav class="admin-nav" aria-label="관리 메뉴"><div><a href="/admin"<?= is_active_path('/admin') && !is_active_path('/admin/posts') && !is_active_path('/admin/inquiries') && !is_active_path('/admin/company') ? ' aria-current="page"' : '' ?>>대시보드</a><a href="/admin/posts"<?= is_active_path('/admin/posts') ? ' aria-current="page"' : '' ?>>글 관리</a><a href="/admin/inquiries"<?= is_active_path('/admin/inquiries') ? ' aria-current="page"' : '' ?>>문의 관리</a><a href="/admin/company"<?= is_active_path('/admin/company') ? ' aria-current="page"' : '' ?>>회사 정보</a></div></nav><?php endif; ?>
<main id="admin-main" class="admin-main"><?= $content ?></main>
</body>
</html>

View File

@@ -0,0 +1,79 @@
<?php
$pageTitle = isset($title) ? $title . ' | ' . config('app.name') : config('app.name');
$pageDescription = $description ?? '네트워크, 키폰, CCTV 설계·시공·유지보수 전문 기업';
$canonicalPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$canonical = rtrim((string) config('app.base_url'), '/') . $canonicalPath;
$legalName = site_setting('legal_name', config('app.legal_name'));
$phone = site_setting('phone', config('app.contact.phone'));
$phoneSub = site_setting('phone_sub', config('app.contact.phone_sub'));
$fax = site_setting('fax', config('app.contact.fax'));
$email = site_setting('email', config('app.contact.email'));
$address = site_setting('address', config('app.contact.address'));
$bizno = site_setting('bizno', '206-86-70582');
?>
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($pageTitle) ?></title>
<meta name="description" content="<?= e($pageDescription) ?>">
<?php if (config('app.noindex', false)): ?><meta name="robots" content="noindex,nofollow,noarchive"><?php endif; ?>
<link rel="canonical" href="<?= e($canonical) ?>">
<meta property="og:type" content="website">
<meta property="og:locale" content="ko_KR">
<meta property="og:site_name" content="<?= e(config('app.name')) ?>">
<meta property="og:title" content="<?= e($pageTitle) ?>">
<meta property="og:description" content="<?= e($pageDescription) ?>">
<meta property="og:url" content="<?= e($canonical) ?>">
<link rel="stylesheet" href="<?= e(asset('css/site.css')) ?>?v=20260805-11">
<link rel="stylesheet" href="<?= e(asset('css/content.css')) ?>?v=20260805-9">
<script src="<?= e(asset('js/site.js')) ?>?v=20260805-9" defer></script>
</head>
<body>
<a class="skip-link" href="#main-content">본문 바로가기</a>
<header class="site-header">
<div class="header-inner">
<a class="brand" href="/" aria-label="<?= e($legalName) ?> 홈">
<img class="brand-logo" src="<?= e(asset('images/company-logo-v3.png')) ?>" alt="" width="64" height="30">
<span class="brand-copy"><strong><?= e($legalName) ?></strong></span>
</a>
<button class="menu-toggle" type="button" aria-expanded="false" aria-controls="site-navigation">
<span class="sr-only">메뉴 열기</span><span></span><span></span><span></span>
</button>
<nav id="site-navigation" class="site-navigation" aria-label="주요 메뉴">
<a href="/"<?= is_active_path('/') ? ' aria-current="page"' : '' ?>>홈</a>
<a href="/blog"<?= is_active_path('/blog') ? ' aria-current="page"' : '' ?>>블로그</a>
<a href="/contact"<?= is_active_path('/contact') ? ' aria-current="page"' : '' ?>>1:1 문의</a>
</nav>
<div class="header-actions">
<a class="header-phone" href="tel:<?= e($phone) ?>"><?= e($phone) ?></a>
<a class="button button-primary button-small" href="/contact">상담문의</a>
</div>
</div>
</header>
<main id="main-content"><?= $content ?></main>
<section class="cta-band" aria-labelledby="cta-title">
<div class="container cta-inner">
<div><h2 id="cta-title">통신 인프라 구축, 어디서부터 시작할지 고민되세요?</h2><p>현장 상황에 맞는 상담과 견적을 안내해 드립니다.</p></div>
<div class="cta-actions"><a class="button button-light" href="tel:<?= e($phone) ?>">전화상담 <?= e($phone) ?></a><a class="button button-outline-light" href="/contact">1:1 온라인 문의</a></div>
</div>
</section>
<footer class="site-footer">
<div class="container footer-grid">
<div><a class="brand footer-brand" href="/"><img class="brand-logo" src="<?= e(asset('images/company-logo-v3.png')) ?>" alt="" width="56" height="26"><span class="brand-copy"><strong><?= e($legalName) ?></strong></span></a><p>네트워크·키폰·CCTV 설계부터 시공·유지보수까지 책임집니다.</p></div>
<div><h2>바로가기</h2><a href="/">홈</a><a href="/blog">블로그</a><a href="/contact">1:1 문의</a><a href="/privacy">개인정보처리방침</a></div>
<div><h2>연락처</h2><p>대표번호 <?= e($phone) ?><br>경기지점 <?= e($phoneSub) ?><br>FAX <?= e($fax) ?><br>이메일 <?= e($email) ?></p></div>
<div><h2>사업자 정보</h2><p><?= e($legalName) ?><br>사업자등록번호 <?= e($bizno) ?><br><?= e($address) ?></p></div>
</div>
<div class="container footer-bottom">© 2026 <?= e($legalName) ?>. All rights reserved.</div>
</footer>
<a class="floating-call" href="tel:<?= e($phone) ?>" aria-label="전화상담 <?= e($phone) ?>">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
<span>전화상담</span>
</a>
</body>
</html>

85
app/Views/privacy.php Normal file
View File

@@ -0,0 +1,85 @@
<?php
$legalName = site_setting('legal_name', config('app.legal_name'));
$phone = site_setting('phone', config('app.contact.phone'));
$email = site_setting('email', config('app.contact.email'));
?>
<article class="policy">
<header class="policy-heading">
<span>PRIVACY</span>
<h1>개인정보처리방침</h1>
<p><?= e($legalName) ?>(이하 “회사”)는 정보주체의 개인정보를 중요하게 생각하며 「개인정보 보호법」 등 관련 법령을 준수합니다.</p>
</header>
<nav class="policy-index" aria-label="개인정보처리방침 목차">
<ol>
<li><a href="#policy-purpose">처리 목적·항목·보유기간</a></li>
<li><a href="#policy-third-party">제3자 제공</a></li>
<li><a href="#policy-outsourcing">처리 위탁</a></li>
<li><a href="#policy-destruction">파기</a></li>
<li><a href="#policy-rights">정보주체의 권리</a></li>
<li><a href="#policy-security">안전성 확보 조치</a></li>
<li><a href="#policy-cookie">자동 수집 장치</a></li>
<li><a href="#policy-contact">담당부서·권익침해 구제</a></li>
</ol>
</nav>
<section id="policy-purpose">
<h2>1. 개인정보의 처리 목적, 항목 및 보유기간</h2>
<p>회사는 다음 목적에 필요한 최소한의 개인정보를 처리합니다.</p>
<div class="policy-table-wrap">
<table>
<thead><tr><th scope="col">구분</th><th scope="col">처리 목적</th><th scope="col">처리 항목</th><th scope="col">법적 근거</th><th scope="col">보유기간</th></tr></thead>
<tbody><tr><th scope="row">온라인 상담 문의</th><td>상담 접수, 문의자 확인, 답변 제공 및 처리 이력 관리</td><td><strong>필수</strong>: 이름 또는 회사명, 연락처, 문의 유형, 제목, 내용, 조회 PIN의 일방향 암호화값<br><strong>선택</strong>: 이메일<br><strong>자동 생성</strong>: 접수번호, 접수·동의·답변·처리 시각</td><td>정보주체의 동의<br>「개인정보 보호법」 제15조 제1항 제1호</td><td>문의 처리 종료 후 1년</td></tr></tbody>
</table>
</div>
<p>관계 법령에 따라 개인정보를 별도로 보존해야 하는 경우에는 해당 법령에서 정한 기간 동안 분리하여 보관합니다.</p>
</section>
<section id="policy-third-party">
<h2>2. 개인정보의 제3자 제공</h2>
<p>회사는 원칙적으로 정보주체의 개인정보를 제3자에게 제공하지 않습니다. 다만 정보주체가 별도로 동의했거나 법률에 특별한 규정이 있는 경우에는 필요한 범위에서 제공할 수 있습니다.</p>
</section>
<section id="policy-outsourcing">
<h2>3. 개인정보 처리업무의 위탁</h2>
<p>회사는 원활한 사이트 운영을 위해 다음 업무를 위탁하고 있으며, 위탁계약 등을 통해 개인정보가 안전하게 관리되도록 필요한 사항을 규정하고 있습니다.</p>
<div class="policy-table-wrap"><table><thead><tr><th scope="col">수탁업체</th><th scope="col">위탁업무</th></tr></thead><tbody><tr><td>카페24(주)</td><td>웹·데이터베이스 호스팅, 데이터 저장 및 백업을 위한 인프라 제공</td></tr></tbody></table></div>
<p>수탁업체 또는 위탁업무가 변경되면 본 처리방침을 통해 공개합니다.</p>
</section>
<section id="policy-destruction">
<h2>4. 개인정보의 파기 절차 및 방법</h2>
<p>회사는 보유기간이 지나거나 처리 목적을 달성해 개인정보가 불필요하게 된 경우 지체 없이 파기합니다. 온라인 문의는 관리자가 처리 상태를 “종료”로 변경한 시점부터 1년간 보관한 뒤 데이터베이스에서 복구할 수 없도록 삭제합니다. 관계 법령에 따라 보존해야 하는 정보는 다른 개인정보와 분리하여 보관한 후 기간이 끝나면 파기합니다.</p>
<ul><li>전자적 파일: 복구 또는 재생할 수 없는 방법으로 영구 삭제</li><li>종이 문서가 발생한 경우: 분쇄 또는 소각</li></ul>
</section>
<section id="policy-rights">
<h2>5. 정보주체와 법정대리인의 권리·의무 및 행사방법</h2>
<p>정보주체는 회사에 개인정보 열람, 정정·삭제, 처리정지 및 동의 철회를 요구할 수 있습니다. 아래 담당부서에 전화 또는 이메일로 요청하면 본인 확인 후 관련 법령에서 정한 절차에 따라 처리합니다. 법정대리인이나 위임받은 사람을 통해서도 권리를 행사할 수 있으며, 이 경우 위임장 등 정당한 대리권을 확인할 수 있는 자료를 요청할 수 있습니다.</p>
</section>
<section id="policy-security">
<h2>6. 개인정보의 안전성 확보 조치</h2>
<p>회사는 개인정보의 분실·도난·유출·위조·변조 또는 훼손을 방지하기 위해 다음 조치를 시행합니다.</p>
<ul><li>개인정보 취급자 및 관리자 접근 권한 제한</li><li>조회 PIN과 관리자 비밀번호의 일방향 암호화 저장</li><li>HTTPS 암호화 통신, 접근 통제 및 조회 시도 제한</li><li>보안 프로그램과 서버 소프트웨어의 점검 및 갱신</li><li>개인정보 처리시스템 접속기록의 보호와 정기 점검</li></ul>
</section>
<section id="policy-cookie">
<h2>7. 자동으로 수집하는 장치의 설치·운영 및 거부</h2>
<p>회사는 로그인 상태 유지, 위조 요청 방지 및 문의 중복 접수 방지를 위해 필수 세션 쿠키를 사용합니다. 이 쿠키에는 임의의 세션 식별자만 저장되며 브라우저를 닫으면 만료됩니다. 브라우저 설정에서 쿠키를 차단할 수 있으나, 차단하면 문의 접수와 관리자 기능을 이용하기 어려울 수 있습니다.</p>
<p>오시는 길의 네이버 지도를 불러올 때 이용자의 브라우저가 네이버 지도 서비스에 직접 연결되며, 해당 서비스에서 접속 정보 등이 처리될 수 있습니다. 자세한 내용은 <a href="https://www.navercorp.com/policy/privacy" target="_blank" rel="noopener">네이버 개인정보처리방침</a>에서 확인할 수 있습니다.</p>
</section>
<section id="policy-contact">
<h2>8. 개인정보 보호 담당부서 및 권익침해 구제</h2>
<div class="policy-contact"><h3>개인정보 보호 담당부서</h3><dl><div><dt>담당</dt><dd><?= e($legalName) ?> 개인정보 보호 담당부서</dd></div><div><dt>전화</dt><dd><a href="tel:<?= e($phone) ?>"><?= e($phone) ?></a></dd></div><div><dt>이메일</dt><dd><a href="mailto:<?= e($email) ?>"><?= e($email) ?></a></dd></div></dl></div>
<p>개인정보 침해에 관한 상담이나 피해 구제가 필요한 경우 다음 기관에 문의할 수 있습니다.</p>
<ul><li>개인정보침해신고센터: 국번 없이 118 · <a href="https://privacy.kisa.or.kr" target="_blank" rel="noopener">privacy.kisa.or.kr</a></li><li>개인정보분쟁조정위원회: 1833-6972 · <a href="https://www.kopico.go.kr" target="_blank" rel="noopener">www.kopico.go.kr</a></li><li>대검찰청: 국번 없이 1301 · <a href="https://www.spo.go.kr" target="_blank" rel="noopener">www.spo.go.kr</a></li><li>경찰청: 국번 없이 182 · <a href="https://ecrm.police.go.kr" target="_blank" rel="noopener">ecrm.police.go.kr</a></li></ul>
</section>
<section>
<h2>9. 개인정보처리방침의 변경</h2>
<p>이 개인정보처리방침은 2026년 8월 5일부터 적용됩니다. 내용이 변경되는 경우 시행 전에 사이트를 통해 안내합니다.</p>
<p class="policy-effective">공고일자: 2026년 8월 5일<br>시행일자: 2026년 8월 5일</p>
</section>
</article>

39
app/bootstrap.php Normal file
View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
spl_autoload_register(static function (string $class): void {
$prefix = 'App\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$relative = str_replace('\\', DIRECTORY_SEPARATOR, substr($class, strlen($prefix)));
$path = __DIR__ . DIRECTORY_SEPARATOR . $relative . '.php';
if (is_file($path)) {
require $path;
}
});
require __DIR__ . '/Support/helpers.php';
date_default_timezone_set((string) config('app.timezone', 'Asia/Seoul'));
if (session_status() !== PHP_SESSION_ACTIVE) {
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https';
session_name((string) config('app.session_name', 'gtsit_session'));
session_set_cookie_params([
'lifetime' => 0,
'path' => (string) (config('app.base_path', '') ?: '/'),
'secure' => $secure,
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
}
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: strict-origin-when-cross-origin');
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');