Compare commits

5 Commits

Author SHA1 Message Date
3da410770a landing page 2026-03-22 15:34:09 -05:00
61f4b5acd6 TimeZone Support 2026-03-21 20:12:32 -05:00
190ac299e3 minimal calibration sequence update 2026-03-18 01:41:36 -05:00
f59726be8d websocket update on wand pos update 2026-03-18 01:07:21 -05:00
a51e498928 printComplete 2026-03-17 20:24:25 -05:00
5 changed files with 1993 additions and 26 deletions

205
index.js
View File

@@ -52,8 +52,10 @@ const wsMessageRateLimiter = new RateLimiterMemory({
duration: 1,
});
const path = require('path');
const app = express();
const port = 3000;
app.use(express.static(path.join(__dirname, 'public')));
app.use(json());
// Rate limiting middleware for HTTP requests
@@ -100,6 +102,11 @@ let agenda;
// Add fcm_token column for push notification delivery
await pool.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS fcm_token TEXT");
// Add timezone support
await pool.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS timezone TEXT DEFAULT 'America/Chicago'");
await pool.query("ALTER TABLE devices ADD COLUMN IF NOT EXISTS timezone TEXT");
await pool.query("ALTER TABLE groups ADD COLUMN IF NOT EXISTS timezone TEXT");
// Initialise Firebase Admin SDK for push notifications
if (process.env.FIREBASE_SERVICE_ACCOUNT_JSON) {
admin.initializeApp({
@@ -279,10 +286,13 @@ io.on('connection', async (socket) => {
// Update calibration status in database based on device's actual state
if (data.port && typeof data.calibrated === 'boolean') {
const result = await pool.query(
"update peripherals set calibrated=$1 where device_id=$2 and peripheral_number=$3 returning id, user_id",
[data.calibrated, rows[0].device_id, data.port]
);
// When the device declares itself uncalibrated, also clear await_calib so
// any stale pending-calibration state is gone. The device is already connected
// via socket, so the next calib_start from the agenda job will land correctly.
const updateQuery = data.calibrated
? "update peripherals set calibrated=TRUE where device_id=$1 and peripheral_number=$2 returning id, user_id"
: "update peripherals set calibrated=FALSE, await_calib=FALSE where device_id=$1 and peripheral_number=$2 returning id, user_id";
const result = await pool.query(updateQuery, [rows[0].device_id, data.port]);
console.log(`Updated port ${data.port} calibrated status to ${data.calibrated}`);
// Notify user app of calibration status change
@@ -638,9 +648,6 @@ async function authenticateToken(req, res, next) {
}
}
app.get('/', (req, res) => {
res.send('Hello World!');
});
app.post('/login', async (req, res) => {
const { email, password } = req.body;
@@ -892,7 +899,7 @@ app.post('/logout', authenticateToken, async (req, res) => {
app.get('/account_info', authenticateToken, async (req, res) => {
try {
const {rows} = await pool.query(
'SELECT name, email, created_at FROM users WHERE id = $1',
'SELECT name, email, created_at, COALESCE(timezone, \'America/Chicago\') AS timezone FROM users WHERE id = $1',
[req.user]
);
if (rows.length === 0) {
@@ -901,7 +908,8 @@ app.get('/account_info', authenticateToken, async (req, res) => {
res.status(200).json({
name: rows[0].name,
email: rows[0].email,
created_at: rows[0].created_at
created_at: rows[0].created_at,
timezone: rows[0].timezone
});
} catch (err) {
console.error(err);
@@ -1358,10 +1366,10 @@ app.get('/device_name', authenticateToken, async (req, res) => {
console.log("deviceName");
try {
const {deviceId} = req.query;
const {rows} = await pool.query('select device_name, max_ports, battery_soc from devices where id=$1 and user_id=$2',
const {rows} = await pool.query('select device_name, max_ports, battery_soc, timezone from devices where id=$1 and user_id=$2',
[deviceId, req.user]);
if (rows.length != 1) return res.sendStatus(404);
res.status(200).json({device_name: rows[0].device_name, max_ports: rows[0].max_ports, battery_soc: rows[0].battery_soc});
res.status(200).json({device_name: rows[0].device_name, max_ports: rows[0].max_ports, battery_soc: rows[0].battery_soc, timezone: rows[0].timezone});
} catch {
res.sendStatus(500);
}
@@ -1387,13 +1395,14 @@ app.post('/add_device', authenticateToken, async (req, res) => {
console.log("add device request");
console.log(req.user);
console.log(req.peripheral);
const {deviceName, maxPorts} = req.body;
const {deviceName, maxPorts, timezone} = req.body;
console.log(deviceName);
const ports = maxPorts || 4; // Default to 4 for multi-port devices
const {rows} = await pool.query("insert into devices (user_id, device_name, max_ports) values ($1, $2, $3) returning id",
[req.user, deviceName, ports]
const {rows} = await pool.query("insert into devices (user_id, device_name, max_ports, timezone) values ($1, $2, $3, $4) returning id",
[req.user, deviceName, ports, timezone || null]
); // finish token return based on device ID.
const deviceInitToken = await createTempPeriphToken(rows[0].id);
console.log("complete");
res.status(201).json({token: deviceInitToken});
} catch (err) {
console.log(err);
@@ -1478,14 +1487,20 @@ app.post('/position', authenticateToken, async (req, res) => {
console.log("devicepos");
try {
const {port, pos} = req.body;
const {rows} = await pool.query("update peripherals set last_pos=$1 where device_id=$2 and peripheral_number=$3 returning await_calib",
const {rows} = await pool.query("update peripherals set last_pos=$1 where device_id=$2 and peripheral_number=$3 returning await_calib, id, user_id",
[pos, req.peripheral, port]);
if (rows.length != 1) {
return res.sendStatus(404);
}
res.status(201).json(rows[0]);
res.status(201).json({await_calib: rows[0].await_calib});
// Notify user app of device-reported position
const {rows: userRows} = await pool.query("select socket from user_tokens where user_id=$1 and connected=TRUE", [rows[0].user_id]);
if (userRows.length === 1 && userRows[0]) {
io.to(userRows[0].socket).emit("device_pos_report", {periphID: rows[0].id, pos});
}
} catch {
res.status(500).json({error: "server error"});
}
@@ -1815,6 +1830,28 @@ function createCronExpression(time, daysOfWeek) {
return `${time.minute} ${time.hour} * * ${cronDays}`;
}
// Look up the effective timezone for a device's schedules (device → user fallback)
async function getScheduleTimezone(deviceId, userId) {
const { rows } = await pool.query(
`SELECT COALESCE(d.timezone, u.timezone, 'America/Chicago') AS tz
FROM devices d JOIN users u ON d.user_id = u.id
WHERE d.id = $1 AND d.user_id = $2`,
[deviceId, userId]
);
return rows.length > 0 ? rows[0].tz : 'America/Chicago';
}
// Look up the effective timezone for a group's schedules (group → user fallback)
async function getGroupScheduleTimezone(groupId, userId) {
const { rows } = await pool.query(
`SELECT COALESCE(g.timezone, u.timezone, 'America/Chicago') AS tz
FROM groups g JOIN users u ON g.user_id = u.id
WHERE g.id = $1 AND g.user_id = $2`,
[groupId, userId]
);
return rows.length > 0 ? rows[0].tz : 'America/Chicago';
}
// Helper function to find and verify a schedule job belongs to the user
async function findUserScheduleJob(jobId, userId) {
const jobs = await agenda.jobs({
@@ -1862,16 +1899,18 @@ app.post('/add_schedule', authenticateToken, async (req, res) => {
const changedPosList = [{periphNum: periphNum, periphID: periphId, pos: newPos}];
// Schedule the recurring job
const tz = await getScheduleTimezone(deviceId, req.user);
const job = await agenda.create('posChangeScheduled', {
deviceID: deviceId,
changedPosList: changedPosList,
userID: req.user
});
job.repeatEvery(cronExpression, {
timezone: tz,
skipImmediate: true
});
await job.save();
res.status(201).json({
@@ -1949,13 +1988,15 @@ app.post('/update_schedule', authenticateToken, async (req, res) => {
console.log("Creating new job with cron:", cronExpression);
// Create new job with updated schedule
const tz = await getScheduleTimezone(deviceId, req.user);
const job = await agenda.create('posChangeScheduled', {
deviceID: deviceId,
changedPosList: changedPosList,
userID: req.user
});
job.repeatEvery(cronExpression, {
timezone: tz,
skipImmediate: true
});
@@ -2058,7 +2099,7 @@ app.get('/group_list', authenticateToken, async (req, res) => {
app.post('/add_group', authenticateToken, async (req, res) => {
console.log("add_group request for user:", req.user);
try {
const { name, peripheral_ids } = req.body;
const { name, peripheral_ids, timezone } = req.body;
// Validate input
if (!name || !name.trim()) {
@@ -2104,11 +2145,11 @@ app.post('/add_group', authenticateToken, async (req, res) => {
// Insert into groups table
const insertGroupQuery = `
INSERT INTO groups (user_id, name)
VALUES ($1, $2)
INSERT INTO groups (user_id, name, timezone)
VALUES ($1, $2, $3)
RETURNING id
`;
const { rows: groupRows } = await pool.query(insertGroupQuery, [req.user, name.trim()]);
const { rows: groupRows } = await pool.query(insertGroupQuery, [req.user, name.trim(), timezone || null]);
const groupId = groupRows[0].id;
// Insert into group_peripherals table
@@ -2291,6 +2332,7 @@ app.post('/add_group_schedule', authenticateToken, async (req, res) => {
}
// Create the job
const tz = await getGroupScheduleTimezone(groupId, req.user);
const job = await agenda.create('groupPosChangeScheduled', {
groupID: groupId,
newPos,
@@ -2298,7 +2340,7 @@ app.post('/add_group_schedule', authenticateToken, async (req, res) => {
});
job.repeatEvery(cronExpression, {
timezone: 'America/New_York',
timezone: tz,
skipImmediate: true
});
@@ -2369,9 +2411,10 @@ app.post('/update_group_schedule', authenticateToken, async (req, res) => {
}
// Update job
const tz = await getGroupScheduleTimezone(groupId, req.user);
job.attrs.data.newPos = newPos;
job.repeatEvery(cronExpression, {
timezone: 'America/New_York',
timezone: tz,
skipImmediate: true
});
@@ -2488,7 +2531,9 @@ app.post('/group_schedule_list', authenticateToken, async (req, res) => {
});
const scheduledUpdates = jobs.map(job => {
const interval = cronParser.parseExpression(job.attrs.repeatInterval);
const interval = cronParser.parseExpression(job.attrs.repeatInterval, {
tz: job.attrs.repeatTimezone || undefined
});
const schedule = {
minutes: interval.fields.minute,
hours: interval.fields.hour,
@@ -2509,3 +2554,111 @@ app.post('/group_schedule_list', authenticateToken, async (req, res) => {
}
});
app.get('/user_timezone', authenticateToken, async (req, res) => {
try {
const { rows } = await pool.query(
"SELECT COALESCE(timezone, 'America/Chicago') AS timezone FROM users WHERE id=$1",
[req.user]
);
res.status(200).json({ timezone: rows[0]?.timezone ?? 'America/Chicago' });
} catch (error) {
console.error('Error fetching user timezone:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
app.post('/update_user_timezone', authenticateToken, async (req, res) => {
try {
const { timezone } = req.body;
if (!timezone || typeof timezone !== 'string') {
return res.status(400).json({ error: 'timezone is required' });
}
// Validate it's a recognisable IANA name by running it through cron-parser
try {
cronParser.parseExpression('0 0 * * 0', { tz: timezone });
} catch {
return res.status(400).json({ error: 'Invalid IANA timezone' });
}
await pool.query("UPDATE users SET timezone=$1 WHERE id=$2", [timezone, req.user]);
res.status(200).json({ success: true });
} catch (error) {
console.error('Error updating user timezone:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
app.post('/update_device_timezone', authenticateToken, async (req, res) => {
try {
const { deviceId, timezone } = req.body;
if (!deviceId || !timezone) return res.status(400).json({ error: 'Missing required fields' });
try {
cronParser.parseExpression('0 0 * * 0', { tz: timezone });
} catch {
return res.status(400).json({ error: 'Invalid IANA timezone' });
}
const result = await pool.query(
'UPDATE devices SET timezone=$1 WHERE id=$2 AND user_id=$3',
[timezone, deviceId, req.user]
);
if (result.rowCount === 0) return res.status(404).json({ error: 'Device not found' });
res.status(200).json({ success: true });
} catch (error) {
console.error('Error updating device timezone:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
app.get('/group_timezone', authenticateToken, async (req, res) => {
try {
const { groupId } = req.query;
if (!groupId) return res.status(400).json({ error: 'groupId is required' });
const { rows } = await pool.query(
'SELECT timezone FROM groups WHERE id=$1 AND user_id=$2',
[groupId, req.user]
);
if (rows.length === 0) return res.status(404).json({ error: 'Group not found' });
res.status(200).json({ timezone: rows[0].timezone });
} catch (error) {
console.error('Error fetching group timezone:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
app.post('/update_group_timezone', authenticateToken, async (req, res) => {
try {
const { groupId, timezone } = req.body;
if (!groupId || !timezone) return res.status(400).json({ error: 'Missing required fields' });
try {
cronParser.parseExpression('0 0 * * 0', { tz: timezone });
} catch {
return res.status(400).json({ error: 'Invalid IANA timezone' });
}
const result = await pool.query(
'UPDATE groups SET timezone=$1 WHERE id=$2 AND user_id=$3',
[timezone, groupId, req.user]
);
if (result.rowCount === 0) return res.status(404).json({ error: 'Group not found' });
res.status(200).json({ success: true });
} catch (error) {
console.error('Error updating group timezone:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
app.post('/rename_group', authenticateToken, async (req, res) => {
try {
const { groupId, newName } = req.body;
if (!groupId || !newName || !newName.trim()) return res.status(400).json({ error: 'Missing required fields' });
const result = await pool.query(
'UPDATE groups SET name=$1 WHERE id=$2 AND user_id=$3',
[newName.trim(), groupId, req.user]
);
if (result.rowCount === 0) return res.status(404).json({ error: 'Group not found' });
res.status(200).json({ success: true });
} catch (error) {
if (error.code === '23505') return res.status(409).json({ error: 'A group with this name already exists' });
console.error('Error renaming group:', error);
res.status(500).json({ error: 'Internal server error' });
}
});

260
public/app/index.html Normal file
View File

@@ -0,0 +1,260 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BlindMaster App — Coming Soon</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<style>
:root {
--accent: #3B82F6;
--accent-rgb: 59, 130, 246;
--bg: #0A0B0F;
--bg-card: #181B24;
--text: #F1F5F9;
--text-muted: #8892A4;
--text-faint: #4B5563;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Poppins', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
overflow: hidden;
}
/* Background grid */
body::before {
content: '';
position: fixed;
inset: 0;
background-image:
linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px);
background-size: 48px 48px;
mask-image: radial-gradient(ellipse 70% 70% at 50% 50%, black 30%, transparent 100%);
pointer-events: none;
z-index: 0;
}
/* Glow */
body::after {
content: '';
position: fixed;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 600px; height: 400px;
background: radial-gradient(ellipse, rgba(var(--accent-rgb), 0.12) 0%, transparent 70%);
pointer-events: none;
z-index: 0;
}
.card {
position: relative;
z-index: 1;
background: var(--bg-card);
border: 1px solid rgba(255,255,255,0.07);
border-radius: 24px;
padding: 56px 48px;
text-align: center;
max-width: 480px;
width: 100%;
box-shadow: 0 32px 80px rgba(0,0,0,0.5);
animation: fadeUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
.logo {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
font-weight: 700;
font-size: 1rem;
color: var(--text-muted);
margin-bottom: 36px;
}
.logo-icon {
display: flex;
flex-direction: column;
gap: 4px;
width: 22px;
}
.logo-slat {
height: 3px;
background: var(--accent);
border-radius: 2px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
background: rgba(var(--accent-rgb), 0.12);
border: 1px solid rgba(var(--accent-rgb), 0.25);
padding: 5px 14px;
border-radius: 100px;
margin-bottom: 28px;
}
.badge-dot {
width: 6px; height: 6px;
background: var(--accent);
border-radius: 50%;
animation: pulse 1.8s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.75); }
}
h1 {
font-size: 2rem;
font-weight: 800;
line-height: 1.15;
letter-spacing: -0.02em;
margin-bottom: 16px;
}
h1 span {
background: linear-gradient(135deg, var(--accent), #a78bfa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
p {
font-size: 0.92rem;
color: var(--text-muted);
line-height: 1.7;
margin-bottom: 36px;
}
.progress-bar {
height: 4px;
background: rgba(255,255,255,0.06);
border-radius: 100px;
overflow: hidden;
margin-bottom: 8px;
}
.progress-fill {
height: 100%;
width: 72%;
background: linear-gradient(90deg, var(--accent), rgba(var(--accent-rgb), 0.4));
border-radius: 100px;
animation: shimmer 2s infinite;
background-size: 200% 100%;
}
@keyframes shimmer {
0% { background-position: 200% center; }
100% { background-position: -200% center; }
}
.progress-label {
font-size: 0.68rem;
color: var(--text-faint);
text-align: right;
margin-bottom: 40px;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
font-weight: 500;
color: var(--text-muted);
text-decoration: none;
transition: color 0.2s;
}
.back-link:hover { color: var(--text); }
.back-link svg { transition: transform 0.2s; }
.back-link:hover svg { transform: translateX(-3px); }
@media (max-width: 520px) {
.card { padding: 40px 24px; }
h1 { font-size: 1.6rem; }
}
</style>
</head>
<body>
<div class="card">
<div class="logo">
<div class="logo-icon">
<div class="logo-slat"></div>
<div class="logo-slat"></div>
<div class="logo-slat"></div>
<div class="logo-slat"></div>
</div>
BlindMaster
</div>
<div class="badge">
<span class="badge-dot"></span>
In Development
</div>
<h1>The web app is<br /><span>coming soon.</span></h1>
<p>
We're building the BlindMaster web experience so you can control
your blinds from any browser — no install required. Stay tuned.
</p>
<div class="progress-bar">
<div class="progress-fill"></div>
</div>
<div class="progress-label">Development in progress</div>
<a href="/" class="back-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<path d="M19 12H5M12 5l-7 7 7 7"/>
</svg>
Back to homepage
</a>
</div>
<script>
// Match time-based accent color from main landing page
const hour = new Date().getHours();
let accent, accentRgb;
if (hour >= 5 && hour < 10) {
accent = '#F97316'; accentRgb = '249, 115, 22';
} else if (hour >= 10 && hour < 18) {
accent = '#3B82F6'; accentRgb = '59, 130, 246';
} else {
accent = '#7C3AED'; accentRgb = '124, 58, 237';
}
document.documentElement.style.setProperty('--accent', accent);
document.documentElement.style.setProperty('--accent-rgb', accentRgb);
// Update slats to match accent
document.querySelectorAll('.logo-slat').forEach(s => s.style.background = accent);
</script>
</body>
</html>

934
public/css/styles.css Normal file
View File

@@ -0,0 +1,934 @@
/* ============================================================
BlindMaster Landing Page — Styles
Theme: time-based accent (orange / blue / purple)
matching the Flutter app's time-of-day color system
============================================================ */
/* ---------- CSS Custom Properties (set by JS at runtime) --- */
:root {
--accent: #3B82F6; /* default: day blue */
--accent-rgb: 59, 130, 246;
--accent-dark: #1D4ED8;
--accent-glow: rgba(59, 130, 246, 0.25);
--bg: #0A0B0F;
--bg-surface: #12141A;
--bg-card: #181B24;
--bg-card-border: rgba(255,255,255,0.07);
--text: #F1F5F9;
--text-muted: #8892A4;
--text-faint: #4B5563;
--slat-color: #6B4C2A;
--slat-shadow: rgba(0,0,0,0.4);
--transition: 0.6s cubic-bezier(0.16, 1, 0.3, 1);
--radius: 16px;
--radius-sm: 10px;
}
/* ---------- Reset & Base ----------------------------------- */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; font-size: 16px; }
body {
font-family: 'Poppins', sans-serif;
background: var(--bg);
color: var(--text);
overflow-x: hidden;
line-height: 1.6;
}
a { color: inherit; text-decoration: none; }
img { display: block; max-width: 100%; }
button { font-family: inherit; cursor: pointer; border: none; }
/* ---------- Utility ---------------------------------------- */
.section-inner {
max-width: 1160px;
margin: 0 auto;
padding: 0 24px;
}
.section-eyebrow {
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 12px;
}
.section-title {
font-size: clamp(1.8rem, 4vw, 2.8rem);
font-weight: 700;
line-height: 1.2;
margin-bottom: 16px;
}
.section-sub {
font-size: 1.05rem;
color: var(--text-muted);
max-width: 560px;
}
.section-header { margin-bottom: 56px; }
/* ---------- Buttons ---------------------------------------- */
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 13px 28px;
border-radius: 100px;
font-size: 0.95rem;
font-weight: 600;
transition: all 0.25s ease;
white-space: nowrap;
}
.btn-primary {
background: var(--accent);
color: #fff;
box-shadow: 0 0 24px var(--accent-glow);
}
.btn-primary:hover {
filter: brightness(1.12);
box-shadow: 0 0 36px var(--accent-glow);
transform: translateY(-1px);
}
.btn-ghost {
background: transparent;
color: var(--text-muted);
border: 1.5px solid var(--bg-card-border);
}
.btn-ghost:hover {
color: var(--text);
border-color: rgba(255,255,255,0.18);
background: rgba(255,255,255,0.04);
}
/* ---------- Scroll reveal ---------------------------------- */
.reveal {
opacity: 0;
transform: translateY(28px);
transition: opacity 0.7s ease, transform 0.7s ease;
transition-delay: var(--delay, 0ms);
}
.reveal.visible {
opacity: 1;
transform: translateY(0);
}
/* ============================================================
NAV
============================================================ */
.nav {
position: fixed;
top: 0; left: 0; right: 0;
z-index: 100;
padding: 16px 0;
transition: background 0.3s, backdrop-filter 0.3s, box-shadow 0.3s;
}
.nav.scrolled {
background: rgba(10, 11, 15, 0.85);
backdrop-filter: blur(16px);
box-shadow: 0 1px 0 rgba(255,255,255,0.06);
}
.nav-inner {
max-width: 1160px;
margin: 0 auto;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
.nav-logo {
display: flex;
align-items: center;
gap: 10px;
font-weight: 700;
font-size: 1.05rem;
}
.nav-logo-icon {
width: 26px;
height: 22px;
display: flex;
flex-direction: column;
gap: 4px;
justify-content: center;
}
.nav-logo-icon.small { width: 20px; height: 18px; gap: 3px; }
.slat {
height: 3px;
background: var(--accent);
border-radius: 2px;
transition: background var(--transition);
}
.nav-links {
display: flex;
align-items: center;
gap: 32px;
list-style: none;
}
.nav-links a {
font-size: 0.9rem;
font-weight: 500;
color: var(--text-muted);
transition: color 0.2s;
}
.nav-links a:hover { color: var(--text); }
.nav-cta {
background: var(--accent) !important;
color: #fff !important;
padding: 8px 20px;
border-radius: 100px;
font-size: 0.85rem !important;
transition: filter 0.2s, box-shadow 0.2s !important;
}
.nav-cta:hover { filter: brightness(1.1); }
.nav-launch {
background: transparent;
color: var(--text) !important;
border: 1.5px solid var(--bg-card-border);
padding: 7px 18px;
border-radius: 100px;
font-size: 0.85rem !important;
transition: border-color 0.2s, background 0.2s !important;
}
.nav-launch:hover {
border-color: rgba(255,255,255,0.2) !important;
background: rgba(255,255,255,0.05) !important;
}
.mobile-nav-launch {
color: var(--accent) !important;
font-weight: 600 !important;
}
.nav-hamburger {
display: none;
flex-direction: column;
gap: 5px;
background: transparent;
padding: 4px;
}
.nav-hamburger span {
display: block;
width: 22px;
height: 2px;
background: var(--text-muted);
border-radius: 2px;
transition: 0.3s;
}
.mobile-nav {
display: none;
position: fixed;
top: 64px; left: 0; right: 0;
background: rgba(10,11,15,0.97);
backdrop-filter: blur(16px);
border-bottom: 1px solid var(--bg-card-border);
z-index: 99;
padding: 16px 0;
transform: translateY(-16px);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s, transform 0.25s;
}
.mobile-nav.open {
transform: translateY(0);
opacity: 1;
pointer-events: all;
display: block;
}
.mobile-nav ul { list-style: none; }
.mobile-nav-link {
display: block;
padding: 14px 24px;
font-size: 1rem;
font-weight: 500;
color: var(--text-muted);
}
.mobile-nav-link:hover { color: var(--text); }
/* ============================================================
HERO
============================================================ */
.hero {
min-height: 100vh;
display: flex;
align-items: center;
padding: 120px 24px 80px;
max-width: 1160px;
margin: 0 auto;
gap: 48px;
position: relative;
}
.hero-bg-grid {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background-image:
linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px);
background-size: 48px 48px;
mask-image: radial-gradient(ellipse 80% 60% at 50% 40%, black 30%, transparent 100%);
}
.hero-content {
flex: 1;
min-width: 0;
z-index: 1;
}
.hero-badge {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--accent);
background: rgba(var(--accent-rgb), 0.12);
border: 1px solid rgba(var(--accent-rgb), 0.25);
padding: 6px 14px;
border-radius: 100px;
margin-bottom: 28px;
}
.badge-dot {
width: 6px; height: 6px;
background: var(--accent);
border-radius: 50%;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.8); }
}
.hero-title {
font-size: clamp(2.4rem, 6vw, 4.2rem);
font-weight: 800;
line-height: 1.1;
margin-bottom: 20px;
letter-spacing: -0.02em;
}
.hero-accent {
background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 60%, #fff));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero-subtitle {
font-size: 1.1rem;
color: var(--text-muted);
max-width: 500px;
margin-bottom: 36px;
line-height: 1.7;
}
.hero-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 48px;
}
.hero-stats {
display: flex;
align-items: center;
gap: 24px;
}
.stat { text-align: center; }
.stat-num {
display: block;
font-size: 1.6rem;
font-weight: 700;
color: var(--text);
line-height: 1;
}
.stat-label {
font-size: 0.72rem;
color: var(--text-faint);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-top: 4px;
display: block;
}
.stat-divider {
width: 1px;
height: 36px;
background: var(--bg-card-border);
}
/* ---- Hero visual: window + blind ---- */
.hero-visual {
flex: 0 0 420px;
z-index: 1;
}
.window-frame {
background: var(--bg-card);
border: 1px solid var(--bg-card-border);
border-radius: 20px;
overflow: hidden;
box-shadow: 0 32px 80px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.04) inset;
}
.window-header {
display: flex;
align-items: center;
gap: 6px;
padding: 12px 16px;
background: rgba(255,255,255,0.03);
border-bottom: 1px solid var(--bg-card-border);
}
.window-dot {
width: 12px; height: 12px;
border-radius: 50%;
}
.window-dot.red { background: #FF5F57; }
.window-dot.yellow { background: #FEBC2E; }
.window-dot.green { background: #28C840; }
.window-title {
font-size: 0.78rem;
color: var(--text-muted);
margin-left: 8px;
font-weight: 500;
}
.blind-container {
height: 220px;
background: linear-gradient(180deg, #87CEEB 0%, #B0D4F1 100%);
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 4px 0;
}
.blind-slat {
height: 18px;
background: var(--slat-color);
border-radius: 2px;
margin: 0 12px;
box-shadow: 0 2px 4px var(--slat-shadow);
transform-origin: center;
transition: transform 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
transform: rotateX(calc(var(--i, 0) * 0deg));
}
.app-ui-overlay {
padding: 16px 20px;
}
.app-slider-label {
font-size: 0.72rem;
color: var(--text-muted);
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 10px;
}
.app-slider-track {
height: 6px;
background: rgba(255,255,255,0.08);
border-radius: 100px;
position: relative;
margin-bottom: 8px;
}
.app-slider-thumb {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 20px;
height: 20px;
background: var(--accent);
border-radius: 50%;
box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.3);
transition: left 0.5s cubic-bezier(0.34, 1.56, 0.64, 1), background var(--transition);
cursor: pointer;
}
.app-slider-vals {
display: flex;
justify-content: space-between;
font-size: 0.65rem;
color: var(--text-faint);
}
/* ============================================================
FEATURES
============================================================ */
.features {
padding: 120px 0;
background: linear-gradient(180deg, var(--bg) 0%, var(--bg-surface) 100%);
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.feature-card {
background: var(--bg-card);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius);
padding: 28px;
transition: border-color 0.25s, box-shadow 0.25s, transform 0.25s;
}
.feature-card:hover {
border-color: rgba(var(--accent-rgb), 0.35);
box-shadow: 0 8px 32px rgba(0,0,0,0.3), 0 0 0 1px rgba(var(--accent-rgb), 0.1) inset;
transform: translateY(-3px);
}
.feature-icon {
width: 48px; height: 48px;
background: rgba(var(--accent-rgb), 0.12);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 18px;
color: var(--accent);
transition: background var(--transition), color var(--transition);
}
.feature-icon svg { width: 22px; height: 22px; }
.feature-card h3 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 10px;
}
.feature-card p {
font-size: 0.88rem;
color: var(--text-muted);
line-height: 1.65;
}
/* ============================================================
HOW IT WORKS
============================================================ */
.how-it-works {
padding: 120px 0;
background: var(--bg-surface);
}
/* Arch flow */
.arch-flow {
display: flex;
align-items: center;
gap: 0;
margin-bottom: 24px;
flex-wrap: nowrap;
overflow-x: auto;
padding-bottom: 8px;
}
.arch-node {
flex: 1;
min-width: 180px;
background: var(--bg-card);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius);
padding: 24px 20px;
text-align: center;
transition: border-color 0.25s;
}
.arch-node-center {
border-color: rgba(var(--accent-rgb), 0.35);
box-shadow: 0 0 40px rgba(var(--accent-rgb), 0.08);
}
.arch-icon {
width: 52px; height: 52px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 14px;
color: var(--accent);
}
.app-icon { background: rgba(var(--accent-rgb), 0.12); }
.server-icon { background: rgba(var(--accent-rgb), 0.18); }
.device-icon { background: rgba(var(--accent-rgb), 0.12); }
.arch-icon svg { width: 24px; height: 24px; }
.arch-node h4 { font-size: 0.95rem; font-weight: 600; margin-bottom: 8px; }
.arch-node p { font-size: 0.78rem; color: var(--text-muted); line-height: 1.6; }
.arch-tag {
margin-top: 12px;
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.06em;
color: var(--accent);
background: rgba(var(--accent-rgb), 0.1);
padding: 3px 10px;
border-radius: 100px;
display: inline-block;
font-family: 'Courier New', monospace;
}
.arch-arrow {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 0 8px;
flex-shrink: 0;
}
.arch-arrow-line {
width: 40px;
height: 1px;
background: linear-gradient(90deg, var(--bg-card-border), rgba(var(--accent-rgb), 0.4));
}
.arch-arrow-label {
font-size: 0.6rem;
color: var(--text-faint);
white-space: nowrap;
letter-spacing: 0.04em;
writing-mode: horizontal-tb;
}
.arch-ble-note {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.78rem;
color: var(--text-faint);
margin-bottom: 56px;
padding: 10px 16px;
background: rgba(255,255,255,0.03);
border-radius: var(--radius-sm);
border: 1px solid var(--bg-card-border);
color: var(--text-muted);
}
/* Steps */
.steps-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 20px;
}
.step {
padding: 24px;
background: var(--bg-card);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius);
}
.step-num {
font-size: 2.2rem;
font-weight: 800;
color: rgba(var(--accent-rgb), 0.2);
line-height: 1;
margin-bottom: 12px;
font-variant-numeric: tabular-nums;
}
.step h4 { font-size: 0.95rem; font-weight: 600; margin-bottom: 10px; }
.step p { font-size: 0.85rem; color: var(--text-muted); line-height: 1.65; }
/* ============================================================
TECH / OPEN HARDWARE
============================================================ */
.tech {
padding: 120px 0;
background: linear-gradient(180deg, var(--bg-surface) 0%, var(--bg) 100%);
}
.tech-content {
display: flex;
align-items: center;
gap: 64px;
flex-wrap: wrap;
}
.tech-text { flex: 1; min-width: 280px; }
.tech-text .section-title { margin-bottom: 20px; }
.tech-text p {
font-size: 0.95rem;
color: var(--text-muted);
line-height: 1.75;
margin-bottom: 28px;
}
.tech-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 12px;
}
.tech-list li {
display: flex;
align-items: flex-start;
gap: 10px;
font-size: 0.88rem;
color: var(--text-muted);
line-height: 1.5;
}
.tech-check {
color: var(--accent);
font-weight: 700;
flex-shrink: 0;
margin-top: 1px;
}
/* Chip visual */
.tech-visual {
flex: 0 0 280px;
display: flex;
justify-content: center;
}
.chip-card {
position: relative;
width: 240px;
}
.chip-glow {
position: absolute;
inset: -40px;
background: radial-gradient(ellipse at 50% 50%, rgba(var(--accent-rgb), 0.18) 0%, transparent 70%);
pointer-events: none;
transition: background var(--transition);
}
.chip-body {
background: var(--bg-card);
border: 1px solid var(--bg-card-border);
border-radius: 20px;
padding: 24px 20px;
text-align: center;
position: relative;
}
.chip-label {
font-size: 1.1rem;
font-weight: 700;
margin-bottom: 4px;
}
.chip-sub {
font-size: 0.7rem;
color: var(--text-faint);
letter-spacing: 0.06em;
margin-bottom: 20px;
}
.chip-pins {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 20px;
}
.chip-pin-row {
display: flex;
flex-direction: column;
gap: 6px;
}
.chip-pin {
width: 24px;
height: 7px;
background: rgba(var(--accent-rgb), 0.35);
border-radius: 2px;
transition: background var(--transition);
}
.chip-core {
flex: 1;
aspect-ratio: 1;
background: rgba(var(--accent-rgb), 0.08);
border: 1px solid rgba(var(--accent-rgb), 0.2);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
max-width: 100px;
max-height: 100px;
}
.chip-core-inner {
width: 60%;
height: 60%;
background: rgba(var(--accent-rgb), 0.15);
border-radius: 6px;
border: 1px solid rgba(var(--accent-rgb), 0.3);
}
.chip-tags {
display: flex;
gap: 6px;
flex-wrap: wrap;
justify-content: center;
}
.chip-tags span {
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.06em;
color: var(--accent);
background: rgba(var(--accent-rgb), 0.1);
padding: 3px 8px;
border-radius: 100px;
font-family: 'Courier New', monospace;
}
/* ============================================================
COMING SOON
============================================================ */
.coming-soon {
padding: 120px 0;
background: var(--bg);
}
.coming-soon-card {
background: var(--bg-card);
border: 1px solid rgba(var(--accent-rgb), 0.2);
border-radius: 24px;
padding: 64px 56px;
text-align: center;
position: relative;
overflow: hidden;
box-shadow: 0 0 80px rgba(var(--accent-rgb), 0.06);
}
.coming-soon-card::before {
content: '';
position: absolute;
top: -80px; left: 50%;
transform: translateX(-50%);
width: 400px; height: 200px;
background: radial-gradient(ellipse, rgba(var(--accent-rgb), 0.15) 0%, transparent 70%);
pointer-events: none;
}
.cs-badge {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--accent);
background: rgba(var(--accent-rgb), 0.12);
border: 1px solid rgba(var(--accent-rgb), 0.25);
padding: 6px 16px;
border-radius: 100px;
margin-bottom: 28px;
}
.cs-badge-dot {
width: 6px; height: 6px;
background: var(--accent);
border-radius: 50%;
animation: pulse 1.5s infinite;
}
.coming-soon-card h2 {
font-size: clamp(1.6rem, 3.5vw, 2.4rem);
font-weight: 700;
margin-bottom: 16px;
line-height: 1.2;
}
.coming-soon-card > p {
font-size: 0.95rem;
color: var(--text-muted);
max-width: 520px;
margin: 0 auto 36px;
line-height: 1.7;
}
.coming-soon-card strong { color: var(--text); }
.cs-highlights {
display: flex;
justify-content: center;
gap: 24px;
flex-wrap: wrap;
margin-bottom: 40px;
}
.cs-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
color: var(--text-muted);
font-weight: 500;
}
.cs-item svg { color: var(--accent); flex-shrink: 0; }
.cs-form {
display: flex;
gap: 10px;
max-width: 420px;
margin: 0 auto 16px;
flex-wrap: wrap;
justify-content: center;
}
.cs-input {
flex: 1;
min-width: 200px;
background: rgba(255,255,255,0.05);
border: 1.5px solid var(--bg-card-border);
border-radius: 100px;
padding: 13px 20px;
font-family: inherit;
font-size: 0.9rem;
color: var(--text);
outline: none;
transition: border-color 0.2s;
}
.cs-input::placeholder { color: var(--text-faint); }
.cs-input:focus { border-color: var(--accent); }
.cs-btn { flex-shrink: 0; }
.cs-disclaimer {
font-size: 0.72rem;
color: var(--text-faint);
margin-top: 4px;
}
.cs-success {
color: #4ADE80;
font-size: 0.9rem;
font-weight: 500;
margin-top: 12px;
}
/* ============================================================
FOOTER
============================================================ */
.footer {
border-top: 1px solid var(--bg-card-border);
padding: 48px 24px;
}
.footer-inner {
max-width: 1160px;
margin: 0 auto;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.footer-logo {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
font-size: 0.95rem;
}
.footer-tagline {
font-size: 0.82rem;
color: var(--text-faint);
}
.footer-links {
display: flex;
gap: 24px;
flex-wrap: wrap;
justify-content: center;
}
.footer-links a {
font-size: 0.82rem;
color: var(--text-faint);
transition: color 0.2s;
}
.footer-links a:hover { color: var(--text-muted); }
.footer-copy {
font-size: 0.72rem;
color: var(--text-faint);
}
/* ============================================================
RESPONSIVE
============================================================ */
@media (max-width: 900px) {
.hero {
flex-direction: column;
padding-top: 100px;
text-align: center;
}
.hero-subtitle { margin-left: auto; margin-right: auto; }
.hero-actions { justify-content: center; }
.hero-stats { justify-content: center; }
.hero-visual { width: 100%; flex: none; }
.window-frame { max-width: 380px; margin: 0 auto; }
.nav-links { display: none; }
.nav-hamburger { display: flex; }
.tech-content { flex-direction: column; }
.tech-visual { flex: none; width: 100%; }
.arch-flow { gap: 4px; }
.arch-arrow-line { width: 24px; }
}
@media (max-width: 600px) {
.coming-soon-card { padding: 40px 24px; }
.arch-flow { flex-direction: column; }
.arch-arrow { flex-direction: row; gap: 8px; }
.arch-arrow-line { width: 20px; height: 1px; }
}

419
public/index.html Normal file
View File

@@ -0,0 +1,419 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BlindMaster — Smart Blinds, Effortlessly Controlled</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="css/styles.css" />
</head>
<body>
<!-- NAV -->
<nav class="nav" id="nav">
<div class="nav-inner">
<div class="nav-logo">
<div class="nav-logo-icon">
<div class="slat"></div>
<div class="slat"></div>
<div class="slat"></div>
<div class="slat"></div>
</div>
<span>BlindMaster</span>
</div>
<ul class="nav-links">
<li><a href="#features">Features</a></li>
<li><a href="#how-it-works">How It Works</a></li>
<li><a href="#tech">Open Hardware</a></li>
<li><a href="#coming-soon" class="nav-cta">Get Early Access</a></li>
<li><a href="/app" class="nav-launch">Launch App</a></li>
</ul>
<button class="nav-hamburger" id="hamburger" aria-label="Menu">
<span></span><span></span><span></span>
</button>
</div>
</nav>
<!-- MOBILE NAV -->
<div class="mobile-nav" id="mobileNav">
<ul>
<li><a href="#features" class="mobile-nav-link">Features</a></li>
<li><a href="#how-it-works" class="mobile-nav-link">How It Works</a></li>
<li><a href="#tech" class="mobile-nav-link">Open Hardware</a></li>
<li><a href="#coming-soon" class="mobile-nav-link">Get Early Access</a></li>
<li><a href="/app" class="mobile-nav-link mobile-nav-launch">Launch App</a></li>
</ul>
</div>
<!-- HERO -->
<section class="hero" id="hero">
<div class="hero-bg-grid"></div>
<div class="hero-content">
<div class="hero-badge">
<span class="badge-dot"></span>
Smart Home IoT
</div>
<h1 class="hero-title">
Your blinds,<br />
<span class="hero-accent">on your schedule.</span>
</h1>
<p class="hero-subtitle">
BlindMaster brings real-time remote control, intelligent scheduling, and
seamless IoT integration to your motorized window blinds — from anywhere.
</p>
<div class="hero-actions">
<a href="/app" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><rect x="5" y="2" width="14" height="20" rx="2"/><path d="M12 18h.01"/></svg>
Launch App
</a>
<a href="#how-it-works" class="btn btn-ghost">See How It Works</a>
</div>
<div class="hero-stats">
<div class="stat">
<span class="stat-num">11</span>
<span class="stat-label">Positions</span>
</div>
<div class="stat-divider"></div>
<div class="stat">
<span class="stat-num"></span>
<span class="stat-label">Schedules</span>
</div>
<div class="stat-divider"></div>
<div class="stat">
<span class="stat-num">Real-time</span>
<span class="stat-label">Socket.IO Sync</span>
</div>
</div>
</div>
<div class="hero-visual">
<div class="window-frame">
<div class="window-header">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
<span class="window-title">BlindMaster</span>
</div>
<div class="blind-container" id="heroBlind">
<div class="blind-slat" style="--i:0"></div>
<div class="blind-slat" style="--i:1"></div>
<div class="blind-slat" style="--i:2"></div>
<div class="blind-slat" style="--i:3"></div>
<div class="blind-slat" style="--i:4"></div>
<div class="blind-slat" style="--i:5"></div>
<div class="blind-slat" style="--i:6"></div>
<div class="blind-slat" style="--i:7"></div>
<div class="blind-slat" style="--i:8"></div>
<div class="blind-slat" style="--i:9"></div>
</div>
<div class="app-ui-overlay">
<div class="app-slider-label">Position</div>
<div class="app-slider-track">
<div class="app-slider-thumb" id="sliderThumb"></div>
</div>
<div class="app-slider-vals">
<span>Close ↓</span>
<span>Open</span>
<span>Close ↑</span>
</div>
</div>
</div>
</div>
</section>
<!-- FEATURES -->
<section class="features" id="features">
<div class="section-inner">
<div class="section-header reveal">
<p class="section-eyebrow">Why BlindMaster</p>
<h2 class="section-title">Everything you need to control your light.</h2>
</div>
<div class="features-grid">
<div class="feature-card reveal" style="--delay:0ms">
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M12 18.5A6.5 6.5 0 1 0 12 5.5a6.5 6.5 0 0 0 0 13Z"/>
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
</svg>
</div>
<h3>Real-Time Control</h3>
<p>Adjust any blind instantly via WebSocket — sub-second response from your phone to your window, anywhere in the world.</p>
</div>
<div class="feature-card reveal" style="--delay:80ms">
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<rect x="3" y="4" width="18" height="18" rx="2"/>
<path d="M16 2v4M8 2v4M3 10h18"/>
<path d="M8 14h.01M12 14h.01M16 14h.01M8 18h.01M12 18h.01M16 18h.01"/>
</svg>
</div>
<h3>Smart Scheduling</h3>
<p>Set automated cron-based schedules per blind or per group. Wake up to light gradually filling the room — automatically.</p>
</div>
<div class="feature-card reveal" style="--delay:160ms">
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</div>
<h3>Groups &amp; Rooms</h3>
<p>Group multiple blinds and control them together. One tap to raise every blind in a room simultaneously.</p>
</div>
<div class="feature-card reveal" style="--delay:240ms">
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
</div>
<h3>Secure by Default</h3>
<p>JWT authentication, Argon2 password hashing, TLS everywhere, and multi-layer rate limiting — your home stays yours.</p>
</div>
<div class="feature-card reveal" style="--delay:320ms">
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M23 6l-9.5 9.5-5-5L1 18"/>
<path d="M17 6h6v6"/>
</svg>
</div>
<h3>Auto-Calibration</h3>
<p>A guided multi-stage calibration flow maps encoder ticks to your exact blind travel — precise positioning every time.</p>
</div>
<div class="feature-card reveal" style="--delay:400ms">
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<rect x="2" y="7" width="20" height="14" rx="2"/>
<path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/>
<line x1="12" y1="12" x2="12" y2="16"/>
<line x1="10" y1="14" x2="14" y2="14"/>
</svg>
</div>
<h3>Battery-Aware</h3>
<p>Built-in MAX17048 fuel gauge monitoring with low-battery alerts. Dynamic CPU scaling and servo power gating extend runtime.</p>
</div>
</div>
</div>
</section>
<!-- HOW IT WORKS -->
<section class="how-it-works" id="how-it-works">
<div class="section-inner">
<div class="section-header reveal">
<p class="section-eyebrow">Architecture</p>
<h2 class="section-title">Three components. One seamless system.</h2>
<p class="section-sub">BlindMaster is a full-stack IoT platform — every layer is purpose-built to work together.</p>
</div>
<div class="arch-flow reveal">
<div class="arch-node">
<div class="arch-icon app-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<rect x="5" y="2" width="14" height="20" rx="2"/>
<path d="M12 18h.01"/>
</svg>
</div>
<h4>Flutter App</h4>
<p>iOS &amp; Android mobile app. Time-based theming. Real-time slider control. Schedule management.</p>
<div class="arch-tag">blinds_flutter</div>
</div>
<div class="arch-arrow">
<div class="arch-arrow-line"></div>
<div class="arch-arrow-label">Socket.IO + JWT + TLS</div>
<div class="arch-arrow-line"></div>
</div>
<div class="arch-node arch-node-center">
<div class="arch-icon server-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<rect x="2" y="2" width="20" height="8" rx="2"/>
<rect x="2" y="14" width="20" height="8" rx="2"/>
<line x1="6" y1="6" x2="6.01" y2="6"/>
<line x1="6" y1="18" x2="6.01" y2="18"/>
</svg>
</div>
<h4>Express Server</h4>
<p>Central relay &amp; API. PostgreSQL + MongoDB. Agenda scheduling. Email via AWS SES.</p>
<div class="arch-tag">blinds_express</div>
</div>
<div class="arch-arrow">
<div class="arch-arrow-line"></div>
<div class="arch-arrow-label">Socket.IO + JWT + TLS</div>
<div class="arch-arrow-line"></div>
</div>
<div class="arch-node">
<div class="arch-icon device-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M9 3H5a2 2 0 0 0-2 2v4m6-6h10a2 2 0 0 1 2 2v4M9 3v18m0 0h10a2 2 0 0 0 2-2V9M9 21H5a2 2 0 0 1-2-2V9m0 0h18"/>
</svg>
</div>
<h4>ESP32-C6</h4>
<p>FreeRTOS firmware. BLE provisioning. Servo + encoder control. NVS persistent state.</p>
<div class="arch-tag">Blinds_XIAO</div>
</div>
</div>
<div class="arch-ble-note reveal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" width="16" height="16">
<path d="M7 7l10 10M17 7l-5 5 5 5M7 7l5 5-5 5"/>
</svg>
BLE provisioning (setup only) — phone pairs directly to device to deliver Wi-Fi credentials &amp; auth token
</div>
<div class="steps-grid reveal">
<div class="step">
<div class="step-num">01</div>
<h4>Provision Once</h4>
<p>Pair your ESP32-C6 device over BLE from the app. Enter your Wi-Fi credentials and authenticate — stored securely on the device in NVS.</p>
</div>
<div class="step">
<div class="step-num">02</div>
<h4>Calibrate</h4>
<p>A guided handshake walks the device through measuring your blind's full travel range. Precise 11-position control from that point on.</p>
</div>
<div class="step">
<div class="step-num">03</div>
<h4>Control &amp; Schedule</h4>
<p>Use the app slider for immediate control or set recurring schedules. Changes reach your blinds in real-time over Socket.IO.</p>
</div>
</div>
</div>
</section>
<!-- OPEN HARDWARE -->
<section class="tech" id="tech">
<div class="section-inner">
<div class="tech-content reveal">
<div class="tech-text">
<p class="section-eyebrow">Open Hardware</p>
<h2 class="section-title">Built on the Seeed XIAO ESP32-C6.</h2>
<p>BlindMaster hardware is built around the Seeed XIAO ESP32-C6 — a compact, powerful RISC-V module with built-in Wi-Fi 6 and Bluetooth 5.3. The firmware is open-source ESP-IDF, and hardware schematics will be open-sourced at launch.</p>
<ul class="tech-list">
<li>
<span class="tech-check"></span>
LEDC PWM servo control at 50Hz with encoder-based position feedback
</li>
<li>
<span class="tech-check"></span>
MAX17048 I²C fuel gauge for accurate LiPo state-of-charge monitoring
</li>
<li>
<span class="tech-check"></span>
Dynamic CPU scaling (80160MHz) + light sleep for extended battery life
</li>
<li>
<span class="tech-check"></span>
NimBLE secure provisioning — no cloud dependency during setup
</li>
</ul>
</div>
<div class="tech-visual">
<div class="chip-card">
<div class="chip-glow"></div>
<div class="chip-body">
<div class="chip-label">ESP32-C6</div>
<div class="chip-sub">RISC-V · Wi-Fi 6 · BT 5.3</div>
<div class="chip-pins">
<div class="chip-pin-row left">
<div class="chip-pin"></div>
<div class="chip-pin"></div>
<div class="chip-pin"></div>
<div class="chip-pin"></div>
<div class="chip-pin"></div>
</div>
<div class="chip-core">
<div class="chip-core-inner"></div>
</div>
<div class="chip-pin-row right">
<div class="chip-pin"></div>
<div class="chip-pin"></div>
<div class="chip-pin"></div>
<div class="chip-pin"></div>
<div class="chip-pin"></div>
</div>
</div>
<div class="chip-tags">
<span>FreeRTOS</span>
<span>ESP-IDF</span>
<span>NimBLE</span>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- COMING SOON -->
<section class="coming-soon" id="coming-soon">
<div class="section-inner">
<div class="coming-soon-card reveal">
<div class="cs-badge">
<span class="cs-badge-dot"></span>
Coming Soon
</div>
<h2>Ready to take control of your light?</h2>
<p>
BlindMaster is launching on <strong>TestFlight</strong> for iOS beta testers,
with hardware schematics open-sourced at the same time. Join the early access
list to be first to know.
</p>
<div class="cs-highlights">
<div class="cs-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 12 19.79 19.79 0 0 1 1.61 3.45 2 2 0 0 1 3.59 1h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.91 8.96a16 16 0 0 0 6.29 6.29l.62-.91a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
iOS TestFlight Beta
</div>
<div class="cs-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"/></svg>
Open-Source Hardware
</div>
<div class="cs-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><rect x="5" y="2" width="14" height="20" rx="2"/><path d="M12 18h.01"/></svg>
Android App (following)
</div>
</div>
<form class="cs-form" id="earlyAccessForm">
<input
type="email"
placeholder="your@email.com"
class="cs-input"
id="emailInput"
required
/>
<button type="submit" class="btn btn-primary cs-btn">Notify Me</button>
</form>
<p class="cs-disclaimer">No spam. Just a launch notification.</p>
</div>
</div>
</section>
<!-- FOOTER -->
<footer class="footer">
<div class="footer-inner">
<div class="footer-logo">
<div class="nav-logo-icon small">
<div class="slat"></div>
<div class="slat"></div>
<div class="slat"></div>
<div class="slat"></div>
</div>
<span>BlindMaster</span>
</div>
<p class="footer-tagline">Smart blinds. Effortless control.</p>
<div class="footer-links">
<a href="#features">Features</a>
<a href="#how-it-works">How It Works</a>
<a href="#tech">Hardware</a>
<a href="#coming-soon">Early Access</a>
</div>
<p class="footer-copy">&copy; 2026 BlindMaster. All rights reserved.</p>
</div>
</footer>
<script src="js/main.js"></script>
</body>
</html>

201
public/js/main.js Normal file
View File

@@ -0,0 +1,201 @@
/* ============================================================
BlindMaster Landing Page — main.js
- Time-based accent color (mirrors Flutter app theming)
- Nav scroll behavior
- Scroll reveal animations
- Hero blind animation
- Interactive demo slider
- Early access form
============================================================ */
(function () {
'use strict';
// ----------------------------------------------------------
// 1. Time-based theme (matches Flutter app color system)
// Orange 510am | Blue 10am6pm | Purple 6pm+
// ----------------------------------------------------------
function getThemeForHour(hour) {
if (hour >= 5 && hour < 10) {
return {
name: 'morning',
accent: '#F97316',
accentRgb: '249, 115, 22',
accentDark: '#C2410C',
accentGlow: 'rgba(249, 115, 22, 0.25)',
};
} else if (hour >= 10 && hour < 18) {
return {
name: 'day',
accent: '#3B82F6',
accentRgb: '59, 130, 246',
accentDark: '#1D4ED8',
accentGlow: 'rgba(59, 130, 246, 0.25)',
};
} else {
return {
name: 'evening',
accent: '#7C3AED',
accentRgb: '124, 58, 237',
accentDark: '#5B21B6',
accentGlow: 'rgba(124, 58, 237, 0.25)',
};
}
}
function applyTheme(theme) {
const root = document.documentElement;
root.style.setProperty('--accent', theme.accent);
root.style.setProperty('--accent-rgb', theme.accentRgb);
root.style.setProperty('--accent-dark', theme.accentDark);
root.style.setProperty('--accent-glow', theme.accentGlow);
}
const hour = new Date().getHours();
applyTheme(getThemeForHour(hour));
// ----------------------------------------------------------
// 2. Nav — scrolled state
// ----------------------------------------------------------
const nav = document.getElementById('nav');
function updateNav() {
if (window.scrollY > 40) {
nav.classList.add('scrolled');
} else {
nav.classList.remove('scrolled');
}
}
window.addEventListener('scroll', updateNav, { passive: true });
updateNav();
// ----------------------------------------------------------
// 3. Mobile nav toggle
// ----------------------------------------------------------
const hamburger = document.getElementById('hamburger');
const mobileNav = document.getElementById('mobileNav');
let mobileOpen = false;
hamburger.addEventListener('click', () => {
mobileOpen = !mobileOpen;
mobileNav.classList.toggle('open', mobileOpen);
});
document.querySelectorAll('.mobile-nav-link').forEach(link => {
link.addEventListener('click', () => {
mobileOpen = false;
mobileNav.classList.remove('open');
});
});
// ----------------------------------------------------------
// 4. Scroll reveal
// ----------------------------------------------------------
const revealEls = document.querySelectorAll('.reveal');
const revealObserver = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
revealObserver.unobserve(entry.target);
}
});
},
{ threshold: 0.12, rootMargin: '0px 0px -40px 0px' }
);
revealEls.forEach(el => revealObserver.observe(el));
// ----------------------------------------------------------
// 5. Hero blind animation
// Cycle through positions to demo the 010 position model
// ----------------------------------------------------------
const slats = document.querySelectorAll('.blind-slat');
const sliderThumb = document.getElementById('sliderThumb');
// Position 010: map to slat rotation angle
// 0 = fully closed (slats flat, blocking light)
// 5 = fully open (slats perpendicular, maximum light)
// 10 = closed from opposite direction
function positionToAngle(pos) {
if (pos <= 5) {
return (pos / 5) * 75; // 0° → 75°
} else {
return 75 - ((pos - 5) / 5) * 75; // 75° → 0°
}
}
function positionToSliderPercent(pos) {
return (pos / 10) * 100;
}
function setBlindPosition(pos) {
const angle = positionToAngle(pos);
slats.forEach(slat => {
slat.style.transform = `rotateX(${angle}deg)`;
});
sliderThumb.style.left = `${positionToSliderPercent(pos)}%`;
}
// Animate through positions: 0 → 5 → 10 → 5 → 0, looping
const demoPositions = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1];
let demoIndex = 0;
setBlindPosition(demoPositions[0]);
setInterval(() => {
demoIndex = (demoIndex + 1) % demoPositions.length;
setBlindPosition(demoPositions[demoIndex]);
}, 700);
// ----------------------------------------------------------
// 6. Interactive slider on demo window (drag/click)
// ----------------------------------------------------------
const sliderTrack = document.querySelector('.app-slider-track');
function handleSliderInteraction(clientX) {
const rect = sliderTrack.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
const pos = Math.round(pct * 10);
setBlindPosition(pos);
}
sliderTrack.addEventListener('mousedown', (e) => {
handleSliderInteraction(e.clientX);
const onMove = (e) => handleSliderInteraction(e.clientX);
const onUp = () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
});
sliderTrack.addEventListener('touchstart', (e) => {
handleSliderInteraction(e.touches[0].clientX);
const onMove = (e) => handleSliderInteraction(e.touches[0].clientX);
const onEnd = () => {
window.removeEventListener('touchmove', onMove);
window.removeEventListener('touchend', onEnd);
};
window.addEventListener('touchmove', onMove);
window.addEventListener('touchend', onEnd);
}, { passive: true });
// ----------------------------------------------------------
// 7. Early access form
// ----------------------------------------------------------
const form = document.getElementById('earlyAccessForm');
const emailInput = document.getElementById('emailInput');
form.addEventListener('submit', (e) => {
e.preventDefault();
const email = emailInput.value.trim();
if (!email) return;
// Replace form with success message
const successMsg = document.createElement('p');
successMsg.className = 'cs-success';
successMsg.textContent = `You're on the list! We'll notify ${email} at launch.`;
form.replaceWith(successMsg);
});
})();