diff --git a/src/app/(owner)/owner/settings/app/page.tsx b/src/app/(owner)/owner/settings/app/page.tsx
new file mode 100644
index 0000000..4779bb4
--- /dev/null
+++ b/src/app/(owner)/owner/settings/app/page.tsx
@@ -0,0 +1,46 @@
+import type { Metadata } from "next";
+import { redirect } from "next/navigation";
+import { auth } from "@/auth";
+import { SettingsPageShell } from "@/components/settings/settings-page-shell";
+import { prisma } from "@/lib/db/prisma";
+import { isSuperAdmin } from "@/lib/auth/permissions";
+import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
+import { parseSplashScreens } from "@/lib/business/branding";
+import { getBusinessProfile } from "@/services/business/business.service";
+import { AppIconSettingsForm } from "@/features/owner/components/app-icon-settings-form";
+
+export const metadata: Metadata = { title: "App Icon & Home Screen" };
+export const dynamic = "force-dynamic";
+
+/** Super Administrator only — not Owner, unlike every other /owner/settings/* page. See requireSuperAdminBusinessManager in features/owner/actions/business.ts for the matching action-layer check. */
+export default async function OwnerAppIconSettingsPage() {
+ const session = await auth();
+ if (!session?.user) redirect("/sign-in");
+
+ if (!(await isSuperAdmin(session.user.id))) redirect("/owner");
+
+ const context = await getActingBusinessContext();
+ if (!context) redirect("/staff");
+
+ const business = await getBusinessProfile(prisma, context.businessId);
+ if (!business) redirect("/owner");
+
+ return (
+
+
+
+ );
+}
diff --git a/src/app/(owner)/owner/settings/business/page.tsx b/src/app/(owner)/owner/settings/business/page.tsx
index 0804f67..6bae6e8 100644
--- a/src/app/(owner)/owner/settings/business/page.tsx
+++ b/src/app/(owner)/owner/settings/business/page.tsx
@@ -38,9 +38,6 @@ export default async function OwnerBusinessDetailsPage() {
welcomeBackgroundDesktopUrl: business.welcomeBackgroundDesktopUrl,
welcomeBackgroundMobileUrl: business.welcomeBackgroundMobileUrl,
faviconUrl: business.faviconUrl,
- appIconUrl: business.appIconUrl,
- themeColor: business.themeColor,
- backgroundColor: business.backgroundColor,
phone: business.phone ?? "",
email: business.email ?? "",
website: business.website ?? "",
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 062048c..54b2541 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -19,10 +19,20 @@ const inter = Inter({
*/
export async function generateMetadata(): Promise {
const branding = await getBranding();
+
return {
title: { default: branding.name, template: `%s | ${branding.name}` },
description: branding.description || "Book your next appointment in seconds.",
- appleWebApp: { capable: true, statusBarStyle: "default", title: branding.shortName },
+ appleWebApp: {
+ capable: true,
+ statusBarStyle: "default",
+ title: branding.shortName,
+ // One exact per-device `apple-touch-startup-image` entry per array
+ // item — imported wholesale from a generator's ZIP bundle (see
+ // importAppIconZipAction), not hand-picked. Empty array renders no
+ // splash screens at all, which is the correct "unconfigured" state.
+ startupImage: branding.splashScreens.length > 0 ? branding.splashScreens : undefined,
+ },
icons:
branding.faviconUrl || branding.appIconUrl
? { icon: branding.faviconUrl || undefined, apple: branding.appIconUrl || undefined }
diff --git a/src/app/manifest.ts b/src/app/manifest.ts
index c1fa7a4..41761ce 100644
--- a/src/app/manifest.ts
+++ b/src/app/manifest.ts
@@ -7,15 +7,24 @@ import { getBranding } from "@/lib/business/branding";
* Phase 8: name/description/colors reflect the installed business (see
* src/lib/business/branding.ts) rather than the internal product name.
*
- * `icons` intentionally stays pointed at the app's own pre-generated,
- * correctly-sized/maskable PWA icon set rather than a business's single
- * `appIconUrl` — there's no image-processing pipeline in this app to
- * derive the required 192/512/maskable variants from one arbitrary
- * uploaded URL (see docs/phase-8-summary.md, "Known limitations"), so
- * this is fallback rule #2 ("business name + a neutral generated icon"),
- * applied deliberately rather than risking a broken/non-square install
- * icon.
+ * `force-dynamic` is required, not optional — without it, Next has no
+ * signal that this route depends on request-time data (a plain DB read
+ * doesn't count as one of Next's dynamic-usage triggers) and statically
+ * renders it once at build time. Since the Docker image is built before
+ * the installation wizard ever runs, that bakes in the pre-install
+ * neutral fallback ("Book an Appointment"/"Booking") permanently — the
+ * real business name/icons would never appear no matter how the Business
+ * row changes afterward.
+ *
+ * Each of the four manifest icons falls back independently to the app's
+ * own pre-generated, correctly-sized/maskable PWA icon when a business
+ * hasn't uploaded its own — a business can set some without all four (see
+ * the "App Icon & Home Screen" super-admin settings screen). This is
+ * fallback rule #2 ("business name + a neutral generated icon") applied
+ * per-field, rather than risking a broken/non-square install icon.
*/
+export const dynamic = "force-dynamic";
+
export default async function manifest(): Promise {
const branding = await getBranding();
return {
@@ -29,10 +38,30 @@ export default async function manifest(): Promise {
theme_color: branding.themeColor,
orientation: "portrait-primary",
icons: [
- { src: "/icons/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
- { src: "/icons/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
- { src: "/icons/icon-maskable-192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
- { src: "/icons/icon-maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
+ {
+ src: branding.icon192Url || "/icons/icon-192.png",
+ sizes: "192x192",
+ type: "image/png",
+ purpose: "any",
+ },
+ {
+ src: branding.icon512Url || "/icons/icon-512.png",
+ sizes: "512x512",
+ type: "image/png",
+ purpose: "any",
+ },
+ {
+ src: branding.maskableIcon192Url || "/icons/icon-maskable-192.png",
+ sizes: "192x192",
+ type: "image/png",
+ purpose: "maskable",
+ },
+ {
+ src: branding.maskableIcon512Url || "/icons/icon-maskable-512.png",
+ sizes: "512x512",
+ type: "image/png",
+ purpose: "maskable",
+ },
],
};
}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 01a0ee6..bd28ae5 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -5,7 +5,7 @@ import { auth } from "@/auth";
import { Button } from "@/components/ui/button";
import { getBranding } from "@/lib/business/branding";
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
-import { hasPermission } from "@/lib/auth/permissions";
+import { hasPermission, isSuperAdmin } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db/prisma";
import { WelcomeInstallCard } from "@/features/pwa/components/welcome-install-card";
@@ -14,10 +14,14 @@ import { WelcomeInstallCard } from "@/features/pwa/components/welcome-install-ca
* The unauthenticated entry point — always shown at `/` before
* authentication, per the brief's "Welcome routing" requirements.
* Authenticated visitors never see it: they're routed straight to their
- * role's home (customer → /home, barber → /staff, owner → /owner) below.
- * Unreachable at all before installation completes — src/proxy.ts
- * redirects every non-exempt route (including this one) to /install
- * until ApplicationInstallation flips to INSTALLED.
+ * role's calendar (barber → /staff, owner/super admin → /owner/calendar)
+ * or customer home below. This is also where a bare sign-in with no
+ * `callbackUrl` ends up — see safeRedirectPath's fallback in
+ * sign-in-form.tsx — so it's the one place that has to get every role
+ * right, not just staffProfile holders. Unreachable at all before
+ * installation completes — src/proxy.ts redirects every non-exempt route
+ * (including this one) to /install until ApplicationInstallation flips
+ * to INSTALLED.
*/
export default async function WelcomePage() {
const session = await auth();
@@ -27,6 +31,12 @@ export default async function WelcomePage() {
const canManageStaff = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
redirect(canManageStaff ? "/owner" : "/staff");
}
+ // A Super Administrator has no StaffProfile by design (see
+ // isSuperAdmin) — without this check they'd fall through to the
+ // customer /home below instead of the staff shell + Calendar tab
+ // their nav role (see lib/navigation/nav-config.ts) actually gives
+ // them once they land somewhere in it.
+ if (await isSuperAdmin(session.user.id)) redirect("/owner/calendar");
redirect("/home");
}
diff --git a/src/components/navigation/bottom-nav.tsx b/src/components/navigation/bottom-nav.tsx
index 848d05a..ca74c7c 100644
--- a/src/components/navigation/bottom-nav.tsx
+++ b/src/components/navigation/bottom-nav.tsx
@@ -46,6 +46,8 @@ export interface BottomNavProps {
* role-aware component.
*/
variant?: "customer" | "staff";
+ /** Business-wide, Super Administrator-only display setting (see Business.bottomNavShowLabels) — defaults to the original icon+label layout when a caller doesn't pass it. */
+ showLabels?: boolean;
}
/**
@@ -54,7 +56,7 @@ export interface BottomNavProps {
* and pass it down — one less thing to get out of sync as routes are
* added in later phases.
*/
-export function BottomNav({ items, variant = "customer" }: BottomNavProps) {
+export function BottomNav({ items, variant = "customer", showLabels = true }: BottomNavProps) {
const pathname = usePathname();
const isStaff = variant === "staff";
const activeHref = resolveActiveHref(pathname, items);
@@ -85,14 +87,19 @@ export function BottomNav({ items, variant = "customer" }: BottomNavProps) {
key={href}
href={href}
aria-current={active ? "page" : undefined}
+ // Icon-only drops the flex-col label row entirely (not just
+ // hides the text) so the tap target re-centers vertically
+ // instead of leaving an empty gap where the label used to be.
+ aria-label={showLabels ? undefined : label}
className={cn(
- "flex flex-col items-center gap-1 rounded-lg px-3 py-1 transition-colors",
+ "flex items-center rounded-lg transition-colors",
+ showLabels ? "flex-col gap-1 px-3 py-1" : "justify-center px-4 py-2",
active ? (isStaff ? "text-white" : "text-ink") : "hover:text-ink",
isStaff && !active && "hover:text-white",
)}
>
-
- {label}
+
+ {showLabels && {label}}
);
})}
diff --git a/src/components/navigation/staff-shell.tsx b/src/components/navigation/staff-shell.tsx
index c5b305b..5ff2ed0 100644
--- a/src/components/navigation/staff-shell.tsx
+++ b/src/components/navigation/staff-shell.tsx
@@ -33,7 +33,7 @@ export async function StaffShell({
{children}
-
+
);
}
diff --git a/src/features/auth/components/sign-in-form.tsx b/src/features/auth/components/sign-in-form.tsx
index 7ed9317..3442e90 100644
--- a/src/features/auth/components/sign-in-form.tsx
+++ b/src/features/auth/components/sign-in-form.tsx
@@ -14,7 +14,12 @@ import { safeRedirectPath } from "@/lib/utils/safe-redirect";
export function SignInForm() {
const searchParams = useSearchParams();
- const callbackUrl = safeRedirectPath(searchParams.get("callbackUrl"), "/home");
+ // "/" rather than a hardcoded "/home" — the role isn't known client-side
+ // before sign-in completes, so a bare sign-in (no callbackUrl) routes
+ // through the root welcome page's own post-auth redirect, which already
+ // knows how to send a barber/owner/super admin to their Calendar tab
+ // instead of the customer home. See src/app/page.tsx.
+ const callbackUrl = safeRedirectPath(searchParams.get("callbackUrl"), "/");
const [formError, setFormError] = useState(null);
const {
diff --git a/src/features/owner/actions/business.ts b/src/features/owner/actions/business.ts
index 944d804..a565e0b 100644
--- a/src/features/owner/actions/business.ts
+++ b/src/features/owner/actions/business.ts
@@ -1,17 +1,33 @@
"use server";
+import JSZip from "jszip";
+import { imageSize } from "image-size";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { Permission } from "@/generated/prisma/enums";
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
+import { isSuperAdmin } from "@/lib/auth/permissions";
import { writeAuditLog } from "@/lib/audit/write-audit-log";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
+import { parseSplashScreens } from "@/lib/business/branding";
+import type { SplashScreenEntry } from "@/lib/business/branding";
+import { MEDIA_MAX_UPLOAD_BYTES, storeMediaFile } from "@/lib/media/media-storage";
+import { createMediaAsset } from "@/services/media/media.service";
import {
setOpeningHours,
+ updateAppIconSettings,
updateBusinessProfile,
updateBusinessSettings,
+ updateNavigationSettings,
+} from "@/services/business/business.service";
+import type {
+ AppIconSettingsInput,
+ BusinessLocationInput,
+ BusinessProfileInput,
+ BusinessSettingsInput,
+ NavigationSettingsInput,
+ OpeningHoursDayInput,
} from "@/services/business/business.service";
-import type { BusinessLocationInput, BusinessProfileInput, BusinessSettingsInput, OpeningHoursDayInput } from "@/services/business/business.service";
export interface BusinessActionResult {
success: boolean;
@@ -40,6 +56,27 @@ async function requireBusinessManager() {
return { staff: { businessId: context.businessId, userId: context.userId }, error: null } as const;
}
+/**
+ * Stricter than requireBusinessManager above — the App Icon & Home
+ * Screen settings are Super Administrator only (not Owner), unlike every
+ * other business-profile action in this file. Re-checked here rather
+ * than trusting the page-level redirect, same "enforce again at the
+ * action layer" rule the rest of the app follows.
+ */
+async function requireSuperAdminBusinessManager() {
+ const session = await auth();
+ if (!session?.user) return { staff: null, error: "You must be signed in." } as const;
+
+ if (!(await isSuperAdmin(session.user.id))) {
+ return { staff: null, error: "Only a Super Administrator can change these settings." } as const;
+ }
+
+ const context = await getActingBusinessContext();
+ if (!context) return { staff: null, error: "You must be signed in as staff." } as const;
+
+ return { staff: { businessId: context.businessId, userId: context.userId }, error: null } as const;
+}
+
export async function updateBusinessProfileAction(params: {
profile: BusinessProfileInput;
location: BusinessLocationInput;
@@ -79,6 +116,236 @@ export async function updateBusinessSettingsAction(settings: BusinessSettingsInp
return { success: true };
}
+export async function updateAppIconSettingsAction(settings: AppIconSettingsInput): Promise {
+ const { staff, error } = await requireSuperAdminBusinessManager();
+ if (!staff) return { success: false, error: error ?? "Not authorized." };
+
+ await updateAppIconSettings(prisma, { businessId: staff.businessId, settings });
+
+ await writeAuditLog(prisma, {
+ actorUserId: staff.userId,
+ action: "BUSINESS_APP_ICON_SETTINGS_UPDATED",
+ entityType: "Business",
+ entityId: staff.businessId,
+ });
+
+ return { success: true };
+}
+
+/** Icon+label vs icon-only bottom navigation — Super Administrator only, same rationale as updateAppIconSettingsAction (a change here is visible to every signed-in user immediately, not scoped to the actor). */
+export async function updateNavigationSettingsAction(
+ settings: NavigationSettingsInput,
+): Promise {
+ const { staff, error } = await requireSuperAdminBusinessManager();
+ if (!staff) return { success: false, error: error ?? "Not authorized." };
+
+ await updateNavigationSettings(prisma, { businessId: staff.businessId, settings });
+
+ await writeAuditLog(prisma, {
+ actorUserId: staff.userId,
+ action: "BUSINESS_NAVIGATION_SETTINGS_UPDATED",
+ entityType: "Business",
+ entityId: staff.businessId,
+ metadata: { bottomNavShowLabels: settings.bottomNavShowLabels },
+ });
+
+ return { success: true };
+}
+
+const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+
+function isPngBuffer(buffer: Buffer): boolean {
+ return buffer.length >= PNG_SIGNATURE.length && buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE);
+}
+
+/**
+ * Extracts ``
+ * tags from a generator's readme (e.g. progressier.com's PWA icon/splash
+ * bundle) — attribute-order-agnostic within each tag, since the only
+ * contract that matters is "this is an apple-touch-startup-image link
+ * with a media query and an href", not the exact source formatting.
+ */
+function parseAppleTouchStartupImageTags(readmeText: string): { media: string; hrefBasename: string }[] {
+ const tags = readmeText.match(/]*rel="apple-touch-startup-image"[^>]*>/gi) ?? [];
+ const results: { media: string; hrefBasename: string }[] = [];
+ for (const tag of tags) {
+ const media = tag.match(/media="([^"]*)"/i)?.[1];
+ const href = tag.match(/href="([^"]*)"/i)?.[1];
+ if (!media || !href) continue;
+ results.push({ media, hrefBasename: href.split("/").pop() ?? href });
+ }
+ return results;
+}
+
+/** Validates, stores, and records one extracted ZIP image — null on anything that fails validation (caller turns that into a warning, not a hard failure). */
+async function uploadZipImage(
+ entry: JSZip.JSZipObject,
+ staff: { businessId: string; userId: string },
+): Promise {
+ const buffer = Buffer.from(await entry.async("nodebuffer"));
+ if (!isPngBuffer(buffer) || buffer.length > MEDIA_MAX_UPLOAD_BYTES) return null;
+
+ let width: number | null = null;
+ let height: number | null = null;
+ try {
+ const dimensions = imageSize(buffer);
+ width = dimensions.width ?? null;
+ height = dimensions.height ?? null;
+ } catch {
+ // Best-effort — an unrecognised/corrupt header shouldn't block the upload.
+ }
+
+ const stored = await storeMediaFile({ buffer, mimeType: "image/png", category: "branding" });
+ await createMediaAsset(prisma, {
+ businessId: staff.businessId,
+ url: stored.url,
+ originalFilename: entry.name.split("/").pop() ?? null,
+ mimeType: "image/png",
+ sizeBytes: buffer.length,
+ width,
+ height,
+ category: "branding",
+ uploadedByUserId: staff.userId,
+ });
+ return stored.url;
+}
+
+export interface ImportAppIconZipResult {
+ success: boolean;
+ error?: string;
+ warnings?: string[];
+ applied?: {
+ appIconUrl: string | null;
+ icon192Url: string | null;
+ icon512Url: string | null;
+ maskableIcon192Url: string | null;
+ maskableIcon512Url: string | null;
+ splashScreens: SplashScreenEntry[];
+ };
+}
+
+/**
+ * Accepts a ZIP from a PWA icon/splash-screen generator (e.g.
+ * progressier.com) — extracts `icon.png` (applied to the Apple touch icon
+ * AND all four Android manifest icon fields, since the generator only
+ * produces one square source image) and every splash screen referenced
+ * by `readme.txt`'s `apple-touch-startup-image` tags (see
+ * parseAppleTouchStartupImageTags), each uploaded through the same media
+ * pipeline as a manual MediaPicker upload. Partial-success by design: a
+ * missing readme, a missing icon, or a handful of unreadable entries
+ * degrade to warnings rather than aborting the whole import — only "found
+ * nothing at all usable" is a hard failure.
+ */
+export async function importAppIconZipAction(formData: FormData): Promise {
+ const { staff, error } = await requireSuperAdminBusinessManager();
+ if (!staff) return { success: false, error: error ?? "Not authorized." };
+
+ const file = formData.get("file");
+ if (!(file instanceof File)) return { success: false, error: "No file provided." };
+ if (!file.name.toLowerCase().endsWith(".zip")) {
+ return { success: false, error: "Please upload a .zip file." };
+ }
+
+ let zip: JSZip;
+ try {
+ zip = await JSZip.loadAsync(Buffer.from(await file.arrayBuffer()));
+ } catch {
+ return { success: false, error: "Couldn't read that file as a ZIP archive." };
+ }
+
+ const entries = Object.values(zip.files).filter((entry) => !entry.dir);
+ const entryByBasename = new Map(entries.map((entry) => [entry.name.split("/").pop() ?? entry.name, entry]));
+
+ const warnings: string[] = [];
+
+ const iconEntry = entries.find((entry) => /^icon\.png$/i.test(entry.name.split("/").pop() ?? ""));
+ let iconUrl: string | null = null;
+ if (iconEntry) {
+ iconUrl = await uploadZipImage(iconEntry, staff);
+ if (!iconUrl) warnings.push("Couldn't import icon.png — it may be too large or not a valid PNG.");
+ } else {
+ warnings.push("No icon.png found in the ZIP — icon fields were left unchanged.");
+ }
+
+ const readmeEntry = entries.find((entry) => (entry.name.split("/").pop() ?? "").toLowerCase() === "readme.txt");
+ const splashScreens: SplashScreenEntry[] = [];
+ if (readmeEntry) {
+ const tags = parseAppleTouchStartupImageTags(await readmeEntry.async("text"));
+ if (tags.length === 0) {
+ warnings.push("readme.txt was found but contained no apple-touch-startup-image tags.");
+ }
+ for (const { media, hrefBasename } of tags) {
+ const imageEntry = entryByBasename.get(hrefBasename);
+ if (!imageEntry) {
+ warnings.push(`Referenced splash image "${hrefBasename}" wasn't found in the ZIP — skipped.`);
+ continue;
+ }
+ const url = await uploadZipImage(imageEntry, staff);
+ if (url) {
+ splashScreens.push({ media, url });
+ } else {
+ warnings.push(`Couldn't import "${hrefBasename}" — skipped.`);
+ }
+ }
+ } else {
+ warnings.push("No readme.txt found in the ZIP — splash screens were left unchanged.");
+ }
+
+ if (!iconUrl && splashScreens.length === 0) {
+ return {
+ success: false,
+ error:
+ "Nothing usable was found in that ZIP — expected an icon.png and/or a readme.txt with apple-touch-startup-image tags.",
+ warnings,
+ };
+ }
+
+ const settings: AppIconSettingsInput = {};
+ if (iconUrl) {
+ settings.appIconUrl = iconUrl;
+ settings.icon192Url = iconUrl;
+ settings.icon512Url = iconUrl;
+ settings.maskableIcon192Url = iconUrl;
+ settings.maskableIcon512Url = iconUrl;
+ }
+ if (splashScreens.length > 0) settings.splashScreens = splashScreens;
+
+ await updateAppIconSettings(prisma, { businessId: staff.businessId, settings });
+
+ await writeAuditLog(prisma, {
+ actorUserId: staff.userId,
+ action: "BUSINESS_APP_ICONS_IMPORTED_FROM_ZIP",
+ entityType: "Business",
+ entityId: staff.businessId,
+ metadata: { iconImported: !!iconUrl, splashScreenCount: splashScreens.length, warningCount: warnings.length },
+ });
+
+ const current = await prisma.business.findUnique({
+ where: { id: staff.businessId },
+ select: {
+ appIconUrl: true,
+ icon192Url: true,
+ icon512Url: true,
+ maskableIcon192Url: true,
+ maskableIcon512Url: true,
+ splashScreens: true,
+ },
+ });
+
+ return {
+ success: true,
+ warnings: warnings.length > 0 ? warnings : undefined,
+ applied: {
+ appIconUrl: current?.appIconUrl ?? null,
+ icon192Url: current?.icon192Url ?? null,
+ icon512Url: current?.icon512Url ?? null,
+ maskableIcon192Url: current?.maskableIcon192Url ?? null,
+ maskableIcon512Url: current?.maskableIcon512Url ?? null,
+ splashScreens: parseSplashScreens(current?.splashScreens),
+ },
+ };
+}
+
export interface OpeningHoursActionResult {
success: boolean;
error?: string;
diff --git a/src/features/owner/components/app-icon-settings-form.tsx b/src/features/owner/components/app-icon-settings-form.tsx
new file mode 100644
index 0000000..636492e
--- /dev/null
+++ b/src/features/owner/components/app-icon-settings-form.tsx
@@ -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(null);
+ const [saved, setSaved] = useState(false);
+ const [isPending, startTransition] = useTransition();
+
+ const fileInputRef = useRef(null);
+ const [isImporting, setIsImporting] = useState(false);
+ const [importError, setImportError] = useState(null);
+ const [importWarnings, setImportWarnings] = useState([]);
+ const [importSuccessMessage, setImportSuccessMessage] = useState(null);
+
+ const set = (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 (
+
+
+
Import from a Generator
+
+
+ Generate a bundle at {" "}
+ progressier.com/pwa-icons-and-ios-splash-screen-generator and upload
+ the ZIP here — the icon and every splash screen it contains are imported automatically, no manual
+ resizing needed.
+
+ Theme and background colour set the status bar and Android splash screen shown while the app opens
+ after installing.
+
+
+
+
+
+
Navigation
+
+ set("bottomNavShowLabels", checked)}
+ />
+
+
+
+
+
iOS Icon
+
+ set("appIconUrl", url)}
+ category="branding"
+ hint="A square image works best — used when someone adds this to their home screen on iPhone/iPad."
+ />
+
+
+
+
+
Android Icons
+
+
+ Android/Chrome installs read these directly — each falls back to a neutral icon on its own if left
+ unset.
+
+ set("icon192Url", url)}
+ category="branding"
+ hint="Square, 192×192px."
+ />
+ set("icon512Url", url)}
+ category="branding"
+ hint="Square, 512×512px."
+ />
+ 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."
+ />
+ set("maskableIcon512Url", url)}
+ category="branding"
+ hint="Square, 512×512px. Same center-safe-zone rule as above."
+ />
+
+
+
+
+
iOS Splash Screens
+
+ {form.splashScreens.length === 0 ? (
+
No splash screens imported yet — upload a ZIP above.