Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Fikir basit: bir esnafın kapısından "web sitenizi konuşalım mı?" diye boş
Uygulama dört adımlı tek bir akış üzerine kurulu:

1. **Keşfet** — haritadan bir konum ve yarıçap ver. VibeCodingEx Google Places'a sorar, dönen işletmeleri yerele kaydeder: isim, kategori, adres, telefon, site, puan ve birkaç gerçek yorum.
2. **Üret** — bir işletmeyi açıp "üret" dersin. Gemini işletme hakkında bilinen her şeyi okuyup **2-3 ayrı yazılım/ürün fikri** çıkarır (QR menü, randevu, sadakat sistemi, kendi güncelleyebileceği bir site — e-ticaret ve muhasebe yazılımı hariç), her fikir için bir fiyat tahminiyle.
2. **Üret** — bir işletmeyi açıp "üret" dersin. Gemini işletme hakkında bilinen her şeyi okuyup **2-3 ayrı yazılım/ürün fikri** çıkarır (QR menü, randevu, sadakat sistemi, kendi güncelleyebileceği bir site — e-ticaret ve muhasebe yazılımı hariç), her fikir için bir fiyat tahminiyle. Üretmeden önce bütçe seçebilirsin: bütçe farketmez, ekonomik işler ya da kapsamlı sistemler. Aynı işletmeye farklı bütçelerle tekrar üretebilirsin.
3. **Pitch al** — aynı üretim, işletmenin kısa profilini ve kapıda okuyabileceğin, kopyala-yapıştır hazır bir satış metnini de verir.
4. **Takip et** — gezerken işletmeleri ziyaret edildi / kazanıldı / kaybedildi diye işaretle; listeyi filtrele, ara; hepsini durum rengiyle haritada gör.

Expand Down Expand Up @@ -149,6 +149,8 @@ server/
gemini.ts – prompt kurma + structured-output ayrıştırma

