Compare commits
10
Commits
046fa2189f
...
cdde5bc952
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdde5bc952 | ||
|
|
6db47427ad | ||
|
|
8ebbbadf98 | ||
|
|
0f58d2fb21 | ||
|
|
b8e28e3f1a | ||
|
|
ac6f9ff6a3 | ||
|
|
e9d4fe31b5 | ||
|
|
f546431e5f | ||
|
|
78897dbc50 | ||
|
|
4dddfb9dc2 |
@@ -0,0 +1,23 @@
|
|||||||
|
# Release Notes
|
||||||
|
|
||||||
|
## v4 — 2026-07-18
|
||||||
|
|
||||||
|
- Added a web app manifest (`manifest.json`) and a 512x512 icon, so the site
|
||||||
|
can be installed as a desktop app from Edge/Chrome — opens in its own
|
||||||
|
window instead of a browser tab, while still loading live from the server.
|
||||||
|
|
||||||
|
## v3 — 2026-07-16
|
||||||
|
|
||||||
|
- Added a favicon (clock + "H" mark), with a transparent icon set for
|
||||||
|
browser tabs and an opaque apple-touch-icon for iOS.
|
||||||
|
- New customer form now redirects to the customer list after saving,
|
||||||
|
instead of the new customer's edit page.
|
||||||
|
- Customer addresses in the customer list now link out to a Google Maps
|
||||||
|
search (URL-encoded, opens in a new tab).
|
||||||
|
|
||||||
|
## v2 — 2026-07-16
|
||||||
|
|
||||||
|
- Added a "+ New Customer" button and a standalone `/customers/new`
|
||||||
|
form, so a customer's contact info can be added without first
|
||||||
|
creating a service order. Reuses the edit-customer template for both
|
||||||
|
create and edit.
|
||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build the container image and tag it for the registry.
|
||||||
|
# Usage: ./build-and-tag.sh <tag> e.g. ./build-and-tag.sh v1
|
||||||
|
set -e
|
||||||
|
|
||||||
|
IMAGE=hardings-service
|
||||||
|
REGISTRY=registry.digitalsorcery.net
|
||||||
|
TAG="${1:?usage: $0 <tag>}"
|
||||||
|
|
||||||
|
podman build -t "${IMAGE}:${TAG}" -f Containerfile .
|
||||||
|
podman tag "${IMAGE}:${TAG}" "${REGISTRY}/${IMAGE}:${TAG}"
|
||||||
|
|
||||||
|
echo "Push with: podman push ${REGISTRY}/${IMAGE}:${TAG}"
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "hardingsclocks-atrex",
|
"name": "hardings-service",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Repair tracker for Harding's Clocks",
|
"description": "Repair tracker for Harding's Clocks",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
+17
-1
@@ -52,7 +52,8 @@ nav {
|
|||||||
background: var(--color-wood-dark);
|
background: var(--color-wood-dark);
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1.5rem;
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem 1.5rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +74,21 @@ nav a:hover {
|
|||||||
color: var(--color-wood-light);
|
color: var(--color-wood-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Below ~430px (a phone in portrait, at this site's 150% base font size)
|
||||||
|
the brand name alone can approach full width - drop it to its own line
|
||||||
|
above the links instead of letting the flex row overflow the viewport. */
|
||||||
|
@media (max-width: 430px) {
|
||||||
|
nav {
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
gap: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a.brand {
|
||||||
|
flex-basis: 100%;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
max-width: 960px;
|
max-width: 960px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 166 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "Harding's Service",
|
||||||
|
"short_name": "Harding's Service",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#F2E8D5",
|
||||||
|
"theme_color": "#4A2810",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||||
|
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -20,6 +20,36 @@ router.get('/', (req, res) => {
|
|||||||
res.render('customers/list.njk', { customers, q });
|
res.render('customers/list.njk', { customers, q });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/new', (req, res) => {
|
||||||
|
res.render('customers/edit.njk', { customer: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/new', (req, res) => {
|
||||||
|
const b = req.body as Record<string, string>;
|
||||||
|
|
||||||
|
const name = (b.name || '').trim();
|
||||||
|
if (!name) return res.status(400).send('Name is required');
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO customers
|
||||||
|
(name, phone, alt_phone, email, address_line1, address_line2, city, state, zip, internal_note)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
).run(
|
||||||
|
name,
|
||||||
|
formatPhone(b.phone),
|
||||||
|
formatPhone(b.alt_phone),
|
||||||
|
b.email || null,
|
||||||
|
b.address_line1 || null,
|
||||||
|
b.address_line2 || null,
|
||||||
|
b.city || null,
|
||||||
|
b.state || null,
|
||||||
|
b.zip || null,
|
||||||
|
b.internal_note || null
|
||||||
|
);
|
||||||
|
|
||||||
|
res.redirect(303, '/customers');
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/:id/edit', (req, res) => {
|
router.get('/:id/edit', (req, res) => {
|
||||||
const customer = db.prepare('SELECT * FROM customers WHERE id = ?').get(req.params.id);
|
const customer = db.prepare('SELECT * FROM customers WHERE id = ?').get(req.params.id);
|
||||||
if (!customer) return res.status(404).send('Customer not found');
|
if (!customer) return res.status(404).send('Customer not found');
|
||||||
|
|||||||
+18
-2
@@ -3,6 +3,7 @@ import nunjucks from 'nunjucks';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { formatCents } from './money.js';
|
import { formatCents } from './money.js';
|
||||||
|
import { db } from './db.js';
|
||||||
import ordersRouter from './routes/orders.js';
|
import ordersRouter from './routes/orders.js';
|
||||||
import customersRouter from './routes/customers.js';
|
import customersRouter from './routes/customers.js';
|
||||||
import clocksRouter from './routes/clocks.js';
|
import clocksRouter from './routes/clocks.js';
|
||||||
@@ -40,7 +41,22 @@ app.use((err: unknown, _req: express.Request, res: express.Response, _next: expr
|
|||||||
});
|
});
|
||||||
|
|
||||||
const PORT = process.env.PORT ? Number(process.env.PORT) : 3000;
|
const PORT = process.env.PORT ? Number(process.env.PORT) : 3000;
|
||||||
const HOST = '127.0.0.1';
|
const HOST = process.env.HOST || '127.0.0.1';
|
||||||
app.listen(PORT, HOST, () => {
|
const server = app.listen(PORT, HOST, () => {
|
||||||
console.log(`Harding's Clocks running at http://${HOST}:${PORT}`);
|
console.log(`Harding's Clocks running at http://${HOST}:${PORT}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function shutdown(signal: string) {
|
||||||
|
console.log(`${signal} received, shutting down`);
|
||||||
|
server.close((err) => {
|
||||||
|
db.close();
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
{% extends "layout.njk" %}
|
{% extends "layout.njk" %}
|
||||||
{% block title %}Edit {{ customer.name }} — Harding's Clocks{% endblock %}
|
{% block title %}{% if customer.id %}Edit {{ customer.name }}{% else %}New Customer{% endif %} — Harding's Clocks{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1>Edit Customer</h1>
|
<h1>{% if customer.id %}Edit Customer{% else %}New Customer{% endif %}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" action="/customers/{{ customer.id }}/edit" class="stacked-form">
|
<form method="post" action="{% if customer.id %}/customers/{{ customer.id }}/edit{% else %}/customers/new{% endif %}" class="stacked-form">
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Customer</legend>
|
<legend>Customer</legend>
|
||||||
<div class="grid-2">
|
<div class="grid-2">
|
||||||
<label>Name <input type="text" name="name" value="{{ customer.name }}" required></label>
|
<label>Name <input type="text" name="name" value="{{ customer.name or '' }}" required></label>
|
||||||
<label>Phone <input type="text" name="phone" value="{{ customer.phone or '' }}"></label>
|
<label>Phone <input type="text" name="phone" value="{{ customer.phone or '' }}"></label>
|
||||||
<label>Alt phone <input type="text" name="alt_phone" value="{{ customer.alt_phone or '' }}"></label>
|
<label>Alt phone <input type="text" name="alt_phone" value="{{ customer.alt_phone or '' }}"></label>
|
||||||
<label>Email <input type="email" name="email" value="{{ customer.email or '' }}"></label>
|
<label>Email <input type="email" name="email" value="{{ customer.email or '' }}"></label>
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1>Customers</h1>
|
<h1>Customers</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<a class="button-link" href="/customers/new">+ New Customer</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="search-form" method="get" action="/customers">
|
<form class="search-form" method="get" action="/customers">
|
||||||
@@ -29,7 +32,11 @@
|
|||||||
<td>{{ c.email or '' }}</td>
|
<td>{{ c.email or '' }}</td>
|
||||||
<td>
|
<td>
|
||||||
{%- if c.address_line1 -%}
|
{%- if c.address_line1 -%}
|
||||||
{{ c.address_line1 }}{% if c.city %}, {{ c.city }}{% endif %}{% if c.state %}, {{ c.state }}{% endif %} {{ c.zip or '' }}
|
{% set addr = c.address_line1 %}
|
||||||
|
{% if c.city %}{% set addr = addr + ', ' + c.city %}{% endif %}
|
||||||
|
{% if c.state %}{% set addr = addr + ', ' + c.state %}{% endif %}
|
||||||
|
{% if c.zip %}{% set addr = addr + ' ' + c.zip %}{% endif %}
|
||||||
|
<a href="https://www.google.com/maps/search/?api=1&query={{ addr | urlencode }}" target="_blank" rel="noopener">{{ addr }}</a>
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
</td>
|
</td>
|
||||||
<td><a class="button-link" href="/?customer_id={{ c.id }}">Orders</a></td>
|
<td><a class="button-link" href="/?customer_id={{ c.id }}">Orders</a></td>
|
||||||
|
|||||||
@@ -4,6 +4,13 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{% block title %}Harding's Clocks{% endblock %}</title>
|
<title>{% block title %}Harding's Clocks{% endblock %}</title>
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16x16.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32x32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png">
|
||||||
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
|
||||||
|
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials">
|
||||||
|
<meta name="theme-color" content="#4A2810">
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
<script src="/vendor/htmx/htmx.min.js" defer></script>
|
<script src="/vendor/htmx/htmx.min.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
Reference in New Issue
Block a user