Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,26 @@ jobs:
- name: Typecheck web
run: npm --prefix apps/web run typecheck

web-lint:
name: Web lint
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: apps/web/package-lock.json

- name: Install web dependencies
run: npm --prefix apps/web ci

- name: Lint web
run: npm --prefix apps/web run lint

docker:
name: Docker builds
runs-on: ubuntu-latest
Expand Down
18 changes: 12 additions & 6 deletions apps/web/components/booking/booking-map-explorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import Image from "next/image";
import {
ArrowLeft01Icon,
Dollar01Icon,
Expand Down Expand Up @@ -574,15 +575,20 @@ const MapCanvas = memo(function MapCanvas({
style={{ transform: `scale(${mapData.zoomScale})`, willChange: "transform" }}
>
{mapData.tiles.map((tile) => (
<img
<div
key={tile.key}
src={tile.src}
alt=""
draggable={false}
decoding="async"
className="pointer-events-none absolute select-none object-cover"
style={{ left: tile.left, top: tile.top, width: tile.size, height: tile.size }}
/>
>
<Image
src={tile.src}
alt=""
fill
draggable={false}
sizes={tile.size}
className="object-cover"
/>
</div>
))}
<div className="pointer-events-none absolute inset-0 bg-indigo-600/[0.03]" />
{mapData.points.map((p) => (
Expand Down
93 changes: 65 additions & 28 deletions apps/web/components/landing/metrix-landing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ type VenueOption = {
unit: string;
seats: string;
};
type ChatMessageProps = {
accent?: boolean;
children: React.ReactNode;
from?: "bot" | "me";
visible: boolean;
};

const Pict = ({ icon, size = 56, label, className }: PictProps & { icon: IconSvgElement; className?: string }) => (
<span
Expand All @@ -59,6 +65,14 @@ const Plus = ({ size = 18 }: PictProps) => <HugeiconsIcon icon={PlusSignIcon} si
const Pin = ({ size = 16 }: PictProps) => <HugeiconsIcon icon={Location01Icon} size={size} strokeWidth={2} aria-hidden="true" />;
const Clock = ({ size = 16 }: PictProps) => <HugeiconsIcon icon={Clock01Icon} size={size} strokeWidth={2} aria-hidden="true" />;

function ChatMessage({ from = "bot", children, accent, visible }: ChatMessageProps) {
return (
<div className={`metrix-chat-row ${from === "me" ? "is-me" : ""} ${visible ? "is-visible" : ""}`}>
<div className={`metrix-chat-msg ${from === "me" ? "is-me" : ""} ${accent ? "is-accent" : ""}`}>{children}</div>
</div>
);
}

const SPACE_OPTIONS = [
{
id: "desk" as const,
Expand Down Expand Up @@ -285,12 +299,6 @@ function ChatPreview() {
return () => window.clearInterval(interval);
}, []);

const Msg = ({ from = "bot", children, accent, visible }: { from?: "bot" | "me"; children: React.ReactNode; accent?: boolean; visible: boolean }) => (
<div className={`metrix-chat-row ${from === "me" ? "is-me" : ""} ${visible ? "is-visible" : ""}`}>
<div className={`metrix-chat-msg ${from === "me" ? "is-me" : ""} ${accent ? "is-accent" : ""}`}>{children}</div>
</div>
);

return (
<div className="metrix-chat-card">
<div className="metrix-chat-head">
Expand All @@ -305,12 +313,12 @@ function ChatPreview() {
</div>

<div className="metrix-chat-body">
<Msg visible={step >= 0}>
<ChatMessage visible={step >= 0}>
<strong>Hi! Where do you need a workspace?</strong>
<small>Tap a Moscow location or send your address</small>
</Msg>
<Msg visible={step >= 1} from="me">Patriarchy · today</Msg>
<Msg visible={step >= 2}>
</ChatMessage>
<ChatMessage visible={step >= 1} from="me">Patriarchy · today</ChatMessage>
<ChatMessage visible={step >= 2}>
<strong>3 spaces near you</strong>
<div className="metrix-chat-list">
{[
Expand All @@ -324,12 +332,12 @@ function ChatPreview() {
</span>
))}
</div>
</Msg>
<Msg visible={step >= 3} from="me">Courtyard Bench, 14:00-18:00</Msg>
<Msg visible={step >= 4} accent>
</ChatMessage>
<ChatMessage visible={step >= 3} from="me">Courtyard Bench, 14:00-18:00</ChatMessage>
<ChatMessage visible={step >= 4} accent>
<span className="metrix-chat-confirm"><CheckDot size={18} color="var(--metrix-ink)" /> <strong>Booked. 3 604 RUB paid.</strong></span>
<small>Door code <b>4421</b> · receipt sent</small>
</Msg>
</ChatMessage>
</div>

<div className="metrix-chat-composer">
Expand Down Expand Up @@ -545,8 +553,37 @@ function BookingDemo() {
const startHour = HOURS[startIdx];
const endHour = HOURS[Math.min(startIdx + hours, HOURS.length - 1)] ?? "20:00";

useEffect(() => setVenueIdx(0), [city, spaceId]);
useEffect(() => setConfirmed(false), [city, spaceId, venueIdx, startIdx, hours, people, extras]);
const resetConfirmation = () => setConfirmed(false);
const selectCity = (nextCity: string) => {
setCity(nextCity);
setVenueIdx(0);
resetConfirmation();
};
const selectSpace = (nextSpaceId: SpaceId) => {
setSpaceId(nextSpaceId);
setVenueIdx(0);
resetConfirmation();
};
const selectVenue = (index: number) => {
setVenueIdx(index);
resetConfirmation();
};
const selectStart = (index: number) => {
setStartIdx(index);
resetConfirmation();
};
const selectHours = (nextHours: number) => {
setHours(nextHours);
resetConfirmation();
};
const setPeopleCount = (nextPeople: number) => {
setPeople(nextPeople);
resetConfirmation();
};
const toggleExtra = (key: keyof typeof extras) => {
setExtras((current) => ({ ...current, [key]: !current[key] }));
resetConfirmation();
};

return (
<section id="demo" className="metrix-section metrix-demo">
Expand All @@ -555,12 +592,12 @@ function BookingDemo() {
<div className="metrix-demo-grid">
<div className="metrix-card metrix-booking-surface" data-reveal="left">
<Field label="01 Where">
<div className="metrix-chip-row">{Object.keys(BOOKING_VENUES).map((item) => <Chip key={item} on={item === city} onClick={() => setCity(item)}><Pin size={12} /> {item}</Chip>)}</div>
<div className="metrix-chip-row">{Object.keys(BOOKING_VENUES).map((item) => <Chip key={item} on={item === city} onClick={() => selectCity(item)}><Pin size={12} /> {item}</Chip>)}</div>
</Field>
<Field label="02 What">
<div className="metrix-space-options">
{SPACE_OPTIONS.map((item) => (
<button key={item.id} className={item.id === spaceId ? "is-active" : ""} onClick={() => setSpaceId(item.id)}>
<button key={item.id} className={item.id === spaceId ? "is-active" : ""} onClick={() => selectSpace(item.id)}>
{item.pict(28)}<span>{item.name}</span><small>from {priceLabelShort(item.price, item.unit)}</small>
</button>
))}
Expand All @@ -569,27 +606,27 @@ function BookingDemo() {
<Field label="03 Which" hint={`${spaceVenues.length} ${space.name.toLowerCase()}${spaceVenues.length === 1 ? "" : "s"} open in ${city}`}>
<div className="metrix-venue-list">
{spaceVenues.map((item, index) => (
<button key={item.name} className={index === venueIdx ? "is-active" : ""} onClick={() => setVenueIdx(index)}>
<button key={item.name} className={index === venueIdx ? "is-active" : ""} onClick={() => selectVenue(index)}>
<i /><strong>{item.name}</strong><span>{item.area} · {priceLabel(item.price, item.unit)}</span>{index === 0 && <b>hot</b>}
</button>
))}
</div>
</Field>
<Field label="04 When" hint={`Today · ${startHour} -> ${endHour}`}>
<div className="metrix-hours">{HOURS.map((hour, index) => <button key={hour} className={index === startIdx ? "is-start" : index > startIdx && index < startIdx + hours ? "is-range" : ""} onClick={() => setStartIdx(index)}>{hour}</button>)}</div>
<div className="metrix-duration"><span>Duration</span>{[1, 2, 3, 4, 6, 8].map((item) => <Chip key={item} small on={item === hours} onClick={() => setHours(item)}>{item}h</Chip>)}</div>
<div className="metrix-hours">{HOURS.map((hour, index) => <button key={hour} className={index === startIdx ? "is-start" : index > startIdx && index < startIdx + hours ? "is-range" : ""} onClick={() => selectStart(index)}>{hour}</button>)}</div>
<div className="metrix-duration"><span>Duration</span>{[1, 2, 3, 4, 6, 8].map((item) => <Chip key={item} small on={item === hours} onClick={() => selectHours(item)}>{item}h</Chip>)}</div>
</Field>
<Field label="05 Extras">
<div className="metrix-chip-row">
<div className="metrix-people">
<span>People</span>
<button onClick={() => setPeople(Math.max(1, people - 1))}>-</button>
<button onClick={() => setPeopleCount(Math.max(1, people - 1))}>-</button>
<strong className="metrix-num">{people}</strong>
<button onClick={() => setPeople(people + 1)}>+</button>
<button onClick={() => setPeopleCount(people + 1)}>+</button>
</div>
<Chip on={extras.coffee} onClick={() => setExtras((current) => ({ ...current, coffee: !current.coffee }))}>Coffee 450 RUB/hr</Chip>
<Chip on={extras.parking} onClick={() => setExtras((current) => ({ ...current, parking: !current.parking }))}>Parking 800 RUB</Chip>
{(spaceId === "meeting" || spaceId === "office") && <Chip on={extras.screen} onClick={() => setExtras((current) => ({ ...current, screen: !current.screen }))}>4K screen · free</Chip>}
<Chip on={extras.coffee} onClick={() => toggleExtra("coffee")}>Coffee 450 RUB/hr</Chip>
<Chip on={extras.parking} onClick={() => toggleExtra("parking")}>Parking 800 RUB</Chip>
{(spaceId === "meeting" || spaceId === "office") && <Chip on={extras.screen} onClick={() => toggleExtra("screen")}>4K screen · free</Chip>}
</div>
</Field>
</div>
Expand All @@ -612,7 +649,7 @@ function BookingDemo() {
<small>Free cancellation up to 1 hour before · No card needed today</small>
</div>
<div className="metrix-card metrix-chat-snippet">
<span className="metrix-eyebrow">What you'll see in chat</span>
<span className="metrix-eyebrow">What you&apos;ll see in chat</span>
{confirmed ? (
<p className="is-confirmed"><CheckDot color="var(--metrix-ink)" size={16} /> <strong>Booking confirmed</strong><br />{venue.name} · {startHour}-{endHour} · {formatRub(total)} paid<br />Door code <b>{1000 + (subtotal * 7) % 9000}</b></p>
) : (
Expand Down Expand Up @@ -662,7 +699,7 @@ function B2B() {
function FAQ() {
const [open, setOpen] = useState(0);
const faqs: Array<[string, React.ReactNode]> = [
["Do I need an account?", <>No. The Telegram bot is your account. Open <BotHandle />, send one message, and you're in. We use your Telegram identity for receipts and remember your card so you never re-enter it.</>],
["Do I need an account?", <>No. The Telegram bot is your account. Open <BotHandle />, send one message, and you&apos;re in. We use your Telegram identity for receipts and remember your card so you never re-enter it.</>],
["What if a space turns out to be busy or closed?", "Every booking is live-confirmed by the venue within 30 seconds. If we can't confirm, you're auto-refunded and offered the next-closest space at the same hour."],
["Can I cancel or move a booking?", "Yes. Free cancellation up to 60 minutes before check-in. Within the hour, you keep 50%. Move a booking to a different time, same venue, at any point."],
["How does pricing work?", "You pay the live venue rate in RUB plus a 6% Metrix fee. Desks start at 2 900 RUB / day, meeting rooms at 27 000 RUB / hour, private offices at 1 080 000 RUB / month, and team pods at 33 000 RUB / desk / month."],
Expand Down
4 changes: 2 additions & 2 deletions apps/web/components/landing/sections/technology-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@ export function TechnologySection() {
Real-time booking
</p>
<h2 className="text-4xl font-bold leading-[1.1] tracking-[-0.03em] text-zinc-900 dark:text-white md:text-5xl">
Always know what's open before you leave home.
Always know what&apos;s open before you leave home.
</h2>
<p className="mt-6 text-lg leading-relaxed text-zinc-500 dark:text-zinc-400">
Metrix syncs desk and room availability in real time. No stale calendars, no double-bookings. What you see in the bot is what's actually free.
Metrix syncs desk and room availability in real time. No stale calendars, no double-bookings. What you see in the bot is what&apos;s actually free.
</p>
<ul className="mt-8 flex flex-col gap-3">
{features.map((f) => (
Expand Down
3 changes: 2 additions & 1 deletion apps/web/components/media/fade-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ interface FadeImageProps extends Omit<ImageProps, "onLoad"> {
fadeDelay?: number;
}

export function FadeImage({ className, fadeDelay = 0, ...props }: FadeImageProps) {
export function FadeImage({ alt, className, fadeDelay = 0, ...props }: FadeImageProps) {
const [isVisible, setIsVisible] = useState(false);
const [isLoaded, setIsLoaded] = useState(false);
const ref = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -54,6 +54,7 @@ export function FadeImage({ className, fadeDelay = 0, ...props }: FadeImageProps
<div ref={ref} className="relative h-full w-full">
<Image
{...props}
alt={alt}
className={`${className || ""} transition-all duration-700 ease-out ${
shouldShow ? "opacity-100 scale-100" : "opacity-0 scale-[1.02]"
}`}
Expand Down
13 changes: 3 additions & 10 deletions apps/web/components/providers/theme-toggle.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,21 @@
"use client";

import { useEffect, useState } from "react";
import { useEffect } from "react";
import { useTheme } from "next-themes";
import { HugeiconsIcon } from "@hugeicons/react";
import { Sun01Icon, Moon01Icon } from "@hugeicons/core-free-icons";

export function ThemeToggle() {
const { resolvedTheme, setTheme, theme } = useTheme();
const [mounted, setMounted] = useState(false);

useEffect(() => {
setMounted(true);
}, []);

useEffect(() => {
if (!mounted) return;

const meta = document.querySelector<HTMLMetaElement>('meta[name="color-scheme"]');
if (!meta) return;

meta.content = theme === "dark" ? "dark" : theme === "light" ? "light" : "light dark";
}, [mounted, theme, resolvedTheme]);
}, [theme, resolvedTheme]);

const dark = mounted ? resolvedTheme === "dark" : false;
const dark = resolvedTheme === "dark";

return (
<button
Expand Down
32 changes: 20 additions & 12 deletions apps/web/components/ui/hooks/use-mobile 2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,26 @@ import * as React from 'react'

const MOBILE_BREAKPOINT = 768

export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
function getIsMobileSnapshot() {
return window.innerWidth < MOBILE_BREAKPOINT
}

React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener('change', onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener('change', onChange)
}, [])
function getServerSnapshot() {
return false
}

export function useIsMobile() {
return React.useSyncExternalStore(
(onStoreChange) => {
if (typeof window === 'undefined') {
return () => undefined
}

return !!isMobile
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
mql.addEventListener('change', onStoreChange)
return () => mql.removeEventListener('change', onStoreChange)
},
getIsMobileSnapshot,
getServerSnapshot,
)
}
2 changes: 1 addition & 1 deletion apps/web/components/ui/layout/sidebar/menu-extras.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export function SidebarMenuSkeleton({
showIcon = false,
...props
}: React.ComponentProps<"div"> & { showIcon?: boolean }) {
const width = React.useMemo(() => `${Math.floor(Math.random() * 40) + 50}%`, []);
const width = showIcon ? "72%" : "88%";

return (
<div
Expand Down
4 changes: 3 additions & 1 deletion apps/web/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import nextConfig from "eslint-config-next/core-web-vitals";

export default [...nextConfig];
const eslintConfig = [...nextConfig];

export default eslintConfig;
Loading