PWA Settings
Fixed webmanifest generation and pwa icon. Made nav bar text optional.
This commit is contained in:
23 files changed
+1065
-91
No files matched your search
@@ -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<string | null>(null);
|
||||
|
||||
const {
|
||||
|
||||
@@ -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<BusinessActionResult> {
|
||||
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<BusinessActionResult> {
|
||||
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 `<link rel="apple-touch-startup-image" media="..." href="...">`
|
||||
* 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(/<link\b[^>]*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<string | null> {
|
||||
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<ImportAppIconZipResult> {
|
||||
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;
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user