feat: migrate to Next.js for Mini App and enhance project structure

- Replaced the previous webapp with a new Mini App built using Next.js, improving performance and maintainability.
- Updated the `.gitignore` to exclude Next.js build artifacts and node modules.
- Revised documentation in `AGENTS.md`, `README.md`, and `architecture.md` to reflect the new Mini App structure and technology stack.
- Enhanced Dockerfile to support the new build process for the Next.js application.
- Updated CI workflow to build and test the Next.js application.
- Added new configuration options for the Mini App, including `MINI_APP_SHORT_NAME` for improved deep linking.
- Refactored frontend testing setup to accommodate the new structure and testing framework.
- Removed legacy webapp files and dependencies to streamline the project.
This commit is contained in:
2026-03-03 16:04:08 +03:00
parent 2de5c1cb81
commit 16bf1a1043
148 changed files with 20240 additions and 7270 deletions

View File

@@ -0,0 +1,145 @@
/**
* Contact links (phone, Telegram) for duty cards and day detail.
* Ported from webapp/js/contactHtml.js buildContactLinksHtml.
*/
"use client";
import { useTranslation } from "@/i18n/use-translation";
import { formatPhoneDisplay } from "@/lib/phone-format";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Phone as PhoneIcon, Send as TelegramIcon } from "lucide-react";
export interface ContactLinksProps {
phone?: string | null;
username?: string | null;
layout?: "inline" | "block";
showLabels?: boolean;
/** Optional label for aria-label on links (e.g. duty holder name for "Call …", "Message … on Telegram"). */
contextLabel?: string;
className?: string;
}
const linkClass =
"text-accent hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2";
/**
* Renders phone (tel:) and Telegram (t.me) links. Used on flip card back and day detail.
*/
export function ContactLinks({
phone,
username,
layout = "inline",
showLabels = true,
contextLabel,
className,
}: ContactLinksProps) {
const { t } = useTranslation();
const hasPhone = Boolean(phone && String(phone).trim());
const rawUsername = username && String(username).trim();
const cleanUsername = rawUsername ? rawUsername.replace(/^@+/, "") : "";
const hasUsername = Boolean(cleanUsername);
if (!hasPhone && !hasUsername) return null;
const ariaCall = contextLabel
? t("contact.aria_call", { name: contextLabel })
: t("contact.phone");
const ariaTelegram = contextLabel
? t("contact.aria_telegram", { name: contextLabel })
: t("contact.telegram");
if (layout === "block") {
return (
<div className={cn("flex flex-col gap-2", className)}>
{hasPhone && (
<Button
variant="outline"
size="sm"
className="h-12 justify-start gap-3 px-4 text-accent hover:bg-accent/10 hover:text-accent"
asChild
>
<a href={`tel:${String(phone).trim()}`} aria-label={ariaCall}>
<PhoneIcon className="size-5" aria-hidden />
<span>{formatPhoneDisplay(phone!)}</span>
</a>
</Button>
)}
{hasUsername && (
<Button
variant="outline"
size="sm"
className="h-12 justify-start gap-3 px-4 text-accent hover:bg-accent/10 hover:text-accent"
asChild
>
<a
href={`https://t.me/${encodeURIComponent(cleanUsername)}`}
target="_blank"
rel="noopener noreferrer"
aria-label={ariaTelegram}
>
<TelegramIcon className="size-5" aria-hidden />
<span>@{cleanUsername}</span>
</a>
</Button>
)}
</div>
);
}
const parts: React.ReactNode[] = [];
if (hasPhone) {
const displayPhone = formatPhoneDisplay(phone!);
parts.push(
showLabels ? (
<span key="phone">
{t("contact.phone")}:{" "}
<a
href={`tel:${String(phone).trim()}`}
className={linkClass}
aria-label={ariaCall}
>
{displayPhone}
</a>
</span>
) : (
<a
key="phone"
href={`tel:${String(phone).trim()}`}
className={linkClass}
aria-label={ariaCall}
>
{displayPhone}
</a>
)
);
}
if (hasUsername) {
const href = `https://t.me/${encodeURIComponent(cleanUsername)}`;
const link = (
<a
key="tg"
href={href}
target="_blank"
rel="noopener noreferrer"
className={linkClass}
aria-label={ariaTelegram}
>
@{cleanUsername}
</a>
);
parts.push(showLabels ? <span key="tg">{t("contact.telegram")}: {link}</span> : link);
}
return (
<div className={cn("text-sm text-muted-foreground flex flex-wrap items-center gap-x-1", className)}>
{parts.map((p, i) => (
<span key={i} className="inline-flex items-center gap-x-1">
{i > 0 && <span aria-hidden className="text-muted-foreground">·</span>}
{p}
</span>
))}
</div>
);
}