feat(mobile): integrate Instagram/Canva style interactive QR Canvas Studio with touch drag, rotate, badge boxes, shadows and dynamic layers

This commit is contained in:
AyrisAI
2026-08-21 01:24:17 +03:00
parent d00b45ce72
commit ae2386eacf
5 changed files with 952 additions and 474 deletions
+51 -474
View File
@@ -24,11 +24,22 @@ import { getActiveRestaurant, type ActiveRestaurant } from "@/lib/active-restaur
import {
COMPONENT_TEMPLATES,
TEMPLATE_ASPECT_RATIOS,
FONT_OPTIONS,
type QrCustomFields,
type TextItemConfig,
} from "@/components/qr-templates";
import {
QrCanvasStudio,
getDefaultLayersForTemplate,
type CanvasTextLayer,
} from "@/components/qr-canvas";
const HIDE_ALL_TEMPLATE_FIELDS: QrCustomFields = {
restaurantName: { text: "", visible: false },
subtitle: { text: "", visible: false },
tableNo: { text: "", visible: false },
ctaText: { text: "", visible: false },
wifiText: { text: "", visible: false },
socialText: { text: "", visible: false },
};
interface QrResponse {
id: string;
@@ -52,64 +63,11 @@ export default function QrScreen() {
);
const [exportingPng, setExportingPng] = useState(false);
const [exportingPdf, setExportingPdf] = useState(false);
const [showCustomizePanel, setShowCustomizePanel] = useState(false);
const [fields, setFields] = useState<QrCustomFields>({
restaurantName: {
text: "Gourmet Bistro",
fontSize: 26,
fontId: "serif",
visible: true,
},
subtitle: {
text: "RESTAURANT & BAR\nMODERN CUISINE",
fontSize: 11,
fontId: "sans",
visible: true,
},
tableNo: {
text: "MASA NO: 01",
fontSize: 11,
fontId: "sans",
visible: true,
},
ctaText: {
text: "MENÜYÜ GÖRMEK İÇİN OKUTUN",
fontSize: 13,
fontId: "serif",
visible: true,
},
wifiText: {
text: "WiFi: Gourmet_Guest | Şifre: lezzetli01",
fontSize: 10,
fontId: "sans",
visible: true,
},
socialText: {
text: "@gourmetbistro",
fontSize: 10,
fontId: "sans",
visible: true,
},
});
const [selectedEditField, setSelectedEditField] = useState<keyof QrCustomFields | null>(null);
const FIELD_DEFINITIONS: Array<{
key: keyof QrCustomFields;
label: string;
placeholder: string;
min: number;
max: number;
icon: string;
}> = [
{ key: "restaurantName", label: "Restoran Başlığı", placeholder: "Restoran Adı", min: 14, max: 48, icon: "restaurant-outline" },
{ key: "subtitle", label: "Slogan / Alt Başlık", placeholder: "Slogan (Enter ile alt satıra geçebilir)", min: 8, max: 26, icon: "chatbubble-outline" },
{ key: "ctaText", label: "Yönlendirme (CTA) Metni", placeholder: "Örn: MENÜYÜ GÖRMEK İÇİN OKUTUN", min: 9, max: 28, icon: "scan-outline" },
{ key: "tableNo", label: "Masa Numarası", placeholder: "Örn: MASA NO: 01", min: 8, max: 22, icon: "grid-outline" },
{ key: "wifiText", label: "WiFi & Şifre Bilgisi", placeholder: "Örn: WiFi: Bistro | Şifre: 12345", min: 7, max: 20, icon: "wifi-outline" },
{ key: "socialText", label: "Sosyal Medya / İletişim", placeholder: "Örn: @menulio", min: 7, max: 20, icon: "share-social-outline" },
];
// Instagram Story / Canva Tarzı Katmanlar
const [layers, setLayers] = useState<CanvasTextLayer[]>(() =>
getDefaultLayersForTemplate("qr-1", "Gourmet Bistro")
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const viewShotRef = useRef<any>(null);
@@ -124,10 +82,6 @@ export default function QrScreen() {
const displayWidth = 300;
const displayHeight = Math.round(displayWidth / aspectRatio);
useEffect(() => {
(async () => {
const activeRest = await getActiveRestaurant();
@@ -143,13 +97,11 @@ export default function QrScreen() {
const restDetail = await api.get<{ name?: string }>(`/restaurants/${activeRest.restaurantId}`);
if (restDetail?.name) {
setRestaurantName(restDetail.name);
setFields((prev) => ({
...prev,
restaurantName: {
...prev.restaurantName,
text: restDetail.name || "Gourmet Bistro",
},
}));
setLayers((prev) =>
prev.map((l) =>
l.id === "rName" ? { ...l, text: restDetail.name!.toUpperCase() } : l
)
);
}
} catch {
// fallback
@@ -164,6 +116,7 @@ export default function QrScreen() {
async function handleCopy() {
if (!qr?.targetUrl) return;
await Clipboard.setStringAsync(qr.targetUrl);
@@ -331,14 +284,17 @@ export default function QrScreen() {
return (
<Pressable
key={preset.key}
onPress={() => setSelectedKey(preset.key)}
onPress={() => {
setSelectedKey(preset.key);
setLayers(getDefaultLayersForTemplate(preset.key, restaurantName));
}}
style={{
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 14,
backgroundColor: isSelected ? "#D4AF37" : "#1A1A1E",
borderWidth: 1.5,
borderColor: isSelected ? "#D4AF37" : "#2B2B32",
borderRadius: 14,
paddingHorizontal: 16,
paddingVertical: 10,
flexDirection: "row",
alignItems: "center",
gap: 8,
@@ -369,417 +325,38 @@ export default function QrScreen() {
</ScrollView>
)}
{/* Live Stand Preview Wrapped in ViewShot */}
{/* İnteraktif Instagram / Canva Tarzı QR Stant Stüdyosu */}
<View
style={{
alignItems: "center",
justifyContent: "center",
padding: 16,
borderRadius: 28,
backgroundColor: "#18181B",
width: "100%",
marginBottom: 20,
shadowColor: "#000",
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.5,
shadowRadius: 20,
elevation: 8,
borderWidth: 1,
borderColor: "rgba(255,255,255,0.08)",
}}
>
<ViewShot ref={viewShotRef} options={{ format: "png", quality: 1.0 }}>
{/* Component-based template — no XML parsing, pixel-perfect */}
<View
style={{
width: displayWidth,
height: displayHeight,
borderRadius: 24,
overflow: "hidden",
backgroundColor: "#0C0C0E",
}}
>
{TemplateComponent ? (
<TemplateComponent
qrBase64={qr.pngBase64}
width={displayWidth}
height={displayHeight}
fields={fields}
/>
) : null}
</View>
<QrCanvasStudio
templateKey={selectedKey}
restaurantName={restaurantName}
qrBase64={qr.pngBase64}
width={displayWidth}
height={displayHeight}
renderBackground={() =>
TemplateComponent ? (
<TemplateComponent
qrBase64={qr.pngBase64}
width={displayWidth}
height={displayHeight}
fields={HIDE_ALL_TEMPLATE_FIELDS}
/>
) : null
}
layers={layers}
onLayersChange={setLayers}
/>
</ViewShot>
</View>
{/* Hızlı Katman Seçici (Canlı Seçim) */}
<View style={{ width: "100%", marginBottom: 12 }}>
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
<Text style={{ color: "#D4AF37", fontSize: 12, fontWeight: "800", letterSpacing: 0.5 }}>
CANLI DÜZENLENECEK ALANI SEÇİN:
</Text>
{selectedEditField && (
<Pressable
onPress={() => setSelectedEditField(null)}
style={{
backgroundColor: "rgba(255,255,255,0.08)",
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 6,
}}
>
<Text style={{ color: "#A1A1AA", fontSize: 11, fontWeight: "600" }}>Kapat</Text>
</Pressable>
)}
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: 8 }}>
{FIELD_DEFINITIONS.map(({ key, label, icon }) => {
const item = fields[key];
const isSelected = selectedEditField === key;
return (
<Pressable
key={key}
onPress={() => setSelectedEditField(isSelected ? null : key)}
style={({ pressed }) => ({
backgroundColor: isSelected ? "#D4AF37" : item.visible ? "#242429" : "#1A1A1E",
paddingHorizontal: 12,
paddingVertical: 9,
borderRadius: 12,
borderWidth: 1.5,
borderColor: isSelected ? "#D4AF37" : item.visible ? "rgba(212, 175, 55, 0.3)" : "#2B2B32",
flexDirection: "row",
alignItems: "center",
gap: 6,
opacity: pressed ? 0.8 : 1,
})}
>
<Ionicons
name={icon as any}
size={14}
color={isSelected ? "#0C0C0E" : item.visible ? "#D4AF37" : "#71717A"}
/>
<Text
style={{
color: isSelected ? "#0C0C0E" : item.visible ? "#FFFFFF" : "#71717A",
fontSize: 12,
fontWeight: isSelected ? "800" : "700",
}}
>
{label}
</Text>
{item.offsetY ? (
<View
style={{
backgroundColor: isSelected ? "#0C0C0E" : "#D4AF37",
borderRadius: 6,
paddingHorizontal: 4,
paddingVertical: 1,
}}
>
<Text
style={{
color: isSelected ? "#D4AF37" : "#0C0C0E",
fontSize: 9,
fontWeight: "800",
}}
>
{item.offsetY > 0 ? `+${item.offsetY}` : item.offsetY}px
</Text>
</View>
) : null}
</Pressable>
);
})}
</ScrollView>
</View>
{/* CANLI DÜZENLEYİCİ KARTI (Sayfa İçinde Anında Görsel Geri Bildirim) */}
{selectedEditField && (() => {
const def = FIELD_DEFINITIONS.find((d) => d.key === selectedEditField)!;
const item = fields[selectedEditField];
return (
<View
style={{
width: "100%",
backgroundColor: "#18181B",
borderRadius: 20,
borderWidth: 1.5,
borderColor: "#D4AF37",
padding: 16,
marginBottom: 20,
gap: 14,
shadowColor: "#D4AF37",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 10,
elevation: 4,
}}
>
{/* Başlık & Görünürlük */}
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
<View
style={{
width: 28,
height: 28,
borderRadius: 8,
backgroundColor: "#D4AF37",
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name={def.icon as any} size={16} color="#0C0C0E" />
</View>
<Text style={{ color: "#FFFFFF", fontSize: 15, fontWeight: "800" }}>
{def.label}
</Text>
</View>
{/* Stantta Göster / Gizle */}
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
<Text style={{ color: item.visible ? "#10B981" : "#71717A", fontSize: 11, fontWeight: "700" }}>
{item.visible ? "Görünür" : "Gizli"}
</Text>
<Switch
value={item.visible}
onValueChange={(v) =>
setFields((prev) => ({
...prev,
[selectedEditField]: { ...prev[selectedEditField], visible: v },
}))
}
trackColor={{ false: "#27272A", true: "#D4AF37" }}
thumbColor="#FFFFFF"
ios_backgroundColor="#27272A"
/>
</View>
</View>
{item.visible && (
<>
{/* Metin Girişi (Enter ile alt satır destekli) */}
<View style={{ gap: 4 }}>
<Text style={{ color: "#D4AF37", fontSize: 11, fontWeight: "700" }}>
METİN İÇERİĞİ (ENTER İLE YENİ SATIRA GEÇEBİLİR):
</Text>
<TextInput
value={item.text}
onChangeText={(t) =>
setFields((prev) => ({
...prev,
[selectedEditField]: { ...prev[selectedEditField], text: t },
}))
}
placeholder={def.placeholder}
placeholderTextColor="#71717A"
multiline
style={{
backgroundColor: "#121215",
borderWidth: 1,
borderColor: "rgba(255,255,255,0.12)",
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 10,
color: "#FFFFFF",
fontSize: 14,
minHeight: 46,
}}
/>
</View>
{/* DİKEY KONUM / YUKARI - AŞAĞI TAŞIMA (offsetY) */}
<View style={{ gap: 6, backgroundColor: "#121215", padding: 12, borderRadius: 12 }}>
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
<Text style={{ color: "#D4AF37", fontSize: 11, fontWeight: "700" }}>
DİKEY KONUM (YUKARI / AŞAĞI TAŞIMA)
</Text>
<Text style={{ color: "#E5C378", fontSize: 11, fontWeight: "800" }}>
{item.offsetY ? `${item.offsetY > 0 ? "+" : ""}${item.offsetY} px` : "Orijinal (0px)"}
</Text>
</View>
<View style={{ flexDirection: "row", gap: 8 }}>
<Pressable
onPress={() =>
setFields((prev) => ({
...prev,
[selectedEditField]: {
...prev[selectedEditField],
offsetY: (prev[selectedEditField].offsetY || 0) - 3,
},
}))
}
style={{
flex: 1,
backgroundColor: "#242429",
paddingVertical: 8,
borderRadius: 8,
alignItems: "center",
flexDirection: "row",
justifyContent: "center",
gap: 4,
}}
>
<Ionicons name="arrow-up" size={14} color="#D4AF37" />
<Text style={{ color: "#FFFFFF", fontSize: 12, fontWeight: "700" }}>Yukarı (-3px)</Text>
</Pressable>
<Pressable
onPress={() =>
setFields((prev) => ({
...prev,
[selectedEditField]: {
...prev[selectedEditField],
offsetY: (prev[selectedEditField].offsetY || 0) + 3,
},
}))
}
style={{
flex: 1,
backgroundColor: "#242429",
paddingVertical: 8,
borderRadius: 8,
alignItems: "center",
flexDirection: "row",
justifyContent: "center",
gap: 4,
}}
>
<Ionicons name="arrow-down" size={14} color="#D4AF37" />
<Text style={{ color: "#FFFFFF", fontSize: 12, fontWeight: "700" }}>Aşağı (+3px)</Text>
</Pressable>
{item.offsetY ? (
<Pressable
onPress={() =>
setFields((prev) => ({
...prev,
[selectedEditField]: {
...prev[selectedEditField],
offsetY: 0,
},
}))
}
style={{
backgroundColor: "#3A2A2A",
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 8,
alignItems: "center",
justifyContent: "center",
}}
>
<Text style={{ color: "#EF4444", fontSize: 11, fontWeight: "700" }}>Sıfırla</Text>
</Pressable>
) : null}
</View>
</View>
{/* YAZI TİPİ (FONT) & BOYUT YAN YANA */}
<View style={{ gap: 6 }}>
<Text style={{ color: "#D4AF37", fontSize: 11, fontWeight: "700" }}>
YAZI TİPİ (TÜRKÇE DESTEKLİ FONT)
</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: 6 }}>
{FONT_OPTIONS.map((f) => {
const isSelected = item.fontId === f.id;
return (
<Pressable
key={f.id}
onPress={() =>
setFields((prev) => ({
...prev,
[selectedEditField]: { ...prev[selectedEditField], fontId: f.id },
}))
}
style={{
backgroundColor: isSelected ? "#D4AF37" : "#242429",
paddingHorizontal: 12,
paddingVertical: 7,
borderRadius: 10,
borderWidth: 1.5,
borderColor: isSelected ? "#D4AF37" : "rgba(255,255,255,0.06)",
}}
>
<Text
style={{
color: isSelected ? "#0C0C0E" : "#FFFFFF",
fontSize: 11.5,
fontWeight: isSelected ? "800" : "600",
}}
>
{f.label}
</Text>
</Pressable>
);
})}
</ScrollView>
</View>
{/* YAZI BOYUTU (FONT SIZE) */}
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
backgroundColor: "#121215",
padding: 10,
borderRadius: 12,
}}
>
<Text style={{ color: "#D4AF37", fontSize: 12, fontWeight: "700" }}>
YAZI BOYUTU:
</Text>
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
<Pressable
onPress={() =>
setFields((prev) => ({
...prev,
[selectedEditField]: {
...prev[selectedEditField],
fontSize: Math.max(def.min, (prev[selectedEditField].fontSize || 12) - 1),
},
}))
}
style={{
backgroundColor: "#242429",
paddingHorizontal: 14,
paddingVertical: 6,
borderRadius: 8,
}}
>
<Ionicons name="remove" size={16} color="#FFFFFF" />
</Pressable>
<Text style={{ color: "#D4AF37", fontSize: 16, fontWeight: "800", minWidth: 40, textAlign: "center" }}>
{item.fontSize || 12} px
</Text>
<Pressable
onPress={() =>
setFields((prev) => ({
...prev,
[selectedEditField]: {
...prev[selectedEditField],
fontSize: Math.min(def.max, (prev[selectedEditField].fontSize || 12) + 1),
},
}))
}
style={{
backgroundColor: "#242429",
paddingHorizontal: 14,
paddingVertical: 6,
borderRadius: 8,
}}
>
<Ionicons name="add" size={16} color="#FFFFFF" />
</Pressable>
</View>
</View>
</>
)}
</View>
);
})()}
@@ -0,0 +1,119 @@
import React, { useRef } from "react";
import {
PanResponder,
Pressable,
Text,
View,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { type CanvasTextLayer, getCanvasFontFamily } from "./types";
interface CanvasLayerItemProps {
layer: CanvasTextLayer;
isSelected: boolean;
onSelect: (id: string) => void;
onUpdatePosition: (id: string, x: number, y: number) => void;
onDelete: (id: string) => void;
canvasWidth: number;
canvasHeight: number;
}
export const CanvasLayerItem: React.FC<CanvasLayerItemProps> = ({
layer,
isSelected,
onSelect,
onUpdatePosition,
onDelete,
canvasWidth,
canvasHeight,
}) => {
const panStartRef = useRef({ x: layer.x, y: layer.y });
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderGrant: () => {
onSelect(layer.id);
panStartRef.current = { x: layer.x, y: layer.y };
},
onPanResponderMove: (_evt, gestureState) => {
const newX = Math.max(0, Math.min(canvasWidth - 40, panStartRef.current.x + gestureState.dx));
const newY = Math.max(0, Math.min(canvasHeight - 30, panStartRef.current.y + gestureState.dy));
onUpdatePosition(layer.id, Math.round(newX), Math.round(newY));
},
})
).current;
const fontFamily = getCanvasFontFamily(layer.fontId);
return (
<View
{...panResponder.panHandlers}
style={{
position: "absolute",
left: layer.x,
top: layer.y,
transform: [{ rotate: `${layer.rotation || 0}deg` }],
zIndex: isSelected ? 100 : 10,
padding: 4,
}}
>
<View
style={{
borderWidth: isSelected ? 1.5 : 0,
borderColor: "#D4AF37",
borderStyle: "dashed",
borderRadius: 8,
paddingHorizontal: layer.bgBoxColor ? 10 : 4,
paddingVertical: layer.bgBoxColor ? 5 : 2,
backgroundColor: layer.bgBoxColor || "transparent",
alignItems: "center",
justifyContent: "center",
}}
>
<Text
style={{
fontFamily,
fontSize: layer.fontSize,
color: layer.color,
fontWeight: layer.fontWeight || "bold",
letterSpacing: layer.letterSpacing || 0.5,
textAlign: "center",
textShadowColor: layer.hasShadow ? "rgba(0,0,0,0.85)" : "transparent",
textShadowOffset: layer.hasShadow ? { width: 1, height: 1 } : { width: 0, height: 0 },
textShadowRadius: layer.hasShadow ? 4 : 0,
}}
>
{layer.text || "Metin"}
</Text>
{/* Seçili İken Hızlı Silme Rozeti */}
{isSelected && (
<Pressable
onPress={() => onDelete(layer.id)}
style={{
position: "absolute",
top: -12,
right: -12,
backgroundColor: "#EF4444",
width: 22,
height: 22,
borderRadius: 11,
alignItems: "center",
justifyContent: "center",
borderWidth: 1.5,
borderColor: "#FFFFFF",
shadowColor: "#000",
shadowOpacity: 0.3,
shadowRadius: 3,
elevation: 4,
}}
>
<Ionicons name="close" size={12} color="#FFFFFF" />
</Pressable>
)}
</View>
</View>
);
};
@@ -0,0 +1,573 @@
import React, { useState } from "react";
import {
Pressable,
ScrollView,
Switch,
Text,
TextInput,
View,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { CanvasLayerItem } from "./CanvasLayerItem";
import {
type CanvasTextLayer,
CANVAS_FONT_OPTIONS,
CANVAS_COLOR_PALETTE,
INSTAGRAM_BADGE_STYLES,
getDefaultLayersForTemplate,
} from "./types";
interface QrCanvasStudioProps {
templateKey: string;
restaurantName: string;
qrBase64?: string;
width: number;
height: number;
renderBackground: () => React.ReactNode;
layers: CanvasTextLayer[];
onLayersChange: React.Dispatch<React.SetStateAction<CanvasTextLayer[]>>;
}
export const QrCanvasStudio: React.FC<QrCanvasStudioProps> = ({
templateKey,
restaurantName,
width,
height,
renderBackground,
layers,
onLayersChange,
}) => {
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(layers[0]?.id || null);
const selectedLayer = layers.find((l) => l.id === selectedLayerId);
// Pozisyon Güncelleme
const handleUpdatePosition = (id: string, x: number, y: number) => {
onLayersChange((prev) =>
prev.map((l) => (l.id === id ? { ...l, x, y } : l))
);
};
// Yeni Katman Ekle
const handleAddTextLayer = () => {
const newLayer: CanvasTextLayer = {
id: `layer_${Date.now()}`,
text: "Yeni Metin",
x: Math.round(width / 2 - 40),
y: Math.round(height / 2 - 20),
rotation: 0,
fontSize: 14,
fontId: "sans",
color: "#FFFFFF",
bgBoxColor: "rgba(18, 18, 21, 0.85)",
fontWeight: "800",
};
onLayersChange((prev) => [...prev, newLayer]);
setSelectedLayerId(newLayer.id);
};
// Katman Sil
const handleDeleteLayer = (id: string) => {
onLayersChange((prev) => prev.filter((l) => l.id !== id));
if (selectedLayerId === id) {
setSelectedLayerId(null);
}
};
// Katman Kopyala
const handleDuplicateLayer = (id: string) => {
const target = layers.find((l) => l.id === id);
if (!target) return;
const duplicated: CanvasTextLayer = {
...target,
id: `layer_${Date.now()}`,
x: Math.min(width - 50, target.x + 15),
y: Math.min(height - 30, target.y + 15),
};
onLayersChange((prev) => [...prev, duplicated]);
setSelectedLayerId(duplicated.id);
};
// Şablonu Sıfırla
const handleResetLayers = () => {
const defaults = getDefaultLayersForTemplate(templateKey, restaurantName);
onLayersChange(defaults);
setSelectedLayerId(defaults[0]?.id || null);
};
return (
<View style={{ width: "100%", alignItems: "center" }}>
{/* 1. İNTERAKTİF TUVAL ALANI */}
<View
style={{
width,
height,
borderRadius: 24,
overflow: "hidden",
backgroundColor: "#0C0C0E",
position: "relative",
shadowColor: "#000",
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.5,
shadowRadius: 20,
elevation: 8,
}}
>
{/* Arka Plan Şablonu & Sabit QR */}
<View style={{ position: "absolute", top: 0, left: 0, width, height }}>
{renderBackground()}
</View>
{/* Serbest Sürüklenebilir Katmanlar */}
{layers.map((layer) => (
<CanvasLayerItem
key={layer.id}
layer={layer}
isSelected={selectedLayerId === layer.id}
onSelect={setSelectedLayerId}
onUpdatePosition={handleUpdatePosition}
onDelete={handleDeleteLayer}
canvasWidth={width}
canvasHeight={height}
/>
))}
</View>
{/* 2. STÜDYO EYLEM BUTONLARI */}
<View
style={{
width: "100%",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginVertical: 14,
gap: 8,
}}
>
{/* Yeni Metin Ekle */}
<Pressable
onPress={handleAddTextLayer}
style={({ pressed }) => ({
flex: 1,
backgroundColor: "#D4AF37",
paddingVertical: 10,
paddingHorizontal: 12,
borderRadius: 12,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 6,
opacity: pressed ? 0.85 : 1,
})}
>
<Ionicons name="add-circle" size={18} color="#0C0C0E" />
<Text style={{ color: "#0C0C0E", fontSize: 13, fontWeight: "800" }}>
+ Metin Ekle
</Text>
</Pressable>
{/* Seçiliyi Kopyala */}
{selectedLayer && (
<Pressable
onPress={() => handleDuplicateLayer(selectedLayer.id)}
style={({ pressed }) => ({
backgroundColor: "#242429",
borderWidth: 1,
borderColor: "rgba(255,255,255,0.1)",
paddingVertical: 10,
paddingHorizontal: 12,
borderRadius: 12,
flexDirection: "row",
alignItems: "center",
gap: 6,
opacity: pressed ? 0.85 : 1,
})}
>
<Ionicons name="copy-outline" size={16} color="#FFFFFF" />
<Text style={{ color: "#FFFFFF", fontSize: 12, fontWeight: "700" }}>
Çoğalt
</Text>
</Pressable>
)}
{/* Sıfırla */}
<Pressable
onPress={handleResetLayers}
style={({ pressed }) => ({
backgroundColor: "#242429",
borderWidth: 1,
borderColor: "rgba(255,255,255,0.1)",
paddingVertical: 10,
paddingHorizontal: 12,
borderRadius: 12,
flexDirection: "row",
alignItems: "center",
gap: 6,
opacity: pressed ? 0.85 : 1,
})}
>
<Ionicons name="refresh-outline" size={16} color="#A1A1AA" />
<Text style={{ color: "#A1A1AA", fontSize: 12, fontWeight: "600" }}>
Sıfırla
</Text>
</Pressable>
</View>
{/* 3. SEÇİLİ METİN INSTAGRAM TARZI DÜZENLEYİCİ */}
{selectedLayer ? (
<View
style={{
width: "100%",
backgroundColor: "#18181B",
borderRadius: 20,
borderWidth: 1.5,
borderColor: "#D4AF37",
padding: 16,
marginBottom: 20,
gap: 14,
shadowColor: "#D4AF37",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 10,
elevation: 4,
}}
>
{/* Başlık & Kapat */}
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
<View
style={{
width: 28,
height: 28,
borderRadius: 8,
backgroundColor: "#D4AF37",
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name="sparkles" size={15} color="#0C0C0E" />
</View>
<Text style={{ color: "#FFFFFF", fontSize: 15, fontWeight: "800" }}>
Metin & Stil Stüdyosu
</Text>
</View>
<Pressable
onPress={() => setSelectedLayerId(null)}
style={{
backgroundColor: "rgba(255,255,255,0.08)",
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 8,
}}
>
<Text style={{ color: "#A1A1AA", fontSize: 11, fontWeight: "600" }}>Seçimi Kaldır</Text>
</Pressable>
</View>
{/* Metin İçeriği (Enter Destekli) */}
<View style={{ gap: 4 }}>
<Text style={{ color: "#D4AF37", fontSize: 11, fontWeight: "700" }}>
METİN İÇERİĞİ (ENTER İLE YENİ SATIR):
</Text>
<TextInput
value={selectedLayer.text}
onChangeText={(t) =>
onLayersChange((prev) =>
prev.map((l) => (l.id === selectedLayer.id ? { ...l, text: t } : l))
)
}
placeholder="Metninizi yazın..."
placeholderTextColor="#71717A"
multiline
style={{
backgroundColor: "#121215",
borderWidth: 1,
borderColor: "rgba(255,255,255,0.12)",
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 10,
color: "#FFFFFF",
fontSize: 14,
minHeight: 46,
}}
/>
</View>
{/* DÖNDÜRME & EĞME AÇISI (ROTATION) */}
<View style={{ gap: 6, backgroundColor: "#121215", padding: 12, borderRadius: 12 }}>
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
<Text style={{ color: "#D4AF37", fontSize: 11, fontWeight: "700" }}>
EĞİM & DÖNDÜRME AÇISI (ROTATION)
</Text>
<Text style={{ color: "#E5C378", fontSize: 11, fontWeight: "800" }}>
{selectedLayer.rotation || 0}°
</Text>
</View>
<View style={{ flexDirection: "row", gap: 6 }}>
{[-15, -6, 0, 6, 15].map((deg) => {
const isActive = selectedLayer.rotation === deg;
return (
<Pressable
key={deg}
onPress={() =>
onLayersChange((prev) =>
prev.map((l) =>
l.id === selectedLayer.id ? { ...l, rotation: deg } : l
)
)
}
style={{
flex: 1,
backgroundColor: isActive ? "#D4AF37" : "#242429",
paddingVertical: 8,
borderRadius: 8,
alignItems: "center",
}}
>
<Text
style={{
color: isActive ? "#0C0C0E" : "#FFFFFF",
fontSize: 11,
fontWeight: isActive ? "800" : "600",
}}
>
{deg > 0 ? `+${deg}°` : `${deg}°`}
</Text>
</Pressable>
);
})}
</View>
</View>
{/* INSTAGRAM TARZI VURGU / KUTU STİLİ */}
<View style={{ gap: 6 }}>
<Text style={{ color: "#D4AF37", fontSize: 11, fontWeight: "700" }}>
INSTAGRAM KUTUCUK STİLİ (BADGE):
</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: 6 }}>
{INSTAGRAM_BADGE_STYLES.map((b) => {
const isActive = selectedLayer.bgBoxColor === b.bgBoxColor;
return (
<Pressable
key={b.id}
onPress={() =>
onLayersChange((prev) =>
prev.map((l) =>
l.id === selectedLayer.id ? { ...l, bgBoxColor: b.bgBoxColor } : l
)
)
}
style={{
backgroundColor: isActive ? "#D4AF37" : "#242429",
paddingHorizontal: 12,
paddingVertical: 7,
borderRadius: 10,
borderWidth: 1.5,
borderColor: isActive ? "#D4AF37" : "rgba(255,255,255,0.06)",
}}
>
<Text
style={{
color: isActive ? "#0C0C0E" : "#FFFFFF",
fontSize: 11.5,
fontWeight: isActive ? "800" : "600",
}}
>
{b.label}
</Text>
</Pressable>
);
})}
</ScrollView>
</View>
{/* YAZI RENGİ PALETİ */}
<View style={{ gap: 6 }}>
<Text style={{ color: "#D4AF37", fontSize: 11, fontWeight: "700" }}>
YAZI RENGİ:
</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: 8 }}>
{CANVAS_COLOR_PALETTE.map((c) => {
const isActive = selectedLayer.color === c.color;
return (
<Pressable
key={c.id}
onPress={() =>
onLayersChange((prev) =>
prev.map((l) =>
l.id === selectedLayer.id ? { ...l, color: c.color } : l
)
)
}
style={{
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: c.color,
borderWidth: isActive ? 3 : 1.5,
borderColor: isActive ? "#D4AF37" : "rgba(255,255,255,0.3)",
alignItems: "center",
justifyContent: "center",
}}
>
{isActive && (
<Ionicons
name="checkmark"
size={16}
color={c.color === "#FFFFFF" || c.color === "#F6EFE3" ? "#0C0C0E" : "#FFFFFF"}
/>
)}
</Pressable>
);
})}
</ScrollView>
</View>
{/* YAZI TİPİ (FONT) */}
<View style={{ gap: 6 }}>
<Text style={{ color: "#D4AF37", fontSize: 11, fontWeight: "700" }}>
YAZI TİPİ (TÜRKÇE UYUMLU):
</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: 6 }}>
{CANVAS_FONT_OPTIONS.map((f) => {
const isActive = selectedLayer.fontId === f.id;
return (
<Pressable
key={f.id}
onPress={() =>
onLayersChange((prev) =>
prev.map((l) =>
l.id === selectedLayer.id ? { ...l, fontId: f.id } : l
)
)
}
style={{
backgroundColor: isActive ? "#D4AF37" : "#242429",
paddingHorizontal: 12,
paddingVertical: 7,
borderRadius: 10,
borderWidth: 1.5,
borderColor: isActive ? "#D4AF37" : "rgba(255,255,255,0.06)",
}}
>
<Text
style={{
color: isActive ? "#0C0C0E" : "#FFFFFF",
fontSize: 11.5,
fontWeight: isActive ? "800" : "600",
}}
>
{f.label}
</Text>
</Pressable>
);
})}
</ScrollView>
</View>
{/* BOYUT & GÖLGE AYARI */}
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
backgroundColor: "#121215",
padding: 10,
borderRadius: 12,
}}
>
{/* Boyut */}
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
<Text style={{ color: "#D4AF37", fontSize: 12, fontWeight: "700" }}>
Boyut:
</Text>
<Pressable
onPress={() =>
onLayersChange((prev) =>
prev.map((l) =>
l.id === selectedLayer.id
? { ...l, fontSize: Math.max(8, l.fontSize - 1) }
: l
)
)
}
style={{
backgroundColor: "#242429",
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 8,
}}
>
<Ionicons name="remove" size={15} color="#FFFFFF" />
</Pressable>
<Text style={{ color: "#D4AF37", fontSize: 15, fontWeight: "800", minWidth: 32, textAlign: "center" }}>
{selectedLayer.fontSize}px
</Text>
<Pressable
onPress={() =>
onLayersChange((prev) =>
prev.map((l) =>
l.id === selectedLayer.id
? { ...l, fontSize: Math.min(60, l.fontSize + 1) }
: l
)
)
}
style={{
backgroundColor: "#242429",
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 8,
}}
>
<Ionicons name="add" size={15} color="#FFFFFF" />
</Pressable>
</View>
{/* Gölge Anahtarı */}
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
<Text style={{ color: "#A1A1AA", fontSize: 11, fontWeight: "700" }}>
Gölge
</Text>
<Switch
value={selectedLayer.hasShadow || false}
onValueChange={(v) =>
onLayersChange((prev) =>
prev.map((l) =>
l.id === selectedLayer.id ? { ...l, hasShadow: v } : l
)
)
}
trackColor={{ false: "#27272A", true: "#D4AF37" }}
thumbColor="#FFFFFF"
ios_backgroundColor="#27272A"
/>
</View>
</View>
</View>
) : (
<View
style={{
width: "100%",
backgroundColor: "#18181B",
borderRadius: 16,
padding: 14,
marginBottom: 20,
alignItems: "center",
flexDirection: "row",
justifyContent: "center",
gap: 8,
borderWidth: 1,
borderColor: "rgba(255,255,255,0.06)",
}}
>
<Ionicons name="hand-left-outline" size={18} color="#D4AF37" />
<Text style={{ color: "#A1A1AA", fontSize: 12.5, fontWeight: "600" }}>
Stant üzerindeki herhangi bir metne dokunarak sürükleyin veya düzenleyin.
</Text>
</View>
)}
</View>
);
};
@@ -0,0 +1,3 @@
export * from "./types";
export * from "./CanvasLayerItem";
export * from "./QrCanvasStudio";
@@ -0,0 +1,206 @@
export interface CanvasTextLayer {
id: string;
text: string;
x: number; // px from canvas left
y: number; // px from canvas top
rotation: number; // degrees (-180 to 180)
fontSize: number;
fontId: string;
color: string;
bgBoxColor?: string; // Instagram badge kutusu (örn: rgba(0,0,0,0.8), #D4AF37 vb.)
hasShadow?: boolean;
letterSpacing?: number;
fontWeight?: "normal" | "600" | "700" | "800" | "900";
}
export const CANVAS_FONT_OPTIONS = [
{
id: "sans",
label: "Modern Sans",
fontFamily: "System, -apple-system, Roboto, 'Helvetica Neue', Arial, sans-serif",
},
{
id: "serif",
label: "Zarif Serif",
fontFamily: "Georgia, 'Times New Roman', serif",
},
{
id: "times",
label: "Klasik Times",
fontFamily: "'Times New Roman', Times, Georgia, serif",
},
{
id: "palatino",
label: "Lüks Palatino",
fontFamily: "Palatino, 'Palatino Linotype', Georgia, serif",
},
{
id: "trebuchet",
label: "Dinamik",
fontFamily: "'Trebuchet MS', 'Segoe UI', Arial, sans-serif",
},
];
export const CANVAS_COLOR_PALETTE = [
{ id: "gold", color: "#D4AF37", label: "Altın" },
{ id: "white", color: "#FFFFFF", label: "Beyaz" },
{ id: "black", color: "#121215", label: "Siyah" },
{ id: "cream", color: "#F6EFE3", label: "Krem" },
{ id: "emerald", color: "#0C4443", label: "Zümrüt" },
{ id: "crimson", color: "#A64423", label: "Kızıl" },
{ id: "blue", color: "#003554", label: "Lacivert" },
];
export const INSTAGRAM_BADGE_STYLES = [
{ id: "none", label: "Düz", bgBoxColor: undefined },
{ id: "dark", label: "Siyah Kutu", bgBoxColor: "rgba(18, 18, 21, 0.88)" },
{ id: "gold", label: "Altın Kutu", bgBoxColor: "rgba(212, 175, 55, 0.92)" },
{ id: "white", label: "Beyaz Kutu", bgBoxColor: "rgba(255, 255, 255, 0.92)" },
];
export function getCanvasFontFamily(fontId?: string): string {
const found = CANVAS_FONT_OPTIONS.find((f) => f.id === fontId);
return found ? found.fontFamily : "System, -apple-system, Roboto, sans-serif";
}
// Her şablon için başlangıç varsayılan katmanları (1:1 veya standart orana göre)
export function getDefaultLayersForTemplate(templateKey: string, restaurantName = "Gourmet Bistro"): CanvasTextLayer[] {
if (templateKey === "qr-1") {
return [
{
id: "rName",
text: restaurantName.toUpperCase(),
x: 40,
y: 50,
rotation: 0,
fontSize: 18,
fontId: "serif",
color: "#D4AF37",
fontWeight: "800",
letterSpacing: 2,
},
{
id: "sub",
text: "RESTAURANT & BAR",
x: 65,
y: 78,
rotation: 0,
fontSize: 9,
fontId: "sans",
color: "#E5C378",
fontWeight: "600",
letterSpacing: 1.5,
},
{
id: "cta",
text: "MENÜYÜ GÖRMEK İÇİN OKUTUN",
x: 35,
y: 236,
rotation: 0,
fontSize: 10,
fontId: "serif",
color: "#D4AF37",
fontWeight: "700",
letterSpacing: 1,
},
{
id: "table",
text: "MASA NO: 01 • WiFi: Bistro",
x: 45,
y: 265,
rotation: 0,
fontSize: 9,
fontId: "sans",
color: "#C5A059",
fontWeight: "600",
},
];
}
if (templateKey === "qr-8") {
return [
{
id: "rName",
text: "EXPLORE OUR MENU",
x: 35,
y: 35,
rotation: 0,
fontSize: 16,
fontId: "sans",
color: "#FFFFFF",
fontWeight: "900",
letterSpacing: 1,
},
{
id: "sub",
text: "THE DAILY SPECIALS",
x: 50,
y: 68,
rotation: -4,
fontSize: 13,
fontId: "sans",
color: "#D1A752",
fontWeight: "800",
},
{
id: "cta",
text: "SCAN TO VIEW\nFULL MENU & SPECIALS",
x: 55,
y: 215,
rotation: 0,
fontSize: 9.5,
fontId: "sans",
color: "#FFFFFF",
fontWeight: "800",
},
{
id: "wifi",
text: "SCAN TO ORDER & BROWSE",
x: 55,
y: 258,
rotation: 0,
fontSize: 9,
fontId: "sans",
color: "#D1A752",
fontWeight: "800",
},
];
}
// Varsayılan
return [
{
id: "rName",
text: restaurantName,
x: 50,
y: 45,
rotation: 0,
fontSize: 17,
fontId: "serif",
color: "#D4AF37",
fontWeight: "800",
},
{
id: "sub",
text: "SCAN TO ORDER",
x: 75,
y: 75,
rotation: 0,
fontSize: 10,
fontId: "sans",
color: "#FFFFFF",
fontWeight: "700",
},
{
id: "cta",
text: "DİJİTAL MENÜ",
x: 75,
y: 245,
rotation: 0,
fontSize: 11,
fontId: "serif",
color: "#D4AF37",
fontWeight: "800",
},
];
}