shared/
config/
pricing.ts – fiyat bantları ve bütçe seçenekleri — kendi rakamların buraya
types/
business.ts – Business, ProductIdea, ReviewSnippet — tek doğruluk kaynağı
usage.ts – DailyUsage tipi, sunucu ile istemci arası ortak
Expand All @@ -169,7 +171,8 @@ Kendi bölgen/pazarın için değiştirmen muhtemel yerler:
- **Varsayılan harita merkezi** — `app/utils/mapStyle.ts` → `DEFAULT_MAP_CENTER` / `DEFAULT_MAP_ZOOM`. Hem Keşfet haritası hem Harita sayfası buradan besleniyor; kendi şehrinin koordinatını yaz.
- **Kategori listesi** — `app/utils/category.ts` → `CATEGORY_META`. Filtre dropdown'u ve ikonlar buradan gelir. Geçerli tip adları için [Google Place Types (New)](https://developers.google.com/maps/documentation/places/web-service/place-types).
- **Hariç tutulanlar** — `server/utils/places.ts` → `DEFAULT_EXCLUDED_TYPES` (Google'a "hiç gösterme" dediklerimiz) ve `POST_FILTER_EXCLUDED_TYPES` (sonradan elediklerimiz).
- **AI'ın tonu ve fiyat mantığı** — `server/utils/gemini.ts` → `buildPrompt()`. Pitch'in açılış tonu, yasak fikir türleri (şu an e-ticaret/muhasebe) ve fiyat bantları (500 TL–10.000 TL arası 4 kademe) hep bu tek fonksiyonda.
- **Fiyatların kendisi** — `shared/config/pricing.ts` → `PRICE_BANDS`. Dört kademenin kapsamı ve ücreti burada; kendi rakamlarını yazman için prompt'a dokunman gerekmiyor. `price` alanı serbest metin, para birimi fark etmez. Aynı dosyadaki `BUDGET_FOCUSES` de üretim öncesi seçilen bütçe seçeneklerini tanımlar — bir seçeneği çıkarmak ya da yenisini eklemek tek satır.
- **AI'ın tonu** — `server/utils/gemini.ts` → `buildPrompt()`. Pitch'in açılış tonu ve yasak fikir türleri (şu an e-ticaret/muhasebe) bu fonksiyonda; fiyat bantları yukarıdaki config'den geliyor.
- **Dil** — tüm arayüz ve prompt'lar Türkçe hardcoded; ayrı i18n katmanı yok. Başka dile çevireceksen `.vue` metinlerini ve `gemini.ts`'teki prompt'u (çıktı dilini de) güncelle.
- **Renk teması** — vurgu rengi (amber) Tailwind class'ları olarak (`bg-amber-500` vb.) doğrudan yazılı; merkezi bir değişken yok. Değiştirmek için `.vue` içinde ara-değiştir.
- **Yarıçap seçenekleri** — `app/pages/discover.vue` içindeki `<select>`'te sabit 1/3/5 km.
Expand Down Expand Up @@ -225,7 +228,7 @@ The whole point: instead of knocking with an empty "can we talk about your websi
The app is a single four-step flow:

1. **Discover** — give the map a location and radius. VibeCodingEx asks Google Places and saves the returned businesses locally: name, category, address, phone, website, rating and a few real reviews.
2. **Generate** — open a business and hit generate. Gemini reads everything known about it and produces **2-3 distinct software/product ideas** (QR menu, appointments, loyalty, a site the owner can update — no e-commerce or accounting software), each with a price estimate.
2. **Generate** — open a business and hit generate. Gemini reads everything known about it and produces **2-3 distinct software/product ideas** (QR menu, appointments, loyalty, a site the owner can update — no e-commerce or accounting software), each with a price estimate. Pick a budget first: any budget, cheap one-off work, or larger systems. You can regenerate the same business at a different budget.
3. **Get a pitch** — the same run also produces a short profile and a copy-paste-ready sales script you can read at the door.
4. **Track** — mark businesses as visited / won / lost as you go; filter and search the list; see everything on the map by status color.

Expand Down Expand Up @@ -312,6 +315,7 @@ server/
gemini (prompt building + structured-output parsing)

shared/
config/ – pricing.ts (price bands + budget options, put your own rates here)
types/ – business.ts, usage.ts (single source of truth, shared client ↔ server)
```

Expand All @@ -330,7 +334,8 @@ Likely places to change for your own area/market:
- **Default map center** — `app/utils/mapStyle.ts` → `DEFAULT_MAP_CENTER` / `DEFAULT_MAP_ZOOM`. Feeds both the Discover map and the Map page.
- **Category list** — `app/utils/category.ts` → `CATEGORY_META`. Drives the filter dropdown and icons. Valid type names: [Google Place Types (New)](https://developers.google.com/maps/documentation/places/web-service/place-types).
- **Excluded types** — `server/utils/places.ts` → `DEFAULT_EXCLUDED_TYPES` and `POST_FILTER_EXCLUDED_TYPES`.
- **AI tone & pricing logic** — `server/utils/gemini.ts` → `buildPrompt()`. Pitch tone, banned idea types (currently e-commerce/accounting) and price bands (four tiers, 500–10,000 TL) all live here.
- **The rates themselves** — `shared/config/pricing.ts` → `PRICE_BANDS`. Scope and price of each of the four tiers; drop your own numbers in without touching the prompt. `price` is free text, so any currency works. `BUDGET_FOCUSES` in the same file defines the budget options offered before generating — removing one or adding another is a single line.
- **AI tone** — `server/utils/gemini.ts` → `buildPrompt()`. Pitch tone and banned idea types (currently e-commerce/accounting) live here; the price bands come from the config above.
- **Language** — all UI text and prompts are hardcoded Turkish; there's no i18n layer.
- **Color theme** — the accent (amber) is written directly as Tailwind classes; no central variable.
- **Radius options** — the `<select>` in `app/pages/discover.vue` has fixed 1/3/5 km.
Expand Down
60 changes: 41 additions & 19 deletions app/components/BusinessDetail.vue
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
<script setup lang="ts">
import type { Business, BusinessStatus, ProductIdea, ReviewSnippet } from '#shared/types/business'
import { BUDGET_FOCUSES, DEFAULT_BUDGET_FOCUS } from '#shared/config/pricing'

const props = defineProps<{ business: Business }>()
const emit = defineEmits<{ processed: [business: Business]; statusChanged: [business: Business] }>()

const { copiedKey, copy } = useClipboard()
const processing = ref(false)
const errorMessage = ref<string | null>(null)
const budgetFocus = ref(DEFAULT_BUDGET_FOCUS)

const reviews = computed<ReviewSnippet[]>(() => {
if (!props.business.reviews_json) return []
Expand Down Expand Up @@ -38,7 +40,10 @@ async function generate() {
processing.value = true
errorMessage.value = null
try {
const updated = await $fetch<Business>(`/api/process/${props.business.id}`, { method: 'POST' })
const updated = await $fetch<Business>(`/api/process/${props.business.id}`, {
method: 'POST',
body: { budgetFocus: budgetFocus.value },
})
emit('processed', updated)
} catch {
errorMessage.value = 'Üretim başarısız oldu, tekrar dene.'
Expand Down Expand Up @@ -136,16 +141,24 @@ async function setStatus(status: BusinessStatus) {
</div>
</section>

<button
v-if="ideas.length === 0"
type="button"
class="btn-brand inline-flex items-center gap-2 py-3.5 px-6 rounded-xl disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed disabled:shadow-none"
:disabled="processing"
@click="generate"
>
<AppIcon name="sparkles" class="w-4 h-4" :class="processing ? 'animate-spin-slow' : ''" />
{{ processing ? 'Üretiliyor…' : 'Profil + Pitch Üret' }}
</button>
<div v-if="ideas.length === 0" class="flex items-center gap-3 flex-wrap">
<select
v-model="budgetFocus"
:disabled="processing"
class="appearance-none bg-zinc-800/50 border border-zinc-700 rounded-xl pl-3 pr-9 py-3.5 text-sm text-zinc-100 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
<option v-for="focus in BUDGET_FOCUSES" :key="focus.id" :value="focus.id">{{ focus.label }}</option>
</select>
<button
type="button"
class="btn-brand inline-flex items-center gap-2 py-3.5 px-6 rounded-xl disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed disabled:shadow-none"
:disabled="processing"
@click="generate"
>
<AppIcon name="sparkles" class="w-4 h-4" :class="processing ? 'animate-spin-slow' : ''" />
{{ processing ? 'Üretiliyor…' : 'Profil + Pitch Üret' }}
</button>
</div>
<p v-if="errorMessage" class="text-red-400 text-sm">{{ errorMessage }}</p>

<template v-if="ideas.length > 0">
Expand All @@ -166,14 +179,23 @@ async function setStatus(status: BusinessStatus) {
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="font-semibold text-zinc-100 text-lg">Ürün Fikirleri</h3>
<button
type="button"
class="text-xs px-3 py-1.5 rounded-lg border border-zinc-700 hover:border-zinc-600 hover:bg-zinc-800 bg-zinc-900 transition text-zinc-300 disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
:disabled="processing"
@click="generate"
>
{{ processing ? 'Üretiliyor…' : 'Yeniden Üret' }}
</button>
<div class="flex items-center gap-2">
<select
v-model="budgetFocus"
:disabled="processing"
class="appearance-none bg-zinc-900 border border-zinc-700 rounded-lg pl-2.5 pr-7 py-1.5 text-xs text-zinc-300 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
<option v-for="focus in BUDGET_FOCUSES" :key="focus.id" :value="focus.id">{{ focus.label }}</option>
</select>
<button
type="button"
class="text-xs px-3 py-1.5 rounded-lg border border-zinc-700 hover:border-zinc-600 hover:bg-zinc-800 bg-zinc-900 transition text-zinc-300 disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
:disabled="processing"
@click="generate"
>
{{ processing ? 'Üretiliyor…' : 'Yeniden Üret' }}
</button>
</div>
</div>

<div class="grid md:grid-cols-2 gap-4">
Expand Down
4 changes: 3 additions & 1 deletion server/api/process/[id].post.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { getBusiness, incrementUsage, saveProcessResult } from '../../utils/db'
import { processBusinessWithGemini } from '../../utils/gemini'
import type { ProcessRequestBody } from '#shared/types/business'

export default defineEventHandler(async (event) => {
const body = await readBody<ProcessRequestBody>(event).catch(() => null)
const id = Number(getRouterParam(event, 'id'))
if (!Number.isInteger(id)) {
throw createError({ statusCode: 400, statusMessage: 'Geçersiz id' })
Expand All @@ -17,7 +19,7 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 500, statusMessage: 'GEMINI_API_KEY tanımlı değil' })
}

const result = await processBusinessWithGemini(business, apiKey)
const result = await processBusinessWithGemini(business, apiKey, body?.budgetFocus)
incrementUsage('process')
saveProcessResult(id, result)
return getBusiness(id)
Expand Down
33 changes: 24 additions & 9 deletions server/utils/gemini.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Business, ProcessResultBody, ProductIdea } from '#shared/types/business'
import { resolvePriceBands } from '#shared/config/pricing'

const RESPONSE_SCHEMA = {
type: 'OBJECT',
Expand All @@ -23,7 +24,20 @@ const RESPONSE_SCHEMA = {
required: ['profile_md', 'pitch_script_md', 'ideas'],
}

function buildPrompt(business: Business): string {
function buildPrompt(business: Business, budgetFocus: string | undefined): string {
const bands = resolvePriceBands(budgetFocus)
const bandLines = bands.map(b => `- ${b.scope}: ${b.price}`).join('\n')
const hasCmsBand = bands.some(b => b.cms)
const hasStaticBand = bands.some(b => !b.cms)

// With a narrowed budget the "one of each" rule becomes impossible to
// satisfy, so it only ships when both kinds of band are on the table.
const cmsMixRule = hasCmsBand && hasStaticBand
? 'En az bir fikir needs_cms=true olmalı, en az bir fikir needs_cms=false olmalı — ama bunu zorla dağıtma, işletmenin gerçek ihtiyacına göre karar ver.'
: hasCmsBand
? 'Bu üretimde tüm fikirler yönetim paneli/CMS içeren kapsamlı çözümler olmalı (needs_cms=true).'
: 'Bu üretimde tüm fikirler tek seferlik, yönetim paneli gerektirmeyen yapılar olmalı (needs_cms=false).'

return `Sen, yerel işletmelere özel dijital çözümler geliştiren bağımsız bir yazılımcı/serbest çalışan için saha satış asistanısın. Bu yazılımcı büyük bir şirket ya da ekip değil — tek başına çalışan biri olabilir. Aşağıdaki işletme için TÜM çıktıyı TÜRKÇE üret.

İşletme verisi:
Expand Down Expand Up @@ -53,11 +67,8 @@ function buildPrompt(business: Business): string {
- Sadece bir WhatsApp numarasına veya Google yorum sayfasına yönlendiren bir QR kod, TEK BAŞINA bir fikir/ürün DEĞİLDİR — bunu üretmek neredeyse sıfır efor gerektirir (bir QR kod üreticisiyle 30 saniyede yapılır), bunu ayrı bir "proje" gibi sunma veya fiyatlandırma.
- Bu tarz bir QR yönlendirmesi ancak daha büyük bir fikrin (örn. QR menü sisteminin) içinde küçük bir özellik olarak yer alabilir, kendi başına fikir olamaz.

ÖNEMLİ — fiyatlandırma gerçekçi ve efora orantılı olmalı, her şeyi "proje" gibi pahalı gösterme:
- Çok basit, tek sayfalık statik yapılar (sadece adres/telefon/harita/WhatsApp butonu olan dijital kartvizit gibi): 500-1.200 TL tek seferlik
- Orta karmaşıklıkta statik yapı (çok bölümlü tek sayfa, görsel galeri, form): 1.200-2.500 TL tek seferlik
- Yönetim panelli/CMS gerektiren sistem (kendi güncelleyebileceği menü, randevu, stok vb.): 3.000-6.000 TL kurulum + aylık 300-750 TL
- Entegrasyon gerektiren daha kapsamlı sistem (otomatik bildirim, çoklu panel, WhatsApp API entegrasyonu): 5.000-10.000 TL
ÖNEMLİ — fiyatlandırma gerçekçi ve efora orantılı olmalı, her şeyi "proje" gibi pahalı gösterme. price_estimate SADECE aşağıdaki bantlardan biri olabilir, başka bir fiyat uydurma:
${bandLines}

Fikir üretirken KESİNLİKLE ÖNERME:
- E-ticaret / online satış-sepet sistemi
Expand All @@ -66,11 +77,15 @@ Fikir üretirken KESİNLİKLE ÖNERME:

Bunun yerine şuna benzer basit, uygulanabilir fikirlere odaklan: QR menü, randevu/rezervasyon sistemi, sıra yönetimi, stok/envanter takibi, müşteri sadakat programı, hatırlatma/bildirim sistemi, dijital katalog, geri bildirim/değerlendirme toplama sistemi, içeriği kendi güncelleyebileceği kurumsal bir web sitesi.

En az bir fikir needs_cms=true olmalı, en az bir fikir needs_cms=false olmalı — ama bunu zorla dağıtma, işletmenin gerçek ihtiyacına göre karar ver. Fikirlerden en az biri işletmenin kategorisine (${business.category ?? 'bilinmiyor'}) özel, gerçekten anlamlı olmalı — genel geçer önerilerden kaçın.`
${cmsMixRule} Fikirlerden en az biri işletmenin kategorisine (${business.category ?? 'bilinmiyor'}) özel, gerçekten anlamlı olmalı — genel geçer önerilerden kaçın.`
}

export async function processBusinessWithGemini(business: Business, apiKey: string): Promise<ProcessResultBody> {
const prompt = buildPrompt(business)
export async function processBusinessWithGemini(
business: Business,
apiKey: string,
budgetFocus?: string,
): Promise<ProcessResultBody> {
const prompt = buildPrompt(business, budgetFocus)

const response = await $fetch<{
candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>
Expand Down
66 changes: 66 additions & 0 deletions shared/config/pricing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Every money-related decision the AI makes comes from this file. Rates are
// personal — put your own in here instead of editing the prompt in
// server/utils/gemini.ts.

export interface PriceBand {
id: string
// What kind of work this band covers, in the words the AI should reason with.
scope: string
// What to charge for it. Free text, so "500-1.200 TL tek seferlik" and
// "€300 flat" are equally valid.
price: string
// Whether work in this band ships a management panel the owner updates
// themselves. Drives the needs_cms flag and the "offer both kinds" rule.
cms: boolean
}

export const PRICE_BANDS: PriceBand[] = [
{
id: 'statik-basit',
scope: 'Çok basit, tek sayfalık statik yapılar (sadece adres/telefon/harita/WhatsApp butonu olan dijital kartvizit gibi)',
price: '500-1.200 TL tek seferlik',
cms: false,
},
{
id: 'statik-orta',
scope: 'Orta karmaşıklıkta statik yapı (çok bölümlü tek sayfa, görsel galeri, form)',
price: '1.200-2.500 TL tek seferlik',
cms: false,
},
{
id: 'cms',
scope: 'Yönetim panelli/CMS gerektiren sistem (kendi güncelleyebileceği menü, randevu, stok vb.)',
price: '3.000-6.000 TL kurulum + aylık 300-750 TL',
cms: true,
},
{
id: 'entegrasyon',
scope: 'Entegrasyon gerektiren daha kapsamlı sistem (otomatik bildirim, çoklu panel, WhatsApp API entegrasyonu)',
price: '5.000-10.000 TL',
cms: true,
},
]

export interface BudgetFocus {
id: string
label: string
// Empty means "no restriction" — the AI may use every band.
bandIds: string[]
}

export const BUDGET_FOCUSES: BudgetFocus[] = [
{ id: 'hepsi', label: 'Bütçe farketmez', bandIds: [] },
{ id: 'ekonomik', label: 'Ekonomik işler', bandIds: ['statik-basit', 'statik-orta'] },
{ id: 'kapsamli', label: 'Kapsamlı sistemler', bandIds: ['cms', 'entegrasyon'] },
]

export const DEFAULT_BUDGET_FOCUS = 'hepsi'

// Falls back to every band when the id is unknown, so a stale client or a
// hand-written request can never produce a priceless prompt.
export function resolvePriceBands(focusId: string | undefined): PriceBand[] {
const focus = BUDGET_FOCUSES.find(f => f.id === focusId)
if (!focus || focus.bandIds.length === 0) return PRICE_BANDS
const bands = PRICE_BANDS.filter(b => focus.bandIds.includes(b.id))
return bands.length > 0 ? bands : PRICE_BANDS
}
6 changes: 6 additions & 0 deletions shared/types/business.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ export interface DiscoveredBusiness {
reviews_json: string | null
}

export interface ProcessRequestBody {
// A BudgetFocus id from shared/config/pricing.ts. Unknown or missing means
// every price band is fair game.
budgetFocus?: string
}

export interface DiscoverRequestBody {
location?: string
lat?: number
Expand Down