PWA Settings

Fixed webmanifest generation and pwa icon.

Made nav bar text optional.
This commit is contained in:
Stefan Lazic committed 2026-08-07 13:38:11 +01:00
1 parent a702320d4e
commit 769d3f9ebc
23 files changed
+1065 -91

No files matched your search

@@ -0,0 +1,327 @@
"use client";
import { useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { FileArchive } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ColorPicker } from "@/components/ui/color-picker";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { MediaPicker } from "@/features/media/components/media-picker";
import type { SplashScreenEntry } from "@/lib/business/branding";
import {
importAppIconZipAction,
updateAppIconSettingsAction,
updateNavigationSettingsAction,
} from "../actions/business";
export interface AppIconSettingsInitial {
shortName: string;
appIconUrl: string | null;
themeColor: string | null;
backgroundColor: string | null;
icon192Url: string | null;
icon512Url: string | null;
maskableIcon192Url: string | null;
maskableIcon512Url: string | null;
splashScreens: SplashScreenEntry[];
bottomNavShowLabels: boolean;
}
export function AppIconSettingsForm({ initial }: { initial: AppIconSettingsInitial }) {
const router = useRouter();
const [form, setForm] = useState(initial);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [isPending, startTransition] = useTransition();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isImporting, setIsImporting] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const [importWarnings, setImportWarnings] = useState<string[]>([]);
const [importSuccessMessage, setImportSuccessMessage] = useState<string | null>(null);
const set = <K extends keyof AppIconSettingsInitial>(key: K, value: AppIconSettingsInitial[K]) => {
setSaved(false);
setForm((prev) => ({ ...prev, [key]: value }));
};
const handleZipSelected = async (fileList: FileList | null) => {
const file = fileList?.[0];
if (!file) return;
setImportError(null);
setImportWarnings([]);
setImportSuccessMessage(null);
setIsImporting(true);
const formData = new FormData();
formData.set("file", file);
const result = await importAppIconZipAction(formData);
setIsImporting(false);
if (fileInputRef.current) fileInputRef.current.value = "";
if (!result.success || !result.applied) {
setImportError(result.error ?? "Couldn't import that ZIP.");
setImportWarnings(result.warnings ?? []);
return;
}
setForm((prev) => ({ ...prev, ...result.applied }));
setImportWarnings(result.warnings ?? []);
setImportSuccessMessage(
`Imported${result.applied.appIconUrl ? " icon" : ""}${
result.applied.appIconUrl && result.applied.splashScreens.length > 0 ? " and" : ""
}${
result.applied.splashScreens.length > 0
? ` ${result.applied.splashScreens.length} splash screen${result.applied.splashScreens.length === 1 ? "" : "s"}`
: ""
}. Review below, then Save.`,
);
router.refresh();
};
const handleSave = () => {
setError(null);
startTransition(async () => {
const [iconResult, navigationResult] = await Promise.all([
updateAppIconSettingsAction({
shortName: form.shortName || null,
appIconUrl: form.appIconUrl,
themeColor: form.themeColor,
backgroundColor: form.backgroundColor,
icon192Url: form.icon192Url,
icon512Url: form.icon512Url,
maskableIcon192Url: form.maskableIcon192Url,
maskableIcon512Url: form.maskableIcon512Url,
splashScreens: form.splashScreens,
}),
updateNavigationSettingsAction({ bottomNavShowLabels: form.bottomNavShowLabels }),
]);
if (!iconResult.success) {
setError(iconResult.error ?? "Couldn't save these settings.");
return;
}
if (!navigationResult.success) {
setError(navigationResult.error ?? "Couldn't save the navigation setting.");
return;
}
setSaved(true);
router.refresh();
});
};
return (
<div className="space-y-6">
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Import from a Generator</h3>
<div className="rounded-md border-line space-y-3 border bg-white p-6">
<p className="text-muted text-xs">
Generate a bundle at {" "}
<span className="font-bold">progressier.com/pwa-icons-and-ios-splash-screen-generator</span> and upload
the ZIP here — the icon and every splash screen it contains are imported automatically, no manual
resizing needed.
</p>
<input
ref={fileInputRef}
type="file"
accept=".zip,application/zip,application/x-zip-compressed"
className="hidden"
onChange={(e) => void handleZipSelected(e.target.files)}
/>
<Button
type="button"
variant="secondary"
isLoading={isImporting}
onClick={() => fileInputRef.current?.click()}
>
<FileArchive className="h-4 w-4" aria-hidden="true" />
{isImporting ? "Importing…" : "Upload ZIP"}
</Button>
{importError && (
<p role="alert" className="text-danger text-sm font-medium">
{importError}
</p>
)}
{importSuccessMessage && !importError && (
<p role="status" className="text-accent-foreground text-sm font-medium">
{importSuccessMessage}
</p>
)}
{importWarnings.length > 0 && (
<ul className="text-muted list-inside list-disc space-y-1 text-xs">
{importWarnings.map((w, i) => (
<li key={i}>{w}</li>
))}
</ul>
)}
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Preview</h3>
<div
className="rounded-md border-line flex items-center gap-4 border p-6"
style={{ backgroundColor: form.backgroundColor || undefined }}
>
<div className="h-16 w-16 shrink-0 overflow-hidden rounded-2xl border border-line bg-white shadow-sm">
{form.icon512Url || form.appIconUrl ? (
// eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern as MediaPicker
<img src={form.icon512Url || form.appIconUrl || ""} alt="" className="h-full w-full object-cover" />
) : (
<div
className="flex h-full w-full items-center justify-center text-lg font-bold text-white"
style={{ backgroundColor: form.themeColor || "#111b15" }}
>
{(form.shortName || "A").charAt(0).toUpperCase()}
</div>
)}
</div>
<div>
<p className="text-sm font-bold">{form.shortName || "App Name"}</p>
<p className="text-muted text-xs">How the icon looks on a home screen</p>
</div>
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Name &amp; Colours</h3>
<div className="rounded-md border-line space-y-4 border bg-white p-6">
<Input
label="App Name"
value={form.shortName}
maxLength={30}
onChange={(e) => set("shortName", e.target.value)}
hint="Shown under the icon once someone adds this to their home screen. Keep it short."
/>
<div className="grid grid-cols-2 gap-4">
<ColorPicker label="Theme Colour" value={form.themeColor} onChange={(hex) => set("themeColor", hex)} />
<ColorPicker
label="Background Colour"
value={form.backgroundColor}
onChange={(hex) => set("backgroundColor", hex)}
/>
</div>
<p className="text-muted text-xs">
Theme and background colour set the status bar and Android splash screen shown while the app opens
after installing.
</p>
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Navigation</h3>
<div className="rounded-md border-line border bg-white p-6">
<Switch
label="Show labels on the bottom bar"
description="Turn off to show icons only, with no text underneath — applies to every customer and staff/owner bottom bar."
checked={form.bottomNavShowLabels}
onCheckedChange={(checked) => set("bottomNavShowLabels", checked)}
/>
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">iOS Icon</h3>
<div className="rounded-md border-line space-y-4 border bg-white p-6">
<MediaPicker
label="Apple Touch Icon"
value={form.appIconUrl}
onChange={(url) => set("appIconUrl", url)}
category="branding"
hint="A square image works best — used when someone adds this to their home screen on iPhone/iPad."
/>
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Android Icons</h3>
<div className="rounded-md border-line space-y-4 border bg-white p-6">
<p className="text-muted text-xs">
Android/Chrome installs read these directly — each falls back to a neutral icon on its own if left
unset.
</p>
<MediaPicker
label="Icon — 192×192"
value={form.icon192Url}
onChange={(url) => set("icon192Url", url)}
category="branding"
hint="Square, 192×192px."
/>
<MediaPicker
label="Icon — 512×512"
value={form.icon512Url}
onChange={(url) => set("icon512Url", url)}
category="branding"
hint="Square, 512×512px."
/>
<MediaPicker
label="Maskable Icon — 192×192"
value={form.maskableIcon192Url}
onChange={(url) => set("maskableIcon192Url", url)}
category="branding"
hint="Square, 192×192px. Keep the subject within the center ~80% — Android may crop this to a circle or rounded square."
/>
<MediaPicker
label="Maskable Icon — 512×512"
value={form.maskableIcon512Url}
onChange={(url) => set("maskableIcon512Url", url)}
category="branding"
hint="Square, 512×512px. Same center-safe-zone rule as above."
/>
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">iOS Splash Screens</h3>
<div className="rounded-md border-line space-y-3 border bg-white p-6">
{form.splashScreens.length === 0 ? (
<p className="text-muted text-sm">No splash screens imported yet — upload a ZIP above.</p>
) : (
<>
<p className="text-sm font-bold">
{form.splashScreens.length} splash screen{form.splashScreens.length === 1 ? "" : "s"} imported
</p>
<div className="grid grid-cols-6 gap-2 sm:grid-cols-8">
{form.splashScreens.map((s, i) => (
// eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern as MediaPicker
<img
key={i}
src={s.url}
alt=""
className="border-line aspect-[9/19] w-full rounded border object-cover"
/>
))}
</div>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => set("splashScreens", [])}
>
Remove all splash screens
</Button>
</>
)}
</div>
</section>
<div className="space-y-4">
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
{saved && !error && (
<p role="status" className="text-accent-foreground text-sm font-medium">
Saved — changes are live immediately.
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
Save
</Button>
</div>
</div>
);
}
@@ -3,7 +3,6 @@
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { ColorPicker } from "@/components/ui/color-picker";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
@@ -22,9 +21,6 @@ export interface BusinessDetailsInitial {
welcomeBackgroundDesktopUrl: string | null;
welcomeBackgroundMobileUrl: string | null;
faviconUrl: string | null;
appIconUrl: string | null;
themeColor: string | null;
backgroundColor: string | null;
phone: string;
email: string;
website: string;
@@ -69,9 +65,6 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
welcomeBackgroundDesktopUrl: form.welcomeBackgroundDesktopUrl,
welcomeBackgroundMobileUrl: form.welcomeBackgroundMobileUrl,
faviconUrl: form.faviconUrl,
appIconUrl: form.appIconUrl,
themeColor: form.themeColor,
backgroundColor: form.backgroundColor,
phone: form.phone || undefined,
email: form.email || undefined,
website: form.website || undefined,
@@ -152,17 +145,6 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
category="branding"
hint="Browser tab icon. Falls back to the app default when unset."
/>
<MediaPicker
label="App Icon"
value={form.appIconUrl}
onChange={(url) => set("appIconUrl", url)}
category="branding"
hint="Used when someone installs this as an app on their phone."
/>
<div className="grid grid-cols-2 gap-4">
<ColorPicker label="Theme Colour" value={form.themeColor} onChange={(hex) => set("themeColor", hex)} />
<ColorPicker label="Background Colour" value={form.backgroundColor} onChange={(hex) => set("backgroundColor", hex)} />
</div>
</div>
</section>