Dockerfile: deps -> builder -> runner. NEXT_PUBLIC_* vars are passed as
build args (inlined into the client bundle at build time, per Next.js);
everything else is read at container runtime instead (docker-compose.yml's
env_file / docker run --env-file) and never baked into an image layer.
Runner stage is non-root, ships only server.js + .next/static + public/
via output: "standalone".
Verified by actually running the build twice locally (Docker itself isn't
available in this environment) -- once with the real .env.local, once with
only placeholder values and no .env.local at all, matching the real Docker
build condition. The second run caught a real, pre-existing bug that had
nothing to do with Docker specifically: /login and three other
client-component trees (location/error, location/permission,
CategoryChips and NeighborhoodSearch nested in Server Component pages) all
call useSearchParams() without a Suspense boundary. next dev tolerates
this; next build hard-fails on it ("missing-suspense-with-csr-bailout").
This would have broken any production build -- Vercel, bare metal,
whatever -- not just Docker; fixed all four by wrapping in <Suspense>.
Also added .env.example (committed, no real values -- .env.local itself
stays gitignored) and a docker-compose.yml, with the --env-file .env.local
requirement called out explicitly in README.md since Compose only
auto-reads a file literally named .env, not .env.local.
87 lines
3.2 KiB
TypeScript
87 lines
3.2 KiB
TypeScript
"use client";
|
||
|
||
import { Suspense, useState } from "react";
|
||
import { useRouter, useSearchParams } from "next/navigation";
|
||
import { MapPin, Zap, Store } from "lucide-react";
|
||
import { Button } from "@/components/ui/button";
|
||
import { setLocation } from "@/lib/location/client";
|
||
|
||
const BENEFITS = [
|
||
{ icon: MapPin, text: "نمایش فروشگاههای محله شما" },
|
||
{ icon: Zap, text: "تحویل سریعتر" },
|
||
{ icon: Store, text: "کشف بازارچههای اطراف" },
|
||
];
|
||
|
||
// L01 — winofyfrontenddoc (1).md §4.1. No illustration asset was provided;
|
||
// approximated with a simple icon badge rather than the isometric map graphic.
|
||
export default function LocationPermissionPage() {
|
||
return (
|
||
<Suspense fallback={null}>
|
||
<LocationPermissionPageInner />
|
||
</Suspense>
|
||
);
|
||
}
|
||
|
||
function LocationPermissionPageInner() {
|
||
const router = useRouter();
|
||
const searchParams = useSearchParams();
|
||
const next = searchParams.get("next") ?? "/";
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
function allowLocation() {
|
||
setLoading(true);
|
||
navigator.geolocation.getCurrentPosition(
|
||
(position) => {
|
||
setLocation({ mode: "geo", lat: position.coords.latitude, lng: position.coords.longitude });
|
||
router.push(next);
|
||
router.refresh();
|
||
},
|
||
() => {
|
||
setLoading(false);
|
||
router.push(`/location/error?next=${encodeURIComponent(next)}`);
|
||
},
|
||
{ timeout: 10000 },
|
||
);
|
||
}
|
||
|
||
return (
|
||
<main className="flex min-h-full flex-1 flex-col items-center gap-8 bg-surface px-6 py-10 text-center">
|
||
<span className="flex h-28 w-28 items-center justify-center rounded-full bg-primary-action/10 text-primary-action">
|
||
<MapPin className="h-14 w-14" />
|
||
</span>
|
||
|
||
<div className="flex flex-col gap-2">
|
||
<h1 className="text-headline-medium font-extrabold text-foreground">بازارچه محلی شما</h1>
|
||
<p className="text-body-large text-gray-700">برای نمایش فروشگاههای نزدیک، به موقعیت مکانی شما نیاز داریم</p>
|
||
</div>
|
||
|
||
<div className="flex w-full flex-col gap-3 text-right">
|
||
{BENEFITS.map(({ icon: Icon, text }) => (
|
||
<div key={text} className="flex items-center gap-3 rounded-xl border border-gray-300 bg-white p-3">
|
||
<Icon className="h-5 w-5 shrink-0 text-primary-action" />
|
||
<span className="text-body-medium text-foreground">{text}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="mt-auto flex w-full flex-col gap-4">
|
||
<Button
|
||
size="lg"
|
||
className="w-full bg-primary-action hover:bg-primary-action/90 text-primary-foreground"
|
||
disabled={loading}
|
||
onClick={allowLocation}
|
||
>
|
||
{loading ? "در حال دریافت موقعیت..." : "اجازه دسترسی به موقعیت مکانی"}
|
||
</Button>
|
||
<button
|
||
type="button"
|
||
onClick={() => router.push(`/location/search?next=${encodeURIComponent(next)}`)}
|
||
className="text-body-medium font-semibold text-primary-action"
|
||
>
|
||
انتخاب دستی محله
|
||
</button>
|
||
<span className="text-body-medium text-gray-500">پشتیبانی</span>
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|